Merge remote-tracking branch 'origin/main' into switch-managed-browser-to-browser-use
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
+47
-4
@@ -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~ "
|
||||
|
||||
+1
-1
@@ -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...")
|
||||
|
||||
+1
-1
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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}"})
|
||||
|
||||
+56
-57
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
+49
-37
@@ -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="⏰",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
+40
-4
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+5
-9
@@ -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",
|
||||
|
||||
@@ -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… | — |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user