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 c1e04d0a72..df5532e125 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/cli.py b/cli.py index 28800778c2..a60f699d02 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.""" @@ -8127,6 +8141,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: @@ -8240,7 +8273,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/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...") 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/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/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() 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 2ccde1d01d..faa872a933 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -514,7 +514,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": { @@ -528,7 +528,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": { @@ -1230,7 +1230,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({ @@ -1377,31 +1392,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: @@ -1464,33 +1488,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: @@ -1943,7 +1941,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 @@ -1951,6 +1949,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())) @@ -2169,14 +2175,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/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/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/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), ) 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