Merge remote-tracking branch 'origin/main' into feature/homeassistant-integration
# Conflicts: # run_agent.py
This commit is contained in:
commit
3fdf03390e
50 changed files with 7354 additions and 358 deletions
|
|
@ -77,6 +77,85 @@ def _strip_blocked_tools(toolsets: List[str]) -> List[str]:
|
|||
return [t for t in toolsets if t not in blocked_toolset_names]
|
||||
|
||||
|
||||
def _build_child_progress_callback(task_index: int, parent_agent, task_count: int = 1) -> Optional[callable]:
|
||||
"""Build a callback that relays child agent tool calls to the parent display.
|
||||
|
||||
Two display paths:
|
||||
CLI: prints tree-view lines above the parent's delegation spinner
|
||||
Gateway: batches tool names and relays to parent's progress callback
|
||||
|
||||
Returns None if no display mechanism is available, in which case the
|
||||
child agent runs with no progress callback (identical to current behavior).
|
||||
"""
|
||||
spinner = getattr(parent_agent, '_delegate_spinner', None)
|
||||
parent_cb = getattr(parent_agent, 'tool_progress_callback', None)
|
||||
|
||||
if not spinner and not parent_cb:
|
||||
return None # No display → no callback → zero behavior change
|
||||
|
||||
# Show 1-indexed prefix only in batch mode (multiple tasks)
|
||||
prefix = f"[{task_index + 1}] " if task_count > 1 else ""
|
||||
|
||||
# Gateway: batch tool names, flush periodically
|
||||
_BATCH_SIZE = 5
|
||||
_batch: List[str] = []
|
||||
|
||||
def _callback(tool_name: str, preview: str = None):
|
||||
# Special "_thinking" event: model produced text content (reasoning)
|
||||
if tool_name == "_thinking":
|
||||
if spinner:
|
||||
short = (preview[:55] + "...") if preview and len(preview) > 55 else (preview or "")
|
||||
try:
|
||||
spinner.print_above(f" {prefix}├─ 💭 \"{short}\"")
|
||||
except Exception:
|
||||
pass
|
||||
# Don't relay thinking to gateway (too noisy for chat)
|
||||
return
|
||||
|
||||
# Regular tool call event
|
||||
if spinner:
|
||||
short = (preview[:35] + "...") if preview and len(preview) > 35 else (preview or "")
|
||||
tool_emojis = {
|
||||
"terminal": "💻", "web_search": "🔍", "web_extract": "📄",
|
||||
"read_file": "📖", "write_file": "✍️", "patch": "🔧",
|
||||
"search_files": "🔎", "list_directory": "📂",
|
||||
"browser_navigate": "🌐", "browser_click": "👆",
|
||||
"text_to_speech": "🔊", "image_generate": "🎨",
|
||||
"vision_analyze": "👁️", "process": "⚙️",
|
||||
}
|
||||
emoji = tool_emojis.get(tool_name, "⚡")
|
||||
line = f" {prefix}├─ {emoji} {tool_name}"
|
||||
if short:
|
||||
line += f" \"{short}\""
|
||||
try:
|
||||
spinner.print_above(line)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if parent_cb:
|
||||
_batch.append(tool_name)
|
||||
if len(_batch) >= _BATCH_SIZE:
|
||||
summary = ", ".join(_batch)
|
||||
try:
|
||||
parent_cb("subagent_progress", f"🔀 {prefix}{summary}")
|
||||
except Exception:
|
||||
pass
|
||||
_batch.clear()
|
||||
|
||||
def _flush():
|
||||
"""Flush remaining batched tool names to gateway on completion."""
|
||||
if parent_cb and _batch:
|
||||
summary = ", ".join(_batch)
|
||||
try:
|
||||
parent_cb("subagent_progress", f"🔀 {prefix}{summary}")
|
||||
except Exception:
|
||||
pass
|
||||
_batch.clear()
|
||||
|
||||
_callback._flush = _flush
|
||||
return _callback
|
||||
|
||||
|
||||
def _run_single_child(
|
||||
task_index: int,
|
||||
goal: str,
|
||||
|
|
@ -85,6 +164,7 @@ def _run_single_child(
|
|||
model: Optional[str],
|
||||
max_iterations: int,
|
||||
parent_agent,
|
||||
task_count: int = 1,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Spawn and run a single child agent. Called from within a thread.
|
||||
|
|
@ -98,37 +178,21 @@ def _run_single_child(
|
|||
|
||||
child_prompt = _build_child_system_prompt(goal, context)
|
||||
|
||||
# Build a progress callback that surfaces subagent tool activity.
|
||||
# CLI: updates the parent's delegate spinner text.
|
||||
# Gateway: forwards to the parent's progress callback (feeds message queue).
|
||||
parent_progress_cb = getattr(parent_agent, 'tool_progress_callback', None)
|
||||
def _child_progress(tool_name: str, preview: str = None):
|
||||
tag = f"[subagent-{task_index+1}] {tool_name}"
|
||||
# Update CLI spinner
|
||||
spinner = getattr(parent_agent, '_delegate_spinner', None)
|
||||
if spinner:
|
||||
detail = f'"{preview}"' if preview else ""
|
||||
try:
|
||||
spinner.update_text(f"🔀 {tag} {detail}")
|
||||
except Exception:
|
||||
pass
|
||||
# Forward to gateway progress queue
|
||||
if parent_progress_cb:
|
||||
try:
|
||||
parent_progress_cb(tag, preview)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Extract parent's API key so subagents inherit auth (e.g. Nous Portal)
|
||||
parent_api_key = None
|
||||
if hasattr(parent_agent, '_client_kwargs'):
|
||||
# Extract parent's API key so subagents inherit auth (e.g. Nous Portal).
|
||||
parent_api_key = getattr(parent_agent, "api_key", None)
|
||||
if (not parent_api_key) and hasattr(parent_agent, "_client_kwargs"):
|
||||
parent_api_key = parent_agent._client_kwargs.get("api_key")
|
||||
|
||||
# Build progress callback to relay tool calls to parent display
|
||||
child_progress_cb = _build_child_progress_callback(task_index, parent_agent, task_count)
|
||||
|
||||
child = AIAgent(
|
||||
base_url=parent_agent.base_url,
|
||||
api_key=parent_api_key,
|
||||
model=model or parent_agent.model,
|
||||
provider=getattr(parent_agent, "provider", None),
|
||||
api_mode=getattr(parent_agent, "api_mode", None),
|
||||
max_iterations=max_iterations,
|
||||
enabled_toolsets=child_toolsets,
|
||||
quiet_mode=True,
|
||||
|
|
@ -143,7 +207,7 @@ def _run_single_child(
|
|||
providers_ignored=parent_agent.providers_ignored,
|
||||
providers_order=parent_agent.providers_order,
|
||||
provider_sort=parent_agent.provider_sort,
|
||||
tool_progress_callback=_child_progress,
|
||||
tool_progress_callback=child_progress_cb,
|
||||
)
|
||||
|
||||
# Set delegation depth so children can't spawn grandchildren
|
||||
|
|
@ -158,6 +222,13 @@ def _run_single_child(
|
|||
with contextlib.redirect_stdout(devnull), contextlib.redirect_stderr(devnull):
|
||||
result = child.run_conversation(user_message=goal)
|
||||
|
||||
# Flush any remaining batched progress to gateway
|
||||
if child_progress_cb and hasattr(child_progress_cb, '_flush'):
|
||||
try:
|
||||
child_progress_cb._flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
duration = round(time.monotonic() - child_start, 2)
|
||||
|
||||
summary = result.get("final_response") or ""
|
||||
|
|
@ -275,6 +346,7 @@ def delegate_task(
|
|||
model=model,
|
||||
max_iterations=effective_max_iter,
|
||||
parent_agent=parent_agent,
|
||||
task_count=1,
|
||||
)
|
||||
results.append(result)
|
||||
else:
|
||||
|
|
@ -299,6 +371,7 @@ def delegate_task(
|
|||
model=model,
|
||||
max_iterations=effective_max_iter,
|
||||
parent_agent=parent_agent,
|
||||
task_count=n_tasks,
|
||||
)
|
||||
futures[future] = i
|
||||
|
||||
|
|
@ -318,14 +391,21 @@ def delegate_task(
|
|||
results.append(entry)
|
||||
completed_count += 1
|
||||
|
||||
# Print per-task completion line (visible in CLI via patch_stdout)
|
||||
# Print per-task completion line above the spinner
|
||||
idx = entry["task_index"]
|
||||
label = task_labels[idx] if idx < len(task_labels) else f"Task {idx}"
|
||||
dur = entry.get("duration_seconds", 0)
|
||||
status = entry.get("status", "?")
|
||||
icon = "✓" if status == "completed" else "✗"
|
||||
remaining = n_tasks - completed_count
|
||||
print(f" {icon} [{idx+1}/{n_tasks}] {label} ({dur}s)")
|
||||
completion_line = f"{icon} [{idx+1}/{n_tasks}] {label} ({dur}s)"
|
||||
if spinner_ref:
|
||||
try:
|
||||
spinner_ref.print_above(completion_line)
|
||||
except Exception:
|
||||
print(f" {completion_line}")
|
||||
else:
|
||||
print(f" {completion_line}")
|
||||
|
||||
# Update spinner text to show remaining count
|
||||
if spinner_ref and remaining > 0:
|
||||
|
|
|
|||
|
|
@ -11,20 +11,26 @@ from tools.environments.base import BaseEnvironment
|
|||
|
||||
# Noise lines emitted by interactive shells when stdin is not a terminal.
|
||||
# Filtered from output to keep tool results clean.
|
||||
_SHELL_NOISE = frozenset({
|
||||
_SHELL_NOISE_SUBSTRINGS = (
|
||||
"bash: cannot set terminal process group",
|
||||
"bash: no job control in this shell",
|
||||
"bash: no job control in this shell\n",
|
||||
"no job control in this shell",
|
||||
"no job control in this shell\n",
|
||||
})
|
||||
"cannot set terminal process group",
|
||||
"tcsetattr: Inappropriate ioctl for device",
|
||||
)
|
||||
|
||||
|
||||
def _clean_shell_noise(output: str) -> str:
|
||||
"""Strip shell startup warnings that leak when using -i without a TTY."""
|
||||
lines = output.split("\n", 2) # only check first two lines
|
||||
if lines and lines[0].strip() in _SHELL_NOISE:
|
||||
return "\n".join(lines[1:])
|
||||
return output
|
||||
"""Strip shell startup warnings that leak when using -i without a TTY.
|
||||
|
||||
Removes all leading lines that match known noise patterns, not just the first.
|
||||
Some environments emit multiple noise lines (e.g. Docker, non-TTY sessions).
|
||||
"""
|
||||
lines = output.split("\n")
|
||||
# Strip all leading noise lines
|
||||
while lines and any(noise in lines[0] for noise in _SHELL_NOISE_SUBSTRINGS):
|
||||
lines.pop(0)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class LocalEnvironment(BaseEnvironment):
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ def get_async_client() -> AsyncOpenAI:
|
|||
default_headers={
|
||||
"HTTP-Referer": "https://github.com/NousResearch/hermes-agent",
|
||||
"X-OpenRouter-Title": "Hermes Agent",
|
||||
"X-OpenRouter-Categories": "cli-agent",
|
||||
"X-OpenRouter-Categories": "productivity,cli-agent",
|
||||
},
|
||||
)
|
||||
return _client
|
||||
|
|
|
|||
|
|
@ -87,13 +87,13 @@ class ProcessRegistry:
|
|||
- Cleanup thread (sandbox reaping coordination)
|
||||
"""
|
||||
|
||||
# Noise lines emitted by interactive shells when stdin is not a terminal.
|
||||
_SHELL_NOISE = frozenset({
|
||||
_SHELL_NOISE_SUBSTRINGS = (
|
||||
"bash: cannot set terminal process group",
|
||||
"bash: no job control in this shell",
|
||||
"bash: no job control in this shell\n",
|
||||
"no job control in this shell",
|
||||
"no job control in this shell\n",
|
||||
})
|
||||
"cannot set terminal process group",
|
||||
"tcsetattr: Inappropriate ioctl for device",
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self._running: Dict[str, ProcessSession] = {}
|
||||
|
|
@ -106,10 +106,10 @@ class ProcessRegistry:
|
|||
@staticmethod
|
||||
def _clean_shell_noise(text: str) -> str:
|
||||
"""Strip shell startup warnings from the beginning of output."""
|
||||
lines = text.split("\n", 2)
|
||||
if lines and lines[0].strip() in ProcessRegistry._SHELL_NOISE:
|
||||
return "\n".join(lines[1:])
|
||||
return text
|
||||
lines = text.split("\n")
|
||||
while lines and any(noise in lines[0] for noise in ProcessRegistry._SHELL_NOISE_SUBSTRINGS):
|
||||
lines.pop(0)
|
||||
return "\n".join(lines)
|
||||
|
||||
# ----- Spawn -----
|
||||
|
||||
|
|
|
|||
|
|
@ -24,26 +24,13 @@ from typing import Dict, Any, List, Optional
|
|||
|
||||
from openai import AsyncOpenAI, OpenAI
|
||||
|
||||
from agent.auxiliary_client import get_text_auxiliary_client
|
||||
from agent.auxiliary_client import get_async_text_auxiliary_client
|
||||
|
||||
# Resolve the auxiliary client at import time so we have the model slug.
|
||||
# We build an AsyncOpenAI from the same credentials for async summarization.
|
||||
_aux_client, _SUMMARIZER_MODEL = get_text_auxiliary_client()
|
||||
_async_aux_client: AsyncOpenAI | None = None
|
||||
if _aux_client is not None:
|
||||
_async_kwargs = {
|
||||
"api_key": _aux_client.api_key,
|
||||
"base_url": str(_aux_client.base_url),
|
||||
}
|
||||
if "openrouter" in str(_aux_client.base_url).lower():
|
||||
_async_kwargs["default_headers"] = {
|
||||
"HTTP-Referer": "https://github.com/NousResearch/hermes-agent",
|
||||
"X-OpenRouter-Title": "Hermes Agent",
|
||||
"X-OpenRouter-Categories": "cli-agent",
|
||||
}
|
||||
_async_aux_client = AsyncOpenAI(**_async_kwargs)
|
||||
# Resolve the async auxiliary client at import time so we have the model slug.
|
||||
# Handles Codex Responses API adapter transparently.
|
||||
_async_aux_client, _SUMMARIZER_MODEL = get_async_text_auxiliary_client()
|
||||
MAX_SESSION_CHARS = 100_000
|
||||
MAX_SUMMARY_TOKENS = 2000
|
||||
MAX_SUMMARY_TOKENS = 10000
|
||||
|
||||
|
||||
def _format_timestamp(ts) -> str:
|
||||
|
|
|
|||
|
|
@ -1037,8 +1037,12 @@ def terminal_tool(
|
|||
)
|
||||
output = output[:head_chars] + truncated_notice + output[-tail_chars:]
|
||||
|
||||
# Redact secrets from command output (catches env/printenv leaking keys)
|
||||
from agent.redact import redact_sensitive_text
|
||||
output = redact_sensitive_text(output.strip()) if output else ""
|
||||
|
||||
return json.dumps({
|
||||
"output": output.strip() if output else "",
|
||||
"output": output,
|
||||
"exit_code": returncode,
|
||||
"error": None
|
||||
}, ensure_ascii=False)
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ if _aux_sync_client is not None:
|
|||
_async_kwargs["default_headers"] = {
|
||||
"HTTP-Referer": "https://github.com/NousResearch/hermes-agent",
|
||||
"X-OpenRouter-Title": "Hermes Agent",
|
||||
"X-OpenRouter-Categories": "cli-agent",
|
||||
"X-OpenRouter-Categories": "productivity,cli-agent",
|
||||
}
|
||||
_aux_async_client = AsyncOpenAI(**_async_kwargs)
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ import asyncio
|
|||
from typing import List, Dict, Any, Optional
|
||||
from firecrawl import Firecrawl
|
||||
from openai import AsyncOpenAI
|
||||
from agent.auxiliary_client import get_text_auxiliary_client
|
||||
from agent.auxiliary_client import get_async_text_auxiliary_client
|
||||
from tools.debug_helpers import DebugSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -67,21 +67,9 @@ def _get_firecrawl_client():
|
|||
|
||||
DEFAULT_MIN_LENGTH_FOR_SUMMARIZATION = 5000
|
||||
|
||||
# Resolve auxiliary text client at module level; build an async wrapper.
|
||||
_aux_sync_client, DEFAULT_SUMMARIZER_MODEL = get_text_auxiliary_client()
|
||||
_aux_async_client: AsyncOpenAI | None = None
|
||||
if _aux_sync_client is not None:
|
||||
_async_kwargs = {
|
||||
"api_key": _aux_sync_client.api_key,
|
||||
"base_url": str(_aux_sync_client.base_url),
|
||||
}
|
||||
if "openrouter" in str(_aux_sync_client.base_url).lower():
|
||||
_async_kwargs["default_headers"] = {
|
||||
"HTTP-Referer": "https://github.com/NousResearch/hermes-agent",
|
||||
"X-OpenRouter-Title": "Hermes Agent",
|
||||
"X-OpenRouter-Categories": "cli-agent",
|
||||
}
|
||||
_aux_async_client = AsyncOpenAI(**_async_kwargs)
|
||||
# Resolve async auxiliary client at module level.
|
||||
# Handles Codex Responses API adapter transparently.
|
||||
_aux_async_client, DEFAULT_SUMMARIZER_MODEL = get_async_text_auxiliary_client()
|
||||
|
||||
_debug = DebugSession("web_tools", env_var="WEB_TOOLS_DEBUG")
|
||||
|
||||
|
|
@ -174,7 +162,7 @@ async def _call_summarizer_llm(
|
|||
content: str,
|
||||
context_str: str,
|
||||
model: str,
|
||||
max_tokens: int = 4000,
|
||||
max_tokens: int = 20000,
|
||||
is_chunk: bool = False,
|
||||
chunk_info: str = ""
|
||||
) -> Optional[str]:
|
||||
|
|
@ -306,7 +294,7 @@ async def _process_large_content_chunked(
|
|||
chunk_content,
|
||||
context_str,
|
||||
model,
|
||||
max_tokens=2000,
|
||||
max_tokens=10000,
|
||||
is_chunk=True,
|
||||
chunk_info=chunk_info
|
||||
)
|
||||
|
|
@ -374,7 +362,7 @@ Create a single, unified markdown summary."""
|
|||
{"role": "user", "content": synthesis_prompt}
|
||||
],
|
||||
temperature=0.1,
|
||||
**auxiliary_max_tokens_param(4000),
|
||||
**auxiliary_max_tokens_param(20000),
|
||||
**({} if not _extra else {"extra_body": _extra}),
|
||||
)
|
||||
final_summary = response.choices[0].message.content.strip()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue