Merge remote-tracking branch 'origin/main' into feature/homeassistant-integration

# Conflicts:
#	run_agent.py
This commit is contained in:
0xbyt4 2026-03-01 11:59:12 +03:00
commit 3fdf03390e
50 changed files with 7354 additions and 358 deletions

View file

@ -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:

View file

@ -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):

View file

@ -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

View file

@ -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 -----

View file

@ -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:

View file

@ -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)

View file

@ -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)

View file

@ -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()