From d9e7e42d0b692b91168b4d69cb5a87e6460f3100 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Apr 2026 01:00:02 -0700 Subject: [PATCH 1/5] fix(approval): load permanent command allowlist on startup (#5076) Co-authored-by: Timo Karp Co-authored-by: Claude Opus 4.6 (1M context) --- tools/approval.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tools/approval.py b/tools/approval.py index 1939983625..b49e444a4e 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -871,3 +871,7 @@ def check_all_command_guards(command: str, env_type: str, return {"approved": True, "message": None, "user_approved": True, "description": combined_desc} + + +# Load permanent allowlist from config on module import +load_permanent_allowlist() From 1c425f219ecde160daddeec82283c735f1df9aeb Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Apr 2026 01:03:52 -0700 Subject: [PATCH 2/5] fix(cli): defer response content until reasoning block completes (#5773) When show_reasoning is on with streaming, content tokens could arrive while the reasoning box was still rendering (interleaved thinking mode). This caused the response box to open before reasoning finished, resulting in reasoning appearing after the response in the terminal. Fix: buffer content in _deferred_content while _reasoning_box_opened is True. Flush the buffer through _emit_stream_text when _close_reasoning_box runs, ensuring reasoning always renders before the response. --- cli.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/cli.py b/cli.py index 29e6257d1e..09cf2094a7 100644 --- a/cli.py +++ b/cli.py @@ -1920,6 +1920,12 @@ class HermesCLI: _cprint(f"{_DIM}└{'─' * (w - 2)}┘{_RST}") self._reasoning_box_opened = False + # Flush any content that was deferred while reasoning was rendering. + deferred = getattr(self, "_deferred_content", "") + if deferred: + self._deferred_content = "" + self._emit_stream_text(deferred) + def _stream_delta(self, text) -> None: """Line-buffered streaming callback for real-time token rendering. @@ -2022,6 +2028,13 @@ class HermesCLI: if not text: return + # When show_reasoning is on and reasoning is still rendering, + # defer content until the reasoning box closes. This ensures the + # reasoning block always appears BEFORE the response in the terminal. + if self.show_reasoning and getattr(self, "_reasoning_box_opened", False): + self._deferred_content = getattr(self, "_deferred_content", "") + text + return + # Close the live reasoning box before opening the response box self._close_reasoning_box() @@ -2088,6 +2101,7 @@ class HermesCLI: self._reasoning_box_opened = False self._reasoning_buf = "" self._reasoning_preview_buf = "" + self._deferred_content = "" def _slow_command_status(self, command: str) -> str: """Return a user-facing status message for slower slash commands.""" From e120d2afacf90f3fec243c814da27216cba1943b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Apr 2026 02:40:16 -0700 Subject: [PATCH 3/5] feat: notify_on_complete for background processes (#5779) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: notify_on_complete for background processes When terminal(background=true, notify_on_complete=true), the system auto-triggers a new agent turn when the process exits — no polling needed. Changes: - ProcessSession: add notify_on_complete field - ProcessRegistry: add completion_queue, populate on _move_to_finished() - Terminal tool: add notify_on_complete parameter to schema + handler - CLI: drain completion_queue after agent turn AND during idle loop - Gateway: enhanced _run_process_watcher injects synthetic MessageEvent on completion, triggering a full agent turn - Checkpoint persistence includes notify_on_complete for crash recovery - code_execution_tool: block notify_on_complete in sandbox scripts - 15 new tests covering queue mechanics, checkpoint round-trip, schema * docs: update terminal tool descriptions for notify_on_complete - background: remove 'ONLY for servers' language, describe both patterns (long-lived processes AND long-running tasks with notify_on_complete) - notify_on_complete: more prescriptive about when to use it - TERMINAL_TOOL_DESCRIPTION: remove 'Do NOT use background for builds' guidance that contradicted the new feature --- cli.py | 43 ++++- gateway/run.py | 51 ++++- tests/tools/test_notify_on_complete.py | 247 +++++++++++++++++++++++++ tools/code_execution_tool.py | 2 +- tools/process_registry.py | 21 +++ tools/terminal_tool.py | 44 ++++- 6 files changed, 398 insertions(+), 10 deletions(-) create mode 100644 tests/tools/test_notify_on_complete.py diff --git a/cli.py b/cli.py index 09cf2094a7..6f02dc9360 100644 --- a/cli.py +++ b/cli.py @@ -8134,6 +8134,25 @@ class HermesCLI: # Periodic config watcher — auto-reload MCP on mcp_servers change if not self._agent_running: self._check_config_mcp_changes() + # Check for background process completion notifications + # while the agent is idle (user hasn't typed anything yet). + try: + from tools.process_registry import process_registry + if not process_registry.completion_queue.empty(): + completion = process_registry.completion_queue.get_nowait() + _exit = completion.get("exit_code", "?") + _cmd = completion.get("command", "unknown") + _sid = completion.get("session_id", "unknown") + _out = completion.get("output", "") + _synth = ( + f"[SYSTEM: Background process {_sid} completed " + f"(exit code {_exit}).\n" + f"Command: {_cmd}\n" + f"Output:\n{_out}]" + ) + self._pending_input.put(_synth) + except Exception: + pass continue if not user_input: @@ -8247,7 +8266,29 @@ class HermesCLI: except Exception as e: _cprint(f"{_DIM}Voice auto-restart failed: {e}{_RST}") threading.Thread(target=_restart_recording, daemon=True).start() - + + # Drain process completion notifications — any background + # process that finished with notify_on_complete while the + # agent was running (or before) gets auto-injected as a + # new user message so the agent can react to it. + try: + from tools.process_registry import process_registry + while not process_registry.completion_queue.empty(): + completion = process_registry.completion_queue.get_nowait() + _exit = completion.get("exit_code", "?") + _cmd = completion.get("command", "unknown") + _sid = completion.get("session_id", "unknown") + _out = completion.get("output", "") + _synth = ( + f"[SYSTEM: Background process {_sid} completed " + f"(exit code {_exit}).\n" + f"Command: {_cmd}\n" + f"Output:\n{_out}]" + ) + self._pending_input.put(_synth) + except Exception: + pass # Non-fatal — don't break the main loop + except Exception as e: print(f"Error: {e}") diff --git a/gateway/run.py b/gateway/run.py index e4a5324acb..7a45be62d4 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6048,12 +6048,13 @@ class GatewayRunner: platform_name = watcher.get("platform", "") chat_id = watcher.get("chat_id", "") thread_id = watcher.get("thread_id", "") + agent_notify = watcher.get("notify_on_complete", False) notify_mode = self._load_background_notifications_mode() - logger.debug("Process watcher started: %s (every %ss, notify=%s)", - session_id, interval, notify_mode) + logger.debug("Process watcher started: %s (every %ss, notify=%s, agent_notify=%s)", + session_id, interval, notify_mode, agent_notify) - if notify_mode == "off": + if notify_mode == "off" and not agent_notify: # Still wait for the process to exit so we can log it, but don't # push any messages to the user. while True: @@ -6077,6 +6078,47 @@ class GatewayRunner: last_output_len = current_output_len if session.exited: + # --- Agent-triggered completion: inject synthetic message --- + if agent_notify: + from tools.ansi_strip import strip_ansi + _out = strip_ansi(session.output_buffer[-2000:]) if session.output_buffer else "" + synth_text = ( + f"[SYSTEM: Background process {session_id} completed " + f"(exit code {session.exit_code}).\n" + f"Command: {session.command}\n" + f"Output:\n{_out}]" + ) + adapter = None + for p, a in self.adapters.items(): + if p.value == platform_name: + adapter = a + break + if adapter and chat_id: + try: + from gateway.platforms.base import MessageEvent, MessageType + from gateway.session import SessionSource + from gateway.config import Platform + _platform_enum = Platform(platform_name) + _source = SessionSource( + platform=_platform_enum, + chat_id=chat_id, + thread_id=thread_id or None, + ) + synth_event = MessageEvent( + text=synth_text, + message_type=MessageType.TEXT, + source=_source, + ) + logger.info( + "Process %s finished — injecting agent notification for session %s", + session_id, session_key, + ) + await adapter.handle_message(synth_event) + except Exception as e: + logger.error("Agent notify injection error: %s", e) + break + + # --- Normal text-only notification --- # Decide whether to notify based on mode should_notify = ( notify_mode in ("all", "result") @@ -6101,8 +6143,9 @@ class GatewayRunner: logger.error("Watcher delivery error: %s", e) break - elif has_new_output and notify_mode == "all": + elif has_new_output and notify_mode == "all" and not agent_notify: # New output available -- deliver status update (only in "all" mode) + # Skip periodic updates for agent_notify watchers (they only care about completion) new_output = session.output_buffer[-500:] if session.output_buffer else "" message_text = ( f"[Background process {session_id} is still running~ " diff --git a/tests/tools/test_notify_on_complete.py b/tests/tools/test_notify_on_complete.py new file mode 100644 index 0000000000..888721906d --- /dev/null +++ b/tests/tools/test_notify_on_complete.py @@ -0,0 +1,247 @@ +"""Tests for notify_on_complete background process feature. + +Covers: + - ProcessSession.notify_on_complete field + - ProcessRegistry.completion_queue population on _move_to_finished() + - Checkpoint persistence of notify_on_complete + - Terminal tool schema includes notify_on_complete + - Terminal tool handler passes notify_on_complete through +""" + +import json +import os +import queue +import time +import pytest +from pathlib import Path +from unittest.mock import MagicMock, patch + +from tools.process_registry import ( + ProcessRegistry, + ProcessSession, +) + + +@pytest.fixture() +def registry(): + """Create a fresh ProcessRegistry.""" + return ProcessRegistry() + + +def _make_session( + sid="proc_test_notify", + command="echo hello", + task_id="t1", + exited=False, + exit_code=None, + output="", + notify_on_complete=False, +) -> ProcessSession: + s = ProcessSession( + id=sid, + command=command, + task_id=task_id, + started_at=time.time(), + exited=exited, + exit_code=exit_code, + output_buffer=output, + notify_on_complete=notify_on_complete, + ) + return s + + +# ========================================================================= +# ProcessSession field +# ========================================================================= + +class TestProcessSessionField: + def test_default_false(self): + s = ProcessSession(id="proc_1", command="echo hi") + assert s.notify_on_complete is False + + def test_set_true(self): + s = ProcessSession(id="proc_1", command="echo hi", notify_on_complete=True) + assert s.notify_on_complete is True + + +# ========================================================================= +# Completion queue +# ========================================================================= + +class TestCompletionQueue: + def test_queue_exists(self, registry): + assert hasattr(registry, "completion_queue") + assert registry.completion_queue.empty() + + def test_move_to_finished_no_notify(self, registry): + """Processes without notify_on_complete don't enqueue.""" + s = _make_session(notify_on_complete=False, output="done") + s.exited = True + s.exit_code = 0 + registry._running[s.id] = s + with patch.object(registry, "_write_checkpoint"): + registry._move_to_finished(s) + assert registry.completion_queue.empty() + + def test_move_to_finished_with_notify(self, registry): + """Processes with notify_on_complete push to queue.""" + s = _make_session( + notify_on_complete=True, + output="build succeeded", + exit_code=0, + ) + s.exited = True + s.exit_code = 0 + registry._running[s.id] = s + with patch.object(registry, "_write_checkpoint"): + registry._move_to_finished(s) + + assert not registry.completion_queue.empty() + completion = registry.completion_queue.get_nowait() + assert completion["session_id"] == s.id + assert completion["command"] == "echo hello" + assert completion["exit_code"] == 0 + assert "build succeeded" in completion["output"] + + def test_move_to_finished_nonzero_exit(self, registry): + """Nonzero exit codes are captured correctly.""" + s = _make_session( + notify_on_complete=True, + output="FAILED", + exit_code=1, + ) + s.exited = True + s.exit_code = 1 + registry._running[s.id] = s + with patch.object(registry, "_write_checkpoint"): + registry._move_to_finished(s) + + completion = registry.completion_queue.get_nowait() + assert completion["exit_code"] == 1 + assert "FAILED" in completion["output"] + + def test_output_truncated_to_2000(self, registry): + """Long output is truncated to last 2000 chars.""" + long_output = "x" * 5000 + s = _make_session( + notify_on_complete=True, + output=long_output, + ) + s.exited = True + s.exit_code = 0 + registry._running[s.id] = s + with patch.object(registry, "_write_checkpoint"): + registry._move_to_finished(s) + + completion = registry.completion_queue.get_nowait() + assert len(completion["output"]) == 2000 + + def test_multiple_completions_queued(self, registry): + """Multiple notify processes all push to the same queue.""" + for i in range(3): + s = _make_session( + sid=f"proc_{i}", + notify_on_complete=True, + output=f"output_{i}", + ) + s.exited = True + s.exit_code = 0 + registry._running[s.id] = s + with patch.object(registry, "_write_checkpoint"): + registry._move_to_finished(s) + + completions = [] + while not registry.completion_queue.empty(): + completions.append(registry.completion_queue.get_nowait()) + assert len(completions) == 3 + ids = {c["session_id"] for c in completions} + assert ids == {"proc_0", "proc_1", "proc_2"} + + +# ========================================================================= +# Checkpoint persistence +# ========================================================================= + +class TestCheckpointNotify: + def test_checkpoint_includes_notify(self, registry, tmp_path): + with patch("tools.process_registry.CHECKPOINT_PATH", tmp_path / "procs.json"): + s = _make_session(notify_on_complete=True) + registry._running[s.id] = s + registry._write_checkpoint() + + data = json.loads((tmp_path / "procs.json").read_text()) + assert len(data) == 1 + assert data[0]["notify_on_complete"] is True + + def test_checkpoint_without_notify(self, registry, tmp_path): + with patch("tools.process_registry.CHECKPOINT_PATH", tmp_path / "procs.json"): + s = _make_session(notify_on_complete=False) + registry._running[s.id] = s + registry._write_checkpoint() + + data = json.loads((tmp_path / "procs.json").read_text()) + assert data[0]["notify_on_complete"] is False + + def test_recover_preserves_notify(self, registry, tmp_path): + checkpoint = tmp_path / "procs.json" + checkpoint.write_text(json.dumps([{ + "session_id": "proc_live", + "command": "sleep 999", + "pid": os.getpid(), + "task_id": "t1", + "notify_on_complete": True, + }])) + with patch("tools.process_registry.CHECKPOINT_PATH", checkpoint): + recovered = registry.recover_from_checkpoint() + assert recovered == 1 + s = registry.get("proc_live") + assert s.notify_on_complete is True + + def test_recover_defaults_false(self, registry, tmp_path): + """Old checkpoint entries without the field default to False.""" + checkpoint = tmp_path / "procs.json" + checkpoint.write_text(json.dumps([{ + "session_id": "proc_live", + "command": "sleep 999", + "pid": os.getpid(), + "task_id": "t1", + }])) + with patch("tools.process_registry.CHECKPOINT_PATH", checkpoint): + recovered = registry.recover_from_checkpoint() + assert recovered == 1 + s = registry.get("proc_live") + assert s.notify_on_complete is False + + +# ========================================================================= +# Terminal tool schema +# ========================================================================= + +class TestTerminalSchema: + def test_schema_has_notify_on_complete(self): + from tools.terminal_tool import TERMINAL_SCHEMA + props = TERMINAL_SCHEMA["parameters"]["properties"] + assert "notify_on_complete" in props + assert props["notify_on_complete"]["type"] == "boolean" + assert props["notify_on_complete"]["default"] is False + + def test_handler_passes_notify(self): + """_handle_terminal passes notify_on_complete to terminal_tool.""" + from tools.terminal_tool import _handle_terminal + with patch("tools.terminal_tool.terminal_tool", return_value='{"ok":true}') as mock_tt: + _handle_terminal( + {"command": "echo hi", "background": True, "notify_on_complete": True}, + task_id="t1", + ) + _, kwargs = mock_tt.call_args + assert kwargs["notify_on_complete"] is True + + +# ========================================================================= +# Code execution blocked params +# ========================================================================= + +class TestCodeExecutionBlocked: + def test_notify_on_complete_blocked_in_sandbox(self): + from tools.code_execution_tool import _TERMINAL_BLOCKED_PARAMS + assert "notify_on_complete" in _TERMINAL_BLOCKED_PARAMS diff --git a/tools/code_execution_tool.py b/tools/code_execution_tool.py index ff5c7f7fed..5c4658b6f4 100644 --- a/tools/code_execution_tool.py +++ b/tools/code_execution_tool.py @@ -300,7 +300,7 @@ def _call(tool_name, args): # --------------------------------------------------------------------------- # Terminal parameters that must not be used from ephemeral sandbox scripts -_TERMINAL_BLOCKED_PARAMS = {"background", "check_interval", "pty"} +_TERMINAL_BLOCKED_PARAMS = {"background", "check_interval", "pty", "notify_on_complete"} def _rpc_server_loop( diff --git a/tools/process_registry.py b/tools/process_registry.py index a3796c8ae3..f5ac9543f9 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -81,6 +81,7 @@ class ProcessSession: watcher_chat_id: str = "" watcher_thread_id: str = "" watcher_interval: int = 0 # 0 = no watcher configured + notify_on_complete: bool = False # Queue agent notification on exit _lock: threading.Lock = field(default_factory=threading.Lock) _reader_thread: Optional[threading.Thread] = field(default=None, repr=False) _pty: Any = field(default=None, repr=False) # ptyprocess handle (when use_pty=True) @@ -112,6 +113,12 @@ class ProcessRegistry: # Side-channel for check_interval watchers (gateway reads after agent run) self.pending_watchers: List[Dict[str, Any]] = [] + # Completion notifications — processes with notify_on_complete push here + # on exit. CLI process_loop and gateway drain this after each agent turn + # to auto-trigger a new agent turn with the process results. + import queue as _queue_mod + self.completion_queue: _queue_mod.Queue = _queue_mod.Queue() + @staticmethod def _clean_shell_noise(text: str) -> str: """Strip shell startup warnings from the beginning of output.""" @@ -415,6 +422,18 @@ class ProcessRegistry: self._finished[session.id] = session self._write_checkpoint() + # If the caller requested agent notification, enqueue the completion + # so the CLI/gateway can auto-trigger a new agent turn. + if session.notify_on_complete: + from tools.ansi_strip import strip_ansi + output_tail = strip_ansi(session.output_buffer[-2000:]) if session.output_buffer else "" + self.completion_queue.put({ + "session_id": session.id, + "command": session.command, + "exit_code": session.exit_code, + "output": output_tail, + }) + # ----- Query Methods ----- def get(self, session_id: str) -> Optional[ProcessSession]: @@ -721,6 +740,7 @@ class ProcessRegistry: "watcher_chat_id": s.watcher_chat_id, "watcher_thread_id": s.watcher_thread_id, "watcher_interval": s.watcher_interval, + "notify_on_complete": s.notify_on_complete, }) # Atomic write to avoid corruption on crash @@ -771,6 +791,7 @@ class ProcessRegistry: watcher_chat_id=entry.get("watcher_chat_id", ""), watcher_thread_id=entry.get("watcher_thread_id", ""), watcher_interval=entry.get("watcher_interval", 0), + notify_on_complete=entry.get("notify_on_complete", False), ) with self._lock: self._running[session.id] = session diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index be565f1966..305d08011f 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -421,9 +421,11 @@ Do NOT use sed/awk to edit files — use patch instead. Do NOT use echo/cat heredoc to create files — use write_file instead. Reserve terminal for: builds, installs, git, processes, scripts, network, package managers, and anything that needs a shell. -Foreground (default): Commands return INSTANTLY when done, even if the timeout is high. Set timeout=300 for long builds/scripts — you'll still get the result in seconds if it's fast. Prefer foreground for everything that finishes. -Background: ONLY for long-running servers, watchers, or processes that never exit. Set background=true to get a session_id, then use process(action="wait") to block until done — it returns instantly on completion, same as foreground. Use process(action="poll") only when you need a progress check without blocking. -Do NOT use background for scripts, builds, or installs — foreground with a generous timeout is always better (fewer tool calls, instant results). +Foreground (default): Commands return INSTANTLY when done, even if the timeout is high. Set timeout=300 for long builds/scripts — you'll still get the result in seconds if it's fast. Prefer foreground for short commands. +Background: Set background=true to get a session_id. Two patterns: + (1) Long-lived processes that never exit (servers, watchers). + (2) Long-running tasks with notify_on_complete=true — you can keep working on other things and the system auto-notifies you when the task finishes. Great for test suites, builds, deployments, or anything that takes more than a minute. +Use process(action="poll") for progress checks, process(action="wait") to block until done. Working directory: Use 'workdir' for per-command cwd. PTY mode: Set pty=true for interactive CLI tools (Codex, Claude Code, Python REPL). @@ -1009,6 +1011,7 @@ def terminal_tool( workdir: Optional[str] = None, check_interval: Optional[int] = None, pty: bool = False, + notify_on_complete: bool = False, ) -> str: """ Execute a command in the configured terminal environment. @@ -1022,6 +1025,7 @@ def terminal_tool( workdir: Working directory for this command (optional, uses session cwd if not set) check_interval: Seconds between auto-checks for background processes (gateway only, min 30) pty: If True, use pseudo-terminal for interactive CLI tools (local backend only) + notify_on_complete: If True and background=True, auto-notify the agent when the process exits Returns: str: JSON string with output, exit_code, and error fields @@ -1254,6 +1258,32 @@ def terminal_tool( f"configured limit of {max_timeout}s" ) + # Mark for agent notification on completion + if notify_on_complete and background: + proc_session.notify_on_complete = True + result_data["notify_on_complete"] = True + + # In gateway mode, auto-register a fast watcher so the + # gateway can detect completion and trigger a new agent + # turn. CLI mode uses the completion_queue directly. + _gw_platform = os.getenv("HERMES_SESSION_PLATFORM", "") + if _gw_platform and not check_interval: + _gw_chat_id = os.getenv("HERMES_SESSION_CHAT_ID", "") + _gw_thread_id = os.getenv("HERMES_SESSION_THREAD_ID", "") + proc_session.watcher_platform = _gw_platform + proc_session.watcher_chat_id = _gw_chat_id + proc_session.watcher_thread_id = _gw_thread_id + proc_session.watcher_interval = 5 + process_registry.pending_watchers.append({ + "session_id": proc_session.id, + "check_interval": 5, + "session_key": session_key, + "platform": _gw_platform, + "chat_id": _gw_chat_id, + "thread_id": _gw_thread_id, + "notify_on_complete": True, + }) + # Register check_interval watcher (gateway picks this up after agent run) if check_interval and background: effective_interval = max(30, check_interval) @@ -1550,7 +1580,7 @@ TERMINAL_SCHEMA = { }, "background": { "type": "boolean", - "description": "ONLY for servers/watchers that never exit. For scripts, builds, installs — use foreground with timeout instead (it returns instantly when done).", + "description": "Run the command in the background. Two patterns: (1) Long-lived processes that never exit (servers, watchers). (2) Long-running tasks paired with notify_on_complete=true — you can keep working and get notified when the task finishes. For short commands, prefer foreground with a generous timeout instead.", "default": False }, "timeout": { @@ -1571,6 +1601,11 @@ TERMINAL_SCHEMA = { "type": "boolean", "description": "Run in pseudo-terminal (PTY) mode for interactive CLI tools like Codex, Claude Code, or Python REPL. Only works with local and SSH backends. Default: false.", "default": False + }, + "notify_on_complete": { + "type": "boolean", + "description": "When true (and background=true), you'll be automatically notified when the process finishes — no polling needed. Use this for tasks that take a while (tests, builds, deployments) so you can keep working on other things in the meantime.", + "default": False } }, "required": ["command"] @@ -1587,6 +1622,7 @@ def _handle_terminal(args, **kw): workdir=args.get("workdir"), check_interval=args.get("check_interval"), pty=args.get("pty", False), + notify_on_complete=args.get("notify_on_complete", False), ) From cafdfd36549538713b0a91aaef42877c2be2845a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Apr 2026 02:49:20 -0700 Subject: [PATCH 4/5] fix: sync bundled skills to default profile when updating from a named profile (#5795) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The filter in cmd_update() excluded is_default profiles from the cross-profile skill sync loop. When running 'hermes update' from a named profile (e.g. hermes -p coder update), the default profile (~/.hermes) never received new bundled skills. Remove the 'not p.is_default' condition so all profiles — including default — are synced regardless of which profile runs the update. Reported by olafgeibig. --- hermes_cli/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 1a968952ae..55faf84131 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -3566,7 +3566,7 @@ def cmd_update(args): try: from hermes_cli.profiles import list_profiles, get_active_profile_name, seed_profile_skills active = get_active_profile_name() - other_profiles = [p for p in list_profiles() if not p.is_default and p.name != active] + other_profiles = [p for p in list_profiles() if p.name != active] if other_profiles: print() print("→ Syncing bundled skills to other profiles...") From 8b861b77c1f854a2b7914be1afa52facebfb046f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 7 Apr 2026 03:28:44 -0700 Subject: [PATCH 5/5] =?UTF-8?q?refactor:=20remove=20browser=5Fclose=20tool?= =?UTF-8?q?=20=E2=80=94=20auto-cleanup=20handles=20it=20(#5792)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: remove browser_close tool — auto-cleanup handles it The browser_close tool was called in only 9% of browser sessions (13/144 navigations across 66 sessions), always redundantly — cleanup_browser() already runs via _cleanup_task_resources() at conversation end, and the background inactivity reaper catches anything else. Removing it saves one tool schema slot in every browser-enabled API call. Also fixes a latent bug: cleanup_browser() now handles Camofox sessions too (previously only Browserbase). Camofox sessions were never auto-cleaned per-task because they live in a separate dict from _active_sessions. Files changed (13): - tools/browser_tool.py: remove function, schema, registry entry; add camofox cleanup to cleanup_browser() - toolsets.py, model_tools.py, prompt_builder.py, display.py, acp_adapter/tools.py: remove browser_close from all tool lists - tests/: remove browser_close test, update toolset assertion - docs/skills: remove all browser_close references * fix: repeat browser_scroll 5x per call for meaningful page movement Most backends scroll ~100px per call — barely visible on a typical viewport. Repeating 5x gives ~500px (~half a viewport), making each scroll tool call actually useful. Backend-agnostic approach: works across all 7+ browser backends without needing to configure each one's scroll amount individually. Breaks early on error for the agent-browser path. * feat: auto-return compact snapshot from browser_navigate Every browser session starts with navigate → snapshot. Now navigate returns the compact accessibility tree snapshot inline, saving one tool call per browser task. The snapshot captures the full page DOM (not viewport-limited), so scroll position doesn't affect it. browser_snapshot remains available for refreshing after interactions or getting full=true content. Both Browserbase and Camofox paths auto-snapshot. If the snapshot fails for any reason, navigation still succeeds — the snapshot is a bonus, not a requirement. Schema descriptions updated to guide models: navigate mentions it returns a snapshot, snapshot mentions it's for refresh/full content. * refactor: slim cronjob tool schema — consolidate model/provider, drop unused params Session data (151 calls across 67 sessions) showed several schema properties were never used by models. Consolidated and cleaned up: Removed from schema (still work via backend/CLI): - skill (singular): use skills array instead - reason: pause-only, unnecessary - include_disabled: now defaults to true - base_url: extreme edge case, zero usage - provider (standalone): merged into model object Consolidated: - model + provider → single 'model' object with {model, provider} fields. If provider is omitted, the current main provider is pinned at creation time so the job stays stable even if the user changes their default. Kept: - script: useful data collection feature - skills array: standard interface for skill loading Schema shrinks from 14 to 10 properties. All backend functionality preserved — the Python function signature and handler lambda still accept every parameter. * fix: remove mixture_of_agents from core toolsets — opt-in only via hermes tools MoA was in _HERMES_CORE_TOOLS and composite toolsets (hermes-cli, hermes-messaging, safe), which meant it appeared in every session for anyone with OPENROUTER_API_KEY set. The _DEFAULT_OFF_TOOLSETS gate only works after running 'hermes tools' explicitly. Now MoA only appears when a user explicitly enables it via 'hermes tools'. The moa toolset definition and check_fn remain unchanged — it just needs to be opted into. --- acp_adapter/tools.py | 1 - agent/display.py | 2 - agent/prompt_builder.py | 1 - cli-config.yaml.example | 2 +- model_tools.py | 2 +- skills/dogfood/SKILL.md | 3 +- tests/gateway/test_api_server_toolset.py | 2 +- tests/tools/test_browser_cleanup.py | 12 -- tools/browser_camofox.py | 19 +++ tools/browser_tool.py | 123 +++++++++---------- tools/cronjob_tools.py | 86 +++++++------ toolsets.py | 14 +-- website/docs/reference/tools-reference.md | 1 - website/docs/reference/toolsets-reference.md | 2 +- website/docs/user-guide/features/browser.md | 8 +- 15 files changed, 136 insertions(+), 142 deletions(-) diff --git a/acp_adapter/tools.py b/acp_adapter/tools.py index 8756aa9296..52313220b7 100644 --- a/acp_adapter/tools.py +++ b/acp_adapter/tools.py @@ -39,7 +39,6 @@ TOOL_KIND_MAP: Dict[str, ToolKind] = { "browser_scroll": "execute", "browser_press": "execute", "browser_back": "execute", - "browser_close": "execute", "browser_get_images": "read", # Agent internals "delegate_task": "execute", diff --git a/agent/display.py b/agent/display.py index 94259fa80a..5eac70a4b7 100644 --- a/agent/display.py +++ b/agent/display.py @@ -890,8 +890,6 @@ def get_cute_tool_message( return _wrap(f"┊ ◀️ back {dur}") if tool_name == "browser_press": return _wrap(f"┊ ⌨️ press {args.get('key', '?')} {dur}") - if tool_name == "browser_close": - return _wrap(f"┊ 🚪 close browser {dur}") if tool_name == "browser_get_images": return _wrap(f"┊ 🖼️ images extracting {dur}") if tool_name == "browser_vision": diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 0a2cbe3741..d6c296f61d 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -744,7 +744,6 @@ def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) - "browser_type", "browser_scroll", "browser_console", - "browser_close", "browser_press", "browser_get_images", "browser_vision", diff --git a/cli-config.yaml.example b/cli-config.yaml.example index e26ee920e7..73bff981f9 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -539,7 +539,7 @@ platform_toolsets: # terminal - terminal, process # file - read_file, write_file, patch, search # browser - browser_navigate, browser_snapshot, browser_click, browser_type, -# browser_scroll, browser_back, browser_press, browser_close, +# browser_scroll, browser_back, browser_press, # browser_get_images, browser_vision (requires BROWSERBASE_API_KEY) # vision - vision_analyze (requires OPENROUTER_API_KEY) # image_gen - image_generate (requires FAL_KEY) diff --git a/model_tools.py b/model_tools.py index da5ba7154e..c37007c413 100644 --- a/model_tools.py +++ b/model_tools.py @@ -211,7 +211,7 @@ _LEGACY_TOOLSET_MAP = { "browser_tools": [ "browser_navigate", "browser_snapshot", "browser_click", "browser_type", "browser_scroll", "browser_back", - "browser_press", "browser_close", "browser_get_images", + "browser_press", "browser_get_images", "browser_vision", "browser_console" ], "cronjob_tools": ["cronjob"], diff --git a/skills/dogfood/SKILL.md b/skills/dogfood/SKILL.md index 81a4ebfdeb..b7ba366395 100644 --- a/skills/dogfood/SKILL.md +++ b/skills/dogfood/SKILL.md @@ -16,7 +16,7 @@ This skill guides you through systematic exploratory QA testing of web applicati ## Prerequisites -- Browser toolset must be available (`browser_navigate`, `browser_snapshot`, `browser_click`, `browser_type`, `browser_vision`, `browser_console`, `browser_scroll`, `browser_back`, `browser_press`, `browser_close`) +- Browser toolset must be available (`browser_navigate`, `browser_snapshot`, `browser_click`, `browser_type`, `browser_vision`, `browser_console`, `browser_scroll`, `browser_back`, `browser_press`) - A target URL and testing scope from the user ## Inputs @@ -148,7 +148,6 @@ Save the report to `{output_dir}/report.md`. | `browser_press` | Press a keyboard key | | `browser_vision` | Screenshot + AI analysis; use `annotate=true` for element labels | | `browser_console` | Get JS console output and errors | -| `browser_close` | Close the browser session | ## Tips diff --git a/tests/gateway/test_api_server_toolset.py b/tests/gateway/test_api_server_toolset.py index 3b4ff254d8..943d867e61 100644 --- a/tests/gateway/test_api_server_toolset.py +++ b/tests/gateway/test_api_server_toolset.py @@ -39,7 +39,7 @@ class TestHermesApiServerToolset: tools = resolve_toolset("hermes-api-server") for tool in ["browser_navigate", "browser_snapshot", "browser_click", "browser_type", "browser_scroll", "browser_back", - "browser_press", "browser_close"]: + "browser_press"]: assert tool in tools, f"Missing browser tool: {tool}" def test_toolset_includes_homeassistant_tools(self): diff --git a/tests/tools/test_browser_cleanup.py b/tests/tools/test_browser_cleanup.py index 9dfabe6404..df21f3a0ea 100644 --- a/tests/tools/test_browser_cleanup.py +++ b/tests/tools/test_browser_cleanup.py @@ -65,18 +65,6 @@ class TestBrowserCleanup: mock_stop.assert_called_once_with("task-1") mock_run.assert_called_once_with("task-1", "close", [], timeout=10) - def test_browser_close_delegates_to_cleanup_browser(self): - import json - - browser_tool = self.browser_tool - browser_tool._active_sessions["task-2"] = {"session_name": "sess-2"} - - with patch("tools.browser_tool.cleanup_browser") as mock_cleanup: - result = json.loads(browser_tool.browser_close("task-2")) - - assert result == {"success": True, "closed": True} - mock_cleanup.assert_called_once_with("task-2") - def test_emergency_cleanup_clears_all_tracking_state(self): browser_tool = self.browser_tool browser_tool._cleanup_done = False diff --git a/tools/browser_camofox.py b/tools/browser_camofox.py index c2278f83ef..91f8fa4fdd 100644 --- a/tools/browser_camofox.py +++ b/tools/browser_camofox.py @@ -240,6 +240,25 @@ def camofox_navigate(url: str, task_id: Optional[str] = None) -> str: "Browser is visible via VNC. " "Share this link with the user so they can watch the browser live." ) + + # Auto-take a compact snapshot so the model can act immediately + try: + snap_data = _get( + f"/tabs/{session['tab_id']}/snapshot", + params={"userId": session["user_id"]}, + ) + snapshot_text = snap_data.get("snapshot", "") + from tools.browser_tool import ( + SNAPSHOT_SUMMARIZE_THRESHOLD, + _truncate_snapshot, + ) + if len(snapshot_text) > SNAPSHOT_SUMMARIZE_THRESHOLD: + snapshot_text = _truncate_snapshot(snapshot_text) + result["snapshot"] = snapshot_text + result["element_count"] = snap_data.get("refsCount", 0) + except Exception: + pass # Navigation succeeded; snapshot is a bonus + return json.dumps(result) except requests.HTTPError as e: return json.dumps({"success": False, "error": f"Navigation failed: {e}"}) diff --git a/tools/browser_tool.py b/tools/browser_tool.py index a6043e0bf3..ba2f81cf4a 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -518,7 +518,7 @@ atexit.register(_stop_browser_cleanup_thread) BROWSER_TOOL_SCHEMAS = [ { "name": "browser_navigate", - "description": "Navigate to a URL in the browser. Initializes the session and loads the page. Must be called before other browser tools. For simple information retrieval, prefer web_search or web_extract (faster, cheaper). Use browser tools when you need to interact with a page (click, fill forms, dynamic content).", + "description": "Navigate to a URL in the browser. Initializes the session and loads the page. Must be called before other browser tools. For simple information retrieval, prefer web_search or web_extract (faster, cheaper). Use browser tools when you need to interact with a page (click, fill forms, dynamic content). Returns a compact page snapshot with interactive elements and ref IDs — no need to call browser_snapshot separately after navigating.", "parameters": { "type": "object", "properties": { @@ -532,7 +532,7 @@ BROWSER_TOOL_SCHEMAS = [ }, { "name": "browser_snapshot", - "description": "Get a text-based snapshot of the current page's accessibility tree. Returns interactive elements with ref IDs (like @e1, @e2) for browser_click and browser_type. full=false (default): compact view with interactive elements. full=true: complete page content. Snapshots over 8000 chars are truncated or LLM-summarized. Requires browser_navigate first.", + "description": "Get a text-based snapshot of the current page's accessibility tree. Returns interactive elements with ref IDs (like @e1, @e2) for browser_click and browser_type. full=false (default): compact view with interactive elements. full=true: complete page content. Snapshots over 8000 chars are truncated or LLM-summarized. Requires browser_navigate first. Note: browser_navigate already returns a compact snapshot — use this to refresh after interactions that change the page, or with full=true for complete content.", "parameters": { "type": "object", "properties": { @@ -615,15 +615,7 @@ BROWSER_TOOL_SCHEMAS = [ "required": ["key"] } }, - { - "name": "browser_close", - "description": "Close the browser session and release resources. Call this when done with browser tasks to free up Browserbase session quota.", - "parameters": { - "type": "object", - "properties": {}, - "required": [] - } - }, + { "name": "browser_get_images", "description": "Get a list of all images on the current page with their URLs and alt text. Useful for finding images to analyze with the vision tool. Requires browser_navigate to be called first.", @@ -1229,7 +1221,22 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str: "Consider upgrading Browserbase plan for proxy support." ) response["stealth_features"] = active_features - + + # Auto-take a compact snapshot so the model can act immediately + # without a separate browser_snapshot call. + try: + snap_result = _run_browser_command(effective_task_id, "snapshot", ["-c"]) + if snap_result.get("success"): + snap_data = snap_result.get("data", {}) + snapshot_text = snap_data.get("snapshot", "") + refs = snap_data.get("refs", {}) + if len(snapshot_text) > SNAPSHOT_SUMMARIZE_THRESHOLD: + snapshot_text = _truncate_snapshot(snapshot_text) + response["snapshot"] = snapshot_text + response["element_count"] = len(refs) if refs else 0 + except Exception as e: + logger.debug("Auto-snapshot after navigate failed: %s", e) + return json.dumps(response, ensure_ascii=False) else: return json.dumps({ @@ -1376,31 +1383,40 @@ def browser_scroll(direction: str, task_id: Optional[str] = None) -> str: Returns: JSON string with scroll result """ - if _is_camofox_mode(): - from tools.browser_camofox import camofox_scroll - return camofox_scroll(direction, task_id) - - effective_task_id = task_id or "default" - # Validate direction if direction not in ["up", "down"]: return json.dumps({ "success": False, "error": f"Invalid direction '{direction}'. Use 'up' or 'down'." }, ensure_ascii=False) - - result = _run_browser_command(effective_task_id, "scroll", [direction]) - - if result.get("success"): - return json.dumps({ - "success": True, - "scrolled": direction - }, ensure_ascii=False) - else: - return json.dumps({ - "success": False, - "error": result.get("error", f"Failed to scroll {direction}") - }, ensure_ascii=False) + + # Repeat the scroll 5 times to get meaningful page movement. + # Most backends scroll ~100px per call, which is barely visible. + # 5x gives roughly half a viewport of travel, backend-agnostic. + _SCROLL_REPEATS = 5 + + if _is_camofox_mode(): + from tools.browser_camofox import camofox_scroll + result = None + for _ in range(_SCROLL_REPEATS): + result = camofox_scroll(direction, task_id) + return result + + effective_task_id = task_id or "default" + + result = None + for _ in range(_SCROLL_REPEATS): + result = _run_browser_command(effective_task_id, "scroll", [direction]) + if not result.get("success"): + return json.dumps({ + "success": False, + "error": result.get("error", f"Failed to scroll {direction}") + }, ensure_ascii=False) + + return json.dumps({ + "success": True, + "scrolled": direction + }, ensure_ascii=False) def browser_back(task_id: Optional[str] = None) -> str: @@ -1463,33 +1479,7 @@ def browser_press(key: str, task_id: Optional[str] = None) -> str: }, ensure_ascii=False) -def browser_close(task_id: Optional[str] = None) -> str: - """ - Close the browser session. - Args: - task_id: Task identifier for session isolation - - Returns: - JSON string with close result - """ - if _is_camofox_mode(): - from tools.browser_camofox import camofox_close - return camofox_close(task_id) - - effective_task_id = task_id or "default" - with _cleanup_lock: - had_session = effective_task_id in _active_sessions - - cleanup_browser(effective_task_id) - - response = { - "success": True, - "closed": True, - } - if not had_session: - response["warning"] = "Session may not have been active" - return json.dumps(response, ensure_ascii=False) def browser_console(clear: bool = False, expression: Optional[str] = None, task_id: Optional[str] = None) -> str: @@ -1942,7 +1932,7 @@ def cleanup_browser(task_id: Optional[str] = None) -> None: Clean up browser session for a task. Called automatically when a task completes or when inactivity timeout is reached. - Closes both the agent-browser session and the Browserbase session. + Closes both the agent-browser/Browserbase session and Camofox sessions. Args: task_id: Task identifier to clean up @@ -1950,6 +1940,14 @@ def cleanup_browser(task_id: Optional[str] = None) -> None: if task_id is None: task_id = "default" + # Also clean up Camofox session if running in Camofox mode + if _is_camofox_mode(): + try: + from tools.browser_camofox import camofox_close + camofox_close(task_id) + except Exception as e: + logger.debug("Camofox cleanup for task %s: %s", task_id, e) + logger.debug("cleanup_browser called for task_id: %s", task_id) logger.debug("Active sessions: %s", list(_active_sessions.keys())) @@ -2168,14 +2166,7 @@ registry.register( check_fn=check_browser_requirements, emoji="⌨️", ) -registry.register( - name="browser_close", - toolset="browser", - schema=_BROWSER_SCHEMA_MAP["browser_close"], - handler=lambda args, **kw: browser_close(task_id=kw.get("task_id")), - check_fn=check_browser_requirements, - emoji="🚪", -) + registry.register( name="browser_get_images", toolset="browser", diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index eb13240b15..8dbcf7c3a2 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -103,6 +103,32 @@ def _canonical_skills(skill: Optional[str] = None, skills: Optional[Any] = None) + +def _resolve_model_override(model_obj: Optional[Dict[str, Any]]) -> tuple: + """Resolve a model override object into (provider, model) for job storage. + + If provider is omitted, pins the current main provider from config so the + job doesn't drift when the user later changes their default via hermes model. + + Returns (provider_str_or_none, model_str_or_none). + """ + if not model_obj or not isinstance(model_obj, dict): + return (None, None) + model_name = (model_obj.get("model") or "").strip() or None + provider_name = (model_obj.get("provider") or "").strip() or None + if model_name and not provider_name: + # Pin to the current main provider so the job is stable + try: + from hermes_cli.config import load_config + cfg = load_config() + model_cfg = cfg.get("model", {}) + if isinstance(model_cfg, dict): + provider_name = model_cfg.get("provider") or None + except Exception: + pass # Best-effort; provider stays None + return (provider_name, model_name) + + def _normalize_optional_job_value(value: Optional[Any], *, strip_trailing_slash: bool = False) -> Optional[str]: if value is None: return None @@ -392,14 +418,9 @@ Use action='list' to inspect jobs. Use action='update', 'pause', 'resume', 'remove', or 'run' to manage an existing job. Jobs run in a fresh session with no current-chat context, so prompts must be self-contained. -If skill or skills are provided on create, the future cron run loads those skills in order, then follows the prompt as the task instruction. +If skills are provided on create, the future cron run loads those skills in order, then follows the prompt as the task instruction. On update, passing skills=[] clears attached skills. -If script is provided on create, the referenced Python script runs before each agent turn. -Its stdout is injected into the prompt as context. Use this for data collection and change -detection — the script handles gathering data, the agent analyzes and reports. -On update, pass script="" to clear an attached script. - NOTE: The agent's final response is auto-delivered to the target. Put the primary user-facing content in the final response. Cron jobs run autonomously with no user present — they cannot ask questions or request clarification. @@ -418,7 +439,7 @@ Important safety rule: cron-run sessions should not recursively schedule more cr }, "prompt": { "type": "string", - "description": "For create: the full self-contained prompt. If skill or skills are also provided, this becomes the task instruction paired with those skills." + "description": "For create: the full self-contained prompt. If skills are also provided, this becomes the task instruction paired with those skills." }, "schedule": { "type": "string", @@ -436,39 +457,30 @@ Important safety rule: cron-run sessions should not recursively schedule more cr "type": "string", "description": "Delivery target: origin, local, telegram, discord, slack, whatsapp, signal, matrix, mattermost, homeassistant, dingtalk, feishu, wecom, email, sms, or platform:chat_id or platform:chat_id:thread_id for Telegram topics. Examples: 'origin', 'local', 'telegram', 'telegram:-1001234567890:17585', 'discord:#engineering'" }, - "model": { - "type": "string", - "description": "Optional per-job model override used when the cron job runs" - }, - "provider": { - "type": "string", - "description": "Optional per-job provider override used when resolving runtime credentials" - }, - "base_url": { - "type": "string", - "description": "Optional per-job base URL override paired with provider/model routing" - }, - "include_disabled": { - "type": "boolean", - "description": "For list: include paused/completed jobs" - }, - "skill": { - "type": "string", - "description": "Optional single skill name to load before executing the cron prompt" - }, "skills": { "type": "array", "items": {"type": "string"}, - "description": "Optional ordered list of skills to load before executing the cron prompt. On update, pass an empty array to clear attached skills." + "description": "Optional ordered list of skill names to load before executing the cron prompt. On update, pass an empty array to clear attached skills." }, - "reason": { - "type": "string", - "description": "Optional pause reason" + "model": { + "type": "object", + "description": "Optional per-job model override. If provider is omitted, the current main provider is pinned at creation time so the job stays stable.", + "properties": { + "provider": { + "type": "string", + "description": "Provider name (e.g. 'openrouter', 'anthropic'). Omit to use and pin the current provider." + }, + "model": { + "type": "string", + "description": "Model name (e.g. 'anthropic/claude-sonnet-4', 'claude-sonnet-4')" + } + }, + "required": ["model"] }, "script": { "type": "string", "description": "Optional path to a Python script that runs before each cron job execution. Its stdout is injected into the prompt as context. Use for data collection and change detection. Relative paths resolve under ~/.hermes/scripts/. On update, pass empty string to clear." - } + }, }, "required": ["action"] } @@ -502,7 +514,7 @@ registry.register( name="cronjob", toolset="cronjob", schema=CRONJOB_SCHEMA, - handler=lambda args, **kw: cronjob( + handler=lambda args, **kw: (lambda _mo=_resolve_model_override(args.get("model")): cronjob( action=args.get("action", ""), job_id=args.get("job_id"), prompt=args.get("prompt"), @@ -510,16 +522,16 @@ registry.register( name=args.get("name"), repeat=args.get("repeat"), deliver=args.get("deliver"), - include_disabled=args.get("include_disabled", False), + include_disabled=args.get("include_disabled", True), skill=args.get("skill"), skills=args.get("skills"), - model=args.get("model"), - provider=args.get("provider"), + model=_mo[1], + provider=_mo[0] or args.get("provider"), base_url=args.get("base_url"), reason=args.get("reason"), script=args.get("script"), task_id=kw.get("task_id"), - ), + ))(), check_fn=check_cronjob_requirements, emoji="⏰", ) diff --git a/toolsets.py b/toolsets.py index 84c19637f9..04e43b2861 100644 --- a/toolsets.py +++ b/toolsets.py @@ -37,14 +37,12 @@ _HERMES_CORE_TOOLS = [ "read_file", "write_file", "patch", "search_files", # Vision + image generation "vision_analyze", "image_generate", - # MoA - "mixture_of_agents", # Skills "skills_list", "skill_view", "skill_manage", # Browser automation "browser_navigate", "browser_snapshot", "browser_click", "browser_type", "browser_scroll", "browser_back", - "browser_press", "browser_close", "browser_get_images", + "browser_press", "browser_get_images", "browser_vision", "browser_console", # Text-to-speech "text_to_speech", @@ -116,7 +114,7 @@ TOOLSETS = { "tools": [ "browser_navigate", "browser_snapshot", "browser_click", "browser_type", "browser_scroll", "browser_back", - "browser_press", "browser_close", "browser_get_images", + "browser_press", "browser_get_images", "browser_vision", "browser_console", "web_search" ], "includes": [] @@ -214,7 +212,7 @@ TOOLSETS = { "safe": { "description": "Safe toolkit without terminal access", - "tools": ["mixture_of_agents"], + "tools": [], "includes": ["web", "vision", "image_gen"] }, @@ -235,7 +233,7 @@ TOOLSETS = { "skills_list", "skill_view", "skill_manage", "browser_navigate", "browser_snapshot", "browser_click", "browser_type", "browser_scroll", "browser_back", - "browser_press", "browser_close", "browser_get_images", + "browser_press", "browser_get_images", "browser_vision", "browser_console", "todo", "memory", "session_search", @@ -255,14 +253,12 @@ TOOLSETS = { "read_file", "write_file", "patch", "search_files", # Vision + image generation "vision_analyze", "image_generate", - # MoA - "mixture_of_agents", # Skills "skills_list", "skill_view", "skill_manage", # Browser automation "browser_navigate", "browser_snapshot", "browser_click", "browser_type", "browser_scroll", "browser_back", - "browser_press", "browser_close", "browser_get_images", + "browser_press", "browser_get_images", "browser_vision", "browser_console", # Planning & memory "todo", "memory", diff --git a/website/docs/reference/tools-reference.md b/website/docs/reference/tools-reference.md index 5353ca5ff7..cd798697df 100644 --- a/website/docs/reference/tools-reference.md +++ b/website/docs/reference/tools-reference.md @@ -20,7 +20,6 @@ In addition to built-in tools, Hermes can load tools dynamically from MCP server |------|-------------|----------------------| | `browser_back` | Navigate back to the previous page in browser history. Requires browser_navigate to be called first. | — | | `browser_click` | Click on an element identified by its ref ID from the snapshot (e.g., '@e5'). The ref IDs are shown in square brackets in the snapshot output. Requires browser_navigate and browser_snapshot to be called first. | — | -| `browser_close` | Close the browser session and release resources. Call this when done with browser tasks to free up Browserbase session quota. | — | | `browser_console` | Get browser console output and JavaScript errors from the current page. Returns console.log/warn/error/info messages and uncaught JS exceptions. Use this to detect silent JavaScript errors, failed API calls, and application warnings. Requi… | — | | `browser_get_images` | Get a list of all images on the current page with their URLs and alt text. Useful for finding images to analyze with the vision tool. Requires browser_navigate to be called first. | — | | `browser_navigate` | Navigate to a URL in the browser. Initializes the session and loads the page. Must be called before other browser tools. For simple information retrieval, prefer web_search or web_extract (faster, cheaper). Use browser tools when you need… | — | diff --git a/website/docs/reference/toolsets-reference.md b/website/docs/reference/toolsets-reference.md index 19ff00a3f2..7d566e6024 100644 --- a/website/docs/reference/toolsets-reference.md +++ b/website/docs/reference/toolsets-reference.md @@ -52,7 +52,7 @@ Or in-session: | Toolset | Tools | Purpose | |---------|-------|---------| -| `browser` | `browser_back`, `browser_click`, `browser_close`, `browser_console`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `web_search` | Full browser automation. Includes `web_search` as a fallback for quick lookups. | +| `browser` | `browser_back`, `browser_click`, `browser_console`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `web_search` | Full browser automation. Includes `web_search` as a fallback for quick lookups. | | `clarify` | `clarify` | Ask the user a question when the agent needs clarification. | | `code_execution` | `execute_code` | Run Python scripts that call Hermes tools programmatically. | | `cronjob` | `cronjob` | Schedule and manage recurring tasks. | diff --git a/website/docs/user-guide/features/browser.md b/website/docs/user-guide/features/browser.md index 8f9fc24ebb..0dafec10b6 100644 --- a/website/docs/user-guide/features/browser.md +++ b/website/docs/user-guide/features/browser.md @@ -277,10 +277,6 @@ Check the browser console for any JavaScript errors Use `clear=True` to clear the console after reading, so subsequent calls only show new messages. -### `browser_close` - -Close the browser session and release resources. Call this when done to free up Browserbase session quota. - ## Practical Examples ### Filling Out a Web Form @@ -295,7 +291,6 @@ Agent workflow: 4. browser_type(ref="@e5", text="SecurePass123") 5. browser_click(ref="@e8") → clicks "Create Account" 6. browser_snapshot() → confirms success -7. browser_close() ``` ### Researching Dynamic Content @@ -307,7 +302,6 @@ Agent workflow: 1. browser_navigate("https://github.com/trending") 2. browser_snapshot(full=true) → reads trending repo list 3. Returns formatted results -4. browser_close() ``` ## Session Recording @@ -349,5 +343,5 @@ If paid features aren't available on your plan, Hermes automatically falls back - **Text-based interaction** — relies on accessibility tree, not pixel coordinates - **Snapshot size** — large pages may be truncated or LLM-summarized at 8000 characters - **Session timeout** — cloud sessions expire based on your provider's plan settings -- **Cost** — cloud sessions consume provider credits; use `browser_close` when done. Use `/browser connect` for free local browsing. +- **Cost** — cloud sessions consume provider credits; sessions are automatically cleaned up when the conversation ends or after inactivity. Use `/browser connect` for free local browsing. - **No file downloads** — cannot download files from the browser