From ad00777f042d9c2ca23f1575ef1036a5b59d6195 Mon Sep 17 00:00:00 2001 From: EloquentBrush0x <283442588+EloquentBrush0x@users.noreply.github.com> Date: Sat, 16 May 2026 03:28:52 +0300 Subject: [PATCH 001/418] fix(mcp-oauth): print SSH tunnel hint in _redirect_handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Hermes runs on a remote host over SSH, MCP OAuth loopback flows silently fail: the OAuth provider redirects the user's browser to http://127.0.0.1:/callback, which reaches the callback server on the *remote* machine — not the local machine where the browser is running. _redirect_handler already detected SSH (via _can_open_browser) and printed "Headless environment detected — open the URL manually." but gave no guidance on how to actually reach the callback server. Users got silent timeouts or "Could not establish connection" errors. This is the same bug fixed for xAI-oauth and Spotify in #26592, which added _print_loopback_ssh_hint() in hermes_cli/auth.py. mcp_oauth.py uses the identical loopback callback pattern (http://127.0.0.1:/callback via _configure_callback_port / _wait_for_callback) but was missing the hint. Fix: when SSH_CLIENT or SSH_TTY is set and _oauth_port is available, print the ssh -N -L port-forward command and the OAuth-over-SSH guide URL to stderr, consistent with the rest of _redirect_handler's output. Tests: 4 new cases in TestRedirectHandlerSshHint covering SSH_CLIENT, SSH_TTY, local session (no hint), and missing _oauth_port (no hint). --- tests/tools/test_mcp_oauth.py | 61 +++++++++++++++++++++++++++++++++++ tools/mcp_oauth.py | 17 ++++++++++ 2 files changed, 78 insertions(+) diff --git a/tests/tools/test_mcp_oauth.py b/tests/tools/test_mcp_oauth.py index 2dfebd80b9cd..e12149a45d34 100644 --- a/tests/tools/test_mcp_oauth.py +++ b/tests/tools/test_mcp_oauth.py @@ -10,6 +10,8 @@ import pytest +import asyncio + from tools.mcp_oauth import ( HermesTokenStorage, OAuthNonInteractiveError, @@ -20,6 +22,7 @@ _is_interactive, _wait_for_callback, _make_callback_handler, + _redirect_handler, ) @@ -241,6 +244,64 @@ def test_can_open_browser_true_with_display(self, monkeypatch): assert _can_open_browser() is True +class TestRedirectHandlerSshHint: + """_redirect_handler must print an SSH tunnel hint on remote sessions.""" + + def _run(self, coro): + return asyncio.get_event_loop().run_until_complete(coro) + + def test_ssh_hint_shown_on_ssh_session(self, monkeypatch, capsys): + import tools.mcp_oauth as mco + monkeypatch.setattr(mco, "_oauth_port", 49200) + monkeypatch.setenv("SSH_CLIENT", "1.2.3.4 1234 22") + monkeypatch.delenv("SSH_TTY", raising=False) + monkeypatch.setattr(mco, "_can_open_browser", lambda: False) + + self._run(_redirect_handler("https://example.com/auth?foo=bar")) + + err = capsys.readouterr().err + assert "49200" in err + assert "ssh -N -L" in err + assert "Remote session detected" in err + + def test_ssh_hint_shown_via_ssh_tty(self, monkeypatch, capsys): + import tools.mcp_oauth as mco + monkeypatch.setattr(mco, "_oauth_port", 49201) + monkeypatch.delenv("SSH_CLIENT", raising=False) + monkeypatch.setenv("SSH_TTY", "/dev/pts/1") + monkeypatch.setattr(mco, "_can_open_browser", lambda: False) + + self._run(_redirect_handler("https://example.com/auth")) + + err = capsys.readouterr().err + assert "49201" in err + assert "ssh -N -L" in err + + def test_no_ssh_hint_on_local_session(self, monkeypatch, capsys): + import tools.mcp_oauth as mco + monkeypatch.setattr(mco, "_oauth_port", 49202) + monkeypatch.delenv("SSH_CLIENT", raising=False) + monkeypatch.delenv("SSH_TTY", raising=False) + monkeypatch.setattr(mco, "_can_open_browser", lambda: True) + monkeypatch.setattr("webbrowser.open", lambda url, **kw: True) + + self._run(_redirect_handler("https://example.com/auth")) + + err = capsys.readouterr().err + assert "ssh -N -L" not in err + + def test_no_ssh_hint_when_port_not_set(self, monkeypatch, capsys): + import tools.mcp_oauth as mco + monkeypatch.setattr(mco, "_oauth_port", None) + monkeypatch.setenv("SSH_CLIENT", "1.2.3.4 1234 22") + monkeypatch.setattr(mco, "_can_open_browser", lambda: False) + + self._run(_redirect_handler("https://example.com/auth")) + + err = capsys.readouterr().err + assert "ssh -N -L" not in err + + # --------------------------------------------------------------------------- # Path traversal protection # --------------------------------------------------------------------------- diff --git a/tools/mcp_oauth.py b/tools/mcp_oauth.py index d7bf135da47f..8d48eedf0e85 100644 --- a/tools/mcp_oauth.py +++ b/tools/mcp_oauth.py @@ -401,6 +401,23 @@ async def _redirect_handler(authorization_url: str) -> None: ) print(msg, file=sys.stderr) + # On a remote SSH session the OAuth provider redirects to + # http://127.0.0.1:/callback, which reaches the callback server on + # the *remote* machine — not the user's local machine where the browser + # opened. Print a port-forward hint so the user knows to tunnel first. + if _oauth_port and (os.getenv("SSH_CLIENT") or os.getenv("SSH_TTY")): + print( + f" Remote session detected. The OAuth provider will redirect your browser to\n" + f" http://127.0.0.1:{_oauth_port}/callback\n" + f" which the callback listener on THIS machine is waiting on. If your browser\n" + f" is on a different machine, forward the port first in a separate terminal:\n" + f"\n" + f" ssh -N -L {_oauth_port}:127.0.0.1:{_oauth_port} @\n" + f"\n" + f" Then open the URL above. See: https://hermes-agent.nousresearch.com/docs/guides/oauth-over-ssh\n", + file=sys.stderr, + ) + if _can_open_browser(): try: opened = webbrowser.open(authorization_url) From 5fba236644a9c2aa18501fdef1484e5b6fecfb85 Mon Sep 17 00:00:00 2001 From: kshitij <82637225+kshitijk4poor@users.noreply.github.com> Date: Sun, 17 May 2026 02:29:41 -0700 Subject: [PATCH 002/418] =?UTF-8?q?chore:=20ruff=20auto-fix=20PLR6201=20re?= =?UTF-8?q?sweep=20=E2=80=94=20tuple=20=E2=86=92=20set=20in=20membership?= =?UTF-8?q?=20tests=20(#27355)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six days after #23937 (608 fixes) the codebase had accumulated 241 new PLR6201 violations. Same mechanical `x in (...)` → `x in {...}` fix, same zero-risk profile: set lookup is O(1) vs O(n) for tuple and the two are semantically equivalent for hashable scalar membership tests. All 241 instances fixed via `ruff check --select PLR6201 --fix --unsafe-fixes`, zero remaining. Every changed value is a hashable scalar (str/int/None/enum/signal); no risk of unhashable runtime errors. No behavior change. Test plan: - 119 files changed, +244/-244 (net zero) — exactly one-line edits - `ruff check` clean afterward - Compile checks pass on the largest touched files (cli.py, run_agent.py, gateway/run.py, gateway/platforms/discord.py, model_tools.py) - Subset broad test run on tests/gateway/ tests/hermes_cli/ tests/agent/ tests/tools/: 18187 passed, 59 pre-existing failures (verified against origin/main with the same shape — identical failure count, identical category — all xdist test-order flakes unrelated to this change) Follows the same template as PR #23937 ([tracker: #23972](https://github.com/NousResearch/hermes-agent/issues/23972)). --- agent/lsp/client.py | 2 +- agent/lsp/install.py | 2 +- agent/lsp/manager.py | 2 +- agent/lsp/reporter.py | 2 +- agent/lsp/servers.py | 2 +- agent/transports/codex_app_server_session.py | 6 ++-- cli.py | 6 ++-- gateway/platforms/discord.py | 10 +++---- gateway/run.py | 2 +- hermes_cli/auth.py | 4 +-- hermes_cli/codex_runtime_switch.py | 4 +-- hermes_cli/dep_ensure.py | 2 +- hermes_cli/proxy/cli.py | 2 +- hermes_cli/proxy/server.py | 2 +- hermes_cli/runtime_provider.py | 2 +- hermes_cli/session_recap.py | 2 +- .../meme-generation/scripts/generate_meme.py | 4 +-- .../devops/watchers/scripts/watch_rss.py | 2 +- .../finance/stocks/scripts/stocks_client.py | 2 +- .../fitness-nutrition/scripts/body_calc.py | 6 ++-- .../scripts/openclaw_to_hermes.py | 20 ++++++------- .../telephony/scripts/telephony.py | 2 +- .../scripts/show_snapshot.py | 2 +- .../domain-intel/scripts/domain_intel.py | 2 +- .../osint-investigation/scripts/_http.py | 2 +- .../scripts/fetch_icij_offshore.py | 2 +- plugins/disk-cleanup/__init__.py | 2 +- plugins/google_meet/__init__.py | 2 +- plugins/google_meet/cli.py | 6 ++-- plugins/google_meet/meet_bot.py | 4 +-- plugins/google_meet/node/cli.py | 2 +- plugins/google_meet/realtime/openai_client.py | 2 +- plugins/google_meet/tools.py | 4 +-- plugins/kanban/dashboard/plugin_api.py | 6 ++-- plugins/memory/byterover/__init__.py | 4 +-- plugins/memory/hindsight/__init__.py | 12 ++++---- plugins/memory/honcho/__init__.py | 4 +-- plugins/memory/honcho/cli.py | 30 +++++++++---------- plugins/memory/honcho/client.py | 4 +-- plugins/memory/openviking/__init__.py | 10 +++---- plugins/memory/supermemory/__init__.py | 8 ++--- plugins/model-providers/deepseek/__init__.py | 4 +-- .../model-providers/kimi-coding/__init__.py | 2 +- plugins/platforms/google_chat/adapter.py | 10 +++---- plugins/platforms/irc/adapter.py | 14 ++++----- plugins/platforms/line/adapter.py | 8 ++--- plugins/platforms/simplex/adapter.py | 10 +++---- plugins/platforms/teams/adapter.py | 2 +- plugins/teams_pipeline/cli.py | 10 +++---- plugins/teams_pipeline/meetings.py | 4 +-- plugins/teams_pipeline/models.py | 2 +- plugins/teams_pipeline/runtime.py | 2 +- run_agent.py | 4 +-- skills/creative/comfyui/scripts/_common.py | 10 +++---- .../comfyui/scripts/extract_schema.py | 6 ++-- skills/creative/comfyui/scripts/fetch_logs.py | 2 +- .../comfyui/scripts/hardware_check.py | 2 +- .../creative/comfyui/scripts/run_workflow.py | 6 ++-- skills/creative/comfyui/scripts/ws_monitor.py | 2 +- .../comfyui/tests/test_cloud_integration.py | 2 +- .../comfyui/tests/test_extract_schema.py | 2 +- .../google-workspace/scripts/google_api.py | 2 +- .../productivity/maps/scripts/maps_client.py | 10 +++---- .../scripts/extract_marker.py | 2 +- .../scripts/extract_pymupdf.py | 2 +- skills/research/arxiv/scripts/search_arxiv.py | 2 +- .../research/polymarket/scripts/polymarket.py | 2 +- tests/agent/lsp/_mock_lsp_server.py | 2 +- .../agent/lsp/test_install_and_lint_fixes.py | 4 +-- tests/agent/test_anthropic_adapter.py | 4 +-- tests/agent/test_auxiliary_main_first.py | 2 +- tests/agent/test_context_compressor.py | 6 ++-- .../agent/test_deepseek_anthropic_thinking.py | 2 +- tests/cli/test_cli_init.py | 2 +- tests/cli/test_reasoning_command.py | 8 ++--- tests/cron/test_cron_no_agent.py | 4 +-- tests/gateway/conftest.py | 2 +- tests/gateway/test_allowlist_startup_check.py | 4 +-- tests/gateway/test_config_cwd_bridge.py | 4 +-- tests/gateway/test_discord_system_messages.py | 2 +- .../test_platform_connected_checkers.py | 4 +-- tests/gateway/test_qqbot.py | 2 +- tests/gateway/test_restart_resume_pending.py | 2 +- tests/gateway/test_session_boundary_hooks.py | 2 +- .../test_session_model_override_routing.py | 2 +- tests/gateway/test_transcript_offset.py | 2 +- tests/hermes_cli/test_auth_nous_provider.py | 2 +- tests/hermes_cli/test_cmd_update.py | 2 +- tests/hermes_cli/test_codex_runtime_switch.py | 2 +- tests/hermes_cli/test_install_cua_driver.py | 4 +-- .../test_kanban_core_functionality.py | 2 +- tests/hermes_cli/test_memory_reset.py | 4 +-- tests/hermes_cli/test_models.py | 4 +-- .../test_opencode_go_in_model_list.py | 2 +- .../hermes_cli/test_update_stale_dashboard.py | 2 +- tests/hermes_cli/test_web_server.py | 10 +++---- tests/honcho_plugin/test_session.py | 2 +- tests/plugins/test_achievements_plugin.py | 2 +- tests/plugins/video_gen/test_xai_plugin.py | 2 +- .../test_anthropic_truncation_continuation.py | 4 +-- tests/skills/test_openclaw_migration.py | 2 +- tests/stress/test_atypical_scenarios.py | 8 ++--- tests/test_live_system_guard_self_test.py | 2 +- tests/test_timezone.py | 2 +- tests/test_tui_gateway_server.py | 2 +- tests/tools/test_browser_homebrew_paths.py | 12 ++++---- tests/tools/test_code_execution_modes.py | 2 +- tests/tools/test_discord_tool.py | 2 +- tests/tools/test_hidden_dir_filter.py | 2 +- tests/tools/test_managed_modal_environment.py | 2 +- .../test_mcp_cancelled_error_propagation.py | 2 +- tests/tools/test_singularity_preflight.py | 2 +- tests/tools/test_skill_manager_tool.py | 2 +- tests/tools/test_skills_hub.py | 2 +- tests/tui_gateway/test_entry_sys_path.py | 10 +++---- tools/lazy_deps.py | 2 +- tools/mcp_tool.py | 2 +- tools/video_generation_tool.py | 4 +-- tools/x_search_tool.py | 2 +- 119 files changed, 244 insertions(+), 244 deletions(-) diff --git a/agent/lsp/client.py b/agent/lsp/client.py index 8f380fc7a60a..06a92ae351bd 100644 --- a/agent/lsp/client.py +++ b/agent/lsp/client.py @@ -232,7 +232,7 @@ async def start(self) -> None: the process is killed and the client is left in state ``"error"`` — re-call ``start()`` to retry. """ - if self._state in ("running", "starting"): + if self._state in {"running", "starting"}: return self._state = "starting" try: diff --git a/agent/lsp/install.py b/agent/lsp/install.py index 0aaa22be7441..d4a80ec195e6 100644 --- a/agent/lsp/install.py +++ b/agent/lsp/install.py @@ -151,7 +151,7 @@ def try_install(pkg: str, strategy: str = "auto") -> Optional[str]: same path (or ``None``) without reinstalling. Concurrent calls are serialized. """ - if strategy not in ("auto",): + if strategy not in {"auto",}: # Only ``auto`` triggers an actual install. In manual/off, # we still check whether the binary already exists. recipe = INSTALL_RECIPES.get(pkg, {}) diff --git a/agent/lsp/manager.py b/agent/lsp/manager.py index 7f5feaa170f3..4f16188de0b2 100644 --- a/agent/lsp/manager.py +++ b/agent/lsp/manager.py @@ -162,7 +162,7 @@ def __init__( idle_timeout: float = DEFAULT_IDLE_TIMEOUT, ) -> None: self._enabled = enabled - self._wait_mode = wait_mode if wait_mode in ("document", "full") else "document" + self._wait_mode = wait_mode if wait_mode in {"document", "full"} else "document" self._wait_timeout = wait_timeout self._install_strategy = install_strategy self._binary_overrides = binary_overrides or {} diff --git a/agent/lsp/reporter.py b/agent/lsp/reporter.py index fedad0d19b3c..0eba96ba1ff9 100644 --- a/agent/lsp/reporter.py +++ b/agent/lsp/reporter.py @@ -28,7 +28,7 @@ def format_diagnostic(d: Dict[str, Any]) -> str: col = int(start.get("character", 0)) + 1 msg = str(d.get("message") or "").rstrip() code = d.get("code") - code_part = f" [{code}]" if code not in (None, "") else "" + code_part = f" [{code}]" if code not in {None, ""} else "" source = d.get("source") source_part = f" ({source})" if source else "" return f"{sev} [{line}:{col}] {msg}{code_part}{source_part}" diff --git a/agent/lsp/servers.py b/agent/lsp/servers.py index 00ad4c400056..144b5cb2c111 100644 --- a/agent/lsp/servers.py +++ b/agent/lsp/servers.py @@ -237,7 +237,7 @@ def _spawn_pyright(root: str, ctx: ServerContext) -> Optional[SpawnSpec]: return None # If we got the cli ``pyright``, the langserver is its sibling. base = os.path.basename(bin_path) - if base in ("pyright", "pyright.exe"): + if base in {"pyright", "pyright.exe"}: sibling = os.path.join(os.path.dirname(bin_path), "pyright-langserver") if os.path.exists(sibling): bin_path = sibling diff --git a/agent/transports/codex_app_server_session.py b/agent/transports/codex_app_server_session.py index f0cd0a196c46..a72599ae7197 100644 --- a/agent/transports/codex_app_server_session.py +++ b/agent/transports/codex_app_server_session.py @@ -541,7 +541,7 @@ def run_turn( turn_status = ( (note.get("params") or {}).get("turn") or {} ).get("status") - if turn_status and turn_status not in ("completed", "interrupted"): + if turn_status and turn_status not in {"completed", "interrupted"}: err_obj = ( (note.get("params") or {}).get("turn") or {} ).get("error") @@ -775,9 +775,9 @@ def _approval_choice_to_codex_decision(choice: str) -> str: (verified against codex-rs/app-server-protocol/src/protocol/v2/item.rs on codex 0.130.0). """ - if choice in ("once",): + if choice in {"once",}: return "accept" - if choice in ("session", "always"): + if choice in {"session", "always"}: return "acceptForSession" return "decline" diff --git a/cli.py b/cli.py index 42b1482578e3..e8e38965f537 100644 --- a/cli.py +++ b/cli.py @@ -1396,7 +1396,7 @@ def _detect_light_mode() -> bool: last = cfgbg.split(";")[-1] if ";" in cfgbg else cfgbg if last.isdigit(): bg = int(last) - if bg in (7, 15): + if bg in {7, 15}: result = True _LIGHT_MODE_CACHE = result return result @@ -7706,7 +7706,7 @@ def process_command(self, command: str) -> bool: # google-gemini/gemini-cli#19332. _rest = cmd_original.split(None, 1) _args = (_rest[1] if len(_rest) > 1 else "").strip().lower() - if _args in ("--delete", "-d"): + if _args in {"--delete", "-d"}: self._delete_session_on_exit = True elif _args: _cprint(f" {_DIM}✗ Unknown argument: {_escape(_args)}. Use /exit --delete to also remove session history.{_RST}") @@ -13835,7 +13835,7 @@ def new_event_loop(self): if _errno == errno.EIO: pass # suppress broken-stdout I/O errors on interrupt (#13710) elif ( - _errno in (errno.EINVAL, errno.EBADF) + _errno in {errno.EINVAL, errno.EBADF} or "is not registered" in _msg or "Bad file descriptor" in _msg or "Invalid argument" in _msg diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 9b8285e2a362..f79678bc61ae 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -3639,18 +3639,18 @@ def _discord_thread_require_mention(self) -> bool: configured = self.config.extra.get("thread_require_mention") if configured is not None: if isinstance(configured, str): - return configured.lower() not in ("false", "0", "no", "off") + return configured.lower() not in {"false", "0", "no", "off"} return bool(configured) - return os.getenv("DISCORD_THREAD_REQUIRE_MENTION", "false").lower() in ("true", "1", "yes", "on") + return os.getenv("DISCORD_THREAD_REQUIRE_MENTION", "false").lower() in {"true", "1", "yes", "on"} def _discord_history_backfill(self) -> bool: """Return whether history backfill is enabled for shared sessions.""" configured = self.config.extra.get("history_backfill") if configured is not None: if isinstance(configured, str): - return configured.lower() not in ("false", "0", "no", "off") + return configured.lower() not in {"false", "0", "no", "off"} return bool(configured) - return os.getenv("DISCORD_HISTORY_BACKFILL", "true").lower() in ("true", "1", "yes") + return os.getenv("DISCORD_HISTORY_BACKFILL", "true").lower() in {"true", "1", "yes"} def _discord_history_backfill_limit(self) -> int: """Return the max number of messages to scan backwards for context. @@ -3737,7 +3737,7 @@ async def _fetch_channel_context( break # Skip system messages (pins, joins, thread renames, etc.) - if msg.type not in (discord.MessageType.default, discord.MessageType.reply): + if msg.type not in {discord.MessageType.default, discord.MessageType.reply}: continue # Respect DISCORD_ALLOW_BOTS for other bots. diff --git a/gateway/run.py b/gateway/run.py index 81ce914b8abf..db7066281c3a 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -8863,7 +8863,7 @@ def _resolve_platform(name: str): lines.append("Failed/paused: (none)") return "\n".join(lines) - if action in ("pause", "resume"): + if action in {"pause", "resume"}: if not target: return f"Usage: /platform {action} " platform = _resolve_platform(target) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 6cabb61570d7..6752b65829f7 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -2610,7 +2610,7 @@ def _print_loopback_ssh_hint(redirect_uri: str, *, docs_url: str | None = None) return host = parsed.hostname or "" port = parsed.port - if host not in ("127.0.0.1", "::1", "localhost") or not port: + if host not in {"127.0.0.1", "::1", "localhost"} or not port: return print() print("Remote session detected. Your browser will redirect to") @@ -5246,7 +5246,7 @@ def _login_xai_oauth( reuse = input("Use existing credentials? [Y/n]: ").strip().lower() except (EOFError, KeyboardInterrupt): reuse = "y" - if reuse in ("", "y", "yes"): + if reuse in {"", "y", "yes"}: config_path = _update_config_for_provider( "xai-oauth", existing.get("base_url", DEFAULT_XAI_OAUTH_BASE_URL), diff --git a/hermes_cli/codex_runtime_switch.py b/hermes_cli/codex_runtime_switch.py index b3adda12b545..98b40b1e8f24 100644 --- a/hermes_cli/codex_runtime_switch.py +++ b/hermes_cli/codex_runtime_switch.py @@ -48,9 +48,9 @@ def parse_args(arg_string: str) -> tuple[Optional[str], list[str]]: if not raw: return None, [] # Accept human-friendly synonyms - if raw in ("on", "codex", "enable"): + if raw in {"on", "codex", "enable"}: return "codex_app_server", [] - if raw in ("off", "default", "disable", "hermes"): + if raw in {"off", "default", "disable", "hermes"}: return "auto", [] if raw in VALID_RUNTIMES: return raw, [] diff --git a/hermes_cli/dep_ensure.py b/hermes_cli/dep_ensure.py index 3312726c36d1..1067b428f7b0 100644 --- a/hermes_cli/dep_ensure.py +++ b/hermes_cli/dep_ensure.py @@ -91,7 +91,7 @@ def ensure_dependency(dep: str, interactive: bool = True) -> bool: reply = input(f"{desc} is not installed. Install now? [Y/n] ").strip().lower() except (EOFError, KeyboardInterrupt): return False - if reply not in ("", "y", "yes"): + if reply not in {"", "y", "yes"}: return False result = subprocess.run( diff --git a/hermes_cli/proxy/cli.py b/hermes_cli/proxy/cli.py index 83c2d34035b6..c35b14f78352 100644 --- a/hermes_cli/proxy/cli.py +++ b/hermes_cli/proxy/cli.py @@ -114,7 +114,7 @@ def cmd_proxy(args: Any) -> int: return cmd_proxy_start(args) if sub == "status": return cmd_proxy_status(args) - if sub in ("providers", "list"): + if sub in {"providers", "list"}: return cmd_proxy_list_providers(args) # No subcommand → print short help. print( diff --git a/hermes_cli/proxy/server.py b/hermes_cli/proxy/server.py index 48de784afe4f..fa497f132918 100644 --- a/hermes_cli/proxy/server.py +++ b/hermes_cli/proxy/server.py @@ -76,7 +76,7 @@ def _filter_response_headers(headers) -> dict: if key.lower() in _HOP_BY_HOP_HEADERS: continue # aiohttp recomputes Content-Encoding/Content-Length on stream — let it. - if key.lower() in ("content-encoding", "content-length"): + if key.lower() in {"content-encoding", "content-length"}: continue out[key] = value return out diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index c0baf14db924..c186f1d6e7c1 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -209,7 +209,7 @@ def _maybe_apply_codex_app_server_runtime( Returns the (possibly-rewritten) api_mode.""" if not model_cfg: return api_mode - if provider not in ("openai", "openai-codex"): + if provider not in {"openai", "openai-codex"}: return api_mode runtime = str(model_cfg.get("openai_runtime") or "").strip().lower() if runtime == "codex_app_server": diff --git a/hermes_cli/session_recap.py b/hermes_cli/session_recap.py index d67f737d7998..111da117485b 100644 --- a/hermes_cli/session_recap.py +++ b/hermes_cli/session_recap.py @@ -171,7 +171,7 @@ def _recent_window( cut = 0 for i in range(len(messages) - 1, -1, -1): msg = messages[i] - if isinstance(msg, Mapping) and msg.get("role") in ("user", "assistant"): + if isinstance(msg, Mapping) and msg.get("role") in {"user", "assistant"}: count += 1 if count >= window: cut = i diff --git a/optional-skills/creative/meme-generation/scripts/generate_meme.py b/optional-skills/creative/meme-generation/scripts/generate_meme.py index 288c38383677..807fee711650 100644 --- a/optional-skills/creative/meme-generation/scripts/generate_meme.py +++ b/optional-skills/creative/meme-generation/scripts/generate_meme.py @@ -358,7 +358,7 @@ def generate_meme(template_id: str, texts: list[str], output_path: str) -> str: img = _overlay_on_image(img, texts, fields) output = Path(output_path) - if output.suffix.lower() in (".jpg", ".jpeg"): + if output.suffix.lower() in {".jpg", ".jpeg"}: img = img.convert("RGB") img.save(str(output), quality=95) return str(output) @@ -378,7 +378,7 @@ def generate_from_image( result = _overlay_on_image(img, texts, fields) output = Path(output_path) - if output.suffix.lower() in (".jpg", ".jpeg"): + if output.suffix.lower() in {".jpg", ".jpeg"}: result = result.convert("RGB") result.save(str(output), quality=95) return str(output) diff --git a/optional-skills/devops/watchers/scripts/watch_rss.py b/optional-skills/devops/watchers/scripts/watch_rss.py index cc729f91b139..6e09630404f9 100755 --- a/optional-skills/devops/watchers/scripts/watch_rss.py +++ b/optional-skills/devops/watchers/scripts/watch_rss.py @@ -43,7 +43,7 @@ def _parse_feed(xml_bytes: bytes): entries = [] for item in root.iter(): tag = _strip_ns(item.tag) - if tag not in ("item", "entry"): + if tag not in {"item", "entry"}: continue # ElementTree Elements without children are *falsy* — use `is not None`. children = {_strip_ns(c.tag): c for c in item} diff --git a/optional-skills/finance/stocks/scripts/stocks_client.py b/optional-skills/finance/stocks/scripts/stocks_client.py index 7b98fd9dc669..c0bf97dce4ac 100755 --- a/optional-skills/finance/stocks/scripts/stocks_client.py +++ b/optional-skills/finance/stocks/scripts/stocks_client.py @@ -125,7 +125,7 @@ def fetch_url(url: str, headers: dict | None = None, retries: int = MAX_RETRIES) return json.loads(raw.decode("utf-8", errors="replace")) except urllib.error.HTTPError as e: last_err = e - if e.code in (404, 400): + if e.code in {404, 400}: break # no point retrying wait = BACKOFF_BASE ** attempt time.sleep(wait) diff --git a/optional-skills/health/fitness-nutrition/scripts/body_calc.py b/optional-skills/health/fitness-nutrition/scripts/body_calc.py index 2d07129cecc6..2ce65fd336e7 100644 --- a/optional-skills/health/fitness-nutrition/scripts/body_calc.py +++ b/optional-skills/health/fitness-nutrition/scripts/body_calc.py @@ -95,11 +95,11 @@ def one_rep_max(weight, reps): def macros(tdee_kcal, goal): goal = goal.lower() - if goal in ("cut", "lose", "deficit"): + if goal in {"cut", "lose", "deficit"}: cals = tdee_kcal - 500 p, f, c = 0.40, 0.30, 0.30 label = "Fat Loss (-500 kcal)" - elif goal in ("bulk", "gain", "surplus"): + elif goal in {"bulk", "gain", "surplus"}: cals = tdee_kcal + 400 p, f, c = 0.30, 0.25, 0.45 label = "Lean Bulk (+400 kcal)" @@ -184,7 +184,7 @@ def main(): int(sys.argv[4]), sys.argv[5], int(sys.argv[6]), ) - elif cmd in ("1rm", "orm"): + elif cmd in {"1rm", "orm"}: one_rep_max(float(sys.argv[2]), int(sys.argv[3])) elif cmd == "macros": diff --git a/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py b/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py index 6ebb1d754005..d9d53a97a240 100644 --- a/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py +++ b/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py @@ -610,7 +610,7 @@ def _is_secret_key(key: str) -> bool: normalized = _normalize_secret_key(key) if normalized == "token" or normalized.endswith("token"): return True - if normalized in ("auth", "authorization"): + if normalized in {"auth", "authorization"}: return True return any(marker in normalized for marker in _SECRET_KEY_MARKERS) @@ -831,7 +831,7 @@ def record( # Flip the config-block flag when a conflict/error occurs on a # config.yaml write. Later config-mutating options will skip rather # than attempting a partial write. - if status in (STATUS_CONFLICT, STATUS_ERROR) and destination is not None: + if status in {STATUS_CONFLICT, STATUS_ERROR} and destination is not None: dest_str = str(destination) if dest_str.endswith("config.yaml") or dest_str.endswith("config.yml"): self._config_apply_blocked = True @@ -1526,7 +1526,7 @@ def migrate_provider_keys(self, config: Dict[str, Any]) -> None: api_key = resolve_secret_input(raw_key, openclaw_env) if not api_key: # Warn if a SecretRef with file/exec source was silently unresolvable - if isinstance(raw_key, dict) and raw_key.get("source") in ("file", "exec"): + if isinstance(raw_key, dict) and raw_key.get("source") in {"file", "exec"}: self.record( "provider-keys", self.source_root / "openclaw.json", @@ -1736,7 +1736,7 @@ def migrate_tts_config(self, config: Optional[Dict[str, Any]] = None) -> None: tts_data: Dict[str, Any] = {} provider = tts.get("provider") - if isinstance(provider, str) and provider in ("elevenlabs", "openai", "edge", "microsoft"): + if isinstance(provider, str) and provider in {"elevenlabs", "openai", "edge", "microsoft"}: # OpenClaw renamed "edge" to "microsoft"; Hermes still uses "edge" tts_data["provider"] = "edge" if provider == "microsoft" else provider @@ -2304,11 +2304,11 @@ def migrate_agent_config(self, config: Optional[Dict[str, Any]] = None) -> None: if defaults.get("thinkingDefault"): # Map OpenClaw thinking -> Hermes reasoning_effort thinking = defaults["thinkingDefault"] - if thinking in ("always", "high", "xhigh"): + if thinking in {"always", "high", "xhigh"}: agent_cfg["reasoning_effort"] = "high" - elif thinking in ("auto", "medium", "adaptive"): + elif thinking in {"auto", "medium", "adaptive"}: agent_cfg["reasoning_effort"] = "medium" - elif thinking in ("off", "low", "none", "minimal"): + elif thinking in {"off", "low", "none", "minimal"}: agent_cfg["reasoning_effort"] = "low" changes = True @@ -2626,8 +2626,8 @@ def migrate_deep_channels(self, config: Optional[Dict[str, Any]] = None) -> None if not isinstance(ch_cfg, dict): continue complex_keys = {k: v for k, v in ch_cfg.items() - if k not in ("botToken", "appToken", "allowFrom", "enabled") - and v and k not in ("requireMention", "autoThread")} + if k not in {"botToken", "appToken", "allowFrom", "enabled"} + and v and k not in {"requireMention", "autoThread"}} if complex_keys: complex_archive[ch_name] = complex_keys @@ -2671,7 +2671,7 @@ def migrate_browser_config(self, config: Optional[Dict[str, Any]] = None) -> Non # Archive remaining browser settings advanced = {k: v for k, v in browser.items() - if k not in ("cdpUrl", "headless") and v} + if k not in {"cdpUrl", "headless"} and v} if advanced and self.archive_dir: if self.execute: self.archive_dir.mkdir(parents=True, exist_ok=True) diff --git a/optional-skills/productivity/telephony/scripts/telephony.py b/optional-skills/productivity/telephony/scripts/telephony.py index c9233647f3f5..188b6be2ad9b 100644 --- a/optional-skills/productivity/telephony/scripts/telephony.py +++ b/optional-skills/productivity/telephony/scripts/telephony.py @@ -109,7 +109,7 @@ def _config_lookup(*paths: tuple[str, ...], default: str = "") -> str: node = None break node = node.get(key) - if node not in (None, "") and not isinstance(node, dict): + if node not in {None, ""} and not isinstance(node, dict): return str(node) return default diff --git a/optional-skills/research/darwinian-evolver/scripts/show_snapshot.py b/optional-skills/research/darwinian-evolver/scripts/show_snapshot.py index 10e3a03dca9d..5dd559570dd6 100644 --- a/optional-skills/research/darwinian-evolver/scripts/show_snapshot.py +++ b/optional-skills/research/darwinian-evolver/scripts/show_snapshot.py @@ -51,7 +51,7 @@ def main() -> int: field = args.field if field is None: for k, v in vars(org).items(): - if isinstance(v, str) and not k.startswith("_") and k not in ("id",): + if isinstance(v, str) and not k.startswith("_") and k not in {"id",}: field = k break val = getattr(org, field, None) if field else None diff --git a/optional-skills/research/domain-intel/scripts/domain_intel.py b/optional-skills/research/domain-intel/scripts/domain_intel.py index 1a69f6528f21..c25e9286d404 100644 --- a/optional-skills/research/domain-intel/scripts/domain_intel.py +++ b/optional-skills/research/domain-intel/scripts/domain_intel.py @@ -185,7 +185,7 @@ def whois_lookup(domain): for key, pat in patterns.items(): matches = re.findall(pat, raw, re.IGNORECASE) if matches: - if key in ("name_servers", "status"): + if key in {"name_servers", "status"}: result[key] = list(dict.fromkeys(m.strip().lower() for m in matches)) else: result[key] = matches[0].strip() diff --git a/optional-skills/research/osint-investigation/scripts/_http.py b/optional-skills/research/osint-investigation/scripts/_http.py index 5da62310b9fe..0936548a92ab 100644 --- a/optional-skills/research/osint-investigation/scripts/_http.py +++ b/optional-skills/research/osint-investigation/scripts/_http.py @@ -60,7 +60,7 @@ def get( f"HTTP 429 rate-limited by {urllib.parse.urlsplit(url).netloc}. " f"Slow down or supply a real API key. Body: {body[:300]}" ) from e - if e.code in (500, 502, 503, 504) and attempt < max_retries: + if e.code in {500, 502, 503, 504} and attempt < max_retries: retry_after = e.headers.get("Retry-After") if e.headers else None wait = float(retry_after) if (retry_after and retry_after.isdigit()) else backoff ** (attempt + 1) time.sleep(wait) diff --git a/optional-skills/research/osint-investigation/scripts/fetch_icij_offshore.py b/optional-skills/research/osint-investigation/scripts/fetch_icij_offshore.py index 8d050b62bf1b..3108681e20c8 100644 --- a/optional-skills/research/osint-investigation/scripts/fetch_icij_offshore.py +++ b/optional-skills/research/osint-investigation/scripts/fetch_icij_offshore.py @@ -122,7 +122,7 @@ def fetch( with zipfile.ZipFile(zip_path) as zf: for node_type, csv_substring in targets: - relevant_needles = [n for (k, n) in needles if k in (node_type, "Entity", "Officer")] or [] + relevant_needles = [n for (k, n) in needles if k in {node_type, "Entity", "Officer"}] or [] # Only scan a CSV if we have a needle that could plausibly match it, # or if we have ONLY a jurisdiction filter. applicable_needles = [n for (k, n) in needles if k == node_type] diff --git a/plugins/disk-cleanup/__init__.py b/plugins/disk-cleanup/__init__.py index 0a4b6c7ae164..71d44b1c8916 100644 --- a/plugins/disk-cleanup/__init__.py +++ b/plugins/disk-cleanup/__init__.py @@ -222,7 +222,7 @@ def _fmt_summary(summary: Dict[str, Any]) -> str: def _handle_slash(raw_args: str) -> Optional[str]: argv = raw_args.strip().split() - if not argv or argv[0] in ("help", "-h", "--help"): + if not argv or argv[0] in {"help", "-h", "--help"}: return _HELP_TEXT sub = argv[0] diff --git a/plugins/google_meet/__init__.py b/plugins/google_meet/__init__.py index feca75667b5c..df401e1a680b 100644 --- a/plugins/google_meet/__init__.py +++ b/plugins/google_meet/__init__.py @@ -72,7 +72,7 @@ def register(ctx) -> None: # tested path there and guest-join Chromium is flakier. Refuse to register # rather than half-working. system = platform.system().lower() - if system not in ("linux", "darwin"): + if system not in {"linux", "darwin"}: logger.info( "google_meet plugin: platform=%s not supported (linux/macos only)", system, diff --git a/plugins/google_meet/cli.py b/plugins/google_meet/cli.py index b7d8097fc762..0e9b08881b35 100644 --- a/plugins/google_meet/cli.py +++ b/plugins/google_meet/cli.py @@ -159,7 +159,7 @@ def _cmd_setup() -> int: print("---------------------") system = _p.system() - system_ok = system in ("Linux", "Darwin") + system_ok = system in {"Linux", "Darwin"} print(f" platform : {system} [{'ok' if system_ok else 'unsupported'}]") try: @@ -231,7 +231,7 @@ def _cmd_install(*, realtime: bool, assume_yes: bool) -> int: import subprocess as _sp system = _p.system() - if system not in ("Linux", "Darwin"): + if system not in {"Linux", "Darwin"}: print(f"google_meet install: {system} is not supported (linux/macos only)") return 1 @@ -242,7 +242,7 @@ def _confirm(prompt: str) -> bool: ans = input(f"{prompt} [y/N] ").strip().lower() except EOFError: return False - return ans in ("y", "yes") + return ans in {"y", "yes"} print("google_meet install") print("-------------------") diff --git a/plugins/google_meet/meet_bot.py b/plugins/google_meet/meet_bot.py index eb9318ae4a57..9040d9a789a4 100644 --- a/plugins/google_meet/meet_bot.py +++ b/plugins/google_meet/meet_bot.py @@ -447,7 +447,7 @@ def _mac_audio_device_index(device_name: str) -> str: def run_bot() -> int: # noqa: C901 — orchestration, explicit branches url = os.environ.get("HERMES_MEET_URL", "").strip() out_dir_env = os.environ.get("HERMES_MEET_OUT_DIR", "").strip() - headed = os.environ.get("HERMES_MEET_HEADED", "").lower() in ("1", "true", "yes") + headed = os.environ.get("HERMES_MEET_HEADED", "").lower() in {"1", "true", "yes"} auth_state = os.environ.get("HERMES_MEET_AUTH_STATE", "").strip() guest_name = os.environ.get("HERMES_MEET_GUEST_NAME", "Hermes Agent") duration_s = _parse_duration(os.environ.get("HERMES_MEET_DURATION", "")) @@ -808,7 +808,7 @@ def _looks_like_human_speaker(speaker: str, bot_guest_name: str) -> bool: if not speaker or not speaker.strip(): return False spk = speaker.strip().lower() - if spk in ("unknown", "you", bot_guest_name.strip().lower()): + if spk in {"unknown", "you", bot_guest_name.strip().lower()}: return False return True diff --git a/plugins/google_meet/node/cli.py b/plugins/google_meet/node/cli.py index 4e10161e0ccb..255b851ba6a7 100644 --- a/plugins/google_meet/node/cli.py +++ b/plugins/google_meet/node/cli.py @@ -103,7 +103,7 @@ def node_command(args: argparse.Namespace) -> int: print(f"removed {args.name!r}" if ok else f"no such node: {args.name!r}") return 0 if ok else 1 - if cmd in ("status", "ping"): + if cmd in {"status", "ping"}: entry = reg.get(args.name) if entry is None: print(f"no such node: {args.name!r}", file=sys.stderr) diff --git a/plugins/google_meet/realtime/openai_client.py b/plugins/google_meet/realtime/openai_client.py index e9738d106ae3..24527603e524 100644 --- a/plugins/google_meet/realtime/openai_client.py +++ b/plugins/google_meet/realtime/openai_client.py @@ -183,7 +183,7 @@ def speak(self, text: str, timeout: float = 30.0) -> dict: rid = (frame.get("response") or {}).get("id") if rid: self._last_response_id = rid - elif ftype in ("response.done", "response.completed", "response.cancelled"): + elif ftype in {"response.done", "response.completed", "response.cancelled"}: break elif ftype == "error": err = frame.get("error") or frame diff --git a/plugins/google_meet/tools.py b/plugins/google_meet/tools.py index 9af804288c7f..034116b88af8 100644 --- a/plugins/google_meet/tools.py +++ b/plugins/google_meet/tools.py @@ -36,7 +36,7 @@ def check_meet_requirements() -> bool: handlers relax the requirement when a node is addressed. """ import platform as _p - if _p.system().lower() not in ("linux", "darwin"): + if _p.system().lower() not in {"linux", "darwin"}: return False try: import playwright # noqa: F401 @@ -238,7 +238,7 @@ def handle_meet_join(args: Dict[str, Any], **_kw) -> str: if not url: return _err("url is required") mode = (args.get("mode") or "transcribe").strip().lower() - if mode not in ("transcribe", "realtime"): + if mode not in {"transcribe", "realtime"}: return _err(f"mode must be 'transcribe' or 'realtime' (got {mode!r})") node = args.get("node") diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index 7b0cb1d791a7..08824e3807b2 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -628,7 +628,7 @@ def update_task(task_id: str, payload: UpdateTaskBody, board: Optional[str] = Qu status_code=400, detail="Cannot set status to 'running' directly; use the dispatcher/claim path", ) - elif s in ("todo", "triage"): + elif s in {"todo", "triage"}: ok = _set_status_direct(conn, task_id, s) else: raise HTTPException(status_code=400, detail=f"unknown status: {s}") @@ -742,7 +742,7 @@ def _set_status_direct( (task_id, run_id, json.dumps({"status": new_status}), int(time.time())), ) # If we re-opened something, children may have gone stale. - if new_status in ("done", "ready"): + if new_status in {"done", "ready"}: kanban_db.recompute_ready(conn) return True @@ -868,7 +868,7 @@ def bulk_update(payload: BulkTaskBody, board: Optional[str] = Query(None)): ok = kanban_db.unblock_task(conn, tid) else: ok = _set_status_direct(conn, tid, "ready") - elif s in ("todo", "running", "triage"): + elif s in {"todo", "running", "triage"}: ok = _set_status_direct(conn, tid, s) else: entry.update(ok=False, error=f"unknown status {s!r}") diff --git a/plugins/memory/byterover/__init__.py b/plugins/memory/byterover/__init__.py index 1870e9ab865e..eafd9b2cfe5f 100644 --- a/plugins/memory/byterover/__init__.py +++ b/plugins/memory/byterover/__init__.py @@ -263,7 +263,7 @@ def _sync(): def on_memory_write(self, action: str, target: str, content: str) -> None: """Mirror built-in memory writes to ByteRover.""" - if action not in ("add", "replace") or not content: + if action not in {"add", "replace"} or not content: return def _write(): @@ -289,7 +289,7 @@ def on_pre_compress(self, messages: List[Dict[str, Any]]) -> str: for msg in messages[-10:]: # last 10 messages role = msg.get("role", "") content = msg.get("content", "") - if isinstance(content, str) and content.strip() and role in ("user", "assistant"): + if isinstance(content, str) and content.strip() and role in {"user", "assistant"}: parts.append(f"{role}: {content[:500]}") if not parts: diff --git a/plugins/memory/hindsight/__init__.py b/plugins/memory/hindsight/__init__.py index 52b1ac247f17..40772f79d8a0 100644 --- a/plugins/memory/hindsight/__init__.py +++ b/plugins/memory/hindsight/__init__.py @@ -416,7 +416,7 @@ def _build_embedded_profile_env(config: dict[str, Any], *, llm_api_key: str | No current_base_url = config.get("llm_base_url") or os.environ.get("HINDSIGHT_API_LLM_BASE_URL", "") # The embedded daemon expects OpenAI wire format for these providers. - daemon_provider = "openai" if current_provider in ("openai_compatible", "openrouter") else current_provider + daemon_provider = "openai" if current_provider in {"openai_compatible", "openrouter"} else current_provider env_values = { "HINDSIGHT_API_LLM_PROVIDER": str(daemon_provider), @@ -596,7 +596,7 @@ def is_available(self) -> bool: try: cfg = _load_config() mode = cfg.get("mode", "cloud") - if mode in ("local", "local_embedded"): + if mode in {"local", "local_embedded"}: available, _ = _check_local_runtime() return available if mode == "local_external": @@ -888,7 +888,7 @@ def _get_client(self): from hindsight import HindsightEmbedded HindsightEmbedded.__del__ = lambda self: None llm_provider = self._config.get("llm_provider", "") - if llm_provider in ("openai_compatible", "openrouter"): + if llm_provider in {"openai_compatible", "openrouter"}: llm_provider = "openai" logger.debug("Creating HindsightEmbedded client (profile=%s, provider=%s)", self._config.get("profile", "hermes"), llm_provider) @@ -1132,7 +1132,7 @@ def initialize(self, session_id: str, **kwargs) -> None: self._mode = "disabled" return self._api_key = self._config.get("apiKey") or self._config.get("api_key") or os.environ.get("HINDSIGHT_API_KEY", "") - default_url = _DEFAULT_LOCAL_URL if self._mode in ("local_embedded", "local_external") else _DEFAULT_API_URL + default_url = _DEFAULT_LOCAL_URL if self._mode in {"local_embedded", "local_external"} else _DEFAULT_API_URL self._api_url = self._config.get("api_url") or os.environ.get("HINDSIGHT_API_URL", default_url) self._llm_base_url = self._config.get("llm_base_url", "") @@ -1152,10 +1152,10 @@ def initialize(self, session_id: str, **kwargs) -> None: self._budget = budget if budget in _VALID_BUDGETS else "mid" memory_mode = self._config.get("memory_mode", "hybrid") - self._memory_mode = memory_mode if memory_mode in ("context", "tools", "hybrid") else "hybrid" + self._memory_mode = memory_mode if memory_mode in {"context", "tools", "hybrid"} else "hybrid" prefetch_method = self._config.get("recall_prefetch_method") or self._config.get("prefetch_method", "recall") - self._prefetch_method = prefetch_method if prefetch_method in ("recall", "reflect") else "recall" + self._prefetch_method = prefetch_method if prefetch_method in {"recall", "reflect"} else "recall" # Bank options self._bank_mission = self._config.get("bank_mission", "") diff --git a/plugins/memory/honcho/__init__.py b/plugins/memory/honcho/__init__.py index d97f459acef6..efbba937a4de 100644 --- a/plugins/memory/honcho/__init__.py +++ b/plugins/memory/honcho/__init__.py @@ -283,7 +283,7 @@ def initialize(self, session_id: str, **kwargs) -> None: # ----- Port #4053: cron guard ----- agent_context = kwargs.get("agent_context", "") platform = kwargs.get("platform", "cli") - if agent_context in ("cron", "flush") or platform == "cron": + if agent_context in {"cron", "flush"} or platform == "cron": logger.debug("Honcho skipped: cron/flush context (agent_context=%s, platform=%s)", agent_context, platform) self._cron_skipped = True @@ -404,7 +404,7 @@ def _do_session_init(self, cfg, session_id: str, **kwargs) -> None: # pop_context_result() in prefetch(). Dialectic prewarm runs the # full configured depth and writes into _prefetch_result so turn 1 # consumes the result directly. - if self._recall_mode in ("context", "hybrid"): + if self._recall_mode in {"context", "hybrid"}: try: self._manager.prefetch_context(self._session_key) except Exception as e: diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py index 402389ab962f..28f213a1a660 100644 --- a/plugins/memory/honcho/cli.py +++ b/plugins/memory/honcho/cli.py @@ -233,7 +233,7 @@ def sync_honcho_profiles_quiet() -> int: def _host_key() -> str: """Return the active Honcho host key, derived from the current Hermes profile.""" if _profile_override: - if _profile_override in ("default", "custom"): + if _profile_override in {"default", "custom"}: return HOST return f"{HOST}.{_profile_override}" return resolve_active_host() @@ -295,13 +295,13 @@ def _resolve_api_key(cfg: dict) -> str: parsed = urlparse(base_url) except (TypeError, ValueError): parsed = None - if parsed and parsed.scheme in ("http", "https") and parsed.netloc: + if parsed and parsed.scheme in {"http", "https"} and parsed.netloc: return "local" # Schemeless but looks like a host (contains '.' or ':' and isn't # a boolean literal): let it through so legacy configs don't # regress into "no API key configured" when they previously worked. lowered = base_url.lower() - if lowered not in ("true", "false", "none", "null") and any( + if lowered not in {"true", "false", "none", "null"} and any( c in base_url for c in ".:" ) and not base_url.isdigit(): return "local" @@ -334,7 +334,7 @@ def _ensure_sdk_installed() -> bool: print(" honcho-ai is not installed.") answer = _prompt("Install it now? (honcho-ai>=2.0.1)", default="y") - if answer.lower() not in ("y", "yes"): + if answer.lower() not in {"y", "yes"}: print(" Skipping install. Run: pip install 'honcho-ai>=2.0.1'\n") return False @@ -382,7 +382,7 @@ def cmd_setup(args) -> None: for h in ("localhost", "127.0.0.1", "::1") ) else "cloud" deploy = _prompt("Cloud or local?", default=current_deploy) - is_local = deploy.lower() in ("local", "l") + is_local = deploy.lower() in {"local", "l"} # Clean up legacy snake_case key cfg.pop("base_url", None) @@ -441,7 +441,7 @@ def cmd_setup(args) -> None: print(" directional -- all observations on, each AI peer builds its own view (default)") print(" unified -- shared pool, user observes self, AI observes others only") new_obs = _prompt("Observation mode", default=current_obs) - if new_obs in ("unified", "directional"): + if new_obs in {"unified", "directional"}: hermes_host["observationMode"] = new_obs else: hermes_host["observationMode"] = "directional" @@ -457,17 +457,17 @@ def cmd_setup(args) -> None: try: hermes_host["writeFrequency"] = int(new_wf) except (ValueError, TypeError): - hermes_host["writeFrequency"] = new_wf if new_wf in ("async", "turn", "session") else "async" + hermes_host["writeFrequency"] = new_wf if new_wf in {"async", "turn", "session"} else "async" # --- 6. Recall mode --- _raw_recall = hermes_host.get("recallMode") or cfg.get("recallMode", "hybrid") - current_recall = "hybrid" if _raw_recall not in ("hybrid", "context", "tools") else _raw_recall + current_recall = "hybrid" if _raw_recall not in {"hybrid", "context", "tools"} else _raw_recall print("\n Recall mode:") print(" hybrid -- auto-injected context + Honcho tools available (default)") print(" context -- auto-injected context only, Honcho tools hidden") print(" tools -- Honcho tools only, no auto-injected context") new_recall = _prompt("Recall mode", default=current_recall) - if new_recall in ("hybrid", "context", "tools"): + if new_recall in {"hybrid", "context", "tools"}: hermes_host["recallMode"] = new_recall # --- 7. Context token budget --- @@ -477,7 +477,7 @@ def cmd_setup(args) -> None: print(" uncapped -- no limit (default)") print(" N -- token limit per turn (e.g. 1200)") new_ctx_tokens = _prompt("Context tokens", default=current_display) - if new_ctx_tokens.strip().lower() in ("none", "uncapped", "no limit"): + if new_ctx_tokens.strip().lower() in {"none", "uncapped", "no limit"}: hermes_host.pop("contextTokens", None) elif new_ctx_tokens.strip() == "": pass # keep current @@ -517,7 +517,7 @@ def cmd_setup(args) -> None: print(" high -- complex behavioral patterns") print(" max -- thorough audit-level analysis") new_reasoning = _prompt("Reasoning level", default=current_reasoning) - if new_reasoning in ("minimal", "low", "medium", "high", "max"): + if new_reasoning in {"minimal", "low", "medium", "high", "max"}: hermes_host["dialecticReasoningLevel"] = new_reasoning else: hermes_host["dialecticReasoningLevel"] = "low" @@ -530,7 +530,7 @@ def cmd_setup(args) -> None: print(" per-repo -- one session per git repository") print(" global -- single session across all directories") new_strat = _prompt("Session strategy", default=current_strat) - if new_strat in ("per-session", "per-repo", "per-directory", "global"): + if new_strat in {"per-session", "per-repo", "per-directory", "global"}: hermes_host["sessionStrategy"] = new_strat hermes_host["enabled"] = True @@ -1130,7 +1130,7 @@ def cmd_migrate(args) -> None: print(" Paste the key when prompted.") print() answer = _prompt(" Run 'hermes honcho setup' now?", default="y") - if answer.lower() in ("y", "yes"): + if answer.lower() in {"y", "yes"}: cmd_setup(args) cfg = _read_config() has_key = bool(cfg.get("apiKey", "")) @@ -1176,7 +1176,7 @@ def cmd_migrate(args) -> None: print(" hermes honcho migrate — this step handles it interactively") if has_key: answer = _prompt(" Upload user memory files to Honcho now?", default="y") - if answer.lower() in ("y", "yes"): + if answer.lower() in {"y", "yes"}: try: from plugins.memory.honcho.client import ( HonchoClientConfig, @@ -1226,7 +1226,7 @@ def cmd_migrate(args) -> None: print() if has_key: answer = _prompt(" Seed AI identity from all detected files now?", default="y") - if answer.lower() in ("y", "yes"): + if answer.lower() in {"y", "yes"}: try: from plugins.memory.honcho.client import ( HonchoClientConfig, diff --git a/plugins/memory/honcho/client.py b/plugins/memory/honcho/client.py index de34642911e5..eb268216c9b6 100644 --- a/plugins/memory/honcho/client.py +++ b/plugins/memory/honcho/client.py @@ -47,7 +47,7 @@ def resolve_active_host() -> str: try: from hermes_cli.profiles import get_active_profile_name profile = get_active_profile_name() - if profile and profile not in ("default", "custom"): + if profile and profile not in {"default", "custom"}: return f"{HOST}.{profile}" except Exception: pass @@ -653,7 +653,7 @@ def resolve_session_name( return base # per-directory: one Honcho session per working directory (default) - if self.session_strategy in ("per-directory", "per-session"): + if self.session_strategy in {"per-directory", "per-session"}: base = Path(cwd).name if self.session_peer_prefix and self.peer_name: return f"{self.peer_name}-{base}" diff --git a/plugins/memory/openviking/__init__.py b/plugins/memory/openviking/__init__.py index ecb02b3de7e0..ff01bbf402ed 100644 --- a/plugins/memory/openviking/__init__.py +++ b/plugins/memory/openviking/__init__.py @@ -357,7 +357,7 @@ def _is_windows_absolute_path(value: str) -> bool: len(value) >= 3 and value[0].isalpha() and value[1] == ":" - and value[2] in ("/", "\\") + and value[2] in {"/", "\\"} ) @@ -381,7 +381,7 @@ def _is_local_path_reference(value: str) -> bool: def _path_from_file_uri(uri: str) -> Path | str: parsed = urlparse(uri) - if parsed.netloc not in ("", "localhost"): + if parsed.netloc not in {"", "localhost"}: return f"Unsupported non-local file URI: {uri}" return Path(url2pathname(parsed.path)).expanduser() @@ -755,7 +755,7 @@ def _tool_read(self, args: dict) -> str: level = args.get("level", "overview") - summary_level = level in ("abstract", "overview") + summary_level = level in {"abstract", "overview"} # OpenViking expects directory URIs for pseudo summary files # (e.g. viking://user/hermes/.overview.md). resolved_uri = self._normalize_summary_uri(uri) if summary_level else uri @@ -832,7 +832,7 @@ def _tool_browse(self, args: dict) -> str: result = self._unwrap_result(resp) # Format list/tree results for readability - if action in ("list", "tree"): + if action in {"list", "tree"}: raw_entries = result if isinstance(result, dict): raw_entries = result.get("entries") or result.get("items") or result.get("children") or [] @@ -887,7 +887,7 @@ def _tool_add_resource(self, args: dict) -> str: payload: Dict[str, Any] = {} for key in ("reason", "to", "parent", "instruction", "wait", "timeout"): - if key in args and args[key] not in (None, ""): + if key in args and args[key] not in {None, ""}: payload[key] = args[key] parsed_url = urlparse(url) diff --git a/plugins/memory/supermemory/__init__.py b/plugins/memory/supermemory/__init__.py index f0cbfd60276d..35b5b6fd649e 100644 --- a/plugins/memory/supermemory/__init__.py +++ b/plugins/memory/supermemory/__init__.py @@ -88,9 +88,9 @@ def _as_bool(value: Any, default: bool) -> bool: return value if isinstance(value, str): lowered = value.strip().lower() - if lowered in ("true", "1", "yes", "y", "on"): + if lowered in {"true", "1", "yes", "y", "on"}: return True - if lowered in ("false", "0", "no", "n", "off"): + if lowered in {"false", "0", "no", "n", "off"}: return False return default @@ -508,7 +508,7 @@ def initialize(self, session_id: str, **kwargs) -> None: self._allowed_containers = [self._container_tag] + list(self._custom_containers) agent_context = kwargs.get("agent_context", "") - self._write_enabled = agent_context not in ("cron", "flush", "subagent") + self._write_enabled = agent_context not in {"cron", "flush", "subagent"} self._active = bool(self._api_key) self._client = None if self._active: @@ -598,7 +598,7 @@ def on_session_end(self, messages: List[Dict[str, Any]]) -> None: cleaned = [] for message in messages or []: role = message.get("role") - if role not in ("user", "assistant"): + if role not in {"user", "assistant"}: continue content = _clean_text_for_capture(str(message.get("content", ""))) if content: diff --git a/plugins/model-providers/deepseek/__init__.py b/plugins/model-providers/deepseek/__init__.py index 525766f87eb6..34a8017b76e3 100644 --- a/plugins/model-providers/deepseek/__init__.py +++ b/plugins/model-providers/deepseek/__init__.py @@ -74,9 +74,9 @@ def build_api_kwargs_extras( # its server default (currently high). if isinstance(reasoning_config, dict): effort = (reasoning_config.get("effort") or "").strip().lower() - if effort in ("xhigh", "max"): + if effort in {"xhigh", "max"}: top_level["reasoning_effort"] = "max" - elif effort in ("low", "medium", "high"): + elif effort in {"low", "medium", "high"}: top_level["reasoning_effort"] = effort return extra_body, top_level diff --git a/plugins/model-providers/kimi-coding/__init__.py b/plugins/model-providers/kimi-coding/__init__.py index b5cf53a80103..ed96ec514ef0 100644 --- a/plugins/model-providers/kimi-coding/__init__.py +++ b/plugins/model-providers/kimi-coding/__init__.py @@ -37,7 +37,7 @@ def build_api_kwargs_extras( # Enabled extra_body["thinking"] = {"type": "enabled"} effort = (reasoning_config.get("effort") or "").strip().lower() - if effort in ("low", "medium", "high"): + if effort in {"low", "medium", "high"}: top_level["reasoning_effort"] = effort else: top_level["reasoning_effort"] = "medium" diff --git a/plugins/platforms/google_chat/adapter.py b/plugins/platforms/google_chat/adapter.py index 1520d6664eb5..0fdf1ea9d867 100644 --- a/plugins/platforms/google_chat/adapter.py +++ b/plugins/platforms/google_chat/adapter.py @@ -1539,7 +1539,7 @@ async def _build_message_event( if sender_email and space_name: self._last_sender_by_chat[space_name] = sender_email.strip().lower() - chat_type = "dm" if space_type in ("DIRECT_MESSAGE", "DM") else "group" + chat_type = "dm" if space_type in {"DIRECT_MESSAGE", "DM"} else "group" text = msg.get("argumentText") or msg.get("text") or "" text = text.strip() @@ -1935,7 +1935,7 @@ def _do_delete() -> None: return True except HttpError as exc: status = getattr(getattr(exc, "resp", None), "status", None) - if status in (403, 404): + if status in {403, 404}: return False logger.debug( "[GoogleChat] delete_message failed: %s", @@ -1958,7 +1958,7 @@ async def _patch_message( update_mask = ",".join(update_mask_fields) or "text" # Patch body cannot carry thread (immutable). - patch_body = {k: v for k, v in body.items() if k not in ("thread",)} + patch_body = {k: v for k, v in body.items() if k not in {"thread",}} def _do_patch() -> Dict[str, Any]: return ( @@ -2791,7 +2791,7 @@ def _upload() -> Dict[str, Any]: upload_resp = await asyncio.to_thread(_upload) except HttpError as exc: status = getattr(getattr(exc, "resp", None), "status", None) - if status in (401, 403): + if status in {401, 403}: logger.warning( "[GoogleChat] media.upload auth failure for identity=%s " "(token revoked or scope missing) — falling back to " @@ -2927,7 +2927,7 @@ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: display = info.get("displayName") or chat_id return { "name": display, - "type": "dm" if space_type in ("DIRECT_MESSAGE", "DM") else "group", + "type": "dm" if space_type in {"DIRECT_MESSAGE", "DM"} else "group", "chat_id": chat_id, } diff --git a/plugins/platforms/irc/adapter.py b/plugins/platforms/irc/adapter.py index ff10475d4e16..3358fa5b1886 100644 --- a/plugins/platforms/irc/adapter.py +++ b/plugins/platforms/irc/adapter.py @@ -112,7 +112,7 @@ def __init__(self, config, **kwargs): self.nickname = os.getenv("IRC_NICKNAME") or extra.get("nickname", "hermes-bot") self.channel = os.getenv("IRC_CHANNEL") or extra.get("channel", "") self.use_tls = ( - os.getenv("IRC_USE_TLS", "").lower() in ("1", "true", "yes") + os.getenv("IRC_USE_TLS", "").lower() in {"1", "true", "yes"} if os.getenv("IRC_USE_TLS") else extra.get("use_tls", True) ) @@ -680,7 +680,7 @@ def _env_enablement() -> dict | None: seed["nickname"] = nickname use_tls = os.getenv("IRC_USE_TLS", "").strip().lower() if use_tls: - seed["use_tls"] = use_tls in ("1", "true", "yes") + seed["use_tls"] = use_tls in {"1", "true", "yes"} # Passwords live in PlatformConfig.extra as well for back-compat with # existing config.yaml users; env-reads at construct time still win. if os.getenv("IRC_SERVER_PASSWORD"): @@ -756,7 +756,7 @@ async def _standalone_send( nickname = os.getenv("IRC_NICKNAME") or extra.get("nickname", "hermes-bot") use_tls_env = os.getenv("IRC_USE_TLS") if use_tls_env is not None: - use_tls = use_tls_env.lower() in ("1", "true", "yes") + use_tls = use_tls_env.lower() in {"1", "true", "yes"} else: use_tls = bool(extra.get("use_tls", True)) @@ -821,7 +821,7 @@ async def _raw(line: str) -> None: await _raw(f"PONG :{payload}") elif cmd == "001": registered = True - elif cmd in ("432", "433"): + elif cmd in {"432", "433"}: nick_attempts += 1 if nick_attempts > max_nick_attempts: return {"error": "IRC standalone send: too many nick collisions"} @@ -829,7 +829,7 @@ async def _raw(line: str) -> None: # mutated value, so the suffix stays bounded. standalone_nick = f"{nick_base}-cron-{nick_attempts}"[:30] await _raw(f"NICK {standalone_nick}") - elif cmd in ("464", "465"): + elif cmd in {"464", "465"}: return {"error": f"IRC standalone send: server rejected client ({cmd})"} if nickserv_password: @@ -860,9 +860,9 @@ async def _raw(line: str) -> None: if jcmd == "PING": payload = jmsg["params"][0] if jmsg["params"] else "" await _raw(f"PONG :{payload}") - elif jcmd in ("366", "JOIN"): + elif jcmd in {"366", "JOIN"}: joined = True - elif jcmd in ("403", "405", "471", "473", "474", "475"): + elif jcmd in {"403", "405", "471", "473", "474", "475"}: return {"error": f"IRC standalone send: JOIN {target} rejected ({jcmd})"} # Bytes-aware per-line splitting so multi-line plain text never diff --git a/plugins/platforms/line/adapter.py b/plugins/platforms/line/adapter.py index 907f16be4ff3..49931aa57aba 100644 --- a/plugins/platforms/line/adapter.py +++ b/plugins/platforms/line/adapter.py @@ -325,7 +325,7 @@ def set_error(self, request_id: str, message: str) -> None: def mark_delivered(self, request_id: str) -> None: entry = self._entries.get(request_id) - if entry is None or entry.state not in (State.READY, State.ERROR): + if entry is None or entry.state not in {State.READY, State.ERROR}: return entry.state = State.DELIVERED entry.updated_at = time.time() @@ -614,7 +614,7 @@ def _truthy_env(name: str, default: bool = False) -> bool: v = os.getenv(name) if v is None: return default - return v.strip().lower() in ("1", "true", "yes", "on") + return v.strip().lower() in {"1", "true", "yes", "on"} # --------------------------------------------------------------------------- @@ -910,7 +910,7 @@ async def _dispatch_event(self, event: Dict[str, Any]) -> None: await self._handle_message_event(event) elif event_type == "postback": await self._handle_postback_event(event) - elif event_type in ("follow", "unfollow", "join", "leave"): + elif event_type in {"follow", "unfollow", "join", "leave"}: logger.info("LINE: lifecycle event %s from %s", event_type, source) else: logger.debug("LINE: ignoring event type %r", event_type) @@ -939,7 +939,7 @@ async def _handle_message_event(self, event: Dict[str, Any]) -> None: if msg_type == "text": text = msg.get("text", "") or "" - elif msg_type in ("image", "audio", "video", "file"): + elif msg_type in {"image", "audio", "video", "file"}: local_path = await self._download_media(message_id, msg_type) if local_path: media_urls.append(local_path) diff --git a/plugins/platforms/simplex/adapter.py b/plugins/platforms/simplex/adapter.py index b568f29bbb5e..264deb896084 100644 --- a/plugins/platforms/simplex/adapter.py +++ b/plugins/platforms/simplex/adapter.py @@ -101,11 +101,11 @@ def _guess_extension(data: bytes) -> str: def _is_image_ext(ext: str) -> bool: - return ext.lower() in (".jpg", ".jpeg", ".png", ".gif", ".webp") + return ext.lower() in {".jpg", ".jpeg", ".png", ".gif", ".webp"} def _is_audio_ext(ext: str) -> bool: - return ext.lower() in (".mp3", ".wav", ".ogg", ".m4a", ".aac") + return ext.lower() in {".mp3", ".wav", ".ogg", ".m4a", ".aac"} # --------------------------------------------------------------------------- @@ -326,12 +326,12 @@ async def _handle_new_chat_item(self, wrapper: dict) -> None: # Filter out messages sent by us (direction == "snd") meta = chat_item.get("meta") or {} direction = (meta.get("itemStatus") or {}).get("type", "") - if direction in ("sndSent", "sndSentDirect", "sndSentViaProxy", "sndNew"): + if direction in {"sndSent", "sndSentDirect", "sndSentViaProxy", "sndNew"}: return # Determine chat type and IDs chat_type_raw = chat_info.get("type", "") - is_group = chat_type_raw in ("group", "groupInfo") + is_group = chat_type_raw in {"group", "groupInfo"} if is_group: group_info = chat_info.get("groupInfo") or chat_info.get("group") or {} @@ -374,7 +374,7 @@ async def _handle_new_chat_item(self, wrapper: dict) -> None: media_urls: List[str] = [] media_types: List[str] = [] file_info = chat_item.get("file") or {} - if file_info and file_info.get("fileStatus") not in ("cancelled", "error"): + if file_info and file_info.get("fileStatus") not in {"cancelled", "error"}: file_id = file_info.get("fileId") file_name = file_info.get("fileName", "file") if file_id: diff --git a/plugins/platforms/teams/adapter.py b/plugins/platforms/teams/adapter.py index f8a1dc3d5b4a..975ef5b40933 100644 --- a/plugins/platforms/teams/adapter.py +++ b/plugins/platforms/teams/adapter.py @@ -841,7 +841,7 @@ async def _on_card_action( # bot silently treated every clicker as authorized — meaning any # Teams user who could message the bot could approve dangerous commands. allowed_csv = os.getenv("TEAMS_ALLOWED_USERS", "").strip() - allow_all = os.getenv("TEAMS_ALLOW_ALL_USERS", "").strip().lower() in ("1", "true", "yes") + allow_all = os.getenv("TEAMS_ALLOW_ALL_USERS", "").strip().lower() in {"1", "true", "yes"} if not allow_all: if not allowed_csv: diff --git a/plugins/teams_pipeline/cli.py b/plugins/teams_pipeline/cli.py index 0e1114e3e74b..7afaa3888a0d 100644 --- a/plugins/teams_pipeline/cli.py +++ b/plugins/teams_pipeline/cli.py @@ -99,15 +99,15 @@ def teams_pipeline_command(args: argparse.Namespace) -> int: return 2 try: - if action in ("list", "ls"): + if action in {"list", "ls"}: _cmd_list(args) elif action == "show": _cmd_show(args) - elif action in ("run", "replay"): + elif action in {"run", "replay"}: _cmd_run(args) - elif action in ("fetch", "test"): + elif action in {"fetch", "test"}: _cmd_fetch(args) - elif action in ("subscriptions", "subs"): + elif action in {"subscriptions", "subs"}: _cmd_subscriptions(args) elif action == "subscribe": _cmd_subscribe(args) @@ -117,7 +117,7 @@ def teams_pipeline_command(args: argparse.Namespace) -> int: _cmd_delete_subscription(args) elif action == "maintain-subscriptions": _cmd_maintain_subscriptions(args) - elif action in ("token-health", "token"): + elif action in {"token-health", "token"}: _cmd_token_health(args) elif action == "validate": _cmd_validate(args) diff --git a/plugins/teams_pipeline/meetings.py b/plugins/teams_pipeline/meetings.py index 6d2648abd52f..ed024bc7e313 100644 --- a/plugins/teams_pipeline/meetings.py +++ b/plugins/teams_pipeline/meetings.py @@ -33,7 +33,7 @@ def _meeting_path(meeting_ref: TeamsMeetingRef | str) -> str: def _wrap_graph_error(exc: MicrosoftGraphAPIError, *, missing_message: str) -> TeamsMeetingError: - if exc.status_code in (401, 403): + if exc.status_code in {401, 403}: return TeamsMeetingPermissionError(str(exc)) if exc.status_code == 404: return TeamsMeetingNotFoundError(missing_message) @@ -286,7 +286,7 @@ async def fetch_call_record_artifact( try: payload = await client.get_json(f"/communications/callRecords/{quote(call_record_id, safe='')}") except MicrosoftGraphAPIError as exc: - if exc.status_code in (401, 403) and allow_permission_errors: + if exc.status_code in {401, 403} and allow_permission_errors: return None if exc.status_code == 404: return None diff --git a/plugins/teams_pipeline/models.py b/plugins/teams_pipeline/models.py index 8d85092be961..b1ae5196f515 100644 --- a/plugins/teams_pipeline/models.py +++ b/plugins/teams_pipeline/models.py @@ -145,7 +145,7 @@ class MeetingArtifact: metadata: dict[str, Any] = field(default_factory=dict) def __post_init__(self) -> None: - if self.artifact_type not in ("transcript", "recording", "call_record"): + if self.artifact_type not in {"transcript", "recording", "call_record"}: raise ValueError( "MeetingArtifact.artifact_type must be transcript, recording, or call_record." ) diff --git a/plugins/teams_pipeline/runtime.py b/plugins/teams_pipeline/runtime.py index e8d3ada710c3..f51be5e19e39 100644 --- a/plugins/teams_pipeline/runtime.py +++ b/plugins/teams_pipeline/runtime.py @@ -62,7 +62,7 @@ def build_pipeline_runtime_config(gateway_config: Any) -> dict[str, Any]: "chat_id", ): value = teams_extra.get(key) - if value not in (None, ""): + if value not in {None, ""}: teams_delivery[key] = value if teams_delivery: diff --git a/run_agent.py b/run_agent.py index 8471afccddf1..f25c94f17a94 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1365,7 +1365,7 @@ def _is_entitlement_failure( the existing 1M-context-beta branch handles them; revisit if other subscription tiers start producing the same loop signature). """ - if status_code not in (401, 403, None): + if status_code not in {401, 403, None}: return False if not isinstance(error_context, dict): return False @@ -1774,7 +1774,7 @@ def _file_mutation_verifier_enabled(self) -> bool: import os as _os env = _os.environ.get("HERMES_FILE_MUTATION_VERIFIER") if env is not None: - return env.strip().lower() not in ("0", "false", "no", "off") + return env.strip().lower() not in {"0", "false", "no", "off"} # Read from the persisted config.yaml so gateway and CLI share # the same setting. Import lazily to avoid a startup-time cycle. try: diff --git a/skills/creative/comfyui/scripts/_common.py b/skills/creative/comfyui/scripts/_common.py index ef742733eb5f..efe592a1b339 100644 --- a/skills/creative/comfyui/scripts/_common.py +++ b/skills/creative/comfyui/scripts/_common.py @@ -592,7 +592,7 @@ def redirect_request(self, req2, fp, code, msg, hdrs, newurl): # Build a new request with cleaned headers clean_headers = { k: v for k, v in req2.header_items() - if k.lower() not in ("x-api-key", "authorization", "cookie") + if k.lower() not in {"x-api-key", "authorization", "cookie"} } new_req = urllib.request.Request(newurl, headers=clean_headers, method="GET") return new_req @@ -743,13 +743,13 @@ def safe_path_join(base: Path, *parts: str) -> Path: def media_type_from_filename(filename: str) -> str: ext = Path(filename).suffix.lower() - if ext in (".mp4", ".webm", ".avi", ".mov", ".mkv", ".gif", ".webp"): + if ext in {".mp4", ".webm", ".avi", ".mov", ".mkv", ".gif", ".webp"}: return "video" - if ext in (".wav", ".mp3", ".flac", ".ogg", ".m4a"): + if ext in {".wav", ".mp3", ".flac", ".ogg", ".m4a"}: return "audio" - if ext in (".glb", ".obj", ".ply", ".gltf"): + if ext in {".glb", ".obj", ".ply", ".gltf"}: return "3d" - if ext in (".json", ".txt", ".md"): + if ext in {".json", ".txt", ".md"}: return "text" return "image" diff --git a/skills/creative/comfyui/scripts/extract_schema.py b/skills/creative/comfyui/scripts/extract_schema.py index ba44cfdf6a2f..0eab65b20fdb 100755 --- a/skills/creative/comfyui/scripts/extract_schema.py +++ b/skills/creative/comfyui/scripts/extract_schema.py @@ -81,7 +81,7 @@ def trace_to_node(workflow: dict, link: list, *, max_hops: int = 8) -> str | Non return None cls = node.get("class_type", "") # Reroute / Primitive / passthrough wrappers - if cls in ("Reroute", "PrimitiveNode", "Note", "easy showAnything"): + if cls in {"Reroute", "PrimitiveNode", "Note", "easy showAnything"}: inputs = node.get("inputs", {}) or {} # Find first link-shaped input and follow it next_link = next((v for v in inputs.values() if is_link(v)), None) @@ -105,7 +105,7 @@ def find_negative_prompt_node(workflow: dict) -> str | None: src = trace_to_node(workflow, neg) if src and isinstance(workflow.get(src), dict): cls = workflow[src].get("class_type", "") - if cls.startswith("CLIPTextEncode") or cls in ("smZ CLIPTextEncode", "BNK_CLIPTextEncodeAdvanced"): + if cls.startswith("CLIPTextEncode") or cls in {"smZ CLIPTextEncode", "BNK_CLIPTextEncodeAdvanced"}: return src return None @@ -121,7 +121,7 @@ def find_positive_prompt_node(workflow: dict) -> str | None: src = trace_to_node(workflow, pos) if src and isinstance(workflow.get(src), dict): cls = workflow[src].get("class_type", "") - if cls.startswith("CLIPTextEncode") or cls in ("smZ CLIPTextEncode", "BNK_CLIPTextEncodeAdvanced"): + if cls.startswith("CLIPTextEncode") or cls in {"smZ CLIPTextEncode", "BNK_CLIPTextEncodeAdvanced"}: return src return None diff --git a/skills/creative/comfyui/scripts/fetch_logs.py b/skills/creative/comfyui/scripts/fetch_logs.py index c7b3b084807c..e0b6e12ac757 100755 --- a/skills/creative/comfyui/scripts/fetch_logs.py +++ b/skills/creative/comfyui/scripts/fetch_logs.py @@ -151,7 +151,7 @@ def main(argv: list[str] | None = None) -> int: diag["source"] = res.get("source") diag["prompt_id"] = args.prompt_id emit_json(diag) - return 0 if diag.get("status_str") not in ("error",) else 1 + return 0 if diag.get("status_str") not in {"error",} else 1 if __name__ == "__main__": diff --git a/skills/creative/comfyui/scripts/hardware_check.py b/skills/creative/comfyui/scripts/hardware_check.py index 6a4d6c6d4067..083d018acc64 100755 --- a/skills/creative/comfyui/scripts/hardware_check.py +++ b/skills/creative/comfyui/scripts/hardware_check.py @@ -203,7 +203,7 @@ def detect_apple_silicon() -> dict | None: def detect_intel_arc() -> dict | None: - if platform.system() not in ("Linux", "Windows"): + if platform.system() not in {"Linux", "Windows"}: return None if shutil.which("clinfo"): out = _run(["clinfo", "--list"]) diff --git a/skills/creative/comfyui/scripts/run_workflow.py b/skills/creative/comfyui/scripts/run_workflow.py index 444957960b68..05afb1e319f5 100755 --- a/skills/creative/comfyui/scripts/run_workflow.py +++ b/skills/creative/comfyui/scripts/run_workflow.py @@ -204,7 +204,7 @@ def poll_status(self, prompt_id: str, *, timeout: float = 300.0, s = data.get("status") if s == "completed": return {"status": "success", "data": data} - if s in ("failed",): + if s in {"failed",}: return {"status": "error", "data": data} if s == "cancelled": return {"status": "cancelled", "data": data} @@ -386,7 +386,7 @@ def download_output( # local path; otherwise put the file in output_dir flat. target_parts: list[str] = [] if preserve_subfolder and subfolder: - target_parts.extend(p for p in subfolder.split("/") if p and p not in (".", "..")) + target_parts.extend(p for p in subfolder.split("/") if p and p not in {".", ".."}) target_parts.append(filename) out_path = safe_path_join(output_dir, *target_parts) @@ -467,7 +467,7 @@ def inject_params( # Auto-randomize seed when it's -1 in args, or when randomize_seed_if_unset # and user didn't pass a seed. if "seed" in params: - if "seed" in args and args["seed"] in (None, -1, "-1"): + if "seed" in args and args["seed"] in {None, -1, "-1"}: args = dict(args) args["seed"] = coerce_seed(args["seed"]) warnings.append(f"seed=-1 expanded to {args['seed']}") diff --git a/skills/creative/comfyui/scripts/ws_monitor.py b/skills/creative/comfyui/scripts/ws_monitor.py index b8689655bd0d..e2b6689423a5 100755 --- a/skills/creative/comfyui/scripts/ws_monitor.py +++ b/skills/creative/comfyui/scripts/ws_monitor.py @@ -170,7 +170,7 @@ def main(argv: list[str] | None = None) -> int: parsed = parse_binary_frame(msg) if parsed is None: continue - if parsed["kind"] in ("preview", "preview_with_metadata") and preview_dir: + if parsed["kind"] in {"preview", "preview_with_metadata"} and preview_dir: img_bytes = parsed.get("image_bytes", b"") if img_bytes: ext = parsed.get("ext", "png") diff --git a/skills/creative/comfyui/tests/test_cloud_integration.py b/skills/creative/comfyui/tests/test_cloud_integration.py index eb7b04ca2253..0ce88efe3c2d 100644 --- a/skills/creative/comfyui/tests/test_cloud_integration.py +++ b/skills/creative/comfyui/tests/test_cloud_integration.py @@ -53,7 +53,7 @@ def test_object_info_paid_tier(self, cloud_key): url = resolve_url("https://cloud.comfy.org", "/object_info") r = http_get(url, headers={"X-API-Key": cloud_key}) # Should be either 200 (paid) or 403 (free) — not 404 / 500 - assert r.status in (200, 403) + assert r.status in {200, 403} if r.status == 403: # Body should mention the limitation assert "free tier" in r.text().lower() or "subscription" in r.text().lower() diff --git a/skills/creative/comfyui/tests/test_extract_schema.py b/skills/creative/comfyui/tests/test_extract_schema.py index 1cb965a1fa81..072a788f3188 100644 --- a/skills/creative/comfyui/tests/test_extract_schema.py +++ b/skills/creative/comfyui/tests/test_extract_schema.py @@ -40,7 +40,7 @@ def test_circular_safe(self): } # Should hit max_hops without infinite loop result = trace_to_node(wf, ["1", 0], max_hops=5) - assert result in ("1", "2") # any node, just don't hang + assert result in {"1", "2"} # any node, just don't hang class TestPositiveNegativeDetection: diff --git a/skills/productivity/google-workspace/scripts/google_api.py b/skills/productivity/google-workspace/scripts/google_api.py index 7b8350ab34a2..231b1b6849fc 100644 --- a/skills/productivity/google-workspace/scripts/google_api.py +++ b/skills/productivity/google-workspace/scripts/google_api.py @@ -721,7 +721,7 @@ def drive_share(args): "type": args.type, "role": args.role, } - if args.type in ("user", "group"): + if args.type in {"user", "group"}: if not args.email: print("ERROR: --email is required for type=user or type=group", file=sys.stderr) sys.exit(1) diff --git a/skills/productivity/maps/scripts/maps_client.py b/skills/productivity/maps/scripts/maps_client.py index 279a41aad64f..d272b4a75661 100644 --- a/skills/productivity/maps/scripts/maps_client.py +++ b/skills/productivity/maps/scripts/maps_client.py @@ -181,7 +181,7 @@ def http_get(url, params=None, retries=MAX_RETRIES, silent=False): return json.loads(raw) except urllib.error.HTTPError as exc: last_error = f"HTTP {exc.code}: {exc.reason} for {url}" - if exc.code in (429, 503, 502, 504): + if exc.code in {429, 503, 502, 504}: time.sleep(RETRY_DELAY * attempt) else: if silent: @@ -217,7 +217,7 @@ def http_get_text(url, params=None, retries=MAX_RETRIES, silent=False): return resp.read().decode("utf-8") except urllib.error.HTTPError as exc: last_error = f"HTTP {exc.code}: {exc.reason} for {url}" - if exc.code in (429, 503, 502, 504): + if exc.code in {429, 503, 502, 504}: time.sleep(RETRY_DELAY * attempt) else: if silent: @@ -256,7 +256,7 @@ def http_post(url, data_str, retries=MAX_RETRIES): return json.loads(raw) except urllib.error.HTTPError as exc: last_error = f"HTTP {exc.code}: {exc.reason}" - if exc.code in (429, 503, 502, 504): + if exc.code in {429, 503, 502, 504}: time.sleep(RETRY_DELAY * attempt) else: error_exit(last_error) @@ -459,8 +459,8 @@ def parse_overpass_elements(elements, ref_lat=None, ref_lon=None): "maps_url": f"https://www.google.com/maps/search/?api=1&query={el_lat},{el_lon}", "tags": { k: v for k, v in tags.items() - if k not in ("name", "name:en", - "addr:housenumber", "addr:street", "addr:city") + if k not in {"name", "name:en", + "addr:housenumber", "addr:street", "addr:city"} }, } diff --git a/skills/productivity/ocr-and-documents/scripts/extract_marker.py b/skills/productivity/ocr-and-documents/scripts/extract_marker.py index 4f301aac7b28..d48fd10bb02c 100644 --- a/skills/productivity/ocr-and-documents/scripts/extract_marker.py +++ b/skills/productivity/ocr-and-documents/scripts/extract_marker.py @@ -63,7 +63,7 @@ def check_requirements(): if __name__ == "__main__": args = sys.argv[1:] - if not args or args[0] in ("-h", "--help"): + if not args or args[0] in {"-h", "--help"}: print(__doc__) sys.exit(0) diff --git a/skills/productivity/ocr-and-documents/scripts/extract_pymupdf.py b/skills/productivity/ocr-and-documents/scripts/extract_pymupdf.py index 22063e734894..50cb8ee86c40 100644 --- a/skills/productivity/ocr-and-documents/scripts/extract_pymupdf.py +++ b/skills/productivity/ocr-and-documents/scripts/extract_pymupdf.py @@ -68,7 +68,7 @@ def show_metadata(path): if __name__ == "__main__": args = sys.argv[1:] - if not args or args[0] in ("-h", "--help"): + if not args or args[0] in {"-h", "--help"}: print(__doc__) sys.exit(0) diff --git a/skills/research/arxiv/scripts/search_arxiv.py b/skills/research/arxiv/scripts/search_arxiv.py index 9acd8b97ec9a..0bd6b2370f44 100644 --- a/skills/research/arxiv/scripts/search_arxiv.py +++ b/skills/research/arxiv/scripts/search_arxiv.py @@ -81,7 +81,7 @@ def search(query=None, author=None, category=None, ids=None, max_results=5, sort if __name__ == "__main__": args = sys.argv[1:] - if not args or args[0] in ("-h", "--help"): + if not args or args[0] in {"-h", "--help"}: print(__doc__) sys.exit(0) diff --git a/skills/research/polymarket/scripts/polymarket.py b/skills/research/polymarket/scripts/polymarket.py index 417e0b1747ea..b76e7aa5f9b1 100644 --- a/skills/research/polymarket/scripts/polymarket.py +++ b/skills/research/polymarket/scripts/polymarket.py @@ -233,7 +233,7 @@ def cmd_trades(limit: int = 10, market: str = None): def main(): args = sys.argv[1:] - if not args or args[0] in ("-h", "--help", "help"): + if not args or args[0] in {"-h", "--help", "help"}: print(__doc__) return diff --git a/tests/agent/lsp/_mock_lsp_server.py b/tests/agent/lsp/_mock_lsp_server.py index 0220fec195d0..619b8da233f1 100644 --- a/tests/agent/lsp/_mock_lsp_server.py +++ b/tests/agent/lsp/_mock_lsp_server.py @@ -91,7 +91,7 @@ def main(): if msg.get("method") == "workspace/didChangeWatchedFiles": continue - if msg.get("method") in ("textDocument/didOpen", "textDocument/didChange"): + if msg.get("method") in {"textDocument/didOpen", "textDocument/didChange"}: params = msg.get("params") or {} td = params.get("textDocument") or {} uri = td.get("uri", "") diff --git a/tests/agent/lsp/test_install_and_lint_fixes.py b/tests/agent/lsp/test_install_and_lint_fixes.py index 9046d01295ee..e9f862a6d8ec 100644 --- a/tests/agent/lsp/test_install_and_lint_fixes.py +++ b/tests/agent/lsp/test_install_and_lint_fixes.py @@ -87,10 +87,10 @@ def fake_run(cmd, **kwargs): cmd = captured["cmd"] assert "pyright" in cmd # Should not blow up when extra_pkgs is omitted/None - install_targets = [c for c in cmd if not c.startswith("-") and c not in ( + install_targets = [c for c in cmd if not c.startswith("-") and c not in { "install", "--prefix", str(install_mod.hermes_lsp_bin_dir().parent), "/usr/bin/npm", - )] + }] assert install_targets == ["pyright"] diff --git a/tests/agent/test_anthropic_adapter.py b/tests/agent/test_anthropic_adapter.py index 259e9c1c5237..c7119dfd3b0d 100644 --- a/tests/agent/test_anthropic_adapter.py +++ b/tests/agent/test_anthropic_adapter.py @@ -1658,7 +1658,7 @@ def test_cache_control_stripped_from_thinking_blocks(self): _, result = convert_messages_to_anthropic(messages) assistant = next(m for m in result if m["role"] == "assistant") for block in assistant["content"]: - if block.get("type") in ("thinking", "redacted_thinking"): + if block.get("type") in {"thinking", "redacted_thinking"}: assert "cache_control" not in block def test_thinking_stripped_from_merged_consecutive_assistants(self): @@ -1748,7 +1748,7 @@ def test_multi_turn_conversation_preserves_only_last(self): # First two: no thinking blocks for a in assistants[:2]: assert not any( - b.get("type") in ("thinking", "redacted_thinking") + b.get("type") in {"thinking", "redacted_thinking"} for b in a["content"] if isinstance(b, dict) ) diff --git a/tests/agent/test_auxiliary_main_first.py b/tests/agent/test_auxiliary_main_first.py index 6ac69b27b7c1..d1b758c2884f 100644 --- a/tests/agent/test_auxiliary_main_first.py +++ b/tests/agent/test_auxiliary_main_first.py @@ -371,7 +371,7 @@ def test_main_unavailable_vision_falls_through_to_aggregators(self): provider, client, model = resolve_vision_provider_client() assert client is fallback_client - assert provider in ("openrouter", "nous") + assert provider in {"openrouter", "nous"} def test_explicit_provider_override_still_wins(self): """Explicit config override bypasses main-first policy.""" diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index 559cf2237a25..2d1a40445d79 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -1046,7 +1046,7 @@ def test_summary_role_flips_to_avoid_tail_collision(self): for i in range(1, len(result)): r1 = result[i - 1].get("role") r2 = result[i].get("role") - if r1 in ("user", "assistant") and r2 in ("user", "assistant"): + if r1 in {"user", "assistant"} and r2 in {"user", "assistant"}: assert r1 != r2, f"consecutive {r1} at indices {i-1},{i}" def test_double_collision_merges_summary_into_tail(self): @@ -1087,7 +1087,7 @@ def test_double_collision_merges_summary_into_tail(self): for i in range(1, len(result)): r1 = result[i - 1].get("role") r2 = result[i].get("role") - if r1 in ("user", "assistant") and r2 in ("user", "assistant"): + if r1 in {"user", "assistant"} and r2 in {"user", "assistant"}: assert r1 != r2, f"consecutive {r1} at indices {i-1},{i}" # The summary text should be merged into the first tail message @@ -1164,7 +1164,7 @@ def test_double_collision_user_head_assistant_tail(self): for i in range(1, len(result)): r1 = result[i - 1].get("role") r2 = result[i].get("role") - if r1 in ("user", "assistant") and r2 in ("user", "assistant"): + if r1 in {"user", "assistant"} and r2 in {"user", "assistant"}: assert r1 != r2, f"consecutive {r1} at indices {i-1},{i}" # The summary should be merged into the first tail message (assistant at index 5) diff --git a/tests/agent/test_deepseek_anthropic_thinking.py b/tests/agent/test_deepseek_anthropic_thinking.py index 4d032fa35958..67534adc3e86 100644 --- a/tests/agent/test_deepseek_anthropic_thinking.py +++ b/tests/agent/test_deepseek_anthropic_thinking.py @@ -191,7 +191,7 @@ def test_cache_control_stripped_from_thinking_block(self) -> None: if not isinstance(m.get("content"), list): continue for b in m["content"]: - if isinstance(b, dict) and b.get("type") in ("thinking", "redacted_thinking"): + if isinstance(b, dict) and b.get("type") in {"thinking", "redacted_thinking"}: assert "cache_control" not in b def test_openai_compat_deepseek_base_is_not_matched(self) -> None: diff --git a/tests/cli/test_cli_init.py b/tests/cli/test_cli_init.py index 8417d64e746a..b05df5220c5c 100644 --- a/tests/cli/test_cli_init.py +++ b/tests/cli/test_cli_init.py @@ -99,7 +99,7 @@ def test_default_verbose_is_bool(self): def test_tool_progress_mode_is_string(self): cli = _make_cli() assert isinstance(cli.tool_progress_mode, str) - assert cli.tool_progress_mode in ("off", "new", "all", "verbose") + assert cli.tool_progress_mode in {"off", "new", "all", "verbose"} class TestBusyInputMode: diff --git a/tests/cli/test_reasoning_command.py b/tests/cli/test_reasoning_command.py index f5f7e35cbe7d..5091256a3990 100644 --- a/tests/cli/test_reasoning_command.py +++ b/tests/cli/test_reasoning_command.py @@ -70,7 +70,7 @@ def test_show_enables_display(self): stub = self._make_cli(show_reasoning=False) # Simulate /reasoning show arg = "show" - if arg in ("show", "on"): + if arg in {"show", "on"}: stub.show_reasoning = True stub.agent.reasoning_callback = lambda x: None self.assertTrue(stub.show_reasoning) @@ -79,7 +79,7 @@ def test_hide_disables_display(self): stub = self._make_cli(show_reasoning=True) # Simulate /reasoning hide arg = "hide" - if arg in ("hide", "off"): + if arg in {"hide", "off"}: stub.show_reasoning = False stub.agent.reasoning_callback = None self.assertFalse(stub.show_reasoning) @@ -88,14 +88,14 @@ def test_hide_disables_display(self): def test_on_enables_display(self): stub = self._make_cli(show_reasoning=False) arg = "on" - if arg in ("show", "on"): + if arg in {"show", "on"}: stub.show_reasoning = True self.assertTrue(stub.show_reasoning) def test_off_disables_display(self): stub = self._make_cli(show_reasoning=True) arg = "off" - if arg in ("hide", "off"): + if arg in {"hide", "off"}: stub.show_reasoning = False self.assertFalse(stub.show_reasoning) diff --git a/tests/cron/test_cron_no_agent.py b/tests/cron/test_cron_no_agent.py index 117cb8c7d9aa..583cd34099e8 100644 --- a/tests/cron/test_cron_no_agent.py +++ b/tests/cron/test_cron_no_agent.py @@ -68,7 +68,7 @@ def test_create_job_no_agent_stores_field(hermes_env): assert job["no_agent"] is True assert job["script"] == "watchdog.sh" # Prompt can be empty/None for no_agent jobs. - assert job["prompt"] in (None, "") + assert job["prompt"] in {None, ""} def test_create_job_default_is_not_no_agent(hermes_env): @@ -148,7 +148,7 @@ def test_cronjob_tool_update_toggles_no_agent(hermes_env): off = json.loads(cronjob(action="update", job_id=job_id, no_agent=False, prompt="run")) assert off["success"] is True - assert off["job"].get("no_agent") in (False, None) + assert off["job"].get("no_agent") in {False, None} on = json.loads(cronjob(action="update", job_id=job_id, no_agent=True)) assert on["success"] is True diff --git a/tests/gateway/conftest.py b/tests/gateway/conftest.py index b6bcc28c5062..965933de41b2 100644 --- a/tests/gateway/conftest.py +++ b/tests/gateway/conftest.py @@ -269,7 +269,7 @@ def _scan_for_plugin_adapter_antipattern(source: str) -> list[str]: and isinstance(func.value.value, ast.Name) and func.value.value.id == "sys" and func.value.attr == "path" - and func.attr in ("insert", "append", "extend") + and func.attr in {"insert", "append", "extend"} ): target_name = f"sys.path.{func.attr}" diff --git a/tests/gateway/test_allowlist_startup_check.py b/tests/gateway/test_allowlist_startup_check.py index 96441c052135..abb2db7db123 100644 --- a/tests/gateway/test_allowlist_startup_check.py +++ b/tests/gateway/test_allowlist_startup_check.py @@ -16,8 +16,8 @@ def _would_warn(): "MATRIX_ALLOWED_USERS", "DINGTALK_ALLOWED_USERS", "FEISHU_ALLOWED_USERS", "WECOM_ALLOWED_USERS", "GATEWAY_ALLOWED_USERS") ) - _allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes") or any( - os.getenv(v, "").lower() in ("true", "1", "yes") + _allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"} or any( + os.getenv(v, "").lower() in {"true", "1", "yes"} for v in ("TELEGRAM_ALLOW_ALL_USERS", "DISCORD_ALLOW_ALL_USERS", "WHATSAPP_ALLOW_ALL_USERS", "SLACK_ALLOW_ALL_USERS", "SIGNAL_ALLOW_ALL_USERS", "EMAIL_ALLOW_ALL_USERS", diff --git a/tests/gateway/test_config_cwd_bridge.py b/tests/gateway/test_config_cwd_bridge.py index 236662538827..f7349d073f74 100644 --- a/tests/gateway/test_config_cwd_bridge.py +++ b/tests/gateway/test_config_cwd_bridge.py @@ -44,7 +44,7 @@ def _simulate_config_bridge(cfg: dict, initial_env: dict | None = None): val = terminal_cfg[cfg_key] # Skip cwd placeholder values — don't overwrite already-resolved # TERMINAL_CWD. Mirrors the fix in gateway/run.py. - if cfg_key == "cwd" and str(val) in (".", "auto", "cwd"): + if cfg_key == "cwd" and str(val) in {".", "auto", "cwd"}: continue # Expand shell tilde so subprocess.Popen never receives a literal # "~/" which the kernel rejects. @@ -70,7 +70,7 @@ def _simulate_config_bridge(cfg: dict, initial_env: dict | None = None): # --- Replicate lines 144-147: MESSAGING_CWD fallback --- configured_cwd = env.get("TERMINAL_CWD", "") - if not configured_cwd or configured_cwd in (".", "auto", "cwd"): + if not configured_cwd or configured_cwd in {".", "auto", "cwd"}: messaging_cwd = env.get("MESSAGING_CWD") or "/root" # Path.home() for root env["TERMINAL_CWD"] = messaging_cwd diff --git a/tests/gateway/test_discord_system_messages.py b/tests/gateway/test_discord_system_messages.py index 8e2fb27e7883..e58f2812745a 100644 --- a/tests/gateway/test_discord_system_messages.py +++ b/tests/gateway/test_discord_system_messages.py @@ -48,7 +48,7 @@ def _run_filter(self, message, client_user=None): return False # System message filter (the fix being tested) - if message.type not in (discord.MessageType.default, discord.MessageType.reply): + if message.type not in {discord.MessageType.default, discord.MessageType.reply}: return False return True # message accepted diff --git a/tests/gateway/test_platform_connected_checkers.py b/tests/gateway/test_platform_connected_checkers.py index 307c79b30867..941b8c74506a 100644 --- a/tests/gateway/test_platform_connected_checkers.py +++ b/tests/gateway/test_platform_connected_checkers.py @@ -76,12 +76,12 @@ def test_checker_returns_true_when_configured(platform, checker, monkeypatch): elif platform == Platform.SMS: monkeypatch.setenv("TWILIO_ACCOUNT_SID", "ACtest") mock_config.extra = {} - elif platform in ( + elif platform in { Platform.API_SERVER, Platform.WEBHOOK, Platform.MSGRAPH_WEBHOOK, Platform.WHATSAPP, - ): + }: mock_config.extra = {} elif platform == Platform.FEISHU: mock_config.extra = {"app_id": "app"} diff --git a/tests/gateway/test_qqbot.py b/tests/gateway/test_qqbot.py index 5d5cac54bd38..4b3402387a44 100644 --- a/tests/gateway/test_qqbot.py +++ b/tests/gateway/test_qqbot.py @@ -1076,7 +1076,7 @@ def test_round_trip_parse_matches_build(self): parsed = parse_approval_button_data(btn.action.data) assert parsed is not None assert parsed[0] == session_key - assert parsed[1] in ("allow-once", "allow-always", "deny") + assert parsed[1] in {"allow-once", "allow-always", "deny"} class TestBuildUpdatePromptKeyboard: diff --git a/tests/gateway/test_restart_resume_pending.py b/tests/gateway/test_restart_resume_pending.py index 13ef2f6f99ec..55d9b4a497b1 100644 --- a/tests/gateway/test_restart_resume_pending.py +++ b/tests/gateway/test_restart_resume_pending.py @@ -89,7 +89,7 @@ def _build_agent_history(history: list) -> list: agent_history: list = [] for msg in history: role = msg.get("role") - if not role or role in ("session_meta", "system"): + if not role or role in {"session_meta", "system"}: continue has_tool_calls = "tool_calls" in msg has_tool_call_id = "tool_call_id" in msg diff --git a/tests/gateway/test_session_boundary_hooks.py b/tests/gateway/test_session_boundary_hooks.py index 255795492fc7..30584513325a 100644 --- a/tests/gateway/test_session_boundary_hooks.py +++ b/tests/gateway/test_session_boundary_hooks.py @@ -108,7 +108,7 @@ async def test_finalize_before_reset(mock_invoke_hook): await runner._handle_reset_command(_make_event("/new")) calls = [c for c in mock_invoke_hook.call_args_list - if c[0][0] in ("on_session_finalize", "on_session_reset")] + if c[0][0] in {"on_session_finalize", "on_session_reset"}] hook_names = [c[0][0] for c in calls] assert hook_names == ["on_session_finalize", "on_session_reset"] diff --git a/tests/gateway/test_session_model_override_routing.py b/tests/gateway/test_session_model_override_routing.py index 3530744e2236..26acdc157aa5 100644 --- a/tests/gateway/test_session_model_override_routing.py +++ b/tests/gateway/test_session_model_override_routing.py @@ -187,7 +187,7 @@ def test_gateway_auth_fallback_uses_fallback_model_from_config(tmp_path, monkeyp monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) def fake_resolve_runtime_provider(*, requested=None, explicit_base_url=None, explicit_api_key=None): - if requested in (None, "", "openai-codex"): + if requested in {None, "", "openai-codex"}: from hermes_cli.auth import AuthError raise AuthError("No Codex credentials stored. Run `hermes auth` to authenticate.") assert requested == "openrouter" diff --git a/tests/gateway/test_transcript_offset.py b/tests/gateway/test_transcript_offset.py index d8a2672f4d6a..7cbb519ee3a2 100644 --- a/tests/gateway/test_transcript_offset.py +++ b/tests/gateway/test_transcript_offset.py @@ -31,7 +31,7 @@ def _filter_history(history: list) -> list: role = msg.get("role") if not role: continue - if role in ("session_meta",): + if role in {"session_meta",}: continue if role == "system": continue diff --git a/tests/hermes_cli/test_auth_nous_provider.py b/tests/hermes_cli/test_auth_nous_provider.py index bd6098d3746e..5cd546462dde 100644 --- a/tests/hermes_cli/test_auth_nous_provider.py +++ b/tests/hermes_cli/test_auth_nous_provider.py @@ -555,7 +555,7 @@ def test_skip_with_no_prior_active_provider_clears_it(self, tmp_path, monkeypatc auth_path = hermes_home / "auth.json" auth_after = json.loads(auth_path.read_text()) # active_provider should NOT be set to "nous" after Skip - assert auth_after.get("active_provider") in (None, "") + assert auth_after.get("active_provider") in {None, ""} # But Nous creds are still saved assert "nous" in auth_after.get("providers", {}) diff --git a/tests/hermes_cli/test_cmd_update.py b/tests/hermes_cli/test_cmd_update.py index 2f4b836286b4..b9087c06663d 100644 --- a/tests/hermes_cli/test_cmd_update.py +++ b/tests/hermes_cli/test_cmd_update.py @@ -162,7 +162,7 @@ def test_update_refreshes_repo_and_tui_node_dependencies( if call.args and call.args[0][0] == "/usr/bin/npm" and call.args[0][1] == "ci" - and call.kwargs.get("cwd") in (PROJECT_ROOT, PROJECT_ROOT / "ui-tui") + and call.kwargs.get("cwd") in {PROJECT_ROOT, PROJECT_ROOT / "ui-tui"} ] assert len(repo_and_tui_calls) == 2 for call in repo_and_tui_calls: diff --git a/tests/hermes_cli/test_codex_runtime_switch.py b/tests/hermes_cli/test_codex_runtime_switch.py index 7bf1a59e1e72..a0b4aa5fd415 100644 --- a/tests/hermes_cli/test_codex_runtime_switch.py +++ b/tests/hermes_cli/test_codex_runtime_switch.py @@ -105,7 +105,7 @@ def test_enable_blocked_when_codex_missing(self): assert "Cannot enable" in r.message assert "npm i -g @openai/codex" in r.message # Config NOT mutated on failure - assert cfg.get("model", {}).get("openai_runtime") in (None, "") + assert cfg.get("model", {}).get("openai_runtime") in {None, ""} def test_enable_succeeds_when_codex_present(self): cfg = {} diff --git a/tests/hermes_cli/test_install_cua_driver.py b/tests/hermes_cli/test_install_cua_driver.py index 42a49e22b5d1..6cd50261694d 100644 --- a/tests/hermes_cli/test_install_cua_driver.py +++ b/tests/hermes_cli/test_install_cua_driver.py @@ -48,7 +48,7 @@ def test_upgrade_on_macos_with_binary_runs_installer(self): with patch("platform.system", return_value="Darwin"), \ patch.object(tools_config.shutil, "which", side_effect=lambda n: "/usr/local/bin/" + n - if n in ("cua-driver", "curl") else None), \ + if n in {"cua-driver", "curl"} else None), \ patch.object(tools_config, "_run_cua_driver_installer", return_value=True) as runner, \ patch("subprocess.run"): @@ -82,7 +82,7 @@ def test_non_upgrade_on_macos_with_binary_skips_install(self): with patch("platform.system", return_value="Darwin"), \ patch.object(tools_config.shutil, "which", side_effect=lambda n: "/usr/local/bin/" + n - if n in ("cua-driver", "curl") else None), \ + if n in {"cua-driver", "curl"} else None), \ patch.object(tools_config, "_run_cua_driver_installer") as runner, \ patch("subprocess.run"): assert tools_config.install_cua_driver(upgrade=False) is True diff --git a/tests/hermes_cli/test_kanban_core_functionality.py b/tests/hermes_cli/test_kanban_core_functionality.py index 17252af827a3..35dc7ace9513 100644 --- a/tests/hermes_cli/test_kanban_core_functionality.py +++ b/tests/hermes_cli/test_kanban_core_functionality.py @@ -1046,7 +1046,7 @@ def _signal(pid, sig): task = kb.get_task(conn, tid) # After timeout, task is back in 'ready' and will be re-spawned # by the same pass. That's the intended behaviour. - assert task.status in ("ready", "running") + assert task.status in {"ready", "running"} finally: conn.close() diff --git a/tests/hermes_cli/test_memory_reset.py b/tests/hermes_cli/test_memory_reset.py index 3b91326de204..48f1cfda6a7e 100644 --- a/tests/hermes_cli/test_memory_reset.py +++ b/tests/hermes_cli/test_memory_reset.py @@ -43,9 +43,9 @@ def _run_memory_reset(target="all", yes=False, monkeypatch=None, confirm_input=" mem_dir = get_hermes_home() / "memories" files_to_reset = [] - if target in ("all", "memory"): + if target in {"all", "memory"}: files_to_reset.append(("MEMORY.md", "agent notes")) - if target in ("all", "user"): + if target in {"all", "user"}: files_to_reset.append(("USER.md", "user profile")) existing = [(f, desc) for f, desc in files_to_reset if (mem_dir / f).exists()] diff --git a/tests/hermes_cli/test_models.py b/tests/hermes_cli/test_models.py index 8ccf5b57f2d1..78568f81f2c2 100644 --- a/tests/hermes_cli/test_models.py +++ b/tests/hermes_cli/test_models.py @@ -252,7 +252,7 @@ def test_deepseek_model_detected(self): result = detect_provider_for_model("deepseek-chat", "openai-codex") assert result is not None # Provider is deepseek (direct) or openrouter (fallback) depending on creds - assert result[0] in ("deepseek", "openrouter") + assert result[0] in {"deepseek", "openrouter"} def test_current_provider_model_returns_none(self): """Models belonging to the current provider should not trigger a switch.""" @@ -302,7 +302,7 @@ def test_aggregator_not_suggested(self): with patch("hermes_cli.models.fetch_openrouter_models", return_value=LIVE_OPENROUTER_MODELS): result = detect_provider_for_model("claude-opus-4-6", "openai-codex") assert result is not None - assert result[0] not in ("nous",) # nous has claude models but shouldn't be suggested + assert result[0] not in {"nous",} # nous has claude models but shouldn't be suggested class TestIsNousFreeTier: diff --git a/tests/hermes_cli/test_opencode_go_in_model_list.py b/tests/hermes_cli/test_opencode_go_in_model_list.py index 6020c817979a..f784f75f31b1 100644 --- a/tests/hermes_cli/test_opencode_go_in_model_list.py +++ b/tests/hermes_cli/test_opencode_go_in_model_list.py @@ -44,7 +44,7 @@ def test_opencode_go_appears_when_api_key_set(): # opencode-go can appear as "built-in" (from PROVIDER_TO_MODELS_DEV when # models.dev is reachable) or "hermes" (from HERMES_OVERLAYS fallback when # the API is unavailable, e.g. in CI). - assert opencode_go["source"] in ("built-in", "hermes") + assert opencode_go["source"] in {"built-in", "hermes"} def test_opencode_go_not_appears_when_no_creds(): diff --git a/tests/hermes_cli/test_update_stale_dashboard.py b/tests/hermes_cli/test_update_stale_dashboard.py index 546fd489911d..e79caeb9dc6e 100644 --- a/tests/hermes_cli/test_update_stale_dashboard.py +++ b/tests/hermes_cli/test_update_stale_dashboard.py @@ -237,7 +237,7 @@ def fake_kill(pid, sig): sent.append((pid, sig)) # Simulate stubborn process: probe (sig 0) always succeeds, # SIGTERM does nothing, SIGKILL is where it "dies". - if sig in (_signal.SIGTERM, 0, _signal.SIGKILL): + if sig in {_signal.SIGTERM, 0, _signal.SIGKILL}: return # Any other signal — also fine. diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 4d177f92b385..ca2876f0f5cc 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -306,7 +306,7 @@ def test_session_token_endpoint_removed(self): resp = self.client.get("/api/auth/session-token") # The endpoint is gone — the catch-all SPA route serves index.html # or the middleware returns 401 for unauthenticated /api/ paths. - assert resp.status_code in (200, 404) + assert resp.status_code in {200, 404} # Either way, it must NOT return the token as JSON try: data = resp.json() @@ -333,7 +333,7 @@ def test_path_traversal_blocked(self): # %2e%2e = .. resp = self.client.get("/%2e%2e/%2e%2e/etc/passwd") # Should return 200 with index.html (SPA fallback), not the actual file - assert resp.status_code in (200, 404) + assert resp.status_code in {200, 404} if resp.status_code == 200: # Should be the SPA fallback, not the system file assert "root:" not in resp.text @@ -341,7 +341,7 @@ def test_path_traversal_blocked(self): def test_path_traversal_dotdot_blocked(self): """Direct .. path traversal via encoded sequences.""" resp = self.client.get("/%2e%2e/hermes_cli/web_server.py") - assert resp.status_code in (200, 404) + assert resp.status_code in {200, 404} if resp.status_code == 200: assert "FastAPI" not in resp.text # Should not serve the actual source @@ -535,7 +535,7 @@ def get_nested(obj, path): if val is None: continue # not set in user config — fine expected = entry["type"] - if expected in ("string", "select") and not isinstance(val, str): + if expected in {"string", "select"} and not isinstance(val, str): mismatches.append(f"{key}: expected str, got {type(val).__name__}") elif expected == "number" and not isinstance(val, (int, float)): mismatches.append(f"{key}: expected number, got {type(val).__name__}") @@ -1032,7 +1032,7 @@ def test_session_token_endpoint_removed(self): """GET /api/auth/session-token no longer exists.""" resp = self.client.get("/api/auth/session-token") # Should not return a JSON token object - assert resp.status_code in (200, 404) + assert resp.status_code in {200, 404} try: data = resp.json() assert "token" not in data diff --git a/tests/honcho_plugin/test_session.py b/tests/honcho_plugin/test_session.py index 64fcfc7ebfdb..57724432348d 100644 --- a/tests/honcho_plugin/test_session.py +++ b/tests/honcho_plugin/test_session.py @@ -1570,7 +1570,7 @@ def test_full_multi_turn_session(self): self._await_thread(provider) assert mgr.dialectic_query.call_count == 2, "turn 4 cadence fire" _, kwargs = mgr.dialectic_query.call_args - assert kwargs.get("reasoning_level") in ("medium", "high"), \ + assert kwargs.get("reasoning_level") in {"medium", "high"}, \ f"long query must bump reasoning level above 'low'; got {kwargs.get('reasoning_level')}" assert provider._last_dialectic_turn == 4, "cadence tracker advances on success" diff --git a/tests/plugins/test_achievements_plugin.py b/tests/plugins/test_achievements_plugin.py index 782aea7b3975..2d908b3d46e9 100644 --- a/tests/plugins/test_achievements_plugin.py +++ b/tests/plugins/test_achievements_plugin.py @@ -271,7 +271,7 @@ def test_evaluate_all_force_runs_synchronously(plugin_api): # Synchronous — snapshot is fresh on return. assert result["scan_meta"].get("sessions_total") == 25 - assert result["scan_meta"]["mode"] in ("full", "incremental") + assert result["scan_meta"]["mode"] in {"full", "incremental"} def test_start_background_scan_is_idempotent_while_running(plugin_api): diff --git a/tests/plugins/video_gen/test_xai_plugin.py b/tests/plugins/video_gen/test_xai_plugin.py index bd7a880fdee9..4c365020a321 100644 --- a/tests/plugins/video_gen/test_xai_plugin.py +++ b/tests/plugins/video_gen/test_xai_plugin.py @@ -110,4 +110,4 @@ def test_xai_no_operation_kwarg(): result = XAIVideoGenProvider().generate("x", operation="generate") assert result["success"] is False # auth_required, NOT some signature error - assert result["error_type"] in ("auth_required", "api_error") + assert result["error_type"] in {"auth_required", "api_error"} diff --git a/tests/run_agent/test_anthropic_truncation_continuation.py b/tests/run_agent/test_anthropic_truncation_continuation.py index 872015bc0bc8..4e87a33e9d80 100644 --- a/tests/run_agent/test_anthropic_truncation_continuation.py +++ b/tests/run_agent/test_anthropic_truncation_continuation.py @@ -106,9 +106,9 @@ class TestContinuationLogicBranching: def test_all_three_api_modes_hit_continuation_branch(self, api_mode): # The guard in run_agent.py is: # if self.api_mode in ("chat_completions", "bedrock_converse", "anthropic_messages"): - assert api_mode in ("chat_completions", "bedrock_converse", "anthropic_messages") + assert api_mode in {"chat_completions", "bedrock_converse", "anthropic_messages"} def test_codex_responses_still_excluded(self): # codex_responses has its own truncation path (not continuation-based) # and should NOT be routed through the shared block. - assert "codex_responses" not in ("chat_completions", "bedrock_converse", "anthropic_messages") + assert "codex_responses" not in {"chat_completions", "bedrock_converse", "anthropic_messages"} diff --git a/tests/skills/test_openclaw_migration.py b/tests/skills/test_openclaw_migration.py index 708484027be6..0b331c402386 100644 --- a/tests/skills/test_openclaw_migration.py +++ b/tests/skills/test_openclaw_migration.py @@ -846,7 +846,7 @@ def test_skill_installs_cleanly_under_skills_guard(): # the script never writes to that file # # Accept "caution" or "safe" — just not "dangerous" from a *real* threat. - assert result.verdict in ("safe", "caution", "dangerous"), f"Unexpected verdict: {result.verdict}" + assert result.verdict in {"safe", "caution", "dangerous"}, f"Unexpected verdict: {result.verdict}" KNOWN_FALSE_POSITIVES = {"agent_config_mod", "python_os_environ", "hermes_config_mod"} for f in result.findings: assert f.pattern_id in KNOWN_FALSE_POSITIVES, f"Unexpected finding: {f}" diff --git a/tests/stress/test_atypical_scenarios.py b/tests/stress/test_atypical_scenarios.py index 2010049e14f9..e7e83eabccb5 100644 --- a/tests/stress/test_atypical_scenarios.py +++ b/tests/stress/test_atypical_scenarios.py @@ -902,7 +902,7 @@ def _(home, kb): pass # Empty body → accept (legitimate: just title says it all) tid = kb.create_task(conn, title="empty body ok", body="", assignee="w") - assert kb.get_task(conn, tid).body in ("", None) + assert kb.get_task(conn, tid).body in {"", None} # Empty summary on complete → accept kb.claim_task(conn, tid) kb.complete_task(conn, tid, summary="") @@ -994,7 +994,7 @@ def _(home, kb): # Empty title r = client.post("/api/plugins/kanban/tasks", json={"title": ""}) - assert r.status_code in (400, 422), f"empty title should 4xx, got {r.status_code}" + assert r.status_code in {400, 422}, f"empty title should 4xx, got {r.status_code}" # Title only r = client.post("/api/plugins/kanban/tasks", json={"title": "x"}) @@ -1019,7 +1019,7 @@ def _(home, kb): r = client.post("/api/plugins/kanban/tasks", json={ "title": "fine", "nonexistent_field": "whatever", }) - assert r.status_code in (200, 422) + assert r.status_code in {200, 422} # Priority as non-int r = client.post("/api/plugins/kanban/tasks", json={"title": "prio", "priority": "high"}) @@ -1028,7 +1028,7 @@ def _(home, kb): # PATCH with empty body (no changes requested) r = client.patch(f"/api/plugins/kanban/tasks/{tid}", json={}) # Accept either success-no-op or 400 - assert r.status_code in (200, 400) + assert r.status_code in {200, 400} print(" dashboard REST handles weird inputs correctly") # ============================================================================= diff --git a/tests/test_live_system_guard_self_test.py b/tests/test_live_system_guard_self_test.py index 1856935b2409..3bbe8c9f3b0c 100644 --- a/tests/test_live_system_guard_self_test.py +++ b/tests/test_live_system_guard_self_test.py @@ -259,7 +259,7 @@ def test_kill_own_subtree_passes_through(): finally: p.wait(timeout=2) # SIGTERM = 15; subprocess returncode is -15 on POSIX. - assert p.returncode in (-signal.SIGTERM, 128 + int(signal.SIGTERM)) + assert p.returncode in {-signal.SIGTERM, 128 + int(signal.SIGTERM)} def test_subprocess_pkill_with_unrelated_pattern_passes_through(): diff --git a/tests/test_timezone.py b/tests/test_timezone.py index ffb831617d92..f91a27b6a753 100644 --- a/tests/test_timezone.py +++ b/tests/test_timezone.py @@ -63,7 +63,7 @@ def test_us_eastern(self): assert result.tzinfo is not None # Offset is -5h or -4h depending on DST offset_hours = result.utcoffset().total_seconds() / 3600 - assert offset_hours in (-5, -4) + assert offset_hours in {-5, -4} def test_invalid_timezone_falls_back(self, caplog): """Invalid timezone logs warning and falls back to server-local.""" diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 0d5bad8e8754..24a34e75c177 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -3718,7 +3718,7 @@ def run_conversation( assert payload.get("status") == "complete" # Text stays empty — we did NOT fabricate an "Error:" string text = payload.get("text", "") - assert text in ("", None), f"expected empty text, got {text!r}" + assert text in {"", None}, f"expected empty text, got {text!r}" # ── session.most_recent ────────────────────────────────────────────── diff --git a/tests/tools/test_browser_homebrew_paths.py b/tests/tools/test_browser_homebrew_paths.py index 7e4d1c702225..7edf6f6c67de 100644 --- a/tests/tools/test_browser_homebrew_paths.py +++ b/tests/tools/test_browser_homebrew_paths.py @@ -68,10 +68,10 @@ def mock_isdir(p): if p == "/opt/homebrew/opt": return True # node@20/bin and node@24/bin exist - if p in ( + if p in { "/opt/homebrew/opt/node@20/bin", "/opt/homebrew/opt/node@24/bin", - ): + }: return True return False @@ -171,10 +171,10 @@ def mock_path_exists(self): real_isdir = os.path.isdir def selective_isdir(path): - if path in ( + if path in { "/data/data/com.termux/files/usr/bin", "/data/data/com.termux/files/usr/sbin", - ): + }: return True return real_isdir(path) @@ -486,10 +486,10 @@ def capture_popen(cmd, **kwargs): real_isdir = os.path.isdir def selective_isdir(path): - if path in ( + if path in { "/data/data/com.termux/files/usr/bin", "/data/data/com.termux/files/usr/sbin", - ): + }: return True if path.startswith(str(tmp_path)): return True diff --git a/tests/tools/test_code_execution_modes.py b/tests/tools/test_code_execution_modes.py index 4e22fe6e7a2f..e5e2d2262ffa 100644 --- a/tests/tools/test_code_execution_modes.py +++ b/tests/tools/test_code_execution_modes.py @@ -125,7 +125,7 @@ def test_strict_always_sys_executable(self): def test_project_with_no_venv_falls_back(self): """Project mode without VIRTUAL_ENV or CONDA_PREFIX → sys.executable.""" env = {k: v for k, v in os.environ.items() - if k not in ("VIRTUAL_ENV", "CONDA_PREFIX")} + if k not in {"VIRTUAL_ENV", "CONDA_PREFIX"}} with patch.dict(os.environ, env, clear=True): self.assertEqual(_resolve_child_python("project"), sys.executable) diff --git a/tests/tools/test_discord_tool.py b/tests/tools/test_discord_tool.py index 41d2cc957be1..19a31d104572 100644 --- a/tests/tools/test_discord_tool.py +++ b/tests/tools/test_discord_tool.py @@ -633,7 +633,7 @@ def test_discord_tools_not_in_core_tools(self): def test_discord_tools_not_in_other_toolsets(self): from toolsets import TOOLSETS for name, ts in TOOLSETS.items(): - if name in ("hermes-discord", "hermes-gateway", "discord", "discord_admin"): + if name in {"hermes-discord", "hermes-gateway", "discord", "discord_admin"}: continue tools = ts.get("tools", []) assert "discord" not in tools or name == "discord", ( diff --git a/tests/tools/test_hidden_dir_filter.py b/tests/tools/test_hidden_dir_filter.py index d7c10846bea6..c7757864f748 100644 --- a/tests/tools/test_hidden_dir_filter.py +++ b/tests/tools/test_hidden_dir_filter.py @@ -24,7 +24,7 @@ def _new_filter_matches(path: Path) -> bool: Returns True when the path SHOULD be filtered out. """ - return any(part in ('.git', '.github', '.hub') for part in path.parts) + return any(part in {'.git', '.github', '.hub'} for part in path.parts) class TestOldFilterBrokenOnWindows: diff --git a/tests/tools/test_managed_modal_environment.py b/tests/tools/test_managed_modal_environment.py index d36418336cc2..8380e49058c1 100644 --- a/tests/tools/test_managed_modal_environment.py +++ b/tests/tools/test_managed_modal_environment.py @@ -33,7 +33,7 @@ def _restore_tool_and_agent_modules(): original_modules = { name: module for name, module in sys.modules.items() - if name in ("tools", "agent", "hermes_cli") + if name in {"tools", "agent", "hermes_cli"} or name.startswith("tools.") or name.startswith("agent.") or name.startswith("hermes_cli.") diff --git a/tests/tools/test_mcp_cancelled_error_propagation.py b/tests/tools/test_mcp_cancelled_error_propagation.py index ce05d03f43a7..c0e91f315315 100644 --- a/tests/tools/test_mcp_cancelled_error_propagation.py +++ b/tests/tools/test_mcp_cancelled_error_propagation.py @@ -62,7 +62,7 @@ async def drive(): return "clean_return" outcome = asyncio.run(drive()) - assert outcome in ("cancelled_cleanly", "clean_return"), ( + assert outcome in {"cancelled_cleanly", "clean_return"}, ( f"MCPServerTask.run wedged on cancel (outcome={outcome}) — " f"#9930 regression" ) diff --git a/tests/tools/test_singularity_preflight.py b/tests/tools/test_singularity_preflight.py index 0ba50c3e93d1..fa0a0ea4d52a 100644 --- a/tests/tools/test_singularity_preflight.py +++ b/tests/tools/test_singularity_preflight.py @@ -23,7 +23,7 @@ class TestFindSingularityExecutable: def test_prefers_apptainer(self): """When both are available, apptainer should be preferred.""" def which_both(name): - return f"/usr/bin/{name}" if name in ("apptainer", "singularity") else None + return f"/usr/bin/{name}" if name in {"apptainer", "singularity"} else None with patch("shutil.which", side_effect=which_both): assert _find_singularity_executable() == "apptainer" diff --git a/tests/tools/test_skill_manager_tool.py b/tests/tools/test_skill_manager_tool.py index 96c3a361f0c2..33efbb98ae8f 100644 --- a/tests/tools/test_skill_manager_tool.py +++ b/tests/tools/test_skill_manager_tool.py @@ -547,7 +547,7 @@ def test_full_create_via_dispatcher(self, tmp_path): # No provenance marker on a foreground create — record either missing # entirely (telemetry best-effort) or present with created_by unset. rec = usage.get("test-skill") or {} - assert rec.get("created_by") in (None, "", False) + assert rec.get("created_by") in {None, "", False} def test_create_from_background_review_marks_agent_created(self, tmp_path): """Background-review fork creates ARE marked as agent-created.""" diff --git a/tests/tools/test_skills_hub.py b/tests/tools/test_skills_hub.py index b7c483d1a16a..e831b50943ec 100644 --- a/tests/tools/test_skills_hub.py +++ b/tests/tools/test_skills_hub.py @@ -101,7 +101,7 @@ def test_two_part_identifier(self): src = self._source() result = src.trust_level_for("owner/repo") # No path part — still resolves repo correctly - assert result in ("trusted", "community") + assert result in {"trusted", "community"} # --------------------------------------------------------------------------- diff --git a/tests/tui_gateway/test_entry_sys_path.py b/tests/tui_gateway/test_entry_sys_path.py index f8741b18e4b9..e7f9e47cee00 100644 --- a/tests/tui_gateway/test_entry_sys_path.py +++ b/tests/tui_gateway/test_entry_sys_path.py @@ -25,7 +25,7 @@ def _reload_entry_with_env(env_overrides: dict) -> None: _src_root = os.environ.get("HERMES_PYTHON_SRC_ROOT", "") if _src_root and _src_root not in sys.path: sys.path.insert(0, _src_root) - sys.path = [p for p in sys.path if p not in ("", ".")] + sys.path = [p for p in sys.path if p not in {"", "."}] return sys.path[:] finally: sys.path = original_path @@ -45,7 +45,7 @@ def test_empty_string_and_dot_removed_from_sys_path(): assert "." in sys.path # Run the entry.py fixup logic directly - sys.path = [p for p in sys.path if p not in ("", ".")] + sys.path = [p for p in sys.path if p not in {"", "."}] assert "" not in sys.path assert "." not in sys.path @@ -61,7 +61,7 @@ def test_hermes_src_root_inserted_at_front(): _src_root = os.environ.get("HERMES_PYTHON_SRC_ROOT", "") if _src_root and _src_root not in sys.path: sys.path.insert(0, _src_root) - sys.path = [p for p in sys.path if p not in ("", ".")] + sys.path = [p for p in sys.path if p not in {"", "."}] assert sys.path[0] == fake_root finally: @@ -79,7 +79,7 @@ def test_src_root_not_duplicated_if_already_present(): _src_root = os.environ.get("HERMES_PYTHON_SRC_ROOT", "") if _src_root and _src_root not in sys.path: sys.path.insert(0, _src_root) - sys.path = [p for p in sys.path if p not in ("", ".")] + sys.path = [p for p in sys.path if p not in {"", "."}] assert sys.path.count(fake_root) == count_before finally: @@ -95,7 +95,7 @@ def test_no_src_root_env_does_not_crash(): _src_root = os.environ.get("HERMES_PYTHON_SRC_ROOT", "") if _src_root and _src_root not in sys.path: sys.path.insert(0, _src_root) - sys.path = [p for p in sys.path if p not in ("", ".")] + sys.path = [p for p in sys.path if p not in {"", "."}] # No exception raised finally: sys.path = original diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index faaf7ec42bf1..c7d7730c7564 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -450,7 +450,7 @@ def ensure(feature: str, *, prompt: bool = True) -> None: ).strip().lower() except (EOFError, KeyboardInterrupt): answer = "n" - if answer and answer not in ("y", "yes"): + if answer and answer not in {"y", "yes"}: raise FeatureUnavailable( feature, missing, "user declined install at prompt" ) diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index a46496ef59c9..9cec72524aff 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -540,7 +540,7 @@ def _validate_remote_mcp_url(server_name: str, url: Any) -> str: raise InvalidMcpUrlError( f"Invalid MCP URL for '{server_name}': {stripped!r} ({exc})" ) from exc - if parsed.scheme.lower() not in ("http", "https"): + if parsed.scheme.lower() not in {"http", "https"}: raise InvalidMcpUrlError( f"Invalid MCP URL for '{server_name}': scheme must be http or " f"https, got {parsed.scheme!r} ({stripped!r})" diff --git a/tools/video_generation_tool.py b/tools/video_generation_tool.py index 63d80165dc01..472b84092550 100644 --- a/tools/video_generation_tool.py +++ b/tools/video_generation_tool.py @@ -286,9 +286,9 @@ def _coerce_bool(value: Any) -> Optional[bool]: return value if isinstance(value, str): v = value.strip().lower() - if v in ("true", "1", "yes", "on"): + if v in {"true", "1", "yes", "on"}: return True - if v in ("false", "0", "no", "off"): + if v in {"false", "0", "no", "off"}: return False return None diff --git a/tools/x_search_tool.py b/tools/x_search_tool.py index 8b242ee0ca84..1b7685a897d9 100644 --- a/tools/x_search_tool.py +++ b/tools/x_search_tool.py @@ -147,7 +147,7 @@ def _extract_response_text(payload: Dict[str, Any]) -> str: continue for content in item.get("content", []) or []: ctype = content.get("type") - if ctype in ("output_text", "text"): + if ctype in {"output_text", "text"}: text = str(content.get("text") or "").strip() if text: parts.append(text) From d87b27cff86fe5dcf07cbdb073608674f8b92b3c Mon Sep 17 00:00:00 2001 From: Yanzhong Su Date: Thu, 14 May 2026 19:27:17 +0100 Subject: [PATCH 003/418] fix(gateway): add codex runtime telegram alias --- hermes_cli/commands.py | 3 ++- tests/hermes_cli/test_commands.py | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 07e5b5e5c4a3..1e42fb9421eb 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -123,7 +123,8 @@ class CommandDef: CommandDef("model", "Switch model for this session", "Configuration", aliases=("provider",), args_hint="[model] [--provider name] [--global]"), CommandDef("codex-runtime", "Toggle codex app-server runtime for OpenAI/Codex models", - "Configuration", args_hint="[auto|codex_app_server]"), + "Configuration", aliases=("codex_runtime",), + args_hint="[auto|codex_app_server]"), CommandDef("gquota", "Show Google Gemini Code Assist quota usage", "Info", cli_only=True), diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index d08f886fa6a4..6de778347e13 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -107,6 +107,7 @@ def test_alias_resolves_to_canonical(self): assert resolve_command("gateway").name == "platforms" assert resolve_command("set-home").name == "sethome" assert resolve_command("reload_mcp").name == "reload-mcp" + assert resolve_command("codex_runtime").name == "codex-runtime" assert resolve_command("tasks").name == "agents" def test_topic_is_gateway_command(self): @@ -251,6 +252,12 @@ def test_includes_builtin_commands_with_required_args(self): assert "queue" in names assert "steer" in names + def test_hyphenated_codex_runtime_is_exposed_as_underscore_command(self): + """Telegram autocomplete exposes /codex-runtime as /codex_runtime.""" + names = {name for name, _ in telegram_bot_commands()} + assert "codex_runtime" in names + assert "codex-runtime" not in names + class TestSlackSubcommandMap: def test_returns_dict(self): From 5a2a858b84c3e189c7d4ab7db94205c0a2ef480f Mon Sep 17 00:00:00 2001 From: haran2001 <56040092+haran2001@users.noreply.github.com> Date: Sun, 17 May 2026 02:29:27 -0700 Subject: [PATCH 004/418] test(restart_drain): assert i18n catalog resolved (#22266) The restart-drain test previously asserted equality between two calls to t("gateway.draining", count=1), which masked the original xdist failure mode in #22266: if the locale catalog is not resolved from the worker's import path, t() returns the bare key path and both sides of the equality still match. Add a guard that the resolved value is not the raw catalog key and contains the English placeholder substitution. This keeps the test loudly failing when locale resolution silently degrades. --- tests/gateway/test_restart_drain.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/gateway/test_restart_drain.py b/tests/gateway/test_restart_drain.py index 844af4273085..9000e4d4820f 100644 --- a/tests/gateway/test_restart_drain.py +++ b/tests/gateway/test_restart_drain.py @@ -33,7 +33,16 @@ async def test_restart_command_while_busy_requests_drain_without_interrupt(monke result = await runner._handle_message(event) - assert result == t("gateway.draining", count=1) + expected = t("gateway.draining", count=1) + assert result == expected + # Guard against the silent-degradation regression in #22266: if the i18n + # catalog cannot be resolved (e.g. xdist workers losing the locales path) + # then ``t("gateway.draining", count=1)`` returns the bare key + # ``"gateway.draining"`` instead of the formatted English string, and both + # sides of the equality above would still match. Assert on the catalog + # output explicitly so a broken locale resolution fails loudly here. + assert expected != "gateway.draining" + assert "Draining" in expected and "1" in expected running_agent.interrupt.assert_not_called() runner.request_restart.assert_called_once_with(detached=True, via_service=False) From d9abbe7fa4c69333a226b7a2d366713b3f071187 Mon Sep 17 00:00:00 2001 From: haran2001 <56040092+haran2001@users.noreply.github.com> Date: Sun, 17 May 2026 02:29:27 -0700 Subject: [PATCH 005/418] fix(metadata): qwen3.6-plus has a 1M context window (#27008) qwen3.6-plus did not have an explicit entry in DEFAULT_CONTEXT_LENGTHS, so the longest-substring fallback matched the generic 'qwen': 131072 catch-all. That dropped the effective context limit from 1,048,576 tokens to 131,072, prematurely lowered the compression threshold, and produced misleading warnings about main/compression context mismatch in long sessions. Add an explicit 'qwen3.6-plus': 1048576 entry before the catch-all and cover it with a regression test (bare, qwen/, and dashscope/ prefixes). Note: PR #6599 also mentions touching model_metadata.py but the actual diff only edits hermes_cli/models.py, so this fix is independent and not duplicated by that PR. Closes #27008 --- agent/model_metadata.py | 1 + tests/agent/test_model_metadata.py | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 26a844ccb921..b8ec0d6509e4 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -194,6 +194,7 @@ def _strip_provider_prefix(model: str) -> str: "llama": 131072, # Qwen — specific model families before the catch-all. # Official docs: https://help.aliyun.com/zh/model-studio/developer-reference/ + "qwen3.6-plus": 1048576, # 1M context (DashScope/Alibaba & OpenRouter) "qwen3-coder-plus": 1000000, # 1M context "qwen3-coder": 262144, # 256K context "qwen": 131072, diff --git a/tests/agent/test_model_metadata.py b/tests/agent/test_model_metadata.py index 7686364dcac0..4f2b51293a63 100644 --- a/tests/agent/test_model_metadata.py +++ b/tests/agent/test_model_metadata.py @@ -746,6 +746,16 @@ def test_qwen3_coder_context_length(self, mock_fetch): mock_fetch.return_value = {} assert get_model_context_length("qwen3-coder") == 262144 + @patch("agent.model_metadata.fetch_model_metadata") + def test_qwen3_6_plus_context_length(self, mock_fetch): + """qwen3.6-plus has a 1M context window, not the generic 128K Qwen default.""" + mock_fetch.return_value = {} + assert get_model_context_length("qwen3.6-plus") == 1048576 + # Provider-prefixed variants must resolve to the same explicit entry + # via the longest-substring fallback (no portal/OR cache available). + assert get_model_context_length("qwen/qwen3.6-plus") == 1048576 + assert get_model_context_length("dashscope/qwen3.6-plus") == 1048576 + @patch("agent.model_metadata.fetch_model_metadata") def test_qwen_generic_context_length(self, mock_fetch): """Generic qwen models still get the 128K default.""" From 3c51da1cb709566cfd3f29b5d3405a7826e97ced Mon Sep 17 00:00:00 2001 From: ms-alan <1472110+ms-alan@users.noreply.github.com> Date: Sun, 17 May 2026 02:29:28 -0700 Subject: [PATCH 006/418] fix(cli): sync _skill_commands after /reload-skills so Tab completion picks up new skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Tab-completion lambda captured _skill_commands at startup, so newly installed skills were missing from Tab completion even after /reload-skills reported them as added. Two changes: 1. Tab-completion lambda now calls get_skill_commands() instead of reading the module-level _skill_commands snapshot — ensures the lambda always gets fresh data without needing to touch global state. 2. _reload_skills() now syncs cli.py's module-level _skill_commands via get_skill_commands() after reload, so help display, command dispatch, and any other direct _skill_commands readers also see the updated map. Closes #26441 --- cli.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/cli.py b/cli.py index e8e38965f537..6b62493d60c3 100644 --- a/cli.py +++ b/cli.py @@ -2412,6 +2412,7 @@ def _looks_like_slash_command(text: str) -> bool: from agent.skill_commands import ( scan_skill_commands, + get_skill_commands, build_skill_invocation_message, build_preloaded_skills_prompt, ) @@ -9656,12 +9657,18 @@ def _reload_skills(self) -> None: prompt caching intact. """ try: - from agent.skill_commands import reload_skills + from agent.skill_commands import reload_skills, get_skill_commands if not self._command_running: print("🔄 Reloading skills...") result = reload_skills() + + # Sync cli.py's module-level _skill_commands so all consumers + # (help display, command dispatch, Tab-completion lambda) see the + # updated dict without needing to restart the session. + global _skill_commands + _skill_commands = get_skill_commands() added = result.get("added", []) # [{"name", "description"}, ...] removed = result.get("removed", []) # [{"name", "description"}, ...] total = result.get("total", 0) @@ -12667,7 +12674,7 @@ def get_prompt(): _completer = SlashCommandCompleter( - skill_commands_provider=lambda: _skill_commands, + skill_commands_provider=lambda: get_skill_commands(), command_filter=cli_ref._command_available, ) input_area = TextArea( From 6622277f11ca1dee03e868b064fb1851e5598b77 Mon Sep 17 00:00:00 2001 From: godlin Date: Fri, 15 May 2026 13:01:14 +0800 Subject: [PATCH 007/418] fix ACP start events for polished tools --- acp_adapter/tools.py | 1 - tests/acp/test_tools.py | 10 ++++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/acp_adapter/tools.py b/acp_adapter/tools.py index 31ae943a0565..77a62e243bcd 100644 --- a/acp_adapter/tools.py +++ b/acp_adapter/tools.py @@ -1123,7 +1123,6 @@ def build_tool_start( ) # Generic fallback - import json try: args_text = json.dumps(arguments, indent=2, default=str) except (TypeError, ValueError): diff --git a/tests/acp/test_tools.py b/tests/acp/test_tools.py index f9b0dac6d66a..dc62b296c696 100644 --- a/tests/acp/test_tools.py +++ b/tests/acp/test_tools.py @@ -207,6 +207,16 @@ def test_build_tool_start_for_web_extract_is_compact(self): assert result.content is None assert result.raw_input is None + def test_build_tool_start_for_browser_navigate(self): + """browser_navigate should emit a polished start event.""" + args = {"url": "https://x.com"} + result = build_tool_start("tc-browser-start", "browser_navigate", args) + assert isinstance(result, ToolCallStart) + assert result.title == "navigate: https://x.com" + assert result.kind == "fetch" + assert result.content[0].content.text == '{\n "url": "https://x.com"\n}' + assert result.raw_input is None + def test_build_tool_start_for_search(self): """search_files should include pattern in content.""" args = {"pattern": "TODO", "target": "content"} From 8e3cfdfb613ceb923ab9c07f8b88d4fa512b35b4 Mon Sep 17 00:00:00 2001 From: wesleysimplicio <6108320+wesleysimplicio@users.noreply.github.com> Date: Sun, 17 May 2026 02:29:28 -0700 Subject: [PATCH 008/418] fix(webui): allow native text selection in chat via xterm.js bypass (#25720) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat panel renders via xterm.js, and when the inner Hermes TUI enables mouse-events mode (CSI ?1000h family — used for nav inside Ink overlays/pickers) every drag/double-click/triple-click in the canvas is consumed by the terminal instead of producing a native text selection. The reporter (macOS, Brave) confirmed: - click-and-drag selects nothing - Cmd+C with no selection copies the entire visible buffer - existing CSS overrides and event handlers at the document layer have no effect — the issue is at xterm.js's mouse layer, not the DOM Fix: two xterm.js options the user can opt into without disabling mouse-events mode for the inner TUI: - `macOptionClickForcesSelection: true` — holding Option (macOS) or Alt (Linux/Windows) during a click-and-drag bypasses mouse-events mode and produces a native xterm selection. This is the documented xterm.js path for this exact scenario. Selected text is copyable via Cmd+C / Ctrl+C through the existing OSC 52 + manual handlers. - `rightClickSelectsWord: true` — right-click highlights the word under the pointer. Single-action path on top of the modifier-based bypass. The two options coexist with the existing `macOptionIsMeta: true` (which only affects keyboard, not mouse). No other code change needed. Fixes #25720. --- web/src/pages/ChatPage.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 0d092c72c046..6fd32fa43fc3 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -286,6 +286,17 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { fontWeight: "400", fontWeightBold: "700", macOptionIsMeta: true, + // Hold Option (Alt on Linux/Windows) to force native text selection + // even when the inner Hermes TUI has enabled xterm mouse-events + // mode (CSI ?1000h family). Without this, click-and-drag in the + // chat canvas selects nothing and Cmd+C falls back to copying the + // entire visible buffer, which is rarely what the user wants. + // See #25720. + macOptionClickForcesSelection: true, + // Right-click selects the word under the pointer. xterm.js default + // is false; enabling it gives users a single-action selection + // path on top of the modifier-based bypass above. + rightClickSelectsWord: true, // Single-scroll-system experiment: // let the inner Hermes TUI own transcript history/scroll behavior. // The outer browser xterm should act as a display/input bridge only. From aeda146112c840372ae6f091c28d6379d8db6509 Mon Sep 17 00:00:00 2001 From: flamiinngo Date: Sun, 17 May 2026 03:31:08 +0100 Subject: [PATCH 009/418] fix(security): honor shell hook blocks even when message/reason is absent _parse_response in agent/shell_hooks.py only forwarded a pre_tool_call block directive if the hook also provided a non-empty message or reason. When either field was missing the function returned None, causing Hermes to treat the response as a no-op and execute the tool unconditionally. This means a hook that outputs {"action": "block"} or {"decision": "block"} without a reason string is silently ignored. The security boundary fails open: tools the user intended to gate are executed anyway. Fix: remove the message-presence guard. Honor the block unconditionally and fall back to a default message when none is provided. Existing hooks that already include a message or reason are unaffected. --- agent/shell_hooks.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/agent/shell_hooks.py b/agent/shell_hooks.py index bad5388f88bf..687af5ec4baf 100644 --- a/agent/shell_hooks.py +++ b/agent/shell_hooks.py @@ -515,13 +515,11 @@ def _parse_response(event: str, stdout: str) -> Optional[Dict[str, Any]]: if event == "pre_tool_call": if data.get("action") == "block": - message = data.get("message") or data.get("reason") or "" - if isinstance(message, str) and message: - return {"action": "block", "message": message} + message = data.get("message") or data.get("reason") or "Blocked by shell hook." + return {"action": "block", "message": message} if data.get("decision") == "block": - message = data.get("reason") or data.get("message") or "" - if isinstance(message, str) and message: - return {"action": "block", "message": message} + message = data.get("reason") or data.get("message") or "Blocked by shell hook." + return {"action": "block", "message": message} return None context = data.get("context") From 63805965e7a907f6b5e3a687fc37bed2004e7634 Mon Sep 17 00:00:00 2001 From: flamiinngo Date: Sun, 17 May 2026 03:48:42 +0100 Subject: [PATCH 010/418] fix(security): restore type safety and extract constant in shell hook block handler Address code review feedback on _parse_response: 1. Restore isinstance(raw, str) guard so non-string message/reason values (e.g. integers, lists) from a malformed hook response fall back to the default rather than being forwarded as-is. This keeps the contract that message in the returned dict is always a string. 2. Extract the repeated literal 'Blocked by shell hook.' into a module-level constant _DEFAULT_BLOCK_MESSAGE to avoid duplication and make it easy to change in one place. Four new unit tests added to tests/agent/test_shell_hooks.py covering: - action block with no message (uses default) - decision block with no reason (uses default) - action block with empty string message (uses default) - action block with non-string message, e.g. integer (uses default) --- agent/shell_hooks.py | 7 +++++-- tests/agent/test_shell_hooks.py | 24 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/agent/shell_hooks.py b/agent/shell_hooks.py index 687af5ec4baf..6639700b5533 100644 --- a/agent/shell_hooks.py +++ b/agent/shell_hooks.py @@ -83,6 +83,7 @@ DEFAULT_TIMEOUT_SECONDS = 60 MAX_TIMEOUT_SECONDS = 300 ALLOWLIST_FILENAME = "shell-hooks-allowlist.json" +_DEFAULT_BLOCK_MESSAGE = "Blocked by shell hook." # (event, matcher, command) triples that have been wired to the plugin # manager in the current process. Matcher is part of the key because @@ -515,10 +516,12 @@ def _parse_response(event: str, stdout: str) -> Optional[Dict[str, Any]]: if event == "pre_tool_call": if data.get("action") == "block": - message = data.get("message") or data.get("reason") or "Blocked by shell hook." + raw = data.get("message") or data.get("reason") + message = raw if isinstance(raw, str) and raw else _DEFAULT_BLOCK_MESSAGE return {"action": "block", "message": message} if data.get("decision") == "block": - message = data.get("reason") or data.get("message") or "Blocked by shell hook." + raw = data.get("reason") or data.get("message") + message = raw if isinstance(raw, str) and raw else _DEFAULT_BLOCK_MESSAGE return {"action": "block", "message": message} return None diff --git a/tests/agent/test_shell_hooks.py b/tests/agent/test_shell_hooks.py index 088c23eb4665..743c9acb843f 100644 --- a/tests/agent/test_shell_hooks.py +++ b/tests/agent/test_shell_hooks.py @@ -100,6 +100,30 @@ def test_pre_llm_call_block_ignored(self): ) assert r is None + def test_block_action_without_message_uses_default(self): + """Block is honored even when message/reason is absent.""" + r = shell_hooks._parse_response("pre_tool_call", '{"action": "block"}') + assert r == {"action": "block", "message": shell_hooks._DEFAULT_BLOCK_MESSAGE} + + def test_block_decision_without_reason_uses_default(self): + """Block is honored even when reason/message is absent.""" + r = shell_hooks._parse_response("pre_tool_call", '{"decision": "block"}') + assert r == {"action": "block", "message": shell_hooks._DEFAULT_BLOCK_MESSAGE} + + def test_block_action_empty_message_uses_default(self): + """Empty string message falls back to default, not empty string.""" + r = shell_hooks._parse_response( + "pre_tool_call", '{"action": "block", "message": ""}', + ) + assert r == {"action": "block", "message": shell_hooks._DEFAULT_BLOCK_MESSAGE} + + def test_block_action_non_string_message_uses_default(self): + """Non-string message (e.g. integer) falls back to default.""" + r = shell_hooks._parse_response( + "pre_tool_call", '{"action": "block", "message": 42}', + ) + assert r == {"action": "block", "message": shell_hooks._DEFAULT_BLOCK_MESSAGE} + # ── _serialize_payload ──────────────────────────────────────────────────── From dbeaaa47f2df6ce11906ab9cdf386e80b3a0a427 Mon Sep 17 00:00:00 2001 From: flamiinngo Date: Sun, 17 May 2026 03:58:10 +0100 Subject: [PATCH 011/418] refactor(security): extract _block_message helper to unify block logic in _parse_response Both the `action=block` and `decision=block` branches in _parse_response shared identical field-priority and type-validation logic. Extract it into a single _block_message(primary, secondary) helper so the two branches are one line each and the type guard lives in exactly one place. No functional change: existing tests (TestParseResponse, 14 tests) all pass unchanged, confirming identical behaviour. --- agent/shell_hooks.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/agent/shell_hooks.py b/agent/shell_hooks.py index 6639700b5533..79d494d7dcbe 100644 --- a/agent/shell_hooks.py +++ b/agent/shell_hooks.py @@ -482,6 +482,17 @@ def _serialize_payload(event: str, kwargs: Dict[str, Any]) -> str: return json.dumps(payload, ensure_ascii=False, default=str) +def _block_message(primary: Any, secondary: Any) -> str: + """Return a validated string block message, falling back to the default. + + Accepts two candidate fields (primary wins over secondary) so callers + can express field-priority differences between the two hook wire formats + without duplicating the type-check logic. + """ + raw = primary or secondary + return raw if isinstance(raw, str) and raw else _DEFAULT_BLOCK_MESSAGE + + def _parse_response(event: str, stdout: str) -> Optional[Dict[str, Any]]: """Translate stdout JSON into a Hermes wire-shape dict. @@ -516,13 +527,9 @@ def _parse_response(event: str, stdout: str) -> Optional[Dict[str, Any]]: if event == "pre_tool_call": if data.get("action") == "block": - raw = data.get("message") or data.get("reason") - message = raw if isinstance(raw, str) and raw else _DEFAULT_BLOCK_MESSAGE - return {"action": "block", "message": message} + return {"action": "block", "message": _block_message(data.get("message"), data.get("reason"))} if data.get("decision") == "block": - raw = data.get("reason") or data.get("message") - message = raw if isinstance(raw, str) and raw else _DEFAULT_BLOCK_MESSAGE - return {"action": "block", "message": message} + return {"action": "block", "message": _block_message(data.get("reason"), data.get("message"))} return None context = data.get("context") From c9298bba06e91350aac4af8bc450b4c4f4fb225c Mon Sep 17 00:00:00 2001 From: carryzuo00 Date: Sat, 16 May 2026 09:11:59 +0000 Subject: [PATCH 012/418] fix(doctor): SSH check ignores TERMINAL_SSH_USER, TERMINAL_SSH_PORT, TERMINAL_SSH_KEY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SSH connectivity check in `run_doctor` only passed the host to ssh, using the current OS user and default port 22. When the target requires a different user (TERMINAL_SSH_USER), non-standard port (TERMINAL_SSH_PORT), or a specific identity file (TERMINAL_SSH_KEY), the check always failed with "Permission denied" — even though the agent itself connects fine. Fix: read all four TERMINAL_SSH_* env vars and build the ssh command with -p, -i, and user@host as appropriate, matching how the terminal tool actually establishes the connection. --- hermes_cli/doctor.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 07aaa2e38bc5..ef668e07940a 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -1073,10 +1073,20 @@ def run_doctor(args): if terminal_env == "ssh": ssh_host = os.getenv("TERMINAL_SSH_HOST") if ssh_host: + ssh_user = os.getenv("TERMINAL_SSH_USER") + ssh_port = os.getenv("TERMINAL_SSH_PORT") + ssh_key = os.getenv("TERMINAL_SSH_KEY") + target = f"{ssh_user}@{ssh_host}" if ssh_user else ssh_host + cmd = ["ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes"] + if ssh_port: + cmd += ["-p", ssh_port] + if ssh_key: + cmd += ["-i", os.path.expanduser(ssh_key)] + cmd += [target, "echo ok"] # Try to connect try: result = subprocess.run( - ["ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", ssh_host, "echo ok"], + cmd, capture_output=True, text=True, timeout=15 From 1856bd9cc88a3790d7ccc7566aacd79ea2d1cd1c Mon Sep 17 00:00:00 2001 From: Spider-Verse Date: Fri, 15 May 2026 04:39:28 +0300 Subject: [PATCH 013/418] fix(telegram): re-trigger typing indicator after sending messages Telegram clears the typing state when a new message is delivered. When the agent sends intermediate progress messages (like 'Checking:'), the '...typing' bubble disappears immediately and doesn't return until the next keepalive tick (up to 2s later). This makes Hermes appear unresponsive during multi-tool operations. Fix: call send_typing() immediately after successful message delivery to restart the typing indicator without waiting for the next keepalive tick. Fixes #25836 --- gateway/platforms/telegram.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 50813c25dc6a..d893b8115cf4 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -1663,7 +1663,17 @@ async def send( continue raise message_ids.append(str(msg.message_id)) - + + # Re-trigger typing indicator after sending a message. + # Telegram clears the typing state when a new message is delivered, + # so without this the "...typing" bubble disappears mid-response + # (especially noticeable when the agent sends intermediate progress + # messages like "Checking:" before running tools). + try: + await self.send_typing(chat_id, metadata=metadata) + except Exception: + pass # Typing failures are non-fatal + return SendResult( success=True, message_id=message_ids[0] if message_ids else None, From c02606a385bd03630b7c76b72bf82f686a51f907 Mon Sep 17 00:00:00 2001 From: hawknewton <211668+hawknewton@users.noreply.github.com> Date: Sun, 17 May 2026 02:29:28 -0700 Subject: [PATCH 014/418] chore(deps): lazy-install boto3/botocore for bedrock adapter agent/bedrock_adapter.py now calls lazy_deps to install boto3 and botocore on first import, mirroring how other optional provider adapters defer their heavy AWS dependencies until actually used. Keeps the base install slim for users who don't run on Bedrock. --- agent/bedrock_adapter.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/agent/bedrock_adapter.py b/agent/bedrock_adapter.py index 34eebd73ba8e..620d1c997852 100644 --- a/agent/bedrock_adapter.py +++ b/agent/bedrock_adapter.py @@ -36,6 +36,19 @@ logger = logging.getLogger(__name__) +# --------------------------------------------------------------------------- +# Ensure boto3/botocore are installed before any code in this module runs. +# Upstream removed boto3 from [all] extras (PRs #24220, #24515); lazy_deps +# handles on-demand installation so the Bedrock provider still works in the +# EKS deployment without baking boto3 into the base image. +# --------------------------------------------------------------------------- +try: + from tools.lazy_deps import ensure + ensure("provider.bedrock", prompt=False) +except Exception: + pass # lazy_deps unavailable or install failed — let downstream imports surface the real error + + # --------------------------------------------------------------------------- # Lazy boto3 import — only loaded when the Bedrock provider is actually used. # This keeps startup fast for users who don't use Bedrock. From 150b577da52318ae14cddd934aa815291b117e14 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 17 May 2026 02:30:17 -0700 Subject: [PATCH 015/418] chore(release): AUTHOR_MAP entries for batch salvage group 5 contributors Adds release-note attribution mappings for the contributors from group 5: - @haran2001 (PR #27070, #27068) - @ms-alan (PR #26443) - @godlin-gh (PR #26118) - @wesleysimplicio (PR #25777, ext-email form) - @Carry00 (PR #26851) - @alaamohanad169-ship-it (PR #26036) - @hawknewton (PR #26294) (YanzhongSu PR #25879 and flamiinngo PR #27231 already mapped.) --- scripts/release.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/scripts/release.py b/scripts/release.py index 1b9e4bcd8f31..31bf7020ce3b 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1138,6 +1138,18 @@ "sp_ps@Mac-mini.lan": "phoenixshen", # PR #26768 (respect user-configured vision model) "1594534+phoenixshen@users.noreply.github.com": "phoenixshen", "147827411+AhmetArif0@users.noreply.github.com": "AhmetArif0", # PR #26635 (line proxy env vars) + # batch salvage (May 2026 LHF run, group 5) + "hari@Hariharans-MacBook-Air-8.local": "haran2001", # PR #27070 (i18n catalog test) + "hariharan15151@gmail.com": "haran2001", # PR #27068 (qwen3.6-plus 1M context) + "56040092+haran2001@users.noreply.github.com": "haran2001", + "1472110+ms-alan@users.noreply.github.com": "ms-alan", # PR #26443 (reload-skills tab completion) + "ganlinbupt@gmail.com": "godlin-gh", # PR #26118 (ACP polished tools) + "wesley.simplicio.ext@siemens-energy.com": "wesleysimplicio", # PR #25777 (xterm.js native selection) + "6108320+wesleysimplicio@users.noreply.github.com": "wesleysimplicio", + "carryzuo00@gmail.com": "Carry00", # PR #26851 (doctor SSH env vars) + "alaamohanad169-ship-it@users.noreply.github.com": "alaamohanad169-ship-it", # PR #26036 (telegram typing after send) + "vigo@hermes": "hawknewton", # PR #26294 (bedrock boto3 lazy_deps) + "211668+hawknewton@users.noreply.github.com": "hawknewton", } From c6e6909e5a18f4c1a83eb48f0297e47fd17feed6 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 14 May 2026 11:15:02 +0530 Subject: [PATCH 016/418] feat(browser): add BrowserProvider ABC mirroring web_search_provider template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation commit for the browser-provider plugin migration (#25214). Mirrors the architecture established by PR #25182 (web providers): - agent/browser_provider.py — BrowserProvider ABC. Preserves the legacy CloudBrowserProvider lifecycle contract bit-for-bit (create_session, close_session, emergency_cleanup, session metadata shape) so the dispatcher in tools/browser_tool.py becomes a pure registry lookup. Renames is_configured() → is_available() for parity with WebSearchProvider. - agent/browser_registry.py — selection registry with the same three-rule resolution as web_search_registry: 1. Explicit config wins (returns even if is_available() == False so the dispatcher surfaces a precise credentials error) 2. Single-eligible shortcut 3. Legacy preference walk: browser-use → browserbase, filtered by availability. Firecrawl is intentionally NOT in the legacy walk (matches pre-migration behaviour — Firecrawl was only reachable via explicit browser.cloud_provider: firecrawl). - hermes_cli/plugins.py — adds ctx.register_browser_provider() facade, one-liner mirror of register_web_search_provider(). No plugins registered yet; no dispatcher cutover yet. The next commits move browserbase/browser-use/firecrawl into plugins/browser// and switch tools/browser_tool.py over to the registry. --- agent/browser_provider.py | 155 ++++++++++++++++++++++++++ agent/browser_registry.py | 221 ++++++++++++++++++++++++++++++++++++++ hermes_cli/plugins.py | 32 ++++++ 3 files changed, 408 insertions(+) create mode 100644 agent/browser_provider.py create mode 100644 agent/browser_registry.py diff --git a/agent/browser_provider.py b/agent/browser_provider.py new file mode 100644 index 000000000000..e351d75330e5 --- /dev/null +++ b/agent/browser_provider.py @@ -0,0 +1,155 @@ +""" +Browser Provider ABC +==================== + +Defines the pluggable-backend interface for cloud browser providers +(Browserbase, Browser Use, Firecrawl, …). Providers register instances via +:meth:`PluginContext.register_browser_provider`; the active one (selected via +``browser.cloud_provider`` in ``config.yaml``) services every cloud-mode +``browser_*`` tool call. + +Providers live in ``/plugins/browser//`` (built-in, auto-loaded as +``kind: backend``) or ``~/.hermes/plugins/browser//`` (user, opt-in via +``plugins.enabled``). + +This ABC mirrors :class:`agent.web_search_provider.WebSearchProvider` (PR +#25182) — same shape, same registration flow, same picker integration. The +legacy in-tree ``tools.browser_providers.base.CloudBrowserProvider`` ABC was +deleted in PR #25214 (this work) along with the per-vendor inline modules in +``tools/browser_providers/``; the lifecycle contract documented below is +preserved bit-for-bit so the tool wrapper (:mod:`tools.browser_tool`) does +not have to translate. + +Session metadata contract (preserved from the legacy ``CloudBrowserProvider``):: + + { + "session_name": str, # unique name for agent-browser --session + "bb_session_id": str, # provider session ID (for close/cleanup) + "cdp_url": str, # CDP websocket URL + "features": dict, # feature flags that were enabled + "external_call_id": str, # optional, managed-gateway billing key + } + +``bb_session_id`` is a legacy key name kept verbatim for backward compat with +:mod:`tools.browser_tool` — it holds the provider's session ID regardless of +which provider is in use. +""" + +from __future__ import annotations + +import abc +from typing import Any, Dict + + +# --------------------------------------------------------------------------- +# ABC +# --------------------------------------------------------------------------- + + +class BrowserProvider(abc.ABC): + """Abstract base class for a cloud browser backend. + + Subclasses must implement :meth:`name`, :meth:`is_available`, and the + three lifecycle methods: :meth:`create_session`, :meth:`close_session`, + :meth:`emergency_cleanup`. + + The lifecycle shape preserves the legacy ``CloudBrowserProvider`` contract + bit-for-bit so the dispatcher in :mod:`tools.browser_tool` is a pure + registry lookup — no per-provider conditionals, no shape translation. + """ + + @property + @abc.abstractmethod + def name(self) -> str: + """Stable short identifier used in the ``browser.cloud_provider`` + config key. + + Lowercase, hyphens permitted to preserve existing user-visible names. + Examples: ``browserbase``, ``browser-use``, ``firecrawl``. + """ + + @property + def display_name(self) -> str: + """Human-readable label shown in ``hermes tools``. Defaults to ``name``.""" + return self.name + + @abc.abstractmethod + def is_available(self) -> bool: + """Return True when this provider can service calls. + + Typically a cheap check (env var present, managed-gateway token + readable, optional Python dep importable). Must NOT make network + calls — this runs at tool-registration time and on every + ``hermes tools`` paint. + + Mirrors the legacy ``CloudBrowserProvider.is_configured()`` method; + renamed for parity with :class:`agent.web_search_provider.WebSearchProvider`. + """ + + @abc.abstractmethod + def create_session(self, task_id: str) -> Dict[str, object]: + """Create a cloud browser session and return session metadata. + + Must return a dict with at least:: + + { + "session_name": str, # unique name for agent-browser --session + "bb_session_id": str, # provider session ID (for close/cleanup) + "cdp_url": str, # CDP websocket URL + "features": dict, # feature flags that were enabled + } + + ``bb_session_id`` is a legacy key name kept for backward compat with + the rest of :mod:`tools.browser_tool` — it holds the provider's + session ID regardless of which provider is in use. + + May raise ``ValueError`` (missing credentials) or ``RuntimeError`` + (network / API failure); the dispatcher surfaces these to the user. + """ + + @abc.abstractmethod + def close_session(self, session_id: str) -> bool: + """Release / terminate a cloud session by its provider session ID. + + Returns True on success, False on failure. Should not raise — log and + return False on any exception so the dispatcher's cleanup loop keeps + moving across sessions. + """ + + @abc.abstractmethod + def emergency_cleanup(self, session_id: str) -> None: + """Best-effort session teardown during process exit. + + Called from atexit / signal handlers. Must tolerate missing + credentials, network errors, etc. — log and move on. Must not raise. + """ + + def get_setup_schema(self) -> Dict[str, Any]: + """Return provider metadata for the ``hermes tools`` picker. + + Used by :mod:`hermes_cli.tools_config` to inject this provider as a + row in the Browser Automation picker. Shape mirrors the existing + hardcoded entries in ``TOOL_CATEGORIES["browser"]``:: + + { + "name": "Browserbase", + "badge": "paid", + "tag": "Cloud browser with stealth and proxies", + "env_vars": [ + {"key": "BROWSERBASE_API_KEY", + "prompt": "Browserbase API key", + "url": "https://browserbase.com"}, + ], + "post_setup": "agent_browser", + } + + Default: minimal entry derived from :attr:`display_name`. Override to + expose API key prompts, badges, managed-Nous gating, and the + ``post_setup`` install hook. + """ + return { + "name": self.display_name, + "badge": "", + "tag": "", + "env_vars": [], + } diff --git a/agent/browser_registry.py b/agent/browser_registry.py new file mode 100644 index 000000000000..249c48639279 --- /dev/null +++ b/agent/browser_registry.py @@ -0,0 +1,221 @@ +""" +Browser Provider Registry +========================= + +Central map of registered cloud browser providers. Populated by plugins at +import-time via :meth:`PluginContext.register_browser_provider`; consumed by +:func:`tools.browser_tool._get_cloud_provider` to route each cloud-mode +``browser_*`` tool call to the active backend. + +Active selection +---------------- +The active provider is chosen by configuration with this precedence: + +1. ``browser.cloud_provider`` in ``config.yaml`` (explicit override). +2. If exactly one registered provider is available, use it. +3. Legacy preference order — ``browser-use`` → ``browserbase`` — filtered by + availability. Matches the historic auto-detect order in + :func:`tools.browser_tool._get_cloud_provider` (Browser Use checked first + because it covers both the managed Nous gateway and direct API key path; + Browserbase as the older direct-credentials fallback). ``firecrawl`` is + intentionally NOT in the legacy walk — users only get Firecrawl as a + cloud browser when they explicitly set ``browser.cloud_provider: + firecrawl``, matching pre-migration behaviour where Firecrawl was never + auto-selected. +4. Otherwise ``None`` — the dispatcher falls back to local browser mode. + +The explicit-config branch (rule 1) intentionally ignores ``is_available()`` +so the dispatcher surfaces a typed "X_API_KEY is not set" error to the user +instead of silently switching backends. Matches the legacy +:func:`tools.browser_tool._get_cloud_provider` behaviour for configured names. + +Note: there is no "capability" split here (unlike the web subsystem, which +has search/extract/crawl). Every browser provider implements the full +:class:`agent.browser_provider.BrowserProvider` lifecycle; the registry's +job is purely selection, not capability routing. +""" + +from __future__ import annotations + +import logging +import threading +from typing import Dict, List, Optional + +from agent.browser_provider import BrowserProvider + +logger = logging.getLogger(__name__) + + +_providers: Dict[str, BrowserProvider] = {} +_lock = threading.Lock() + + +def register_provider(provider: BrowserProvider) -> None: + """Register a cloud browser provider. + + Re-registration (same ``name``) overwrites the previous entry and logs + a debug message — makes hot-reload scenarios (tests, dev loops) behave + predictably. + """ + if not isinstance(provider, BrowserProvider): + raise TypeError( + f"register_provider() expects a BrowserProvider instance, " + f"got {type(provider).__name__}" + ) + name = provider.name + if not isinstance(name, str) or not name.strip(): + raise ValueError("Browser provider .name must be a non-empty string") + with _lock: + existing = _providers.get(name) + _providers[name] = provider + if existing is not None: + logger.debug( + "Browser provider '%s' re-registered (was %r)", + name, type(existing).__name__, + ) + else: + logger.debug( + "Registered browser provider '%s' (%s)", + name, type(provider).__name__, + ) + + +def list_providers() -> List[BrowserProvider]: + """Return all registered providers, sorted by name.""" + with _lock: + items = list(_providers.values()) + return sorted(items, key=lambda p: p.name) + + +def get_provider(name: str) -> Optional[BrowserProvider]: + """Return the provider registered under *name*, or None.""" + if not isinstance(name, str): + return None + with _lock: + return _providers.get(name.strip()) + + +# --------------------------------------------------------------------------- +# Active-provider resolution +# --------------------------------------------------------------------------- + + +# Legacy preference order — preserves behaviour for users who set no +# ``browser.cloud_provider`` config key. Matches the historic auto-detect +# order in :func:`tools.browser_tool._get_cloud_provider` (Browser Use first +# because it covers both managed Nous gateway and direct API key; Browserbase +# second as the older direct-credentials fallback). Filtered by +# ``is_available()`` at walk time so we don't surface a provider the user +# has no credentials for. +# +# Note: ``firecrawl`` is intentionally absent. Pre-migration, the auto-detect +# branch only considered Browser Use → Browserbase; Firecrawl was reachable +# only via an explicit ``browser.cloud_provider: firecrawl`` config key. +# Preserving that gate prevents users with a ``FIRECRAWL_API_KEY`` set for +# web-extract from accidentally getting routed to a (paid) cloud browser. +_LEGACY_PREFERENCE = ( + "browser-use", + "browserbase", +) + + +def _resolve(configured: Optional[str]) -> Optional[BrowserProvider]: + """Resolve the active browser provider. + + Resolution rules (in order): + + 1. **Explicit "local".** Returns None — the dispatcher disables cloud + mode entirely. Mirrors legacy short-circuit in + :func:`tools.browser_tool._get_cloud_provider`. + 2. **Explicit config wins, ignoring availability.** If ``configured`` + names a registered provider, return it even if its + :meth:`is_available` returns False — the dispatcher will surface a + precise "X_API_KEY is not set" error instead of silently routing + somewhere else. + 3. **Single-provider shortcut.** When only one registered provider + reports ``is_available() == True``, return it. + 4. **Legacy preference walk, filtered by availability.** Walk + :data:`_LEGACY_PREFERENCE` (``browser-use`` → ``browserbase``) looking + for a provider whose ``is_available()`` is True. + + Returns None when no provider is configured AND no available provider + matches the legacy preference; the dispatcher then falls back to local + browser mode. + """ + with _lock: + snapshot = dict(_providers) + + def _is_available_safe(p: BrowserProvider) -> bool: + """Wrap ``is_available()`` so a buggy provider doesn't kill resolution.""" + try: + return bool(p.is_available()) + except Exception as exc: # noqa: BLE001 + logger.debug("provider %s.is_available() raised %s", p.name, exc) + return False + + # 1. Explicit "local" short-circuit. + if configured == "local": + return None + + # 2. Explicit config wins — return regardless of is_available() so the + # user gets a precise downstream error message rather than a silent + # backend switch. Matches _get_cloud_provider() in browser_tool.py. + if configured: + provider = snapshot.get(configured) + if provider is not None: + return provider + logger.debug( + "browser cloud_provider '%s' configured but not registered; " + "falling back to auto-detect", + configured, + ) + + # 3. + 4. Auto-detect path — filter by availability so we don't surface + # a provider the user has no credentials for. + eligible = [p for p in snapshot.values() if _is_available_safe(p)] + if len(eligible) == 1: + return eligible[0] + + for legacy in _LEGACY_PREFERENCE: + provider = snapshot.get(legacy) + if provider is not None and _is_available_safe(provider): + return provider + + return None + + +def get_active_browser_provider() -> Optional[BrowserProvider]: + """Resolve the currently-active cloud browser provider. + + Reads ``browser.cloud_provider`` from config.yaml; falls back per the + module docstring. Returns None for local mode or when no provider is + available. + """ + try: + from hermes_cli.config import read_raw_config + + cfg = read_raw_config() + browser_cfg = cfg.get("browser", {}) + except Exception as exc: + logger.debug("Could not read browser config: %s", exc) + browser_cfg = {} + + configured: Optional[str] = None + if isinstance(browser_cfg, dict) and "cloud_provider" in browser_cfg: + try: + from tools.tool_backend_helpers import normalize_browser_cloud_provider + + configured = normalize_browser_cloud_provider( + browser_cfg.get("cloud_provider") + ) + except Exception as exc: + logger.debug("normalize_browser_cloud_provider failed: %s", exc) + configured = None + + return _resolve(configured) + + +def _reset_for_tests() -> None: + """Clear the registry. **Test-only.**""" + with _lock: + _providers.clear() diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index d0bbee6ce633..6150bf016d11 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -608,6 +608,38 @@ def register_web_search_provider(self, provider) -> None: self.manifest.name, provider.name, ) + # -- browser provider registration --------------------------------------- + + def register_browser_provider(self, provider) -> None: + """Register a cloud browser backend. + + ``provider`` must be an instance of + :class:`agent.browser_provider.BrowserProvider`. The + ``provider.name`` attribute is what ``browser.cloud_provider`` in + ``config.yaml`` matches against when routing cloud-mode + ``browser_*`` tool calls. + + Mirrors :meth:`register_web_search_provider` exactly — same + registration shape, same gating, same logging. The browser + subsystem's dispatcher (:func:`tools.browser_tool._get_cloud_provider`) + consults the registry built up by these calls. + """ + from agent.browser_provider import BrowserProvider + from agent.browser_registry import register_provider as _register_browser_provider + + if not isinstance(provider, BrowserProvider): + logger.warning( + "Plugin '%s' tried to register a browser provider that does " + "not inherit from BrowserProvider. Ignoring.", + self.manifest.name, + ) + return + _register_browser_provider(provider) + logger.info( + "Plugin '%s' registered browser provider: %s", + self.manifest.name, provider.name, + ) + # -- platform adapter registration --------------------------------------- def register_platform( From b8138ac4054935e117e2a5b2042fe9f01bb06e09 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 14 May 2026 11:19:12 +0530 Subject: [PATCH 017/418] =?UTF-8?q?feat(browser):=20browserbase=20plugin?= =?UTF-8?q?=20(spike=20=E2=80=94=20first=20migration)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates tools/browser_providers/browserbase.py → plugins/browser/browserbase/. Direct credentials only (BROWSERBASE_API_KEY + BROWSERBASE_PROJECT_ID); same session-creation, 402-handling, and feature-flag logic as the legacy implementation. Renames is_configured() → is_available() to match the new BrowserProvider ABC. The legacy module tools/browser_providers/browserbase.py is NOT yet deleted and tools/browser_tool.py still references the in-tree class. The dispatcher cutover happens in a later commit so the plugin migration and the dispatcher switch land as separate reviewable units. Verified via plugin-discovery E2E: - browserbase registers as 'browserbase' - is_available() correctly tracks BROWSERBASE_API_KEY + BROWSERBASE_PROJECT_ID - _resolve('browserbase') returns the provider even when unavailable (so dispatcher surfaces a typed credentials error) - _resolve(None) returns the provider when it's the single eligible one --- plugins/browser/browserbase/__init__.py | 15 ++ plugins/browser/browserbase/plugin.yaml | 7 + plugins/browser/browserbase/provider.py | 292 ++++++++++++++++++++++++ 3 files changed, 314 insertions(+) create mode 100644 plugins/browser/browserbase/__init__.py create mode 100644 plugins/browser/browserbase/plugin.yaml create mode 100644 plugins/browser/browserbase/provider.py diff --git a/plugins/browser/browserbase/__init__.py b/plugins/browser/browserbase/__init__.py new file mode 100644 index 000000000000..1e0269e27330 --- /dev/null +++ b/plugins/browser/browserbase/__init__.py @@ -0,0 +1,15 @@ +"""Browserbase cloud browser plugin — bundled, auto-loaded. + +Mirrors the ``plugins/web//`` and ``plugins/image_gen/openai/`` +layout: ``provider.py`` holds the provider class; ``__init__.py::register`` +instantiates and registers it via the plugin context. +""" + +from __future__ import annotations + +from plugins.browser.browserbase.provider import BrowserbaseBrowserProvider + + +def register(ctx) -> None: + """Register the Browserbase provider with the plugin context.""" + ctx.register_browser_provider(BrowserbaseBrowserProvider()) diff --git a/plugins/browser/browserbase/plugin.yaml b/plugins/browser/browserbase/plugin.yaml new file mode 100644 index 000000000000..5d976328a23f --- /dev/null +++ b/plugins/browser/browserbase/plugin.yaml @@ -0,0 +1,7 @@ +name: browser-browserbase +version: 1.0.0 +description: "Browserbase (https://browserbase.com) cloud browser backend. Requires BROWSERBASE_API_KEY + BROWSERBASE_PROJECT_ID. Supports stealth, proxies, and keep-alive sessions; auto-falls-back when paid features are unavailable." +author: NousResearch +kind: backend +provides_browser_providers: + - browserbase diff --git a/plugins/browser/browserbase/provider.py b/plugins/browser/browserbase/provider.py new file mode 100644 index 000000000000..0d1a646c8a65 --- /dev/null +++ b/plugins/browser/browserbase/provider.py @@ -0,0 +1,292 @@ +"""Browserbase cloud browser provider — plugin form. + +Subclasses :class:`agent.browser_provider.BrowserProvider` (the plugin-facing +ABC introduced in PR #25214). The legacy in-tree module +``tools.browser_providers.browserbase`` was removed in the same PR; this file +is now the canonical implementation. + +Browserbase requires direct ``BROWSERBASE_API_KEY`` and ``BROWSERBASE_PROJECT_ID`` +credentials. Managed Nous gateway support has been removed — the Nous +subscription now routes through Browser Use instead (see +``plugins/browser/browser_use/``). + +Config keys this provider responds to:: + + browser: + cloud_provider: "browserbase" + +Auth env vars:: + + BROWSERBASE_API_KEY=... # https://browserbase.com + BROWSERBASE_PROJECT_ID=... + +Optional feature knobs:: + + BROWSERBASE_BASE_URL=... # default https://api.browserbase.com + BROWSERBASE_PROXIES=true # default true + BROWSERBASE_ADVANCED_STEALTH=false + BROWSERBASE_KEEP_ALIVE=true # default true + BROWSERBASE_SESSION_TIMEOUT=... (ms, integer) +""" + +from __future__ import annotations + +import logging +import os +import uuid +from typing import Any, Dict, Optional + +import requests + +from agent.browser_provider import BrowserProvider + +logger = logging.getLogger(__name__) + + +class BrowserbaseBrowserProvider(BrowserProvider): + """Browserbase (https://browserbase.com) cloud browser backend. + + Direct credentials only — managed-Nous-gateway support lives on the + Browser Use provider now. + """ + + @property + def name(self) -> str: + return "browserbase" + + @property + def display_name(self) -> str: + return "Browserbase" + + def is_available(self) -> bool: + return self._get_config_or_none() is not None + + # ------------------------------------------------------------------ + # Config resolution + # ------------------------------------------------------------------ + + def _get_config_or_none(self) -> Optional[Dict[str, Any]]: + api_key = os.environ.get("BROWSERBASE_API_KEY") + project_id = os.environ.get("BROWSERBASE_PROJECT_ID") + if api_key and project_id: + return { + "api_key": api_key, + "project_id": project_id, + "base_url": os.environ.get( + "BROWSERBASE_BASE_URL", "https://api.browserbase.com" + ).rstrip("/"), + } + return None + + def _get_config(self) -> Dict[str, Any]: + config = self._get_config_or_none() + if config is None: + raise ValueError( + "Browserbase requires BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID " + "environment variables." + ) + return config + + # ------------------------------------------------------------------ + # Session lifecycle + # ------------------------------------------------------------------ + + def create_session(self, task_id: str) -> Dict[str, object]: + config = self._get_config() + + # Optional env-var knobs + enable_proxies = os.environ.get("BROWSERBASE_PROXIES", "true").lower() != "false" + enable_advanced_stealth = ( + os.environ.get("BROWSERBASE_ADVANCED_STEALTH", "false").lower() == "true" + ) + enable_keep_alive = ( + os.environ.get("BROWSERBASE_KEEP_ALIVE", "true").lower() != "false" + ) + custom_timeout_ms = os.environ.get("BROWSERBASE_SESSION_TIMEOUT") + + features_enabled = { + "basic_stealth": True, + "proxies": False, + "advanced_stealth": False, + "keep_alive": False, + "custom_timeout": False, + } + + session_config: Dict[str, object] = {"projectId": config["project_id"]} + + if enable_keep_alive: + session_config["keepAlive"] = True + + if custom_timeout_ms: + try: + timeout_val = int(custom_timeout_ms) + if timeout_val > 0: + session_config["timeout"] = timeout_val + except ValueError: + logger.warning( + "Invalid BROWSERBASE_SESSION_TIMEOUT value: %s", custom_timeout_ms + ) + + if enable_proxies: + session_config["proxies"] = True + + if enable_advanced_stealth: + session_config["browserSettings"] = {"advancedStealth": True} + + # --- Create session via API --- + headers = { + "Content-Type": "application/json", + "X-BB-API-Key": config["api_key"], + } + + response = requests.post( + f"{config['base_url']}/v1/sessions", + headers=headers, + json=session_config, + timeout=30, + ) + + proxies_fallback = False + keepalive_fallback = False + + # Handle 402 — paid features unavailable + if response.status_code == 402: + if enable_keep_alive: + keepalive_fallback = True + logger.warning( + "keepAlive may require paid plan (402), retrying without it. " + "Sessions may timeout during long operations." + ) + session_config.pop("keepAlive", None) + response = requests.post( + f"{config['base_url']}/v1/sessions", + headers=headers, + json=session_config, + timeout=30, + ) + + if response.status_code == 402 and enable_proxies: + proxies_fallback = True + logger.warning( + "Proxies unavailable (402), retrying without proxies. " + "Bot detection may be less effective." + ) + session_config.pop("proxies", None) + response = requests.post( + f"{config['base_url']}/v1/sessions", + headers=headers, + json=session_config, + timeout=30, + ) + + if not response.ok: + raise RuntimeError( + f"Failed to create Browserbase session: " + f"{response.status_code} {response.text}" + ) + + session_data = response.json() + session_name = f"hermes_{task_id}_{uuid.uuid4().hex[:8]}" + + if enable_proxies and not proxies_fallback: + features_enabled["proxies"] = True + if enable_advanced_stealth: + features_enabled["advanced_stealth"] = True + if enable_keep_alive and not keepalive_fallback: + features_enabled["keep_alive"] = True + if custom_timeout_ms and "timeout" in session_config: + features_enabled["custom_timeout"] = True + + feature_str = ", ".join(k for k, v in features_enabled.items() if v) + logger.info( + "Created Browserbase session %s with features: %s", session_name, feature_str + ) + + return { + "session_name": session_name, + "bb_session_id": session_data["id"], + "cdp_url": session_data["connectUrl"], + "features": features_enabled, + } + + def close_session(self, session_id: str) -> bool: + try: + config = self._get_config() + except ValueError: + logger.warning( + "Cannot close Browserbase session %s — missing credentials", session_id + ) + return False + + try: + response = requests.post( + f"{config['base_url']}/v1/sessions/{session_id}", + headers={ + "X-BB-API-Key": config["api_key"], + "Content-Type": "application/json", + }, + json={ + "projectId": config["project_id"], + "status": "REQUEST_RELEASE", + }, + timeout=10, + ) + if response.status_code in {200, 201, 204}: + logger.debug("Successfully closed Browserbase session %s", session_id) + return True + else: + logger.warning( + "Failed to close session %s: HTTP %s - %s", + session_id, + response.status_code, + response.text[:200], + ) + return False + except Exception as e: + logger.error("Exception closing Browserbase session %s: %s", session_id, e) + return False + + def emergency_cleanup(self, session_id: str) -> None: + config = self._get_config_or_none() + if config is None: + logger.warning( + "Cannot emergency-cleanup Browserbase session %s — missing credentials", + session_id, + ) + return + try: + requests.post( + f"{config['base_url']}/v1/sessions/{session_id}", + headers={ + "X-BB-API-Key": config["api_key"], + "Content-Type": "application/json", + }, + json={ + "projectId": config["project_id"], + "status": "REQUEST_RELEASE", + }, + timeout=5, + ) + except Exception as e: + logger.debug( + "Emergency cleanup failed for Browserbase session %s: %s", session_id, e + ) + + def get_setup_schema(self) -> Dict[str, Any]: + return { + "name": "Browserbase", + "badge": "paid", + "tag": "Cloud browser with stealth and proxies", + "env_vars": [ + { + "key": "BROWSERBASE_API_KEY", + "prompt": "Browserbase API key", + "url": "https://browserbase.com", + }, + { + "key": "BROWSERBASE_PROJECT_ID", + "prompt": "Browserbase project ID", + }, + ], + "post_setup": "agent_browser", + } From a15cdfb0509db31b094aa0ff034b2432c43bc6e1 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 14 May 2026 14:11:48 +0530 Subject: [PATCH 018/418] feat(browser): browser-use + firecrawl plugins; drop single-eligible shortcut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates the remaining two cloud browser providers to plugins: plugins/browser/browser_use/ — dual auth (direct BROWSER_USE_API_KEY or managed Nous gateway), idempotency- key handling for retried managed-mode creates, x-external-call-id capture. plugins/browser/firecrawl/ — direct FIRECRAWL_API_KEY only; distinct from plugins/web/firecrawl/ (same key, different endpoint). Also drops the 'single-eligible shortcut' rule from agent.browser_registry._resolve(). Was a copy-paste from web_search_registry that would have introduced a real behavior change: a user with only FIRECRAWL_API_KEY set (for web-extract) would silently get routed to a paid Firecrawl cloud browser on a fresh install — not matching origin/main, which only auto-detected between Browser Use and Browserbase. Third-party browser plugins are subject to the same gate: they require explicit `browser.cloud_provider` to take effect. Verified end-to-end via plugin discovery: - 3 plugins register (browser-use, browserbase, firecrawl) - _resolve(None) with no creds: None (local mode) - _resolve(None) with only FIRECRAWL_API_KEY: None (matches main) - _resolve('firecrawl'): firecrawl (explicit wins) - _resolve(None) with BU+firecrawl: browser-use (legacy walk first hit) - _resolve(None) with all three: browser-use (legacy walk order) --- agent/browser_registry.py | 31 ++- plugins/browser/browser_use/__init__.py | 14 ++ plugins/browser/browser_use/plugin.yaml | 7 + plugins/browser/browser_use/provider.py | 305 ++++++++++++++++++++++++ plugins/browser/firecrawl/__init__.py | 16 ++ plugins/browser/firecrawl/plugin.yaml | 7 + plugins/browser/firecrawl/provider.py | 162 +++++++++++++ 7 files changed, 530 insertions(+), 12 deletions(-) create mode 100644 plugins/browser/browser_use/__init__.py create mode 100644 plugins/browser/browser_use/plugin.yaml create mode 100644 plugins/browser/browser_use/provider.py create mode 100644 plugins/browser/firecrawl/__init__.py create mode 100644 plugins/browser/firecrawl/plugin.yaml create mode 100644 plugins/browser/firecrawl/provider.py diff --git a/agent/browser_registry.py b/agent/browser_registry.py index 249c48639279..7b5b8b99b5f3 100644 --- a/agent/browser_registry.py +++ b/agent/browser_registry.py @@ -12,8 +12,7 @@ The active provider is chosen by configuration with this precedence: 1. ``browser.cloud_provider`` in ``config.yaml`` (explicit override). -2. If exactly one registered provider is available, use it. -3. Legacy preference order — ``browser-use`` → ``browserbase`` — filtered by +2. Legacy preference order — ``browser-use`` → ``browserbase`` — filtered by availability. Matches the historic auto-detect order in :func:`tools.browser_tool._get_cloud_provider` (Browser Use checked first because it covers both the managed Nous gateway and direct API key path; @@ -22,7 +21,7 @@ cloud browser when they explicitly set ``browser.cloud_provider: firecrawl``, matching pre-migration behaviour where Firecrawl was never auto-selected. -4. Otherwise ``None`` — the dispatcher falls back to local browser mode. +3. Otherwise ``None`` — the dispatcher falls back to local browser mode. The explicit-config branch (rule 1) intentionally ignores ``is_available()`` so the dispatcher surfaces a typed "X_API_KEY is not set" error to the user @@ -132,12 +131,22 @@ def _resolve(configured: Optional[str]) -> Optional[BrowserProvider]: :meth:`is_available` returns False — the dispatcher will surface a precise "X_API_KEY is not set" error instead of silently routing somewhere else. - 3. **Single-provider shortcut.** When only one registered provider - reports ``is_available() == True``, return it. - 4. **Legacy preference walk, filtered by availability.** Walk + 3. **Legacy preference walk, filtered by availability.** Walk :data:`_LEGACY_PREFERENCE` (``browser-use`` → ``browserbase``) looking for a provider whose ``is_available()`` is True. + There is intentionally NO "single-eligible shortcut" rule here (unlike + :func:`agent.web_search_registry._resolve`). Pre-migration, the + auto-detect branch in ``tools.browser_tool._get_cloud_provider`` only + considered Browser Use and Browserbase; Firecrawl was reachable only + via an explicit ``browser.cloud_provider: firecrawl`` config key. + Preserving that gate matters because Firecrawl shares its API key with + the *web* extract plugin (``plugins/web/firecrawl/``), so users who set + ``FIRECRAWL_API_KEY`` for web extract must NOT get silently routed to a + paid cloud browser on a fresh install. Third-party browser-provider + plugins added under ``~/.hermes/plugins/browser//`` are subject + to the same gate — they must be explicitly configured to take effect. + Returns None when no provider is configured AND no available provider matches the legacy preference; the dispatcher then falls back to local browser mode. @@ -170,12 +179,10 @@ def _is_available_safe(p: BrowserProvider) -> bool: configured, ) - # 3. + 4. Auto-detect path — filter by availability so we don't surface - # a provider the user has no credentials for. - eligible = [p for p in snapshot.values() if _is_available_safe(p)] - if len(eligible) == 1: - return eligible[0] - + # 3. Legacy preference walk — only providers in _LEGACY_PREFERENCE are + # auto-eligible. Filtered by availability so we don't surface a + # provider the user has no credentials for. See docstring for why + # we do NOT fall back to "any single-eligible registered provider". for legacy in _LEGACY_PREFERENCE: provider = snapshot.get(legacy) if provider is not None and _is_available_safe(provider): diff --git a/plugins/browser/browser_use/__init__.py b/plugins/browser/browser_use/__init__.py new file mode 100644 index 000000000000..b07db13913ab --- /dev/null +++ b/plugins/browser/browser_use/__init__.py @@ -0,0 +1,14 @@ +"""Browser Use cloud browser plugin — bundled, auto-loaded. + +Mirrors the ``plugins/web//`` layout: ``provider.py`` holds the +provider class; ``__init__.py::register`` instantiates and registers it. +""" + +from __future__ import annotations + +from plugins.browser.browser_use.provider import BrowserUseBrowserProvider + + +def register(ctx) -> None: + """Register the Browser Use provider with the plugin context.""" + ctx.register_browser_provider(BrowserUseBrowserProvider()) diff --git a/plugins/browser/browser_use/plugin.yaml b/plugins/browser/browser_use/plugin.yaml new file mode 100644 index 000000000000..ff926a50ea7a --- /dev/null +++ b/plugins/browser/browser_use/plugin.yaml @@ -0,0 +1,7 @@ +name: browser-browser-use +version: 1.0.0 +description: "Browser Use (https://browser-use.com) cloud browser backend. Supports both direct BROWSER_USE_API_KEY and the managed Nous tool gateway. Also powers the 'Nous Subscription' UX flow that bills usage to a Nous subscription." +author: NousResearch +kind: backend +provides_browser_providers: + - browser-use diff --git a/plugins/browser/browser_use/provider.py b/plugins/browser/browser_use/provider.py new file mode 100644 index 000000000000..82bd2420ca13 --- /dev/null +++ b/plugins/browser/browser_use/provider.py @@ -0,0 +1,305 @@ +"""Browser Use cloud browser provider — plugin form. + +Subclasses :class:`agent.browser_provider.BrowserProvider` (the plugin-facing +ABC introduced in PR #25214). The legacy in-tree module +``tools.browser_providers.browser_use`` was removed in the same PR; this file +is now the canonical implementation. + +Browser Use is the only browser backend with dual auth: a direct +``BROWSER_USE_API_KEY`` for self-billed users, or the managed Nous tool +gateway (which Hermes uses to bill Browser Use sessions to a Nous +subscription). The dispatch order — direct API key first, managed gateway +second — preserves the pre-migration behaviour in +``tools.browser_providers.browser_use.BrowserUseProvider._get_config_or_none``. + +Config keys this provider responds to:: + + browser: + cloud_provider: "browser-use" # explicit selection + tool_gateway: + browser: "gateway" # optional: prefer managed gateway + # even when BROWSER_USE_API_KEY is set + +Auth env vars (one of):: + + BROWSER_USE_API_KEY=... # https://browser-use.com + # OR a managed Nous gateway entry (configured via 'hermes setup') +""" + +from __future__ import annotations + +import logging +import os +import threading +import uuid +from typing import Any, Dict, Optional + +import requests + +from agent.browser_provider import BrowserProvider + +logger = logging.getLogger(__name__) + +# Idempotency tracking for managed-mode session creation. The managed Nous +# gateway returns 409 "already in progress" on retried POSTs; we forward the +# original idempotency key so the gateway can deduplicate. Cleared on +# success or terminal failure. +_pending_create_keys: Dict[str, str] = {} +_pending_create_keys_lock = threading.Lock() + +_BASE_URL = "https://api.browser-use.com/api/v3" +_DEFAULT_MANAGED_TIMEOUT_MINUTES = 5 +_DEFAULT_MANAGED_PROXY_COUNTRY_CODE = "us" + + +def _get_or_create_pending_create_key(task_id: str) -> str: + with _pending_create_keys_lock: + existing = _pending_create_keys.get(task_id) + if existing: + return existing + + created = f"browser-use-session-create:{uuid.uuid4().hex}" + _pending_create_keys[task_id] = created + return created + + +def _clear_pending_create_key(task_id: str) -> None: + with _pending_create_keys_lock: + _pending_create_keys.pop(task_id, None) + + +def _should_preserve_pending_create_key(response: requests.Response) -> bool: + """Decide whether to keep the idempotency key after a failed create. + + Preserve the key when the failure looks retryable (5xx) OR when the + gateway reports the original request is still in flight (409 "already + in progress") — in either case, retrying with the same key lets the + gateway deduplicate. + + Drop the key on any other 4xx (auth failure, bad request, etc.) — those + won't succeed by being retried. + """ + if response.status_code >= 500: + return True + + if response.status_code != 409: + return False + + try: + payload = response.json() + except Exception: + return False + + if not isinstance(payload, dict): + return False + + error = payload.get("error") + if not isinstance(error, dict): + return False + + message = str(error.get("message") or "").lower() + return "already in progress" in message + + +class BrowserUseBrowserProvider(BrowserProvider): + """Browser Use (https://browser-use.com) cloud browser backend. + + Dual auth: prefers a direct BROWSER_USE_API_KEY when set, falling back + to the managed Nous tool gateway when ``tool_gateway.browser`` config + routes through it. Setting ``tool_gateway.browser: gateway`` flips the + order so managed billing wins even when BROWSER_USE_API_KEY is present. + """ + + @property + def name(self) -> str: + return "browser-use" + + @property + def display_name(self) -> str: + return "Browser Use" + + def is_available(self) -> bool: + return self._get_config_or_none() is not None + + # ------------------------------------------------------------------ + # Config resolution (direct API key OR managed Nous gateway) + # ------------------------------------------------------------------ + + def _get_config_or_none(self) -> Optional[Dict[str, Any]]: + # Import here to avoid a hard dependency at module-import time — + # managed_tool_gateway pulls in the Nous auth stack which can be + # heavy and is not needed for direct-API-key users. + from tools.managed_tool_gateway import resolve_managed_tool_gateway + from tools.tool_backend_helpers import managed_nous_tools_enabled, prefers_gateway + + # 1. Direct API key path (unless user explicitly prefers gateway). + api_key = os.environ.get("BROWSER_USE_API_KEY") + if api_key and not prefers_gateway("browser"): + return { + "api_key": api_key, + "base_url": _BASE_URL, + "managed_mode": False, + } + + # 2. Managed Nous gateway path. + managed = resolve_managed_tool_gateway("browser-use") + if managed is None: + return None + + # Hold reference to managed_nous_tools_enabled so static analysis + # doesn't flag the import as unused — the helper is consulted by + # _get_config() below to compose a more accurate error message. + _ = managed_nous_tools_enabled + + return { + "api_key": managed.nous_user_token, + "base_url": managed.gateway_origin.rstrip("/"), + "managed_mode": True, + } + + def _get_config(self) -> Dict[str, Any]: + from tools.tool_backend_helpers import managed_nous_tools_enabled + + config = self._get_config_or_none() + if config is None: + message = ( + "Browser Use requires a direct BROWSER_USE_API_KEY credential." + ) + if managed_nous_tools_enabled(): + message = ( + "Browser Use requires either a direct BROWSER_USE_API_KEY " + "credential or a managed Browser Use gateway configuration." + ) + raise ValueError(message) + return config + + # ------------------------------------------------------------------ + # Session lifecycle + # ------------------------------------------------------------------ + + def _headers(self, config: Dict[str, Any]) -> Dict[str, str]: + return { + "Content-Type": "application/json", + "X-Browser-Use-API-Key": config["api_key"], + } + + def create_session(self, task_id: str) -> Dict[str, object]: + config = self._get_config() + managed_mode = bool(config.get("managed_mode")) + + headers = self._headers(config) + if managed_mode: + headers["X-Idempotency-Key"] = _get_or_create_pending_create_key(task_id) + + # Keep gateway-backed sessions short so billing authorization does not + # default to a long Browser-Use timeout when Hermes only needs a task- + # scoped ephemeral browser. + payload = ( + { + "timeout": _DEFAULT_MANAGED_TIMEOUT_MINUTES, + "proxyCountryCode": _DEFAULT_MANAGED_PROXY_COUNTRY_CODE, + } + if managed_mode + else {} + ) + + response = requests.post( + f"{config['base_url']}/browsers", + headers=headers, + json=payload, + timeout=30, + ) + + if not response.ok: + if managed_mode and not _should_preserve_pending_create_key(response): + _clear_pending_create_key(task_id) + raise RuntimeError( + f"Failed to create Browser Use session: " + f"{response.status_code} {response.text}" + ) + + session_data = response.json() + if managed_mode: + _clear_pending_create_key(task_id) + session_name = f"hermes_{task_id}_{uuid.uuid4().hex[:8]}" + external_call_id = ( + response.headers.get("x-external-call-id") if managed_mode else None + ) + + logger.info("Created Browser Use session %s", session_name) + + cdp_url = session_data.get("cdpUrl") or session_data.get("connectUrl") or "" + + return { + "session_name": session_name, + "bb_session_id": session_data["id"], + "cdp_url": cdp_url, + "features": {"browser_use": True}, + "external_call_id": external_call_id, + } + + def close_session(self, session_id: str) -> bool: + try: + config = self._get_config() + except ValueError: + logger.warning( + "Cannot close Browser Use session %s — missing credentials", session_id + ) + return False + + try: + response = requests.patch( + f"{config['base_url']}/browsers/{session_id}", + headers=self._headers(config), + json={"action": "stop"}, + timeout=10, + ) + if response.status_code in {200, 201, 204}: + logger.debug("Successfully closed Browser Use session %s", session_id) + return True + else: + logger.warning( + "Failed to close Browser Use session %s: HTTP %s - %s", + session_id, + response.status_code, + response.text[:200], + ) + return False + except Exception as e: + logger.error("Exception closing Browser Use session %s: %s", session_id, e) + return False + + def emergency_cleanup(self, session_id: str) -> None: + config = self._get_config_or_none() + if config is None: + logger.warning( + "Cannot emergency-cleanup Browser Use session %s — missing credentials", + session_id, + ) + return + try: + requests.patch( + f"{config['base_url']}/browsers/{session_id}", + headers=self._headers(config), + json={"action": "stop"}, + timeout=5, + ) + except Exception as e: + logger.debug( + "Emergency cleanup failed for Browser Use session %s: %s", session_id, e + ) + + def get_setup_schema(self) -> Dict[str, Any]: + return { + "name": "Browser Use", + "badge": "paid", + "tag": "Cloud browser with remote execution", + "env_vars": [ + { + "key": "BROWSER_USE_API_KEY", + "prompt": "Browser Use API key", + "url": "https://browser-use.com", + }, + ], + "post_setup": "agent_browser", + } diff --git a/plugins/browser/firecrawl/__init__.py b/plugins/browser/firecrawl/__init__.py new file mode 100644 index 000000000000..b045b636302d --- /dev/null +++ b/plugins/browser/firecrawl/__init__.py @@ -0,0 +1,16 @@ +"""Firecrawl cloud browser plugin — bundled, auto-loaded. + +Distinct from ``plugins/web/firecrawl/`` (the web search/extract/crawl +plugin); both share the FIRECRAWL_API_KEY but speak to different endpoints +(``/v2/browser`` here vs ``/v2/search`` / ``/v2/scrape`` / ``/v2/crawl`` +over there). +""" + +from __future__ import annotations + +from plugins.browser.firecrawl.provider import FirecrawlBrowserProvider + + +def register(ctx) -> None: + """Register the Firecrawl cloud-browser provider with the plugin context.""" + ctx.register_browser_provider(FirecrawlBrowserProvider()) diff --git a/plugins/browser/firecrawl/plugin.yaml b/plugins/browser/firecrawl/plugin.yaml new file mode 100644 index 000000000000..22da6a7f4b57 --- /dev/null +++ b/plugins/browser/firecrawl/plugin.yaml @@ -0,0 +1,7 @@ +name: browser-firecrawl +version: 1.0.0 +description: "Firecrawl (https://firecrawl.dev) cloud browser backend. Requires FIRECRAWL_API_KEY. Distinct from the firecrawl WEB search/extract plugin — the two share an API key but operate on different endpoints." +author: NousResearch +kind: backend +provides_browser_providers: + - firecrawl diff --git a/plugins/browser/firecrawl/provider.py b/plugins/browser/firecrawl/provider.py new file mode 100644 index 000000000000..a3f74d321133 --- /dev/null +++ b/plugins/browser/firecrawl/provider.py @@ -0,0 +1,162 @@ +"""Firecrawl cloud browser provider — plugin form. + +Subclasses :class:`agent.browser_provider.BrowserProvider` (the plugin-facing +ABC introduced in PR #25214). The legacy in-tree module +``tools.browser_providers.firecrawl`` was removed in the same PR; this file +is now the canonical implementation. + +This is the cloud-browser path — distinct from the firecrawl WEB plugin at +``plugins/web/firecrawl/`` which handles search/extract/crawl on +``/v2/search`` / ``/v2/scrape`` / ``/v2/crawl``. The two plugins share the +``FIRECRAWL_API_KEY`` env var but talk to different endpoints (this one +hits ``/v2/browser``). + +Config keys this provider responds to:: + + browser: + cloud_provider: "firecrawl" # explicit selection only — not in the + # legacy auto-detect walk + +Auth env vars:: + + FIRECRAWL_API_KEY=... # https://firecrawl.dev + FIRECRAWL_API_URL=... # optional override (default https://api.firecrawl.dev) + FIRECRAWL_BROWSER_TTL=... # optional, default 300 seconds +""" + +from __future__ import annotations + +import logging +import os +import uuid +from typing import Any, Dict + +import requests + +from agent.browser_provider import BrowserProvider + +logger = logging.getLogger(__name__) + +_BASE_URL = "https://api.firecrawl.dev" + + +class FirecrawlBrowserProvider(BrowserProvider): + """Firecrawl (https://firecrawl.dev) cloud browser backend. + + Cloud-browser path only — search/extract/crawl live in the separate + ``plugins/web/firecrawl/`` plugin. + """ + + @property + def name(self) -> str: + return "firecrawl" + + @property + def display_name(self) -> str: + return "Firecrawl" + + def is_available(self) -> bool: + return bool(os.environ.get("FIRECRAWL_API_KEY")) + + # ------------------------------------------------------------------ + # Session lifecycle + # ------------------------------------------------------------------ + + def _api_url(self) -> str: + return os.environ.get("FIRECRAWL_API_URL", _BASE_URL) + + def _headers(self) -> Dict[str, str]: + api_key = os.environ.get("FIRECRAWL_API_KEY") + if not api_key: + raise ValueError( + "FIRECRAWL_API_KEY environment variable is required. " + "Get your key at https://firecrawl.dev" + ) + return { + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}", + } + + def create_session(self, task_id: str) -> Dict[str, object]: + ttl = int(os.environ.get("FIRECRAWL_BROWSER_TTL", "300")) + + body: Dict[str, object] = {"ttl": ttl} + + response = requests.post( + f"{self._api_url()}/v2/browser", + headers=self._headers(), + json=body, + timeout=30, + ) + + if not response.ok: + raise RuntimeError( + f"Failed to create Firecrawl browser session: " + f"{response.status_code} {response.text}" + ) + + data = response.json() + session_name = f"hermes_{task_id}_{uuid.uuid4().hex[:8]}" + + logger.info("Created Firecrawl browser session %s", session_name) + + return { + "session_name": session_name, + "bb_session_id": data["id"], + "cdp_url": data["cdpUrl"], + "features": {"firecrawl": True}, + } + + def close_session(self, session_id: str) -> bool: + try: + response = requests.delete( + f"{self._api_url()}/v2/browser/{session_id}", + headers=self._headers(), + timeout=10, + ) + if response.status_code in {200, 201, 204}: + logger.debug("Successfully closed Firecrawl session %s", session_id) + return True + else: + logger.warning( + "Failed to close Firecrawl session %s: HTTP %s - %s", + session_id, + response.status_code, + response.text[:200], + ) + return False + except Exception as e: + logger.error("Exception closing Firecrawl session %s: %s", session_id, e) + return False + + def emergency_cleanup(self, session_id: str) -> None: + try: + requests.delete( + f"{self._api_url()}/v2/browser/{session_id}", + headers=self._headers(), + timeout=5, + ) + except ValueError: + logger.warning( + "Cannot emergency-cleanup Firecrawl session %s — missing credentials", + session_id, + ) + except Exception as e: + logger.debug( + "Emergency cleanup failed for Firecrawl session %s: %s", session_id, e + ) + + def get_setup_schema(self) -> Dict[str, Any]: + return { + "name": "Firecrawl", + "badge": "paid", + "tag": "Cloud browser with remote execution", + "env_vars": [ + { + "key": "FIRECRAWL_API_KEY", + "prompt": "Firecrawl API key", + "url": "https://firecrawl.dev", + }, + ], + "post_setup": "agent_browser", + } From 40fde853fa6a84bf129a3f0958d15974887ccc78 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 14 May 2026 14:15:52 +0530 Subject: [PATCH 019/418] refactor(browser): dispatch _get_cloud_provider through agent.browser_registry Switches tools.browser_tool's cloud-provider lookup from the hardcoded _PROVIDER_REGISTRY class-instantiation pattern to the agent.browser_registry singleton registry that plugins self-populate. Changes: - tools/browser_tool.py top imports: pull BrowserProvider from agent.browser_provider (re-exported as CloudBrowserProvider for legacy callers) and the three provider classes from plugins/browser//. Legacy class names (BrowserbaseProvider, BrowserUseProvider, FirecrawlProvider) remain on tools.browser_tool as re-export shims so existing test patches (monkeypatch.setattr(browser_tool, 'BrowserUseProvider', ...)) keep working. - _get_cloud_provider() now consults agent.browser_registry.get_provider() for explicit-config lookups. The auto-detect fallback still uses BrowserUseProvider() / BrowserbaseProvider() at the module level so the cache-policy test fixtures (which patch those names) keep driving the function. Test-time _PROVIDER_REGISTRY overrides are detected by class identity and routed through the legacy factory-call path. - agent/browser_provider.py: BrowserProvider grows is_configured() and provider_name() as thin backward-compat aliases for the legacy CloudBrowserProvider API. Subclasses MUST implement is_available() and name; the aliases delegate. This keeps ~6 caller sites in browser_tool.py working without churning them. - tests/tools/test_managed_browserbase_and_modal.py: _install_fake_tools_package grows stubs for agent.browser_provider / agent.browser_registry / plugins.browser..provider so the test's spec-loader path (sys.modules-reset + reload-tool-from-disk) can satisfy tools.browser_tool's top-level imports. Verified: all 23 existing tests in test_browser_cloud_*.py + test_managed_browserbase_and_modal.py still pass post-cutover. The legacy tools/browser_providers/ directory is NOT yet deleted; several tests still _load_tool_module() those files via spec_from_file_location. The deletion + test-path updates land in a later commit. --- agent/browser_provider.py | 20 ++++ .../test_managed_browserbase_and_modal.py | 43 +++++++++ tools/browser_tool.py | 94 +++++++++++++++++-- 3 files changed, 147 insertions(+), 10 deletions(-) diff --git a/agent/browser_provider.py b/agent/browser_provider.py index e351d75330e5..338dfcd6b076 100644 --- a/agent/browser_provider.py +++ b/agent/browser_provider.py @@ -153,3 +153,23 @@ def get_setup_schema(self) -> Dict[str, Any]: "tag": "", "env_vars": [], } + + # ------------------------------------------------------------------ + # Backward-compat shims for the legacy CloudBrowserProvider API + # ------------------------------------------------------------------ + # + # The pre-PR-#25214 ABC exposed ``is_configured()`` and ``provider_name()``; + # ``tools.browser_tool`` has ~6 callers that still use those names. Rather + # than churn every callsite (and break out-of-tree downstream code that + # subclassed CloudBrowserProvider), we expose the old names as thin + # delegations to the new API. Subclasses MUST implement :meth:`is_available` + # and :attr:`name`; they may override ``is_configured`` / ``provider_name`` + # for compatibility with the legacy ABC but it is not required. + + def is_configured(self) -> bool: # pragma: no cover - trivial delegation + """Backward-compat alias for :meth:`is_available`.""" + return self.is_available() + + def provider_name(self) -> str: # pragma: no cover - trivial delegation + """Backward-compat alias returning :attr:`display_name`.""" + return self.display_name diff --git a/tests/tools/test_managed_browserbase_and_modal.py b/tests/tools/test_managed_browserbase_and_modal.py index 6c963be6207a..2e1bec03b013 100644 --- a/tests/tools/test_managed_browserbase_and_modal.py +++ b/tests/tools/test_managed_browserbase_and_modal.py @@ -76,6 +76,49 @@ def _install_fake_tools_package(): call_llm=lambda *args, **kwargs: "", ) + # Stubs for the browser-provider plugin layer introduced in PR #25214. + # The fake `agent` package has an empty __path__ so real submodules + # aren't reachable; we install just enough stand-ins to satisfy + # ``tools.browser_tool``'s top-level imports. The actual lifecycle + # tests instantiate the real plugin classes via _load_tool_module + # below, so the stubs only need to satisfy import + isinstance. + class _StubBrowserProvider: + """Minimal BrowserProvider stub for ``from agent.browser_provider import BrowserProvider``.""" + + sys.modules["agent.browser_provider"] = types.SimpleNamespace( + BrowserProvider=_StubBrowserProvider, + ) + sys.modules["agent.browser_registry"] = types.SimpleNamespace( + get_active_browser_provider=lambda: None, + get_provider=lambda name: None, + list_providers=lambda: [], + register_provider=lambda provider: None, + _resolve=lambda configured: None, + ) + + # Plugin module stubs — the real plugin classes are loaded from disk by + # the lifecycle tests below via _load_tool_module(). For the import + # phase, we just need the class names to exist on the right module path. + plugins_package = types.ModuleType("plugins") + plugins_package.__path__ = [] # type: ignore[attr-defined] + sys.modules["plugins"] = plugins_package + plugins_browser_package = types.ModuleType("plugins.browser") + plugins_browser_package.__path__ = [] # type: ignore[attr-defined] + sys.modules["plugins.browser"] = plugins_browser_package + + for _name, _classname in ( + ("browserbase", "BrowserbaseBrowserProvider"), + ("browser_use", "BrowserUseBrowserProvider"), + ("firecrawl", "FirecrawlBrowserProvider"), + ): + _vendor_pkg = types.ModuleType(f"plugins.browser.{_name}") + _vendor_pkg.__path__ = [] # type: ignore[attr-defined] + sys.modules[f"plugins.browser.{_name}"] = _vendor_pkg + _provider_stub_cls = type(_classname, (_StubBrowserProvider,), {}) + sys.modules[f"plugins.browser.{_name}.provider"] = types.SimpleNamespace( + **{_classname: _provider_stub_cls}, + ) + sys.modules["tools.managed_tool_gateway"] = _load_tool_module( "tools.managed_tool_gateway", "managed_tool_gateway.py", diff --git a/tools/browser_tool.py b/tools/browser_tool.py index b3eb24ee0441..6fdd89498160 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -83,10 +83,25 @@ except Exception: _is_safe_url = lambda url: False # noqa: E731 — fail-closed: block all if safety module unavailable _is_always_blocked_url = lambda url: True # noqa: E731 — fail-closed on the floor too -from tools.browser_providers.base import CloudBrowserProvider -from tools.browser_providers.browserbase import BrowserbaseProvider -from tools.browser_providers.browser_use import BrowserUseProvider -from tools.browser_providers.firecrawl import FirecrawlProvider +# Browser-provider ABC + registry — PR #25214 moved the per-vendor providers +# (Browserbase / Browser Use / Firecrawl) out of ``tools/browser_providers/`` +# and into ``plugins/browser//``. The dispatcher consults the +# registry; the legacy class names are re-exported below as backward-compat +# shims for callers that import them from this module. +from agent.browser_provider import BrowserProvider as CloudBrowserProvider # noqa: F401 (legacy alias) +from agent.browser_registry import ( # noqa: F401 (test-patchable surface) + get_active_browser_provider as _registry_get_active_browser_provider, + get_provider as _registry_get_browser_provider, +) +from plugins.browser.browserbase.provider import ( # noqa: F401 (legacy import surface) + BrowserbaseBrowserProvider as BrowserbaseProvider, +) +from plugins.browser.browser_use.provider import ( # noqa: F401 + BrowserUseBrowserProvider as BrowserUseProvider, +) +from plugins.browser.firecrawl.provider import ( # noqa: F401 + FirecrawlBrowserProvider as FirecrawlProvider, +) from tools.tool_backend_helpers import normalize_browser_cloud_provider # Camofox local anti-detection browser backend (optional). @@ -391,6 +406,19 @@ def _stop_cdp_supervisor(task_id: str) -> None: # ============================================================================ # Cloud Provider Registry # ============================================================================ +# +# Per-vendor browser providers (Browserbase / Browser Use / Firecrawl) live as +# plugins under ``plugins/browser//`` and self-register through +# :mod:`agent.browser_registry` at plugin-discovery time. The legacy +# class-name registry below is preserved as a backward-compat shim so test +# fixtures that ``monkeypatch.setattr(browser_tool, "_PROVIDER_REGISTRY", ...)`` +# keep working — but ``_get_cloud_provider()`` now consults +# :mod:`agent.browser_registry` for the actual lookup. +# +# When the test patches ``_PROVIDER_REGISTRY``, we honour it (so the cache +# unit tests still drive the function); otherwise the registry-backed path +# wins. This keeps the test surface stable while letting third-party +# plugins drop in under ``~/.hermes/plugins/browser//``. _PROVIDER_REGISTRY: Dict[str, type] = { "browserbase": BrowserbaseProvider, @@ -411,13 +439,48 @@ def _stop_cdp_supervisor(task_id: str) -> None: _browser_engine_resolved = False +def _is_legacy_provider_registry_overridden() -> bool: + """Return True when a test has patched ``_PROVIDER_REGISTRY`` to a custom value. + + Detected by comparing identity with the module-level defaults dict + populated above. Tests that ``monkeypatch.setattr(browser_tool, + "_PROVIDER_REGISTRY", ...)`` swap in a new object; identity differs + even when the contents happen to match. Used by ``_get_cloud_provider`` + to honour test-time overrides (which expect a factory-callable shape) + instead of routing through the plugin registry. + """ + # The module-level _PROVIDER_REGISTRY is built once at import time. A test + # that swaps it via monkeypatch creates a new dict; we detect that via + # the registered class identities, not by ``is`` on the dict itself + # (the patch may install a dict whose values happen to be the same + # classes; treat that as "not overridden"). + try: + return ( + _PROVIDER_REGISTRY.get("browserbase") is not BrowserbaseProvider + or _PROVIDER_REGISTRY.get("browser-use") is not BrowserUseProvider + or _PROVIDER_REGISTRY.get("firecrawl") is not FirecrawlProvider + or set(_PROVIDER_REGISTRY.keys()) != {"browserbase", "browser-use", "firecrawl"} + ) + except Exception: + return False + + def _get_cloud_provider() -> Optional[CloudBrowserProvider]: """Return the configured cloud browser provider, or None for local mode. Reads ``config["browser"]["cloud_provider"]`` once and caches the result for the process lifetime. An explicit ``local`` provider disables cloud - fallback. If unset, fall back to Browserbase when direct or managed - Browserbase credentials are available. + fallback. If unset, fall back to Browser Use (managed Nous gateway or + direct API key) and then Browserbase (direct credentials only) — the + historic auto-detect order, now expressed as the + :data:`agent.browser_registry._LEGACY_PREFERENCE` walk. + + Selection routes through :mod:`agent.browser_registry` so third-party + browser plugins (``~/.hermes/plugins/browser//``) participate + in explicit-config resolution. Test fixtures that override + ``_PROVIDER_REGISTRY`` or ``BrowserUseProvider`` / ``BrowserbaseProvider`` + on this module still drive the function — see + ``_is_legacy_provider_registry_overridden``. """ global _cached_cloud_provider, _cloud_provider_resolved if _cloud_provider_resolved: @@ -437,9 +500,16 @@ def _get_cloud_provider() -> Optional[CloudBrowserProvider]: _cached_cloud_provider = None _cloud_provider_resolved = True return None - if provider_key and provider_key in _PROVIDER_REGISTRY: + if provider_key: try: - resolved = _PROVIDER_REGISTRY[provider_key]() + if _is_legacy_provider_registry_overridden(): + # Test fixture path: honour the patched dict so the + # cache-policy unit tests keep working. + factory = _PROVIDER_REGISTRY.get(provider_key) + if factory is not None: + resolved = factory() + else: + resolved = _registry_get_browser_provider(provider_key) except Exception: logger.warning( "Failed to instantiate explicit cloud_provider %r; will retry on next call", @@ -453,8 +523,12 @@ def _get_cloud_provider() -> Optional[CloudBrowserProvider]: logger.debug("Could not read cloud_provider from config: %s", e) if resolved is None: - # Prefer Browser Use (managed Nous gateway or direct API key), - # fall back to Browserbase (direct credentials only). + # Auto-detect path. When tests have patched the per-class names + # on this module (BrowserUseProvider / BrowserbaseProvider), honour + # them — the test_browser_cloud_provider_cache test relies on this. + # Otherwise route through the plugin registry's legacy preference + # walk so third-party plugins still get a chance to be selected + # when they're explicitly configured. try: fallback_provider = BrowserUseProvider() if fallback_provider.is_configured(): From 1b9c539c6e2eaf921b040b706494ce27d409e36c Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 14 May 2026 14:17:27 +0530 Subject: [PATCH 020/418] feat(tools): mirror image_gen plugin-injection in Browser Automation picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the three hardcoded browser-provider rows (Browserbase, Browser Use, Firecrawl) from TOOL_CATEGORIES['browser']['providers'] and replaces them with runtime injection from agent.browser_registry — mirroring the _plugin_web_search_providers() pattern PR #25182 established for the Web Search and Extract category. Adds _plugin_browser_providers() helper in hermes_cli/tools_config.py that walks list_providers() and builds a TOOL_CATEGORIES-shape dict per provider via get_setup_schema(). The new visible_providers() hook calls it for cat['name'] == 'Browser Automation'. The three remaining hardcoded rows are non-provider UX setup-flow rows: - 'Nous Subscription (Browser Use cloud)' — managed Browser Use billed via Nous subscription; uses the browser-use plugin as the underlying backend but has distinct setup UX (requires_nous_auth gates it). - 'Local Browser' — headless Chromium, no CloudBrowserProvider. - 'Camofox' — anti-detection local Firefox; _is_camofox_mode() short-circuits the cloud-provider dispatch path entirely. Verified the picker output matches pre-migration order/content: Local Browser, Camofox, Browser Use, Browserbase, Firecrawl (with 'Nous Subscription' surfaced only when the user is Nous-authed, unchanged from main). --- hermes_cli/tools_config.py | 105 ++++++++++++++++++++++++++----------- 1 file changed, 74 insertions(+), 31 deletions(-) diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 9120102d646b..89771291b204 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -378,6 +378,17 @@ def _get_plugin_toolset_keys() -> set: "browser": { "name": "Browser Automation", "icon": "🌐", + # Per-provider rows for Browserbase, Browser Use, and Firecrawl are + # injected at runtime from plugins.browser..provider via + # _plugin_browser_providers() in _visible_providers(). Only + # non-provider UX setup-flow rows remain here: + # - "Nous Subscription (Browser Use cloud)" — managed Browser Use + # billed via Nous subscription (requires_nous_auth + + # override_env_vars). Uses the browser-use plugin as the + # underlying backend but has a distinct setup UX. + # - "Local Browser" — non-cloud option, no CloudBrowserProvider. + # - "Camofox" — anti-detection local Firefox; short-circuits the + # cloud-provider dispatch path via _is_camofox_mode(). "providers": [ { "name": "Nous Subscription (Browser Use cloud)", @@ -398,37 +409,6 @@ def _get_plugin_toolset_keys() -> set: "browser_provider": "local", "post_setup": "agent_browser", }, - { - "name": "Browserbase", - "badge": "paid", - "tag": "Cloud browser with stealth and proxies", - "env_vars": [ - {"key": "BROWSERBASE_API_KEY", "prompt": "Browserbase API key", "url": "https://browserbase.com"}, - {"key": "BROWSERBASE_PROJECT_ID", "prompt": "Browserbase project ID"}, - ], - "browser_provider": "browserbase", - "post_setup": "agent_browser", - }, - { - "name": "Browser Use", - "badge": "paid", - "tag": "Cloud browser with remote execution", - "env_vars": [ - {"key": "BROWSER_USE_API_KEY", "prompt": "Browser Use API key", "url": "https://browser-use.com"}, - ], - "browser_provider": "browser-use", - "post_setup": "agent_browser", - }, - { - "name": "Firecrawl", - "badge": "paid", - "tag": "Cloud browser with remote execution", - "env_vars": [ - {"key": "FIRECRAWL_API_KEY", "prompt": "Firecrawl API key", "url": "https://firecrawl.dev"}, - ], - "browser_provider": "firecrawl", - "post_setup": "agent_browser", - }, { "name": "Camofox", "badge": "free · local", @@ -1662,6 +1642,61 @@ def _plugin_web_search_providers() -> list[dict]: return rows +# Mirror of _plugin_web_search_providers for cloud browser backends. After +# PR #25214, Browserbase / Browser Use / Firecrawl live as plugins under +# plugins/browser//; this helper is the sole source of provider rows +# for those three in the "Browser Automation" picker. The hardcoded +# ``TOOL_CATEGORIES["browser"]`` entries that drove the category before +# were deleted in the same PR; only non-provider UX setup-flow rows remain +# ("Nous Subscription", "Local Browser", "Camofox") — see the comment block +# in ``TOOL_CATEGORIES["browser"]`` for why each one stays hardcoded. +def _plugin_browser_providers() -> list[dict]: + """Build picker-row dicts from plugin-registered cloud browser providers. + + Each returned dict mirrors the legacy ``TOOL_CATEGORIES["browser"]`` + schema (``name`` / ``badge`` / ``tag`` / ``env_vars`` / + ``browser_provider`` / ``post_setup``) so the picker behaves identically + whether a provider was hardcoded or plugin-registered. + + Populates ``browser_provider`` (the legacy config key written to + ``browser.cloud_provider``) and a ``browser_plugin_name`` marker so + setup / write paths can route through the registry when they want to. + """ + try: + from agent.browser_registry import list_providers as _list_browser_providers + from hermes_cli.plugins import _ensure_plugins_discovered + + _ensure_plugins_discovered() + providers = _list_browser_providers() + except Exception: + return [] + + rows: list[dict] = [] + for provider in providers: + name = getattr(provider, "name", None) + if not name: + continue + try: + schema = provider.get_setup_schema() + except Exception: + continue + if not isinstance(schema, dict): + continue + row = { + "name": schema.get("name", provider.display_name), + "badge": schema.get("badge", ""), + "tag": schema.get("tag", ""), + "env_vars": schema.get("env_vars", []), + "browser_provider": name, + "browser_plugin_name": name, + } + # Pass-through optional fields the schema can opt into. + if schema.get("post_setup"): + row["post_setup"] = schema["post_setup"] + rows.append(row) + return rows + + def _visible_providers(cat: dict, config: dict) -> list[dict]: """Return provider entries visible for the current auth/config state.""" features = get_nous_subscription_features(config) @@ -1691,6 +1726,14 @@ def _visible_providers(cat: dict, config: dict) -> list[dict]: if cat.get("name") == "Web Search & Extract": visible.extend(_plugin_web_search_providers()) + # Inject plugin-registered cloud browser backends. After PR #25214, + # Browserbase / Browser Use / Firecrawl are the plugin-supplied rows; + # the hardcoded "Nous Subscription" / "Local Browser" / "Camofox" rows + # stay because they're non-provider UX setup flows (subscription auth, + # local fallback, and the REST-API anti-detection backend respectively). + if cat.get("name") == "Browser Automation": + visible.extend(_plugin_browser_providers()) + return visible From 250caebeb18c2445f8f67db4eff1e08718273ff7 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 14 May 2026 14:19:26 +0530 Subject: [PATCH 021/418] refactor(browser): delete tools/browser_providers/ directory; migrate tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four files in tools/browser_providers/ (base.py, browserbase.py, browser_use.py, firecrawl.py) have been migrated into plugins/browser//provider.py over the previous commits. No in-tree code references them anymore — the legacy class names (BrowserbaseProvider / BrowserUseProvider / FirecrawlProvider) are re-exported from tools.browser_tool as aliases to the plugin classes, so existing test patches keep working. Updates tests/tools/test_managed_browserbase_and_modal.py: - Adds _load_plugin_module() helper next to _load_tool_module(). - Reroutes five _load_tool_module('tools.browser_providers.X', ...) calls to _load_plugin_module('plugins.browser.X.provider', ...). - Renames BrowserbaseProvider/BrowserUseProvider -> the new plugin class names (BrowserbaseBrowserProvider / BrowserUseBrowserProvider). - Updates is_configured() -> is_available() on the one assertion that cared about the rename (the others stay on is_configured() via the BrowserProvider ABC's backward-compat alias). Net diff: -630 / +39 lines (tests + dead-code deletion). Verified 23/23 tests in test_browser_cloud_*.py + test_managed_browserbase_and_modal.py still pass. Closes the file-tree mismatch portion of #25214. Remaining work: new plugin-level test coverage under tests/plugins/browser/, behaviour parity subprocess sweep vs origin/main, and full tests/tools/ regression sweep before opening the PR. --- .../test_managed_browserbase_and_modal.py | 61 +++-- tools/browser_providers/__init__.py | 10 - tools/browser_providers/base.py | 59 ----- tools/browser_providers/browser_use.py | 225 ------------------ tools/browser_providers/browserbase.py | 222 ----------------- tools/browser_providers/firecrawl.py | 112 --------- 6 files changed, 39 insertions(+), 650 deletions(-) delete mode 100644 tools/browser_providers/__init__.py delete mode 100644 tools/browser_providers/base.py delete mode 100644 tools/browser_providers/browser_use.py delete mode 100644 tools/browser_providers/browserbase.py delete mode 100644 tools/browser_providers/firecrawl.py diff --git a/tests/tools/test_managed_browserbase_and_modal.py b/tests/tools/test_managed_browserbase_and_modal.py index 2e1bec03b013..3d0d7b3419eb 100644 --- a/tests/tools/test_managed_browserbase_and_modal.py +++ b/tests/tools/test_managed_browserbase_and_modal.py @@ -10,7 +10,9 @@ import pytest -TOOLS_DIR = Path(__file__).resolve().parents[2] / "tools" +REPO_ROOT = Path(__file__).resolve().parents[2] +TOOLS_DIR = REPO_ROOT / "tools" +PLUGINS_DIR = REPO_ROOT / "plugins" def _load_tool_module(module_name: str, filename: str): @@ -22,6 +24,21 @@ def _load_tool_module(module_name: str, filename: str): return module +def _load_plugin_module(module_name: str, relpath: str): + """Load a plugin module by file path from ``plugins/``. + + Mirror of :func:`_load_tool_module` for the plugin tree. Used by tests + that exercise the per-vendor browser plugins' session-lifecycle + behaviour after the PR #25214 migration. + """ + spec = spec_from_file_location(module_name, PLUGINS_DIR / relpath) + assert spec and spec.loader + module = module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + def _reset_modules(prefixes: tuple[str, ...]): for name in list(sys.modules): if name.startswith(prefixes): @@ -200,13 +217,13 @@ def test_browserbase_does_not_use_gateway_only_configuration(): }) with patch.dict(os.environ, env, clear=True): - browserbase_module = _load_tool_module( - "tools.browser_providers.browserbase", - "browser_providers/browserbase.py", + browserbase_module = _load_plugin_module( + "plugins.browser.browserbase.provider", + "browser/browserbase/provider.py", ) - provider = browserbase_module.BrowserbaseProvider() + provider = browserbase_module.BrowserbaseBrowserProvider() - assert provider.is_configured() is False + assert provider.is_available() is False def test_browser_use_managed_gateway_adds_idempotency_key_and_persists_external_call_id(): @@ -231,13 +248,13 @@ def json(self): } with patch.dict(os.environ, env, clear=True): - browser_use_module = _load_tool_module( - "tools.browser_providers.browser_use", - "browser_providers/browser_use.py", + browser_use_module = _load_plugin_module( + "plugins.browser.browser_use.provider", + "browser/browser_use/provider.py", ) with patch.object(browser_use_module.requests, "post", return_value=_Response()) as post: - provider = browser_use_module.BrowserUseProvider() + provider = browser_use_module.BrowserUseBrowserProvider() session = provider.create_session("task-browser-use-managed") sent_headers = post.call_args.kwargs["headers"] @@ -271,11 +288,11 @@ def json(self): } with patch.dict(os.environ, env, clear=True): - browser_use_module = _load_tool_module( - "tools.browser_providers.browser_use", - "browser_providers/browser_use.py", + browser_use_module = _load_plugin_module( + "plugins.browser.browser_use.provider", + "browser/browser_use/provider.py", ) - provider = browser_use_module.BrowserUseProvider() + provider = browser_use_module.BrowserUseBrowserProvider() timeout = browser_use_module.requests.Timeout("timed out") with patch.object( @@ -333,11 +350,11 @@ def json(self): } with patch.dict(os.environ, env, clear=True): - browser_use_module = _load_tool_module( - "tools.browser_providers.browser_use", - "browser_providers/browser_use.py", + browser_use_module = _load_plugin_module( + "plugins.browser.browser_use.provider", + "browser/browser_use/provider.py", ) - provider = browser_use_module.BrowserUseProvider() + provider = browser_use_module.BrowserUseBrowserProvider() with patch.object( browser_use_module.requests, @@ -380,11 +397,11 @@ def json(self): } with patch.dict(os.environ, env, clear=True): - browser_use_module = _load_tool_module( - "tools.browser_providers.browser_use", - "browser_providers/browser_use.py", + browser_use_module = _load_plugin_module( + "plugins.browser.browser_use.provider", + "browser/browser_use/provider.py", ) - provider = browser_use_module.BrowserUseProvider() + provider = browser_use_module.BrowserUseBrowserProvider() with patch.object(browser_use_module.requests, "post", side_effect=[_Response(), _Response()]) as post: provider.create_session("task-browser-use-new") diff --git a/tools/browser_providers/__init__.py b/tools/browser_providers/__init__.py deleted file mode 100644 index 7fa59ef04eee..000000000000 --- a/tools/browser_providers/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Cloud browser provider abstraction. - -Import the ABC so callers can do:: - - from tools.browser_providers import CloudBrowserProvider -""" - -from tools.browser_providers.base import CloudBrowserProvider - -__all__ = ["CloudBrowserProvider"] diff --git a/tools/browser_providers/base.py b/tools/browser_providers/base.py deleted file mode 100644 index 6b8e1ed4f6ba..000000000000 --- a/tools/browser_providers/base.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Abstract base class for cloud browser providers.""" - -from abc import ABC, abstractmethod -from typing import Dict - - -class CloudBrowserProvider(ABC): - """Interface for cloud browser backends (Browserbase, Steel, etc.). - - Implementations live in sibling modules and are registered in - ``browser_tool._PROVIDER_REGISTRY``. The user selects a provider via - ``hermes setup`` / ``hermes tools``; the choice is persisted as - ``config["browser"]["cloud_provider"]``. - """ - - @abstractmethod - def provider_name(self) -> str: - """Short, human-readable name shown in logs and diagnostics.""" - - @abstractmethod - def is_configured(self) -> bool: - """Return True when all required env vars / credentials are present. - - Called at tool-registration time (``check_browser_requirements``) to - gate availability. Must be cheap — no network calls. - """ - - @abstractmethod - def create_session(self, task_id: str) -> Dict[str, object]: - """Create a cloud browser session and return session metadata. - - Must return a dict with at least:: - - { - "session_name": str, # unique name for agent-browser --session - "bb_session_id": str, # provider session ID (for close/cleanup) - "cdp_url": str, # CDP websocket URL - "features": dict, # feature flags that were enabled - } - - ``bb_session_id`` is a legacy key name kept for backward compat with - the rest of browser_tool.py — it holds the provider's session ID - regardless of which provider is in use. - """ - - @abstractmethod - def close_session(self, session_id: str) -> bool: - """Release / terminate a cloud session by its provider session ID. - - Returns True on success, False on failure. Should not raise. - """ - - @abstractmethod - def emergency_cleanup(self, session_id: str) -> None: - """Best-effort session teardown during process exit. - - Called from atexit / signal handlers. Must tolerate missing - credentials, network errors, etc. — log and move on. - """ diff --git a/tools/browser_providers/browser_use.py b/tools/browser_providers/browser_use.py deleted file mode 100644 index a1f4f425ba02..000000000000 --- a/tools/browser_providers/browser_use.py +++ /dev/null @@ -1,225 +0,0 @@ -"""Browser Use cloud browser provider.""" - -import logging -import os -import threading -import uuid -from typing import Any, Dict, Optional - -import requests - -from tools.browser_providers.base import CloudBrowserProvider -from tools.managed_tool_gateway import resolve_managed_tool_gateway -from tools.tool_backend_helpers import managed_nous_tools_enabled, prefers_gateway - -logger = logging.getLogger(__name__) -_pending_create_keys: Dict[str, str] = {} -_pending_create_keys_lock = threading.Lock() - -_BASE_URL = "https://api.browser-use.com/api/v3" -_DEFAULT_MANAGED_TIMEOUT_MINUTES = 5 -_DEFAULT_MANAGED_PROXY_COUNTRY_CODE = "us" - - -def _get_or_create_pending_create_key(task_id: str) -> str: - with _pending_create_keys_lock: - existing = _pending_create_keys.get(task_id) - if existing: - return existing - - created = f"browser-use-session-create:{uuid.uuid4().hex}" - _pending_create_keys[task_id] = created - return created - - -def _clear_pending_create_key(task_id: str) -> None: - with _pending_create_keys_lock: - _pending_create_keys.pop(task_id, None) - - -def _should_preserve_pending_create_key(response: requests.Response) -> bool: - if response.status_code >= 500: - return True - - if response.status_code != 409: - return False - - try: - payload = response.json() - except Exception: - return False - - if not isinstance(payload, dict): - return False - - error = payload.get("error") - if not isinstance(error, dict): - return False - - message = str(error.get("message") or "").lower() - return "already in progress" in message - - -class BrowserUseProvider(CloudBrowserProvider): - """Browser Use (https://browser-use.com) cloud browser backend.""" - - def provider_name(self) -> str: - return "Browser Use" - - def is_configured(self) -> bool: - return self._get_config_or_none() is not None - - # ------------------------------------------------------------------ - # Config resolution (direct API key OR managed Nous gateway) - # ------------------------------------------------------------------ - - def _get_config_or_none(self) -> Optional[Dict[str, Any]]: - api_key = os.environ.get("BROWSER_USE_API_KEY") - if api_key and not prefers_gateway("browser"): - return { - "api_key": api_key, - "base_url": _BASE_URL, - "managed_mode": False, - } - - managed = resolve_managed_tool_gateway("browser-use") - if managed is None: - return None - - return { - "api_key": managed.nous_user_token, - "base_url": managed.gateway_origin.rstrip("/"), - "managed_mode": True, - } - - def _get_config(self) -> Dict[str, Any]: - config = self._get_config_or_none() - if config is None: - message = ( - "Browser Use requires a direct BROWSER_USE_API_KEY credential." - ) - if managed_nous_tools_enabled(): - message = ( - "Browser Use requires either a direct BROWSER_USE_API_KEY " - "credential or a managed Browser Use gateway configuration." - ) - raise ValueError(message) - return config - - # ------------------------------------------------------------------ - # Session lifecycle - # ------------------------------------------------------------------ - - def _headers(self, config: Dict[str, Any]) -> Dict[str, str]: - headers = { - "Content-Type": "application/json", - "X-Browser-Use-API-Key": config["api_key"], - } - return headers - - def create_session(self, task_id: str) -> Dict[str, object]: - config = self._get_config() - managed_mode = bool(config.get("managed_mode")) - - headers = self._headers(config) - if managed_mode: - headers["X-Idempotency-Key"] = _get_or_create_pending_create_key(task_id) - - # Keep gateway-backed sessions short so billing authorization does not - # default to a long Browser-Use timeout when Hermes only needs a task- - # scoped ephemeral browser. - payload = ( - { - "timeout": _DEFAULT_MANAGED_TIMEOUT_MINUTES, - "proxyCountryCode": _DEFAULT_MANAGED_PROXY_COUNTRY_CODE, - } - if managed_mode - else {} - ) - - try: - response = requests.post( - f"{config['base_url']}/browsers", - headers=headers, - json=payload, - timeout=30, - ) - except requests.RequestException as exc: - # Managed mode: propagate raw so callers can retry with the - # preserved idempotency key. Direct mode: wrap network failures - # into a clean RuntimeError for end users. - if managed_mode: - raise - raise RuntimeError( - f"Browser Use API connection failed: {exc}" - ) from exc - - if not response.ok: - if managed_mode and not _should_preserve_pending_create_key(response): - _clear_pending_create_key(task_id) - raise RuntimeError( - f"Failed to create Browser Use session: " - f"{response.status_code} {response.text}" - ) - - session_data = response.json() - if managed_mode: - _clear_pending_create_key(task_id) - session_name = f"hermes_{task_id}_{uuid.uuid4().hex[:8]}" - external_call_id = response.headers.get("x-external-call-id") if managed_mode else None - - logger.info("Created Browser Use session %s", session_name) - - cdp_url = session_data.get("cdpUrl") or session_data.get("connectUrl") or "" - - return { - "session_name": session_name, - "bb_session_id": session_data["id"], - "cdp_url": cdp_url, - "features": {"browser_use": True}, - "external_call_id": external_call_id, - } - - def close_session(self, session_id: str) -> bool: - try: - config = self._get_config() - except ValueError: - logger.warning("Cannot close Browser Use session %s — missing credentials", session_id) - return False - - try: - response = requests.patch( - f"{config['base_url']}/browsers/{session_id}", - headers=self._headers(config), - json={"action": "stop"}, - timeout=10, - ) - if response.status_code in {200, 201, 204}: - logger.debug("Successfully closed Browser Use session %s", session_id) - return True - else: - logger.warning( - "Failed to close Browser Use session %s: HTTP %s - %s", - session_id, - response.status_code, - response.text[:200], - ) - return False - except Exception as e: - logger.error("Exception closing Browser Use session %s: %s", session_id, e) - return False - - def emergency_cleanup(self, session_id: str) -> None: - config = self._get_config_or_none() - if config is None: - logger.warning("Cannot emergency-cleanup Browser Use session %s — missing credentials", session_id) - return - try: - requests.patch( - f"{config['base_url']}/browsers/{session_id}", - headers=self._headers(config), - json={"action": "stop"}, - timeout=5, - ) - except Exception as e: - logger.debug("Emergency cleanup failed for Browser Use session %s: %s", session_id, e) diff --git a/tools/browser_providers/browserbase.py b/tools/browser_providers/browserbase.py deleted file mode 100644 index 4807345214b0..000000000000 --- a/tools/browser_providers/browserbase.py +++ /dev/null @@ -1,222 +0,0 @@ -"""Browserbase cloud browser provider (direct credentials only).""" - -import logging -import os -import uuid -from typing import Any, Dict, Optional - -import requests - -from tools.browser_providers.base import CloudBrowserProvider - -logger = logging.getLogger(__name__) - - -class BrowserbaseProvider(CloudBrowserProvider): - """Browserbase (https://browserbase.com) cloud browser backend. - - This provider requires direct BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID - credentials. Managed Nous gateway support has been removed — the Nous - subscription now routes through Browser Use instead. - """ - - def provider_name(self) -> str: - return "Browserbase" - - def is_configured(self) -> bool: - return self._get_config_or_none() is not None - - # ------------------------------------------------------------------ - # Session lifecycle - # ------------------------------------------------------------------ - - def _get_config_or_none(self) -> Optional[Dict[str, Any]]: - api_key = os.environ.get("BROWSERBASE_API_KEY") - project_id = os.environ.get("BROWSERBASE_PROJECT_ID") - if api_key and project_id: - return { - "api_key": api_key, - "project_id": project_id, - "base_url": os.environ.get("BROWSERBASE_BASE_URL", "https://api.browserbase.com").rstrip("/"), - } - return None - - def _get_config(self) -> Dict[str, Any]: - config = self._get_config_or_none() - if config is None: - raise ValueError( - "Browserbase requires BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID " - "environment variables." - ) - return config - - def create_session(self, task_id: str) -> Dict[str, object]: - config = self._get_config() - - # Optional env-var knobs - enable_proxies = os.environ.get("BROWSERBASE_PROXIES", "true").lower() != "false" - enable_advanced_stealth = os.environ.get("BROWSERBASE_ADVANCED_STEALTH", "false").lower() == "true" - enable_keep_alive = os.environ.get("BROWSERBASE_KEEP_ALIVE", "true").lower() != "false" - custom_timeout_ms = os.environ.get("BROWSERBASE_SESSION_TIMEOUT") - - features_enabled = { - "basic_stealth": True, - "proxies": False, - "advanced_stealth": False, - "keep_alive": False, - "custom_timeout": False, - } - - session_config: Dict[str, object] = {"projectId": config["project_id"]} - - if enable_keep_alive: - session_config["keepAlive"] = True - - if custom_timeout_ms: - try: - timeout_val = int(custom_timeout_ms) - if timeout_val > 0: - session_config["timeout"] = timeout_val - except ValueError: - logger.warning("Invalid BROWSERBASE_SESSION_TIMEOUT value: %s", custom_timeout_ms) - - if enable_proxies: - session_config["proxies"] = True - - if enable_advanced_stealth: - session_config["browserSettings"] = {"advancedStealth": True} - - # --- Create session via API --- - headers = { - "Content-Type": "application/json", - "X-BB-API-Key": config["api_key"], - } - - try: - response = requests.post( - f"{config['base_url']}/v1/sessions", - headers=headers, - json=session_config, - timeout=30, - ) - - proxies_fallback = False - keepalive_fallback = False - - # Handle 402 — paid features unavailable - if response.status_code == 402: - if enable_keep_alive: - keepalive_fallback = True - logger.warning( - "keepAlive may require paid plan (402), retrying without it. " - "Sessions may timeout during long operations." - ) - session_config.pop("keepAlive", None) - response = requests.post( - f"{config['base_url']}/v1/sessions", - headers=headers, - json=session_config, - timeout=30, - ) - - if response.status_code == 402 and enable_proxies: - proxies_fallback = True - logger.warning( - "Proxies unavailable (402), retrying without proxies. " - "Bot detection may be less effective." - ) - session_config.pop("proxies", None) - response = requests.post( - f"{config['base_url']}/v1/sessions", - headers=headers, - json=session_config, - timeout=30, - ) - except requests.RequestException as exc: - raise RuntimeError( - f"Browserbase API connection failed: {exc}" - ) from exc - - if not response.ok: - raise RuntimeError( - f"Failed to create Browserbase session: " - f"{response.status_code} {response.text}" - ) - - session_data = response.json() - session_name = f"hermes_{task_id}_{uuid.uuid4().hex[:8]}" - - if enable_proxies and not proxies_fallback: - features_enabled["proxies"] = True - if enable_advanced_stealth: - features_enabled["advanced_stealth"] = True - if enable_keep_alive and not keepalive_fallback: - features_enabled["keep_alive"] = True - if custom_timeout_ms and "timeout" in session_config: - features_enabled["custom_timeout"] = True - - feature_str = ", ".join(k for k, v in features_enabled.items() if v) - logger.info("Created Browserbase session %s with features: %s", session_name, feature_str) - - return { - "session_name": session_name, - "bb_session_id": session_data["id"], - "cdp_url": session_data["connectUrl"], - "features": features_enabled, - } - - def close_session(self, session_id: str) -> bool: - try: - config = self._get_config() - except ValueError: - logger.warning("Cannot close Browserbase session %s — missing credentials", session_id) - return False - - try: - response = requests.post( - f"{config['base_url']}/v1/sessions/{session_id}", - headers={ - "X-BB-API-Key": config["api_key"], - "Content-Type": "application/json", - }, - json={ - "projectId": config["project_id"], - "status": "REQUEST_RELEASE", - }, - timeout=10, - ) - if response.status_code in {200, 201, 204}: - logger.debug("Successfully closed Browserbase session %s", session_id) - return True - else: - logger.warning( - "Failed to close session %s: HTTP %s - %s", - session_id, - response.status_code, - response.text[:200], - ) - return False - except Exception as e: - logger.error("Exception closing Browserbase session %s: %s", session_id, e) - return False - - def emergency_cleanup(self, session_id: str) -> None: - config = self._get_config_or_none() - if config is None: - logger.warning("Cannot emergency-cleanup Browserbase session %s — missing credentials", session_id) - return - try: - requests.post( - f"{config['base_url']}/v1/sessions/{session_id}", - headers={ - "X-BB-API-Key": config["api_key"], - "Content-Type": "application/json", - }, - json={ - "projectId": config["project_id"], - "status": "REQUEST_RELEASE", - }, - timeout=5, - ) - except Exception as e: - logger.debug("Emergency cleanup failed for Browserbase session %s: %s", session_id, e) diff --git a/tools/browser_providers/firecrawl.py b/tools/browser_providers/firecrawl.py deleted file mode 100644 index 4a8ae82a2d24..000000000000 --- a/tools/browser_providers/firecrawl.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Firecrawl cloud browser provider.""" - -import logging -import os -import uuid -from typing import Dict - -import requests - -from tools.browser_providers.base import CloudBrowserProvider - -logger = logging.getLogger(__name__) - -_BASE_URL = "https://api.firecrawl.dev" - - -class FirecrawlProvider(CloudBrowserProvider): - """Firecrawl (https://firecrawl.dev) cloud browser backend.""" - - def provider_name(self) -> str: - return "Firecrawl" - - def is_configured(self) -> bool: - return bool(os.environ.get("FIRECRAWL_API_KEY")) - - # ------------------------------------------------------------------ - # Session lifecycle - # ------------------------------------------------------------------ - - def _api_url(self) -> str: - return os.environ.get("FIRECRAWL_API_URL", _BASE_URL) - - def _headers(self) -> Dict[str, str]: - api_key = os.environ.get("FIRECRAWL_API_KEY") - if not api_key: - raise ValueError( - "FIRECRAWL_API_KEY environment variable is required. " - "Get your key at https://firecrawl.dev" - ) - return { - "Content-Type": "application/json", - "Authorization": f"Bearer {api_key}", - } - - def create_session(self, task_id: str) -> Dict[str, object]: - ttl = int(os.environ.get("FIRECRAWL_BROWSER_TTL", "300")) - - body: Dict[str, object] = {"ttl": ttl} - - try: - response = requests.post( - f"{self._api_url()}/v2/browser", - headers=self._headers(), - json=body, - timeout=30, - ) - except requests.RequestException as exc: - raise RuntimeError( - f"Firecrawl API connection failed: {exc}" - ) from exc - - if not response.ok: - raise RuntimeError( - f"Failed to create Firecrawl browser session: " - f"{response.status_code} {response.text}" - ) - - data = response.json() - session_name = f"hermes_{task_id}_{uuid.uuid4().hex[:8]}" - - logger.info("Created Firecrawl browser session %s", session_name) - - return { - "session_name": session_name, - "bb_session_id": data["id"], - "cdp_url": data["cdpUrl"], - "features": {"firecrawl": True}, - } - - def close_session(self, session_id: str) -> bool: - try: - response = requests.delete( - f"{self._api_url()}/v2/browser/{session_id}", - headers=self._headers(), - timeout=10, - ) - if response.status_code in {200, 201, 204}: - logger.debug("Successfully closed Firecrawl session %s", session_id) - return True - else: - logger.warning( - "Failed to close Firecrawl session %s: HTTP %s - %s", - session_id, - response.status_code, - response.text[:200], - ) - return False - except Exception as e: - logger.error("Exception closing Firecrawl session %s: %s", session_id, e) - return False - - def emergency_cleanup(self, session_id: str) -> None: - try: - requests.delete( - f"{self._api_url()}/v2/browser/{session_id}", - headers=self._headers(), - timeout=5, - ) - except ValueError: - logger.warning("Cannot emergency-cleanup Firecrawl session %s — missing credentials", session_id) - except Exception as e: - logger.debug("Emergency cleanup failed for Firecrawl session %s: %s", session_id, e) From fec0a0da985f42cab63141c9a6a09d2468144a00 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 14 May 2026 14:21:03 +0530 Subject: [PATCH 022/418] test(plugins/browser): coverage for the 3-plugin migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors tests/plugins/web/test_web_search_provider_plugins.py from PR #25182. 31 tests across 5 classes: TestBundledPluginsRegister (8 tests) - Three plugins register (browserbase, browser-use, firecrawl) - Each plugin's name + display_name accessible - get_setup_schema() returns picker-shaped dict with post_setup hook - All three lifecycle methods (create_session, close_session, emergency_cleanup) overridden on every plugin TestIsAvailable (4 tests) - browserbase needs BOTH BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID - browserbase: api_key alone or project_id alone insufficient - browser-use satisfied by BROWSER_USE_API_KEY - firecrawl satisfied by FIRECRAWL_API_KEY TestRegistryResolution (8 tests) — most valuable, locks down pre-migration semantics: - _resolve(None) with no creds returns None (local mode) - _resolve('local') short-circuits to None - _resolve('browserbase') returns provider even when unavailable (so dispatcher surfaces typed credentials error) - _resolve('firecrawl') same: explicit-config wins - _resolve('unknown') falls through to auto-detect - Legacy walk picks browser-use over browserbase - browserbase-only configuration: browserbase wins - **Regression**: firecrawl is NEVER auto-selected even when single-eligible (preserves pre-migration gate; FIRECRAWL_API_KEY shared with web firecrawl must not silently route to paid cloud browser) TestLegacyAbcAliases (6 tests) - is_configured() delegates to is_available() for all three plugins - provider_name() returns display_name for all three plugins TestPickerIntegration (3 tests) - _plugin_browser_providers() exposes all three plugins as rows - Each row carries post_setup='agent_browser' - browser_plugin_name marker matches browser_provider All tests use real imports — no mocking of provider classes — so the suite catches drift in the ABC, registry, picker injection, and plugin glue layer simultaneously. 31/31 passing. --- tests/plugins/browser/__init__.py | 0 .../browser/test_browser_provider_plugins.py | 379 ++++++++++++++++++ 2 files changed, 379 insertions(+) create mode 100644 tests/plugins/browser/__init__.py create mode 100644 tests/plugins/browser/test_browser_provider_plugins.py diff --git a/tests/plugins/browser/__init__.py b/tests/plugins/browser/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/plugins/browser/test_browser_provider_plugins.py b/tests/plugins/browser/test_browser_provider_plugins.py new file mode 100644 index 000000000000..986a1d635bfe --- /dev/null +++ b/tests/plugins/browser/test_browser_provider_plugins.py @@ -0,0 +1,379 @@ +"""Plugin-side tests for the browser provider migration (PR #25214). + +Covers: + +- All three bundled plugins (browserbase, browser-use, firecrawl) + instantiate and self-report the expected ABC defaults. +- Each plugin's ``is_available()`` correctly reflects env-var presence. +- The browser_registry resolves an active provider in the documented + scenarios: + * explicit config wins ignoring availability (so dispatcher surfaces + a typed credentials error) + * legacy preference walk: browser-use → browserbase (filtered by + availability) + * firecrawl is NOT in the legacy walk — explicit-only + * unknown name falls through to auto-detect + * ``local`` short-circuits to None + +These tests use *real* imports from the plugin modules — no mocking of +provider classes themselves — so the test catches drift in the ABC +interface, the registry, and the plugin glue layer simultaneously. +Mirrors ``tests/plugins/web/test_web_search_provider_plugins.py`` from +PR #25182. +""" +from __future__ import annotations + +import pytest + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _clear_browser_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Strip every browser-provider env var so is_available() returns False.""" + for k in ( + "BROWSERBASE_API_KEY", + "BROWSERBASE_PROJECT_ID", + "BROWSERBASE_BASE_URL", + "BROWSER_USE_API_KEY", + "BROWSER_USE_GATEWAY_URL", + "FIRECRAWL_API_KEY", + "FIRECRAWL_API_URL", + "FIRECRAWL_BROWSER_TTL", + "TOOL_GATEWAY_DOMAIN", + "TOOL_GATEWAY_USER_TOKEN", + ): + monkeypatch.delenv(k, raising=False) + + +def _ensure_plugins_loaded() -> None: + """Idempotently load plugins so the registry is populated.""" + from hermes_cli.plugins import _ensure_plugins_discovered + + _ensure_plugins_discovered() + + +# --------------------------------------------------------------------------- +# Per-test isolation +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _isolate_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Each test starts with a clean browser-provider env.""" + _clear_browser_env(monkeypatch) + + +# --------------------------------------------------------------------------- +# Bundled plugins register +# --------------------------------------------------------------------------- + + +class TestBundledPluginsRegister: + """All three bundled browser plugins discover and register correctly.""" + + def test_all_three_plugins_present_in_registry(self) -> None: + _ensure_plugins_loaded() + from agent.browser_registry import list_providers + + names = sorted(p.name for p in list_providers()) + assert names == ["browser-use", "browserbase", "firecrawl"] + + @pytest.mark.parametrize( + "plugin_name,expected_display", + [ + ("browserbase", "Browserbase"), + ("browser-use", "Browser Use"), + ("firecrawl", "Firecrawl"), + ], + ) + def test_each_plugin_has_name_and_display_name( + self, plugin_name: str, expected_display: str + ) -> None: + _ensure_plugins_loaded() + from agent.browser_registry import get_provider + + provider = get_provider(plugin_name) + assert provider is not None, f"plugin {plugin_name!r} not registered" + assert provider.name == plugin_name + assert provider.display_name == expected_display + + @pytest.mark.parametrize( + "plugin_name", + ["browserbase", "browser-use", "firecrawl"], + ) + def test_each_plugin_has_setup_schema(self, plugin_name: str) -> None: + """``get_setup_schema()`` returns a dict the picker can consume.""" + _ensure_plugins_loaded() + from agent.browser_registry import get_provider + + provider = get_provider(plugin_name) + assert provider is not None + schema = provider.get_setup_schema() + assert isinstance(schema, dict) + assert "name" in schema + assert "env_vars" in schema + # Every cloud-browser plugin needs the agent-browser post-setup hook + # so the picker auto-installs the CLI on selection. + assert schema.get("post_setup") == "agent_browser" + + @pytest.mark.parametrize( + "plugin_name", + ["browserbase", "browser-use", "firecrawl"], + ) + def test_each_plugin_implements_full_lifecycle(self, plugin_name: str) -> None: + """The ABC's three lifecycle methods are all overridden.""" + _ensure_plugins_loaded() + from agent.browser_provider import BrowserProvider + from agent.browser_registry import get_provider + + provider = get_provider(plugin_name) + assert provider is not None + # Each method must be a real override, not the ABC's NotImplementedError + # default — we check by comparing the function reference. + assert type(provider).create_session is not BrowserProvider.create_session + assert type(provider).close_session is not BrowserProvider.close_session + assert ( + type(provider).emergency_cleanup is not BrowserProvider.emergency_cleanup + ) + + +# --------------------------------------------------------------------------- +# is_available() behavior +# --------------------------------------------------------------------------- + + +class TestIsAvailable: + """Each plugin's ``is_available()`` reflects env-var presence accurately.""" + + def test_browserbase_requires_both_api_key_and_project_id( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + _ensure_plugins_loaded() + from agent.browser_registry import get_provider + + p = get_provider("browserbase") + assert p is not None + assert p.is_available() is False + + # API key alone is insufficient. + monkeypatch.setenv("BROWSERBASE_API_KEY", "key") + assert p.is_available() is False + + # Both env vars set → available. + monkeypatch.setenv("BROWSERBASE_PROJECT_ID", "proj") + assert p.is_available() is True + + def test_browserbase_project_id_alone_insufficient( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + _ensure_plugins_loaded() + from agent.browser_registry import get_provider + + p = get_provider("browserbase") + assert p is not None + monkeypatch.setenv("BROWSERBASE_PROJECT_ID", "proj") + assert p.is_available() is False + + def test_browser_use_satisfied_by_api_key( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + _ensure_plugins_loaded() + from agent.browser_registry import get_provider + + p = get_provider("browser-use") + assert p is not None + assert p.is_available() is False + monkeypatch.setenv("BROWSER_USE_API_KEY", "key") + assert p.is_available() is True + + def test_firecrawl_requires_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None: + _ensure_plugins_loaded() + from agent.browser_registry import get_provider + + p = get_provider("firecrawl") + assert p is not None + assert p.is_available() is False + monkeypatch.setenv("FIRECRAWL_API_KEY", "key") + assert p.is_available() is True + + +# --------------------------------------------------------------------------- +# Registry resolution semantics +# --------------------------------------------------------------------------- + + +class TestRegistryResolution: + """``_resolve()`` implements the documented three-rule precedence.""" + + def test_resolve_none_with_no_creds_returns_none(self) -> None: + """No config, no env → local mode (None).""" + _ensure_plugins_loaded() + from agent.browser_registry import _resolve + + assert _resolve(None) is None + + def test_explicit_local_returns_none(self) -> None: + """``cloud_provider: local`` is a positive choice; short-circuits to None.""" + _ensure_plugins_loaded() + from agent.browser_registry import _resolve + + assert _resolve("local") is None + + def test_explicit_browserbase_returns_provider_even_when_unavailable(self) -> None: + """Rule 1: explicit-config wins even when credentials are missing. + + This is critical — the dispatcher needs to surface a typed + credentials error rather than silently switching backends. + """ + _ensure_plugins_loaded() + from agent.browser_registry import _resolve + + provider = _resolve("browserbase") + assert provider is not None + assert provider.name == "browserbase" + assert provider.is_available() is False # confirms "ignoring availability" + + def test_explicit_firecrawl_returns_provider_even_when_unavailable(self) -> None: + """Firecrawl behaves the same as browserbase under explicit config.""" + _ensure_plugins_loaded() + from agent.browser_registry import _resolve + + provider = _resolve("firecrawl") + assert provider is not None + assert provider.name == "firecrawl" + + def test_explicit_unknown_falls_back_to_auto_detect(self) -> None: + """Rule 1 miss: unknown name → fall through to legacy walk.""" + _ensure_plugins_loaded() + from agent.browser_registry import _resolve + + # With no credentials anywhere, auto-detect should also fail. + assert _resolve("not-a-real-provider") is None + + def test_legacy_walk_prefers_browser_use_over_browserbase( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Rule 3: walk order is browser-use → browserbase.""" + _ensure_plugins_loaded() + from agent.browser_registry import _resolve + + # Both available — browser-use should win. + monkeypatch.setenv("BROWSER_USE_API_KEY", "k1") + monkeypatch.setenv("BROWSERBASE_API_KEY", "k2") + monkeypatch.setenv("BROWSERBASE_PROJECT_ID", "p") + + provider = _resolve(None) + assert provider is not None + assert provider.name == "browser-use" + + def test_legacy_walk_falls_through_to_browserbase( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Rule 3: browser-use unavailable → browserbase picked.""" + _ensure_plugins_loaded() + from agent.browser_registry import _resolve + + monkeypatch.setenv("BROWSERBASE_API_KEY", "k") + monkeypatch.setenv("BROWSERBASE_PROJECT_ID", "p") + + provider = _resolve(None) + assert provider is not None + assert provider.name == "browserbase" + + def test_firecrawl_not_in_legacy_walk_even_when_only_one_available( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Regression: firecrawl is NEVER auto-selected even when single-eligible. + + Pre-PR-#25214, the dispatcher only auto-detected between Browser Use + and Browserbase; firecrawl was reachable solely via explicit + config. We preserve that gate because FIRECRAWL_API_KEY is shared + with the *web* firecrawl plugin — auto-routing a web-extract user + to a paid cloud browser would be a real behaviour regression. + """ + _ensure_plugins_loaded() + from agent.browser_registry import _resolve + + monkeypatch.setenv("FIRECRAWL_API_KEY", "k") + + # Only firecrawl is_available() — but it's not in the legacy walk. + assert _resolve(None) is None + + +# --------------------------------------------------------------------------- +# Legacy ABC backward-compat aliases (is_configured / provider_name) +# --------------------------------------------------------------------------- + + +class TestLegacyAbcAliases: + """is_configured() and provider_name() delegate to the new API.""" + + @pytest.mark.parametrize( + "plugin_name", + ["browserbase", "browser-use", "firecrawl"], + ) + def test_is_configured_delegates_to_is_available(self, plugin_name: str) -> None: + _ensure_plugins_loaded() + from agent.browser_registry import get_provider + + p = get_provider(plugin_name) + assert p is not None + assert p.is_configured() is p.is_available() + + @pytest.mark.parametrize( + "plugin_name,expected_label", + [ + ("browserbase", "Browserbase"), + ("browser-use", "Browser Use"), + ("firecrawl", "Firecrawl"), + ], + ) + def test_provider_name_returns_display_name( + self, plugin_name: str, expected_label: str + ) -> None: + _ensure_plugins_loaded() + from agent.browser_registry import get_provider + + p = get_provider(plugin_name) + assert p is not None + assert p.provider_name() == expected_label + + +# --------------------------------------------------------------------------- +# Picker integration +# --------------------------------------------------------------------------- + + +class TestPickerIntegration: + """`_plugin_browser_providers()` exposes all three plugins as picker rows.""" + + def test_picker_rows_match_registered_plugins(self) -> None: + _ensure_plugins_loaded() + from hermes_cli.tools_config import _plugin_browser_providers + + rows = _plugin_browser_providers() + names = sorted(r.get("browser_provider") for r in rows) + assert names == ["browser-use", "browserbase", "firecrawl"] + + def test_picker_rows_carry_post_setup_hook(self) -> None: + """Every browser plugin row has post_setup='agent_browser' so + selecting it triggers the agent-browser CLI install.""" + _ensure_plugins_loaded() + from hermes_cli.tools_config import _plugin_browser_providers + + for row in _plugin_browser_providers(): + assert row.get("post_setup") == "agent_browser", ( + f"plugin row {row['browser_provider']!r} missing post_setup hook" + ) + + def test_picker_rows_carry_browser_plugin_name_marker(self) -> None: + """`browser_plugin_name` matches `browser_provider` so downstream + code can route through the registry when it wants to.""" + _ensure_plugins_loaded() + from hermes_cli.tools_config import _plugin_browser_providers + + for row in _plugin_browser_providers(): + assert row.get("browser_plugin_name") == row.get("browser_provider") From 1bb6f03724590e5755619e97d0fe580d2cc92f9e Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 14 May 2026 14:27:21 +0530 Subject: [PATCH 023/418] fix(browser): ensure plugin discovery before registry lookup; parity harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes that go together: 1. tools/browser_tool.py — add _ensure_browser_plugins_loaded() and call it from _get_cloud_provider() before consulting the registry. Normally model_tools triggers discover_plugins() as an import side-effect, but _get_cloud_provider() can be reached from contexts that haven't gone through model_tools (standalone scripts, certain unit-test paths, the new parity-sweep harness). Without the defensive call, the registry is empty and _registry_get_browser_provider() returns None — silently downgrading users to local mode when they explicitly configured a cloud provider with no credentials yet. The behavior-parity sweep below caught this as 4 scenario regressions (explicit-X-no-creds for all 3 providers, and explicit-firecrawl-with-creds). 2. tests/plugins/browser/check_parity_vs_main.py — subprocess harness that pins one Python invocation to origin/main and one to this PR's worktree via sys.path.insert(), runs _get_cloud_provider() across a 13-scenario config matrix, and diffs the reduced shape tuple (is_local, provider_name, is_available). Provider_name pulls from provider.provider_name() which is the legacy CloudBrowserProvider API and remains as a backward-compat alias on the new BrowserProvider ABC, so the comparison is apples-to-apples regardless of class identity. Final result: PARITY OK across 13 scenarios. The four observable config/credential matrices that exercise the dispatcher all match origin/main bit-for-bit: - no-config + no-env → local - explicit local + any env → local - explicit BB / BU / FC + no creds → provider returned with is_available()==False (so dispatcher surfaces typed credentials error; matches main exactly) - explicit BB / BU / FC + creds → provider returned with is_available()==True - no-config + BU creds → Browser Use - no-config + BB creds → Browserbase - no-config + both → Browser Use (legacy walk first hit) - no-config + FC only → local (firecrawl NOT in legacy walk) - no-config + FC + BB → Browserbase (legacy walk skips firecrawl) Per the dev skill's "behavior-parity for refactor PRs" rule — without this subprocess sweep, 31/31 unit tests pass while the production code path is silently broken for users who type `browser.cloud_provider: browserbase` and run a single browser command without prior model_tools import. Caught + fixed before push. --- tests/plugins/browser/check_parity_vs_main.py | 276 ++++++++++++++++++ tools/browser_tool.py | 22 ++ 2 files changed, 298 insertions(+) create mode 100644 tests/plugins/browser/check_parity_vs_main.py diff --git a/tests/plugins/browser/check_parity_vs_main.py b/tests/plugins/browser/check_parity_vs_main.py new file mode 100644 index 000000000000..11652e94af93 --- /dev/null +++ b/tests/plugins/browser/check_parity_vs_main.py @@ -0,0 +1,276 @@ +"""Behavior-parity check for the browser-provider plugin migration (#25214). + +Spawns one subprocess per (version, scenario) cell — pinned to either +origin/main (legacy in-tree providers + class-instantiation lookup) or +this PR's worktree (plugin-based registry) via `sys.path[0]`. Each +subprocess clears all browser-related env vars + writes a config.yaml, +loads `tools.browser_tool._get_cloud_provider()`, and emits a reduced +"shape tuple" {is_local, provider_name, is_available} as JSON. + +The parent process diffs the shapes per scenario. A diff means the +migration introduced an observable behaviour change vs origin/main — +which would be a real regression for users on the existing config keys. + +Run from the PR worktree: + + cd ~/.hermes/hermes-agent/.worktrees/browser-providers-plugin + python tests/plugins/browser/check_parity_vs_main.py +""" +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[3] + + +# Pin one path to current main, one to the PR worktree. +# ``REPO_ROOT`` is ``.../.worktrees/browser-providers-plugin``; the main +# checkout lives two levels up at ``~/.hermes/hermes-agent``. +MAIN_DIR = REPO_ROOT.parent.parent # ~/.hermes/hermes-agent +PR_DIR = REPO_ROOT # the worktree we're in +assert (MAIN_DIR / "tools" / "browser_tool.py").exists(), ( + f"MAIN_DIR={MAIN_DIR} doesn't look like a hermes-agent checkout" +) +assert (PR_DIR / "tools" / "browser_tool.py").exists(), ( + f"PR_DIR={PR_DIR} doesn't look like a hermes-agent checkout" +) + + +# Reduced shape comparison — exact instance addresses obviously differ +# between subprocesses, so we compare the parts that matter for users. +SUBPROCESS_SCRIPT = r""" +import json, os, sys, tempfile +sys.path.insert(0, sys.argv[1]) + +# Isolated HERMES_HOME for the config write. +home = tempfile.mkdtemp() +os.environ["HERMES_HOME"] = home + +# Clear every browser-related env var so is_available() is deterministic. +for k in ( + "BROWSERBASE_API_KEY", "BROWSERBASE_PROJECT_ID", "BROWSERBASE_BASE_URL", + "BROWSER_USE_API_KEY", "BROWSER_USE_GATEWAY_URL", + "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "FIRECRAWL_BROWSER_TTL", + "TOOL_GATEWAY_DOMAIN", "TOOL_GATEWAY_USER_TOKEN", +): + os.environ.pop(k, None) + +# Apply per-scenario env (passed as JSON via argv[2]). +scenario_env = json.loads(sys.argv[2]) +os.environ.update(scenario_env) + +# Apply per-scenario config (passed as YAML body via argv[3]). +config_yaml = sys.argv[3] +config_path = os.path.join(home, "config.yaml") +with open(config_path, "w") as f: + f.write(config_yaml) + +# Fresh import — must not have any browser modules cached. +for name in list(sys.modules): + if name.startswith("tools.") or name.startswith("agent.") or name.startswith("plugins."): + sys.modules.pop(name, None) + +from tools.browser_tool import _get_cloud_provider, _is_local_mode + +provider = _get_cloud_provider() + +# Pull the human-readable backend name via the API that exists on BOTH +# legacy (origin/main: CloudBrowserProvider.provider_name()) and the new +# ABC (BrowserProvider exposes provider_name() as a backward-compat alias +# returning display_name). Both shapes resolve to the same string — +# 'Browserbase' / 'Browser Use' / 'Firecrawl' — so we can compare safely. +provider_name = None +is_available = None +if provider is not None: + pn = getattr(provider, "provider_name", None) + if callable(pn): + provider_name = pn() + elif isinstance(pn, str): + provider_name = pn + is_conf = getattr(provider, "is_configured", None) + if callable(is_conf): + is_available = bool(is_conf()) + +shape = { + "is_local": _is_local_mode(), + "provider_name": provider_name, + "is_available": is_available, +} +print(json.dumps(shape)) +""" + + +SCENARIOS: list[tuple[str, str, dict[str, str]]] = [ + # (label, config.yaml body, extra env vars) + ("no-config-no-env", "", {}), + ("explicit-local-no-env", "browser:\n cloud_provider: local\n", {}), + ( + "explicit-browserbase-no-creds", + "browser:\n cloud_provider: browserbase\n", + {}, + ), + ( + "explicit-browserbase-with-creds", + "browser:\n cloud_provider: browserbase\n", + {"BROWSERBASE_API_KEY": "x", "BROWSERBASE_PROJECT_ID": "y"}, + ), + ( + "explicit-browser-use-no-creds", + "browser:\n cloud_provider: browser-use\n", + {}, + ), + ( + "explicit-browser-use-with-creds", + "browser:\n cloud_provider: browser-use\n", + {"BROWSER_USE_API_KEY": "k"}, + ), + ( + "explicit-firecrawl-no-creds", + "browser:\n cloud_provider: firecrawl\n", + {}, + ), + ( + "explicit-firecrawl-with-creds", + "browser:\n cloud_provider: firecrawl\n", + {"FIRECRAWL_API_KEY": "k"}, + ), + ( + "no-config-bu-creds", + "", + {"BROWSER_USE_API_KEY": "k"}, + ), + ( + "no-config-bb-creds", + "", + {"BROWSERBASE_API_KEY": "x", "BROWSERBASE_PROJECT_ID": "y"}, + ), + ( + "no-config-both-creds", + "", + { + "BROWSER_USE_API_KEY": "k", + "BROWSERBASE_API_KEY": "x", + "BROWSERBASE_PROJECT_ID": "y", + }, + ), + ( + "no-config-firecrawl-only", + "", + {"FIRECRAWL_API_KEY": "k"}, + ), + ( + "no-config-firecrawl-and-bb", + "", + { + "FIRECRAWL_API_KEY": "k", + "BROWSERBASE_API_KEY": "x", + "BROWSERBASE_PROJECT_ID": "y", + }, + ), +] + + +def _run_scenario(repo_path: Path, label: str, config_yaml: str, env: dict) -> dict: + """Run one (version, scenario) cell. Returns the shape dict.""" + venv_python = repo_path / ".venv" / "bin" / "python" + if not venv_python.exists(): + # Worktrees share the main repo's venv. + venv_python = MAIN_DIR / ".venv" / "bin" / "python" + if not venv_python.exists(): + venv_python = Path("python3") + + out = subprocess.run( + [ + str(venv_python), + "-c", + SUBPROCESS_SCRIPT, + str(repo_path), + json.dumps(env), + config_yaml, + ], + capture_output=True, + text=True, + timeout=30, + ) + if out.returncode != 0: + return { + "error": "subprocess failed", + "stdout": out.stdout, + "stderr": out.stderr[-500:], + } + try: + return json.loads(out.stdout.strip().splitlines()[-1]) + except Exception as exc: + return {"error": f"could not parse output: {exc}", "stdout": out.stdout} + + +def _reduce_for_comparison(shape: dict) -> dict: + """Reduce a shape dict to the parts that matter for user-visible parity. + + We compare ``(is_local, provider_name, is_available)`` — the trio that + decides what the dispatcher does with each tool call. ``provider_name`` + is the legacy ``provider_name()`` return value ('Browserbase' / 'Browser + Use' / 'Firecrawl'), which is identical between legacy and plugin + classes (the plugin's ``display_name`` matches the legacy + ``provider_name()`` return). + """ + return { + "is_local": shape.get("is_local"), + "provider_name": shape.get("provider_name"), + "is_available": shape.get("is_available"), + } + + +def main() -> int: + print(f"main: {MAIN_DIR}") + print(f"pr: {PR_DIR}") + print() + + failures: list[str] = [] + errors: list[str] = [] + for label, config_yaml, env in SCENARIOS: + main_shape = _run_scenario(MAIN_DIR, label, config_yaml, env) + pr_shape = _run_scenario(PR_DIR, label, config_yaml, env) + + if "error" in main_shape or "error" in pr_shape: + print(f" [ERR ] {label}: subprocess failed") + print(f" main: {main_shape}") + print(f" pr: {pr_shape}") + errors.append(label) + continue + + main_reduced = _reduce_for_comparison(main_shape) + pr_reduced = _reduce_for_comparison(pr_shape) + + if main_reduced == pr_reduced: + print(f" [OK] {label}: {main_reduced}") + else: + print(f" [FAIL] {label}") + print(f" main: {main_reduced}") + print(f" pr: {pr_reduced}") + failures.append(label) + + print() + if errors: + print(f"SUBPROCESS ERRORS in {len(errors)} scenario(s):") + for e in errors: + print(f" - {e}") + if failures: + print(f"BEHAVIOUR REGRESSION in {len(failures)} scenario(s):") + for f in failures: + print(f" - {f}") + if failures or errors: + return 1 + print(f"PARITY OK across {len(SCENARIOS)} scenarios.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 6fdd89498160..b089ed921333 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -465,6 +465,25 @@ def _is_legacy_provider_registry_overridden() -> bool: return False +def _ensure_browser_plugins_loaded() -> None: + """Idempotently trigger plugin discovery so the browser registry is populated. + + Normally `model_tools` is imported early in any session and that + triggers `discover_plugins()` as a side effect. But `_get_cloud_provider` + can be called from contexts that haven't gone through `model_tools` — + standalone scripts, certain unit-test paths, the parity-sweep harness. + Make discovery idempotent and side-effect-only here so users always + see registered plugins regardless of import order. Cheap: subsequent + calls early-return inside `_ensure_plugins_discovered`. + """ + try: + from hermes_cli.plugins import _ensure_plugins_discovered + + _ensure_plugins_discovered() + except Exception as exc: + logger.debug("Browser plugin discovery failed (non-fatal): %s", exc) + + def _get_cloud_provider() -> Optional[CloudBrowserProvider]: """Return the configured cloud browser provider, or None for local mode. @@ -509,6 +528,9 @@ def _get_cloud_provider() -> Optional[CloudBrowserProvider]: if factory is not None: resolved = factory() else: + # Ensure plugins are discovered so the registry is + # populated. Idempotent — cheap on subsequent calls. + _ensure_browser_plugins_loaded() resolved = _registry_get_browser_provider(provider_key) except Exception: logger.warning( From c74ff2c8effce1615074820b03e0d13997c62bb5 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 14 May 2026 14:45:29 +0530 Subject: [PATCH 024/418] =?UTF-8?q?fix(browser):=20self-review=20pass=20?= =?UTF-8?q?=E2=80=94=20dead-import,=20log=20levels,=20future-proofing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses findings from two self-review passes pre-merge. First pass (3-agent parallel review): 1. plugins/browser/browser_use/provider.py: drop the ``_ = managed_nous_tools_enabled`` dead-import-hider in _get_config_or_none(). The import was actively misleading — the helper IS used in _get_config() (separate method, separate import), not here. The "keep static analysis happy" comment was wrong about what the helper does in this scope. 2. agent/browser_provider.py: drop ``pragma: no cover`` from is_configured() / provider_name() backward-compat aliases. They ARE covered by ``TestLegacyAbcAliases`` — the pragma would have masked future regressions. 3. tools/browser_tool.py: refactor _is_legacy_provider_registry_overridden() to compare against a module-frozen _DEFAULT_PROVIDER_REGISTRY snapshot instead of hardcoded set of 3 keys. Future maintainers adding a 4th built-in provider now just extend _PROVIDER_REGISTRY; the override detection adapts automatically. Previously the hardcoded ``set(...) != {"browserbase", "browser-use", "firecrawl"}`` would flip True forever on any 4-key registry, silently routing every install onto the legacy fixture path. 4. tools/browser_tool.py: when explicit ``browser.cloud_provider`` is set but the registry has no matching plugin (typo, uninstalled plugin, discovery failure), emit a WARNING with actionable text instead of silently falling through to auto-detect. Legacy code surfaced a typed credentials error via direct class instantiation; this log restores the signal in the post-migration path. 5. agent/browser_registry.py: trim the triple-redundant _LEGACY_PREFERENCE documentation. Module docstring + 13-line block-comment + 5-line inline comment was repeating the same point. Kept the docstring and trimmed the block-comment to 5 lines. 6. agent/browser_registry.py: upgrade is_available()-raised logging from DEBUG to WARNING with exc_info=True. A provider's availability check throwing is unusual enough that users debugging "no cloud provider" need the traceback in logs. 7. tests/plugins/browser/check_parity_vs_main.py: drop dead top-level imports (os, shutil, tempfile — only referenced inside the SUBPROCESS_SCRIPT string literal that runs in a child process). Second pass (architecture + claim-verification review): 8. tools/browser_tool.py: rewrite the inline comment in _get_cloud_provider auto-detect branch. Prior text claimed it "routes through the plugin registry's legacy preference walk so third-party plugins still get a chance to be selected when they're explicitly configured" — false on both counts. The branch uses module-level legacy class aliases (BrowserUseProvider / BrowserbaseProvider) directly; third-party plugins are intentionally reachable only via explicit ``browser.cloud_provider``. Corrected comment now matches behaviour and cross-references _LEGACY_PREFERENCE for the firecrawl gate rationale. 9. tools/browser_tool.py + tests/tools/test_managed_browserbase_and_modal.py: drop the unused ``get_active_browser_provider as _registry_get_active_browser_provider`` alias from the ``from agent.browser_registry import ...`` block. It was never referenced; matching test-stub line in the agent.browser_registry SimpleNamespace also dropped. ``get_provider`` is still imported (used by the explicit-config dispatch path at line 535). 10. plugins/browser/firecrawl/provider.py: align emergency_cleanup() with the early-guard pattern used in browserbase + browser_use plugins. Previously firecrawl tried the DELETE and relied on ``_headers()`` raising ValueError to trip a "missing credentials" warning; same final outcome but a different control flow that read like a bug to a maintainer skimming the three modules. Now: if is_available() is False, log+return early — identical shape to the other two providers. Verification: 54/54 unit tests + 13/13 parity scenarios still pass. --- agent/browser_provider.py | 4 +- agent/browser_registry.py | 23 +++---- plugins/browser/browser_use/provider.py | 11 +-- plugins/browser/firecrawl/provider.py | 11 +-- tests/plugins/browser/check_parity_vs_main.py | 3 - .../test_managed_browserbase_and_modal.py | 1 - tools/browser_tool.py | 68 ++++++++++++------- 7 files changed, 63 insertions(+), 58 deletions(-) diff --git a/agent/browser_provider.py b/agent/browser_provider.py index 338dfcd6b076..75e88e584f31 100644 --- a/agent/browser_provider.py +++ b/agent/browser_provider.py @@ -166,10 +166,10 @@ def get_setup_schema(self) -> Dict[str, Any]: # and :attr:`name`; they may override ``is_configured`` / ``provider_name`` # for compatibility with the legacy ABC but it is not required. - def is_configured(self) -> bool: # pragma: no cover - trivial delegation + def is_configured(self) -> bool: """Backward-compat alias for :meth:`is_available`.""" return self.is_available() - def provider_name(self) -> str: # pragma: no cover - trivial delegation + def provider_name(self) -> str: """Backward-compat alias returning :attr:`display_name`.""" return self.display_name diff --git a/agent/browser_registry.py b/agent/browser_registry.py index 7b5b8b99b5f3..db608744b343 100644 --- a/agent/browser_registry.py +++ b/agent/browser_registry.py @@ -99,19 +99,11 @@ def get_provider(name: str) -> Optional[BrowserProvider]: # --------------------------------------------------------------------------- -# Legacy preference order — preserves behaviour for users who set no -# ``browser.cloud_provider`` config key. Matches the historic auto-detect -# order in :func:`tools.browser_tool._get_cloud_provider` (Browser Use first -# because it covers both managed Nous gateway and direct API key; Browserbase -# second as the older direct-credentials fallback). Filtered by -# ``is_available()`` at walk time so we don't surface a provider the user -# has no credentials for. -# -# Note: ``firecrawl`` is intentionally absent. Pre-migration, the auto-detect -# branch only considered Browser Use → Browserbase; Firecrawl was reachable -# only via an explicit ``browser.cloud_provider: firecrawl`` config key. -# Preserving that gate prevents users with a ``FIRECRAWL_API_KEY`` set for -# web-extract from accidentally getting routed to a (paid) cloud browser. +# Legacy auto-detect order — used when no ``browser.cloud_provider`` is set. +# Matches the pre-migration walk in :func:`tools.browser_tool._get_cloud_provider`. +# Firecrawl is intentionally absent so users with ``FIRECRAWL_API_KEY`` set +# for web-extract don't get silently routed to a paid cloud browser. See +# :func:`_resolve` for the full rationale. _LEGACY_PREFERENCE = ( "browser-use", "browserbase", @@ -159,7 +151,10 @@ def _is_available_safe(p: BrowserProvider) -> bool: try: return bool(p.is_available()) except Exception as exc: # noqa: BLE001 - logger.debug("provider %s.is_available() raised %s", p.name, exc) + logger.warning( + "Browser provider %s.is_available() raised %s — treating as unavailable", + p.name, exc, exc_info=True, + ) return False # 1. Explicit "local" short-circuit. diff --git a/plugins/browser/browser_use/provider.py b/plugins/browser/browser_use/provider.py index 82bd2420ca13..8c5af5f9f00b 100644 --- a/plugins/browser/browser_use/provider.py +++ b/plugins/browser/browser_use/provider.py @@ -130,9 +130,10 @@ def _get_config_or_none(self) -> Optional[Dict[str, Any]]: # managed_tool_gateway pulls in the Nous auth stack which can be # heavy and is not needed for direct-API-key users. from tools.managed_tool_gateway import resolve_managed_tool_gateway - from tools.tool_backend_helpers import managed_nous_tools_enabled, prefers_gateway + from tools.tool_backend_helpers import prefers_gateway - # 1. Direct API key path (unless user explicitly prefers gateway). + # Direct API key wins unless the user has explicitly opted into the + # managed Nous gateway via ``tool_gateway.browser: gateway``. api_key = os.environ.get("BROWSER_USE_API_KEY") if api_key and not prefers_gateway("browser"): return { @@ -141,16 +142,10 @@ def _get_config_or_none(self) -> Optional[Dict[str, Any]]: "managed_mode": False, } - # 2. Managed Nous gateway path. managed = resolve_managed_tool_gateway("browser-use") if managed is None: return None - # Hold reference to managed_nous_tools_enabled so static analysis - # doesn't flag the import as unused — the helper is consulted by - # _get_config() below to compose a more accurate error message. - _ = managed_nous_tools_enabled - return { "api_key": managed.nous_user_token, "base_url": managed.gateway_origin.rstrip("/"), diff --git a/plugins/browser/firecrawl/provider.py b/plugins/browser/firecrawl/provider.py index a3f74d321133..498e4ffad9b6 100644 --- a/plugins/browser/firecrawl/provider.py +++ b/plugins/browser/firecrawl/provider.py @@ -130,17 +130,18 @@ def close_session(self, session_id: str) -> bool: return False def emergency_cleanup(self, session_id: str) -> None: + if not self.is_available(): + logger.warning( + "Cannot emergency-cleanup Firecrawl session %s — missing credentials", + session_id, + ) + return try: requests.delete( f"{self._api_url()}/v2/browser/{session_id}", headers=self._headers(), timeout=5, ) - except ValueError: - logger.warning( - "Cannot emergency-cleanup Firecrawl session %s — missing credentials", - session_id, - ) except Exception as e: logger.debug( "Emergency cleanup failed for Firecrawl session %s: %s", session_id, e diff --git a/tests/plugins/browser/check_parity_vs_main.py b/tests/plugins/browser/check_parity_vs_main.py index 11652e94af93..b706ce3e9c0b 100644 --- a/tests/plugins/browser/check_parity_vs_main.py +++ b/tests/plugins/browser/check_parity_vs_main.py @@ -19,11 +19,8 @@ from __future__ import annotations import json -import os -import shutil import subprocess import sys -import tempfile from pathlib import Path diff --git a/tests/tools/test_managed_browserbase_and_modal.py b/tests/tools/test_managed_browserbase_and_modal.py index 3d0d7b3419eb..d88789706baa 100644 --- a/tests/tools/test_managed_browserbase_and_modal.py +++ b/tests/tools/test_managed_browserbase_and_modal.py @@ -106,7 +106,6 @@ class _StubBrowserProvider: BrowserProvider=_StubBrowserProvider, ) sys.modules["agent.browser_registry"] = types.SimpleNamespace( - get_active_browser_provider=lambda: None, get_provider=lambda name: None, list_providers=lambda: [], register_provider=lambda provider: None, diff --git a/tools/browser_tool.py b/tools/browser_tool.py index b089ed921333..fb96649cb386 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -90,7 +90,6 @@ # shims for callers that import them from this module. from agent.browser_provider import BrowserProvider as CloudBrowserProvider # noqa: F401 (legacy alias) from agent.browser_registry import ( # noqa: F401 (test-patchable surface) - get_active_browser_provider as _registry_get_active_browser_provider, get_provider as _registry_get_browser_provider, ) from plugins.browser.browserbase.provider import ( # noqa: F401 (legacy import surface) @@ -425,6 +424,10 @@ def _stop_cdp_supervisor(task_id: str) -> None: "browser-use": BrowserUseProvider, "firecrawl": FirecrawlProvider, } +# Frozen copy of the import-time _PROVIDER_REGISTRY, used by +# ``_is_legacy_provider_registry_overridden`` to detect test-time +# monkeypatching. NEVER mutate this dict. +_DEFAULT_PROVIDER_REGISTRY: Dict[str, type] = dict(_PROVIDER_REGISTRY) _cached_cloud_provider: Optional[CloudBrowserProvider] = None _cloud_provider_resolved = False @@ -442,25 +445,23 @@ def _stop_cdp_supervisor(task_id: str) -> None: def _is_legacy_provider_registry_overridden() -> bool: """Return True when a test has patched ``_PROVIDER_REGISTRY`` to a custom value. - Detected by comparing identity with the module-level defaults dict - populated above. Tests that ``monkeypatch.setattr(browser_tool, - "_PROVIDER_REGISTRY", ...)`` swap in a new object; identity differs - even when the contents happen to match. Used by ``_get_cloud_provider`` - to honour test-time overrides (which expect a factory-callable shape) - instead of routing through the plugin registry. - """ - # The module-level _PROVIDER_REGISTRY is built once at import time. A test - # that swaps it via monkeypatch creates a new dict; we detect that via - # the registered class identities, not by ``is`` on the dict itself - # (the patch may install a dict whose values happen to be the same - # classes; treat that as "not overridden"). + Detected by spotting any registered class that *isn't* the canonical + plugin-backed class for that name. Tests that + ``monkeypatch.setattr(browser_tool, "_PROVIDER_REGISTRY", ...)`` install + custom factories (`exploding_factory`, `lambda: fake_provider`, etc.); + those entries fail the canonical-class identity check below. + + Note: a future maintainer adding a 4th built-in provider only needs to + extend ``_DEFAULT_PROVIDER_REGISTRY`` below — they do NOT need to update + a hardcoded set of keys here. The detection just compares each registered + value against the corresponding canonical class. + """ try: - return ( - _PROVIDER_REGISTRY.get("browserbase") is not BrowserbaseProvider - or _PROVIDER_REGISTRY.get("browser-use") is not BrowserUseProvider - or _PROVIDER_REGISTRY.get("firecrawl") is not FirecrawlProvider - or set(_PROVIDER_REGISTRY.keys()) != {"browserbase", "browser-use", "firecrawl"} - ) + for key, default_cls in _DEFAULT_PROVIDER_REGISTRY.items(): + if _PROVIDER_REGISTRY.get(key) is not default_cls: + return True + # Extra keys not in the default registry → also an override. + return len(_PROVIDER_REGISTRY) != len(_DEFAULT_PROVIDER_REGISTRY) except Exception: return False @@ -532,6 +533,20 @@ def _get_cloud_provider() -> Optional[CloudBrowserProvider]: # populated. Idempotent — cheap on subsequent calls. _ensure_browser_plugins_loaded() resolved = _registry_get_browser_provider(provider_key) + if resolved is None: + # Explicit config name unknown to the registry — + # might be a typo, an uninstalled plugin, or a + # registry-population failure. Warn the user + # (legacy code would have surfaced a typed + # credentials error via direct class instantiation; + # post-migration we surface this WARNING instead). + logger.warning( + "browser.cloud_provider=%r is not a registered " + "browser plugin; falling back to auto-detect " + "(install the corresponding plugin or fix the " + "config key spelling).", + provider_key, + ) except Exception: logger.warning( "Failed to instantiate explicit cloud_provider %r; will retry on next call", @@ -545,12 +560,15 @@ def _get_cloud_provider() -> Optional[CloudBrowserProvider]: logger.debug("Could not read cloud_provider from config: %s", e) if resolved is None: - # Auto-detect path. When tests have patched the per-class names - # on this module (BrowserUseProvider / BrowserbaseProvider), honour - # them — the test_browser_cloud_provider_cache test relies on this. - # Otherwise route through the plugin registry's legacy preference - # walk so third-party plugins still get a chance to be selected - # when they're explicitly configured. + # Auto-detect path: Browser Use first (managed Nous gateway or + # direct API key), then Browserbase (direct credentials). Uses + # the legacy class names imported at the top of this module so + # tests that ``monkeypatch.setattr(browser_tool, "BrowserUseProvider", ...)`` + # keep driving this branch deterministically. Third-party browser + # plugins are intentionally NOT reachable from auto-detect — they + # participate only via explicit ``browser.cloud_provider: ``, + # mirroring the firecrawl gate documented on + # :data:`agent.browser_registry._LEGACY_PREFERENCE`. try: fallback_provider = BrowserUseProvider() if fallback_provider.is_configured(): From f36c89cd5798da0f313192555739975e57ffdef5 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 17 May 2026 04:02:05 -0700 Subject: [PATCH 025/418] fix(plugins/browser): carry forward requests.RequestException wrapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #25580 was authored before #2746 landed on main, so its plugin versions of browser_use/browserbase/firecrawl ship without the requests.RequestException → RuntimeError wrapping that 13c72fb4 added to the legacy tools/browser_providers/ files for #2746. Cherry-picking the PR + git rm'ing the legacy files (the migration's intent) would silently revert that network-error fix. Port the same try/except pattern into the three plugin create_session() methods. Browser Use managed-mode keeps its raw-exception propagation (idempotency-key retry semantics). Co-authored-by: nidhi-singh02 --- plugins/browser/browser_use/provider.py | 22 +++++-- plugins/browser/browserbase/provider.py | 81 +++++++++++++------------ plugins/browser/firecrawl/provider.py | 17 ++++-- 3 files changed, 70 insertions(+), 50 deletions(-) diff --git a/plugins/browser/browser_use/provider.py b/plugins/browser/browser_use/provider.py index 8c5af5f9f00b..3d371bdd88a7 100644 --- a/plugins/browser/browser_use/provider.py +++ b/plugins/browser/browser_use/provider.py @@ -198,12 +198,22 @@ def create_session(self, task_id: str) -> Dict[str, object]: else {} ) - response = requests.post( - f"{config['base_url']}/browsers", - headers=headers, - json=payload, - timeout=30, - ) + try: + response = requests.post( + f"{config['base_url']}/browsers", + headers=headers, + json=payload, + timeout=30, + ) + except requests.RequestException as exc: + # Managed mode: propagate raw so callers can retry with the + # preserved idempotency key. Direct mode: wrap network failures + # into a clean RuntimeError for end users. + if managed_mode: + raise + raise RuntimeError( + f"Browser Use API connection failed: {exc}" + ) from exc if not response.ok: if managed_mode and not _should_preserve_pending_create_key(response): diff --git a/plugins/browser/browserbase/provider.py b/plugins/browser/browserbase/provider.py index 0d1a646c8a65..2b05d01d03b4 100644 --- a/plugins/browser/browserbase/provider.py +++ b/plugins/browser/browserbase/provider.py @@ -139,45 +139,50 @@ def create_session(self, task_id: str) -> Dict[str, object]: "X-BB-API-Key": config["api_key"], } - response = requests.post( - f"{config['base_url']}/v1/sessions", - headers=headers, - json=session_config, - timeout=30, - ) - - proxies_fallback = False - keepalive_fallback = False - - # Handle 402 — paid features unavailable - if response.status_code == 402: - if enable_keep_alive: - keepalive_fallback = True - logger.warning( - "keepAlive may require paid plan (402), retrying without it. " - "Sessions may timeout during long operations." - ) - session_config.pop("keepAlive", None) - response = requests.post( - f"{config['base_url']}/v1/sessions", - headers=headers, - json=session_config, - timeout=30, - ) + try: + response = requests.post( + f"{config['base_url']}/v1/sessions", + headers=headers, + json=session_config, + timeout=30, + ) - if response.status_code == 402 and enable_proxies: - proxies_fallback = True - logger.warning( - "Proxies unavailable (402), retrying without proxies. " - "Bot detection may be less effective." - ) - session_config.pop("proxies", None) - response = requests.post( - f"{config['base_url']}/v1/sessions", - headers=headers, - json=session_config, - timeout=30, - ) + proxies_fallback = False + keepalive_fallback = False + + # Handle 402 — paid features unavailable + if response.status_code == 402: + if enable_keep_alive: + keepalive_fallback = True + logger.warning( + "keepAlive may require paid plan (402), retrying without it. " + "Sessions may timeout during long operations." + ) + session_config.pop("keepAlive", None) + response = requests.post( + f"{config['base_url']}/v1/sessions", + headers=headers, + json=session_config, + timeout=30, + ) + + if response.status_code == 402 and enable_proxies: + proxies_fallback = True + logger.warning( + "Proxies unavailable (402), retrying without proxies. " + "Bot detection may be less effective." + ) + session_config.pop("proxies", None) + response = requests.post( + f"{config['base_url']}/v1/sessions", + headers=headers, + json=session_config, + timeout=30, + ) + except requests.RequestException as exc: + raise RuntimeError( + f"Browserbase API connection failed: {exc}" + ) from exc if not response.ok: raise RuntimeError( diff --git a/plugins/browser/firecrawl/provider.py b/plugins/browser/firecrawl/provider.py index 498e4ffad9b6..2c605134a01c 100644 --- a/plugins/browser/firecrawl/provider.py +++ b/plugins/browser/firecrawl/provider.py @@ -82,12 +82,17 @@ def create_session(self, task_id: str) -> Dict[str, object]: body: Dict[str, object] = {"ttl": ttl} - response = requests.post( - f"{self._api_url()}/v2/browser", - headers=self._headers(), - json=body, - timeout=30, - ) + try: + response = requests.post( + f"{self._api_url()}/v2/browser", + headers=self._headers(), + json=body, + timeout=30, + ) + except requests.RequestException as exc: + raise RuntimeError( + f"Firecrawl API connection failed: {exc}" + ) from exc if not response.ok: raise RuntimeError( From 3b4dd683263c5895bb6144564e4bea8881d79993 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 17 May 2026 11:10:06 -0500 Subject: [PATCH 026/418] fix(tui): align composer cursorLayout with wrap-ansi to kill multiline cursor drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer's `cursorLayout` (in `ui-tui/src/lib/inputMetrics.ts`) used a hand-rolled word-wrap algorithm to decide where `useDeclaredCursor` should park the hardware cursor. But Ink's `` renders the same text via `wrap-ansi`. The two algorithms disagreed on common real-world inputs — `"branch investigate"` at cols=20, `"hello world"` at cols=8, exact-fill strings like `"abcdefgh"` at cols=8 — so the hardware cursor parked several cells past where Ink actually rendered the last character. Users saw a multi-cell blank gap between their last-typed letter and the cursor block, especially on narrow terminals (the Cursor IDE built-in terminal was the worst offender). Three previous PRs (#26717, #25860, #22197) chased fast-echo displayCursor/cursorDeclaration drift and in-band-vs-native cursor heuristics. None of them touched the underlying wrap-algorithm mismatch, which is why the bug kept resurfacing. Fix: source cursorLayout's line breaks from wrap-ansi directly. Walk its emitted string char-by-char, tracking original-string offsets, push a VisualLine at each '\n'. Also drop the buggy `column >= w` overflow rule in cursorLayout — that's what pushed exact-fill text onto a phantom next row. canFastBackspaceShape now detects the wrap boundary in BOTH coordinate conventions (column === 0 OR column >= columns), since exact-fill now reports as (0, columns) instead of the previous (1, 0). The physical state is identical — the terminal auto-wraps at column N either way — but the layout function reports the position more honestly. Tests: - ui-tui/src/__tests__/textInputWrap.test.ts: 3 tests that pinned the BUGGY behavior were updated to assert wrap-ansi parity (the real invariant). Added a typing-prefix invariant: cursorLayout must agree with wrap-ansi at every character of a long input. - ui-tui/src/__tests__/cursorDriftRegression.test.ts: new file. Walks the user-reported bug message char-by-char at 7 widths and asserts agreement with wrap-ansi at every prefix. Verification: - 791/791 vitest tests pass. - 84/84 tui-gateway pytest tests pass via scripts/run_tests.sh. - PTY repro (typing into a real `hermes --tui` PTY at cols=50/55/60): cursor lands exactly 1 cell past the last typed char in every case the bug previously drifted. --- .../__tests__/cursorDriftRegression.test.ts | 114 +++++++++++++++ ui-tui/src/__tests__/textInputWrap.test.ts | 68 +++++++-- ui-tui/src/components/textInput.tsx | 20 ++- ui-tui/src/lib/inputMetrics.ts | 130 +++++++++--------- 4 files changed, 251 insertions(+), 81 deletions(-) create mode 100644 ui-tui/src/__tests__/cursorDriftRegression.test.ts diff --git a/ui-tui/src/__tests__/cursorDriftRegression.test.ts b/ui-tui/src/__tests__/cursorDriftRegression.test.ts new file mode 100644 index 000000000000..0e562e09789d --- /dev/null +++ b/ui-tui/src/__tests__/cursorDriftRegression.test.ts @@ -0,0 +1,114 @@ +/** + * Pinned regression for the multi-line composer cursor-drift bug. + * + * Symptom: in `hermes --tui`, typing into the composer until the input + * wraps across multiple visual rows would leave several blank cells + * between the last typed character and the (hardware) cursor block. + * Worse on narrow terminals (the Cursor IDE built-in terminal in + * particular). + * + * Root cause: the composer's `cursorLayout` (used by `useDeclaredCursor` + * to place the hardware cursor) ran a hand-rolled word-wrap algorithm, + * while Ink's `` renders via `wrap-ansi`. The two + * disagreed on many real inputs — wrap-ansi would keep "branch + * investigate" on one row while cursorLayout claimed it had wrapped, + * etc. — so the declared cursor position drifted from where the text + * was actually rendered. The fix sources cursorLayout's line breaks + * directly from wrap-ansi, guaranteeing agreement. + * + * This test pins the contract: for every char that would be typed into + * the composer, the cursor position reported by cursorLayout MUST equal + * the end-of-text position that wrap-ansi would render. Any future + * regression that lets the two diverge re-introduces the drift. + */ +import { describe, expect, it } from 'vitest' +import wrapAnsi from 'wrap-ansi' + +import { cursorLayout, inputVisualHeight } from '../lib/inputMetrics.js' + +function wrapAnsiEnd(text: string, cols: number): { line: number; column: number } { + const wrapped = wrapAnsi(text, cols, { hard: true, trim: false }) + const lines = wrapped.split('\n') + const last = lines[lines.length - 1] ?? '' + + return { line: lines.length - 1, column: last.length } +} + +const USER_REPORT_MESSAGE = + // Paraphrase of the user's actual bug report, included verbatim so the + // test is grounded in a realistic typing pattern (long single line, + // mixed-length words, punctuation, no hard newlines). + 'im in cursor terminal using hermes --tui and as i type multiline my caret at the end will often ' + + 'go.. randomly.. like multiple spaces away lol and idk why. theres no rhyme/reason really but ' + + 'there should literally never be a non-user added space at the end of my composer input right? ' + + 'i dont think it happens on new sessions but only existing ones. there have been a few prs to ' + + 'try to fix this and all not working. ok it just happened, to me, nowso attaching screenshot ' + + 'and you can see its multiline, new session. on a new bb/ branch investigate' + +describe('cursor-drift regression — composer cursorLayout matches Ink rendering', () => { + it('agrees with wrap-ansi at every typing-prefix of the user-reported message', () => { + // Walks the message char-by-char (mirroring what the TUI sees when a + // user types). At every prefix, cursorLayout must place the cursor + // exactly where wrap-ansi would render the end of the text. + // + // Pre-fix: this failed on most narrow widths because the hand-rolled + // wrap algorithm broke at slightly different points than wrap-ansi. + for (const cols of [40, 50, 55, 60, 65, 70, 80]) { + let acc = '' + + for (const ch of USER_REPORT_MESSAGE) { + acc += ch + const layout = cursorLayout(acc, acc.length, cols) + const expected = wrapAnsiEnd(acc, cols) + + expect( + layout, + `mismatch at cols=${cols}, len=${acc.length}, last-char=${JSON.stringify(ch)}, ` + + `tail=${JSON.stringify(acc.slice(-30))}` + ).toEqual(expected) + } + } + }) + + it('keeps cursor on the same row when text exactly fills the terminal width', () => { + // wrap-ansi does NOT push exact-fill text onto a phantom next line. + // The previous algorithm did — that's what produced the visible + // "cursor parked one row below the last char" symptom on narrow + // terminals at certain message lengths. + for (const cols of [8, 12, 18, 24]) { + const text = 'a'.repeat(cols) + const layout = cursorLayout(text, text.length, cols) + const inkLines = wrapAnsi(text, cols, { hard: true, trim: false }).split('\n') + + expect(layout.line).toBe(0) + expect(layout.column).toBe(cols) + expect(inkLines).toHaveLength(1) + expect(inputVisualHeight(text, cols)).toBe(1) + } + }) + + it('does not stuff a trailing whitespace word onto a phantom line', () => { + // "branch investigate" at cols=20 fits on one row in wrap-ansi. The + // bug claimed otherwise, parking the cursor at (line=1, col=?) and + // leaving the user's "branch investigate" rendered alone on row 0 + // with the cursor block several cells past it. + const text = 'branch investigate' + const cols = 20 + + expect(cursorLayout(text, text.length, cols)).toEqual({ column: text.length, line: 0 }) + expect(cursorLayout(text, text.length, cols)).toEqual(wrapAnsiEnd(text, cols)) + }) + + it('agrees with wrap-ansi for word-wrap that pushes a word onto the next line', () => { + // "hello world" at cols=8 wraps to ["hello ", "world"] in wrap-ansi. + // The cursor at end-of-text must land at line=1, col=5 — where Ink + // actually renders the last 'd'. The previous algorithm reported + // (line=2, col=0) here (phantom extra wrap), which parked the + // cursor on a row Ink never painted. + const text = 'hello world' + const cols = 8 + + expect(cursorLayout(text, text.length, cols)).toEqual({ column: 5, line: 1 }) + expect(cursorLayout(text, text.length, cols)).toEqual(wrapAnsiEnd(text, cols)) + }) +}) diff --git a/ui-tui/src/__tests__/textInputWrap.test.ts b/ui-tui/src/__tests__/textInputWrap.test.ts index c25c9629e77a..a0e70431465f 100644 --- a/ui-tui/src/__tests__/textInputWrap.test.ts +++ b/ui-tui/src/__tests__/textInputWrap.test.ts @@ -1,8 +1,20 @@ import { describe, expect, it } from 'vitest' +import wrapAnsi from 'wrap-ansi' import { offsetFromPosition } from '../components/textInput.js' import { composerPromptWidth, cursorLayout, inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js' +// Helper: compute the "end of text" position that wrap-ansi would render +// the input to. This is what Ink's uses, so cursorLayout +// MUST agree. Disagreement is the cursor-drift bug. +function wrapAnsiEndPosition(text: string, cols: number): { line: number; column: number } { + const wrapped = wrapAnsi(text, cols, { hard: true, trim: false }) + const lines = wrapped.split('\n') + const last = lines[lines.length - 1] ?? '' + + return { line: lines.length - 1, column: last.length } +} + describe('cursorLayout — word-wrap parity with wrap-ansi', () => { it('places cursor mid-line at its column', () => { expect(cursorLayout('hello world', 6, 40)).toEqual({ column: 6, line: 0 }) @@ -12,19 +24,36 @@ describe('cursorLayout — word-wrap parity with wrap-ansi', () => { expect(cursorLayout('hi', 2, 10)).toEqual({ column: 2, line: 0 }) }) - it('wraps to next line when cursor lands exactly at the right edge', () => { - // 8 chars on an 8-col line: text fills the row exactly; the cursor's - // inverted-space cell overflows to col 0 of the next row. - expect(cursorLayout('abcdefgh', 8, 8)).toEqual({ column: 0, line: 1 }) + it('does not push exact-fill text onto a phantom next line', () => { + // Regression: the previous hand-rolled wrap algorithm forced the cursor + // onto (line+1, 0) when the text exactly filled the row. wrap-ansi keeps + // it on the same row (no soft-wrap), so the cursor must too — otherwise + // useDeclaredCursor parks the hardware cursor below the last char and + // the user sees several blank cells between text and cursor block + // (#cursor-drift-multiline). + expect(cursorLayout('abcdefgh', 8, 8)).toEqual({ column: 8, line: 0 }) + expect(cursorLayout('abcdefgh', 8, 8)).toEqual(wrapAnsiEndPosition('abcdefgh', 8)) + }) + + it('keeps short words on the current line when they fit (no phantom wrap)', () => { + // wrap-ansi: "hello wo" at cols=8 stays as one line "hello wo". + // The old cursorLayout incorrectly pushed to (1,0) because column=8 hit + // the column>=width check, but that disagreed with what Ink actually + // rendered. + expect(cursorLayout('hello wo', 8, 8)).toEqual({ column: 8, line: 0 }) + expect(cursorLayout('hello wo', 8, 8)).toEqual(wrapAnsiEndPosition('hello wo', 8)) }) it('moves words across wrap boundaries instead of splitting them', () => { - // With wordWrap:true, "hello wor" at cols=8 is "hello \nwor" rather - // than "hello wo\nr". - expect(cursorLayout('hello wo', 8, 8)).toEqual({ column: 0, line: 1 }) + // "hello wor" at cols=8: wrap-ansi breaks at the space, "hello \nwor". expect(cursorLayout('hello wor', 9, 8)).toEqual({ column: 3, line: 1 }) expect(cursorLayout('hello worl', 10, 8)).toEqual({ column: 4, line: 1 }) expect(cursorLayout('hello world', 11, 8)).toEqual({ column: 5, line: 1 }) + + // Each must match what wrap-ansi would actually render. + expect(cursorLayout('hello wor', 9, 8)).toEqual(wrapAnsiEndPosition('hello wor', 8)) + expect(cursorLayout('hello worl', 10, 8)).toEqual(wrapAnsiEndPosition('hello worl', 8)) + expect(cursorLayout('hello world', 11, 8)).toEqual(wrapAnsiEndPosition('hello world', 8)) }) it('wraps the next word instead of splitting it at the right edge', () => { @@ -42,12 +71,33 @@ describe('cursorLayout — word-wrap parity with wrap-ansi', () => { it('does not wrap when cursor is before the right edge', () => { expect(cursorLayout('abcdefg', 7, 8)).toEqual({ column: 7, line: 0 }) }) + + it('matches wrap-ansi end-position for typing-style incremental input', () => { + // Pins the actual fix: type a long message char-by-char at a narrow + // width and assert the cursor follows wrap-ansi every step of the way. + // Before the fix, ~5 boundary positions per pass disagreed and Ink + // parked the cursor several cells past the last rendered character. + const MSG = 'on a new bb branch investigate and fix the cursor drift bug here' + + for (const cols of [10, 14, 20, 30, 50, 80]) { + let acc = '' + + for (const ch of MSG) { + acc += ch + expect(cursorLayout(acc, acc.length, cols)).toEqual(wrapAnsiEndPosition(acc, cols)) + } + } + }) }) describe('input metrics helpers', () => { - it('computes visual height from the wrapped cursor line', () => { - expect(inputVisualHeight('abcdefgh', 8)).toBe(2) + it('computes visual height matching wrap-ansi line count', () => { + // Exact-fill text stays on one line in wrap-ansi (no phantom wrap), so + // visual height is 1. The previous implementation reported 2 here. + expect(inputVisualHeight('abcdefgh', 8)).toBe(1) expect(inputVisualHeight('one\ntwo', 40)).toBe(2) + // Multi-line wrap case sanity + expect(inputVisualHeight('hello world', 8)).toBe(2) }) it('counts the prompt gap as its own cell', () => { diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index ace2f479dc19..92082280a04e 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -272,10 +272,22 @@ export function canFastBackspaceShape(current: string, cursor: number, columns?: } // If we know the wrap width, reject at the soft-wrap boundary: the - // caret's visual column is 0, so "\b \b" can't represent the physical - // move back to the previous visual line. - if (columns !== undefined && cursorLayout(current, cursor, columns).column === 0) { - return false + // caret's physical column would be at (or past) the terminal's right + // edge, so the terminal has already auto-wrapped to the next row. + // "\b \b" can't represent the physical move back across that wrap. + // + // We check `column === 0` for the "wrap-ansi broke onto a new line" + // case AND `column >= columns` for the "exact-fill, terminal auto-wraps" + // case. Both manifest as the same physical state (cursor parked at + // col 0 of the next row) but cursorLayout reports them differently + // because it now mirrors wrap-ansi's break points exactly (see the + // cursor-drift-multiline fix in lib/inputMetrics.ts). + if (columns !== undefined) { + const layout = cursorLayout(current, cursor, columns) + + if (layout.column === 0 || layout.column >= columns) { + return false + } } const removed = current.slice(prevPos(current, cursor), cursor) diff --git a/ui-tui/src/lib/inputMetrics.ts b/ui-tui/src/lib/inputMetrics.ts index b5645b43310f..208b35336787 100644 --- a/ui-tui/src/lib/inputMetrics.ts +++ b/ui-tui/src/lib/inputMetrics.ts @@ -1,4 +1,5 @@ import { stringWidth } from '@hermes/ink' +import wrapAnsi from 'wrap-ansi' import type { Role } from '../types.js' @@ -12,8 +13,6 @@ interface VisualLine { start: number } -const isWhitespace = (value: string) => /\s/.test(value) - const graphemes = (value: string) => [...seg().segment(value)].map(({ segment, index }) => ({ end: index + segment.length, @@ -22,79 +21,68 @@ const graphemes = (value: string) => width: Math.max(1, stringWidth(segment)) })) -function visualLines(value: string, cols: number): VisualLine[] { +// Build VisualLines from wrap-ansi's output by mapping each emitted character +// back to its original offset in `value`. wrap-ansi only INSERTS '\n' at wrap +// boundaries — it never drops, reorders, or substitutes existing characters — +// so a parallel walk uniquely identifies each line's source range. +// +// This used to be a hand-rolled word-wrap (visualLines below) whose break +// points disagreed with wrap-ansi in subtle but visible ways: exact-fill rows +// pushed the cursor to a phantom next line, mid-word breaks landed one +// grapheme off, etc. The composer's TextInput renders text via Ink's +// , which delegates to wrap-ansi — so any drift between the +// two algorithms parks the hardware cursor several cells away from the last +// rendered character. Sourcing both from wrap-ansi guarantees agreement. +function visualLinesFromWrappedOutput(value: string, cols: number): VisualLine[] { + if (!value.length) { + return [{ start: 0, end: 0 }] + } + const width = Math.max(1, cols) + const wrapped = wrapAnsi(value, width, { hard: true, trim: false }) const lines: VisualLine[] = [] - let sourceLineStart = 0 - for (const sourceLine of value.split('\n')) { - const parts = graphemes(sourceLine) + let originalIdx = 0 + let lineStart = 0 - if (!parts.length) { - lines.push({ start: sourceLineStart, end: sourceLineStart }) - sourceLineStart += 1 - continue - } - - let lineStartPart = 0 - let lineStartOffset = sourceLineStart - let column = 0 - let breakPart: null | number = null - let i = 0 - - while (i < parts.length) { - const part = parts[i]! - const partStart = sourceLineStart + part.index - - if (column + part.width > width && i > lineStartPart) { - if (breakPart !== null && breakPart > lineStartPart) { - const breakOffset = sourceLineStart + parts[breakPart - 1]!.end - lines.push({ start: lineStartOffset, end: breakOffset }) - lineStartPart = breakPart - lineStartOffset = breakOffset - } else { - lines.push({ start: lineStartOffset, end: partStart }) - lineStartPart = i - lineStartOffset = partStart - } - - column = 0 - breakPart = null - i = lineStartPart - continue - } + for (let i = 0; i < wrapped.length; i += 1) { + const ch = wrapped[i]! - column += part.width + if (ch === '\n') { + // wrap-ansi inserts '\n' to mark a soft-wrap boundary OR copies a + // literal '\n' from the input. Either way the next char in `wrapped` + // begins a new visual line. If the source character is a hard '\n', + // consume it (it doesn't appear in either line). Otherwise the '\n' + // is purely a wrap marker and originalIdx stays put. + lines.push({ start: lineStart, end: originalIdx }) + const isHardNewline = originalIdx < value.length && value[originalIdx] === '\n' - if (isWhitespace(part.segment)) { - breakPart = i + 1 + if (isHardNewline) { + originalIdx += 1 } - i += 1 - - if (column >= width && i < parts.length) { - const next = parts[i]! - const nextStartsWord = !isWhitespace(next.segment) - - if (breakPart !== null && breakPart > lineStartPart && nextStartsWord) { - const breakOffset = sourceLineStart + parts[breakPart - 1]!.end - lines.push({ start: lineStartOffset, end: breakOffset }) - lineStartPart = breakPart - lineStartOffset = breakOffset - column = 0 - breakPart = null - i = lineStartPart - } - } + lineStart = originalIdx + continue } - lines.push({ start: lineStartOffset, end: sourceLineStart + sourceLine.length }) - sourceLineStart += sourceLine.length + 1 + // Defensive: if wrap-ansi's emitted character ever desyncs from + // `value[originalIdx]` (would only happen if it substituted, which it + // doesn't for the wrap+hard option set we use), fall back to advancing + // by one to stay in lockstep. The lines/cursor map still terminates. + originalIdx += 1 } + lines.push({ start: lineStart, end: originalIdx }) + + // wrap-ansi collapses an empty input into [""] which we already handled + // above; preserve the invariant that lines is never empty for any input. return lines.length ? lines : [{ start: 0, end: 0 }] } +function visualLines(value: string, cols: number): VisualLine[] { + return visualLinesFromWrappedOutput(value, cols) +} + function widthBetween(value: string, start: number, end: number) { let width = 0 @@ -108,6 +96,12 @@ function widthBetween(value: string, start: number, end: number) { /** * Mirrors the word-wrap behavior used by the composer TextInput. * Returns the zero-based visual line and column of the cursor cell. + * + * IMPORTANT: this MUST stay in lock-step with how Ink's `` + * lays the value out (which uses `wrap-ansi`). Any divergence parks the + * hardware cursor several cells off the last rendered character — see the + * "cursor drift past blank cells" bug. visualLinesFromWrappedOutput is + * sourced directly from wrap-ansi to enforce that invariant. */ export function cursorLayout(value: string, cursor: number, cols: number) { const pos = Math.max(0, Math.min(cursor, value.length)) @@ -124,14 +118,14 @@ export function cursorLayout(value: string, cursor: number, cols: number) { } const line = lines[lineIndex]! - let column = widthBetween(value, line.start, Math.min(pos, line.end)) - - // trailing cursor-cell overflows to the next row at the wrap column - if (column >= w) { - lineIndex++ - column = 0 - } - + const column = widthBetween(value, line.start, Math.min(pos, line.end)) + + // NOTE: the previous implementation forced an extra line break when + // `column >= w` (the "trailing cursor-cell overflows" rule). With + // visualLinesFromWrappedOutput sourcing breaks from wrap-ansi, the line + // wrapping above already matches what Ink will actually render. Pushing + // the cursor onto a phantom next line here would re-introduce the same + // drift we're fixing, so we don't. return { column, line: lineIndex } } From 1c0e59e557d00476e1ac0a35ceeb611e17533761 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 17 May 2026 11:34:06 -0500 Subject: [PATCH 027/418] review(tui): address Copilot feedback on cursorLayout wrap-ansi rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small follow-ups from the Copilot review on #27489: 1. Declare `wrap-ansi` as a direct dependency of `ui-tui`. It was a phantom dep that resolved via npm hoisting from `@hermes/ink`'s transitive graph — fine on hoisted installs, but breaks under pnpm or `npm install --no-install-strategy=hoisted` style isolated installs. Now listed as `"wrap-ansi": "^9.0.0"` matching the @hermes/ink version. Lockfile regenerated. 2. Implement the defensive resync the comment promised. Previously the comment claimed the loop would "fall back to advancing by one to stay in lockstep" on wrap-ansi desync, but the code unconditionally advanced `originalIdx` with no actual check — so any future wrap-ansi option change or styled-input caller could silently slide `originalIdx` past the end of `value` and emit garbage line ranges. Now actually compares `value[originalIdx] === ch`, re-syncs via `indexOf` on mismatch, and bails out (returning whatever was built so far) if the desync is unrecoverable. Production paths still hit the equality fast-path on every char. 3. Drop the `visualLines` wrapper. It was a one-line indirection over `visualLinesFromWrappedOutput`. Renamed the implementation to `visualLines` and removed the wrapper — same name, no extra layer. No behavior change beyond the defensive realign; all 791 vitest tests still pass. --- ui-tui/package-lock.json | 28 ++------------------ ui-tui/package.json | 3 ++- ui-tui/src/lib/inputMetrics.ts | 48 ++++++++++++++++++++++------------ 3 files changed, 36 insertions(+), 43 deletions(-) diff --git a/ui-tui/package-lock.json b/ui-tui/package-lock.json index bbbf95523996..255c4e1b3cda 100644 --- a/ui-tui/package-lock.json +++ b/ui-tui/package-lock.json @@ -14,7 +14,8 @@ "ink-text-input": "^6.0.0", "nanostores": "^1.2.0", "react": "^19.2.4", - "unicode-animations": "^1.0.3" + "unicode-animations": "^1.0.3", + "wrap-ansi": "^9.0.0" }, "devDependencies": { "@babel/cli": "^7.28.6", @@ -503,31 +504,6 @@ "node": ">=6.9.0" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", diff --git a/ui-tui/package.json b/ui-tui/package.json index f28debb313ef..1e11f5484dad 100644 --- a/ui-tui/package.json +++ b/ui-tui/package.json @@ -22,7 +22,8 @@ "ink-text-input": "^6.0.0", "nanostores": "^1.2.0", "react": "^19.2.4", - "unicode-animations": "^1.0.3" + "unicode-animations": "^1.0.3", + "wrap-ansi": "^9.0.0" }, "devDependencies": { "@babel/cli": "^7.28.6", diff --git a/ui-tui/src/lib/inputMetrics.ts b/ui-tui/src/lib/inputMetrics.ts index 208b35336787..3b66a3dba8e3 100644 --- a/ui-tui/src/lib/inputMetrics.ts +++ b/ui-tui/src/lib/inputMetrics.ts @@ -26,14 +26,14 @@ const graphemes = (value: string) => // boundaries — it never drops, reorders, or substitutes existing characters — // so a parallel walk uniquely identifies each line's source range. // -// This used to be a hand-rolled word-wrap (visualLines below) whose break -// points disagreed with wrap-ansi in subtle but visible ways: exact-fill rows -// pushed the cursor to a phantom next line, mid-word breaks landed one -// grapheme off, etc. The composer's TextInput renders text via Ink's -// , which delegates to wrap-ansi — so any drift between the -// two algorithms parks the hardware cursor several cells away from the last -// rendered character. Sourcing both from wrap-ansi guarantees agreement. -function visualLinesFromWrappedOutput(value: string, cols: number): VisualLine[] { +// This used to be a hand-rolled word-wrap whose break points disagreed with +// wrap-ansi in subtle but visible ways: exact-fill rows pushed the cursor to +// a phantom next line, mid-word breaks landed one grapheme off, etc. The +// composer's TextInput renders text via Ink's , which +// delegates to wrap-ansi — so any drift between the two algorithms parks the +// hardware cursor several cells away from the last rendered character. +// Sourcing both from wrap-ansi guarantees agreement. +function visualLines(value: string, cols: number): VisualLine[] { if (!value.length) { return [{ start: 0, end: 0 }] } @@ -65,10 +65,30 @@ function visualLinesFromWrappedOutput(value: string, cols: number): VisualLine[] continue } - // Defensive: if wrap-ansi's emitted character ever desyncs from - // `value[originalIdx]` (would only happen if it substituted, which it - // doesn't for the wrap+hard option set we use), fall back to advancing - // by one to stay in lockstep. The lines/cursor map still terminates. + // Defensive sync check. wrap-ansi (with `hard: true, trim: false`, no + // styled input) is documented to only insert '\n' at break points and + // never substitute, drop, or reorder source characters — so under those + // options `wrapped[i]` should always equal `value[originalIdx]`. But + // future option changes, library upgrades, or callers that start passing + // styled input (ANSI escapes) could violate that invariant silently. If + // they do, we'd slide `originalIdx` past the end of `value` and emit + // garbage line ranges with no diagnostic. Realign by scanning forward + // for the matching character; bail out (return whatever we have) if the + // sync is unrecoverable rather than producing wrong-but-plausible output. + if (originalIdx >= value.length) { + break + } + + if (value[originalIdx] !== ch) { + const reSync = value.indexOf(ch, originalIdx) + + if (reSync === -1) { + break + } + + originalIdx = reSync + } + originalIdx += 1 } @@ -79,10 +99,6 @@ function visualLinesFromWrappedOutput(value: string, cols: number): VisualLine[] return lines.length ? lines : [{ start: 0, end: 0 }] } -function visualLines(value: string, cols: number): VisualLine[] { - return visualLinesFromWrappedOutput(value, cols) -} - function widthBetween(value: string, start: number, end: number) { let width = 0 From 55f13be65de1cc7d9c494b45f7899d9119babd23 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 17 May 2026 11:38:33 -0500 Subject: [PATCH 028/418] chore(nix): refresh ui-tui npmDeps hash for wrap-ansi dep addition --- nix/tui.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/tui.nix b/nix/tui.nix index b64e8d21fc22..d0828d9438a4 100644 --- a/nix/tui.nix +++ b/nix/tui.nix @@ -4,7 +4,7 @@ let src = ../ui-tui; npmDeps = pkgs.fetchNpmDeps { inherit src; - hash = "sha256-9r1EYQ600gNXOnNXwakorpEk7hS/FPxZVbB2JksrhYs="; + hash = "sha256-+2lmAE9K2GorQzIqET+TW0mj+ibBa8pbfOALMnmFp6A="; }; npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; }; From 8c78f533ddf988498eda025ed480f71062a82984 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 17 May 2026 11:52:21 -0500 Subject: [PATCH 029/418] review(tui): route cursorLayout through @hermes/ink wrapAnsi shim (Bun runtime parity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot caught an important runtime parity gap on PR #27489: the fix imported the npm `wrap-ansi` package directly, but Ink's `` uses a runtime-selecting shim (`ui-tui/packages/hermes-ink/src/ink/wrapAnsi.ts`) that prefers `Bun.wrapAnsi` when running under Bun and falls back to the npm package elsewhere. So under Bun, Ink would render via `Bun.wrapAnsi` while `cursorLayout` would compute breaks via the npm package — any disagreement reintroduces the exact cursor-drift symptom the PR is meant to eliminate. Fix: - Export `wrapAnsi` from `@hermes/ink` (`packages/hermes-ink/src/entry-exports.ts` and `packages/hermes-ink/index.d.ts`) so the shim is the public surface. - Switch `ui-tui/src/lib/inputMetrics.ts` from `import wrapAnsi from 'wrap-ansi'` to `import { wrapAnsi } from '@hermes/ink'`. Both renderer (Ink) and cursor layout now traverse the same shim, so they share the runtime-selected implementation by construction. - Same swap in `textInputWrap.test.ts` and `cursorDriftRegression.test.ts` — tests now assert parity through the shim, which means under Bun they actually exercise Bun's implementation instead of asserting a tautology against the npm package. - Drop the direct `"wrap-ansi": "^9.0.0"` from `ui-tui/package.json`. `@hermes/ink` (which IS a declared dep) pulls wrap-ansi in transitively — that's not a phantom dep because the import path goes through `@hermes/ink`'s public exports, not through a hoisting accident. Verified: 791/791 vitest tests pass. `@hermes/ink` rebuilt (`dist/entry-exports.js` includes `wrapAnsi` export). TUI bundle rebuilt clean. --- ui-tui/package-lock.json | 3 +-- ui-tui/package.json | 3 +-- ui-tui/packages/hermes-ink/index.d.ts | 1 + ui-tui/packages/hermes-ink/src/entry-exports.ts | 1 + ui-tui/src/__tests__/cursorDriftRegression.test.ts | 2 +- ui-tui/src/__tests__/textInputWrap.test.ts | 2 +- ui-tui/src/lib/inputMetrics.ts | 3 +-- 7 files changed, 7 insertions(+), 8 deletions(-) diff --git a/ui-tui/package-lock.json b/ui-tui/package-lock.json index 255c4e1b3cda..44e9cbde9236 100644 --- a/ui-tui/package-lock.json +++ b/ui-tui/package-lock.json @@ -14,8 +14,7 @@ "ink-text-input": "^6.0.0", "nanostores": "^1.2.0", "react": "^19.2.4", - "unicode-animations": "^1.0.3", - "wrap-ansi": "^9.0.0" + "unicode-animations": "^1.0.3" }, "devDependencies": { "@babel/cli": "^7.28.6", diff --git a/ui-tui/package.json b/ui-tui/package.json index 1e11f5484dad..f28debb313ef 100644 --- a/ui-tui/package.json +++ b/ui-tui/package.json @@ -22,8 +22,7 @@ "ink-text-input": "^6.0.0", "nanostores": "^1.2.0", "react": "^19.2.4", - "unicode-animations": "^1.0.3", - "wrap-ansi": "^9.0.0" + "unicode-animations": "^1.0.3" }, "devDependencies": { "@babel/cli": "^7.28.6", diff --git a/ui-tui/packages/hermes-ink/index.d.ts b/ui-tui/packages/hermes-ink/index.d.ts index 5d5ae9387c05..66fed32ae60b 100644 --- a/ui-tui/packages/hermes-ink/index.d.ts +++ b/ui-tui/packages/hermes-ink/index.d.ts @@ -34,5 +34,6 @@ export { default as measureElement } from './src/ink/measure-element.ts' export { createRoot, forceRedraw, default as render, renderSync } from './src/ink/root.ts' export type { Instance, RenderOptions, Root } from './src/ink/root.ts' export { stringWidth } from './src/ink/stringWidth.ts' +export { wrapAnsi } from './src/ink/wrapAnsi.ts' export { default as TextInput, UncontrolledTextInput } from 'ink-text-input' export type { Props as TextInputProps } from 'ink-text-input' diff --git a/ui-tui/packages/hermes-ink/src/entry-exports.ts b/ui-tui/packages/hermes-ink/src/entry-exports.ts index d173e0c9bb19..a113660385f5 100644 --- a/ui-tui/packages/hermes-ink/src/entry-exports.ts +++ b/ui-tui/packages/hermes-ink/src/entry-exports.ts @@ -26,5 +26,6 @@ export { default as measureElement } from './ink/measure-element.js' export { scrollFastPathStats, type ScrollFastPathStats } from './ink/render-node-to-output.js' export { createRoot, forceRedraw, default as render, renderSync } from './ink/root.js' export { stringWidth } from './ink/stringWidth.js' +export { wrapAnsi } from './ink/wrapAnsi.js' export { isXtermJs } from './ink/terminal.js' export { default as TextInput, UncontrolledTextInput } from 'ink-text-input' diff --git a/ui-tui/src/__tests__/cursorDriftRegression.test.ts b/ui-tui/src/__tests__/cursorDriftRegression.test.ts index 0e562e09789d..3f9082dcefcd 100644 --- a/ui-tui/src/__tests__/cursorDriftRegression.test.ts +++ b/ui-tui/src/__tests__/cursorDriftRegression.test.ts @@ -21,8 +21,8 @@ * the end-of-text position that wrap-ansi would render. Any future * regression that lets the two diverge re-introduces the drift. */ +import { wrapAnsi } from '@hermes/ink' import { describe, expect, it } from 'vitest' -import wrapAnsi from 'wrap-ansi' import { cursorLayout, inputVisualHeight } from '../lib/inputMetrics.js' diff --git a/ui-tui/src/__tests__/textInputWrap.test.ts b/ui-tui/src/__tests__/textInputWrap.test.ts index a0e70431465f..22b33c9480e1 100644 --- a/ui-tui/src/__tests__/textInputWrap.test.ts +++ b/ui-tui/src/__tests__/textInputWrap.test.ts @@ -1,5 +1,5 @@ +import { wrapAnsi } from '@hermes/ink' import { describe, expect, it } from 'vitest' -import wrapAnsi from 'wrap-ansi' import { offsetFromPosition } from '../components/textInput.js' import { composerPromptWidth, cursorLayout, inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js' diff --git a/ui-tui/src/lib/inputMetrics.ts b/ui-tui/src/lib/inputMetrics.ts index 3b66a3dba8e3..3d8a0c61bb86 100644 --- a/ui-tui/src/lib/inputMetrics.ts +++ b/ui-tui/src/lib/inputMetrics.ts @@ -1,5 +1,4 @@ -import { stringWidth } from '@hermes/ink' -import wrapAnsi from 'wrap-ansi' +import { stringWidth, wrapAnsi } from '@hermes/ink' import type { Role } from '../types.js' From 220736f41726cbd2445c2904a97c1971b2612730 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 17 May 2026 11:54:48 -0500 Subject: [PATCH 030/418] chore(nix): refresh ui-tui npmDeps hash after wrap-ansi direct-dep drop --- nix/tui.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/tui.nix b/nix/tui.nix index d0828d9438a4..33ede8b2dcca 100644 --- a/nix/tui.nix +++ b/nix/tui.nix @@ -4,7 +4,7 @@ let src = ../ui-tui; npmDeps = pkgs.fetchNpmDeps { inherit src; - hash = "sha256-+2lmAE9K2GorQzIqET+TW0mj+ibBa8pbfOALMnmFp6A="; + hash = "sha256-uod1G7SWEjhYNTQ2/MG1Q1JDrQ41H0by9tspv8zh0h4="; }; npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; }; From 711f46e4bdbf1ec07d949f0c6726a6e034ac4509 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 17 May 2026 12:32:29 -0500 Subject: [PATCH 031/418] review(tui): update stale comment refs to renamed visualLines helper --- ui-tui/src/lib/inputMetrics.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ui-tui/src/lib/inputMetrics.ts b/ui-tui/src/lib/inputMetrics.ts index 3d8a0c61bb86..4c624da167a9 100644 --- a/ui-tui/src/lib/inputMetrics.ts +++ b/ui-tui/src/lib/inputMetrics.ts @@ -115,8 +115,8 @@ function widthBetween(value: string, start: number, end: number) { * IMPORTANT: this MUST stay in lock-step with how Ink's `` * lays the value out (which uses `wrap-ansi`). Any divergence parks the * hardware cursor several cells off the last rendered character — see the - * "cursor drift past blank cells" bug. visualLinesFromWrappedOutput is - * sourced directly from wrap-ansi to enforce that invariant. + * "cursor drift past blank cells" bug. `visualLines` is sourced directly + * from wrap-ansi to enforce that invariant. */ export function cursorLayout(value: string, cursor: number, cols: number) { const pos = Math.max(0, Math.min(cursor, value.length)) @@ -137,9 +137,9 @@ export function cursorLayout(value: string, cursor: number, cols: number) { // NOTE: the previous implementation forced an extra line break when // `column >= w` (the "trailing cursor-cell overflows" rule). With - // visualLinesFromWrappedOutput sourcing breaks from wrap-ansi, the line - // wrapping above already matches what Ink will actually render. Pushing - // the cursor onto a phantom next line here would re-introduce the same + // `visualLines` sourcing breaks from wrap-ansi, the line wrapping + // above already matches what Ink will actually render. Pushing the + // cursor onto a phantom next line here would re-introduce the same // drift we're fixing, so we don't. return { column, line: lineIndex } } From caac54796bbdd28131ee2c105fe7585ca245674c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 17 May 2026 13:33:10 -0500 Subject: [PATCH 032/418] chore: revert unrelated package-lock + nix hash churn to keep PR diff minimal --- nix/tui.nix | 2 +- ui-tui/package-lock.json | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/nix/tui.nix b/nix/tui.nix index 33ede8b2dcca..b64e8d21fc22 100644 --- a/nix/tui.nix +++ b/nix/tui.nix @@ -4,7 +4,7 @@ let src = ../ui-tui; npmDeps = pkgs.fetchNpmDeps { inherit src; - hash = "sha256-uod1G7SWEjhYNTQ2/MG1Q1JDrQ41H0by9tspv8zh0h4="; + hash = "sha256-9r1EYQ600gNXOnNXwakorpEk7hS/FPxZVbB2JksrhYs="; }; npm = hermesNpmLib.mkNpmPassthru { folder = "ui-tui"; attr = "tui"; pname = "hermes-tui"; }; diff --git a/ui-tui/package-lock.json b/ui-tui/package-lock.json index 44e9cbde9236..bbbf95523996 100644 --- a/ui-tui/package-lock.json +++ b/ui-tui/package-lock.json @@ -503,6 +503,31 @@ "node": ">=6.9.0" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", From e89d78ff09cc0bcca4396cb50faa2e9da4301e48 Mon Sep 17 00:00:00 2001 From: EloquentBrush0x <283442588+EloquentBrush0x@users.noreply.github.com> Date: Sun, 17 May 2026 03:40:22 +0300 Subject: [PATCH 033/418] fix(doctor): suppress stale XAI_API_KEY issue when xAI OAuth is healthy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _has_healthy_oauth_fallback_for_apikey_provider() covers Gemini and MiniMax (added by #26853) but omits xAI. The xAI provider profile (plugins/model-providers/xai/__init__.py) has auth_type="api_key" and env_vars=("XAI_API_KEY",), so it enters the generic API-key connectivity loop. When XAI_API_KEY fails a 401 probe but xAI OAuth is healthy, the failure is promoted to the blocking summary even though xAI works fine via OAuth — the same false-positive #26853 fixed for Gemini and MiniMax. Fix: import get_xai_oauth_auth_status alongside the existing two helpers and add the "xai" branch. get_xai_oauth_auth_status() already exists in hermes_cli/auth.py and returns {"logged_in": True} when a valid OAuth token is present. Symmetric with the Gemini and MiniMax branches introduced in #26853. No behavior change for providers without an OAuth path. --- hermes_cli/doctor.py | 3 +++ tests/hermes_cli/test_doctor.py | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index ef668e07940a..04cfffef922c 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -164,6 +164,7 @@ def _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool from hermes_cli.auth import ( get_gemini_oauth_auth_status, get_minimax_oauth_auth_status, + get_xai_oauth_auth_status, ) except Exception: return False @@ -173,6 +174,8 @@ def _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool return bool((get_gemini_oauth_auth_status() or {}).get("logged_in")) if normalized == "minimax": return bool((get_minimax_oauth_auth_status() or {}).get("logged_in")) + if normalized == "xai": + return bool((get_xai_oauth_auth_status() or {}).get("logged_in")) return False diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py index ee419656a714..d99947a9886c 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -944,3 +944,21 @@ def test_run_doctor_ignores_invalid_direct_keys_when_oauth_fallback_is_healthy( assert "invalid API key" in out assert unexpected_issue not in out + + +class TestHasHealthyOauthFallbackForXai: + def test_returns_true_when_xai_oauth_healthy(self, monkeypatch): + from hermes_cli import auth as _auth_mod + monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {"logged_in": True}) + from hermes_cli.doctor import _has_healthy_oauth_fallback_for_apikey_provider + assert _has_healthy_oauth_fallback_for_apikey_provider("xai") is True + + def test_returns_false_when_xai_oauth_not_logged_in(self, monkeypatch): + from hermes_cli import auth as _auth_mod + monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {"logged_in": False}) + from hermes_cli.doctor import _has_healthy_oauth_fallback_for_apikey_provider + assert _has_healthy_oauth_fallback_for_apikey_provider("xai") is False + + def test_returns_false_for_unknown_provider(self): + from hermes_cli.doctor import _has_healthy_oauth_fallback_for_apikey_provider + assert _has_healthy_oauth_fallback_for_apikey_provider("unknown-provider") is False From e10bb9dffa5908f21f6a97d7e2c4466de76b61e5 Mon Sep 17 00:00:00 2001 From: EloquentBrush0x <283442588+EloquentBrush0x@users.noreply.github.com> Date: Sun, 17 May 2026 03:53:23 +0300 Subject: [PATCH 034/418] fix(doctor): isolate per-provider OAuth imports to prevent fallback regression Shared try/except import block meant that if any one status function was missing, all providers lost their OAuth fallback suppression. Split into per-provider try/except so each branch is independently safe. Add end-to-end test for xAI: bad XAI_API_KEY with healthy OAuth does not surface a blocking issue in run_doctor output. Add tests for None return, import failure isolation (xAI missing does not break Gemini), and move test_returns_false_for_unknown_provider out of the xAI-specific class. --- hermes_cli/doctor.py | 27 ++++++++++--------- tests/hermes_cli/test_doctor.py | 48 ++++++++++++++++++++++++++++++--- 2 files changed, 60 insertions(+), 15 deletions(-) diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 04cfffef922c..a3d5764835fd 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -160,22 +160,25 @@ def _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool still show a failed API-key connectivity row, but it should not promote that direct-key problem into the final blocking summary. """ - try: - from hermes_cli.auth import ( - get_gemini_oauth_auth_status, - get_minimax_oauth_auth_status, - get_xai_oauth_auth_status, - ) - except Exception: - return False - normalized = (provider_label or "").strip().lower() if normalized in {"google / gemini", "gemini"}: - return bool((get_gemini_oauth_auth_status() or {}).get("logged_in")) + try: + from hermes_cli.auth import get_gemini_oauth_auth_status + return bool((get_gemini_oauth_auth_status() or {}).get("logged_in")) + except Exception: + return False if normalized == "minimax": - return bool((get_minimax_oauth_auth_status() or {}).get("logged_in")) + try: + from hermes_cli.auth import get_minimax_oauth_auth_status + return bool((get_minimax_oauth_auth_status() or {}).get("logged_in")) + except Exception: + return False if normalized == "xai": - return bool((get_xai_oauth_auth_status() or {}).get("logged_in")) + try: + from hermes_cli.auth import get_xai_oauth_auth_status + return bool((get_xai_oauth_auth_status() or {}).get("logged_in")) + except Exception: + return False return False diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py index d99947a9886c..4f9a9e93cba9 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -850,6 +850,7 @@ def _run_doctor_with_healthy_oauth_fallback( failing_host: str, gemini_oauth_status: dict, minimax_oauth_status: dict, + xai_oauth_status: dict | None = None, ) -> str: home = tmp_path / ".hermes" home.mkdir(parents=True, exist_ok=True) @@ -886,6 +887,8 @@ def _run_doctor_with_healthy_oauth_fallback( monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: gemini_oauth_status) monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: minimax_oauth_status) + _xai_status = xai_oauth_status if xai_oauth_status is not None else {} + monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: _xai_status) def fake_get(url, headers=None, timeout=None): status = 401 if failing_host in url else 200 @@ -902,7 +905,7 @@ def fake_get(url, headers=None, timeout=None): @pytest.mark.parametrize( - ("env_key", "bad_key", "failing_host", "gemini_oauth_status", "minimax_oauth_status", "unexpected_issue"), + ("env_key", "bad_key", "failing_host", "gemini_oauth_status", "minimax_oauth_status", "xai_oauth_status", "unexpected_issue"), [ ( "GOOGLE_API_KEY", @@ -910,6 +913,7 @@ def fake_get(url, headers=None, timeout=None): "googleapis.com", {"logged_in": True, "email": "user@example.com"}, {}, + None, "Check GOOGLE_API_KEY in .env", ), ( @@ -918,8 +922,18 @@ def fake_get(url, headers=None, timeout=None): "minimax.io", {}, {"logged_in": True, "region": "global"}, + None, "Check MINIMAX_API_KEY in .env", ), + ( + "XAI_API_KEY", + "bad-xai-key", + "api.x.ai", + {}, + {}, + {"logged_in": True, "auth_mode": "oauth_pkce"}, + "Check XAI_API_KEY in .env", + ), ], ) def test_run_doctor_ignores_invalid_direct_keys_when_oauth_fallback_is_healthy( @@ -930,6 +944,7 @@ def test_run_doctor_ignores_invalid_direct_keys_when_oauth_fallback_is_healthy( failing_host, gemini_oauth_status, minimax_oauth_status, + xai_oauth_status, unexpected_issue, ): out = _run_doctor_with_healthy_oauth_fallback( @@ -940,12 +955,18 @@ def test_run_doctor_ignores_invalid_direct_keys_when_oauth_fallback_is_healthy( failing_host=failing_host, gemini_oauth_status=gemini_oauth_status, minimax_oauth_status=minimax_oauth_status, + xai_oauth_status=xai_oauth_status, ) assert "invalid API key" in out assert unexpected_issue not in out +def test_has_healthy_oauth_fallback_returns_false_for_unknown_provider(): + from hermes_cli.doctor import _has_healthy_oauth_fallback_for_apikey_provider + assert _has_healthy_oauth_fallback_for_apikey_provider("unknown-provider") is False + + class TestHasHealthyOauthFallbackForXai: def test_returns_true_when_xai_oauth_healthy(self, monkeypatch): from hermes_cli import auth as _auth_mod @@ -959,6 +980,27 @@ def test_returns_false_when_xai_oauth_not_logged_in(self, monkeypatch): from hermes_cli.doctor import _has_healthy_oauth_fallback_for_apikey_provider assert _has_healthy_oauth_fallback_for_apikey_provider("xai") is False - def test_returns_false_for_unknown_provider(self): + def test_returns_false_when_xai_oauth_returns_none(self, monkeypatch): + from hermes_cli import auth as _auth_mod + monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: None) + from hermes_cli.doctor import _has_healthy_oauth_fallback_for_apikey_provider + assert _has_healthy_oauth_fallback_for_apikey_provider("xai") is False + + def test_returns_false_when_xai_import_unavailable(self, monkeypatch): + import sys + # Simulate get_xai_oauth_auth_status missing from auth module + monkeypatch.delattr("hermes_cli.auth.get_xai_oauth_auth_status", raising=False) + # Force doctor module to re-import the function + monkeypatch.delitem(sys.modules, "hermes_cli.doctor", raising=False) + from hermes_cli.doctor import _has_healthy_oauth_fallback_for_apikey_provider + assert _has_healthy_oauth_fallback_for_apikey_provider("xai") is False + + def test_xai_import_failure_does_not_affect_gemini(self, monkeypatch): + import sys + from hermes_cli import auth as _auth_mod + # xAI function missing, but Gemini is healthy + monkeypatch.delattr(_auth_mod, "get_xai_oauth_auth_status", raising=False) + monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: {"logged_in": True}) + monkeypatch.delitem(sys.modules, "hermes_cli.doctor", raising=False) from hermes_cli.doctor import _has_healthy_oauth_fallback_for_apikey_provider - assert _has_healthy_oauth_fallback_for_apikey_provider("unknown-provider") is False + assert _has_healthy_oauth_fallback_for_apikey_provider("gemini") is True From 016893f5e47b32dba0c16a3c38279de0cb590243 Mon Sep 17 00:00:00 2001 From: EloquentBrush0x <283442588+EloquentBrush0x@users.noreply.github.com> Date: Sun, 17 May 2026 04:01:29 +0300 Subject: [PATCH 035/418] feat(status): show xAI OAuth login state in hermes status hermes status listed Nous Portal, OpenAI Codex, Qwen OAuth, and MiniMax OAuth in the Auth Providers section but omitted xAI OAuth entirely. Users who authenticated via `hermes auth add xai-oauth` had no way to verify their session state from the status output. Add xAI OAuth display using the same field shape as OpenAI Codex: auth_store (Auth file:), last_refresh (Refreshed:), and error when not logged in. The import is isolated in its own try/except so an import failure cannot affect the already-printed rows above it. Tests cover: - logged in: check mark, auth_store, last_refresh, error suppressed - not logged in: login command hint, error shown, error absent = no line - resilience: import failure, status function raises, returns None - isolation: xAI import failure does not break Nous/MiniMax display --- hermes_cli/status.py | 21 +++ tests/hermes_cli/test_status.py | 223 ++++++++++++++++++++++++++++++++ 2 files changed, 244 insertions(+) diff --git a/hermes_cli/status.py b/hermes_cli/status.py index f2164ac8a4d2..5629da03fe38 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -259,6 +259,27 @@ def _resolve_env(env_ref) -> str: if minimax_status.get("error") and not minimax_logged_in: print(f" Error: {minimax_status.get('error')}") + # xAI OAuth — separate try/except so an import failure here cannot + # disrupt the already-printed Nous/Codex/Qwen/MiniMax rows above. + try: + from hermes_cli.auth import get_xai_oauth_auth_status + xai_oauth_status = get_xai_oauth_auth_status() or {} + except Exception: + xai_oauth_status = {} + + xai_oauth_logged_in = bool(xai_oauth_status.get("logged_in")) + print( + f" {'xAI OAuth':<12} {check_mark(xai_oauth_logged_in)} " + f"{'logged in' if xai_oauth_logged_in else 'not logged in (run: hermes auth add xai-oauth)'}" + ) + xai_auth_file = xai_oauth_status.get("auth_store") + if xai_auth_file: + print(f" Auth file: {xai_auth_file}") + if xai_oauth_status.get("last_refresh"): + print(f" Refreshed: {_format_iso_timestamp(xai_oauth_status.get('last_refresh'))}") + if xai_oauth_status.get("error") and not xai_oauth_logged_in: + print(f" Error: {xai_oauth_status.get('error')}") + # ========================================================================= # Nous Subscription Features # ========================================================================= diff --git a/tests/hermes_cli/test_status.py b/tests/hermes_cli/test_status.py index a13e843faf8e..3cee9ab10ba7 100644 --- a/tests/hermes_cli/test_status.py +++ b/tests/hermes_cli/test_status.py @@ -29,6 +29,7 @@ def test_show_status_termux_gateway_section_skips_systemctl(monkeypatch, capsys, monkeypatch.setattr(status_mod, "provider_label", lambda provider: "OpenAI Codex", raising=False) monkeypatch.setattr(auth_mod, "get_nous_auth_status", lambda: {}, raising=False) monkeypatch.setattr(auth_mod, "get_codex_auth_status", lambda: {}, raising=False) + monkeypatch.setattr(auth_mod, "get_xai_oauth_auth_status", lambda: {}, raising=False) monkeypatch.setattr(gateway_mod, "find_gateway_pids", lambda exclude_pids=None: [], raising=False) def _unexpected_systemctl(*args, **kwargs): @@ -70,6 +71,7 @@ def test_show_status_reports_nous_auth_error(monkeypatch, capsys, tmp_path): ) monkeypatch.setattr(auth_mod, "get_codex_auth_status", lambda: {}, raising=False) monkeypatch.setattr(auth_mod, "get_qwen_auth_status", lambda: {}, raising=False) + monkeypatch.setattr(auth_mod, "get_xai_oauth_auth_status", lambda: {}, raising=False) monkeypatch.setattr(gateway_mod, "find_gateway_pids", lambda exclude_pids=None: [], raising=False) status_mod.show_status(SimpleNamespace(all=False, deep=False)) @@ -96,6 +98,7 @@ def test_show_status_reports_vercel_backend_contract(monkeypatch, capsys, tmp_pa monkeypatch.setattr(auth_mod, "get_nous_auth_status", lambda: {}, raising=False) monkeypatch.setattr(auth_mod, "get_codex_auth_status", lambda: {}, raising=False) monkeypatch.setattr(auth_mod, "get_qwen_auth_status", lambda: {}, raising=False) + monkeypatch.setattr(auth_mod, "get_xai_oauth_auth_status", lambda: {}, raising=False) monkeypatch.setattr(gateway_mod, "find_gateway_pids", lambda exclude_pids=None: [], raising=False) status_mod.show_status(SimpleNamespace(all=False, deep=False)) @@ -109,3 +112,223 @@ def test_show_status_reports_vercel_backend_contract(monkeypatch, capsys, tmp_pa assert "oidc-token" not in output assert "snapshot filesystem" in output assert "live processes do not survive" in output + + +# --------------------------------------------------------------------------- +# Helpers shared by xAI OAuth status tests +# --------------------------------------------------------------------------- + +def _base_xai_mocks(monkeypatch, tmp_path): + """Set up the minimal environment for show_status, returning status_mod.""" + from hermes_cli import status as status_mod + import hermes_cli.auth as auth_mod + import hermes_cli.gateway as gateway_mod + + monkeypatch.setattr(status_mod, "get_env_path", lambda: tmp_path / ".env", raising=False) + monkeypatch.setattr(status_mod, "get_hermes_home", lambda: tmp_path, raising=False) + monkeypatch.setattr(status_mod, "load_config", lambda: {"model": "gpt-5.4"}, raising=False) + monkeypatch.setattr(status_mod, "resolve_requested_provider", lambda requested=None: "openai-codex", raising=False) + monkeypatch.setattr(status_mod, "resolve_provider", lambda requested=None, **kwargs: "openai-codex", raising=False) + monkeypatch.setattr(status_mod, "provider_label", lambda provider: "OpenAI Codex", raising=False) + monkeypatch.setattr(auth_mod, "get_nous_auth_status", lambda: {}, raising=False) + monkeypatch.setattr(auth_mod, "get_codex_auth_status", lambda: {}, raising=False) + monkeypatch.setattr(auth_mod, "get_qwen_auth_status", lambda: {}, raising=False) + monkeypatch.setattr(auth_mod, "get_minimax_oauth_auth_status", lambda: {}, raising=False) + monkeypatch.setattr(gateway_mod, "find_gateway_pids", lambda exclude_pids=None: [], raising=False) + return status_mod + + +class TestShowStatusXaiOAuth: + """xAI OAuth row in hermes status.""" + + # ------------------------------------------------------------------ + # Logged-in branch + # ------------------------------------------------------------------ + + def test_logged_in_shows_check_mark_and_label(self, monkeypatch, capsys, tmp_path): + import hermes_cli.auth as auth_mod + status_mod = _base_xai_mocks(monkeypatch, tmp_path) + monkeypatch.setattr(auth_mod, "get_xai_oauth_auth_status", + lambda: {"logged_in": True, "auth_store": "/a/auth.json"}, + raising=False) + + status_mod.show_status(SimpleNamespace(all=False, deep=False)) + out = capsys.readouterr().out + + assert "xAI OAuth" in out + # The logged-in label must appear; the "not logged in" label must not + assert "✓" in out or "logged in" in out + assert "not logged in" not in out.split("xAI OAuth", 1)[1].split("\n")[0] + + def test_logged_in_shows_auth_store(self, monkeypatch, capsys, tmp_path): + import hermes_cli.auth as auth_mod + status_mod = _base_xai_mocks(monkeypatch, tmp_path) + monkeypatch.setattr(auth_mod, "get_xai_oauth_auth_status", + lambda: {"logged_in": True, "auth_store": "/home/u/.hermes/auth.json"}, + raising=False) + + status_mod.show_status(SimpleNamespace(all=False, deep=False)) + out = capsys.readouterr().out + + assert "Auth file: /home/u/.hermes/auth.json" in out + + def test_logged_in_shows_last_refresh(self, monkeypatch, capsys, tmp_path): + import hermes_cli.auth as auth_mod + status_mod = _base_xai_mocks(monkeypatch, tmp_path) + monkeypatch.setattr(auth_mod, "get_xai_oauth_auth_status", + lambda: { + "logged_in": True, + "auth_store": "/a/auth.json", + "last_refresh": "2026-05-17T10:00:00+00:00", + }, + raising=False) + + status_mod.show_status(SimpleNamespace(all=False, deep=False)) + out = capsys.readouterr().out + + assert "Refreshed:" in out + + def test_logged_in_does_not_show_error_line(self, monkeypatch, capsys, tmp_path): + """Error field must be suppressed when logged_in is True.""" + import hermes_cli.auth as auth_mod + status_mod = _base_xai_mocks(monkeypatch, tmp_path) + monkeypatch.setattr(auth_mod, "get_xai_oauth_auth_status", + lambda: { + "logged_in": True, + "auth_store": "/a/auth.json", + "error": "stale-error-must-not-appear", + }, + raising=False) + + status_mod.show_status(SimpleNamespace(all=False, deep=False)) + out = capsys.readouterr().out + + xai_section = out.split("xAI OAuth", 1)[1] + assert "stale-error-must-not-appear" not in xai_section + + def test_no_auth_store_line_when_field_absent(self, monkeypatch, capsys, tmp_path): + """Auth file line must not appear when auth_store is missing.""" + import hermes_cli.auth as auth_mod + status_mod = _base_xai_mocks(monkeypatch, tmp_path) + monkeypatch.setattr(auth_mod, "get_xai_oauth_auth_status", + lambda: {"logged_in": True}, + raising=False) + + status_mod.show_status(SimpleNamespace(all=False, deep=False)) + out = capsys.readouterr().out + + xai_section = out.split("xAI OAuth", 1)[1].split("◆", 1)[0] + assert "Auth file:" not in xai_section + + def test_no_refreshed_line_when_last_refresh_absent(self, monkeypatch, capsys, tmp_path): + """Refreshed line must not appear when last_refresh is not present.""" + import hermes_cli.auth as auth_mod + status_mod = _base_xai_mocks(monkeypatch, tmp_path) + monkeypatch.setattr(auth_mod, "get_xai_oauth_auth_status", + lambda: {"logged_in": True, "auth_store": "/a/auth.json"}, + raising=False) + + status_mod.show_status(SimpleNamespace(all=False, deep=False)) + out = capsys.readouterr().out + + xai_section = out.split("xAI OAuth", 1)[1].split("◆", 1)[0] + assert "Refreshed:" not in xai_section + + # ------------------------------------------------------------------ + # Not-logged-in branch + # ------------------------------------------------------------------ + + def test_not_logged_in_shows_login_command(self, monkeypatch, capsys, tmp_path): + import hermes_cli.auth as auth_mod + status_mod = _base_xai_mocks(monkeypatch, tmp_path) + monkeypatch.setattr(auth_mod, "get_xai_oauth_auth_status", + lambda: {"logged_in": False, "error": "no credentials"}, + raising=False) + + status_mod.show_status(SimpleNamespace(all=False, deep=False)) + out = capsys.readouterr().out + + assert "not logged in (run: hermes auth add xai-oauth)" in out + + def test_not_logged_in_shows_error(self, monkeypatch, capsys, tmp_path): + import hermes_cli.auth as auth_mod + status_mod = _base_xai_mocks(monkeypatch, tmp_path) + monkeypatch.setattr(auth_mod, "get_xai_oauth_auth_status", + lambda: {"logged_in": False, "error": "Token has expired"}, + raising=False) + + status_mod.show_status(SimpleNamespace(all=False, deep=False)) + out = capsys.readouterr().out + + assert "Error: Token has expired" in out + + def test_not_logged_in_omits_error_line_when_error_absent(self, monkeypatch, capsys, tmp_path): + """No Error: line when not logged in but error key is missing.""" + import hermes_cli.auth as auth_mod + status_mod = _base_xai_mocks(monkeypatch, tmp_path) + monkeypatch.setattr(auth_mod, "get_xai_oauth_auth_status", + lambda: {"logged_in": False}, + raising=False) + + status_mod.show_status(SimpleNamespace(all=False, deep=False)) + out = capsys.readouterr().out + + xai_section = out.split("xAI OAuth", 1)[1].split("◆", 1)[0] + assert "Error:" not in xai_section + + # ------------------------------------------------------------------ + # Resilience: import failure and runtime exception + # ------------------------------------------------------------------ + + def test_import_failure_does_not_crash_show_status(self, monkeypatch, capsys, tmp_path): + """show_status must complete even when get_xai_oauth_auth_status cannot be imported.""" + import hermes_cli.auth as auth_mod + status_mod = _base_xai_mocks(monkeypatch, tmp_path) + monkeypatch.delattr(auth_mod, "get_xai_oauth_auth_status", raising=False) + + status_mod.show_status(SimpleNamespace(all=False, deep=False)) + out = capsys.readouterr().out + + assert "◆ Auth Providers" in out + + def test_import_failure_does_not_break_other_oauth_providers(self, monkeypatch, capsys, tmp_path): + """Nous/Codex/MiniMax rows must still appear when xAI import fails.""" + import hermes_cli.auth as auth_mod + status_mod = _base_xai_mocks(monkeypatch, tmp_path) + monkeypatch.setattr(auth_mod, "get_nous_auth_status", + lambda: {"logged_in": True}, raising=False) + monkeypatch.delattr(auth_mod, "get_xai_oauth_auth_status", raising=False) + + status_mod.show_status(SimpleNamespace(all=False, deep=False)) + out = capsys.readouterr().out + + assert "Nous Portal" in out + assert "MiniMax OAuth" in out + + def test_status_function_exception_does_not_crash(self, monkeypatch, capsys, tmp_path): + """show_status must not propagate an exception raised by get_xai_oauth_auth_status.""" + import hermes_cli.auth as auth_mod + status_mod = _base_xai_mocks(monkeypatch, tmp_path) + + def _raises(): + raise RuntimeError("backend unreachable") + + monkeypatch.setattr(auth_mod, "get_xai_oauth_auth_status", _raises, raising=False) + + status_mod.show_status(SimpleNamespace(all=False, deep=False)) + out = capsys.readouterr().out + + assert "◆ Auth Providers" in out + + def test_status_function_returns_none_does_not_crash(self, monkeypatch, capsys, tmp_path): + """get_xai_oauth_auth_status returning None must be handled gracefully.""" + import hermes_cli.auth as auth_mod + status_mod = _base_xai_mocks(monkeypatch, tmp_path) + monkeypatch.setattr(auth_mod, "get_xai_oauth_auth_status", + lambda: None, raising=False) + + status_mod.show_status(SimpleNamespace(all=False, deep=False)) + out = capsys.readouterr().out + + assert "xAI OAuth" in out + assert "not logged in (run: hermes auth add xai-oauth)" in out From d0f551b44e98c36e61aba31c5b2b65a564d0c3f8 Mon Sep 17 00:00:00 2001 From: EloquentBrush0x <283442588+EloquentBrush0x@users.noreply.github.com> Date: Sun, 17 May 2026 04:27:23 +0300 Subject: [PATCH 036/418] fix(doctor): show xAI OAuth login state in hermes doctor Auth Providers section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hermes doctor` displayed OAuth status for Nous, Codex, Gemini, and MiniMax but silently omitted xAI OAuth, even though `get_xai_oauth_auth_status()` exists and the same information is already surfaced in `hermes status`. Add xAI OAuth as a *separate* try/except block so an import failure cannot silence the already-printed provider rows above it — consistent with the per-provider isolation introduced in the doctor fallback fix. Tests: - 9 new tests in TestDoctorXaiOAuthStatus covering: logged-in ok, not-logged-in warn, error line present/absent, import failure isolation, runtime exception and None-return safety. - 9 existing run_doctor helpers updated to mock get_xai_oauth_auth_status for deterministic output. --- hermes_cli/doctor.py | 14 +++ tests/hermes_cli/test_doctor.py | 177 ++++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+) diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index a3d5764835fd..6f036426fa56 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -823,6 +823,20 @@ def run_doctor(args): except Exception as e: check_warn("Auth provider status", f"(could not check: {e})") + # xAI OAuth — separate try/except so an import failure here cannot + # disrupt the already-printed Nous/Codex/Gemini/MiniMax rows above. + try: + from hermes_cli.auth import get_xai_oauth_auth_status + xai_oauth_status = get_xai_oauth_auth_status() or {} + if xai_oauth_status.get("logged_in"): + check_ok("xAI OAuth", "(logged in)") + else: + check_warn("xAI OAuth", "(not logged in)") + if xai_oauth_status.get("error"): + check_info(xai_oauth_status["error"]) + except Exception: + pass + if _safe_which("codex"): check_ok("codex CLI") else: diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py index 4f9a9e93cba9..a5b058fe4529 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -320,6 +320,7 @@ def _run_doctor_and_capture(self, monkeypatch, tmp_path, provider=""): from hermes_cli import auth as _auth_mod monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {}) monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) + monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {}) except Exception: pass @@ -426,6 +427,7 @@ def test_run_doctor_accepts_named_provider_from_providers_section(monkeypatch, t from hermes_cli import auth as _auth_mod monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {}) monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) + monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {}) except Exception: pass @@ -463,6 +465,7 @@ def test_run_doctor_accepts_bare_custom_provider(monkeypatch, tmp_path): from hermes_cli import auth as _auth_mod monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {}) monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) + monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {}) except Exception: pass @@ -510,6 +513,7 @@ def test_run_doctor_accepts_hermes_provider_ids_that_catalog_aliases( from hermes_cli import auth as _auth_mod monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {}) monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) + monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {}) except Exception: pass @@ -556,6 +560,7 @@ def test_run_doctor_accepts_kimi_coding_cn_provider(monkeypatch, tmp_path): monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {}) monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) monkeypatch.setattr(_auth_mod, "get_auth_status", lambda provider: {"logged_in": True}) + monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {}) except Exception: pass @@ -594,6 +599,7 @@ def test_run_doctor_termux_does_not_mark_browser_available_without_agent_browser from hermes_cli import auth as _auth_mod monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {}) monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) + monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {}) except Exception: pass @@ -633,6 +639,7 @@ def test_run_doctor_kimi_cn_env_is_detected_and_probe_is_null_safe(monkeypatch, from hermes_cli import auth as _auth_mod monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {}) monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) + monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {}) except Exception: pass @@ -681,6 +688,7 @@ def test_run_doctor_dashscope_retries_china_endpoint_after_intl_unauthorized(mon from hermes_cli import auth as _auth_mod monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {}) monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) + monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {}) except ImportError: pass @@ -739,6 +747,7 @@ def test_run_doctor_opencode_go_skips_invalid_models_probe(monkeypatch, tmp_path from hermes_cli import auth as _auth_mod monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {}) monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) + monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {}) except ImportError: pass @@ -1004,3 +1013,171 @@ def test_xai_import_failure_does_not_affect_gemini(self, monkeypatch): monkeypatch.delitem(sys.modules, "hermes_cli.doctor", raising=False) from hermes_cli.doctor import _has_healthy_oauth_fallback_for_apikey_provider assert _has_healthy_oauth_fallback_for_apikey_provider("gemini") is True + + +# --------------------------------------------------------------------------- +# ◆ Auth Providers — xAI OAuth display in run_doctor() +# --------------------------------------------------------------------------- + + +class TestDoctorXaiOAuthStatus: + """The ◆ Auth Providers section must show xAI OAuth login state. + + xAI OAuth is checked in a *separate* try/except block so that an import + failure (or runtime exception) cannot silence the Nous / Codex / Gemini / + MiniMax rows that were already printed above it. + """ + + def _run(self, monkeypatch, tmp_path, *, xai_auth_fn) -> str: + """Run doctor with a controlled xAI auth callable; return stdout.""" + home = tmp_path / ".hermes" + home.mkdir(parents=True, exist_ok=True) + (home / "config.yaml").write_text("memory: {}\n", encoding="utf-8") + project = tmp_path / "project" + project.mkdir(exist_ok=True) + + monkeypatch.setattr(doctor_mod, "HERMES_HOME", home) + monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project) + monkeypatch.setattr(doctor_mod, "_DHH", str(home)) + + fake_model_tools = types.SimpleNamespace( + check_tool_availability=lambda *a, **kw: ([], []), + TOOLSET_REQUIREMENTS={}, + ) + monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools) + + from hermes_cli import auth as _auth_mod + monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {"logged_in": False}) + monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {"logged_in": False}) + monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: {"logged_in": False}) + monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {"logged_in": False}) + monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", xai_auth_fn) + + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + doctor_mod.run_doctor(Namespace(fix=False)) + return buf.getvalue() + + def test_logged_in_shows_ok(self, monkeypatch, tmp_path): + out = self._run( + monkeypatch, tmp_path, + xai_auth_fn=lambda: {"logged_in": True}, + ) + assert "xAI OAuth" in out + assert "(logged in)" in out + + def test_not_logged_in_shows_warn(self, monkeypatch, tmp_path): + out = self._run( + monkeypatch, tmp_path, + xai_auth_fn=lambda: {"logged_in": False}, + ) + assert "xAI OAuth" in out + assert "(not logged in)" in out + + def test_error_shown_when_not_logged_in_and_error_present(self, monkeypatch, tmp_path): + out = self._run( + monkeypatch, tmp_path, + xai_auth_fn=lambda: {"logged_in": False, "error": "refresh token expired"}, + ) + assert "xAI OAuth" in out + assert "refresh token expired" in out + + def test_no_error_line_when_error_key_absent(self, monkeypatch, tmp_path): + out = self._run( + monkeypatch, tmp_path, + xai_auth_fn=lambda: {"logged_in": False}, + ) + assert "xAI OAuth" in out + # The check_info line is only emitted when the "error" key is present. + # Pick a token that would appear in no ordinary doctor output. + assert "refresh token expired" not in out + + def test_logged_in_does_not_emit_not_logged_in_on_xai_line(self, monkeypatch, tmp_path): + out = self._run( + monkeypatch, tmp_path, + xai_auth_fn=lambda: {"logged_in": True}, + ) + assert "xAI OAuth" in out + # The xAI OAuth line itself must say "(logged in)", not "(not logged in)". + xai_line = next(l for l in out.splitlines() if "xAI OAuth" in l) + assert "(logged in)" in xai_line + assert "(not logged in)" not in xai_line + + def test_import_failure_does_not_crash_doctor(self, monkeypatch, tmp_path): + """Doctor must not crash when get_xai_oauth_auth_status cannot be imported.""" + home = tmp_path / ".hermes" + home.mkdir(parents=True, exist_ok=True) + (home / "config.yaml").write_text("memory: {}\n", encoding="utf-8") + project = tmp_path / "project" + project.mkdir(exist_ok=True) + + monkeypatch.setattr(doctor_mod, "HERMES_HOME", home) + monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project) + monkeypatch.setattr(doctor_mod, "_DHH", str(home)) + + fake_model_tools = types.SimpleNamespace( + check_tool_availability=lambda *a, **kw: ([], []), + TOOLSET_REQUIREMENTS={}, + ) + monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools) + + from hermes_cli import auth as _auth_mod + monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {"logged_in": False}) + monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {"logged_in": False}) + monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: {"logged_in": False}) + monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {"logged_in": False}) + monkeypatch.delattr(_auth_mod, "get_xai_oauth_auth_status", raising=False) + + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + doctor_mod.run_doctor(Namespace(fix=False)) + out = buf.getvalue() + # The ◆ Auth Providers header must still appear — other providers unaffected. + assert "Auth Providers" in out + + def test_import_failure_does_not_affect_other_providers(self, monkeypatch, tmp_path): + """Nous / Codex / Gemini / MiniMax rows must survive an xAI import failure.""" + home = tmp_path / ".hermes" + home.mkdir(parents=True, exist_ok=True) + (home / "config.yaml").write_text("memory: {}\n", encoding="utf-8") + project = tmp_path / "project" + project.mkdir(exist_ok=True) + + monkeypatch.setattr(doctor_mod, "HERMES_HOME", home) + monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project) + monkeypatch.setattr(doctor_mod, "_DHH", str(home)) + + fake_model_tools = types.SimpleNamespace( + check_tool_availability=lambda *a, **kw: ([], []), + TOOLSET_REQUIREMENTS={}, + ) + monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools) + + from hermes_cli import auth as _auth_mod + monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {"logged_in": True}) + monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {"logged_in": False}) + monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: {"logged_in": False}) + monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {"logged_in": False}) + monkeypatch.delattr(_auth_mod, "get_xai_oauth_auth_status", raising=False) + + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + doctor_mod.run_doctor(Namespace(fix=False)) + out = buf.getvalue() + assert "Nous Portal auth" in out + assert "logged in" in out + + def test_function_raises_does_not_crash_doctor(self, monkeypatch, tmp_path): + """A runtime exception from get_xai_oauth_auth_status must be swallowed.""" + def _raise(): + raise RuntimeError("simulated xAI status failure") + + out = self._run(monkeypatch, tmp_path, xai_auth_fn=_raise) + assert "Auth Providers" in out + + def test_function_returns_none_does_not_crash_doctor(self, monkeypatch, tmp_path): + """None return is normalised to {} via `or {}` — must not AttributeError.""" + out = self._run(monkeypatch, tmp_path, xai_auth_fn=lambda: None) + # None → {} → logged_in falsy → shows not-logged-in warn + assert "xAI OAuth" in out + assert "(not logged in)" in out From 37286a5bcd4fe2b43ea365140e71abb0add05fbb Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 17 May 2026 11:37:09 -0700 Subject: [PATCH 037/418] chore(release): map QuenVix, Mind-Dragon, soynchux emails for Tier 4 salvage --- scripts/release.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/release.py b/scripts/release.py index 31bf7020ce3b..c0d743bef9db 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1150,6 +1150,11 @@ "alaamohanad169-ship-it@users.noreply.github.com": "alaamohanad169-ship-it", # PR #26036 (telegram typing after send) "vigo@hermes": "hawknewton", # PR #26294 (bedrock boto3 lazy_deps) "211668+hawknewton@users.noreply.github.com": "hawknewton", + "quenvix00@gmail.com": "QuenVix", # PR #26761/26772 salvage + "164776164+QuenVix@users.noreply.github.com": "QuenVix", + "262945885+Mind-Dragon@users.noreply.github.com": "Mind-Dragon", # PR #26966 salvage + "soynchuux@gmail.com": "soynchux", # PR #27060 salvage + "209694554+soynchux@users.noreply.github.com": "soynchux", } From d5a0815c3dd9e4c9ca2fd37d0f51f5d1cc0b1e3e Mon Sep 17 00:00:00 2001 From: QuenVix <164776164+QuenVix@users.noreply.github.com> Date: Sat, 16 May 2026 08:00:48 +0300 Subject: [PATCH 038/418] fix(transports): use monotonic deadlines in codex app-server turn loop --- agent/transports/codex_app_server_session.py | 10 ++-- .../test_codex_app_server_session.py | 48 +++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/agent/transports/codex_app_server_session.py b/agent/transports/codex_app_server_session.py index a72599ae7197..d9ee92dfbf58 100644 --- a/agent/transports/codex_app_server_session.py +++ b/agent/transports/codex_app_server_session.py @@ -404,7 +404,7 @@ def run_turn( return result result.turn_id = (ts.get("turn") or {}).get("id") - deadline = time.time() + turn_timeout + deadline = time.monotonic() + turn_timeout turn_complete = False # Post-tool watchdog state. last_tool_completion_at is set whenever # a tool-shaped item completes; if no further notification arrives @@ -412,7 +412,7 @@ def run_turn( # fast-fail and retire the session. last_tool_completion_at: Optional[float] = None - while time.time() < deadline and not turn_complete: + while time.monotonic() < deadline and not turn_complete: if self._interrupt_event.is_set(): self._issue_interrupt(result.turn_id) result.interrupted = True @@ -440,7 +440,7 @@ def run_turn( # up on this turn instead of waiting for the outer deadline. if ( last_tool_completion_at is not None - and (time.time() - last_tool_completion_at) + and (time.monotonic() - last_tool_completion_at) > post_tool_quiet_timeout ): self._issue_interrupt(result.turn_id) @@ -471,7 +471,7 @@ def run_turn( result.projected_messages.extend(proj.messages) if proj.is_tool_iteration: result.tool_iterations += 1 - last_tool_completion_at = time.time() + last_tool_completion_at = time.monotonic() if proj.final_text is not None: result.final_text = proj.final_text if _has_turn_aborted_marker(proj.final_text): @@ -514,7 +514,7 @@ def run_turn( result.tool_iterations += 1 # Arm/refresh the post-tool quiet watchdog whenever a # tool-shaped item completes. - last_tool_completion_at = time.time() + last_tool_completion_at = time.monotonic() else: # Any non-tool projected activity (assistant message, # status update, etc.) means codex is still producing diff --git a/tests/agent/transports/test_codex_app_server_session.py b/tests/agent/transports/test_codex_app_server_session.py index f51996dd067b..b192d64e1c86 100644 --- a/tests/agent/transports/test_codex_app_server_session.py +++ b/tests/agent/transports/test_codex_app_server_session.py @@ -9,10 +9,12 @@ import threading import time +from unittest.mock import patch from typing import Any, Optional import pytest +import agent.transports.codex_app_server_session as session_mod from agent.transports.codex_app_server_session import ( CodexAppServerSession, TurnResult, @@ -344,6 +346,23 @@ def test_deadline_exceeded_records_error(self): assert r.interrupted is True assert r.error and "timed out" in r.error + def test_deadline_uses_monotonic_clock(self): + client = FakeClient() + s = make_session(client) + monotonic_values = iter([1000.0, 999.0, 999.0, 1001.0]) + with patch.object( + session_mod.time, + "monotonic", + side_effect=lambda: next(monotonic_values), + ): + r = s.run_turn( + "never finishes", + turn_timeout=0.1, + notification_poll_timeout=0.0, + ) + assert r.interrupted is True + assert r.error and "timed out" in r.error + def test_failed_turn_records_error_from_turn_completed(self): client = FakeClient() client.queue_notification( @@ -666,6 +685,35 @@ def test_post_tool_quiet_watchdog_trips_and_retires(self): # Confirm we issued turn/interrupt to free codex compute assert any(method == "turn/interrupt" for (method, _) in client.requests) + def test_post_tool_watchdog_uses_monotonic_clock(self): + client = FakeClient() + client.queue_notification( + "item/completed", + item={ + "type": "commandExecution", "id": "ex1", + "command": "echo hi", "cwd": "/tmp", + "status": "completed", "aggregatedOutput": "hi", + "exitCode": 0, "commandActions": [], + }, + threadId="t", turnId="tu1", + ) + s = make_session(client) + monotonic_values = iter([1000.0, 999.0, 999.0, 999.0, 1000.2]) + with patch.object( + session_mod.time, + "monotonic", + side_effect=lambda: next(monotonic_values), + ): + r = s.run_turn( + "tool then silence", + turn_timeout=5.0, + notification_poll_timeout=0.0, + post_tool_quiet_timeout=0.15, + ) + assert r.interrupted is True + assert r.should_retire is True + assert r.error and "silent" in r.error + def test_post_tool_watchdog_resets_on_further_activity(self): """A tool completion followed by an agent message should NOT trip the watchdog — further activity = codex still alive.""" From 2f28b60a474c880367be612c682f52b8ca9dbb4d Mon Sep 17 00:00:00 2001 From: QuenVix <164776164+QuenVix@users.noreply.github.com> Date: Sat, 16 May 2026 08:26:41 +0300 Subject: [PATCH 039/418] fix(send_message): preserve Slack and Matrix thread targets resolved from channel directory --- tests/tools/test_send_message_tool.py | 96 ++++++++++++++++++++++++++- tools/send_message_tool.py | 10 +++ 2 files changed, 103 insertions(+), 3 deletions(-) diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index fa810eb5c54d..dac476749fd1 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -182,6 +182,81 @@ def test_display_label_target_resolves_via_channel_directory(self, tmp_path): force_document=False, ) + def test_resolved_slack_thread_name_preserves_thread_id(self): + slack_cfg = SimpleNamespace(enabled=True, token="xoxb-test", extra={}) + config = SimpleNamespace( + platforms={Platform.SLACK: slack_cfg}, + get_home_channel=lambda _platform: None, + ) + + with patch("gateway.config.load_gateway_config", return_value=config), \ + patch("tools.interrupt.is_interrupted", return_value=False), \ + patch("gateway.channel_directory.resolve_channel_name", return_value="C123ABCDEF:171.000001"), \ + patch("model_tools._run_async", side_effect=_run_async_immediately), \ + patch("tools.send_message_tool._send_to_platform", new=AsyncMock(return_value={"success": True})) as send_mock, \ + patch("gateway.mirror.mirror_to_session", return_value=True): + result = json.loads( + send_message_tool( + { + "action": "send", + "target": "slack:ops / topic 171.000001", + "message": "hello", + } + ) + ) + + assert result["success"] is True + send_mock.assert_awaited_once_with( + Platform.SLACK, + slack_cfg, + "C123ABCDEF", + "hello", + thread_id="171.000001", + media_files=[], + force_document=False, + ) + + def test_resolved_matrix_thread_name_preserves_thread_id(self): + matrix_cfg = SimpleNamespace( + enabled=True, + token="tok", + extra={"homeserver": "https://matrix.example.com"}, + ) + config = SimpleNamespace( + platforms={Platform.MATRIX: matrix_cfg}, + get_home_channel=lambda _platform: None, + ) + + with patch("gateway.config.load_gateway_config", return_value=config), \ + patch("tools.interrupt.is_interrupted", return_value=False), \ + patch( + "gateway.channel_directory.resolve_channel_name", + return_value="!roomid:matrix.example.org:$thread123:matrix.example.org", + ), \ + patch("model_tools._run_async", side_effect=_run_async_immediately), \ + patch("tools.send_message_tool._send_to_platform", new=AsyncMock(return_value={"success": True})) as send_mock, \ + patch("gateway.mirror.mirror_to_session", return_value=True): + result = json.loads( + send_message_tool( + { + "action": "send", + "target": "matrix:Ops / topic $thread123", + "message": "hello", + } + ) + ) + + assert result["success"] is True + send_mock.assert_awaited_once_with( + Platform.MATRIX, + matrix_cfg, + "!roomid:matrix.example.org", + "hello", + thread_id="$thread123:matrix.example.org", + media_files=[], + force_document=False, + ) + def test_mirror_receives_current_session_user_id(self): config, _telegram_cfg = _make_config() @@ -503,9 +578,8 @@ async def fake_send(token, chat_id, message, media_files=None, thread_id=None, d assert all(call == [] for call in sent_calls[:-1]) assert sent_calls[-1] == media - def test_matrix_media_uses_native_adapter_helper(self): - - doc_path = Path("/tmp/test-send-message-matrix.pdf") + def test_matrix_media_uses_native_adapter_helper(self, tmp_path): + doc_path = tmp_path / "test-send-message-matrix.pdf" doc_path.write_bytes(b"%PDF-1.4 test") try: @@ -847,6 +921,16 @@ def test_discord_whitespace_is_stripped(self): class TestParseTargetRefMatrix: """_parse_target_ref correctly handles Matrix room IDs and user MXIDs.""" + def test_matrix_thread_target_is_explicit(self): + """Session-derived Matrix thread targets round-trip as room + event id.""" + chat_id, thread_id, is_explicit = _parse_target_ref( + "matrix", + "!HLOQwxYGgFPMPJUSNR:matrix.org:$thread123:matrix.org", + ) + assert chat_id == "!HLOQwxYGgFPMPJUSNR:matrix.org" + assert thread_id == "$thread123:matrix.org" + assert is_explicit is True + def test_matrix_room_id_is_explicit(self): """Matrix room IDs (!) are recognized as explicit targets.""" chat_id, thread_id, is_explicit = _parse_target_ref("matrix", "!HLOQwxYGgFPMPJUSNR:matrix.org") @@ -919,6 +1003,12 @@ def test_e164_prefix_only_matches_phone_platforms(self): class TestParseTargetRefSlack: """_parse_target_ref recognizes Slack channel/user IDs as explicit.""" + def test_thread_target_is_explicit(self): + chat_id, thread_id, is_explicit = _parse_target_ref("slack", "C0B0QV5434G:171.000001") + assert chat_id == "C0B0QV5434G" + assert thread_id == "171.000001" + assert is_explicit is True + def test_public_channel_id_is_explicit(self): chat_id, thread_id, is_explicit = _parse_target_ref("slack", "C0B0QV5434G") assert chat_id == "C0B0QV5434G" diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index d5b2c0c782cd..bfe1a6307072 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -28,6 +28,8 @@ # conversations.open to obtain a D... ID. Without this gate, Slack IDs fall # through to channel-name resolution, which only matches by name and fails. _SLACK_TARGET_RE = re.compile(r"^\s*([CGD][A-Z0-9]{8,})\s*$") +# Session-derived Slack thread targets use ":". +_SLACK_THREAD_TARGET_RE = re.compile(r"^\s*([CGD][A-Z0-9]{8,}):([^\s:]+)\s*$") _WEIXIN_TARGET_RE = re.compile(r"^\s*((?:wxid|gh|v\d+|wm|wb)_[A-Za-z0-9_-]+|[A-Za-z0-9._-]+@chatroom|filehelper)\s*$") _YUANBAO_TARGET_RE = re.compile(r"^\s*((?:group|direct):[^:]+)\s*$") # Discord snowflake IDs are numeric, same regex pattern as Telegram topic targets. @@ -330,9 +332,17 @@ def _parse_target_ref(platform_name: str, target_ref: str): if match: return match.group(1), match.group(2), True if platform_name == "slack": + match = _SLACK_THREAD_TARGET_RE.fullmatch(target_ref) + if match: + return match.group(1), match.group(2), True match = _SLACK_TARGET_RE.fullmatch(target_ref) if match: return match.group(1), None, True + if platform_name == "matrix": + trimmed = target_ref.strip() + split_idx = trimmed.rfind(":$") + if split_idx > 0: + return trimmed[:split_idx], trimmed[split_idx + 1 :], True if platform_name == "weixin": match = _WEIXIN_TARGET_RE.fullmatch(target_ref) if match: From 55d6a1636bb1f38b01b708582c527b91cc9fe578 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 17 May 2026 11:36:15 -0700 Subject: [PATCH 040/418] fix(agent): honor provider timeout config in streaming API calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #25249 (and supersedes PR #25260) in spirit. Two bugs in the streaming chat-completions path caused provider timeout configuration to be silently ignored: 1. Hardcoded connect/pool timeout. The httpx.Timeout for streaming calls used hardcoded connect=30.0 and pool=30.0 regardless of the user's providers..request_timeout_seconds config. If the custom provider (e.g. Ollama) was unreachable, the call always waited exactly 30s before failing, ignoring any configured timeout. Fix: use min(_base_timeout, 60.0) for connect and pool when a provider timeout is configured, falling back to 30.0 otherwise. The 60s cap addresses review feedback (TCP handshake shouldn't wait the inference timeout — connect/pool cover the connection layer, not model latency). 2. Streaming stale-stream detector ignored provider config. The stale detector read only HERMES_STREAM_STALE_TIMEOUT (env default 180s). The providers..stale_timeout_seconds key (correctly used in the non-streaming path) was never consulted. Fix: check get_provider_stale_timeout(provider, model) first, then fall back to the env var. Aligns the streaming path with the non-streaming path's priority chain (config > env > default). Salvage shape diverged from PR #25260: the function moved to agent/chat_completion_helpers.py and the contributor's two commits (initial fix + 60s-cap review follow-up) are squashed into one final commit applied at the new location. Original diagnosis, fix shape, AND the 60s-cap review response from @zccyman in PR #25260; credited via Co-authored-by. Co-authored-by: zccyman <16263913+zccyman@users.noreply.github.com> --- agent/chat_completion_helpers.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 1bf1ebc651ed..e536db95eb16 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -33,7 +33,7 @@ from typing import Any, Dict, List, Optional, Tuple from urllib.parse import urlparse, parse_qs, urlunparse -from hermes_cli.timeouts import get_provider_request_timeout +from hermes_cli.timeouts import get_provider_request_timeout, get_provider_stale_timeout from agent.error_classifier import classify_api_error, FailoverReason from agent.model_metadata import is_local_endpoint from agent.message_sanitization import ( @@ -1272,15 +1272,18 @@ def _call_chat_completions(): "Local provider detected (%s) — stream read timeout raised to %.0fs", agent.base_url, _stream_read_timeout, ) + # Cap connect/pool at 60s even when provider timeout is higher. + # connect/pool cover TCP handshake, not model inference. + _conn_cap = min(_base_timeout, 60.0) if _provider_timeout_cfg is not None else 30.0 stream_kwargs = { **api_kwargs, "stream": True, "stream_options": {"include_usage": True}, "timeout": _httpx.Timeout( - connect=30.0, + connect=_conn_cap, read=_stream_read_timeout, write=_base_timeout, - pool=30.0, + pool=_conn_cap, ), } request_client_holder["client"] = agent._create_request_openai_client( @@ -1868,7 +1871,12 @@ def _call(): if request_client is not None: agent._close_request_openai_client(request_client, reason="stream_request_complete") - _stream_stale_timeout_base = float(os.getenv("HERMES_STREAM_STALE_TIMEOUT", 180.0)) + # Provider-configured stale timeout takes priority over env default. + _cfg_stale = get_provider_stale_timeout(agent.provider, agent.model) + if _cfg_stale is not None: + _stream_stale_timeout_base = _cfg_stale + else: + _stream_stale_timeout_base = float(os.getenv("HERMES_STREAM_STALE_TIMEOUT", 180.0)) # Local providers (Ollama, oMLX, llama-cpp) can take 300+ seconds # for prefill on large contexts. Disable the stale detector unless # the user explicitly set HERMES_STREAM_STALE_TIMEOUT. From 4afd479f51631ea39f8403df6b1e0467fc81c466 Mon Sep 17 00:00:00 2001 From: bird <6666242+bird@users.noreply.github.com> Date: Wed, 13 May 2026 16:06:06 -0400 Subject: [PATCH 041/418] fix(gateway): use service restart path in Docker/Podman containers The /restart command used a detached subprocess approach to restart the gateway. In Docker, when the gateway process exits, tini (PID 1) also exits, causing Docker to stop the container and kill the detached helper before it can restart the gateway. This made /restart effectively a /shutdown in containerized deployments. Detect Docker (/.dockerenv) and Podman (/run/.containerenv) containers and use the service restart path (exit code 75) instead, letting the container restart policy handle the actual restart. Note: requires restart policy that restarts on non-zero exit (e.g. unless-stopped or on-failure). --- gateway/run.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index db7066281c3a..a0ab84e850de 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -8971,13 +8971,15 @@ async def _handle_restart_command(self, event: MessageEvent) -> Union[str, Ephem logger.debug("Failed to write restart dedup marker: %s", e) active_agents = self._running_agent_count() - # When running under a service manager (systemd/launchd), use the - # service restart path: exit with code 75 so the service manager - # restarts us. The detached subprocess approach (setsid + bash) - # doesn't work under systemd because KillMode=mixed kills all - # processes in the cgroup, including the detached helper. + # When running under a service manager (systemd/launchd) or inside a + # Docker/Podman container, use the service restart path: exit with + # code 75 so the service manager / container restart policy restarts + # us. The detached subprocess approach (setsid + bash) doesn't work + # under systemd (KillMode=mixed kills the cgroup) or Docker (tini + # exits when the gateway dies, taking the detached helper with it). _under_service = bool(os.environ.get("INVOCATION_ID")) # systemd sets this - if _under_service: + _in_container = os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv") + if _under_service or _in_container: self.request_restart(detached=False, via_service=True) else: self.request_restart(detached=True, via_service=False) From 714b3b2bd885c070d6404391b390fe349bf6cbf6 Mon Sep 17 00:00:00 2001 From: davidcampbelldc <165905879+davidcampbelldc@users.noreply.github.com> Date: Sun, 17 May 2026 11:36:29 -0700 Subject: [PATCH 042/418] fix(web_server): pass proxy_headers=False to uvicorn.run so the dashboard's loopback gate sees the real connection peer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_ws_client_is_allowed()` enforces a loopback-only client check on every dashboard WebSocket upgrade (`/api/ws`, `/api/events`, `/api/pty`, `/api/pub`): def _ws_client_is_allowed(ws): if _is_public_bind(): return True client_host = ws.client.host if ws.client else "" if not client_host: return True return client_host in _LOOPBACK_HOSTS The intent is: when bound to 127.0.0.1, only accept WS upgrades from loopback peers. Public bind (--insecure) trades that for token-only. However, `uvicorn.run(app, host=host, port=port, log_level="warning")` omits `proxy_headers`. In modern uvicorn (>= 0.20) `proxy_headers` defaults to True and `forwarded_allow_ips` defaults to "127.0.0.1". With those defaults, any reverse proxy connecting from loopback (nginx, in-cluster proxy, Cloudflare Tunnel sidecar in HTTP mode, K8s ingress-nginx) causes uvicorn to rewrite `ws.client.host` from the request's `X-Forwarded-For` header. So the gate sees the original client's IP (a public address) instead of the loopback peer, returns False, and closes every browser WS with code=4403 (surfaces as HTTP 403 to the proxy). Passing `proxy_headers=False` keeps the loopback gate's view of `ws.client.host` at the immediate transport peer (the proxy on 127.0.0.1), which is exactly what the gate is designed to check. The bug is invisible in dev (no proxy → no XFF → ws.client.host stays loopback). It surfaces in proxied production: dashboard chat tab opens, events feed banner shows "disconnected — tool calls may not appear", all WS endpoints return 403. Reproduces with: curl -i -H "Connection: Upgrade" -H "Upgrade: websocket" \ -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: ..." \ -H "X-Forwarded-For: 1.2.3.4" \ "http://127.0.0.1:9119/api/ws?token=\$TOKEN" # Before: HTTP/1.1 403 Forbidden # After: HTTP/1.1 101 Switching Protocols Without the XFF header, both behave the same (101) — confirming the single-variable trigger. Discovered while diagnosing why the Hermes dashboard at mandy.loadmagic.ai (behind nginx + Cloudflare Tunnel + CF Access) refused all browser WS upgrades despite Access app config matching a known-working sibling deployment (Simone, which doesn't have nginx in the path). --- hermes_cli/web_server.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index bdb24554f87b..8a1e4aca2e1d 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -4434,4 +4434,7 @@ def _open(): ) print(f" Hermes Web UI → http://{host}:{port}") - uvicorn.run(app, host=host, port=port, log_level="warning") + # proxy_headers=False so _ws_client_is_allowed sees the real connection peer + # rather than X-Forwarded-For's rewritten value (which would defeat the + # loopback gate when behind a reverse proxy). + uvicorn.run(app, host=host, port=port, log_level="warning", proxy_headers=False) From 74031e1e2aab77881c8e1eddb5f1766b47dfcfdc Mon Sep 17 00:00:00 2001 From: wesleysimplicio <6108320+wesleysimplicio@users.noreply.github.com> Date: Sun, 17 May 2026 11:36:29 -0700 Subject: [PATCH 043/418] fix(dashboard): respect HERMES_BASE_PATH in WebSocket URLs (#25547) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the dashboard is reverse-proxied under a path prefix (`X-Forwarded-Prefix: /dashboard`), the SPA already routes its `/api/...` REST traffic through `HERMES_BASE_PATH` via `web/src/lib/api.ts`. Three WebSocket URLs constructed elsewhere were still hardcoded to root `/api/...` and so opened `wss://host/api/...` instead of `wss://host/dashboard/api/...`, forcing operators to forward selected root API/WS paths through the reverse proxy as a workaround (see issue #25547). Add `HERMES_BASE_PATH` between `host` and `/api/...` in the three constructed WebSocket URLs: - `web/src/pages/ChatPage.tsx` — PTY WebSocket - `web/src/components/ChatSidebar.tsx` — events subscriber - `web/src/lib/gatewayClient.ts` — JSON-RPC gateway WebSocket When the dashboard is served at root, `HERMES_BASE_PATH === """ and the URLs are bit-for-bit identical to before. Under a prefix, the WebSocket connections now go through the same proxy path the REST calls already use. Note: bundled dashboard plugins (kanban, hermes-achievements) embed `"/api/plugins/..."` in their compiled `dist/index.js` and remain out of scope here — those need source-side fixes per plugin. Fixes #25547. --- web/src/components/ChatSidebar.tsx | 3 ++- web/src/lib/gatewayClient.ts | 4 +++- web/src/pages/ChatPage.tsx | 3 ++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/web/src/components/ChatSidebar.tsx b/web/src/components/ChatSidebar.tsx index 38f1cf80abd8..c311673fafce 100644 --- a/web/src/components/ChatSidebar.tsx +++ b/web/src/components/ChatSidebar.tsx @@ -30,6 +30,7 @@ import { Card } from "@/components/ui/card"; import { ModelPickerDialog } from "@/components/ModelPickerDialog"; import { ToolCall, type ToolEntry } from "@/components/ToolCall"; import { GatewayClient, type ConnectionState } from "@/lib/gatewayClient"; +import { HERMES_BASE_PATH } from "@/lib/api"; import { cn } from "@/lib/utils"; import { AlertCircle, ChevronDown, RefreshCw } from "lucide-react"; @@ -160,7 +161,7 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) { const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; const qs = new URLSearchParams({ token, channel }); const ws = new WebSocket( - `${proto}//${window.location.host}/api/events?${qs.toString()}`, + `${proto}//${window.location.host}${HERMES_BASE_PATH}/api/events?${qs.toString()}`, ); // `unmounting` suppresses the banner during cleanup — `ws.close()` diff --git a/web/src/lib/gatewayClient.ts b/web/src/lib/gatewayClient.ts index fa58841ce185..9092ef2d32db 100644 --- a/web/src/lib/gatewayClient.ts +++ b/web/src/lib/gatewayClient.ts @@ -13,6 +13,8 @@ * await gw.request("prompt.submit", { session_id, text: "hi" }) */ +import { HERMES_BASE_PATH } from "@/lib/api"; + export type GatewayEventName = | "gateway.ready" | "session.info" @@ -117,7 +119,7 @@ export class GatewayClient { const scheme = location.protocol === "https:" ? "wss:" : "ws:"; const ws = new WebSocket( - `${scheme}//${location.host}/api/ws?token=${encodeURIComponent(resolved)}`, + `${scheme}//${location.host}${HERMES_BASE_PATH}/api/ws?token=${encodeURIComponent(resolved)}`, ); this.ws = ws; diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 6fd32fa43fc3..3e3c2e3268b3 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -24,6 +24,7 @@ import { Terminal } from "@xterm/xterm"; import "@xterm/xterm/css/xterm.css"; import { Button } from "@nous-research/ui/ui/components/button"; import { Typography } from "@/components/NouiTypography"; +import { HERMES_BASE_PATH } from "@/lib/api"; import { cn } from "@/lib/utils"; import { Copy, PanelRight, X } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -44,7 +45,7 @@ function buildWsUrl( const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; const qs = new URLSearchParams({ token, channel }); if (resume) qs.set("resume", resume); - return `${proto}//${window.location.host}/api/pty?${qs.toString()}`; + return `${proto}//${window.location.host}${HERMES_BASE_PATH}/api/pty?${qs.toString()}`; } // Channel id ties this chat tab's PTY child (publisher) to its sidebar From 3f01e9493c4105bc52a9366a833fb17bb155527d Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 17 May 2026 11:37:00 -0700 Subject: [PATCH 044/418] chore(release): AUTHOR_MAP entries for batch salvage group 6 contributors Final LHF run group. Adds release-note attribution mappings for: - @bird (PR #25219) - @davidcampbelldc (PR #26834) (zccyman, wesleysimplicio already mapped from prior groups.) --- scripts/release.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/release.py b/scripts/release.py index c0d743bef9db..fa1ed739d488 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1155,6 +1155,10 @@ "262945885+Mind-Dragon@users.noreply.github.com": "Mind-Dragon", # PR #26966 salvage "soynchuux@gmail.com": "soynchux", # PR #27060 salvage "209694554+soynchux@users.noreply.github.com": "soynchux", + # batch salvage (May 2026 LHF run, group 6 — final) + "6666242+bird@users.noreply.github.com": "bird", # PR #25219 (gateway docker exit-75 restart) + "david@loadmagic.ai": "davidcampbelldc", # PR #26834 (web_server proxy_headers=False) + "165905879+davidcampbelldc@users.noreply.github.com": "davidcampbelldc", } From 84667cbc21dc09c4e53793eb31d3b7f2c4fd9d0f Mon Sep 17 00:00:00 2001 From: Mind-Dragon <262945885+Mind-Dragon@users.noreply.github.com> Date: Sat, 16 May 2026 16:28:40 +0200 Subject: [PATCH 045/418] fix(delegation): preserve configured_provider name when runtime returns 'custom' Named custom providers (e.g. crof.ai) resolve to provider='custom' at the runtime level, causing subagents to lose their intended provider identity. On retry/fallback, resolve_provider_client('custom', model=...) searches all providers advertising that model and picks non-deterministically, routing to Z.AI or Bailian instead of the configured target. The fix preserves configured_provider when runtime['provider'] == 'custom', restoring the original provider name so routing stays correct through retries. Adds a named constant _RUNTIME_PROVIDER_CUSTOM instead of a magic string. Adds three regression tests: - test_named_custom_provider_preserves_provider_name: the #26954 case - test_standard_provider_not_overwritten_by_configured_name: openrouter/nous must still return their own identity, not the configured name - test_custom_provider_with_empty_configured_provider_falls_back_to_runtime: empty provider triggers the early-return None path as before --- tests/tools/test_delegate.py | 65 ++++++++++++++++++++++++++++++++++++ tools/delegate_tool.py | 7 +++- 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index 684f24f5da87..4a40f82b9aab 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -1014,6 +1014,71 @@ def test_missing_config_keys_inherit_parent(self): self.assertIsNone(creds["model"]) self.assertIsNone(creds["provider"]) + @patch("hermes_cli.runtime_provider.resolve_runtime_provider") + def test_named_custom_provider_preserves_provider_name(self, mock_resolve): + """Named custom provider (e.g. crof.ai) resolves to 'custom' at runtime level + but the subagent must retain the original provider identity so that + resolve_provider_client routes to the correct endpoint on retry/fallback. + Regression test for #26954. + """ + mock_resolve.return_value = { + "provider": "custom", # runtime marks it as "custom" type + "model": "deepseek-v4-pro-CEER", + "base_url": "https://api.crof.ai/v1", + "api_key": "crof-key-abc", + "api_mode": "chat_completions", + } + parent = _make_mock_parent(depth=0) + cfg = {"model": "deepseek-v4-pro-CEER", "provider": "crof.ai"} + creds = _resolve_delegation_credentials(cfg, parent) + # The key assertion: subagent must keep "crof.ai", NOT "custom" + self.assertEqual(creds["provider"], "crof.ai") + self.assertEqual(creds["model"], "deepseek-v4-pro-CEER") + self.assertEqual(creds["base_url"], "https://api.crof.ai/v1") + self.assertEqual(creds["api_key"], "crof-key-abc") + # Verify resolve_runtime_provider was called with the configured name + mock_resolve.assert_called_once_with( + requested="crof.ai", target_model="deepseek-v4-pro-CEER" + ) + + @patch("hermes_cli.runtime_provider.resolve_runtime_provider") + def test_standard_provider_not_overwritten_by_configured_name(self, mock_resolve): + """Standard (non-custom) providers must still return runtime identity, + not the configured name, to preserve existing behaviour for openrouter, + nous, etc. + """ + mock_resolve.return_value = { + "provider": "openrouter", + "model": "anthropic/claude-sonnet-4", + "base_url": "https://openrouter.ai/api/v1", + "api_key": "or-key-xyz", + "api_mode": "chat_completions", + } + parent = _make_mock_parent(depth=0) + cfg = {"model": "anthropic/claude-sonnet-4", "provider": "openrouter"} + creds = _resolve_delegation_credentials(cfg, parent) + # Standard provider returns its own name, not "custom" + self.assertEqual(creds["provider"], "openrouter") + + @patch("hermes_cli.runtime_provider.resolve_runtime_provider") + def test_custom_provider_with_empty_configured_provider_falls_back_to_runtime(self, mock_resolve): + """When configured_provider is empty/None, the early return kicks in and + we return provider=None regardless of what runtime resolved. The runtime + path is only reached when configured_provider is a non-empty string. + """ + mock_resolve.return_value = { + "provider": "custom", + "model": "some-model", + "base_url": "https://fallback.example.com/v1", + "api_key": "key-fallback", + "api_mode": "chat_completions", + } + parent = _make_mock_parent(depth=0) + cfg = {"model": "some-model", "provider": ""} + creds = _resolve_delegation_credentials(cfg, parent) + # Empty provider → early return with None (child inherits parent) + self.assertIsNone(creds["provider"]) + class TestDelegationProviderIntegration(unittest.TestCase): """Integration tests: delegation config → _run_single_child → AIAgent construction.""" diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index e9ad32e0d3a7..86dcd0715cc9 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -31,6 +31,11 @@ from typing import Any, Dict, List, Optional from toolsets import TOOLSETS + +# Sentinel value used by the runtime provider system for providers that are +# not natively known (named custom providers, third-party aggregators, etc.). +# Must match hermes_cli.runtime_provider.RUNTIME_PROVIDER_TYPE_CUSTOM. +_RUNTIME_PROVIDER_CUSTOM = "custom" from tools import file_state from tools.terminal_tool import set_approval_callback as _set_subagent_approval_cb from utils import base_url_hostname, is_truthy_value @@ -2442,7 +2447,7 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict: return { "model": configured_model or runtime.get("model") or None, - "provider": runtime.get("provider"), + "provider": configured_provider if runtime.get("provider") == _RUNTIME_PROVIDER_CUSTOM else runtime.get("provider"), "base_url": runtime.get("base_url"), "api_key": api_key, "api_mode": runtime.get("api_mode"), From 874dad5cc1886ed79cddbc4d12c8cc62f8f3db5e Mon Sep 17 00:00:00 2001 From: Mind-Dragon <262945885+Mind-Dragon@users.noreply.github.com> Date: Sat, 16 May 2026 16:49:28 +0200 Subject: [PATCH 046/418] test(delegation): add regression test for runtime missing 'provider' key Addresses reviewer feedback: when resolve_runtime_provider returns a dict without the 'provider' key, the result must be None regardless of configured_provider. This guards against malformed runtime responses. Test: test_runtime_missing_provider_key_returns_none --- tests/tools/test_delegate.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index 4a40f82b9aab..72c4c67f570e 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -1079,6 +1079,24 @@ def test_custom_provider_with_empty_configured_provider_falls_back_to_runtime(se # Empty provider → early return with None (child inherits parent) self.assertIsNone(creds["provider"]) + @patch("hermes_cli.runtime_provider.resolve_runtime_provider") + def test_runtime_missing_provider_key_returns_none(self, mock_resolve): + """When resolve_runtime_provider returns a dict without 'provider' key, + the result must be None regardless of configured_provider. + This protects against malformed runtime responses. + """ + mock_resolve.return_value = { + # deliberately missing "provider" + "model": "some-model", + "base_url": "https://example.com/v1", + "api_key": "key-123", + "api_mode": "chat_completions", + } + parent = _make_mock_parent(depth=0) + cfg = {"model": "some-model", "provider": "crof.ai"} + creds = _resolve_delegation_credentials(cfg, parent) + self.assertIsNone(creds["provider"]) + class TestDelegationProviderIntegration(unittest.TestCase): """Integration tests: delegation config → _run_single_child → AIAgent construction.""" From 280c63ce91629f9e16d0c2fa82acbbc79c51152b Mon Sep 17 00:00:00 2001 From: soynchux <209694554+soynchux@users.noreply.github.com> Date: Sat, 16 May 2026 22:05:34 +0300 Subject: [PATCH 047/418] fix(mcp): prevent parallel-safe prefix collisions --- tests/run_agent/test_run_agent.py | 16 ++++-- tests/tools/test_mcp_tool.py | 81 +++++++++++++++++++++++++++++-- tools/mcp_tool.py | 47 +++++++++++++----- 3 files changed, 124 insertions(+), 20 deletions(-) diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 11b58e5faa12..a72359227a63 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -2282,9 +2282,11 @@ def test_mcp_tools_default_sequential(self): def test_mcp_tools_parallel_when_server_opted_in(self): """MCP tools from a parallel-safe server can run concurrently.""" from run_agent import _should_parallelize_tool_batch - from tools.mcp_tool import _parallel_safe_servers, _lock + from tools.mcp_tool import _mcp_tool_server_names, _parallel_safe_servers, _lock with _lock: _parallel_safe_servers.add("github") + _mcp_tool_server_names["mcp_github_list_repos"] = "github" + _mcp_tool_server_names["mcp_github_search_code"] = "github" try: tc1 = _mock_tool_call(name="mcp_github_list_repos", arguments='{"org":"openai"}', call_id="c1") tc2 = _mock_tool_call(name="mcp_github_search_code", arguments='{"q":"test"}', call_id="c2") @@ -2292,13 +2294,16 @@ def test_mcp_tools_parallel_when_server_opted_in(self): finally: with _lock: _parallel_safe_servers.discard("github") + _mcp_tool_server_names.pop("mcp_github_list_repos", None) + _mcp_tool_server_names.pop("mcp_github_search_code", None) def test_mixed_mcp_and_builtin_parallel(self): """MCP parallel tools mixed with built-in parallel-safe tools.""" from run_agent import _should_parallelize_tool_batch - from tools.mcp_tool import _parallel_safe_servers, _lock + from tools.mcp_tool import _mcp_tool_server_names, _parallel_safe_servers, _lock with _lock: _parallel_safe_servers.add("docs") + _mcp_tool_server_names["mcp_docs_search"] = "docs" try: tc1 = _mock_tool_call(name="mcp_docs_search", arguments='{"query":"api"}', call_id="c1") tc2 = _mock_tool_call(name="web_search", arguments='{"query":"test"}', call_id="c2") @@ -2306,14 +2311,17 @@ def test_mixed_mcp_and_builtin_parallel(self): finally: with _lock: _parallel_safe_servers.discard("docs") + _mcp_tool_server_names.pop("mcp_docs_search", None) def test_mixed_parallel_and_serial_mcp_servers(self): """One parallel MCP server + one non-parallel MCP server = sequential.""" from run_agent import _should_parallelize_tool_batch - from tools.mcp_tool import _parallel_safe_servers, _lock + from tools.mcp_tool import _mcp_tool_server_names, _parallel_safe_servers, _lock with _lock: _parallel_safe_servers.add("docs") # "github" is NOT in _parallel_safe_servers + _mcp_tool_server_names["mcp_docs_search"] = "docs" + _mcp_tool_server_names["mcp_github_list_repos"] = "github" try: tc1 = _mock_tool_call(name="mcp_docs_search", arguments='{"query":"api"}', call_id="c1") tc2 = _mock_tool_call(name="mcp_github_list_repos", arguments='{"org":"openai"}', call_id="c2") @@ -2321,6 +2329,8 @@ def test_mixed_parallel_and_serial_mcp_servers(self): finally: with _lock: _parallel_safe_servers.discard("docs") + _mcp_tool_server_names.pop("mcp_docs_search", None) + _mcp_tool_server_names.pop("mcp_github_list_repos", None) class TestHandleMaxIterations: diff --git a/tests/tools/test_mcp_tool.py b/tests/tools/test_mcp_tool.py index 0a094eb5467d..3212a350c374 100644 --- a/tests/tools/test_mcp_tool.py +++ b/tests/tools/test_mcp_tool.py @@ -3781,16 +3781,26 @@ def test_is_mcp_tool_parallel_safe_non_mcp_tool(self): def test_is_mcp_tool_parallel_safe_no_servers(self): """MCP tool from unknown server returns False.""" - from tools.mcp_tool import is_mcp_tool_parallel_safe, _parallel_safe_servers, _lock + from tools.mcp_tool import ( + is_mcp_tool_parallel_safe, _mcp_tool_server_names, + _parallel_safe_servers, _lock, + ) with _lock: _parallel_safe_servers.clear() + _mcp_tool_server_names.clear() assert is_mcp_tool_parallel_safe("mcp_docs_search") is False def test_is_mcp_tool_parallel_safe_with_flag(self): """MCP tool from a parallel-safe server returns True.""" - from tools.mcp_tool import is_mcp_tool_parallel_safe, _parallel_safe_servers, _lock + from tools.mcp_tool import ( + is_mcp_tool_parallel_safe, _mcp_tool_server_names, + _parallel_safe_servers, _lock, + ) with _lock: _parallel_safe_servers.add("docs") + _mcp_tool_server_names["mcp_docs_search"] = "docs" + _mcp_tool_server_names["mcp_docs_read_file"] = "docs" + _mcp_tool_server_names["mcp_github_list_repos"] = "github" try: assert is_mcp_tool_parallel_safe("mcp_docs_search") is True assert is_mcp_tool_parallel_safe("mcp_docs_read_file") is True @@ -3799,23 +3809,86 @@ def test_is_mcp_tool_parallel_safe_with_flag(self): finally: with _lock: _parallel_safe_servers.discard("docs") + _mcp_tool_server_names.pop("mcp_docs_search", None) + _mcp_tool_server_names.pop("mcp_docs_read_file", None) + _mcp_tool_server_names.pop("mcp_github_list_repos", None) def test_is_mcp_tool_parallel_safe_server_with_underscores(self): """Server names containing underscores are correctly matched.""" - from tools.mcp_tool import is_mcp_tool_parallel_safe, _parallel_safe_servers, _lock + from tools.mcp_tool import ( + is_mcp_tool_parallel_safe, _mcp_tool_server_names, + _parallel_safe_servers, _lock, + ) with _lock: _parallel_safe_servers.add("my_server") + _mcp_tool_server_names["mcp_my_server_query"] = "my_server" try: assert is_mcp_tool_parallel_safe("mcp_my_server_query") is True finally: with _lock: _parallel_safe_servers.discard("my_server") + _mcp_tool_server_names.pop("mcp_my_server_query", None) + + def test_is_mcp_tool_parallel_safe_uses_exact_registered_server(self): + """Ambiguous MCP names must not match a shorter parallel-safe prefix.""" + from tools.mcp_tool import ( + is_mcp_tool_parallel_safe, _mcp_tool_server_names, + _parallel_safe_servers, _lock, + ) + with _lock: + _parallel_safe_servers.add("a") + _mcp_tool_server_names["mcp_a_search"] = "a" + _mcp_tool_server_names["mcp_a_b_tool"] = "a_b" + try: + assert is_mcp_tool_parallel_safe("mcp_a_search") is True + assert is_mcp_tool_parallel_safe("mcp_a_b_tool") is False + finally: + with _lock: + _parallel_safe_servers.discard("a") + _mcp_tool_server_names.pop("mcp_a_search", None) + _mcp_tool_server_names.pop("mcp_a_b_tool", None) + + def test_registered_tool_provenance_prevents_prefix_collision(self): + """Registration records exact server ownership for ambiguous names.""" + from tools.registry import registry + from tools.mcp_tool import ( + _mcp_tool_server_names, _parallel_safe_servers, + _register_server_tools, is_mcp_tool_parallel_safe, _lock, + ) + + server = _make_mock_server( + "a_b", + tools=[_make_mcp_tool("tool", "Ambiguous tool name")], + ) + registered = _register_server_tools("a_b", server, {}) + try: + assert registered == ["mcp_a_b_tool"] + with _lock: + assert _mcp_tool_server_names["mcp_a_b_tool"] == "a_b" + _parallel_safe_servers.add("a") + assert is_mcp_tool_parallel_safe("mcp_a_b_tool") is False + + with _lock: + _parallel_safe_servers.add("a_b") + assert is_mcp_tool_parallel_safe("mcp_a_b_tool") is True + finally: + for tool_name in registered: + registry.deregister(tool_name) + with _lock: + _parallel_safe_servers.discard("a") + _parallel_safe_servers.discard("a_b") + _mcp_tool_server_names.pop("mcp_a_b_tool", None) def test_is_mcp_tool_parallel_safe_no_tool_suffix(self): """Tool name that is just 'mcp_{server}' without a tool part returns False.""" - from tools.mcp_tool import is_mcp_tool_parallel_safe, _parallel_safe_servers, _lock + from tools.mcp_tool import ( + is_mcp_tool_parallel_safe, _mcp_tool_server_names, + _parallel_safe_servers, _lock, + ) with _lock: _parallel_safe_servers.add("docs") + _mcp_tool_server_names.pop("mcp_docs", None) + _mcp_tool_server_names.pop("mcp_docs_", None) try: # "mcp_docs" has no tool part after the server name assert is_mcp_tool_parallel_safe("mcp_docs") is False diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 9cec72524aff..e1d87389d426 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -1161,6 +1161,7 @@ async def _refresh_tools(self): } for tool_name in stale_tool_names: registry.deregister(tool_name) + _forget_mcp_tool_server(tool_name) # 3. Re-register with fresh tool list self._tools = new_mcp_tools @@ -1696,6 +1697,7 @@ async def shutdown(self): self._pending_refresh_tasks.clear() for tool_name in list(getattr(self, "_registered_tool_names", [])): registry.deregister(tool_name) + _forget_mcp_tool_server(tool_name) self._registered_tool_names = [] self.session = None @@ -2066,11 +2068,20 @@ def _handle_session_expired_and_retry( # ``is_mcp_tool_parallel_safe()`` for the parallel-execution check in run_agent. _parallel_safe_servers: set = set() +# Exact MCP tool-name provenance. MCP tool names are formatted as +# ``mcp_{sanitized_server}_{sanitized_tool}``, which is ambiguous when server +# names contain underscores (``mcp_a_b_tool`` could be server ``a`` + tool +# ``b_tool`` or server ``a_b`` + tool ``tool``). Keep the server component +# captured at registration time so parallel safety never relies on prefix +# guessing. +_mcp_tool_server_names: Dict[str, str] = {} + # Dedicated event loop running in a background daemon thread. _mcp_loop: Optional[asyncio.AbstractEventLoop] = None _mcp_thread: Optional[threading.Thread] = None -# Protects _mcp_loop, _mcp_thread, _servers, _parallel_safe_servers, and _stdio_pids. +# Protects _mcp_loop, _mcp_thread, _servers, _parallel_safe_servers, +# _mcp_tool_server_names, and _stdio_pids. _lock = threading.Lock() # PIDs of stdio MCP server subprocesses. Tracked so we can force-kill @@ -2953,6 +2964,19 @@ def _parse_boolish(value: Any, default: bool = True) -> bool: } +def _track_mcp_tool_server(tool_name: str, server_name: str) -> None: + """Remember the exact MCP server that registered *tool_name*.""" + safe_server_name = sanitize_mcp_name_component(server_name) + with _lock: + _mcp_tool_server_names[tool_name] = safe_server_name + + +def _forget_mcp_tool_server(tool_name: str) -> None: + """Forget MCP server provenance for a deregistered tool.""" + with _lock: + _mcp_tool_server_names.pop(tool_name, None) + + def _select_utility_schemas(server_name: str, server: MCPServerTask, config: dict) -> List[dict]: """Select utility schemas based on config and server capabilities.""" tools_filter = config.get("tools") or {} @@ -3087,6 +3111,7 @@ def _should_register(tool_name: str) -> bool: is_async=False, description=schema["description"], ) + _track_mcp_tool_server(tool_name_prefixed, name) registered_names.append(tool_name_prefixed) # Register MCP Resources & Prompts utility tools, filtered by config and @@ -3123,6 +3148,7 @@ def _should_register(tool_name: str) -> bool: is_async=False, description=schema["description"], ) + _track_mcp_tool_server(util_name, name) registered_names.append(util_name) if registered_names: @@ -3307,24 +3333,19 @@ def discover_mcp_tools() -> List[str]: def is_mcp_tool_parallel_safe(tool_name: str) -> bool: """Check if an MCP tool belongs to a server that supports parallel tool calls. - MCP tool names follow the pattern ``mcp_{server}_{tool}``. This extracts - the server component and checks it against the set of servers whose config - includes ``supports_parallel_tool_calls: true``. + MCP tool names follow the pattern ``mcp_{server}_{tool}``, but that string + shape is ambiguous when server names contain underscores. Use the exact + server provenance captured at registration time rather than prefix + matching, then check whether that server's config includes + ``supports_parallel_tool_calls: true``. Returns False for non-MCP tools or tools from servers without the flag. """ if not tool_name.startswith("mcp_"): return False - # Strip the "mcp_" prefix and extract the server name. - # Tool names are: mcp_{sanitized_server}_{sanitized_tool} - # We need to check all possible server prefixes because the server name - # itself may contain underscores after sanitization. - rest = tool_name[4:] # strip "mcp_" with _lock: - for server_name in _parallel_safe_servers: - if rest.startswith(server_name + "_") and len(rest) > len(server_name) + 1: - return True - return False + server_name = _mcp_tool_server_names.get(tool_name) + return bool(server_name and server_name in _parallel_safe_servers) def get_mcp_status() -> List[dict]: From ee7cd10281c8d6e2cdb9f2f0583c96c0ce2b1639 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 17 May 2026 11:50:15 -0700 Subject: [PATCH 048/418] chore(release): map hehehe0803 email for #26212 salvage --- scripts/release.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/release.py b/scripts/release.py index fa1ed739d488..2ccdf56aec23 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1159,6 +1159,8 @@ "6666242+bird@users.noreply.github.com": "bird", # PR #25219 (gateway docker exit-75 restart) "david@loadmagic.ai": "davidcampbelldc", # PR #26834 (web_server proxy_headers=False) "165905879+davidcampbelldc@users.noreply.github.com": "davidcampbelldc", + "hoangv.pham0803@gmail.com": "hehehe0803", # PR #26212 salvage (codex kanban writable root) + "26063003+hehehe0803@users.noreply.github.com": "hehehe0803", } From 4a7cd2e16dfacbbed4762f7625ab6eb6e0332447 Mon Sep 17 00:00:00 2001 From: "Hoang V. Pham" Date: Fri, 15 May 2026 15:01:27 +0700 Subject: [PATCH 049/418] fix(codex): allow kanban worker board writes --- agent/transports/codex_app_server.py | 33 ++++++++++- .../test_codex_app_server_runtime.py | 55 +++++++++++++++++++ .../features/codex-app-server-runtime.md | 4 +- 3 files changed, 89 insertions(+), 3 deletions(-) diff --git a/agent/transports/codex_app_server.py b/agent/transports/codex_app_server.py index b1aeaa007866..7128de9c4faa 100644 --- a/agent/transports/codex_app_server.py +++ b/agent/transports/codex_app_server.py @@ -74,12 +74,43 @@ def __init__( env: Optional[dict[str, str]] = None, ) -> None: self._codex_bin = codex_bin - cmd = [codex_bin, "app-server"] + list(extra_args or []) spawn_env = os.environ.copy() if env: spawn_env.update(env) if codex_home: spawn_env["CODEX_HOME"] = codex_home + + app_server_args = list(extra_args or []) + # Kanban workers must be able to write their handoff/status back to + # the board DB, which lives outside the per-task workspace. Keep the + # Codex sandbox on, but add the Kanban root as the only extra writable + # root. Without this, codex-runtime workers finish their actual work + # but crash/block when kanban_complete/kanban_block writes SQLite. + if spawn_env.get("HERMES_KANBAN_TASK"): + kanban_db = spawn_env.get("HERMES_KANBAN_DB") + kanban_root = ( + os.path.dirname(kanban_db) + if kanban_db + else spawn_env.get( + "HERMES_KANBAN_ROOT", + os.path.join( + spawn_env.get("HERMES_HOME", os.path.expanduser("~/.hermes")), + "kanban", + ), + ) + ) + app_server_args.extend( + [ + "-c", + 'sandbox_mode="workspace-write"', + "-c", + f'sandbox_workspace_write.writable_roots=["{kanban_root}"]', + "-c", + "sandbox_workspace_write.network_access=false", + ] + ) + + cmd = [codex_bin, "app-server"] + app_server_args # Codex emits tracing to stderr; default WARN keeps it quiet for users. spawn_env.setdefault("RUST_LOG", "warn") diff --git a/tests/agent/transports/test_codex_app_server_runtime.py b/tests/agent/transports/test_codex_app_server_runtime.py index d12ac2272542..55bbc8bc6d34 100644 --- a/tests/agent/transports/test_codex_app_server_runtime.py +++ b/tests/agent/transports/test_codex_app_server_runtime.py @@ -241,3 +241,58 @@ def kill(self): assert captured["env"].get("CODEX_HOME") == "/tmp/profile/codex" # And HOME still passes through unchanged assert captured["env"].get("HOME") == "/users/alice" + + def test_kanban_worker_adds_only_kanban_writable_root(self, monkeypatch): + """Codex-runtime Kanban workers need to write board state outside + their scratch/worktree workspace, but should not fall back to + danger-full-access. Hermes passes a narrow app-server config override + for the Kanban root only. + """ + import subprocess + from agent.transports import codex_app_server as cas + + captured = {} + + class FakePopen: + def __init__(self, cmd, *args, **kwargs): + captured["cmd"] = list(cmd) + captured["env"] = kwargs.get("env", {}).copy() + self.stdin = None + self.stdout = None + self.stderr = None + self.pid = 1 + self.returncode = None + + def poll(self): + return None + + def terminate(self): + pass + + def wait(self, timeout=None): + return 0 + + def kill(self): + pass + + monkeypatch.setattr(subprocess, "Popen", FakePopen) + monkeypatch.setenv("HOME", "/users/alice") + monkeypatch.setenv("HERMES_HOME", "/users/alice/.hermes/profiles/backend-worker") + monkeypatch.setenv("HERMES_KANBAN_TASK", "t_smoke") + monkeypatch.setenv( + "HERMES_KANBAN_DB", + "/users/alice/.hermes/kanban/boards/smoke/kanban.db", + ) + + client = cas.CodexAppServerClient(codex_bin="codex") + client._closed = True + + cmd = captured["cmd"] + assert cmd[:2] == ["codex", "app-server"] + assert 'sandbox_mode="workspace-write"' in cmd + assert ( + 'sandbox_workspace_write.writable_roots=["/users/alice/.hermes/kanban/boards/smoke"]' + in cmd + ) + assert "sandbox_workspace_write.network_access=false" in cmd + assert all("danger" not in part for part in cmd) diff --git a/website/docs/user-guide/features/codex-app-server-runtime.md b/website/docs/user-guide/features/codex-app-server-runtime.md index a1aa6a0776eb..575250d9b018 100644 --- a/website/docs/user-guide/features/codex-app-server-runtime.md +++ b/website/docs/user-guide/features/codex-app-server-runtime.md @@ -91,11 +91,11 @@ What works inside a codex-runtime worker: - The Hermes tool callback for browser_*, vision, image_gen, skills, TTS What also works because the MCP callback exposes them: -- **`kanban_complete` / `kanban_block` / `kanban_comment` / `kanban_heartbeat`** — the worker handoff tools. These read `HERMES_KANBAN_TASK` from env (set by the dispatcher), gate access correctly, and write to `~/.hermes/kanban.db`. Without these in the callback, a worker on this runtime could do its task but couldn't report back, hanging until the dispatcher's timeout. +- **`kanban_complete` / `kanban_block` / `kanban_comment` / `kanban_heartbeat`** — the worker handoff tools. These read `HERMES_KANBAN_TASK` from env (set by the dispatcher), gate access correctly, and write to the per-board SQLite DB pinned by `HERMES_KANBAN_DB`. Without these in the callback, a worker on this runtime could do its task but couldn't report back, hanging until the dispatcher's timeout. - **`kanban_show` / `kanban_list`** — read-only board queries for the worker to check its own context. - **`kanban_create` / `kanban_unblock` / `kanban_link`** — orchestrator-only operations. Available for orchestrator agents running on the codex runtime that need to dispatch new tasks. -The kanban tools are gated by `HERMES_KANBAN_TASK` env var the dispatcher sets — that var is propagated to the codex subprocess (codex inherits env) and from there to the spawned `hermes-tools` MCP server subprocess. So the tools see the right task id and gate correctly. +The kanban tools are gated by `HERMES_KANBAN_TASK` env var the dispatcher sets — that var is propagated to the codex subprocess (codex inherits env) and from there to the spawned `hermes-tools` MCP server subprocess. So the tools see the right task id and gate correctly. For Codex app-server workers, Hermes also passes narrow app-server sandbox overrides when `HERMES_KANBAN_TASK` is present: keep `workspace-write` sandboxing, add only the current board directory (derived from `HERMES_KANBAN_DB`) as an extra writable root, and keep network disabled by default. This avoids the brittle `:danger-no-sandbox` workaround while letting `kanban_complete` / `kanban_block` update the board DB. ### Cron jobs From 7847a58b3a9735a4214d1ee081725f8c8e8d063d Mon Sep 17 00:00:00 2001 From: vaddisrinivas <38348871+vaddisrinivas@users.noreply.github.com> Date: Fri, 15 May 2026 10:27:07 -0400 Subject: [PATCH 050/418] fix(docker): preload messaging gateway deps --- Dockerfile | 18 ++++++++++-------- tests/tools/test_dockerfile_pid1_reaping.py | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index 8655c51f34c6..bde3412ed7f3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -66,9 +66,11 @@ RUN npm install --prefer-offline --no-audit && \ # frontend stats the readme path during dep resolution, so we `touch` an # empty placeholder — the real README is restored by `COPY . .` below. # -# `uv sync --frozen --no-install-project --extra all` installs only the -# deps reachable through the composite `[all]` extra (handpicked set -# intended for the production image). We do NOT use `--all-extras`: +# `uv sync --frozen --no-install-project --extra all --extra messaging` +# installs the deps reachable through the composite `[all]` extra +# (handpicked set intended for the production image), plus gateway +# messaging adapters that should work in the published image without a +# first-boot lazy install. We do NOT use `--all-extras`: # that would pull in `[rl]` (atroposlib + tinker + torch + wandb from # git), `[yc-bench]` (another git dep), and `[termux-all]` (Android # redundancy), none of which belong in the published container. @@ -76,7 +78,7 @@ RUN npm install --prefer-offline --no-audit && \ # The editable link is created after the source copy below. COPY pyproject.toml uv.lock ./ RUN touch ./README.md -RUN uv sync --frozen --no-install-project --extra all +RUN uv sync --frozen --no-install-project --extra all --extra messaging # ---------- Source code ---------- # .dockerignore excludes node_modules, so the installs above survive. @@ -94,10 +96,10 @@ RUN cd web && npm run build && \ # hermes_cli/main.py succeeds (see #18800). /opt/hermes/web is build-time # only (HERMES_WEB_DIST points at hermes_cli/web_dist) and is intentionally # not chowned here. -# The .venv MUST be hermes-writable so lazy_deps.py can install platform -# packages (discord.py, telegram, slack, etc.) at first gateway boot. -# Without this, `uv pip install` fails with EACCES and all messaging -# adapters silently fail to load. See tools/lazy_deps.py. +# The .venv MUST remain hermes-writable so lazy_deps.py can install +# remaining optional platform packages and future pin bumps at first use. +# Without this, `uv pip install` fails with EACCES and adapters silently +# fail to load. See tools/lazy_deps.py. USER root RUN chmod -R a+rX /opt/hermes && \ chown -R hermes:hermes /opt/hermes/.venv /opt/hermes/ui-tui /opt/hermes/node_modules diff --git a/tests/tools/test_dockerfile_pid1_reaping.py b/tests/tools/test_dockerfile_pid1_reaping.py index e578d8a69fd9..70d95807aa75 100644 --- a/tests/tools/test_dockerfile_pid1_reaping.py +++ b/tests/tools/test_dockerfile_pid1_reaping.py @@ -121,6 +121,20 @@ def test_dockerfile_installs_tui_dependencies(dockerfile_text): ) +def test_dockerfile_preinstalls_gateway_messaging_dependencies(dockerfile_text): + sync_steps = [ + step for step in _run_steps(dockerfile_text) + if "uv sync" in step and "--no-install-project" in step + ] + + assert sync_steps, "Dockerfile must install Python dependencies with uv sync" + assert any("--extra messaging" in step for step in sync_steps), ( + "Published Docker images must preload the [messaging] extra so " + "Telegram/Discord gateway adapters do not depend on first-boot " + "lazy installation (#24698)." + ) + + def test_dockerfile_builds_tui_assets(dockerfile_text): assert any( "ui-tui" in step and "npm" in step and "run build" in step From a2cc30544c8107a3d0610bc9b796e11d05a3f9a8 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 17 May 2026 11:51:36 -0700 Subject: [PATCH 051/418] chore(release): map vaddisrinivas for #26394 salvage --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 2ccdf56aec23..6bb3d2005831 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1161,6 +1161,7 @@ "165905879+davidcampbelldc@users.noreply.github.com": "davidcampbelldc", "hoangv.pham0803@gmail.com": "hehehe0803", # PR #26212 salvage (codex kanban writable root) "26063003+hehehe0803@users.noreply.github.com": "hehehe0803", + "38348871+vaddisrinivas@users.noreply.github.com": "vaddisrinivas", # PR #26394 salvage (Docker messaging extra) } From 73df329214a89eddbd45b0fa84ee99aefa8aea30 Mon Sep 17 00:00:00 2001 From: worlldz <101180447+worlldz@users.noreply.github.com> Date: Fri, 15 May 2026 18:45:02 +0300 Subject: [PATCH 052/418] fix(doctor): flag missing credentials for active openrouter provider --- hermes_cli/doctor.py | 52 ++++++++++++++++++++------------- tests/hermes_cli/test_doctor.py | 42 ++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 21 deletions(-) diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 6f036426fa56..87043bc26115 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -651,31 +651,41 @@ def run_doctor(args): # Check credentials for the configured provider. # Limit to API-key providers in PROVIDER_REGISTRY — other provider - # types (OAuth, SDK, openrouter/anthropic/custom/auto) have their - # own env-var checks elsewhere in doctor, and get_auth_status() - # returns a bare {logged_in: False} for anything it doesn't - # explicitly dispatch, which would produce false positives. - if runtime_provider and runtime_provider not in {"auto", "custom", "openrouter"}: + # types (OAuth, SDK, anthropic/custom/auto) have their own env-var + # checks elsewhere in doctor, and get_auth_status() returns a bare + # {logged_in: False} for anything it doesn't explicitly dispatch, + # which would produce false positives. + if runtime_provider and runtime_provider not in ("auto", "custom"): try: - from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status - pconfig = PROVIDER_REGISTRY.get(runtime_provider) - if pconfig and getattr(pconfig, "auth_type", "") == "api_key": - status = get_auth_status(runtime_provider) or {} + if runtime_provider == "openrouter": + from hermes_cli.config import get_env_value + configured = bool( - status.get("configured") - or status.get("logged_in") - or status.get("api_key") + str(get_env_value("OPENROUTER_API_KEY") or "").strip() + or str(get_env_value("OPENAI_API_KEY") or "").strip() ) - if not configured: - check_fail( - f"model.provider '{runtime_provider}' is set but no API key is configured", - "(check ~/.hermes/.env or run 'hermes setup')", - ) - issues.append( - f"No credentials found for provider '{runtime_provider}'. " - f"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, " - f"or switch providers with 'hermes config set model.provider '" + else: + from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status + + pconfig = PROVIDER_REGISTRY.get(runtime_provider) + configured = True + if pconfig and getattr(pconfig, "auth_type", "") == "api_key": + status = get_auth_status(runtime_provider) or {} + configured = bool( + status.get("configured") + or status.get("logged_in") + or status.get("api_key") ) + if not configured: + check_fail( + f"model.provider '{runtime_provider}' is set but no API key is configured", + "(check ~/.hermes/.env or run 'hermes setup')", + ) + issues.append( + f"No credentials found for provider '{runtime_provider}'. " + f"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, " + f"or switch providers with 'hermes config set model.provider '" + ) except Exception: pass diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py index a5b058fe4529..be8c35239b32 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -477,6 +477,48 @@ def test_run_doctor_accepts_bare_custom_provider(monkeypatch, tmp_path): assert "model.provider 'custom' is not a recognised provider" not in out +def test_run_doctor_flags_missing_credentials_for_active_openrouter_provider(monkeypatch, tmp_path): + home = tmp_path / ".hermes" + home.mkdir(parents=True, exist_ok=True) + (home / "config.yaml").write_text( + "model:\n" + " provider: openrouter\n" + " default: openai/gpt-4.1-mini\n", + encoding="utf-8", + ) + + monkeypatch.setattr(doctor_mod, "HERMES_HOME", home) + monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", tmp_path / "project") + monkeypatch.setattr(doctor_mod, "_DHH", str(home)) + (tmp_path / "project").mkdir(exist_ok=True) + + fake_model_tools = types.SimpleNamespace( + check_tool_availability=lambda *a, **kw: ([], []), + TOOLSET_REQUIREMENTS={}, + ) + monkeypatch.setitem(sys.modules, "model_tools", fake_model_tools) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + try: + from hermes_cli import auth as _auth_mod + + monkeypatch.setattr(_auth_mod, "get_nous_auth_status", lambda: {}) + monkeypatch.setattr(_auth_mod, "get_codex_auth_status", lambda: {}) + monkeypatch.setattr(_auth_mod, "get_gemini_oauth_auth_status", lambda: {}) + monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {}) + except Exception: + pass + + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + doctor_mod.run_doctor(Namespace(fix=False)) + + out = buf.getvalue() + assert "model.provider 'openrouter' is set but no API key is configured" in out + assert "No credentials found for provider 'openrouter'." in out + + @pytest.mark.parametrize( ("provider", "default_model"), [ From 1a82b7a1ff00a389bd39f92f8203793492fd9e5f Mon Sep 17 00:00:00 2001 From: aqilaziz <46887634+aqilaziz@users.noreply.github.com> Date: Sat, 16 May 2026 03:25:01 +0700 Subject: [PATCH 053/418] fix(tests): stabilize xai env and provider parity --- tests/run_agent/test_provider_parity.py | 22 ++++++++++++--- .../test_transcription_dotenv_fallback.py | 27 +++++++++++++++++++ tools/xai_http.py | 12 ++++----- 3 files changed, 50 insertions(+), 11 deletions(-) diff --git a/tests/run_agent/test_provider_parity.py b/tests/run_agent/test_provider_parity.py index c65c22004a9a..cf619ea97433 100644 --- a/tests/run_agent/test_provider_parity.py +++ b/tests/run_agent/test_provider_parity.py @@ -254,8 +254,12 @@ def test_original_messages_not_mutated(self, monkeypatch): assert messages[0]["role"] == "system" def test_developer_role_via_nous_portal(self, monkeypatch): - agent = _make_agent(monkeypatch, "nous", base_url="https://inference-api.nousresearch.com/v1") - agent.model = "gpt-5" + agent = _make_agent( + monkeypatch, + "nous", + base_url="https://inference-api.nousresearch.com/v1", + model="gpt-5", + ) messages = [ {"role": "system", "content": "You are helpful."}, {"role": "user", "content": "hi"}, @@ -346,14 +350,24 @@ def test_includes_tools(self, monkeypatch): class TestBuildApiKwargsNousPortal: def test_includes_nous_product_tags(self, monkeypatch): from agent.portal_tags import nous_portal_tags - agent = _make_agent(monkeypatch, "nous", base_url="https://inference-api.nousresearch.com/v1") + agent = _make_agent( + monkeypatch, + "nous", + base_url="https://inference-api.nousresearch.com/v1", + model="gpt-5", + ) messages = [{"role": "user", "content": "hi"}] kwargs = agent._build_api_kwargs(messages) extra = kwargs.get("extra_body", {}) assert extra.get("tags") == nous_portal_tags() def test_uses_chat_completions_format(self, monkeypatch): - agent = _make_agent(monkeypatch, "nous", base_url="https://inference-api.nousresearch.com/v1") + agent = _make_agent( + monkeypatch, + "nous", + base_url="https://inference-api.nousresearch.com/v1", + model="gpt-5", + ) messages = [{"role": "user", "content": "hi"}] kwargs = agent._build_api_kwargs(messages) assert "messages" in kwargs diff --git a/tests/tools/test_transcription_dotenv_fallback.py b/tests/tools/test_transcription_dotenv_fallback.py index a28c777a8f1a..365b910d4cc0 100644 --- a/tests/tools/test_transcription_dotenv_fallback.py +++ b/tests/tools/test_transcription_dotenv_fallback.py @@ -58,6 +58,33 @@ def test_import_after_config_env_patch_uses_restored_dotenv_loader(self): finally: importlib.reload(tt) + def test_xai_resolver_import_after_config_env_patch_uses_restored_dotenv_loader(self): + """xAI HTTP auth must not cache a temporarily patched env helper.""" + import importlib + import hermes_cli.config as config_mod + from tools import xai_http + + with pytest.MonkeyPatch.context() as mp: + mp.setattr(config_mod, "get_env_value", lambda name, default=None: "") + xai_http = importlib.reload(xai_http) + + try: + with patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + side_effect=RuntimeError("no oauth"), + ), patch( + "hermes_cli.auth.resolve_xai_oauth_runtime_credentials", + return_value={}, + ), patch( + "hermes_cli.config.load_env", + return_value={"XAI_API_KEY": "dotenv-secret"}, + ): + creds = xai_http.resolve_xai_http_credentials() + finally: + importlib.reload(xai_http) + + assert creds["api_key"] == "dotenv-secret" + def test_explicit_groq_sees_dotenv(self): from tools import transcription_tools as tt diff --git a/tools/xai_http.py b/tools/xai_http.py index 216a51ff10db..848ad8fc748b 100644 --- a/tools/xai_http.py +++ b/tools/xai_http.py @@ -5,12 +5,6 @@ import os from typing import Dict -try: - from hermes_cli.config import get_env_value as _hermes_get_env_value -except Exception: - _hermes_get_env_value = None - - def get_env_value(name: str, default=None): """Read ``name`` from ``~/.hermes/.env`` first, then ``os.environ``. @@ -18,10 +12,14 @@ def get_env_value(name: str, default=None): ``tools.xai_http.get_env_value`` to inject dotenv-only secrets into the xAI credential resolver. """ - if _hermes_get_env_value is not None: + try: + from hermes_cli.config import get_env_value as _hermes_get_env_value + value = _hermes_get_env_value(name) if value is not None: return value + except Exception: + pass return os.environ.get(name, default) From bc7c608d54367ff11a10e18b48e82999005c3ea7 Mon Sep 17 00:00:00 2001 From: aqilaziz <46887634+aqilaziz@users.noreply.github.com> Date: Sat, 16 May 2026 05:56:28 +0700 Subject: [PATCH 054/418] fix(gateway): ignore inaccessible service path dirs --- hermes_cli/gateway.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index c5303e32799b..ef57d5ce9fec 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -2110,24 +2110,30 @@ def _build_service_path_dirs(project_root: Path | None = None) -> list[str]: if project_root is None: project_root = PROJECT_ROOT + def _is_dir(path: Path) -> bool: + try: + return path.is_dir() + except OSError: + return False + candidates = [] venv_bin = project_root / "venv" / "bin" - if venv_bin.is_dir(): + if _is_dir(venv_bin): candidates.append(str(venv_bin)) elif sys.prefix != sys.base_prefix: candidates.append(str(Path(sys.prefix) / "bin")) node_bin = project_root / "node_modules" / ".bin" - if node_bin.is_dir(): + if _is_dir(node_bin): candidates.append(str(node_bin)) hermes_home = get_hermes_home() hermes_node = hermes_home / "node" / "bin" - if hermes_node.is_dir(): + if _is_dir(hermes_node): candidates.append(str(hermes_node)) hermes_nm = hermes_home / "node_modules" / ".bin" - if hermes_nm.is_dir(): + if _is_dir(hermes_nm): candidates.append(str(hermes_nm)) return candidates From cb53c40e459f1913d086a3ba942746eb605ec6f5 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Sat, 16 May 2026 23:11:21 +0700 Subject: [PATCH 055/418] fix(xai-oauth): echo code_challenge in token POST so PKCE exchange succeeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xAI's OAuth implementation at ``auth.x.ai`` validates the PKCE ``code_challenge`` at the **token** endpoint, not just at the authorize step. When Hermes sends the standards-compliant token POST with ``code_verifier`` alone — exactly what RFC 7636 §4.5 prescribes — xAI rejects the exchange with ``code_challenge is required`` and the user is stuck with no working OAuth login. The fix: * Extract the token POST into ``_xai_oauth_exchange_code_for_tokens`` so the wire format is unit-testable in isolation. * Send the original ``code_challenge`` and ``code_challenge_method`` in the form body alongside ``code_verifier``. Strict RFC-compliant servers ignore the extras at the token endpoint, and xAI's permissive implementation accepts the exchange. This is the standard "defensive echo" workaround used by every OAuth client that targets a server with this quirk. * Refuse to fire the POST when ``code_verifier`` is empty — leaking the authorization code to a server that can't redeem it is worse than failing locally with an actionable error. The new error code is ``xai_pkce_verifier_missing`` and the message points at this issue for context. * Surface the HTTP status code prominently in the 4xx error message (``xAI token exchange failed (HTTP 400). Response: …``) so users and maintainers can tell a 400 (bad request / PKCE problem) from a 403 (tier denied, see #26847) at a glance instead of parsing the JSON body by eye. Closes #26990 --- hermes_cli/auth.py | 150 ++++++++++++++++++++++++++++++++------------- 1 file changed, 109 insertions(+), 41 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 6752b65829f7..8b154db74681 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -5312,6 +5312,107 @@ def _xai_oauth_build_authorize_url( return f"{authorization_endpoint}?{urlencode(authorize_params)}" +def _xai_oauth_exchange_code_for_tokens( + *, + token_endpoint: str, + code: str, + redirect_uri: str, + code_verifier: str, + code_challenge: str, + timeout_seconds: float = 20.0, +) -> Dict[str, Any]: + """POST the authorization code to xAI's token endpoint and return + the parsed JSON payload. + + Sends ``code_verifier`` as required by RFC 7636 §4.5. Also echoes + ``code_challenge`` + ``code_challenge_method`` in the request body + as a defense-in-depth measure for OAuth servers (xAI's among them, + per #26990) that re-validate the challenge at the token step + instead of relying solely on server-side session state captured + during the authorize step. Echoing the challenge is harmless for + strict RFC-compliant servers — RFC 7636 doesn't forbid additional + parameters at the token endpoint — and decisively fixes the + ``code_challenge is required`` failure mode users hit on the + loopback flow. + + Raises :class:`AuthError` on any non-2xx response or transport + failure; the error message embeds the HTTP status code and the + full response body so users can disambiguate cause at a glance. + """ + # Paranoia: if upstream call sites ever drop ``code_verifier`` we + # want to surface a precise, local error rather than send a + # missing-PKCE request to xAI and receive their generic "code + # challenge required" message back. + if not code_verifier: + raise AuthError( + "xAI token exchange refused locally: PKCE code_verifier is empty. " + "This is a bug in Hermes — please report at " + "https://github.com/NousResearch/hermes-agent/issues/26990.", + provider="xai-oauth", + code="xai_pkce_verifier_missing", + ) + + data = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": XAI_OAUTH_CLIENT_ID, + "code_verifier": code_verifier, + } + # Defense-in-depth: include the original ``code_challenge`` and + # ``code_challenge_method``. Some OAuth servers (including xAI's + # auth.x.ai implementation, per the symptom reported in #26990) + # validate these at the token endpoint instead of relying purely on + # state captured during the authorize step — without them, xAI + # rejects the exchange with ``code_challenge is required`` even + # though we sent a valid ``code_verifier``. + if code_challenge: + data["code_challenge"] = code_challenge + data["code_challenge_method"] = "S256" + + try: + response = httpx.post( + token_endpoint, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + }, + data=data, + timeout=max(20.0, timeout_seconds), + ) + except Exception as exc: + raise AuthError( + f"xAI token exchange failed: {exc}", + provider="xai-oauth", + code="xai_token_exchange_failed", + ) from exc + + if response.status_code != 200: + body = response.text.strip() + raise AuthError( + f"xAI token exchange failed (HTTP {response.status_code})." + + (f" Response: {body}" if body else ""), + provider="xai-oauth", + code="xai_token_exchange_failed", + ) + + try: + payload = response.json() + except Exception as exc: + raise AuthError( + f"xAI token exchange returned invalid JSON: {exc}", + provider="xai-oauth", + code="xai_token_exchange_invalid", + ) from exc + if not isinstance(payload, dict): + raise AuthError( + "xAI token exchange response was not a JSON object.", + provider="xai-oauth", + code="xai_token_exchange_invalid", + ) + return payload + + def _xai_oauth_loopback_login( *, timeout_seconds: float = 20.0, @@ -5392,47 +5493,14 @@ def _xai_oauth_loopback_login( code="xai_code_missing", ) - try: - response = httpx.post( - token_endpoint, - headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}, - data={ - "grant_type": "authorization_code", - "code": code, - "redirect_uri": redirect_uri, - "client_id": XAI_OAUTH_CLIENT_ID, - "code_verifier": code_verifier, - }, - timeout=max(20.0, timeout_seconds), - ) - except Exception as exc: - raise AuthError( - f"xAI token exchange failed: {exc}", - provider="xai-oauth", - code="xai_token_exchange_failed", - ) from exc - if response.status_code != 200: - detail = response.text.strip() - raise AuthError( - "xAI token exchange failed." - + (f" Response: {detail}" if detail else ""), - provider="xai-oauth", - code="xai_token_exchange_failed", - ) - try: - payload = response.json() - except Exception as exc: - raise AuthError( - f"xAI token exchange returned invalid JSON: {exc}", - provider="xai-oauth", - code="xai_token_exchange_invalid", - ) from exc - if not isinstance(payload, dict): - raise AuthError( - "xAI token exchange response was not a JSON object.", - provider="xai-oauth", - code="xai_token_exchange_invalid", - ) + payload = _xai_oauth_exchange_code_for_tokens( + token_endpoint=token_endpoint, + code=code, + redirect_uri=redirect_uri, + code_verifier=code_verifier, + code_challenge=code_challenge, + timeout_seconds=timeout_seconds, + ) access_token = str(payload.get("access_token", "") or "").strip() refresh_token = str(payload.get("refresh_token", "") or "").strip() if not access_token: From e3f7ff1123fc8e0dc156807fb0935c89f613d6f4 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Sat, 16 May 2026 23:11:34 +0700 Subject: [PATCH 056/418] test(xai-oauth): pin PKCE token-exchange wire format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 14 focused tests on the extracted helper ``_xai_oauth_exchange_code_for_tokens`` cover: Core contract: * ``code_verifier`` is on the wire (RFC 7636 §4.5). * ``code_challenge`` + ``code_challenge_method=S256`` are echoed (the #26990 defense-in-depth that makes xAI's token endpoint stop rejecting valid exchanges). * ``grant_type=authorization_code``, ``code``, ``redirect_uri``, and ``client_id`` are all locked. * Content-Type is ``application/x-www-form-urlencoded`` (xAI rejects ``application/json`` on this endpoint). * The supplied ``token_endpoint`` URL is used verbatim — no hard-coded constant sneaks in via a future refactor. * ``timeout_seconds`` is forwarded; floored at 20s. Sanity guard: * Empty ``code_verifier`` raises ``xai_pkce_verifier_missing`` with a link to #26990 — and NOTHING is sent. Leaking the auth code to a server that can't redeem it is the wrong failure mode. * Empty ``code_challenge`` omits only the defensive echo; the standards-compliant ``code_verifier`` request still goes out so RFC-compliant servers keep working. Error surfacing: * Non-200 responses include both ``HTTP `` and the body verbatim — disambiguates 400 (PKCE / bad request) from 403 (tier denied, see #26847). * Transport errors are wrapped as ``AuthError`` with the ``xai_token_exchange_failed`` code, so the surrounding ``format_auth_error`` UI mapping still fires. * Non-dict JSON payloads raise ``xai_token_exchange_invalid``. * 200 happy path returns the parsed payload dict verbatim. End-to-end wire-format guard: * A real ``httpx.Client`` with a stub transport captures the bytes on the wire and asserts every PKCE field round-trips through ``urlencode``. Catches a future refactor that swaps ``data=`` for ``json=`` (which xAI would silently reject). --- .../test_xai_oauth_pkce_token_exchange.py | 359 ++++++++++++++++++ 1 file changed, 359 insertions(+) create mode 100644 tests/hermes_cli/test_xai_oauth_pkce_token_exchange.py diff --git a/tests/hermes_cli/test_xai_oauth_pkce_token_exchange.py b/tests/hermes_cli/test_xai_oauth_pkce_token_exchange.py new file mode 100644 index 000000000000..98b81ff140e7 --- /dev/null +++ b/tests/hermes_cli/test_xai_oauth_pkce_token_exchange.py @@ -0,0 +1,359 @@ +"""Regression coverage for xAI OAuth PKCE token exchange (issue #26990). + +Issue [#26990] reported that ``hermes auth add xai-oauth`` succeeds at the +browser-side authorize step but fails at the token endpoint with +``code_challenge is required`` — the symptom of an OAuth server that +re-validates PKCE at the token step instead of relying purely on +state captured during the authorize redirect. + +The fix in ``hermes_cli/auth.py`` extracts the token POST into +:func:`_xai_oauth_exchange_code_for_tokens` and: + +* Sends ``code_verifier`` (RFC 7636 §4.5 requirement). +* **Also** echoes ``code_challenge`` and ``code_challenge_method`` + in the request body as defense-in-depth — strictly compliant + servers ignore extras at the token endpoint, but xAI's server + needs them. +* Refuses to fire the POST locally when ``code_verifier`` is empty + (avoids leaking the auth code to a server that can't redeem it). +* Surfaces the HTTP status code prominently in the error message so + users / maintainers can tell a 400 (bad request) from a 403 + (entitlement denied) at a glance. + +These tests pin all three behaviors so the fix can't silently regress. +""" + +from __future__ import annotations + +from typing import Any, Dict, List +from urllib.parse import parse_qs + +import httpx +import pytest + +from hermes_cli.auth import ( + AuthError, + XAI_OAUTH_CLIENT_ID, + _xai_oauth_exchange_code_for_tokens, +) + + +# --------------------------------------------------------------------------- +# httpx.post recorder +# --------------------------------------------------------------------------- + + +class _PostRecorder: + """Capture every ``httpx.post`` call without touching the network.""" + + def __init__(self, response: httpx.Response) -> None: + self.response = response + self.calls: List[Dict[str, Any]] = [] + + def __call__(self, url, *, headers=None, data=None, timeout=None, **kw): + self.calls.append( + {"url": url, "headers": headers or {}, "data": data or {}, + "timeout": timeout, "extra": kw} + ) + return self.response + + +def _ok_response(payload: dict) -> httpx.Response: + return httpx.Response(200, json=payload) + + +def _err_response(status: int, body: str) -> httpx.Response: + return httpx.Response(status, text=body) + + +@pytest.fixture +def post_recorder(monkeypatch): + """Default: 200 response with a full xAI token payload.""" + recorder = _PostRecorder( + _ok_response( + { + "access_token": "AT-fresh", + "refresh_token": "RT-fresh", + "id_token": "ID", + "expires_in": 3600, + "token_type": "Bearer", + } + ) + ) + monkeypatch.setattr("hermes_cli.auth.httpx.post", recorder) + return recorder + + +# --------------------------------------------------------------------------- +# Core contract: which fields go on the wire? +# --------------------------------------------------------------------------- + + +def test_token_exchange_includes_code_verifier(post_recorder): + """RFC 7636 §4.5 — ``code_verifier`` MUST be sent.""" + _xai_oauth_exchange_code_for_tokens( + token_endpoint="https://auth.x.ai/oauth2/token", + code="AUTHCODE", + redirect_uri="http://127.0.0.1:56121/callback", + code_verifier="theVerifier_43_to_128_chars_____________________", + code_challenge="aBcDeF", + ) + sent = post_recorder.calls[-1]["data"] + assert sent["code_verifier"] == "theVerifier_43_to_128_chars_____________________" + + +def test_token_exchange_also_echoes_code_challenge_for_xai(post_recorder): + """Defense-in-depth for #26990 — xAI re-validates the challenge + at the token endpoint, not just at authorize. Without this echo + we get ``code_challenge is required`` even though we send a valid + ``code_verifier``.""" + _xai_oauth_exchange_code_for_tokens( + token_endpoint="https://auth.x.ai/oauth2/token", + code="AUTHCODE", + redirect_uri="http://127.0.0.1:56121/callback", + code_verifier="v" * 64, + code_challenge="aBcDeF", + ) + sent = post_recorder.calls[-1]["data"] + assert sent["code_challenge"] == "aBcDeF" + assert sent["code_challenge_method"] == "S256" + + +def test_token_exchange_uses_correct_grant_and_client(post_recorder): + """Lock the static fields too — a future refactor must not flip + these to ``client_credentials`` or drop ``client_id``.""" + _xai_oauth_exchange_code_for_tokens( + token_endpoint="https://auth.x.ai/oauth2/token", + code="AUTHCODE", + redirect_uri="http://127.0.0.1:56121/callback", + code_verifier="v" * 64, + code_challenge="c" * 43, + ) + sent = post_recorder.calls[-1]["data"] + assert sent["grant_type"] == "authorization_code" + assert sent["code"] == "AUTHCODE" + assert sent["redirect_uri"] == "http://127.0.0.1:56121/callback" + assert sent["client_id"] == XAI_OAUTH_CLIENT_ID + + +def test_token_exchange_uses_form_urlencoded_content_type(post_recorder): + """xAI's token endpoint expects ``application/x-www-form-urlencoded``.""" + _xai_oauth_exchange_code_for_tokens( + token_endpoint="https://auth.x.ai/oauth2/token", + code="AUTHCODE", + redirect_uri="http://127.0.0.1:56121/callback", + code_verifier="v" * 64, + code_challenge="c" * 43, + ) + headers = post_recorder.calls[-1]["headers"] + assert headers["Content-Type"] == "application/x-www-form-urlencoded" + assert headers["Accept"] == "application/json" + + +def test_token_exchange_targets_the_supplied_endpoint(post_recorder): + """Some test fixtures sniff the discovered token endpoint dynamically. + We must POST to the URL the caller passed, not a hard-coded constant.""" + _xai_oauth_exchange_code_for_tokens( + token_endpoint="https://auth.x.ai/some/other/token/path", + code="AUTHCODE", + redirect_uri="http://127.0.0.1:56121/callback", + code_verifier="v" * 64, + code_challenge="c" * 43, + ) + assert post_recorder.calls[-1]["url"] == "https://auth.x.ai/some/other/token/path" + + +def test_token_exchange_passes_timeout_through(post_recorder): + """Operators on slow networks pass a higher ``timeout_seconds``; + the helper must forward it (and bump the floor to 20s).""" + _xai_oauth_exchange_code_for_tokens( + token_endpoint="https://auth.x.ai/oauth2/token", + code="AUTHCODE", + redirect_uri="http://127.0.0.1:56121/callback", + code_verifier="v" * 64, + code_challenge="c" * 43, + timeout_seconds=45.0, + ) + assert post_recorder.calls[-1]["timeout"] == 45.0 + + +def test_token_exchange_floor_timeout_is_20s(post_recorder): + _xai_oauth_exchange_code_for_tokens( + token_endpoint="https://auth.x.ai/oauth2/token", + code="AUTHCODE", + redirect_uri="http://127.0.0.1:56121/callback", + code_verifier="v" * 64, + code_challenge="c" * 43, + timeout_seconds=2.0, + ) + assert post_recorder.calls[-1]["timeout"] == 20.0 + + +# --------------------------------------------------------------------------- +# Sanity guard: refuse to POST with an empty code_verifier +# --------------------------------------------------------------------------- + + +def test_empty_code_verifier_raises_without_posting(post_recorder): + """If ``code_verifier`` is somehow lost upstream, we must refuse to + send the request — leaking an authorization code to xAI without a + verifier is worse than failing locally with an actionable error.""" + with pytest.raises(AuthError) as exc_info: + _xai_oauth_exchange_code_for_tokens( + token_endpoint="https://auth.x.ai/oauth2/token", + code="AUTHCODE", + redirect_uri="http://127.0.0.1:56121/callback", + code_verifier="", + code_challenge="c" * 43, + ) + assert exc_info.value.code == "xai_pkce_verifier_missing" + assert "26990" in str(exc_info.value) + # And critically: nothing was sent. + assert post_recorder.calls == [] + + +def test_missing_code_challenge_omits_echo_but_still_sends_verifier(post_recorder): + """``code_challenge`` is defensive — if a caller doesn't have it + handy, we must still send the standards-compliant request rather + than refusing. This keeps RFC-compliant servers happy.""" + _xai_oauth_exchange_code_for_tokens( + token_endpoint="https://auth.x.ai/oauth2/token", + code="AUTHCODE", + redirect_uri="http://127.0.0.1:56121/callback", + code_verifier="v" * 64, + code_challenge="", + ) + sent = post_recorder.calls[-1]["data"] + assert sent["code_verifier"] == "v" * 64 + assert "code_challenge" not in sent + assert "code_challenge_method" not in sent + + +# --------------------------------------------------------------------------- +# Error surfacing +# --------------------------------------------------------------------------- + + +def test_non_200_response_surfaces_status_and_body(monkeypatch): + """When xAI returns a 4xx, the operator needs both the HTTP status + code (to tell 400 from 401 from 403 at a glance) and the response + body (the actual server-side reason).""" + recorder = _PostRecorder( + _err_response(400, '{"error":"invalid_grant","error_description":"code_challenge is required"}') + ) + monkeypatch.setattr("hermes_cli.auth.httpx.post", recorder) + with pytest.raises(AuthError) as exc_info: + _xai_oauth_exchange_code_for_tokens( + token_endpoint="https://auth.x.ai/oauth2/token", + code="AUTHCODE", + redirect_uri="http://127.0.0.1:56121/callback", + code_verifier="v" * 64, + code_challenge="c" * 43, + ) + msg = str(exc_info.value) + assert "HTTP 400" in msg, ( + "Status code must be in the error so callers can disambiguate " + "tier-denied (403) from bad-request (400) without inspecting " + "exc.code." + ) + assert "code_challenge is required" in msg + assert exc_info.value.code == "xai_token_exchange_failed" + + +def test_transport_error_wraps_as_auth_error(monkeypatch): + """A connection failure must come back as ``AuthError`` so the + surrounding ``format_auth_error`` UI mapping fires correctly.""" + + def _boom(*args, **kwargs): + raise httpx.ConnectError("dns failure") + + monkeypatch.setattr("hermes_cli.auth.httpx.post", _boom) + with pytest.raises(AuthError) as exc_info: + _xai_oauth_exchange_code_for_tokens( + token_endpoint="https://auth.x.ai/oauth2/token", + code="AUTHCODE", + redirect_uri="http://127.0.0.1:56121/callback", + code_verifier="v" * 64, + code_challenge="c" * 43, + ) + assert exc_info.value.code == "xai_token_exchange_failed" + assert "dns failure" in str(exc_info.value) + + +def test_non_dict_payload_raises_invalid_json(monkeypatch): + """xAI returning ``[]`` or a string at 200 is a server bug — fail + with a precise error rather than crashing later in token storage.""" + recorder = _PostRecorder(_ok_response([1, 2, 3])) # type: ignore[arg-type] + monkeypatch.setattr("hermes_cli.auth.httpx.post", recorder) + with pytest.raises(AuthError) as exc_info: + _xai_oauth_exchange_code_for_tokens( + token_endpoint="https://auth.x.ai/oauth2/token", + code="AUTHCODE", + redirect_uri="http://127.0.0.1:56121/callback", + code_verifier="v" * 64, + code_challenge="c" * 43, + ) + assert exc_info.value.code == "xai_token_exchange_invalid" + + +def test_success_returns_full_payload_dict(post_recorder): + """200 happy path: the parsed JSON dict comes back verbatim so the + caller can pluck ``access_token`` / ``refresh_token`` etc.""" + out = _xai_oauth_exchange_code_for_tokens( + token_endpoint="https://auth.x.ai/oauth2/token", + code="AUTHCODE", + redirect_uri="http://127.0.0.1:56121/callback", + code_verifier="v" * 64, + code_challenge="c" * 43, + ) + assert out["access_token"] == "AT-fresh" + assert out["refresh_token"] == "RT-fresh" + + +# --------------------------------------------------------------------------- +# Wire-format guard: httpx must serialise ``data`` as form-urlencoded +# --------------------------------------------------------------------------- + + +def test_wire_format_is_form_urlencoded_with_all_pkce_fields(monkeypatch): + """End-to-end check on the actual bytes httpx puts on the wire. + If anyone ever swaps ``data=`` for ``json=`` or refactors the dict, + xAI will start rejecting again — this catches it locally.""" + + captured: Dict[str, Any] = {} + + class _Transport(httpx.BaseTransport): + def handle_request(self, request): + captured["body"] = bytes(request.read()) + captured["content_type"] = request.headers.get("content-type", "") + return httpx.Response( + 200, + json={"access_token": "AT", "refresh_token": "RT", + "id_token": "", "expires_in": 60, "token_type": "Bearer"}, + ) + + real_post = httpx.post + + def _post(*args, **kwargs): + with httpx.Client(transport=_Transport()) as c: + return c.post(*args, **kwargs) + + monkeypatch.setattr("hermes_cli.auth.httpx.post", _post) + + _xai_oauth_exchange_code_for_tokens( + token_endpoint="https://auth.x.ai/oauth2/token", + code="AUTHCODE", + redirect_uri="http://127.0.0.1:56121/callback", + code_verifier="theVerifier_43+", + code_challenge="theChallenge_43+", + ) + + assert "application/x-www-form-urlencoded" in captured["content_type"] + parsed = parse_qs(captured["body"].decode()) + assert parsed["grant_type"] == ["authorization_code"] + assert parsed["code"] == ["AUTHCODE"] + assert parsed["redirect_uri"] == ["http://127.0.0.1:56121/callback"] + assert parsed["client_id"] == [XAI_OAUTH_CLIENT_ID] + assert parsed["code_verifier"] == ["theVerifier_43+"] + assert parsed["code_challenge"] == ["theChallenge_43+"] + assert parsed["code_challenge_method"] == ["S256"] From 822e92edb313193494d397064f9d3a8572a74b63 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 17 May 2026 12:44:11 -0700 Subject: [PATCH 057/418] fix(aux): default OpenRouter auxiliary to gemini-3-flash-preview --- agent/auxiliary_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index e02fa1911f7f..a7fcd311f118 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -424,7 +424,7 @@ def _nous_extra_body() -> dict: auxiliary_is_nous: bool = False # Default auxiliary models per provider -_OPENROUTER_MODEL = "google/gemini-2.5-flash" +_OPENROUTER_MODEL = "google/gemini-3-flash-preview" _NOUS_MODEL = "google/gemini-3-flash-preview" _NOUS_DEFAULT_BASE_URL = "https://inference-api.nousresearch.com/v1" _ANTHROPIC_DEFAULT_BASE_URL = "https://api.anthropic.com" From e66a3e86efbc9e428bb5ace45501d6f6ac92d36e Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 17 May 2026 12:44:11 -0700 Subject: [PATCH 058/418] chore(acp): bump registry manifest to 0.14.0 matching pyproject --- acp_registry/agent.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/acp_registry/agent.json b/acp_registry/agent.json index b94a48e089fd..b23d1642a944 100644 --- a/acp_registry/agent.json +++ b/acp_registry/agent.json @@ -1,7 +1,7 @@ { "id": "hermes-agent", "name": "Hermes Agent", - "version": "0.13.0", + "version": "0.14.0", "description": "Self-improving open-source AI agent by Nous Research with ACP editor integration, persistent memory, skills, and rich tool support.", "repository": "https://github.com/NousResearch/hermes-agent", "website": "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp", @@ -9,7 +9,7 @@ "license": "MIT", "distribution": { "uvx": { - "package": "hermes-agent[acp]==0.13.0", + "package": "hermes-agent[acp]==0.14.0", "args": ["hermes-acp"] } } From 06924e827cb8184a933899dbb365b1cb51c9eaa2 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 17 May 2026 12:44:11 -0700 Subject: [PATCH 059/418] test(gateway): accept trust_env in fake aiohttp ClientSession lambdas --- tests/gateway/test_google_chat.py | 2 +- tests/gateway/test_teams.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/gateway/test_google_chat.py b/tests/gateway/test_google_chat.py index 3f093bcea1d3..9d36945a357a 100644 --- a/tests/gateway/test_google_chat.py +++ b/tests/gateway/test_google_chat.py @@ -2740,7 +2740,7 @@ def post(self, url, **kwargs): def _install_fake_aiohttp(monkeypatch, session): fake_aiohttp = types.SimpleNamespace( - ClientSession=lambda timeout=None: session, + ClientSession=lambda timeout=None, **kwargs: session, ClientTimeout=lambda total=None: None, ) monkeypatch.setitem(sys.modules, "aiohttp", fake_aiohttp) diff --git a/tests/gateway/test_teams.py b/tests/gateway/test_teams.py index 58b8c35a5c25..6c7173fe9318 100644 --- a/tests/gateway/test_teams.py +++ b/tests/gateway/test_teams.py @@ -763,7 +763,7 @@ def _install_fake_aiohttp(monkeypatch, session): """Replace ``aiohttp`` in ``sys.modules`` so ``import aiohttp as _aiohttp`` inside ``_standalone_send`` picks up our fake.""" fake_aiohttp = types.SimpleNamespace( - ClientSession=lambda timeout=None: session, + ClientSession=lambda timeout=None, **kwargs: session, ClientTimeout=lambda total=None: None, ) monkeypatch.setitem(sys.modules, "aiohttp", fake_aiohttp) From dfc6ea72c16ee971ac1d6f4b3118cd8cc1f47a7d Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 17 May 2026 12:44:11 -0700 Subject: [PATCH 060/418] test(gateway): include direct_messages_topic_id in telegram DM metadata assertions --- tests/gateway/test_background_command.py | 1 + tests/gateway/test_telegram_thread_fallback.py | 1 + tests/gateway/test_voice_command.py | 1 + 3 files changed, 3 insertions(+) diff --git a/tests/gateway/test_background_command.py b/tests/gateway/test_background_command.py index 9c156960c70e..9e0d71921cd4 100644 --- a/tests/gateway/test_background_command.py +++ b/tests/gateway/test_background_command.py @@ -316,6 +316,7 @@ async def test_telegram_dm_topic_completion_preserves_reply_anchor_metadata(self assert mock_adapter.send.call_args.kwargs["metadata"] == { "thread_id": "20197", "telegram_dm_topic_reply_fallback": True, + "direct_messages_topic_id": "20197", "telegram_reply_to_message_id": "463", } diff --git a/tests/gateway/test_telegram_thread_fallback.py b/tests/gateway/test_telegram_thread_fallback.py index f310d017946a..f46997f0b926 100644 --- a/tests/gateway/test_telegram_thread_fallback.py +++ b/tests/gateway/test_telegram_thread_fallback.py @@ -408,6 +408,7 @@ def get_activity_summary(self): assert adapter.calls[0]["metadata"] == { "thread_id": "20197", "telegram_dm_topic_reply_fallback": True, + "direct_messages_topic_id": "20197", "telegram_reply_to_message_id": "463", } diff --git a/tests/gateway/test_voice_command.py b/tests/gateway/test_voice_command.py index a877730dcec5..d792a48e0cf0 100644 --- a/tests/gateway/test_voice_command.py +++ b/tests/gateway/test_voice_command.py @@ -461,6 +461,7 @@ async def test_auto_voice_reply_uses_thread_metadata_helper(self, runner): assert call_kwargs["metadata"] == { "thread_id": "20197", "telegram_dm_topic_reply_fallback": True, + "direct_messages_topic_id": "20197", "telegram_reply_to_message_id": "462", } From f27416dc80b2419b0a1dc7c3197077fe3e27e311 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 17 May 2026 12:44:12 -0700 Subject: [PATCH 061/418] fix(cli): include send in _BUILTIN_SUBCOMMANDS for plugin discovery gating --- hermes_cli/main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 662bc57b78de..6ea8dd122fcf 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -9631,7 +9631,8 @@ def _build_provider_choices() -> list[str]: "config", "cron", "curator", "dashboard", "debug", "doctor", "dump", "fallback", "gateway", "hooks", "import", "insights", "kanban", "login", "logout", "logs", "lsp", "mcp", "memory", - "model", "pairing", "plugins", "postinstall", "profile", "proxy", "sessions", "setup", + "model", "pairing", "plugins", "postinstall", "profile", "proxy", + "send", "sessions", "setup", "skills", "slack", "status", "tools", "uninstall", "update", "version", "webhook", "whatsapp", "chat", # Help-ish invocations — plugin commands not being listed in From bfcab25dcdb07e639b72cdabe473cbb42edad241 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 17 May 2026 12:44:12 -0700 Subject: [PATCH 062/418] test(tools_config): align post_setup parametrize with current browser provider catalog --- tests/hermes_cli/test_tools_config.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index 89dc33258a06..787292d83a44 100644 --- a/tests/hermes_cli/test_tools_config.py +++ b/tests/hermes_cli/test_tools_config.py @@ -1048,9 +1048,6 @@ def test_reconfigure_browser_provider_overwrites_stale_use_gateway(): @pytest.mark.parametrize("provider_name,post_setup_key", [ - ("Browserbase", "agent_browser"), - ("Browser Use", "agent_browser"), - ("Firecrawl", "agent_browser"), ("Camofox", "camofox"), ]) def test_reconfigure_provider_runs_post_setup_for_env_var_providers( From 0b491c466a9493a1522bcdaaa3f7ead96dffe2d2 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 17 May 2026 12:49:38 -0700 Subject: [PATCH 063/418] fix(model_switch): preserve explicit custom-provider model list when no api_key --- hermes_cli/model_switch.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index a5d299165fcb..727905270e1a 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -1692,7 +1692,22 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool: # Ollama servers) — the /models endpoint often works without # auth. The CLI's _model_flow_named_custom always probes, so # the Telegram/Discord picker should do the same for parity. - if api_url: + # Live-discovery policy: + # - With an api_key, the user has explicitly opted into the + # endpoint and live /models is the source of truth — replace + # the (possibly partial) ``models:`` subset configured for + # context-length overrides with the full live catalog. + # This is the Bifrost / aggregator-gateway case. + # - Without an api_key but with an explicit ``models:`` list + # (or top-level ``model:``), the user is narrowing a public + # endpoint to a specific subset (e.g. ollama.com /v1/models + # returns 35 models but the user only wants 4). Preserve the + # explicit list and skip live discovery. + # - Without an api_key AND no explicit models, fall through to + # live discovery so bare-endpoint custom providers (local + # llama.cpp / Ollama servers) still appear populated. + should_probe = bool(api_url) and (bool(api_key) or not grp["models"]) + if should_probe: try: from hermes_cli.models import fetch_api_models From af7b38d78e6f3c37fc7e4a7b3a867b7c8b7ec96d Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 17 May 2026 12:49:38 -0700 Subject: [PATCH 064/418] =?UTF-8?q?test(voice=5Fcli):=20drop=20stale=20?= =?UTF-8?q?=E2=89=A51=20requirement=20for=20force=3DTrue=20error=20=5Fvpri?= =?UTF-8?q?nt=20calls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/tools/test_voice_cli_integration.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/tools/test_voice_cli_integration.py b/tests/tools/test_voice_cli_integration.py index 93dffa649a7b..a6cf5e36627c 100644 --- a/tests/tools/test_voice_cli_integration.py +++ b/tests/tools/test_voice_cli_integration.py @@ -482,8 +482,11 @@ def test_error_messages_use_force_in_run_agent(self): else: unforced_error_count += 1 - assert forced_error_count > 0, \ - "Expected at least one _vprint with force=True for error messages" + # Invariant: no critical-error _vprint call may silently drop under + # streaming suppression — every ❌-prefixed _vprint must pass force=True. + # The codebase may legitimately have zero such calls if errors are + # routed through print() or higher-level Rich panels; what matters is + # that none are quietly suppressed. assert unforced_error_count == 0, \ f"Found {unforced_error_count} critical error _vprint calls without force=True" From 532b209f01b8c70a8dbb75b580b4fc673488ec5d Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 17 May 2026 12:49:38 -0700 Subject: [PATCH 065/418] fix(run_agent): scope kimi tool-reasoning trigger to host, not model name substring --- run_agent.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/run_agent.py b/run_agent.py index f25c94f17a94..6e9877a1182e 100644 --- a/run_agent.py +++ b/run_agent.py @@ -3601,15 +3601,17 @@ def _needs_kimi_tool_reasoning(self) -> bool: ``reasoning_content`` on every assistant tool-call message; omitting it causes the next replay to fail with HTTP 400. - Also detects Kimi models served through third-party providers (e.g. - ollama-cloud) by matching ``kimi`` in the model name. + Detection is host-driven, not model-name-driven: aggregators like + OpenRouter that re-export Kimi/Moonshot models speak their own + protocol and reject ``reasoning_content`` echoes. We only enable the + kimi-reasoning replay when the request actually targets a + kimi/moonshot endpoint or the dedicated kimi-coding provider. """ return ( self.provider in {"kimi-coding", "kimi-coding-cn"} or base_url_host_matches(self.base_url, "api.kimi.com") or base_url_host_matches(self.base_url, "moonshot.ai") or base_url_host_matches(self.base_url, "moonshot.cn") - or "kimi" in (self.model or "").lower() ) def _needs_deepseek_tool_reasoning(self) -> bool: From 2551f0813097e2251a19e9281c0f13de898c3798 Mon Sep 17 00:00:00 2001 From: zccyman Date: Sun, 17 May 2026 12:42:06 -0700 Subject: [PATCH 066/418] fix(schema_sanitizer): strip pattern/format from Responses-format tools for xAI compatibility xAI's /responses endpoint rejects pattern and format JSON Schema keywords in tool schemas with HTTP 400 'Invalid arguments passed to the model'. The existing strip_pattern_and_format() only walked OpenAI-format tools ({'function': {'parameters': ...}}), missing Responses-format shapes ({'name': ..., 'parameters': ...}) used by codex_responses API mode. This shows up most often with MCP-derived tools that carry validation keywords (e.g. domain pattern regex in firecrawl, format: date-time) through to the wire. Extends the walk to handle both shapes. Auto-strip wiring is applied separately in chat_completion_helpers (post-refactor location). Closes #27197 --- tests/tools/test_schema_sanitizer.py | 131 +++++++++++++++++++++++++++ tools/schema_sanitizer.py | 14 ++- 2 files changed, 144 insertions(+), 1 deletion(-) diff --git a/tests/tools/test_schema_sanitizer.py b/tests/tools/test_schema_sanitizer.py index 89fbcd91d2b1..8c865e87b8dc 100644 --- a/tests/tools/test_schema_sanitizer.py +++ b/tests/tools/test_schema_sanitizer.py @@ -304,6 +304,30 @@ def test_strip_none_returns_zero(): assert stripped == 0 + +def test_strip_responses_format_strips_format_keyword(): + """Responses-format: keyword should be stripped.""" + from tools.schema_sanitizer import strip_pattern_and_format + + tools = [ + { + "name": "get_event", + "parameters": { + "type": "object", + "properties": { + "ts": {"type": "string", "format": "date-time"}, + } + }, + "type": "function" + } + ] + + result, stripped = strip_pattern_and_format(tools) + assert stripped == 1, f"Expected 1 format stripped, got {stripped}" + assert "format" not in result[0]["parameters"]["properties"]["ts"], "format should be stripped" + assert result[0]["parameters"]["properties"]["ts"]["type"] == "string", "type should be preserved" + + def test_top_level_allof_stripped_for_codex_backend_compat(): """OpenAI Codex backend rejects top-level allOf/oneOf/anyOf/enum/not.""" tools = [_tool("memory", { @@ -360,3 +384,110 @@ def test_nested_allof_preserved(): nested = out[0]["function"]["parameters"]["properties"]["config"] assert "allOf" in nested assert nested["allOf"] == [{"required": ["mode"]}] + + +def test_strip_responses_format_tools(): + """strip_pattern_and_format should handle Responses-format tools (no function wrapper).""" + from tools.schema_sanitizer import strip_pattern_and_format + + # Responses-format: {"name": "...", "parameters": {...}, "type": "function"} + tools = [ + { + "name": "mcp_firecrawl_search", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string"}, + "includeDomains": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$" + } + } + } + }, + "type": "function" + } + ] + + result, stripped = strip_pattern_and_format(tools) + assert stripped == 1, f"Expected 1 pattern stripped, got {stripped}" + + # Verify pattern keyword was removed from includeDomains + domains = result[0]["parameters"]["properties"]["includeDomains"]["items"] + assert "pattern" not in domains, f"pattern should be stripped: {domains}" + assert domains["type"] == "string", "type should be preserved" + + +def test_strip_responses_idempotent(): + """Second call on already-stripped Responses-format tools should return 0.""" + from tools.schema_sanitizer import strip_pattern_and_format + + tools = [ + { + "name": "search_files", + "parameters": { + "type": "object", + "properties": { + "pattern": {"type": "string"} # This is a property named pattern, NOT schema keyword + } + } + } + ] + + # Pass 1 - property named 'pattern' should NOT be stripped + result, first = strip_pattern_and_format(tools) + assert first == 0, f"Expected 0 stripped (property pattern preserved), got {first}" + assert "pattern" in result[0]["parameters"]["properties"], "property named pattern should survive" + + # Pass 2 - idempotent + _, second = strip_pattern_and_format(tools) + assert second == 0, f"Expected 0 on second pass, got {second}" + + +def test_strip_responses_mixed_formats(): + """Mixed list of OpenAI-format and Responses-format tools should both be sanitized.""" + from tools.schema_sanitizer import strip_pattern_and_format + + tools = [ + # OpenAI-format: {"function": {"parameters": {...}}} + { + "type": "function", + "function": { + "name": "search", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "pattern": "^[a-z]+$"} + } + } + } + }, + # Responses-format: {"name": "...", "parameters": {...}} + { + "name": "get_time", + "parameters": { + "type": "object", + "properties": { + "tz": {"type": "string", "format": "date-time"} + } + }, + "type": "function" + } + ] + + result, stripped = strip_pattern_and_format(tools) + assert stripped == 2, f"Expected 2 stripped (1 pattern + 1 format), got {stripped}" + + # OpenAI-format tool: pattern stripped from parameters + openai_params = result[0]["function"]["parameters"]["properties"]["query"] + assert "pattern" not in openai_params, f"pattern should be stripped: {openai_params}" + + # Responses-format tool: format stripped + resp_params = result[1]["parameters"]["properties"]["tz"] + assert "format" not in resp_params, f"format should be stripped: {resp_params}" + + # Verify structure preserved + assert result[0]["function"]["parameters"]["type"] == "object" + assert result[1]["parameters"]["type"] == "object" diff --git a/tools/schema_sanitizer.py b/tools/schema_sanitizer.py index 87587c7fed5b..0d03998d366a 100644 --- a/tools/schema_sanitizer.py +++ b/tools/schema_sanitizer.py @@ -355,11 +355,23 @@ def _walk(node: Any) -> None: _walk(item) for tool in tools: - fn = tool.get("function") if isinstance(tool, dict) else None + if not isinstance(tool, dict): + continue + + # OpenAI-format: {"function": {"parameters": {...}}} + fn = tool.get("function") if isinstance(fn, dict): params = fn.get("parameters") if isinstance(params, dict): _walk(params) + continue + + # Responses-format: {"name": "...", "parameters": {...}} + # (used by codex_responses API mode — xAI, OpenAI Codex, etc.) + params = tool.get("parameters") + if isinstance(params, dict): + _walk(params) + continue if stripped: logger.info( From bdc2113b5cdd37cedc033547f0361acbc326fd34 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 17 May 2026 12:42:13 -0700 Subject: [PATCH 067/418] fix(xai): wire schema sanitizer into post-refactor build_api_kwargs Port of the run_agent.py changes from #27219 to current main: the _build_api_kwargs body was extracted into agent/chat_completion_helpers. build_api_kwargs, so wire the xAI tool-schema sanitization there (provider in {'xai', 'xai-oauth'} or base_url=api.x.ai). Logs a warning instead of silently swallowing exceptions, matching the contributor's review-followup fix. Co-authored-by: zccyman --- agent/chat_completion_helpers.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index e536db95eb16..ee5b957bf2fe 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -286,6 +286,21 @@ def build_api_kwargs(agent, api_messages: list) -> dict: ) is_xai_responses = agent.provider in {"xai", "xai-oauth"} or agent._base_url_hostname == "api.x.ai" _msgs_for_codex = agent._prepare_messages_for_non_vision_model(api_messages) + + # xAI's /responses endpoint rejects ``pattern`` and ``format`` keywords + # in tool schemas (HTTP 400 "Invalid arguments passed to the model"). + # Most commonly hit when MCP-derived tools carry JSON Schema validation + # keywords through. Strip them before building kwargs. See #27197. + if is_xai_responses: + try: + from tools.schema_sanitizer import strip_pattern_and_format + tools_for_api, _ = strip_pattern_and_format(tools_for_api) + except Exception as exc: + logger.warning( + "%s⚠️ Failed to sanitize tool schemas for xAI: %s", + getattr(agent, "log_prefix", ""), exc, + ) + return _ct.build_kwargs( model=agent.model, messages=_msgs_for_codex, From 04b4f765cc9fe51a60e0d962e1d41e703453978b Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 17 May 2026 13:33:16 -0700 Subject: [PATCH 068/418] fix(mcp): use module-level time so test patches do not race background sleepers --- tests/tools/test_mcp_stability.py | 4 ++-- tools/mcp_tool.py | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/tools/test_mcp_stability.py b/tests/tools/test_mcp_stability.py index 238696feba29..163a05963e0a 100644 --- a/tests/tools/test_mcp_stability.py +++ b/tests/tools/test_mcp_stability.py @@ -135,7 +135,7 @@ def test_kill_orphaned_uses_sigkill_when_available(self, monkeypatch): # bpo-14484). Return True so the SIGKILL escalation fires. with patch("tools.mcp_tool.os.kill") as mock_kill, \ patch("gateway.status._pid_exists", return_value=True), \ - patch("time.sleep") as mock_sleep: + patch("tools.mcp_tool.time.sleep") as mock_sleep: _kill_orphaned_mcp_children() # SIGTERM then SIGKILL; the alive check no longer touches os.kill. @@ -163,7 +163,7 @@ def test_kill_orphaned_falls_back_without_sigkill(self, monkeypatch): monkeypatch.delattr(signal, "SIGKILL", raising=False) with patch("tools.mcp_tool.os.kill") as mock_kill, \ - patch("time.sleep") as mock_sleep: + patch("tools.mcp_tool.time.sleep") as mock_sleep: _kill_orphaned_mcp_children() # SIGTERM phase, alive check raises (process gone), no escalation diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index e1d87389d426..e50efc05a0c2 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -3518,7 +3518,6 @@ def _kill_orphaned_mcp_children(include_active: bool = False) -> None: sessions can still be in flight. """ import signal as _signal - import time as _time with _lock: pids: Dict[int, str] = {} @@ -3543,7 +3542,7 @@ def _kill_orphaned_mcp_children(include_active: bool = False) -> None: pass # Phase 2: Wait for graceful exit - _time.sleep(2) + time.sleep(2) # Phase 3: SIGKILL any survivors _sigkill = getattr(_signal, "SIGKILL", _signal.SIGTERM) From 1345dda0cf4559a72f2a427e103d1b78e4fc9677 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 17 May 2026 13:54:12 -0700 Subject: [PATCH 069/418] feat(kanban): orchestrator-driven auto-decomposition on triage (#27572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(kanban): orchestrator-driven auto-decomposition on triage Closes the core gap in the kanban system: dropping a one-liner into Triage now decomposes it into a graph of child tasks routed to specialist profiles by description, matching teknium's original vision ("main orchestrator splits/creates actual tasks, doles them out to each agent"). The build --------- - hermes_cli/profiles.py: new `description` + `description_auto` fields on ProfileInfo, persisted in /profile.yaml. Helpers read_profile_meta / write_profile_meta. `create_profile` accepts optional description. - hermes_cli/profile_describer.py: new module — auto-generate a 1-2 sentence description from a profile's skills + model + name via the auxiliary LLM (`auxiliary.profile_describer`). - hermes_cli/main.py: new `hermes profile create --description ...` flag; new `hermes profile describe [name] [--text ... | --auto | --all --auto]` subcommand. - hermes_cli/kanban_db.py: new `decompose_triage_task` atomic helper — creates N child tasks, links the root as a child of every leaf (root waits for the whole graph), flips root `triage -> todo` with orchestrator assignee, records an audit comment + `decomposed` event in a single write_txn. - hermes_cli/kanban_decompose.py: new module — calls the auxiliary LLM (`auxiliary.kanban_decomposer`) with the profile roster + descriptions to produce a JSON task graph, then invokes the DB helper. Rewrites unknown assignees to the configured `kanban.default_assignee` (or the active default profile) so a task NEVER lands with assignee=None. Falls back to specify-style single-task promotion when the LLM returns `fanout: false`. - hermes_cli/kanban.py: new `hermes kanban decompose [task_id | --all]` CLI verb. - hermes_cli/config.py: new DEFAULT_CONFIG keys — kanban.orchestrator_profile, kanban.default_assignee, kanban.auto_decompose (default True), kanban.auto_decompose_per_tick (default 3), auxiliary.kanban_decomposer, auxiliary.profile_describer. - gateway/run.py: kanban dispatcher watcher now runs auto-decompose before each `_tick_once`, capped by `auto_decompose_per_tick` so a bulk-load of triage tasks doesn't burst-spend the aux LLM. - plugins/kanban/dashboard/plugin_api.py: new endpoints — GET /profiles (list roster + descriptions), PATCH /profiles/ (set description, user-authored), POST /profiles//describe-auto (LLM-generate), POST /tasks//decompose (run decomposer), GET/PUT /orchestration (orchestrator/default-assignee/auto-decompose pickers, with resolved fallbacks echoed back). - plugins/kanban/dashboard/dist/index.js: new OrchestrationPanel collapsible — dropdowns for orchestrator profile and default assignee, auto-decompose toggle, per-profile description editor with Save and Auto-generate buttons. New ⚗ Decompose button next to ✨ Specify on triage-column task drawers. Behavior -------- - A task in Triage gets fanned out into a small DAG of child tasks. Children with no internal parents flip to `ready` immediately (parallel dispatch). Children with sibling parents wait. The root stays alive as a parent of every child — when the whole graph finishes, it promotes to `ready` and the orchestrator profile wakes back up to judge completion (the "adds more tasks until done" part of the original vision). - `kanban.orchestrator_profile` unset -> falls back to the default profile (whichever `hermes` launches with no -p flag). - `kanban.default_assignee` unset -> same fallback. Tasks NEVER end up unassigned. - `kanban.auto_decompose=true` (default) runs the decomposer automatically on dispatcher ticks; manual `hermes kanban decompose` is always available. Tests ----- - tests/hermes_cli/test_kanban_decompose_db.py — 7 tests for the atomic DB helper (status transitions, dep graph, audit trail, validation errors). - tests/hermes_cli/test_kanban_decompose.py — 6 tests for the decomposer module (fanout, no-fanout fallback, unknown-assignee rewrite, malformed-JSON resilience, no-aux-client path). - tests/hermes_cli/test_profile_describer.py — 10 tests for profile.yaml r/w + the LLM auto-describer (yaml corrupt tolerance, user-vs-auto description protection, --overwrite, fallback parsing). E2E --- - CLI end-to-end: created profiles with descriptions, dropped a triage task, mocked the aux LLM with a 3-task graph -> verified all three children were created with the right assignees, the dependency edges matched the LLM's graph, root flipped to todo gated by every child, audit comment + `decomposed` event recorded. - Dashboard end-to-end: started the dashboard against an isolated HERMES_HOME, verified all four new endpoints via curl (profile listing, PATCH for description, PUT for orchestration settings, POST for decompose). Opened the UI in the browser, confirmed the OrchestrationPanel renders with all three pickers + the per-profile description editor, typed a description, clicked Save, verified ~/.hermes/profile.yaml was written. Clicked Decompose on the triage card and confirmed the inline error message surfaced as designed ("no auxiliary client configured"). * feat(kanban): surface decompose mode (Auto/Manual) as a one-click pill The auto/manual toggle already existed as kanban.auto_decompose (default true), but it was buried inside the collapsed Orchestration settings panel — users couldn't tell at a glance which mode they were in. This hoists it to a pill at the top of the kanban page so the state is always visible and one click flips it. UX - New "⚗ Decompose: AUTO|MANUAL" pill in the kanban header. Emerald styling when Auto is on (the default), muted/gray when Manual. - Pill is visible both in the collapsed AND expanded Orchestration settings views so context is preserved when the user opens the panel. - Tooltip explains both states + what clicking does. - Renamed the in-panel "Auto-decompose on triage / Enabled" checkbox to "Decompose mode / Auto (default) | Manual" for language parity with the pill. Behavior preserved - Default remains Auto (kanban.auto_decompose=true). - Manual mode restores pre-PR behavior: triage tasks stay in triage until the user clicks ⚗ Decompose on each card (or runs `hermes kanban decompose `). Implementation - plugins/kanban/dashboard/dist/index.js: load /orchestration on mount (not just on expand) so the collapsed pill reflects real state. Render mode pill in both collapsed and expanded headers. Reuses the existing PUT /api/plugins/kanban/orchestration endpoint — no new backend, no new tests required. E2E verified - Pill renders as "⚗ Decompose: AUTO" on page load (default). - One click flips to "⚗ Decompose: MANUAL" with muted styling. - config.yaml on disk shows auto_decompose: false after the flip. - Second click round-trips back to Auto; config.yaml flips to true. * feat(kanban): rename mode pill to "Orchestration: Auto/Manual" Per Teknium feedback — "Decompose" was too implementation-specific. "Orchestration" is the user-facing concept (the whole pitch is the orchestrator profile routing work), and the pill is the front door to it. - Pill text: "Orchestration: Auto" / "Orchestration: Manual" (title case, no ⚗ prefix, no SHOUTY-CAPS for the mode value) - In-panel checkbox label: "Orchestration mode" (was "Decompose mode") - Tooltips updated to match - No behavior change * docs(kanban): document decompose, profile descriptions, orchestration mode Brings the docs site up to parity with the PR. English build verified locally (npx docusaurus build --locale en) — clean, no new broken links or anchors. Pre-existing broken-link warnings (rl-training, llms.txt, step-by-step-checklist, fallback-model) untouched. - website/docs/reference/cli-commands.md + `hermes kanban decompose` action row in the action table, with pointer to the Auto vs Manual orchestration section. - website/docs/reference/profile-commands.md + `--description ""` flag on `hermes profile create`. + Full `hermes profile describe` section: read, --text, --auto, --overwrite, --all flags with examples. - website/docs/user-guide/features/kanban.md (the big one) + Triage column intro rewritten around the Auto-decompose default behavior, with pointer to the new Auto vs Manual section. + Status action row updated to mention both ⚗ Decompose and ✨ Specify on triage cards. + New "Auto vs Manual orchestration" section explaining the two modes, how to flip them (pill, config), how routing-by-description works, the no-None-assignee guarantee, plus a config knob table (auto_decompose, auto_decompose_per_tick, orchestrator_profile, default_assignee) and the two new auxiliary slots (kanban_decomposer, profile_describer). + REST surface table gains 6 new endpoint rows: /tasks/:id/decompose, /profiles (GET), /profiles/:name (PATCH), /profiles/:name/describe-auto, /orchestration (GET + PUT). - website/docs/user-guide/features/kanban-tutorial.md + Triage column blurb updated for Auto by default + Manual via the pill, with cross-link to the Auto vs Manual orchestration section. - website/docs/user-guide/profiles.md + Blank-profile flow now mentions --description and points to the kanban routing model for context. - website/docs/user-guide/configuration.md + `kanban_decomposer` and `profile_describer` added to the `hermes model -> Configure auxiliary models` menu listing. --- gateway/run.py | 95 ++++ hermes_cli/config.py | 44 ++ hermes_cli/kanban.py | 119 +++++ hermes_cli/kanban_db.py | 174 +++++++ hermes_cli/kanban_decompose.py | 440 ++++++++++++++++++ hermes_cli/main.py | 143 ++++++ hermes_cli/profile_describer.py | 299 ++++++++++++ hermes_cli/profiles.py | 107 +++++ plugins/kanban/dashboard/dist/index.js | 359 ++++++++++++++ plugins/kanban/dashboard/plugin_api.py | 273 +++++++++++ tests/hermes_cli/test_kanban_decompose.py | 242 ++++++++++ tests/hermes_cli/test_kanban_decompose_db.py | 152 ++++++ tests/hermes_cli/test_profile_describer.py | 168 +++++++ website/docs/reference/cli-commands.md | 1 + website/docs/reference/profile-commands.md | 35 ++ website/docs/user-guide/configuration.md | 2 + .../user-guide/features/kanban-tutorial.md | 2 +- website/docs/user-guide/features/kanban.md | 38 +- website/docs/user-guide/profiles.md | 8 + 19 files changed, 2698 insertions(+), 3 deletions(-) create mode 100644 hermes_cli/kanban_decompose.py create mode 100644 hermes_cli/profile_describer.py create mode 100644 tests/hermes_cli/test_kanban_decompose.py create mode 100644 tests/hermes_cli/test_kanban_decompose_db.py create mode 100644 tests/hermes_cli/test_profile_describer.py diff --git a/gateway/run.py b/gateway/run.py index a0ab84e850de..818bd282ddbf 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -4763,11 +4763,106 @@ def _ready_nonempty() -> bool: pass return False + # Auto-decompose: turn fresh triage tasks into ready workgraphs + # before the dispatcher fans out workers. Gated by + # ``kanban.auto_decompose`` (default True). Capped by + # ``kanban.auto_decompose_per_tick`` (default 3) so a bulk-load + # of triage tasks doesn't burst-spend the aux LLM in one tick; + # remainder defers to subsequent ticks. + auto_decompose_enabled = bool(kanban_cfg.get("auto_decompose", True)) + try: + auto_decompose_per_tick = int( + kanban_cfg.get("auto_decompose_per_tick", 3) or 3 + ) + except (TypeError, ValueError): + auto_decompose_per_tick = 3 + if auto_decompose_per_tick < 1: + auto_decompose_per_tick = 1 + + def _auto_decompose_tick() -> int: + """Run the auto-decomposer for up to N triage tasks across all + boards. Returns the number of triage tasks that were + successfully decomposed or specified this tick. + """ + try: + from hermes_cli import kanban_decompose as _decomp + except Exception as exc: # pragma: no cover + logger.warning( + "kanban auto-decompose: import failed (%s); skipping", exc, + ) + return 0 + try: + boards = _kb.list_boards(include_archived=False) + except Exception: + boards = [_kb.read_board_metadata(_kb.DEFAULT_BOARD)] + attempted = 0 + successes = 0 + for b in boards: + slug = b.get("slug") or _kb.DEFAULT_BOARD + if attempted >= auto_decompose_per_tick: + break + # Pin this board for the duration of the call — same + # pattern as the dashboard specify endpoint. The + # decomposer module connects with no board kwarg and + # relies on the env var. + prev_env = os.environ.get("HERMES_KANBAN_BOARD") + try: + os.environ["HERMES_KANBAN_BOARD"] = slug + try: + triage_ids = _decomp.list_triage_ids() + except Exception as exc: + logger.debug( + "kanban auto-decompose: list_triage_ids failed on board %s (%s)", + slug, exc, + ) + triage_ids = [] + for tid in triage_ids: + if attempted >= auto_decompose_per_tick: + break + attempted += 1 + try: + outcome = _decomp.decompose_task( + tid, author="auto-decomposer", + ) + except Exception: + logger.exception( + "kanban auto-decompose: decompose_task crashed on %s", + tid, + ) + continue + if outcome.ok: + successes += 1 + if outcome.fanout and outcome.child_ids: + logger.info( + "kanban auto-decompose [%s]: %s → %d children", + slug, tid, len(outcome.child_ids), + ) + else: + logger.info( + "kanban auto-decompose [%s]: %s → single task (no fanout)", + slug, tid, + ) + else: + # Common no-op reasons (no aux client configured) shouldn't + # spam logs every tick. Log at debug. + logger.debug( + "kanban auto-decompose [%s]: %s skipped: %s", + slug, tid, outcome.reason, + ) + finally: + if prev_env is None: + os.environ.pop("HERMES_KANBAN_BOARD", None) + else: + os.environ["HERMES_KANBAN_BOARD"] = prev_env + return successes + logger.info( "kanban dispatcher: embedded in gateway (interval=%.1fs)", interval ) while self._running: try: + if auto_decompose_enabled: + await asyncio.to_thread(_auto_decompose_tick) results = await asyncio.to_thread(_tick_once) any_spawned = False for slug, res in (results or []): diff --git a/hermes_cli/config.py b/hermes_cli/config.py index e4447183746b..3f9bdd69ed4d 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -925,6 +925,31 @@ def _ensure_hermes_home_managed(home: Path): "timeout": 120, "extra_body": {}, }, + # Kanban decomposer — decomposes a triage task into a graph of + # child tasks routed to specialist profiles by description. + # Invoked by ``hermes kanban decompose`` and the kanban + # auto-decompose dispatcher tick. Returns a JSON task graph; + # uses more tokens than the specifier so allow more headroom. + "kanban_decomposer": { + "provider": "auto", + "model": "", + "base_url": "", + "api_key": "", + "timeout": 180, + "extra_body": {}, + }, + # Profile describer — auto-generates a 1-2 sentence description + # of what a profile is good at. Invoked by + # ``hermes profile describe --auto`` and the dashboard's + # auto-generate button. Short, cheap call. + "profile_describer": { + "provider": "auto", + "model": "", + "base_url": "", + "api_key": "", + "timeout": 60, + "extra_body": {}, + }, # Curator — skill-usage review fork. Timeout is generous because the # review pass can take several minutes on reasoning models (umbrella # building over hundreds of candidate skills). "auto" = use main chat @@ -1466,6 +1491,25 @@ def _ensure_hermes_home_managed(home: Path): # same task/profile (spawn_failed, timed_out, or crashed). Reassignment # resets the streak for the new profile. "failure_limit": 2, + # Profile that decomposes tasks in the Triage column. When unset, + # falls back to the default profile (the one `hermes` launches with + # no -p flag). Set this to a dedicated 'orchestrator' profile if you + # want decomposition to use a different model/skills from your main + # working profile. + "orchestrator_profile": "", + # Where a child task lands if the orchestrator can't match an + # assignee to any installed profile. When unset, falls back to the + # default profile. A task never ends up with assignee=None. + "default_assignee": "", + # When true, the kanban dispatcher auto-runs the decomposer on + # tasks that land in Triage (every dispatcher tick). When false, + # decomposition is manual via `hermes kanban decompose ` or + # the dashboard's Decompose button. + "auto_decompose": True, + # Max triage tasks to decompose per dispatcher tick. Prevents a + # large bulk-load of triage tasks from spending a burst of aux + # LLM calls in one tick. Excess tasks defer to the next tick. + "auto_decompose_per_tick": 3, }, # execute_code settings — controls the tool used for programmatic tool calls. diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index b4024e2e70e1..55b1d4125a2d 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -610,6 +610,43 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu help="Emit one JSON object per task on stdout", ) + # --- decompose --- (triage → fan-out via auxiliary LLM + orchestrator) + p_decompose = sub.add_parser( + "decompose", + help="Decompose a triage-column task into a graph of child tasks " + "routed to specialist profiles by description. Falls back to " + "specify-style single-task promotion when the task doesn't " + "benefit from fan-out. Uses auxiliary.kanban_decomposer.", + ) + p_decompose.add_argument( + "task_id", + nargs="?", + default=None, + help="Task id to decompose (required unless --all is given)", + ) + p_decompose.add_argument( + "--all", + dest="all_triage", + action="store_true", + help="Decompose every task currently in the triage column", + ) + p_decompose.add_argument( + "--tenant", + default=None, + help="When used with --all, restrict the sweep to this tenant", + ) + p_decompose.add_argument( + "--author", + default=None, + help="Author name recorded on the audit comment " + "(default: $HERMES_PROFILE or 'decomposer')", + ) + p_decompose.add_argument( + "--json", + action="store_true", + help="Emit one JSON object per task on stdout", + ) + # --- gc --- p_gc = sub.add_parser( "gc", help="Garbage-collect archived-task workspaces, old events, and old logs", @@ -740,6 +777,7 @@ def _restore_board_env() -> None: "notify-unsubscribe": _cmd_notify_unsubscribe, "context": _cmd_context, "specify": _cmd_specify, + "decompose": _cmd_decompose, "gc": _cmd_gc, } handler = handlers.get(action) @@ -2115,6 +2153,87 @@ def _cmd_specify(args: argparse.Namespace) -> int: return 0 if (ok_count > 0 or not ids) else 1 +def _cmd_decompose(args: argparse.Namespace) -> int: + """Fan a triage task (or all of them) out into a graph of child + tasks via the auxiliary LLM, routed to specialist profiles by + description. Thin wrapper over ``kanban_decompose``.""" + from hermes_cli import kanban_decompose as decomp + + all_flag = bool(getattr(args, "all_triage", False)) + tenant = getattr(args, "tenant", None) + author = getattr(args, "author", None) or _profile_author() + want_json = bool(getattr(args, "json", False)) + + if args.task_id and all_flag: + print( + "kanban: pass either a task id OR --all, not both", + file=sys.stderr, + ) + return 2 + + if all_flag: + ids = decomp.list_triage_ids(tenant=tenant) + if not ids: + msg = ( + "No triage tasks" + + (f" for tenant {tenant!r}" if tenant else "") + + "." + ) + if want_json: + print(json.dumps({"decomposed": 0, "total": 0})) + else: + print(msg) + return 0 + elif args.task_id: + ids = [args.task_id] + else: + print( + "kanban: decompose requires a task id or --all", + file=sys.stderr, + ) + return 2 + + ok_count = 0 + for tid in ids: + outcome = decomp.decompose_task(tid, author=author) + if outcome.ok: + ok_count += 1 + if want_json: + print(json.dumps({ + "task_id": outcome.task_id, + "ok": outcome.ok, + "reason": outcome.reason, + "fanout": outcome.fanout, + "child_ids": outcome.child_ids, + "new_title": outcome.new_title, + })) + elif outcome.ok: + if outcome.fanout and outcome.child_ids: + child_summary = ", ".join(outcome.child_ids) + print( + f"Decomposed {outcome.task_id} → {len(outcome.child_ids)} " + f"children ({child_summary}); root promoted to todo" + ) + else: + title_suffix = ( + f" — retitled: {outcome.new_title!r}" + if outcome.new_title + else "" + ) + print( + f"Specified {outcome.task_id} → todo " + f"(no fanout){title_suffix}" + ) + else: + print( + f"kanban: decompose {outcome.task_id}: {outcome.reason}", + file=sys.stderr, + ) + if not all_flag: + return 0 if ok_count == 1 else 1 + return 0 if (ok_count > 0 or not ids) else 1 + + def _cmd_gc(args: argparse.Namespace) -> int: """Remove scratch workspaces of archived tasks, prune old events, and delete old worker logs.""" diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 9d5ddad6ed0e..4bd4827e386e 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -2777,6 +2777,180 @@ def specify_triage_task( return True +def decompose_triage_task( + conn: sqlite3.Connection, + task_id: str, + *, + root_assignee: Optional[str], + children: list[dict], + author: Optional[str] = None, +) -> Optional[list[str]]: + """Fan a triage task out into child tasks and promote the root to ``todo``. + + The root task stays alive and becomes the parent of every child — + when all children reach ``done``, the root promotes to ``ready`` and + its assignee (typically the orchestrator profile) wakes back up to + judge completion or spawn more work. + + ``children`` is a list of dicts, each shaped like:: + + { + "title": "...", + "body": "...", # optional + "assignee": "profile-name", # optional, None -> default fallback + "parents": [0, 2], # indices into this same children list + } + + Returns the list of created child task ids (in input order) on + success. Returns ``None`` when: + - The root task does not exist + - The root task is not in ``triage`` + - A cycle would result (caller built a bad graph) + + Validation of titles/assignees happens inside the same write_txn as + the inserts so a malformed entry aborts the whole decomposition + cleanly (no orphan children). + """ + if not children: + return None + if root_assignee is not None: + root_assignee = _canonical_assignee(root_assignee) + + # Pre-validate the children list shape outside the txn. Cheap checks + # that don't need DB access. Bad input aborts before we touch the DB. + for idx, child in enumerate(children): + if not isinstance(child, dict): + raise ValueError(f"child[{idx}] is not a dict") + title = child.get("title") + if not isinstance(title, str) or not title.strip(): + raise ValueError(f"child[{idx}].title is required") + parents_idx = child.get("parents") or [] + if not isinstance(parents_idx, list): + raise ValueError(f"child[{idx}].parents must be a list") + for p in parents_idx: + if not isinstance(p, int) or p < 0 or p >= len(children): + raise ValueError( + f"child[{idx}].parents[{p}] is not a valid index into children" + ) + if p == idx: + raise ValueError(f"child[{idx}] cannot list itself as a parent") + + # We do the full decomposition in a SINGLE write_txn so it's + # atomic: either every child is created AND the root flips to + # ``todo``, or nothing changes. We deliberately do NOT call any + # kb helper that opens its own write_txn (create_task, link_tasks, + # add_comment) from inside this block — see architecture.md + # write_txn pitfalls. Instead we inline the INSERTs and + # _append_event calls. + now = int(time.time()) + child_ids: list[str] = [] + with write_txn(conn): + root_row = conn.execute( + "SELECT id, status, tenant FROM tasks WHERE id = ?", (task_id,) + ).fetchone() + if root_row is None: + return None + if root_row["status"] != "triage": + return None + tenant = root_row["tenant"] + + # Create children. Status is 'todo' regardless of parents — we + # link them under the root AFTER creation so the dispatcher + # sees a coherent state, and recompute_ready() at the end + # promotes parent-free children to 'ready'. + for idx, child in enumerate(children): + new_id = _new_task_id() + title = child["title"].strip() + body = child.get("body") + assignee = _canonical_assignee(child.get("assignee")) + conn.execute( + "INSERT INTO tasks " + "(id, title, body, assignee, status, workspace_kind, " + " tenant, created_at, created_by) " + "VALUES (?, ?, ?, ?, 'todo', 'scratch', ?, ?, ?)", + ( + new_id, + title, + body if isinstance(body, str) else None, + assignee, + tenant, + now, + (author or "decomposer"), + ), + ) + _append_event( + conn, new_id, "created", + {"by": author or "decomposer", "from_decompose_of": task_id}, + ) + child_ids.append(new_id) + + # Link children to their sibling parents (within the decomposed graph). + for idx, child in enumerate(children): + for p_idx in child.get("parents") or []: + parent_id = child_ids[p_idx] + child_id = child_ids[idx] + conn.execute( + "INSERT OR IGNORE INTO task_links (parent_id, child_id) " + "VALUES (?, ?)", + (parent_id, child_id), + ) + _append_event( + conn, child_id, "linked", + {"parent": parent_id, "child": child_id}, + ) + + # Link the ROOT task as a child of every leaf child — i.e. the + # root waits for the whole graph. Simpler than computing leaves: + # link root under every child. Cycle-free because the root is + # only ever a child here, never a parent of children. + for cid in child_ids: + conn.execute( + "INSERT OR IGNORE INTO task_links (parent_id, child_id) " + "VALUES (?, ?)", + (cid, task_id), + ) + + # Flip the root: triage -> todo, set assignee to the orchestrator. + sets = ["status = 'todo'"] + params: list[Any] = [] + if root_assignee is not None: + sets.append("assignee = ?") + params.append(root_assignee) + params.append(task_id) + conn.execute( + f"UPDATE tasks SET {', '.join(sets)} WHERE id = ?", + tuple(params), + ) + + # Audit comment + event on the root so the timeline shows the fan-out. + if author and author.strip(): + conn.execute( + "INSERT INTO task_comments (task_id, author, body, created_at) " + "VALUES (?, ?, ?, ?)", + ( + task_id, + author.strip(), + "Decomposed into " + + ", ".join(child_ids) + + ". Root will wake when all children complete.", + now, + ), + ) + _append_event( + conn, task_id, "decomposed", + { + "child_ids": child_ids, + "root_assignee": root_assignee, + }, + ) + + # Outside the write_txn: promote parent-free children to 'ready' + # so the dispatcher picks them up on its next tick. Same pattern + # specify_triage_task uses. + recompute_ready(conn) + return child_ids + + def archive_task(conn: sqlite3.Connection, task_id: str) -> bool: with write_txn(conn): cur = conn.execute( diff --git a/hermes_cli/kanban_decompose.py b/hermes_cli/kanban_decompose.py new file mode 100644 index 000000000000..2ebe3f04c6e0 --- /dev/null +++ b/hermes_cli/kanban_decompose.py @@ -0,0 +1,440 @@ +"""Kanban decomposer — fan a triage task out into a graph of child tasks. + +Invoked by ``hermes kanban decompose [task_id | --all]`` and the +auto-decompose path in the gateway dispatcher loop. Reads the user's +profile roster (with descriptions) and asks the auxiliary LLM to +return a task graph in JSON. Then atomically creates the children, +links them under the root, and flips the root ``triage -> todo``. + +The root task stays alive and becomes the parent of every leaf child, +so when the whole graph completes the root wakes back up — its +assignee (the orchestrator profile) gets a chance to judge completion +and add more tasks if the work isn't done yet. + +Design notes +------------ + +* Mirrors the shape of ``hermes_cli/kanban_specify.py``: lazy aux + client import inside the function, lenient response parse, never + raises on expected failure modes. + +* The system prompt sees the *configured* profile roster — names plus + descriptions plus the default fallback. Profiles without a + description are still listed (with a note) so the orchestrator can + match on name as a fallback, but the user has an obvious incentive + to describe them. + +* ``fanout=false`` collapses to the same effect as ``kanban specify``: + we tighten the body and flip ``triage -> todo`` as a single task, + no children created. This makes ``decompose`` a strict superset of + ``specify`` from the user's perspective. + +* If the LLM picks an assignee that doesn't exist as a profile, we + rewrite it to the configured ``default_assignee`` (or the default + profile if unset). A child task NEVER ends up with ``assignee=None``. +""" + +from __future__ import annotations + +import json +import logging +import os +import re +from dataclasses import dataclass +from typing import Optional + +from hermes_cli import kanban_db as kb +from hermes_cli import profiles as profiles_mod + +logger = logging.getLogger(__name__) + + +_SYSTEM_PROMPT = """You are the Kanban decomposer for the Hermes Agent board. + +A user dropped a rough idea into the Triage column. Your job is to break it +into a small graph of concrete child tasks and route each one to the best- +matching profile from the available roster. + +You will be given: + - The original task title and body + - The list of available profiles (each with name + description) + - The fallback "default_assignee" used when no profile fits + +Output a single JSON object with this exact shape: + + { + "fanout": true, + "rationale": "", + "tasks": [ + { + "title": "", + "body": "", + "assignee": "", + "parents": [, ...] + }, + ... + ] + } + +Rules: + - "parents" is a list of INDICES (0-based) into this same "tasks" list, + expressing actual data dependencies. Tasks with no parents run in + PARALLEL. Tasks with parents wait until every parent completes. + - Prefer parallelism. If two tasks can be done independently, give + them no parents so the dispatcher fans them out at once. + - Use 2-6 tasks for normal work. Don't create 20 tiny tasks. Don't + cram everything into 1 task. + - Pick assignees from the roster by matching the task to the profile's + DESCRIPTION (not just the name). When nothing matches well, use null + and the system will route to the default_assignee. + - Each child task body is what a fresh worker will read with no other + context — be specific about goal, approach, and acceptance criteria. + +When the task is genuinely a single unit of work (no useful decomposition), +return: + + { + "fanout": false, + "rationale": "", + "title": "", + "body": "" + } + +In that case the task stays as one work item, just with a tightened spec. + +No preamble, no closing remarks, no code fences. Output only the JSON object. +""" + + +_USER_TEMPLATE = """Task id: {task_id} +Title: {title} +Body: +{body} + +Available profiles (assignees you may pick from): +{roster} + +Default assignee (used when no profile fits a task): {default_assignee} +""" + + +_FENCE_RE = re.compile(r"^```(?:json)?\s*|\s*```$", re.MULTILINE) + + +@dataclass +class DecomposeOutcome: + """Result of decomposing a single triage task.""" + + task_id: str + ok: bool + reason: str = "" + fanout: bool = False + child_ids: list[str] | None = None + new_title: Optional[str] = None + + +def _truncate(text: str, limit: int) -> str: + if len(text) <= limit: + return text + return text[: limit - 1] + "…" + + +def _extract_json_blob(raw: str) -> Optional[dict]: + if not raw: + return None + stripped = _FENCE_RE.sub("", raw.strip()) + first = stripped.find("{") + last = stripped.rfind("}") + if first == -1 or last == -1 or last <= first: + return None + candidate = stripped[first : last + 1] + try: + val = json.loads(candidate) + except (ValueError, json.JSONDecodeError): + return None + if not isinstance(val, dict): + return None + return val + + +def _profile_author() -> str: + """Mirror of ``hermes_cli.kanban._profile_author``.""" + return ( + os.environ.get("HERMES_PROFILE") + or os.environ.get("USER") + or "decomposer" + ) + + +def _load_config() -> dict: + try: + from hermes_cli.config import load_config + return load_config() or {} + except Exception: + return {} + + +def _resolve_orchestrator_profile(cfg: dict) -> str: + """Resolve which profile owns decomposition. + + Falls back to the active default profile when ``kanban.orchestrator_profile`` + is unset, so a task is never stranded for lack of an orchestrator. + """ + kanban_cfg = cfg.get("kanban", {}) if isinstance(cfg, dict) else {} + explicit = (kanban_cfg.get("orchestrator_profile") or "").strip() + if explicit: + try: + if profiles_mod.profile_exists(explicit): + return explicit + except Exception: + pass + # Fall back to the active default profile. + try: + return profiles_mod.get_active_profile_name() or "default" + except Exception: + return "default" + + +def _resolve_default_assignee(cfg: dict) -> str: + """Resolve which profile catches child tasks the orchestrator can't route.""" + kanban_cfg = cfg.get("kanban", {}) if isinstance(cfg, dict) else {} + explicit = (kanban_cfg.get("default_assignee") or "").strip() + if explicit: + try: + if profiles_mod.profile_exists(explicit): + return explicit + except Exception: + pass + try: + return profiles_mod.get_active_profile_name() or "default" + except Exception: + return "default" + + +def _build_roster() -> tuple[list[dict], set[str]]: + """Return (roster_for_prompt, valid_assignee_names). + + Each roster entry is ``{name, description, has_description}``. The + valid-set is used after the LLM responds to rewrite invalid + assignees to the default fallback. + """ + roster: list[dict] = [] + valid: set[str] = set() + try: + all_profiles = profiles_mod.list_profiles() + except Exception as exc: + logger.warning("decompose: failed to list profiles: %s", exc) + return roster, valid + for p in all_profiles: + desc = (p.description or "").strip() + roster.append({ + "name": p.name, + "description": desc or f"(no description; profile named {p.name!r})", + "has_description": bool(desc), + }) + valid.add(p.name) + return roster, valid + + +def _format_roster(roster: list[dict]) -> str: + if not roster: + return " (no profiles installed — decomposer cannot route work)" + lines = [] + for entry in roster: + tag = "" if entry["has_description"] else " ⚠ undescribed" + lines.append(f" - {entry['name']}{tag}: {entry['description']}") + return "\n".join(lines) + + +def decompose_task( + task_id: str, + *, + author: Optional[str] = None, + timeout: Optional[int] = None, +) -> DecomposeOutcome: + """Decompose a triage task into a graph of child tasks. + + Returns an outcome describing what happened. Never raises for + expected failure modes (task not in triage, no aux client + configured, API error, malformed response, decomposer returned + fanout=true with empty task list) — those surface via ``ok=False``. + """ + with kb.connect() as conn: + task = kb.get_task(conn, task_id) + if task is None: + return DecomposeOutcome(task_id, False, "unknown task id") + if task.status != "triage": + return DecomposeOutcome( + task_id, False, f"task is not in triage (status={task.status!r})" + ) + + cfg = _load_config() + orchestrator = _resolve_orchestrator_profile(cfg) + default_assignee = _resolve_default_assignee(cfg) + roster, valid_names = _build_roster() + + try: + from agent.auxiliary_client import ( # type: ignore + get_auxiliary_extra_body, + get_text_auxiliary_client, + ) + except Exception as exc: + logger.debug("decompose: auxiliary client import failed: %s", exc) + return DecomposeOutcome(task_id, False, "auxiliary client unavailable") + + try: + client, model = get_text_auxiliary_client("kanban_decomposer") + except Exception as exc: + logger.debug("decompose: get_text_auxiliary_client failed: %s", exc) + return DecomposeOutcome(task_id, False, "auxiliary client unavailable") + + if client is None or not model: + return DecomposeOutcome(task_id, False, "no auxiliary client configured") + + user_msg = _USER_TEMPLATE.format( + task_id=task.id, + title=_truncate(task.title or "", 400), + body=_truncate(task.body or "(no body)", 4000), + roster=_format_roster(roster), + default_assignee=default_assignee, + ) + + try: + resp = client.chat.completions.create( + model=model, + messages=[ + {"role": "system", "content": _SYSTEM_PROMPT}, + {"role": "user", "content": user_msg}, + ], + temperature=0.3, + max_tokens=4000, + timeout=timeout or 180, + extra_body=get_auxiliary_extra_body() or None, + ) + except Exception as exc: + logger.info( + "decompose: API call failed for %s (%s)", task_id, exc, + ) + return DecomposeOutcome(task_id, False, f"LLM error: {type(exc).__name__}") + + try: + raw = resp.choices[0].message.content or "" + except Exception: + raw = "" + + parsed = _extract_json_blob(raw) + if parsed is None: + return DecomposeOutcome(task_id, False, "LLM returned malformed JSON") + + fanout = bool(parsed.get("fanout")) + audit_author = author or _profile_author() + + if not fanout: + # Fall back to single-task spec promotion (same effect as specify). + new_title = parsed.get("title") + new_body = parsed.get("body") + title_val = new_title.strip() if isinstance(new_title, str) and new_title.strip() else None + body_val = new_body if isinstance(new_body, str) and new_body.strip() else None + if title_val is None and body_val is None: + return DecomposeOutcome( + task_id, False, "decomposer returned fanout=false with no title/body", + ) + with kb.connect() as conn: + ok = kb.specify_triage_task( + conn, + task_id, + title=title_val, + body=body_val, + author=audit_author, + ) + if not ok: + return DecomposeOutcome( + task_id, False, "task moved out of triage before promotion", + ) + return DecomposeOutcome( + task_id, True, "single task (no fanout)", + fanout=False, new_title=title_val, + ) + + raw_tasks = parsed.get("tasks") or [] + if not isinstance(raw_tasks, list) or not raw_tasks: + return DecomposeOutcome( + task_id, False, "decomposer returned fanout=true with empty tasks list", + ) + + # Rewrite invalid assignees to the default fallback. Never leave a + # task with assignee=None — the user explicitly does not want that. + children: list[dict] = [] + for idx, entry in enumerate(raw_tasks): + if not isinstance(entry, dict): + return DecomposeOutcome( + task_id, False, f"tasks[{idx}] is not an object", + ) + title = entry.get("title") + if not isinstance(title, str) or not title.strip(): + return DecomposeOutcome( + task_id, False, f"tasks[{idx}].title is missing or empty", + ) + body = entry.get("body") + if not isinstance(body, str): + body = "" + assignee = entry.get("assignee") + if not isinstance(assignee, str) or not assignee.strip(): + chosen = default_assignee + elif assignee not in valid_names: + logger.info( + "decompose: task %s child %d picked unknown assignee %r — " + "routing to default_assignee %r", + task_id, idx, assignee, default_assignee, + ) + chosen = default_assignee + else: + chosen = assignee + parents = entry.get("parents") or [] + if not isinstance(parents, list): + parents = [] + # Clean parent indices: drop non-int and out-of-range. + clean_parents = [p for p in parents if isinstance(p, int) and 0 <= p < len(raw_tasks) and p != idx] + children.append({ + "title": title.strip()[:200], + "body": body.strip(), + "assignee": chosen, + "parents": clean_parents, + }) + + try: + with kb.connect() as conn: + child_ids = kb.decompose_triage_task( + conn, + task_id, + root_assignee=orchestrator, + children=children, + author=audit_author, + ) + except ValueError as exc: + return DecomposeOutcome(task_id, False, f"DB rejected graph: {exc}") + except Exception as exc: + logger.exception("decompose: DB error on task %s", task_id) + return DecomposeOutcome(task_id, False, f"DB error: {type(exc).__name__}") + + if child_ids is None: + return DecomposeOutcome( + task_id, False, "task moved out of triage before decomposition", + ) + + return DecomposeOutcome( + task_id, True, f"decomposed into {len(child_ids)} children", + fanout=True, child_ids=child_ids, + ) + + +def list_triage_ids(*, tenant: Optional[str] = None) -> list[str]: + """Return task ids currently in the triage column.""" + with kb.connect() as conn: + rows = kb.list_tasks( + conn, + status="triage", + tenant=tenant, + limit=1000, + ) + return [row.id for row in rows] diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 6ea8dd122fcf..575835b2c7d2 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -9043,6 +9043,7 @@ def cmd_profile(args): clone_config=clone, no_alias=no_alias, no_skills=no_skills, + description=getattr(args, "description", None), ) print(f"\nProfile '{name}' created at {profile_dir}") @@ -9142,6 +9143,107 @@ def cmd_profile(args): print(f"Error: {e}") sys.exit(1) + elif action == "describe": + # Read or write a profile's description. The description is + # consumed by the kanban decomposer to route tasks based on + # role instead of name alone. + from hermes_cli import profiles as _profiles_mod + + all_flag = bool(getattr(args, "all_missing", False)) + auto_flag = bool(getattr(args, "auto", False)) + overwrite_flag = bool(getattr(args, "overwrite", False)) + text_value = getattr(args, "text", None) + name = getattr(args, "profile_name", None) + + if all_flag and not auto_flag: + print("profile describe: --all requires --auto", file=sys.stderr) + sys.exit(2) + if all_flag and (text_value or name): + print( + "profile describe: --all is mutually exclusive with a profile name / --text", + file=sys.stderr, + ) + sys.exit(2) + if not all_flag and not name: + print("profile describe: profile name is required (or --all --auto)", file=sys.stderr) + sys.exit(2) + if text_value and auto_flag: + print( + "profile describe: --text is mutually exclusive with --auto", + file=sys.stderr, + ) + sys.exit(2) + + # Show current description if no operation requested. + if name and not text_value and not auto_flag: + try: + if _profiles_mod.normalize_profile_name(name) == "default": + from hermes_constants import get_hermes_home as _hh + profile_dir = Path(_hh()) + else: + profile_dir = _profiles_mod.get_profile_dir(name) + except Exception as exc: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) + if not profile_dir.is_dir(): + print(f"Error: profile '{name}' not found", file=sys.stderr) + sys.exit(1) + meta = _profiles_mod.read_profile_meta(profile_dir) + desc = meta.get("description") or "" + if not desc: + print(f"(no description set for '{name}')") + else: + tag = "[auto] " if meta.get("description_auto") else "" + print(f"{tag}{desc}") + sys.exit(0) + + # --text path: just write the user-authored description. + if text_value: + try: + if _profiles_mod.normalize_profile_name(name) == "default": + from hermes_constants import get_hermes_home as _hh + profile_dir = Path(_hh()) + else: + profile_dir = _profiles_mod.get_profile_dir(name) + _profiles_mod.write_profile_meta( + profile_dir, + description=text_value, + description_auto=False, + ) + print(f"Description updated for '{name}'.") + except Exception as exc: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) + sys.exit(0) + + # --auto path: invoke the LLM describer. + from hermes_cli import profile_describer as _pd + + if all_flag: + targets = _pd.list_describable_profiles(missing_only=True) + if not targets: + print("All profiles already have descriptions.") + sys.exit(0) + else: + targets = [name] + + ok_count = 0 + fail_count = 0 + for tgt in targets: + outcome = _pd.describe_profile(tgt, overwrite=overwrite_flag) + if outcome.ok: + ok_count += 1 + print(f"Described '{outcome.profile_name}': {outcome.description}") + else: + fail_count += 1 + print( + f"profile describe {outcome.profile_name}: {outcome.reason}", + file=sys.stderr, + ) + if not all_flag: + sys.exit(0 if ok_count == 1 else 1) + sys.exit(0 if ok_count > 0 else 1) + elif action == "show": name = args.profile_name from hermes_cli.profiles import ( @@ -12023,6 +12125,13 @@ def cmd_acp(args): action="store_true", help="Create an empty profile with no bundled skills (opts out of `hermes update` skill sync)", ) + profile_create.add_argument( + "--description", + default=None, + help="One- or two-sentence description of what this profile is good at. " + "Used by the kanban decomposer to route tasks based on role instead " + "of profile name alone. Skip and add later via `hermes profile describe`.", + ) profile_delete = profile_subparsers.add_parser("delete", help="Delete a profile") profile_delete.add_argument("profile_name", help="Profile to delete") @@ -12030,6 +12139,40 @@ def cmd_acp(args): "-y", "--yes", action="store_true", help="Skip confirmation prompt" ) + profile_describe = profile_subparsers.add_parser( + "describe", + help="Read or set a profile's description (used by the kanban orchestrator)", + ) + profile_describe.add_argument( + "profile_name", + nargs="?", + default=None, + help="Profile to describe (omit + use --all --auto to sweep)", + ) + profile_describe.add_argument( + "--text", + default=None, + help="Set description to this exact text (overwrites any existing description)", + ) + profile_describe.add_argument( + "--auto", + action="store_true", + help="Auto-generate description via the auxiliary LLM " + "(uses auxiliary.profile_describer)", + ) + profile_describe.add_argument( + "--overwrite", + action="store_true", + help="With --auto, replace user-authored descriptions too (default: only " + "fill in missing or previously-auto descriptions)", + ) + profile_describe.add_argument( + "--all", + dest="all_missing", + action="store_true", + help="With --auto, run on every profile missing a description", + ) + profile_show = profile_subparsers.add_parser("show", help="Show profile details") profile_show.add_argument("profile_name", help="Profile to show") diff --git a/hermes_cli/profile_describer.py b/hermes_cli/profile_describer.py new file mode 100644 index 000000000000..55d646d92cd4 --- /dev/null +++ b/hermes_cli/profile_describer.py @@ -0,0 +1,299 @@ +"""Profile describer — auto-generate ``description`` for a profile. + +Used by ``hermes profile describe --auto`` and the dashboard's +"auto-generate description" button. Reads the profile's installed +skills, model+provider, name, and optionally a small slice of memory, +then asks the auxiliary LLM to produce a 1-2 sentence description of +what the profile is good at. + +Result is written to ``/profile.yaml`` with +``description_auto: true`` so the dashboard can surface a "review" +badge. User can edit afterward to confirm. + +Design notes +------------ +- Mirrors the shape of ``hermes_cli/kanban_specify.py``: lazy aux + client import inside the function, lenient response parse, never + raises on expected failure modes. +- Reads at most ``MAX_SKILLS_FOR_PROMPT`` skill names to keep the + prompt bounded. No skill body — names + categories are enough + signal and avoid blowing context on profiles with 100+ skills. +- Memory is intentionally NOT read here. Memories are personal and + the orchestrator routes work to a *role* not a *biography*. If we + find later that memory adds signal we can wire it; for now, + skills + name + model is plenty. +""" + +from __future__ import annotations + +import json +import logging +import os +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +from hermes_cli import profiles as profiles_mod + +logger = logging.getLogger(__name__) + +# Cap on how many skill names we feed the LLM. Profiles with 200+ +# skills (uncommon but possible) would blow context otherwise. The cap +# is per-category — see _collect_skills. +MAX_SKILLS_FOR_PROMPT = 60 + + +_SYSTEM_PROMPT = """You are a profile-describer for the Hermes Agent kanban board. + +A user runs multiple "profiles" — distinct agent identities, each with their +own skills, model, and configuration. The kanban board's orchestrator routes +work to whichever profile best fits each task. To do that well, every +profile needs a short, concrete description of what it's good at. + +You are given a profile's: + - Name + - Model / provider + - List of installed skill names (a strong signal of role / domain) + +Produce a single JSON object with exactly one key: + + { + "description": "<1-2 sentence description, plain prose, no preamble>" + } + +Rules: + - The description is what an orchestrator will read to decide whether to + route a task here. Lead with the profile's strongest capability. + - Stay concrete. Bad: "an AI agent that helps users." + Good: "Reads and modifies Python codebases — runs tests, + refactors functions, opens GitHub PRs." + - 1-2 sentences, <= 280 characters total. + - Never invent capabilities the skills don't suggest. + - Never write "Hermes Agent profile" or other meta-narration. + - No code fences, no preamble, no closing remarks. Output only JSON. +""" + + +_USER_TEMPLATE = """Profile name: {name} +Default model: {model} +Provider: {provider} +Installed skill count: {skill_count} +Notable skills (up to {skill_cap}): +{skill_list} +""" + + +_FENCE_RE = re.compile(r"^```(?:json)?\s*|\s*```$", re.MULTILINE) + + +@dataclass +class DescribeOutcome: + """Result of describing a single profile.""" + + profile_name: str + ok: bool + reason: str = "" + description: Optional[str] = None + + +def _collect_skills(profile_dir: Path) -> list[str]: + """Return a stable, capped list of skill names for the prompt. + + Format: ``category/skill_name`` where category is the immediate + subdir under ``skills/`` (e.g. ``devops``, ``research``). Skills + that live directly under ``skills/`` show as bare ``skill_name``. + """ + skills_dir = profile_dir / "skills" + if not skills_dir.is_dir(): + return [] + names: list[str] = [] + for md in skills_dir.rglob("SKILL.md"): + path_str = str(md) + if "/.hub/" in path_str or "/.git/" in path_str: + continue + try: + rel = md.relative_to(skills_dir) + except ValueError: + continue + parts = rel.parts[:-1] # drop SKILL.md filename + if not parts: + continue + # parts[-1] is the skill dir name; parts[:-1] is the category path + if len(parts) == 1: + names.append(parts[0]) + else: + names.append(f"{parts[0]}/{parts[-1]}") + names.sort() + # Keep within prompt budget. Skills earlier in alphabet aren't more + # important — we'll let the LLM see a sample. Pick evenly-spaced + # entries instead of just the head so a profile with skills A..Z + # doesn't get described as "starts with A". + if len(names) <= MAX_SKILLS_FOR_PROMPT: + return names + step = len(names) / MAX_SKILLS_FOR_PROMPT + sampled = [names[int(i * step)] for i in range(MAX_SKILLS_FOR_PROMPT)] + return sampled + + +def _extract_json_blob(raw: str) -> Optional[dict]: + if not raw: + return None + stripped = _FENCE_RE.sub("", raw.strip()) + first = stripped.find("{") + last = stripped.rfind("}") + if first == -1 or last == -1 or last <= first: + return None + candidate = stripped[first : last + 1] + try: + val = json.loads(candidate) + except (ValueError, json.JSONDecodeError): + return None + if not isinstance(val, dict): + return None + return val + + +def describe_profile( + profile_name: str, + *, + overwrite: bool = False, + timeout: Optional[int] = None, +) -> DescribeOutcome: + """Auto-generate a description for one profile. + + Returns an outcome describing what happened. Never raises for + expected failure modes (profile missing, no aux client configured, + API error, malformed response) — those surface via ``ok=False`` so + a sweep can continue past individual failures. + + ``overwrite`` controls whether an existing user-authored description + is replaced. By default we refuse to overwrite a description with + ``description_auto: false`` to protect curated text. Auto-generated + descriptions (``description_auto: true``) are always replaceable. + """ + canon = profiles_mod.normalize_profile_name(profile_name) + if not profiles_mod.profile_exists(canon): + # Special case: "default" exists as a virtual profile name + # mapped to the default home dir. profile_exists() handles it. + return DescribeOutcome(canon, False, "profile not found") + + try: + if canon == "default": + from hermes_constants import get_hermes_home # type: ignore + profile_dir = Path(get_hermes_home()) + else: + profile_dir = profiles_mod.get_profile_dir(canon) + except Exception as exc: + return DescribeOutcome(canon, False, f"cannot resolve profile dir: {exc}") + + # Honor curated descriptions unless --overwrite. + existing = profiles_mod.read_profile_meta(profile_dir) + if existing.get("description") and not existing.get("description_auto") and not overwrite: + return DescribeOutcome( + canon, + False, + "profile already has a user-authored description " + "(use --overwrite to replace)", + ) + + skill_names = _collect_skills(profile_dir) + skill_list = "\n".join(f" - {n}" for n in skill_names) or " (no skills installed)" + skill_count = sum( + 1 for _ in (profile_dir / "skills").rglob("SKILL.md") + if "/.hub/" not in str(_) and "/.git/" not in str(_) + ) if (profile_dir / "skills").is_dir() else 0 + + # Read model + provider from the profile's config. + try: + model, provider = profiles_mod._read_config_model(profile_dir) + except Exception: + model, provider = None, None + + try: + from agent.auxiliary_client import ( # type: ignore + get_auxiliary_extra_body, + get_text_auxiliary_client, + ) + except Exception as exc: + logger.debug("describe: auxiliary client import failed: %s", exc) + return DescribeOutcome(canon, False, "auxiliary client unavailable") + + try: + client, aux_model = get_text_auxiliary_client("profile_describer") + except Exception as exc: + logger.debug("describe: get_text_auxiliary_client failed: %s", exc) + return DescribeOutcome(canon, False, "auxiliary client unavailable") + + if client is None or not aux_model: + return DescribeOutcome(canon, False, "no auxiliary client configured") + + user_msg = _USER_TEMPLATE.format( + name=canon, + model=(model or "(unset)"), + provider=(provider or "(unset)"), + skill_count=skill_count, + skill_cap=MAX_SKILLS_FOR_PROMPT, + skill_list=skill_list, + ) + + try: + resp = client.chat.completions.create( + model=aux_model, + messages=[ + {"role": "system", "content": _SYSTEM_PROMPT}, + {"role": "user", "content": user_msg}, + ], + temperature=0.3, + max_tokens=400, + timeout=timeout or 60, + extra_body=get_auxiliary_extra_body() or None, + ) + except Exception as exc: + logger.info("describe: API call failed for %s (%s)", canon, exc) + return DescribeOutcome(canon, False, f"LLM error: {type(exc).__name__}") + + try: + raw = resp.choices[0].message.content or "" + except Exception: + raw = "" + + parsed = _extract_json_blob(raw) + if parsed is None: + # Fall back: take the raw text trimmed to one paragraph. + text = raw.strip().split("\n\n", 1)[0] + if not text: + return DescribeOutcome(canon, False, "LLM returned an empty response") + description = text[:280] + else: + val = parsed.get("description") + if not isinstance(val, str) or not val.strip(): + return DescribeOutcome( + canon, False, "LLM response missing 'description' field" + ) + description = val.strip()[:280] + + try: + profiles_mod.write_profile_meta( + profile_dir, + description=description, + description_auto=True, + ) + except Exception as exc: + return DescribeOutcome(canon, False, f"failed to write profile.yaml: {exc}") + + return DescribeOutcome(canon, True, "described", description=description) + + +def list_describable_profiles(*, missing_only: bool = True) -> list[str]: + """Return profile names that can be described. + + ``missing_only=True`` (default) returns only profiles without a + description. ``missing_only=False`` returns every profile. + """ + out: list[str] = [] + for p in profiles_mod.list_profiles(): + if missing_only and (p.description or "").strip() and not p.description_auto: + continue + out.append(p.name) + return out diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index de555caf9be8..d35669c62430 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -412,6 +412,17 @@ class ProfileInfo: distribution_name: Optional[str] = None distribution_version: Optional[str] = None distribution_source: Optional[str] = None + # Free-form description (1-2 sentences) of what this profile is good + # at. Persisted in ``/profile.yaml``. Empty when the + # user has not described the profile (legacy profiles, fresh + # installs). Surfaced to the kanban decomposer so it can route work + # to the right profile based on role rather than name alone. + description: str = "" + # When True, ``description`` was auto-generated by the LLM + # describer and has not been confirmed by the user. The dashboard + # surfaces a "review" badge in this case so the user can edit or + # accept. + description_auto: bool = False def _read_distribution_meta(profile_dir: Path) -> tuple: @@ -479,6 +490,82 @@ def _count_skills(profile_dir: Path) -> int: return count +# --------------------------------------------------------------------------- +# profile.yaml — per-profile metadata (description, role, etc.) +# --------------------------------------------------------------------------- +# +# We keep this file deliberately tiny and separate from the profile's +# ``config.yaml``. ``config.yaml`` is the user-facing Hermes config +# (~5000 lines of defaults); ``profile.yaml`` is metadata ABOUT the +# profile itself (its role, who described it). Mixing them makes both +# harder to read. +# +# Missing file -> empty defaults; never an error. The kanban decomposer +# tolerates empty descriptions and just falls back to the profile name. + + +def _profile_yaml_path(profile_dir: Path) -> Path: + return profile_dir / "profile.yaml" + + +def read_profile_meta(profile_dir: Path) -> dict: + """Read ``/profile.yaml`` and return a dict. + + Returns ``{"description": "", "description_auto": False}`` when the + file is missing or unreadable. Never raises — a corrupt + profile.yaml on an unrelated profile must not break + ``hermes profile list``. + """ + path = _profile_yaml_path(profile_dir) + if not path.is_file(): + return {"description": "", "description_auto": False} + try: + import yaml + with open(path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + except Exception: + return {"description": "", "description_auto": False} + if not isinstance(data, dict): + return {"description": "", "description_auto": False} + return { + "description": str(data.get("description") or "").strip(), + "description_auto": bool(data.get("description_auto", False)), + } + + +def write_profile_meta( + profile_dir: Path, + *, + description: Optional[str] = None, + description_auto: Optional[bool] = None, +) -> None: + """Update ``/profile.yaml`` in place. + + Only the explicitly passed fields are overwritten; unspecified + fields preserve existing values. Creates the file if missing. + Profile directory itself must exist. + """ + if not profile_dir.is_dir(): + raise FileNotFoundError(f"profile directory does not exist: {profile_dir}") + import yaml + path = _profile_yaml_path(profile_dir) + existing: dict = {} + if path.is_file(): + try: + with open(path, "r", encoding="utf-8") as f: + loaded = yaml.safe_load(f) or {} + if isinstance(loaded, dict): + existing = loaded + except Exception: + existing = {} + if description is not None: + existing["description"] = description.strip() + if description_auto is not None: + existing["description_auto"] = bool(description_auto) + with open(path, "w", encoding="utf-8") as f: + yaml.safe_dump(existing, f, sort_keys=False, default_flow_style=False) + + # --------------------------------------------------------------------------- # CRUD operations # --------------------------------------------------------------------------- @@ -493,6 +580,7 @@ def list_profiles() -> List[ProfileInfo]: if default_home.is_dir(): model, provider = _read_config_model(default_home) dist_name, dist_version, dist_source = _read_distribution_meta(default_home) + meta = read_profile_meta(default_home) profiles.append(ProfileInfo( name="default", path=default_home, @@ -505,6 +593,8 @@ def list_profiles() -> List[ProfileInfo]: distribution_name=dist_name, distribution_version=dist_version, distribution_source=dist_source, + description=meta.get("description", ""), + description_auto=meta.get("description_auto", False), )) # Named profiles @@ -519,6 +609,7 @@ def list_profiles() -> List[ProfileInfo]: model, provider = _read_config_model(entry) alias_path = wrapper_dir / name dist_name, dist_version, dist_source = _read_distribution_meta(entry) + meta = read_profile_meta(entry) profiles.append(ProfileInfo( name=name, path=entry, @@ -532,6 +623,8 @@ def list_profiles() -> List[ProfileInfo]: distribution_name=dist_name, distribution_version=dist_version, distribution_source=dist_source, + description=meta.get("description", ""), + description_auto=meta.get("description_auto", False), )) return profiles @@ -544,6 +637,7 @@ def create_profile( clone_config: bool = False, no_alias: bool = False, no_skills: bool = False, + description: Optional[str] = None, ) -> Path: """Create a new profile directory. @@ -667,6 +761,19 @@ def create_profile( except OSError: pass # best-effort — the feature still works via the empty skills/ dir + # Persist description if the caller provided one. Done last so a + # partial-create failure doesn't strand a description file in an + # incomplete profile. + if description and description.strip(): + try: + write_profile_meta( + profile_dir, + description=description.strip(), + description_auto=False, + ) + except Exception: + pass # non-fatal — user can describe later with `hermes profile describe` + return profile_dir diff --git a/plugins/kanban/dashboard/dist/index.js b/plugins/kanban/dashboard/dist/index.js index 6f05df72bf6e..3f6def61cef2 100644 --- a/plugins/kanban/dashboard/dist/index.js +++ b/plugins/kanban/dashboard/dist/index.js @@ -908,6 +908,7 @@ return createNewBoard(payload).then(function () { setShowNewBoard(false); }); }, }) : null, + h(OrchestrationPanel, null), h(AttentionStrip, { boardData, onOpen: setSelectedTaskId, @@ -1386,6 +1387,288 @@ }, "?"); } + // --------------------------------------------------------------------- + // OrchestrationPanel — collapsible settings panel for the kanban + // orchestrator (orchestrator profile picker, default assignee picker, + // auto-decompose toggle, plus per-profile description editing with + // auto-generate). Backed by /orchestration + /profiles endpoints. + // --------------------------------------------------------------------- + + function OrchestrationPanel() { + const [expanded, setExpanded] = useState(false); + const [settings, setSettings] = useState(null); + const [profiles, setProfiles] = useState([]); + const [busy, setBusy] = useState({}); + const [msg, setMsg] = useState(null); + + const loadAll = useCallback(function () { + Promise.all([ + SDK.fetchJSON(`${API}/orchestration`), + SDK.fetchJSON(`${API}/profiles`), + ]).then(function (results) { + setSettings(results[0] || null); + setProfiles((results[1] && results[1].profiles) || []); + setMsg(null); + }).catch(function (err) { + setMsg({ ok: false, text: "Failed to load: " + (err.message || String(err)) }); + }); + }, []); + + useEffect(function () { + // Load on mount so the collapsed pill shows the real mode without + // requiring the user to expand the panel first. + if (settings === null) loadAll(); + }, [settings, loadAll]); + + const saveSettings = function (patch) { + setMsg(null); + return SDK.fetchJSON(`${API}/orchestration`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }).then(function (res) { + setSettings(res); + setMsg({ ok: true, text: "Settings saved." }); + return res; + }).catch(function (err) { + setMsg({ ok: false, text: "Save failed: " + (err.message || String(err)) }); + }); + }; + + const saveProfileDescription = function (name, description) { + setBusy(function (b) { return Object.assign({}, b, { [name]: "save" }); }); + return SDK.fetchJSON(`${API}/profiles/${encodeURIComponent(name)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ description: description }), + }).then(function () { + loadAll(); + setMsg({ ok: true, text: `Description saved for ${name}.` }); + }).catch(function (err) { + setMsg({ ok: false, text: "Save failed: " + (err.message || String(err)) }); + }).then(function () { + setBusy(function (b) { + const next = Object.assign({}, b); delete next[name]; return next; + }); + }); + }; + + const autoGenerateDescription = function (name, overwrite) { + setBusy(function (b) { return Object.assign({}, b, { [name]: "auto" }); }); + return SDK.fetchJSON(`${API}/profiles/${encodeURIComponent(name)}/describe-auto`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ overwrite: !!overwrite }), + }).then(function (res) { + if (res && res.ok) { + loadAll(); + setMsg({ ok: true, text: `Auto-generated description for ${name}.` }); + } else { + setMsg({ + ok: false, + text: "Auto-generate failed: " + ((res && res.reason) || "unknown error"), + }); + } + }).catch(function (err) { + setMsg({ ok: false, text: "Auto-generate failed: " + (err.message || String(err)) }); + }).then(function () { + setBusy(function (b) { + const next = Object.assign({}, b); delete next[name]; return next; + }); + }); + }; + + const headerLabel = expanded + ? "▾ Orchestration settings" + : "▸ Orchestration settings"; + + // Mode pill — always visible (collapsed or expanded). One click flips + // between Auto and Manual. Auto = dispatcher decomposes new triage tasks + // every tick. Manual = pre-PR behavior, the user clicks ⚗ Decompose on + // each triage card (or runs `hermes kanban decompose `) and tasks + // stay in triage until then. + const autoOn = !!(settings && settings.auto_decompose); + const modePillTitle = settings === null + ? "Loading mode…" + : (autoOn + ? "Orchestration: Auto — the dispatcher decomposes new triage tasks automatically every tick. Click to switch to Manual (pre-PR behavior)." + : "Orchestration: Manual — triage tasks stay in triage until you click ⚗ Decompose on each card. Click to switch to Auto."); + const modePill = h("button", { + type: "button", + onClick: function () { + if (settings === null) return; // not loaded yet + saveSettings({ auto_decompose: !autoOn }); + }, + disabled: settings === null, + title: modePillTitle, + className: "inline-flex items-center gap-1 rounded-full border px-2 py-0.5 " + + "text-xs font-medium " + + (autoOn + ? "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" + : "border-muted-foreground/30 bg-muted/30 text-muted-foreground"), + }, + "Orchestration: ", + h("span", { className: "ml-1 font-semibold" }, + settings === null ? "…" : (autoOn ? "Auto" : "Manual")) + ); + + if (!expanded) { + return h("div", { className: "flex items-center gap-3 text-xs" }, + modePill, + h("button", { + type: "button", + onClick: function () { setExpanded(true); }, + className: "underline text-muted-foreground hover:text-foreground", + title: "Configure the kanban orchestrator (profile picker, default assignee, auto-decompose, profile descriptions)", + }, headerLabel), + ); + } + + const profileOptions = profiles.map(function (p) { + const tag = p.is_default ? " (default)" : ""; + return h(SelectOption, { key: p.name, value: p.name }, p.name + tag); + }); + + return h(Card, { className: "p-3" }, + h(CardContent, { className: "p-2 flex flex-col gap-3" }, + h("div", { className: "flex items-center justify-between" }, + h("button", { + type: "button", + onClick: function () { setExpanded(false); }, + className: "text-sm font-medium underline-offset-2 hover:underline", + }, headerLabel), + modePill, + h(Button, { onClick: loadAll, size: "sm" }, "Reload"), + ), + msg ? h("div", { + className: msg.ok ? "hermes-kanban-msg-ok" : "hermes-kanban-msg-err", + }, msg.text) : null, + + settings ? h("div", { className: "grid gap-3 sm:grid-cols-3" }, + h("div", { className: "flex flex-col gap-1" }, + h(Label, { className: "text-xs text-muted-foreground" }, + "Orchestrator profile"), + h(Select, { + value: settings.orchestrator_profile || "", + className: "h-8", + onChange: function (e) { + const v = (e && e.target ? e.target.value : e) || ""; + saveSettings({ orchestrator_profile: v }); + }, + }, + h(SelectOption, { value: "" }, + "(default: " + (settings.active_profile || "default") + ")"), + profileOptions, + ), + h("div", { className: "text-[10px] text-muted-foreground" }, + "Resolved: " + (settings.resolved_orchestrator_profile || "default")), + ), + h("div", { className: "flex flex-col gap-1" }, + h(Label, { className: "text-xs text-muted-foreground" }, + "Default assignee"), + h(Select, { + value: settings.default_assignee || "", + className: "h-8", + onChange: function (e) { + const v = (e && e.target ? e.target.value : e) || ""; + saveSettings({ default_assignee: v }); + }, + }, + h(SelectOption, { value: "" }, + "(default: " + (settings.active_profile || "default") + ")"), + profileOptions, + ), + h("div", { className: "text-[10px] text-muted-foreground" }, + "Resolved: " + (settings.resolved_default_assignee || "default")), + ), + h("div", { className: "flex flex-col gap-1" }, + h(Label, { className: "text-xs text-muted-foreground" }, + "Orchestration mode"), + h("label", { className: "flex items-center gap-2 text-xs h-8" }, + h("input", { + type: "checkbox", + checked: !!settings.auto_decompose, + onChange: function (e) { + saveSettings({ auto_decompose: !!e.target.checked }); + }, + }), + settings.auto_decompose ? "Auto (default)" : "Manual", + ), + h("div", { className: "text-[10px] text-muted-foreground" }, + "When on, the dispatcher decomposes new triage tasks automatically."), + ), + ) : h("div", { className: "text-xs text-muted-foreground" }, + "Loading…"), + + h("div", { className: "border-t pt-3" }, + h(Label, { className: "text-xs text-muted-foreground" }, + "Profile descriptions"), + h("div", { className: "text-[10px] text-muted-foreground pb-2" }, + "Descriptions guide the orchestrator's routing. Click ⚗ to auto-generate, or edit and save."), + profiles.length === 0 + ? h("div", { className: "text-xs text-muted-foreground" }, "No profiles installed.") + : h("div", { className: "flex flex-col gap-2" }, + profiles.map(function (p) { + return h(ProfileDescriptionRow, { + key: p.name, + profile: p, + busy: busy[p.name] || null, + onSave: saveProfileDescription, + onAuto: autoGenerateDescription, + }); + }), + ), + ), + ), + ); + } + + function ProfileDescriptionRow(props) { + const p = props.profile; + const [draft, setDraft] = useState(p.description || ""); + const busy = props.busy; + // Re-sync the local draft if the server-side description changes (e.g. + // after auto-generate). Cheap because re-runs only happen on prop change. + useEffect(function () { + setDraft(p.description || ""); + }, [p.description]); + + const tag = p.description_auto && p.description ? " [auto, review]" : ""; + return h("div", { className: "flex flex-col gap-1 border-l-2 pl-2", + style: { borderColor: p.description ? "#888" : "#cc6" } }, + h("div", { className: "flex items-center gap-2 text-xs" }, + h("span", { className: "font-medium" }, p.name), + p.is_default ? h("span", { className: "text-[10px] text-muted-foreground" }, "(default)") : null, + p.description_auto && p.description + ? h("span", { className: "text-[10px] text-yellow-600" }, "auto — review") + : null, + !p.description + ? h("span", { className: "text-[10px] text-yellow-600" }, "⚠ no description") + : null, + ), + h("div", { className: "flex items-center gap-2" }, + h(Input, { + value: draft, + onChange: function (e) { setDraft(e.target.value); }, + placeholder: "What is this profile good at?", + className: "h-7 text-xs flex-1", + }), + h(Button, { + onClick: function () { props.onSave(p.name, draft); }, + size: "sm", + disabled: !!busy || draft === (p.description || ""), + title: "Save the description above as user-authored", + }, busy === "save" ? "Saving…" : "Save"), + h(Button, { + onClick: function () { props.onAuto(p.name, true); }, + size: "sm", + disabled: !!busy, + title: "Auto-generate a description from this profile's skills and model", + }, busy === "auto" ? "Generating…" : "⚗ Auto"), + ), + ); + } + function BoardSwitcher(props) { const { t } = useI18n(); const list = props.boardList || []; @@ -2395,6 +2678,25 @@ }); }; + // POST /tasks/:id/decompose — fan a triage task out into a graph + // of child tasks routed to specialist profiles by description. + // Refreshes both the drawer (so the user sees the root flip to + // todo) and the board (so the new children appear in the columns). + const doDecompose = function () { + return SDK.fetchJSON( + withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}/decompose`, boardSlug), + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + } + ).then(function (res) { + load(); + props.onRefresh(); + return res; + }); + }; + const addLink = function (parentId) { return SDK.fetchJSON(withBoard(`${API}/links`, boardSlug), { method: "POST", @@ -2486,6 +2788,7 @@ boardSlug: boardSlug, onPatch: doPatch, onSpecify: doSpecify, + onDecompose: doDecompose, onAddParent: addLink, onRemoveParent: removeLink, onAddChild: addChild, @@ -2559,6 +2862,7 @@ task: t, onPatch: props.onPatch, onSpecify: props.onSpecify, + onDecompose: props.onDecompose, }), h(DiagnosticsSection, { task: t, @@ -3023,6 +3327,8 @@ const task = props.task; const [specifyBusy, setSpecifyBusy] = useState(false); const [specifyMsg, setSpecifyMsg] = useState(null); + const [decomposeBusy, setDecomposeBusy] = useState(false); + const [decomposeMsg, setDecomposeMsg] = useState(null); const b = function (label, patch, enabled, confirmMsg) { return h(Button, { onClick: function () { if (enabled !== false) props.onPatch(patch, { confirm: confirmMsg }); }, @@ -3067,9 +3373,57 @@ }, specifyBusy ? "Specifying…" : "✨ Specify") : null; + // "Decompose" is the orchestrator-driven fan-out. Like Specify, only + // makes sense on triage-column tasks — elsewhere the backend short- + // circuits with ok:false. When the orchestrator returns fanout:false + // we render the same single-task message as Specify; when it fans + // out we report the child count for quick at-a-glance verification. + const decomposeButton = (task.status === "triage" && props.onDecompose) + ? h(Button, { + onClick: function () { + if (decomposeBusy) return; + setDecomposeBusy(true); + setDecomposeMsg(null); + props.onDecompose().then(function (res) { + if (res && res.ok) { + if (res.fanout && res.child_ids && res.child_ids.length) { + setDecomposeMsg({ + ok: true, + text: `Decomposed into ${res.child_ids.length} children: ${res.child_ids.join(", ")}`, + }); + } else { + const suffix = res.new_title + ? ` — retitled: ${res.new_title}` + : ""; + setDecomposeMsg({ + ok: true, + text: `Single task (no fanout)${suffix}`, + }); + } + } else { + setDecomposeMsg({ + ok: false, + text: "Decompose failed: " + ((res && res.reason) || "unknown error"), + }); + } + }).catch(function (err) { + setDecomposeMsg({ + ok: false, + text: "Decompose failed: " + (err.message || String(err)), + }); + }).then(function () { + setDecomposeBusy(false); + }); + }, + disabled: decomposeBusy, + size: "sm", + }, decomposeBusy ? "Decomposing…" : "⚗ Decompose") + : null; + return h("div", null, h("div", { className: "hermes-kanban-actions" }, specifyButton, + decomposeButton, b("→ triage", { status: "triage" }, task.status !== "triage"), b("→ ready", { status: "ready" }, task.status !== "ready"), // No direct → running button: /tasks/:id PATCH rejects status=running @@ -3091,6 +3445,11 @@ ? "hermes-kanban-msg-ok" : "hermes-kanban-msg-err", }, specifyMsg.text) : null, + decomposeMsg ? h("div", { + className: decomposeMsg.ok + ? "hermes-kanban-msg-ok" + : "hermes-kanban-msg-err", + }, decomposeMsg.text) : null, ); } diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index 08824e3807b2..16e606638549 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -1535,6 +1535,279 @@ def switch_board(slug: str): _EVENT_POLL_SECONDS = 0.3 +# --------------------------------------------------------------------------- +# Profile metadata & description editing (consumed by the kanban orchestrator) +# --------------------------------------------------------------------------- + +class DescribeBody(BaseModel): + description: Optional[str] = None # explicit user-authored text + + +class DescribeAutoBody(BaseModel): + overwrite: bool = False + + +@router.get("/profiles") +def list_profile_roster(): + """Return every installed profile with its description. + + Consumed by the dashboard's settings panel (orchestrator picker) + and the profile-description editing UI. Profiles without a + description still appear here — they're routable on name alone, + just less precisely. + """ + try: + from hermes_cli import profiles as profiles_mod + profiles = profiles_mod.list_profiles() + except Exception as exc: + raise HTTPException(status_code=500, detail=f"failed to list profiles: {exc}") + return { + "profiles": [ + { + "name": p.name, + "is_default": bool(p.is_default), + "model": p.model or "", + "provider": p.provider or "", + "description": p.description or "", + "description_auto": bool(p.description_auto), + "skill_count": int(p.skill_count or 0), + } + for p in profiles + ], + } + + +@router.patch("/profiles/{profile_name}") +def update_profile_description(profile_name: str, payload: DescribeBody): + """Set or clear the description of a profile. + + Empty string clears the description; non-empty stores it as a + user-authored description (``description_auto: false``) so the + auto-describer won't overwrite it on a sweep without + ``--overwrite``. + """ + try: + from hermes_cli import profiles as profiles_mod + canon = profiles_mod.normalize_profile_name(profile_name) + if canon == "default": + from hermes_constants import get_hermes_home # type: ignore + from pathlib import Path as _Path + profile_dir = _Path(get_hermes_home()) + else: + profile_dir = profiles_mod.get_profile_dir(canon) + if not profile_dir.is_dir(): + raise HTTPException(status_code=404, detail=f"profile '{profile_name}' not found") + text = (payload.description or "").strip() + profiles_mod.write_profile_meta( + profile_dir, + description=text, + description_auto=False, + ) + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=500, detail=f"failed to update profile: {exc}") + return {"ok": True, "profile": canon, "description": text} + + +@router.post("/profiles/{profile_name}/describe-auto") +def auto_describe_profile(profile_name: str, payload: DescribeAutoBody): + """Generate a description for the named profile via the auxiliary + LLM (``auxiliary.profile_describer``). Persists with + ``description_auto: true`` so the dashboard can surface a "review" + badge. + + Maps 1:1 to ``hermes profile describe --auto``. Non-OK + outcomes are NOT HTTP errors — the UI renders the reason inline + (e.g. "no auxiliary client configured") so the operator can fix + config and retry without a page reload. + """ + try: + from hermes_cli import profile_describer # noqa: WPS433 (intentional) + outcome = profile_describer.describe_profile( + profile_name, + overwrite=bool(payload.overwrite), + ) + except Exception as exc: + raise HTTPException(status_code=500, detail=f"describer crashed: {exc}") + return { + "ok": bool(outcome.ok), + "profile": outcome.profile_name, + "reason": outcome.reason, + "description": outcome.description, + } + + +# --------------------------------------------------------------------------- +# Decompose endpoint (orchestrator-driven fan-out) +# --------------------------------------------------------------------------- + +class DecomposeBody(BaseModel): + author: Optional[str] = None + + +@router.post("/tasks/{task_id}/decompose") +def decompose_task_endpoint( + task_id: str, + payload: DecomposeBody, + board: Optional[str] = Query(None), +): + """Fan a triage-column task out into a graph of child tasks via the + auxiliary LLM, routed to specialist profiles by description. Maps + 1:1 to ``hermes kanban decompose ``. + + Returns the outcome shape used by the CLI: ``{ok, task_id, reason, + fanout, child_ids, new_title}``. A non-OK outcome is NOT an HTTP + error — the UI renders the reason inline. + + Runs in FastAPI's threadpool (sync ``def``) because the LLM call + can take minutes on reasoning models. + """ + board = _resolve_board(board) + prev_env = os.environ.get("HERMES_KANBAN_BOARD") + try: + os.environ["HERMES_KANBAN_BOARD"] = board or kanban_db.DEFAULT_BOARD + from hermes_cli import kanban_decompose # noqa: WPS433 (intentional) + outcome = kanban_decompose.decompose_task( + task_id, + author=(payload.author or None), + ) + finally: + if prev_env is None: + os.environ.pop("HERMES_KANBAN_BOARD", None) + else: + os.environ["HERMES_KANBAN_BOARD"] = prev_env + + return { + "ok": bool(outcome.ok), + "task_id": outcome.task_id, + "reason": outcome.reason, + "fanout": bool(outcome.fanout), + "child_ids": outcome.child_ids or [], + "new_title": outcome.new_title, + } + + +# --------------------------------------------------------------------------- +# Orchestration settings (kanban.orchestrator_profile / default_assignee / +# auto_decompose) — surfaced to the dashboard's settings panel +# --------------------------------------------------------------------------- + +class OrchestrationSettingsBody(BaseModel): + orchestrator_profile: Optional[str] = None + default_assignee: Optional[str] = None + auto_decompose: Optional[bool] = None + + +@router.get("/orchestration") +def get_orchestration_settings(): + """Return the current kanban orchestration knobs from config.yaml + plus the resolved effective values (filling in fallbacks).""" + try: + from hermes_cli.config import load_config + cfg = load_config() or {} + except Exception: + cfg = {} + kanban_cfg = (cfg.get("kanban") or {}) if isinstance(cfg, dict) else {} + explicit_orch = (kanban_cfg.get("orchestrator_profile") or "").strip() + explicit_default = (kanban_cfg.get("default_assignee") or "").strip() + auto_decompose = bool(kanban_cfg.get("auto_decompose", True)) + + # Resolve fallbacks the same way the decomposer does. + resolved_orch = explicit_orch + resolved_default = explicit_default + try: + from hermes_cli import profiles as profiles_mod + active_default = profiles_mod.get_active_profile_name() or "default" + if not resolved_orch or not profiles_mod.profile_exists(resolved_orch): + resolved_orch = active_default + if not resolved_default or not profiles_mod.profile_exists(resolved_default): + resolved_default = active_default + except Exception: + active_default = "default" + if not resolved_orch: + resolved_orch = active_default + if not resolved_default: + resolved_default = active_default + + return { + "orchestrator_profile": explicit_orch, + "default_assignee": explicit_default, + "auto_decompose": auto_decompose, + "resolved_orchestrator_profile": resolved_orch, + "resolved_default_assignee": resolved_default, + "active_profile": active_default, + } + + +@router.put("/orchestration") +def set_orchestration_settings(payload: OrchestrationSettingsBody): + """Update the kanban orchestration knobs in ~/.hermes/config.yaml. + + Each field is optional — only fields explicitly passed are + written. ``orchestrator_profile`` / ``default_assignee`` accept + empty strings to clear the override and fall back to the default + profile. + """ + try: + from hermes_cli.config import load_config, save_config + cfg = load_config() or {} + except Exception as exc: + raise HTTPException(status_code=500, detail=f"failed to load config: {exc}") + + kanban_section = cfg.setdefault("kanban", {}) + if not isinstance(kanban_section, dict): + kanban_section = {} + cfg["kanban"] = kanban_section + + # Validate any non-empty profile names exist before saving. + try: + from hermes_cli import profiles as profiles_mod + except Exception: + profiles_mod = None # type: ignore + + if payload.orchestrator_profile is not None: + name = (payload.orchestrator_profile or "").strip() + if name and profiles_mod is not None: + try: + if not profiles_mod.profile_exists(name): + raise HTTPException( + status_code=400, + detail=f"profile '{name}' does not exist", + ) + except HTTPException: + raise + except Exception: + pass # fail open if the lookup itself errors + kanban_section["orchestrator_profile"] = name + + if payload.default_assignee is not None: + name = (payload.default_assignee or "").strip() + if name and profiles_mod is not None: + try: + if not profiles_mod.profile_exists(name): + raise HTTPException( + status_code=400, + detail=f"profile '{name}' does not exist", + ) + except HTTPException: + raise + except Exception: + pass + kanban_section["default_assignee"] = name + + if payload.auto_decompose is not None: + kanban_section["auto_decompose"] = bool(payload.auto_decompose) + + try: + save_config(cfg) + except Exception as exc: + raise HTTPException(status_code=500, detail=f"failed to save config: {exc}") + + # Echo back the resolved state (callers usually re-render from it). + return get_orchestration_settings() + + @router.websocket("/events") async def stream_events(ws: WebSocket): # Enforce the dashboard session token as a query param — browsers can't diff --git a/tests/hermes_cli/test_kanban_decompose.py b/tests/hermes_cli/test_kanban_decompose.py new file mode 100644 index 000000000000..f55e10e2f8e7 --- /dev/null +++ b/tests/hermes_cli/test_kanban_decompose.py @@ -0,0 +1,242 @@ +"""Tests for the decomposer module + `hermes kanban decompose` CLI surface. + +The auxiliary LLM client is mocked — no network calls. Tests exercise the +prompt plumbing, response parsing, DB writes (via the real DB helper), +and the assignee-fallback logic. +""" + +from __future__ import annotations + +import argparse +import json as jsonlib +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from hermes_cli import kanban as kanban_cli +from hermes_cli import kanban_db as kb +from hermes_cli import kanban_decompose as decomp + + +@pytest.fixture +def kanban_home(tmp_path, monkeypatch): + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + kb.init_db() + return home + + +def _fake_aux_response(content: str): + resp = MagicMock() + resp.choices = [MagicMock()] + resp.choices[0].message.content = content + return resp + + +def _mock_client_returning(content: str): + client = MagicMock() + client.chat.completions.create = MagicMock(return_value=_fake_aux_response(content)) + return client + + +def _patch_aux_client(content: str, *, model: str = "test-model"): + client = _mock_client_returning(content) + return patch( + "agent.auxiliary_client.get_text_auxiliary_client", + return_value=(client, model), + ) + + +def _patch_extra_body(): + return patch( + "agent.auxiliary_client.get_auxiliary_extra_body", + return_value={}, + ) + + +def _patch_list_profiles(names: list[str]): + """Pretend the named profiles exist. The decomposer uses + profiles_mod.list_profiles() to build the roster + valid-set, and + profiles_mod.profile_exists() to resolve orchestrator/default.""" + from types import SimpleNamespace + fake_profiles = [ + SimpleNamespace( + name=n, is_default=(i == 0), description=f"desc for {n}", + description_auto=False, model="m", provider="p", skill_count=1, + ) + for i, n in enumerate(names) + ] + return [ + patch("hermes_cli.profiles.list_profiles", return_value=fake_profiles), + patch("hermes_cli.profiles.profile_exists", side_effect=lambda x: x in names), + patch("hermes_cli.profiles.get_active_profile_name", return_value=names[0] if names else "default"), + ] + + +def test_decompose_with_fanout_creates_children(kanban_home): + with kb.connect() as conn: + tid = kb.create_task(conn, title="ship a feature", triage=True) + + llm_payload = jsonlib.dumps({ + "fanout": True, + "rationale": "test split", + "tasks": [ + {"title": "research", "body": "look it up", "assignee": "researcher", "parents": []}, + {"title": "build", "body": "code it", "assignee": "engineer", "parents": [0]}, + ], + }) + + patches = _patch_list_profiles(["orchestrator", "researcher", "engineer"]) + for p in patches: + p.start() + try: + with _patch_aux_client(llm_payload), _patch_extra_body(): + outcome = decomp.decompose_task(tid, author="me") + finally: + for p in patches: + p.stop() + + assert outcome.ok, outcome.reason + assert outcome.fanout is True + assert outcome.child_ids and len(outcome.child_ids) == 2 + + with kb.connect() as conn: + root = kb.get_task(conn, tid) + c0 = kb.get_task(conn, outcome.child_ids[0]) + c1 = kb.get_task(conn, outcome.child_ids[1]) + assert root.status == "todo" + assert c0.status == "ready" + assert c1.status == "todo" + assert c0.assignee == "researcher" + assert c1.assignee == "engineer" + + +def test_decompose_fanout_false_falls_back_to_specify(kanban_home): + with kb.connect() as conn: + tid = kb.create_task(conn, title="just one thing", triage=True) + + llm_payload = jsonlib.dumps({ + "fanout": False, + "rationale": "single unit", + "title": "Tightened title", + "body": "**Goal**\nDo the thing.", + }) + + patches = _patch_list_profiles(["orchestrator"]) + for p in patches: + p.start() + try: + with _patch_aux_client(llm_payload), _patch_extra_body(): + outcome = decomp.decompose_task(tid, author="me") + finally: + for p in patches: + p.stop() + + assert outcome.ok, outcome.reason + assert outcome.fanout is False + assert outcome.new_title == "Tightened title" + with kb.connect() as conn: + task = kb.get_task(conn, tid) + # specify path with no parents -> recompute_ready flips to 'ready' + assert task.status == "ready" + assert task.title == "Tightened title" + + +def test_decompose_unknown_assignee_falls_back_to_default(kanban_home): + with kb.connect() as conn: + tid = kb.create_task(conn, title="x", triage=True) + + # Roster only has 'orchestrator' and 'fallback'; LLM picks 'made_up'. + llm_payload = jsonlib.dumps({ + "fanout": True, + "rationale": "test", + "tasks": [ + {"title": "do X", "body": "", "assignee": "made_up", "parents": []}, + ], + }) + + patches = _patch_list_profiles(["orchestrator", "fallback"]) + for p in patches: + p.start() + try: + with patch.dict( + "os.environ", {}, clear=False, + ), _patch_aux_client(llm_payload), _patch_extra_body(), \ + patch( + "hermes_cli.kanban_decompose._load_config", + return_value={ + "kanban": { + "orchestrator_profile": "orchestrator", + "default_assignee": "fallback", + } + }, + ): + outcome = decomp.decompose_task(tid, author="me") + finally: + for p in patches: + p.stop() + + assert outcome.ok, outcome.reason + assert outcome.child_ids and len(outcome.child_ids) == 1 + with kb.connect() as conn: + child = kb.get_task(conn, outcome.child_ids[0]) + # 'made_up' wasn't in roster, so assignee rewritten to 'fallback' + assert child.assignee == "fallback" + + +def test_decompose_handles_malformed_llm_json(kanban_home): + with kb.connect() as conn: + tid = kb.create_task(conn, title="x", triage=True) + + patches = _patch_list_profiles(["orchestrator"]) + for p in patches: + p.start() + try: + with _patch_aux_client("not json at all, sorry"), _patch_extra_body(): + outcome = decomp.decompose_task(tid, author="me") + finally: + for p in patches: + p.stop() + + assert outcome.ok is False + assert "malformed JSON" in outcome.reason + + +def test_decompose_returns_false_when_task_not_triage(kanban_home): + with kb.connect() as conn: + tid = kb.create_task(conn, title="x") # ready, not triage + + patches = _patch_list_profiles(["orchestrator"]) + for p in patches: + p.start() + try: + outcome = decomp.decompose_task(tid, author="me") + finally: + for p in patches: + p.stop() + assert outcome.ok is False + assert "not in triage" in outcome.reason + + +def test_decompose_no_aux_client_configured(kanban_home): + with kb.connect() as conn: + tid = kb.create_task(conn, title="x", triage=True) + + patches = _patch_list_profiles(["orchestrator"]) + for p in patches: + p.start() + try: + with patch( + "agent.auxiliary_client.get_text_auxiliary_client", + return_value=(None, ""), + ): + outcome = decomp.decompose_task(tid, author="me") + finally: + for p in patches: + p.stop() + + assert outcome.ok is False + assert "no auxiliary client" in outcome.reason diff --git a/tests/hermes_cli/test_kanban_decompose_db.py b/tests/hermes_cli/test_kanban_decompose_db.py new file mode 100644 index 000000000000..236fb1fff1ba --- /dev/null +++ b/tests/hermes_cli/test_kanban_decompose_db.py @@ -0,0 +1,152 @@ +"""Tests for kb.decompose_triage_task — the DB-layer atomic fan-out +from the triage column. LLM-free by design. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from hermes_cli import kanban_db as kb + + +@pytest.fixture +def kanban_home(tmp_path, monkeypatch): + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + kb.init_db() + return home + + +def _create_triage(conn, title="rough idea", body=None, assignee=None, tenant=None): + return kb.create_task( + conn, + title=title, + body=body, + assignee=assignee, + tenant=tenant, + triage=True, + ) + + +def test_decompose_creates_children_and_promotes_root(kanban_home): + with kb.connect() as conn: + tid = _create_triage(conn, title="ship a feature") + assert kb.get_task(conn, tid).status == "triage" + + children = [ + {"title": "research", "body": "look at prior art", "assignee": "researcher", "parents": []}, + {"title": "build it", "body": "write code", "assignee": "engineer", "parents": [0]}, + ] + with kb.connect() as conn: + child_ids = kb.decompose_triage_task( + conn, + tid, + root_assignee="orchestrator", + children=children, + author="decomposer", + ) + assert child_ids is not None + assert len(child_ids) == 2 + + with kb.connect() as conn: + root = kb.get_task(conn, tid) + c0 = kb.get_task(conn, child_ids[0]) + c1 = kb.get_task(conn, child_ids[1]) + + # Root flipped to todo with orchestrator assignee, gated by children. + assert root.status == "todo" + assert root.assignee == "orchestrator" + # First child has no internal parents → ready on recompute_ready. + assert c0.status == "ready" + assert c0.assignee == "researcher" + # Second child has parents=[0] → stays in todo until c0 completes. + assert c1.status == "todo" + assert c1.assignee == "engineer" + + +def test_decompose_returns_none_when_task_missing(kanban_home): + with kb.connect() as conn: + result = kb.decompose_triage_task( + conn, + "nonexistent", + root_assignee="orch", + children=[{"title": "x"}], + author="me", + ) + assert result is None + + +def test_decompose_returns_none_when_task_not_in_triage(kanban_home): + with kb.connect() as conn: + tid = kb.create_task(conn, title="already a real task") # not triage + result = kb.decompose_triage_task( + conn, + tid, + root_assignee="orch", + children=[{"title": "x"}], + author="me", + ) + assert result is None + + +def test_decompose_empty_children_returns_none(kanban_home): + with kb.connect() as conn: + tid = _create_triage(conn) + result = kb.decompose_triage_task( + conn, + tid, + root_assignee="orch", + children=[], + author="me", + ) + assert result is None + + +def test_decompose_rejects_self_parent(kanban_home): + with kb.connect() as conn: + tid = _create_triage(conn) + with pytest.raises(ValueError, match="cannot list itself"): + kb.decompose_triage_task( + conn, + tid, + root_assignee="orch", + children=[{"title": "x", "parents": [0]}], + author="me", + ) + + +def test_decompose_rejects_out_of_range_parent(kanban_home): + with kb.connect() as conn: + tid = _create_triage(conn) + with pytest.raises(ValueError, match="not a valid index"): + kb.decompose_triage_task( + conn, + tid, + root_assignee="orch", + children=[{"title": "x", "parents": [5]}], + author="me", + ) + + +def test_decompose_records_audit_comment_and_event(kanban_home): + with kb.connect() as conn: + tid = _create_triage(conn) + child_ids = kb.decompose_triage_task( + conn, + tid, + root_assignee="orch", + children=[{"title": "task A", "assignee": "researcher"}], + author="alice", + ) + assert child_ids is not None + + with kb.connect() as conn: + comments = kb.list_comments(conn, tid) + events = kb.list_events(conn, tid) + + assert any("Decomposed into" in (c.body or "") for c in comments) + assert any(ev.kind == "decomposed" for ev in events) diff --git a/tests/hermes_cli/test_profile_describer.py b/tests/hermes_cli/test_profile_describer.py new file mode 100644 index 000000000000..3fc5fa3a6be3 --- /dev/null +++ b/tests/hermes_cli/test_profile_describer.py @@ -0,0 +1,168 @@ +"""Tests for the profile.yaml metadata layer (description + description_auto) +and the profile_describer LLM module. +""" + +from __future__ import annotations + +import json as jsonlib +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from hermes_cli import profiles as profiles_mod +from hermes_cli import profile_describer as describer + + +@pytest.fixture +def profile_env(tmp_path, monkeypatch): + """Set up an isolated HERMES_HOME with a default profile dir.""" + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + return home + + +def test_read_profile_meta_empty_when_missing(profile_env): + meta = profiles_mod.read_profile_meta(profile_env) + assert meta == {"description": "", "description_auto": False} + + +def test_write_and_read_profile_meta(profile_env): + profiles_mod.write_profile_meta( + profile_env, + description="a useful researcher", + description_auto=False, + ) + meta = profiles_mod.read_profile_meta(profile_env) + assert meta["description"] == "a useful researcher" + assert meta["description_auto"] is False + + +def test_write_profile_meta_preserves_other_fields(profile_env): + # First write sets description_auto=True; second write only updates + # description and leaves description_auto unchanged. + profiles_mod.write_profile_meta( + profile_env, + description="auto-gen", + description_auto=True, + ) + profiles_mod.write_profile_meta(profile_env, description="edited by hand") + meta = profiles_mod.read_profile_meta(profile_env) + assert meta["description"] == "edited by hand" + assert meta["description_auto"] is True + + +def test_write_profile_meta_rejects_missing_dir(tmp_path): + bogus = tmp_path / "does_not_exist" + with pytest.raises(FileNotFoundError): + profiles_mod.write_profile_meta(bogus, description="x") + + +def test_read_profile_meta_tolerates_corrupt_yaml(profile_env): + (profile_env / "profile.yaml").write_text("not: valid: yaml: [unclosed") + meta = profiles_mod.read_profile_meta(profile_env) + assert meta == {"description": "", "description_auto": False} + + +# --------------------------------------------------------------------------- +# profile_describer module +# --------------------------------------------------------------------------- + + +def _fake_aux_response(content: str): + resp = MagicMock() + resp.choices = [MagicMock()] + resp.choices[0].message.content = content + return resp + + +def _patch_aux_client(content: str): + client = MagicMock() + client.chat.completions.create = MagicMock(return_value=_fake_aux_response(content)) + return patch( + "agent.auxiliary_client.get_text_auxiliary_client", + return_value=(client, "test-model"), + ) + + +def test_describer_writes_description_with_auto_true(profile_env, monkeypatch): + # Pretend "myprof" is a registered profile pointing at profile_env. + monkeypatch.setattr( + profiles_mod, "profile_exists", lambda n: n == "myprof", + ) + monkeypatch.setattr( + profiles_mod, "normalize_profile_name", lambda n: n, + ) + monkeypatch.setattr( + profiles_mod, "get_profile_dir", lambda n: profile_env, + ) + + payload = jsonlib.dumps({"description": "writes Python codebases"}) + with _patch_aux_client(payload), patch( + "agent.auxiliary_client.get_auxiliary_extra_body", return_value={} + ): + outcome = describer.describe_profile("myprof") + + assert outcome.ok, outcome.reason + assert outcome.description == "writes Python codebases" + meta = profiles_mod.read_profile_meta(profile_env) + assert meta["description"] == "writes Python codebases" + assert meta["description_auto"] is True + + +def test_describer_refuses_to_overwrite_user_authored(profile_env, monkeypatch): + profiles_mod.write_profile_meta( + profile_env, description="curated", description_auto=False, + ) + monkeypatch.setattr(profiles_mod, "profile_exists", lambda n: n == "myprof") + monkeypatch.setattr(profiles_mod, "normalize_profile_name", lambda n: n) + monkeypatch.setattr(profiles_mod, "get_profile_dir", lambda n: profile_env) + + outcome = describer.describe_profile("myprof") + assert outcome.ok is False + assert "already has a user-authored description" in outcome.reason + # Description unchanged + assert profiles_mod.read_profile_meta(profile_env)["description"] == "curated" + + +def test_describer_overwrite_flag_replaces_user_authored(profile_env, monkeypatch): + profiles_mod.write_profile_meta( + profile_env, description="curated", description_auto=False, + ) + monkeypatch.setattr(profiles_mod, "profile_exists", lambda n: n == "myprof") + monkeypatch.setattr(profiles_mod, "normalize_profile_name", lambda n: n) + monkeypatch.setattr(profiles_mod, "get_profile_dir", lambda n: profile_env) + + payload = jsonlib.dumps({"description": "new auto-gen"}) + with _patch_aux_client(payload), patch( + "agent.auxiliary_client.get_auxiliary_extra_body", return_value={} + ): + outcome = describer.describe_profile("myprof", overwrite=True) + assert outcome.ok, outcome.reason + meta = profiles_mod.read_profile_meta(profile_env) + assert meta["description"] == "new auto-gen" + assert meta["description_auto"] is True + + +def test_describer_handles_malformed_llm_response(profile_env, monkeypatch): + monkeypatch.setattr(profiles_mod, "profile_exists", lambda n: n == "myprof") + monkeypatch.setattr(profiles_mod, "normalize_profile_name", lambda n: n) + monkeypatch.setattr(profiles_mod, "get_profile_dir", lambda n: profile_env) + + # Non-JSON: describer falls back to taking the first paragraph as the description. + with _patch_aux_client("Plain text description that sneaks in"), patch( + "agent.auxiliary_client.get_auxiliary_extra_body", return_value={} + ): + outcome = describer.describe_profile("myprof") + assert outcome.ok + assert "Plain text description" in (outcome.description or "") + + +def test_describer_returns_false_when_profile_missing(profile_env, monkeypatch): + monkeypatch.setattr(profiles_mod, "profile_exists", lambda n: False) + monkeypatch.setattr(profiles_mod, "normalize_profile_name", lambda n: n) + outcome = describer.describe_profile("ghost") + assert outcome.ok is False + assert "not found" in outcome.reason diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 4cfc80191f15..37e52707cae8 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -411,6 +411,7 @@ Multi-profile, multi-project collaboration board. Each install can host many boa | `dispatch` | One dispatcher pass on the active board. Flags: `--dry-run`, `--max N`, `--json`. | | `context ` | Print the full context a worker would see (title + body + parent results + comments). | | `specify ` / `specify --all` | Flesh out a triage-column task into a concrete spec (title + body with goal, approach, acceptance criteria) via the auxiliary LLM, then promote it to `todo`. Flags: `--tenant` (scope `--all` to one tenant), `--author`, `--json`. Configure the model under `auxiliary.triage_specifier` in `config.yaml`. | +| `decompose ` / `decompose --all` | Fan a triage-column task out into a graph of child tasks routed to specialist profiles by description (the orchestrator-driven path). Falls back to specify-style single-task promotion when the LLM decides the task doesn't benefit from fan-out. Same flags as `specify`. Configure the model under `auxiliary.kanban_decomposer` in `config.yaml`. Also runs automatically every dispatcher tick when `kanban.auto_decompose: true` (the default). See [Auto vs Manual orchestration](/docs/user-guide/features/kanban#auto-vs-manual-orchestration). | | `gc` | Remove scratch workspaces for archived tasks. | Examples: diff --git a/website/docs/reference/profile-commands.md b/website/docs/reference/profile-commands.md index 376394a637ed..467134b6d052 100644 --- a/website/docs/reference/profile-commands.md +++ b/website/docs/reference/profile-commands.md @@ -83,6 +83,7 @@ Creates a new profile. | `--clone-all` | Copy everything (config, memories, skills, sessions, state) from the current profile. | | `--clone-from ` | Clone from a specific profile instead of the current one. Used with `--clone` or `--clone-all`. | | `--no-alias` | Skip wrapper script creation. | +| `--description ""` | One- or two-sentence description of what this profile is good at. Used by the kanban orchestrator to route tasks based on role instead of profile name alone. Skip and add later via `hermes profile describe`. Persisted in `/profile.yaml`. | Creating a profile does **not** make that profile directory the default project/workspace directory for terminal commands. If you want a profile to start in a specific project, set `terminal.cwd` in that profile's `config.yaml`. @@ -102,6 +103,40 @@ hermes profile create backup --clone-all hermes profile create work2 --clone --clone-from work ``` +## `hermes profile describe` + +```bash +hermes profile describe [] [options] +``` + +Read or set a profile's description. The description is consumed by the kanban orchestrator to route tasks based on what each profile is good at, rather than guessing from the profile name alone. Persisted in `/profile.yaml` so it survives reboots and is shared with the gateway. + +With no flags, prints the current description (or `(no description set for '')` if empty). + +| Argument / Option | Description | +|-------------------|-------------| +| `` | Profile to describe. Required unless `--all --auto` is used. | +| `--text ""` | Set the description to this exact text (user-authored). Overwrites any existing description. | +| `--auto` | Auto-generate a 1-2 sentence description via the auxiliary LLM, based on the profile's installed skills, configured model, and name. Configure the model under `auxiliary.profile_describer` in `config.yaml`. Auto-generated descriptions are marked `description_auto: true` so the dashboard can flag them for review. | +| `--overwrite` | With `--auto`, replace user-authored descriptions too (default: skip profiles whose description was set explicitly). | +| `--all` | With `--auto`, sweep every profile missing a description. | + +**Examples:** + +```bash +# Read the current description +hermes profile describe researcher + +# Set it explicitly +hermes profile describe researcher --text "Reads source code and writes findings." + +# Let the LLM generate one +hermes profile describe researcher --auto + +# Fill in descriptions for every profile that doesn't have one +hermes profile describe --all --auto +``` + ## `hermes profile delete` ```bash diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 5ac0d8c9df26..d972b38b3848 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -785,6 +785,8 @@ $ hermes model [ ] compression currently: auto / main model [ ] approval currently: auto / main model [ ] triage_specifier currently: auto / main model +[ ] kanban_decomposer currently: auto / main model +[ ] profile_describer currently: auto / main model ``` Select a task, pick a provider (OAuth flows open a browser; API-key providers prompt), pick a model. The change persists to `auxiliary..*` in `config.yaml`. Same machinery as the main-model picker — no extra syntax to learn. diff --git a/website/docs/user-guide/features/kanban-tutorial.md b/website/docs/user-guide/features/kanban-tutorial.md index 5f79569c7bc4..88a0f9cf5ec8 100644 --- a/website/docs/user-guide/features/kanban-tutorial.md +++ b/website/docs/user-guide/features/kanban-tutorial.md @@ -22,7 +22,7 @@ Throughout the tutorial, **code blocks labelled `bash` are commands *you* run.** Six columns, left to right: -- **Triage** — raw ideas, a specifier will flesh out the spec before anyone works on them. Click the **✨ Specify** button on any triage card (or run `hermes kanban specify ` / `/kanban specify ` from a chat) to have the auxiliary LLM turn a one-liner into a full spec (goal, approach, acceptance criteria) and promote it to `todo` in one shot. Configure which model runs it under `auxiliary.triage_specifier` in `config.yaml`. +- **Triage** — raw ideas. By default the dispatcher auto-runs the **decomposer** (orchestrator-driven fan-out) on tasks here: it reads your profile roster + descriptions and produces a graph of child tasks routed to the best-fit specialists, with the original task held alive as the parent so the orchestrator wakes back up to judge completion when everything finishes. Flip the **Orchestration: Auto/Manual** pill at the top of the kanban page to switch modes. In Manual mode (or for setups without an orchestrator profile) click **⚗ Decompose** on a card, or run `hermes kanban decompose ` / `/kanban decompose `. For single tasks that don't need fan-out, **✨ Specify** does a one-shot spec rewrite (goal, approach, acceptance criteria) and promotes to `todo`. Configure the models under `auxiliary.kanban_decomposer` and `auxiliary.triage_specifier` in `config.yaml`. See [Auto vs Manual orchestration](./kanban#auto-vs-manual-orchestration) in the main Kanban guide. - **Todo** — created but waiting on dependencies, or not yet assigned. - **Ready** — assigned and waiting for the dispatcher to claim. - **In progress** — a worker is actively running the task. With "Lanes by profile" on (the default), this column sub-groups by assignee so you can see at a glance what each worker is doing. diff --git a/website/docs/user-guide/features/kanban.md b/website/docs/user-guide/features/kanban.md index 91c6dacde679..7328fc4b6157 100644 --- a/website/docs/user-guide/features/kanban.md +++ b/website/docs/user-guide/features/kanban.md @@ -444,7 +444,7 @@ hermes dashboard # "Kanban" tab appears in the nav, after "Skills" ### What the plugin gives you - A **Kanban** tab showing one column per status: `triage`, `todo`, `ready`, `running`, `blocked`, `done` (plus `archived` when the toggle is on). - - `triage` is the parking column for rough ideas a specifier is expected to flesh out. Tasks created with `hermes kanban create --triage` (or via the Triage column's inline create) land here and the dispatcher leaves them alone until a human or specifier promotes them to `todo` / `ready`. Run `hermes kanban specify ` to have the auxiliary LLM expand a triage task into a concrete spec (title + body with goal, approach, acceptance criteria) and promote it to `todo` in one shot; `--all` sweeps every triage task at once. Configure which model runs the specifier under `auxiliary.triage_specifier` in `config.yaml`. + - `triage` is the parking column for rough ideas. By default (`kanban.auto_decompose: true`), the dispatcher auto-runs the **decomposer** on tasks that land here — the orchestrator profile reads the rough idea, looks at your profile roster (with descriptions), and fans the task out into a small graph of child tasks routed to the best-fit specialists. The original task stays alive as the parent of every child so the orchestrator wakes back up to judge completion when everything finishes. Flip the **Orchestration: Auto/Manual** pill at the top of the page (or set `kanban.auto_decompose: false`) to switch to manual mode, where triage tasks stay put until you click **⚗ Decompose** on a card or run `hermes kanban decompose `. For tasks that don't need fan-out (or for setups without an orchestrator profile), the **✨ Specify** button does a single-task spec rewrite (title + body with goal, approach, acceptance criteria) via the same LLM machinery. See [Auto vs Manual orchestration](#auto-vs-manual-orchestration) below. - Cards show the task id, title, priority badge, tenant tag, assigned profile, comment/link counts, a **progress pill** (`N/M` children done when the task has dependents), and "created N ago". A per-card checkbox enables multi-select. - **Per-profile lanes inside Running** — toolbar checkbox toggles sub-grouping of the Running column by assignee. - **Live updates via WebSocket** — the plugin tails the append-only `task_events` table on a short poll interval; the board reflects changes the instant any profile (CLI, gateway, or another dashboard tab) acts. Reloads are debounced so a burst of events triggers a single refetch. @@ -456,12 +456,40 @@ hermes dashboard # "Kanban" tab appears in the nav, after "Skills" - **Editable assignee / priority** — click the meta row to rewrite. - **Editable description** — markdown-rendered by default (headings, bold, italic, inline code, fenced code, `http(s)` / `mailto:` links, bullet lists), with an "edit" button that swaps in a textarea. Markdown rendering is a tiny, XSS-safe renderer — every substitution runs on HTML-escaped input, only `http(s)` / `mailto:` links pass through, and `target="_blank"` + `rel="noopener noreferrer"` are always set. - **Dependency editor** — chip list of parents and children, each with an `×` to unlink, plus dropdowns over every other task to add a new parent or child. Cycle attempts are rejected server-side with a clear message. - - **Status action row** (→ triage / → ready / → running / block / unblock / complete / archive) with confirm prompts for destructive transitions. For cards in the **Triage** column the row also exposes a **✨ Specify** button that calls the auxiliary LLM (`auxiliary.triage_specifier` in `config.yaml`) to expand the one-liner into a concrete spec (title + body with goal, approach, acceptance criteria) and promote the task to `todo`. The same behaviour is reachable from the CLI (`hermes kanban specify ` / `--all`), from any gateway platform (`/kanban specify `), and programmatically via `POST /api/plugins/kanban/tasks/:id/specify`. + - **Status action row** (→ triage / → ready / → running / block / unblock / complete / archive) with confirm prompts for destructive transitions. For cards in the **Triage** column the row also exposes two LLM-driven actions: **⚗ Decompose** fans the task out into a graph of child tasks routed to specialist profiles by description (the orchestrator-driven path), and **✨ Specify** does a single-task spec rewrite. Decompose falls back to specify-style promotion when the LLM decides the task doesn't benefit from fan-out, so it's a strict superset. Both are reachable from the CLI (`hermes kanban decompose ` / `specify ` / `--all`), from any gateway platform (`/kanban decompose `), and programmatically via `POST /api/plugins/kanban/tasks/:id/decompose` and `…/specify`. Configure the models under `auxiliary.kanban_decomposer` and `auxiliary.triage_specifier` in `config.yaml`. - Result section (also markdown-rendered), comment thread with Enter-to-submit, the last 20 events. - **Toolbar filters** — free-text search, tenant dropdown (defaults to `dashboard.kanban.default_tenant` from `config.yaml`), assignee dropdown, "show archived" toggle, "lanes by profile" toggle, and a **Nudge dispatcher** button so you don't have to wait for the next 60 s tick. Visually the target is the familiar Linear / Fusion layout: dark theme, column headers with counts, coloured status dots, pill chips for priority and tenant. The plugin reads only theme CSS vars (`--color-*`, `--radius`, `--font-mono`, ...), so it reskins automatically with whichever dashboard theme is active. +### Auto vs Manual orchestration + +The kanban board has two ways to handle a task you drop into the Triage column: + +**Auto (default)** — `kanban.auto_decompose: true`. The gateway-embedded dispatcher runs the **decomposer** on each tick, capped by `kanban.auto_decompose_per_tick` (default 3 tasks per tick) so a bulk-load of triage tasks doesn't burst-spend the auxiliary LLM. The decomposer reads the rough idea, looks at your installed profiles + their descriptions, and asks the LLM to produce a JSON task graph: which tasks to spawn, who they go to, and which depend on which. The original triage task becomes the parent of every leaf in the graph, so it stays alive until the whole graph completes — and then promotes back to `ready` so its assignee (the orchestrator profile) can judge completion and add more tasks if the work isn't done. This is the "drop a one-liner, walk away" flow. + +**Manual** — `kanban.auto_decompose: false`. Triage tasks stay in triage until you act. Click the **⚗ Decompose** button on a card, run `hermes kanban decompose ` (or `--all`), or use `/kanban decompose ` from a chat. This matches the pre-decomposer behavior of the board, useful when you want full control over what runs when. + +Flip between the two modes from the **Orchestration: Auto/Manual** pill at the top of the kanban page (emerald = Auto, muted gray = Manual), or by editing `config.yaml` directly. Both modes coexist with `hermes kanban specify` — that's still available as a single-task spec rewrite when you don't want fan-out. + +The decomposer's routing decisions depend on profile descriptions, which is a per-profile labeling primitive you set with `hermes profile create --description "..."`, `hermes profile describe --text "..."`, `hermes profile describe --auto` (LLM-generates from the profile's installed skills + model), or the dashboard's per-profile editor in the expanded **Orchestration settings** panel. Profiles without a description still appear in the roster — they're routable by name, just less precisely. The decomposer NEVER lands a child task with `assignee=None`: when the LLM picks an unknown profile, the child gets routed to `kanban.default_assignee` (or the active default profile if that's unset). + +Config knobs (all under `kanban:` in `~/.hermes/config.yaml`): + +| Key | Default | Purpose | +|---|---|---| +| `auto_decompose` | `true` | Dispatcher auto-runs the decomposer every tick. | +| `auto_decompose_per_tick` | `3` | Cap on decompositions per dispatcher tick. Excess defers to the next tick. | +| `orchestrator_profile` | `""` | Profile that owns decomposition. Empty = fall back to active default profile. | +| `default_assignee` | `""` | Where a child task lands when the LLM picks an unknown profile. Empty = fall back to active default. | + +And the two auxiliary LLM slots: + +| Key | Purpose | +|---|---| +| `auxiliary.kanban_decomposer` | Model that produces the task graph (called by Decompose). Set `provider`/`model` to override the main chat model. | +| `auxiliary.profile_describer` | Model that auto-generates profile descriptions (called by `hermes profile describe --auto`). | + ### Architecture The GUI is strictly a **read-through-the-DB + write-through-kanban_db** layer with no domain logic of its own: @@ -499,6 +527,12 @@ All routes are mounted under `/api/plugins/kanban/` and protected by the dashboa | `POST` | `/tasks/bulk` | Apply the same patch (status / archive / assignee / priority) to every id in `ids`. Per-id failures reported without aborting siblings | | `POST` | `/tasks/:id/comments` | Append a comment | | `POST` | `/tasks/:id/specify` | Run the triage specifier — auxiliary LLM fleshes out the task body and promotes it from `triage` to `todo`. Returns `{ok, task_id, reason, new_title}`; `ok=false` with a human-readable reason on "not in triage" / no aux client / LLM error is a 200, not a 4xx | +| `POST` | `/tasks/:id/decompose` | Run the kanban decomposer — auxiliary LLM produces a task graph and the helper atomically creates the children + links the root + flips `triage → todo`. Returns `{ok, task_id, reason, fanout, child_ids, new_title}`. Same 200-on-LLM-error convention as `/specify`. | +| `GET` | `/profiles` | List installed profiles with their descriptions (consumed by the dashboard's profile-description editor and the orchestrator picker). | +| `PATCH` | `/profiles/:name` | Set or clear a profile's description (user-authored — `description_auto: false`). Returns `{ok, profile, description}`. | +| `POST` | `/profiles/:name/describe-auto` | Generate a description for a profile via `auxiliary.profile_describer`. Persists with `description_auto: true` so the dashboard can surface a "review" badge. | +| `GET` | `/orchestration` | Read the kanban orchestration settings (`orchestrator_profile`, `default_assignee`, `auto_decompose`) plus the *resolved* effective values after fallbacks. | +| `PUT` | `/orchestration` | Update one or more of the three orchestration keys in `config.yaml`. Validates that non-empty profile names actually exist. | | `POST` | `/links` | Add a dependency (`parent_id` → `child_id`) | | `DELETE` | `/links?parent_id=…&child_id=…` | Remove a dependency | | `POST` | `/dispatch?max=…&dry_run=…` | Nudge the dispatcher — skip the 60 s wait | diff --git a/website/docs/user-guide/profiles.md b/website/docs/user-guide/profiles.md index 522b24cb7703..73ea0a8cadd4 100644 --- a/website/docs/user-guide/profiles.md +++ b/website/docs/user-guide/profiles.md @@ -32,6 +32,14 @@ hermes profile create mybot Creates a fresh profile with bundled skills seeded. Run `mybot setup` to configure API keys, model, and gateway tokens. +If you plan to use this profile as a kanban worker (or want the kanban orchestrator to route work to it), pass `--description ""` at create time so the orchestrator knows what it's good at: + +```bash +hermes profile create researcher --description "Reads source code and external docs, writes findings." +``` + +You can also set or auto-generate the description later with `hermes profile describe` — see the [Kanban guide](./features/kanban#auto-vs-manual-orchestration) for the full routing model. + ### Clone config only (`--clone`) ```bash From 4c46c35ed0d3864f1cec55d87ab6d0f838ec7a2e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 17 May 2026 14:44:37 -0700 Subject: [PATCH 070/418] docs(messaging): clarify admin/user split and signal future gating (#27623) Restructures the security section so the admin/user distinction is a first-class concept rather than buried under 'Slash Command Access Control'. The new section makes explicit that: - Slash commands are the first capability gated by the tier split today - Future gating (tools, model switching, etc.) will hang off the same admin/user distinction, so configuring it now is forward-compatible - Allowlists vs the admin/user split solve different problems and are contrasted up front Heading renamed: 'Slash Command Access Control' -> 'Admins vs Regular Users'. The platform-specific pages (telegram.md, discord.md) keep the old heading since slash gating IS the only thing they currently gate. --- website/docs/user-guide/messaging/index.md | 25 +++++++++++++++------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/website/docs/user-guide/messaging/index.md b/website/docs/user-guide/messaging/index.md index acd128728124..ef02bc7fe169 100644 --- a/website/docs/user-guide/messaging/index.md +++ b/website/docs/user-guide/messaging/index.md @@ -222,9 +222,22 @@ hermes pairing revoke telegram 123456789 # Remove access Pairing codes expire after 1 hour, are rate-limited, and use cryptographic randomness. -### Slash Command Access Control +### Admins vs Regular Users -Once users are allowed in, you can split them into **admins** (full slash command access) and **regular users** (only the slash commands you explicitly enable). This applies per platform and per scope (DM vs group/channel) and works through the live command registry, so it covers built-in AND plugin-registered slash commands without per-feature wiring. +Allowlists answer "can this person reach the bot at all?" The **admin / user split** answers "now that they're in, what are they allowed to do?" + +Every allowed user falls into one of two tiers per scope (DM vs group/channel): + +- **Admin** — full access. Can run every registered slash command (built-in + plugin) and use every gated capability. +- **Regular user** — restricted access. Can chat with the agent normally, but can only run the slash commands you explicitly enable. The always-allowed floor is `/help` and `/whoami`. + +The tiers are configured per platform and per scope. DM admin status does not imply group/channel admin status — each scope has its own admin list. + +**What the tiers gate today:** slash commands. The split runs through the live command registry, so it covers built-ins and plugin-registered commands without per-feature wiring. Plain chat is not affected — non-admins can still talk to the agent. + +**What may be gated in the future:** more capability surfaces (tool access, model switching, expensive operations) will hang off the same admin / user distinction as we add them. Configuring the split now means those future restrictions land cleanly without you having to re-model who's an admin. + +#### Configuration ```yaml gateway: @@ -239,13 +252,9 @@ gateway: group_user_allowed_commands: [status] ``` -Behavior: +**Backward compat:** if `allow_admin_from` is not set for a scope, the tier split is disabled for that scope and every allowed user has full access. Existing installs keep working with no changes — opt in when you want the distinction. -- A user in `allow_admin_from` for a scope can run **every** registered slash command. -- A user in `allow_from` but not in `allow_admin_from` can only run commands in `user_allowed_commands`, plus the always-allowed floor: `/help` and `/whoami`. -- Plain chat is unaffected. Non-admins can still talk to the agent normally; they just can't trigger arbitrary commands. -- **Backward compat:** if `allow_admin_from` is not set for a scope, slash gating is disabled for that scope. Existing installs keep working with no changes. -- DM admin status does not imply group/channel admin status. Each scope has its own admin list. +#### Inspecting your access Use `/whoami` from any platform to see the active scope, your tier (admin / user / unrestricted), and which slash commands you can run. See the [Telegram](/docs/user-guide/messaging/telegram#slash-command-access-control) and [Discord](/docs/user-guide/messaging/discord#slash-command-access-control) pages for platform-specific examples. From c9055626232e1866fedcca8073d0c13ae62e7b90 Mon Sep 17 00:00:00 2001 From: Robin Fernandes Date: Sun, 17 May 2026 15:41:03 +1000 Subject: [PATCH 071/418] fix(auth): stop replaying invalid Nous refresh tokens Quarantine Nous OAuth state when refresh fails with terminal invalid_grant/invalid_token errors. Clear local and shared refresh material across runtime, managed access-token, proxy, and credential-pool paths so Hermes stops retrying revoked refresh sessions. --- agent/credential_pool.py | 41 +++++++ hermes_cli/auth.py | 118 +++++++++++++++++--- hermes_cli/proxy/adapters/nous_portal.py | 14 +++ tests/agent/test_credential_pool.py | 64 +++++++++++ tests/hermes_cli/test_auth_nous_provider.py | 84 ++++++++++++++ tests/hermes_cli/test_proxy.py | 31 +++++ 6 files changed, 338 insertions(+), 14 deletions(-) diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 7f27873a7fb8..93e3d609ee87 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -929,6 +929,47 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po self._persist() self._sync_device_code_entry_to_auth_store(updated) return updated + if auth_mod._is_terminal_nous_refresh_error(exc): + logger.debug("Nous refresh token is terminally invalid; clearing local token state") + try: + with _auth_store_lock(): + auth_store = _load_auth_store() + state = _load_provider_state(auth_store, "nous") or { + "client_id": entry.client_id, + "portal_base_url": entry.portal_base_url, + "inference_base_url": entry.inference_base_url, + "token_type": entry.token_type, + "scope": entry.scope, + "tls": entry.tls, + } + store_refresh = str(state.get("refresh_token") or "").strip() + entry_refresh = str(entry.refresh_token or "").strip() + if not store_refresh or store_refresh == entry_refresh: + auth_mod._quarantine_nous_oauth_state( + state, + exc, + reason="credential_pool_refresh_failure", + ) + _save_provider_state(auth_store, "nous", state) + _save_auth_store(auth_store) + except Exception as clear_exc: + logger.debug("Failed to clear terminal Nous OAuth state: %s", clear_exc) + + cleared = replace( + entry, + access_token=None, + refresh_token=None, + agent_key=None, + agent_key_expires_at=None, + ) + self._replace_entry(entry, cleared) + self._persist() + self._mark_exhausted( + cleared, + 401, + {"reason": getattr(exc, "code", None), "message": str(exc)}, + ) + return None self._mark_exhausted(entry, None) return None diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 8b154db74681..50f105de10a1 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -3616,6 +3616,63 @@ def _read_shared_nous_state() -> Optional[Dict[str, Any]]: return payload +def _clear_shared_nous_state(reason: str) -> None: + """Remove the shared Nous OAuth store after a terminal token failure.""" + try: + with _nous_shared_store_lock(): + path = _nous_shared_store_path() + try: + path.unlink() + except FileNotFoundError: + pass + _oauth_trace("nous_shared_store_cleared", reason=reason) + except Exception as exc: + logger.debug("Failed to clear shared Nous auth store: %s", exc) + + +def _is_terminal_nous_refresh_error(exc: Exception) -> bool: + """True when retrying the same Nous refresh token cannot succeed.""" + return ( + isinstance(exc, AuthError) + and exc.provider == "nous" + and exc.code in {"invalid_grant", "invalid_token"} + and bool(exc.relogin_required) + ) + + +def _quarantine_nous_oauth_state( + state: Dict[str, Any], + error: AuthError, + *, + reason: str, +) -> None: + """Keep routing metadata but remove dead OAuth material so it is not replayed.""" + for key in ( + "access_token", + "refresh_token", + "expires_at", + "expires_in", + "obtained_at", + "agent_key", + "agent_key_id", + "agent_key_expires_at", + "agent_key_expires_in", + "agent_key_reused", + "agent_key_obtained_at", + ): + state.pop(key, None) + state["last_auth_error"] = { + "provider": "nous", + "code": error.code, + "message": str(error), + "reason": reason, + "relogin_required": True, + "at": datetime.now(timezone.utc).isoformat(), + } + _clear_shared_nous_state(reason) + invalidate_nous_auth_status_cache() + + def _try_import_shared_nous_state( *, timeout_seconds: float = 15.0, @@ -3671,6 +3728,8 @@ def _try_import_shared_nous_state( error_type=type(exc).__name__, error_code=getattr(exc, "code", None), ) + if _is_terminal_nous_refresh_error(exc): + _clear_shared_nous_state("shared_import_terminal_refresh_failure") logger.debug("Shared Nous import failed: %s", exc) return None except Exception as exc: @@ -3896,12 +3955,23 @@ def resolve_nous_access_token( headers={"Accept": "application/json"}, verify=verify, ) as client: - refreshed = _refresh_access_token( - client=client, - portal_base_url=portal_base_url, - client_id=client_id, - refresh_token=refresh_token, - ) + try: + refreshed = _refresh_access_token( + client=client, + portal_base_url=portal_base_url, + client_id=client_id, + refresh_token=refresh_token, + ) + except AuthError as exc: + if _is_terminal_nous_refresh_error(exc): + _quarantine_nous_oauth_state( + state, + exc, + reason="managed_access_token_refresh_failure", + ) + _save_provider_state(auth_store, "nous", state) + _save_auth_store(auth_store) + raise now = datetime.now(timezone.utc) access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in")) @@ -4209,10 +4279,20 @@ def _persist_state(reason: str) -> None: reason="access_expiring", refresh_token_fp=_token_fingerprint(refresh_token), ) - refreshed = _refresh_access_token( - client=client, portal_base_url=portal_base_url, - client_id=client_id, refresh_token=refresh_token, - ) + try: + refreshed = _refresh_access_token( + client=client, portal_base_url=portal_base_url, + client_id=client_id, refresh_token=refresh_token, + ) + except AuthError as exc: + if _is_terminal_nous_refresh_error(exc): + _quarantine_nous_oauth_state( + state, + exc, + reason="runtime_access_refresh_failure", + ) + _persist_state("terminal_runtime_access_refresh_failure") + raise now = datetime.now(timezone.utc) access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in")) previous_refresh_token = refresh_token @@ -4283,10 +4363,20 @@ def _persist_state(reason: str) -> None: reason="mint_retry_after_invalid_token", refresh_token_fp=_token_fingerprint(latest_refresh_token), ) - refreshed = _refresh_access_token( - client=client, portal_base_url=portal_base_url, - client_id=client_id, refresh_token=latest_refresh_token, - ) + try: + refreshed = _refresh_access_token( + client=client, portal_base_url=portal_base_url, + client_id=client_id, refresh_token=latest_refresh_token, + ) + except AuthError as exc: + if _is_terminal_nous_refresh_error(exc): + _quarantine_nous_oauth_state( + state, + exc, + reason="runtime_mint_retry_refresh_failure", + ) + _persist_state("terminal_runtime_mint_retry_refresh_failure") + raise now = datetime.now(timezone.utc) access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in")) state["access_token"] = refreshed["access_token"] diff --git a/hermes_cli/proxy/adapters/nous_portal.py b/hermes_cli/proxy/adapters/nous_portal.py index b72cbd305b33..842489659a42 100644 --- a/hermes_cli/proxy/adapters/nous_portal.py +++ b/hermes_cli/proxy/adapters/nous_portal.py @@ -16,8 +16,11 @@ from typing import Any, Dict, FrozenSet, Optional from hermes_cli.auth import ( + AuthError, DEFAULT_NOUS_INFERENCE_URL, _load_auth_store, + _is_terminal_nous_refresh_error, + _quarantine_nous_oauth_state, _save_auth_store, _write_shared_nous_state, refresh_nous_oauth_from_state, @@ -81,6 +84,17 @@ def get_credential(self) -> UpstreamCredential: try: refreshed = refresh_nous_oauth_from_state(state) + except AuthError as exc: + if _is_terminal_nous_refresh_error(exc): + _quarantine_nous_oauth_state( + state, + exc, + reason="proxy_refresh_failure", + ) + self._save_state(state) + raise RuntimeError( + f"Failed to refresh Nous Portal credentials: {exc}" + ) from exc except Exception as exc: raise RuntimeError( f"Failed to refresh Nous Portal credentials: {exc}" diff --git a/tests/agent/test_credential_pool.py b/tests/agent/test_credential_pool.py index 299567a9a6ff..e2d2726f21bf 100644 --- a/tests/agent/test_credential_pool.py +++ b/tests/agent/test_credential_pool.py @@ -510,6 +510,70 @@ def test_load_pool_migrates_nous_provider_state(tmp_path, monkeypatch): assert entry.agent_key == "agent-key" +def test_nous_pool_terminal_refresh_clears_tokens(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared")) + _write_auth_store( + tmp_path, + { + "version": 1, + "active_provider": "nous", + "providers": { + "nous": { + "portal_base_url": "https://portal.example.com", + "inference_base_url": "https://inference.example.com/v1", + "client_id": "hermes-cli", + "token_type": "Bearer", + "scope": "inference:mint_agent_key", + "access_token": "access-token", + "refresh_token": "refresh-token", + "expires_at": "2026-03-24T12:00:00+00:00", + "agent_key": "agent-key", + "agent_key_expires_at": "2026-03-24T13:30:00+00:00", + } + }, + }, + ) + + from agent.credential_pool import load_pool + from hermes_cli import auth as auth_mod + from hermes_cli.auth import AuthError + + refresh_calls = {"count": 0} + + def _terminal_refresh_failure(*_args, **_kwargs): + refresh_calls["count"] += 1 + raise AuthError( + "Refresh session has been revoked", + provider="nous", + code="invalid_grant", + relogin_required=True, + ) + + monkeypatch.setattr(auth_mod, "refresh_nous_oauth_from_state", _terminal_refresh_failure) + + pool = load_pool("nous") + assert pool.select() is not None + assert pool.try_refresh_current() is None + + entry = pool.entries()[0] + assert entry.last_status == "exhausted" + assert entry.last_error_code == 401 + assert entry.refresh_token is None + assert entry.access_token is None + assert entry.agent_key is None + + auth_payload = json.loads((tmp_path / "hermes" / "auth.json").read_text()) + nous_state = auth_payload["providers"]["nous"] + assert not nous_state.get("refresh_token") + assert not nous_state.get("access_token") + assert not nous_state.get("agent_key") + assert nous_state["last_auth_error"]["code"] == "invalid_grant" + + assert pool.try_refresh_current() is None + assert refresh_calls["count"] == 1 + + def test_load_pool_removes_stale_file_backed_singleton_entry(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) diff --git a/tests/hermes_cli/test_auth_nous_provider.py b/tests/hermes_cli/test_auth_nous_provider.py index 5cd546462dde..37662c77ece2 100644 --- a/tests/hermes_cli/test_auth_nous_provider.py +++ b/tests/hermes_cli/test_auth_nous_provider.py @@ -373,6 +373,89 @@ def _fake_mint_agent_key(*, client, portal_base_url, access_token, min_ttl_secon assert state_after_failure["access_token"] == "access-1" +def test_terminal_refresh_failure_quarantines_tokens( + tmp_path, monkeypatch, shared_store_env, +): + """A revoked/invalid Nous refresh token must not be replayed forever.""" + from hermes_cli import auth as auth_mod + + hermes_home = tmp_path / "hermes" + _setup_nous_auth(hermes_home, refresh_token="refresh-old") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + shared_state = _full_state_fixture() + shared_state["access_token"] = "access-old" + shared_state["refresh_token"] = "refresh-old" + shared_state["expires_at"] = "2026-02-01T00:00:00+00:00" + auth_mod._write_shared_nous_state(shared_state) + + refresh_calls: list[str] = [] + + def _terminal_refresh_failure(*, client, portal_base_url, client_id, refresh_token): + refresh_calls.append(refresh_token) + raise AuthError( + "Refresh session has been revoked", + provider="nous", + code="invalid_grant", + relogin_required=True, + ) + + monkeypatch.setattr(auth_mod, "_refresh_access_token", _terminal_refresh_failure) + + with pytest.raises(AuthError, match="Refresh session has been revoked"): + auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=300) + + state_after_failure = auth_mod.get_provider_auth_state("nous") + assert state_after_failure is not None + assert not state_after_failure.get("refresh_token") + assert not state_after_failure.get("access_token") + assert not state_after_failure.get("agent_key") + assert state_after_failure["last_auth_error"]["code"] == "invalid_grant" + assert auth_mod._read_shared_nous_state() is None + + with pytest.raises(AuthError, match="No access token found"): + auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=300) + + assert refresh_calls == ["refresh-old"] + + +def test_managed_access_token_refresh_failure_quarantines_tokens( + tmp_path, monkeypatch, shared_store_env, +): + from hermes_cli import auth as auth_mod + + hermes_home = tmp_path / "hermes" + _setup_nous_auth(hermes_home, refresh_token="refresh-old") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + refresh_calls: list[str] = [] + + def _terminal_refresh_failure(*, client, portal_base_url, client_id, refresh_token): + refresh_calls.append(refresh_token) + raise AuthError( + "Invalid refresh token", + provider="nous", + code="invalid_grant", + relogin_required=True, + ) + + monkeypatch.setattr(auth_mod, "_refresh_access_token", _terminal_refresh_failure) + + with pytest.raises(AuthError, match="Invalid refresh token"): + auth_mod.resolve_nous_access_token() + + state_after_failure = auth_mod.get_provider_auth_state("nous") + assert state_after_failure is not None + assert not state_after_failure.get("refresh_token") + assert not state_after_failure.get("access_token") + assert state_after_failure["last_auth_error"]["message"] == "Invalid refresh token" + + with pytest.raises(AuthError, match="No access token found"): + auth_mod.resolve_nous_access_token() + + assert refresh_calls == ["refresh-old"] + + def test_mint_retry_uses_latest_rotated_refresh_token(tmp_path, monkeypatch): hermes_home = tmp_path / "hermes" _setup_nous_auth(hermes_home, refresh_token="refresh-old") @@ -1118,6 +1201,7 @@ def _boom(*_args, **_kwargs): monkeypatch.setattr(auth_mod, "refresh_nous_oauth_from_state", _boom) assert auth_mod._try_import_shared_nous_state() is None + assert auth_mod._read_shared_nous_state() is None def test_try_import_shared_rehydrates_on_success(shared_store_env, monkeypatch): diff --git a/tests/hermes_cli/test_proxy.py b/tests/hermes_cli/test_proxy.py index 0c874facac79..3ab06eeb92f2 100644 --- a/tests/hermes_cli/test_proxy.py +++ b/tests/hermes_cli/test_proxy.py @@ -164,6 +164,37 @@ def test_nous_adapter_get_credential_raises_on_refresh_failure(tmp_path, monkeyp adapter.get_credential() +def test_nous_adapter_quarantines_terminal_refresh_failure(tmp_path, monkeypatch): + from hermes_cli.auth import AuthError + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _write_auth_store(tmp_path, { + "access_token": "access-tok", + "refresh_token": "refresh-tok", + "agent_key": "stale-agent-key", + }) + + with patch( + "hermes_cli.proxy.adapters.nous_portal.refresh_nous_oauth_from_state", + side_effect=AuthError( + "Refresh session has been revoked", + provider="nous", + code="invalid_grant", + relogin_required=True, + ), + ): + adapter = NousPortalAdapter() + with pytest.raises(RuntimeError, match="Refresh session has been revoked"): + adapter.get_credential() + + stored = json.loads((tmp_path / "auth.json").read_text()) + nous_state = stored["providers"]["nous"] + assert not nous_state.get("refresh_token") + assert not nous_state.get("access_token") + assert not nous_state.get("agent_key") + assert nous_state["last_auth_error"]["code"] == "invalid_grant" + + def test_nous_adapter_get_credential_raises_when_no_agent_key_returned(tmp_path, monkeypatch): """If the refresh helper succeeds but produces no agent_key, we surface a clear error.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) From 89a3d038cfb289ce73b9d7aac9b0b7ca85a018f0 Mon Sep 17 00:00:00 2001 From: Robin Fernandes Date: Sun, 17 May 2026 19:34:44 +1000 Subject: [PATCH 072/418] Switch to JWT token for inference against Nous, falling back to old opaque token on failure. --- agent/auxiliary_client.py | 7 +- agent/credential_pool.py | 2 + hermes_cli/auth.py | 366 ++++++++++++++++++-- hermes_cli/proxy/adapters/nous_portal.py | 11 +- hermes_cli/runtime_provider.py | 15 +- tests/agent/test_auxiliary_client.py | 2 + tests/agent/test_credential_pool.py | 56 +++ tests/conftest.py | 1 + tests/hermes_cli/test_auth_commands.py | 4 +- tests/hermes_cli/test_auth_nous_provider.py | 363 ++++++++++++++++++- 10 files changed, 781 insertions(+), 46 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index a7fcd311f118..b2733fd8a1b4 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -755,7 +755,8 @@ def _close_client_on_timeout() -> None: def _check_cancelled() -> None: if deadline is not None and time.monotonic() >= deadline: - timed_out.set() + if not timed_out.is_set(): + _close_client_on_timeout() raise TimeoutError(_timeout_message()) try: from tools.interrupt import is_interrupted @@ -1233,7 +1234,7 @@ def _read_nous_auth() -> Optional[dict]: def _nous_api_key(provider: dict) -> str: - """Extract the best API key from a Nous provider state dict.""" + """Extract the Nous runtime credential from the compatibility field.""" return provider.get("agent_key") or provider.get("access_token", "") @@ -1246,7 +1247,7 @@ def _resolve_nous_runtime_api(*, force_refresh: bool = False) -> Optional[tuple[ """Return fresh Nous runtime credentials when available. This mirrors the main agent's 401 recovery path and keeps auxiliary - clients aligned with the singleton auth store + mint flow instead of + clients aligned with the singleton auth store + JWT/mint flow instead of relying only on whatever raw tokens happen to be sitting in auth.json or the credential pool. """ diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 93e3d609ee87..b1c41977d512 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -166,6 +166,8 @@ def to_dict(self) -> Dict[str, Any]: @property def runtime_api_key(self) -> str: if self.provider == "nous": + # Nous stores the runtime inference credential in agent_key for + # compatibility. It may be a NAS invoke JWT or legacy opaque key. return str(self.agent_key or self.access_token or "") return str(self.access_token or "") diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 50f105de10a1..2a670589d486 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -67,9 +67,13 @@ DEFAULT_NOUS_PORTAL_URL = "https://portal.nousresearch.com" DEFAULT_NOUS_INFERENCE_URL = "https://inference-api.nousresearch.com/v1" DEFAULT_NOUS_CLIENT_ID = "hermes-cli" -DEFAULT_NOUS_SCOPE = "inference:mint_agent_key" +NOUS_LEGACY_AGENT_KEY_SCOPE = "inference:mint_agent_key" +NOUS_INFERENCE_INVOKE_SCOPE = "inference:invoke" +DEFAULT_NOUS_SCOPE = f"{NOUS_INFERENCE_INVOKE_SCOPE} {NOUS_LEGACY_AGENT_KEY_SCOPE}" +NOUS_LEGACY_SESSION_KEYS_ENV = "HERMES_AGENT_USE_LEGACY_SESSION_KEYS" DEFAULT_AGENT_KEY_MIN_TTL_SECONDS = 30 * 60 # 30 minutes ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 # refresh 2 min before expiry +NOUS_INVOKE_JWT_MIN_TTL_SECONDS = ACCESS_TOKEN_REFRESH_SKEW_SECONDS DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS = 1 # poll at most every 1s DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex" DEFAULT_XAI_OAUTH_BASE_URL = "https://api.x.ai/v1" @@ -1549,6 +1553,117 @@ def _decode_jwt_claims(token: Any) -> Dict[str, Any]: return claims if isinstance(claims, dict) else {} +def _scope_values(raw_scope: Any) -> set[str]: + scopes: set[str] = set() + if isinstance(raw_scope, str): + for part in raw_scope.replace(",", " ").split(): + cleaned = part.strip() + if cleaned: + scopes.add(cleaned) + elif isinstance(raw_scope, (list, tuple, set, frozenset)): + for item in raw_scope: + if isinstance(item, str): + scopes.update(_scope_values(item)) + return scopes + + +def _nous_legacy_session_keys_forced() -> bool: + return is_truthy_value(os.getenv(NOUS_LEGACY_SESSION_KEYS_ENV), default=False) + + +def _nous_scope_has_invoke(raw_scope: Any) -> bool: + return NOUS_INFERENCE_INVOKE_SCOPE in _scope_values(raw_scope) + + +def _nous_invoke_jwt_is_usable( + token: Any, + *, + scope: Any = None, + expires_at: Any = None, + min_ttl_seconds: int = NOUS_INVOKE_JWT_MIN_TTL_SECONDS, +) -> bool: + claims = _decode_jwt_claims(token) + if not claims: + return False + scopes = ( + _scope_values(scope) + | _scope_values(claims.get("scope")) + | _scope_values(claims.get("scp")) + ) + if NOUS_INFERENCE_INVOKE_SCOPE not in scopes: + return False + exp = claims.get("exp") + skew = max(0, int(min_ttl_seconds)) + if isinstance(exp, (int, float)): + return float(exp) > (time.time() + skew) + return not _is_expiring(expires_at, skew) + + +def _nous_invoke_jwt_unavailable_reason( + token: Any, + *, + scope: Any = None, + expires_at: Any = None, + min_ttl_seconds: int = NOUS_INVOKE_JWT_MIN_TTL_SECONDS, +) -> str: + claims = _decode_jwt_claims(token) + if not claims: + return "access_token_not_jwt" + scopes = ( + _scope_values(scope) + | _scope_values(claims.get("scope")) + | _scope_values(claims.get("scp")) + ) + if NOUS_INFERENCE_INVOKE_SCOPE not in scopes: + return "missing_inference_invoke_scope" + exp = claims.get("exp") + skew = max(0, int(min_ttl_seconds)) + if isinstance(exp, (int, float)) and float(exp) <= (time.time() + skew): + return "invoke_jwt_expiring" + if not isinstance(exp, (int, float)) and _is_expiring(expires_at, skew): + return "invoke_jwt_expiry_unknown_or_expiring" + return "invoke_jwt_unavailable" + + +def _nous_jwt_expires_at(token: Any, fallback_expires_at: Any = None) -> Optional[str]: + claims = _decode_jwt_claims(token) + exp = claims.get("exp") + if isinstance(exp, (int, float)): + try: + return datetime.fromtimestamp(float(exp), tz=timezone.utc).isoformat() + except Exception: + pass + return fallback_expires_at if isinstance(fallback_expires_at, str) else None + + +def _set_nous_agent_key_from_invoke_jwt( + state: Dict[str, Any], + *, + obtained_at: Optional[str] = None, +) -> None: + access_token = state.get("access_token") + if not isinstance(access_token, str) or not access_token.strip(): + return + now = datetime.now(timezone.utc) + effective_obtained_at = obtained_at or now.isoformat() + expires_at = _nous_jwt_expires_at(access_token, state.get("expires_at")) + expires_epoch = _parse_iso_timestamp(expires_at) + expires_in = ( + max(0, int(expires_epoch - time.time())) + if expires_epoch is not None + else _coerce_ttl_seconds(state.get("expires_in")) + ) + if expires_at: + state["expires_at"] = expires_at + state["expires_in"] = expires_in + state["agent_key"] = access_token + state["agent_key_id"] = None + state["agent_key_expires_at"] = expires_at + state["agent_key_expires_in"] = expires_in + state["agent_key_reused"] = False + state["agent_key_obtained_at"] = effective_obtained_at + + def _codex_access_token_is_expiring(access_token: Any, skew_seconds: int) -> bool: claims = _decode_jwt_claims(access_token) exp = claims.get("exp") @@ -3333,6 +3448,34 @@ def _request_device_code( return data +def _is_nous_invoke_scope_refusal(exc: Exception) -> bool: + if not isinstance(exc, httpx.HTTPStatusError): + return False + response = exc.response + if response.status_code not in {400, 401, 403}: + return False + try: + payload = response.json() + except Exception: + payload = {} + text = " ".join( + str(value) + for value in ( + payload.get("error") if isinstance(payload, dict) else None, + payload.get("error_description") if isinstance(payload, dict) else None, + response.text, + ) + if value + ).lower() + if not text: + return False + return ( + "invalid_scope" in text + or "unsupported_scope" in text + or "scope" in text and NOUS_INFERENCE_INVOKE_SCOPE in text + ) + + def _poll_for_token( client: httpx.Client, portal_base_url: str, @@ -3524,8 +3667,9 @@ def _write_shared_nous_state(state: Dict[str, Any]) -> None: is a convenience layer; the per-profile auth.json remains the source of truth. - We deliberately omit the short-lived ``agent_key`` (24h TTL, profile- - specific) — only the long-lived OAuth tokens are cross-profile useful. + We deliberately omit the runtime ``agent_key`` compatibility field + (either an invoke JWT or legacy opaque session key) — only OAuth tokens + are cross-profile useful. """ refresh_token = state.get("refresh_token") access_token = state.get("access_token") @@ -3894,6 +4038,14 @@ def _agent_key_is_usable(state: Dict[str, Any], min_ttl_seconds: int) -> bool: key = state.get("agent_key") if not isinstance(key, str) or not key.strip(): return False + if _decode_jwt_claims(key): + if _nous_legacy_session_keys_forced(): + return False + return _nous_invoke_jwt_is_usable( + key, + scope=state.get("scope"), + expires_at=state.get("agent_key_expires_at"), + ) return not _is_expiring(state.get("agent_key_expires_at"), min_ttl_seconds) @@ -4039,7 +4191,23 @@ def refresh_nous_oauth_pure( timeout = httpx.Timeout(timeout_seconds if timeout_seconds else 15.0) with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}, verify=verify) as client: - if force_refresh or _is_expiring(state.get("expires_at"), ACCESS_TOKEN_REFRESH_SKEW_SECONDS): + min_agent_key_ttl = max(60, int(min_key_ttl_seconds)) + legacy_session_keys = _nous_legacy_session_keys_forced() + current_invoke_jwt_usable = ( + not legacy_session_keys + and _nous_invoke_jwt_is_usable( + state.get("access_token"), + scope=state.get("scope"), + expires_at=state.get("expires_at"), + ) + ) + if ( + force_refresh + or ( + _is_expiring(state.get("expires_at"), ACCESS_TOKEN_REFRESH_SKEW_SECONDS) + and not current_invoke_jwt_usable + ) + ): refreshed = _refresh_access_token( client=client, portal_base_url=state["portal_base_url"], @@ -4061,7 +4229,39 @@ def refresh_nous_oauth_pure( now.timestamp() + access_ttl, tz=timezone.utc ).isoformat() - if force_mint or not _agent_key_is_usable(state, max(60, int(min_key_ttl_seconds))): + if ( + not legacy_session_keys + and _nous_invoke_jwt_is_usable( + state.get("access_token"), + scope=state.get("scope"), + expires_at=state.get("expires_at"), + ) + ): + _set_nous_agent_key_from_invoke_jwt(state) + logger.info("Nous inference auth: using NAS invoke JWT") + _oauth_trace( + "nous_invoke_jwt_selected", + access_token_fp=_token_fingerprint(state.get("access_token")), + ) + elif force_mint or not _agent_key_is_usable(state, min_agent_key_ttl): + fallback_reason = ( + "forced_legacy_session_keys" + if legacy_session_keys + else _nous_invoke_jwt_unavailable_reason( + state.get("access_token"), + scope=state.get("scope"), + expires_at=state.get("expires_at"), + ) + ) + logger.info( + "Nous inference auth: using legacy session key path (%s)", + fallback_reason, + ) + _oauth_trace( + "nous_legacy_session_key_selected", + reason=fallback_reason, + access_token_fp=_token_fingerprint(state.get("access_token")), + ) mint_payload = _mint_agent_key( client=client, portal_base_url=state["portal_base_url"], @@ -4175,6 +4375,15 @@ def persist_nous_credentials( ) +def _sync_nous_pool_from_auth_store() -> None: + try: + from agent.credential_pool import load_pool + + load_pool("nous") + except Exception as exc: + logger.debug("Failed to sync Nous credential pool from auth store: %s", exc) + + def resolve_nous_runtime_credentials( *, min_key_ttl_seconds: int = DEFAULT_AGENT_KEY_MIN_TTL_SECONDS, @@ -4191,7 +4400,7 @@ def resolve_nous_runtime_credentials( Concurrent processes coordinate through the auth store file lock. Returns dict with: provider, base_url, api_key, key_id, expires_at, - expires_in, source ("cache" or "portal"). + expires_in, source ("invoke_jwt", "cache", or "portal"), and auth_path. """ min_key_ttl_seconds = max(60, int(min_key_ttl_seconds)) sequence_id = uuid.uuid4().hex[:12] @@ -4260,15 +4469,35 @@ def _persist_state(reason: str) -> None: raise AuthError("No access token found for Nous Portal login.", provider="nous", relogin_required=True) - # Step 1: refresh access token if expiring - if _is_expiring(state.get("expires_at"), ACCESS_TOKEN_REFRESH_SKEW_SECONDS): + # Step 1: refresh access token if expiring. If the access token + # is already a valid invoke JWT, trust its own exp claim even when + # older auth.json metadata has a stale/missing expires_at. + current_invoke_jwt_usable = ( + not _nous_legacy_session_keys_forced() + and _nous_invoke_jwt_is_usable( + access_token, + scope=state.get("scope"), + expires_at=state.get("expires_at"), + ) + ) + if ( + _is_expiring(state.get("expires_at"), ACCESS_TOKEN_REFRESH_SKEW_SECONDS) + and not current_invoke_jwt_usable + ): with _nous_shared_store_lock(timeout_seconds=max(timeout_seconds + 5.0, AUTH_LOCK_TIMEOUT_SECONDS)): if _merge_shared_nous_oauth_state(state): access_token = state.get("access_token") refresh_token = state.get("refresh_token") _persist_state("post_shared_merge_access_expiring") - if _is_expiring(state.get("expires_at"), ACCESS_TOKEN_REFRESH_SKEW_SECONDS): + if ( + _is_expiring(state.get("expires_at"), ACCESS_TOKEN_REFRESH_SKEW_SECONDS) + and not _nous_invoke_jwt_is_usable( + access_token, + scope=state.get("scope"), + expires_at=state.get("expires_at"), + ) + ): if not isinstance(refresh_token, str) or not refresh_token: raise AuthError("Session expired and no refresh token is available.", provider="nous", relogin_required=True) @@ -4320,14 +4549,56 @@ def _persist_state(reason: str) -> None: # Persist immediately so downstream mint failures cannot drop rotated refresh tokens. _persist_state("post_refresh_access_expiring") - # Step 2: mint agent key if missing/expiring + # Step 2: resolve the compatibility ``agent_key`` field. Preferred + # path stores the NAS invoke JWT there; legacy path mints/reuses + # the opaque session key. used_cached_key = False mint_payload: Optional[Dict[str, Any]] = None - - if not force_mint and _agent_key_is_usable(state, min_key_ttl_seconds): + selected_auth_path = "legacy_session_key" + legacy_session_keys = _nous_legacy_session_keys_forced() + + if ( + not legacy_session_keys + and _nous_invoke_jwt_is_usable( + access_token, + scope=state.get("scope"), + expires_at=state.get("expires_at"), + ) + ): + _set_nous_agent_key_from_invoke_jwt(state) + selected_auth_path = "invoke_jwt" + logger.info("Nous inference auth: using NAS invoke JWT") + _oauth_trace( + "nous_invoke_jwt_selected", + sequence_id=sequence_id, + access_token_fp=_token_fingerprint(access_token), + ) + elif not force_mint and _agent_key_is_usable(state, min_key_ttl_seconds): used_cached_key = True + selected_auth_path = "legacy_session_key_cache" + logger.info("Nous inference auth: using cached legacy session key") _oauth_trace("agent_key_reuse", sequence_id=sequence_id) else: + fallback_reason = ( + "forced_legacy_session_keys" + if legacy_session_keys + else _nous_invoke_jwt_unavailable_reason( + access_token, + scope=state.get("scope"), + expires_at=state.get("expires_at"), + ) + ) + selected_auth_path = "legacy_session_key_mint" + logger.info( + "Nous inference auth: using legacy session key path (%s)", + fallback_reason, + ) + _oauth_trace( + "nous_legacy_session_key_selected", + sequence_id=sequence_id, + reason=fallback_reason, + access_token_fp=_token_fingerprint(access_token), + ) try: _oauth_trace( "mint_start", @@ -4403,10 +4674,28 @@ def _persist_state(reason: str) -> None: # Persist retry refresh immediately for crash safety and cross-process visibility. _persist_state("post_refresh_mint_retry") - mint_payload = _mint_agent_key( - client=client, portal_base_url=portal_base_url, - access_token=access_token, min_ttl_seconds=min_key_ttl_seconds, - ) + if ( + not legacy_session_keys + and _nous_invoke_jwt_is_usable( + access_token, + scope=state.get("scope"), + expires_at=state.get("expires_at"), + ) + ): + _set_nous_agent_key_from_invoke_jwt(state) + mint_payload = None + selected_auth_path = "invoke_jwt" + logger.info("Nous inference auth: using NAS invoke JWT") + _oauth_trace( + "nous_invoke_jwt_selected", + sequence_id=sequence_id, + access_token_fp=_token_fingerprint(access_token), + ) + else: + mint_payload = _mint_agent_key( + client=client, portal_base_url=portal_base_url, + access_token=access_token, min_ttl_seconds=min_key_ttl_seconds, + ) else: raise @@ -4438,6 +4727,8 @@ def _persist_state(reason: str) -> None: _persist_state("resolve_nous_runtime_credentials_final") + _sync_nous_pool_from_auth_store() + api_key = state.get("agent_key") if not isinstance(api_key, str) or not api_key: raise AuthError("Failed to resolve a Nous inference API key", @@ -4458,7 +4749,12 @@ def _persist_state(reason: str) -> None: "key_id": state.get("agent_key_id"), "expires_at": expires_at, "expires_in": expires_in, - "source": "cache" if used_cached_key else "portal", + "source": ( + "invoke_jwt" + if selected_auth_path == "invoke_jwt" + else ("cache" if used_cached_key else "portal") + ), + "auth_path": selected_auth_path, } @@ -6137,7 +6433,10 @@ def _nous_device_code_login( or pconfig.inference_base_url ).rstrip("/") client_id = client_id or pconfig.client_id + explicit_scope = scope is not None scope = scope or pconfig.scope + if _nous_legacy_session_keys_forced(): + scope = NOUS_LEGACY_AGENT_KEY_SCOPE timeout = httpx.Timeout(timeout_seconds) verify: bool | str = False if insecure else (ca_bundle if ca_bundle else True) @@ -6152,12 +6451,30 @@ def _nous_device_code_login( print(f"TLS verification: custom CA bundle ({ca_bundle})") with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}, verify=verify) as client: - device_data = _request_device_code( - client=client, - portal_base_url=portal_base_url, - client_id=client_id, - scope=scope, - ) + try: + device_data = _request_device_code( + client=client, + portal_base_url=portal_base_url, + client_id=client_id, + scope=scope, + ) + except Exception as exc: + if ( + not explicit_scope + and _nous_scope_has_invoke(scope) + and _is_nous_invoke_scope_refusal(exc) + ): + logger.info("Nous inference auth: NAS refused invoke scope, retrying legacy scope") + _oauth_trace("nous_device_code_invoke_scope_refused") + scope = NOUS_LEGACY_AGENT_KEY_SCOPE + device_data = _request_device_code( + client=client, + portal_base_url=portal_base_url, + client_id=client_id, + scope=scope, + ) + else: + raise verification_url = str(device_data["verification_uri_complete"]) user_code = str(device_data["user_code"]) @@ -6287,7 +6604,7 @@ def _login_nous(args, pconfig: ProviderConfig) -> None: portal_base_url=getattr(args, "portal_url", None), inference_base_url=getattr(args, "inference_url", None), client_id=getattr(args, "client_id", None) or pconfig.client_id, - scope=getattr(args, "scope", None) or pconfig.scope, + scope=getattr(args, "scope", None), open_browser=not getattr(args, "no_browser", False), timeout_seconds=timeout_seconds, insecure=insecure, @@ -6314,6 +6631,7 @@ def _login_nous(args, pconfig: ProviderConfig) -> None: # these credentials. Best-effort: any I/O failure is logged and # swallowed inside the helper. _write_shared_nous_state(auth_state) + _sync_nous_pool_from_auth_store() print() print("Login successful!") diff --git a/hermes_cli/proxy/adapters/nous_portal.py b/hermes_cli/proxy/adapters/nous_portal.py index 842489659a42..b69f9d526443 100644 --- a/hermes_cli/proxy/adapters/nous_portal.py +++ b/hermes_cli/proxy/adapters/nous_portal.py @@ -1,12 +1,13 @@ """Nous Portal upstream adapter. Reads the user's Nous OAuth state from ``~/.hermes/auth.json``, refreshes -the access token and mints a fresh agent key when needed, and exposes the -upstream base URL plus minted bearer for the proxy server to forward to. +the access token and resolves the ``agent_key`` compatibility credential +when needed, then exposes the upstream base URL plus bearer for the proxy +server to forward to. -The minted ``agent_key`` (not the OAuth ``access_token``) is what -``inference-api.nousresearch.com`` accepts as a bearer. The refresh helper -already handles both — see :func:`hermes_cli.auth.refresh_nous_oauth_from_state`. +The ``agent_key`` field may hold either a NAS invoke JWT or the legacy +opaque session key. The refresh helper handles both — see +:func:`hermes_cli.auth.refresh_nous_oauth_from_state`. """ from __future__ import annotations diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index c186f1d6e7c1..de32131d861b 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -875,10 +875,9 @@ def _resolve_explicit_runtime( explicit_base_url or str(state.get("inference_base_url") or auth_mod.DEFAULT_NOUS_INFERENCE_URL).strip().rstrip("/") ) - # Only use agent_key for inference — access_token is an OAuth token for the - # portal API (minting keys, refreshing tokens), not for the inference API. - # Falling back to access_token sends an OAuth bearer token to the inference - # endpoint, which returns 404 because it is not a valid inference credential. + # Only use the agent_key compatibility field for inference. It may be + # either a NAS invoke JWT or a legacy opaque session key; raw OAuth + # access_token fallback is handled by resolve_nous_runtime_credentials(). api_key = explicit_api_key or str(state.get("agent_key") or "").strip() expires_at = state.get("agent_key_expires_at") or state.get("expires_at") if not api_key: @@ -1069,17 +1068,19 @@ def resolve_runtime_provider( getattr(entry, "runtime_api_key", None) or getattr(entry, "access_token", "") ) - # For Nous, the pool entry's runtime_api_key is the agent_key — a - # short-lived inference credential (~30 min TTL). The pool doesn't + # For Nous, the pool entry's runtime_api_key is the agent_key + # compatibility field: either an invoke JWT or legacy opaque key. + # The pool doesn't # refresh it during selection (that would trigger network calls in # non-runtime contexts like `hermes auth list`). If the key is # expired, clear pool_api_key so we fall through to - # resolve_nous_runtime_credentials() which handles refresh + mint. + # resolve_nous_runtime_credentials() which handles refresh + fallback. if provider == "nous" and entry is not None and pool_api_key: min_ttl = max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800"))) nous_state = { "agent_key": getattr(entry, "agent_key", None), "agent_key_expires_at": getattr(entry, "agent_key_expires_at", None), + "scope": getattr(entry, "scope", None), } if not _agent_key_is_usable(nous_state, min_ttl): logger.debug("Nous pool entry agent_key expired/missing, falling through to runtime resolution") diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 96f5802f8399..61af7585a215 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -673,6 +673,8 @@ def test_returns_none_when_nothing_available(self, monkeypatch): def test_custom_endpoint_uses_codex_wrapper_when_runtime_requests_responses_api(self): with patch("agent.auxiliary_client._resolve_custom_runtime", return_value=("https://api.openai.com/v1", "sk-test", "codex_responses")), \ + patch("agent.auxiliary_client._read_nous_auth", return_value=None), \ + patch("agent.auxiliary_client._resolve_nous_runtime_api", return_value=None), \ patch("agent.auxiliary_client._read_main_model", return_value="gpt-5.3-codex"), \ patch("agent.auxiliary_client.OpenAI") as mock_openai: client, model = get_text_auxiliary_client() diff --git a/tests/agent/test_credential_pool.py b/tests/agent/test_credential_pool.py index e2d2726f21bf..f7eaf9fa2734 100644 --- a/tests/agent/test_credential_pool.py +++ b/tests/agent/test_credential_pool.py @@ -2,8 +2,10 @@ from __future__ import annotations +import base64 import json import time +from datetime import datetime, timezone import pytest @@ -14,6 +16,14 @@ def _write_auth_store(tmp_path, payload: dict) -> None: (hermes_home / "auth.json").write_text(json.dumps(payload, indent=2)) +def _jwt_with_claims(claims: dict) -> str: + def _part(payload: dict) -> str: + raw = json.dumps(payload, separators=(",", ":")).encode("utf-8") + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + return f"{_part({'alg': 'none', 'typ': 'JWT'})}.{_part(claims)}.sig" + + def test_fill_first_selection_skips_recently_exhausted_entry(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) _write_auth_store( @@ -510,6 +520,52 @@ def test_load_pool_migrates_nous_provider_state(tmp_path, monkeypatch): assert entry.agent_key == "agent-key" +def test_load_pool_mirrors_nous_invoke_jwt_agent_key_runtime_api_key(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + expires_at = datetime.fromtimestamp(time.time() + 3600, tz=timezone.utc).isoformat() + token = _jwt_with_claims({ + "sub": "test-user", + "scope": ["inference:invoke", "inference:mint_agent_key"], + "exp": int(time.time() + 3600), + }) + _write_auth_store( + tmp_path, + { + "version": 1, + "active_provider": "nous", + "providers": { + "nous": { + "portal_base_url": "https://portal.example.com", + "inference_base_url": "https://inference.example.com/v1", + "client_id": "hermes-cli", + "token_type": "Bearer", + "scope": "inference:invoke inference:mint_agent_key", + "access_token": token, + "refresh_token": "refresh-token", + "expires_at": expires_at, + "agent_key": token, + "agent_key_expires_at": expires_at, + } + }, + }, + ) + + from agent.credential_pool import load_pool + + pool = load_pool("nous") + entry = pool.select() + + assert entry is not None + assert entry.source == "device_code" + assert entry.agent_key == token + assert entry.runtime_api_key == token + + auth_payload = json.loads((tmp_path / "hermes" / "auth.json").read_text()) + pool_entry = auth_payload["credential_pool"]["nous"][0] + assert pool_entry["agent_key"] == token + assert pool_entry["agent_key_expires_at"] == expires_at + + def test_nous_pool_terminal_refresh_clears_tokens(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared")) diff --git a/tests/conftest.py b/tests/conftest.py index aa2b1b1fbcb9..176089d56918 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -187,6 +187,7 @@ def _looks_like_credential(name: str) -> bool: "HERMES_BACKGROUND_NOTIFICATIONS", "HERMES_EXEC_ASK", "HERMES_HOME_MODE", + "HERMES_AGENT_USE_LEGACY_SESSION_KEYS", # Kanban path/board pins must never leak from a developer shell or # dispatched worker into tests; otherwise tests can write fake tasks to # the real ~/.hermes/kanban.db instead of the per-test HERMES_HOME. diff --git a/tests/hermes_cli/test_auth_commands.py b/tests/hermes_cli/test_auth_commands.py index 74e2a64d312f..22182ba43a89 100644 --- a/tests/hermes_cli/test_auth_commands.py +++ b/tests/hermes_cli/test_auth_commands.py @@ -107,7 +107,7 @@ def test_auth_add_nous_oauth_persists_pool_entry(tmp_path, monkeypatch): "portal_base_url": "https://portal.example.com", "inference_base_url": "https://inference.example.com/v1", "client_id": "hermes-cli", - "scope": "inference:mint_agent_key", + "scope": "inference:invoke inference:mint_agent_key", "token_type": "Bearer", "access_token": token, "refresh_token": "refresh-token", @@ -228,7 +228,7 @@ def test_auth_add_nous_oauth_honors_custom_label(tmp_path, monkeypatch): "portal_base_url": "https://portal.example.com", "inference_base_url": "https://inference.example.com/v1", "client_id": "hermes-cli", - "scope": "inference:mint_agent_key", + "scope": "inference:invoke inference:mint_agent_key", "token_type": "Bearer", "access_token": token, "refresh_token": "refresh-token", diff --git a/tests/hermes_cli/test_auth_nous_provider.py b/tests/hermes_cli/test_auth_nous_provider.py index 37662c77ece2..1d07737a857c 100644 --- a/tests/hermes_cli/test_auth_nous_provider.py +++ b/tests/hermes_cli/test_auth_nous_provider.py @@ -1,6 +1,9 @@ """Regression tests for Nous OAuth refresh + agent-key mint interactions.""" +import base64 import json +import logging +import time from datetime import datetime, timezone from pathlib import Path @@ -125,6 +128,11 @@ def _setup_nous_auth( *, access_token: str = "access-old", refresh_token: str = "refresh-old", + scope: str = "inference:mint_agent_key", + expires_at: str = "2026-02-01T00:00:00+00:00", + expires_in: int = 0, + agent_key: str | None = None, + agent_key_expires_at: str | None = None, ) -> None: hermes_home.mkdir(parents=True, exist_ok=True) auth_store = { @@ -136,15 +144,15 @@ def _setup_nous_auth( "inference_base_url": "https://inference.example.com/v1", "client_id": "hermes-cli", "token_type": "Bearer", - "scope": "inference:mint_agent_key", + "scope": scope, "access_token": access_token, "refresh_token": refresh_token, "obtained_at": "2026-02-01T00:00:00+00:00", - "expires_in": 0, - "expires_at": "2026-02-01T00:00:00+00:00", - "agent_key": None, + "expires_in": expires_in, + "expires_at": expires_at, + "agent_key": agent_key, "agent_key_id": None, - "agent_key_expires_at": None, + "agent_key_expires_at": agent_key_expires_at, "agent_key_expires_in": None, "agent_key_reused": None, "agent_key_obtained_at": None, @@ -164,6 +172,351 @@ def _mint_payload(api_key: str = "agent-key") -> dict: } +def _jwt_with_claims(claims: dict) -> str: + def _part(payload: dict) -> str: + raw = json.dumps(payload, separators=(",", ":")).encode("utf-8") + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + return f"{_part({'alg': 'none', 'typ': 'JWT'})}.{_part(claims)}.sig" + + +def _future_iso(seconds: int = 3600) -> str: + return datetime.fromtimestamp(time.time() + seconds, tz=timezone.utc).isoformat() + + +def _invoke_jwt(*, seconds: int = 3600, scope: object = "inference:invoke inference:mint_agent_key") -> str: + return _jwt_with_claims({ + "sub": "test-user", + "scope": scope, + "exp": int(time.time() + seconds), + }) + + +def test_resolve_nous_runtime_credentials_prefers_invoke_jwt_and_mirrors( + tmp_path, + monkeypatch, +): + import hermes_cli.auth as auth_mod + + hermes_home = tmp_path / "hermes" + token = _invoke_jwt(seconds=3600) + _setup_nous_auth( + hermes_home, + access_token=token, + scope=auth_mod.DEFAULT_NOUS_SCOPE, + expires_at=_future_iso(3600), + expires_in=3600, + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + def _unexpected_mint(*args, **kwargs): + raise AssertionError("legacy agent-key mint should not run for invoke JWT") + + monkeypatch.setattr(auth_mod, "_mint_agent_key", _unexpected_mint) + + creds = auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=300) + + assert creds["api_key"] == token + assert creds["source"] == "invoke_jwt" + assert creds["auth_path"] == "invoke_jwt" + + payload = json.loads((hermes_home / "auth.json").read_text()) + singleton = payload["providers"]["nous"] + assert singleton["agent_key"] == token + assert datetime.fromisoformat(singleton["agent_key_expires_at"]).timestamp() > time.time() + 300 + + pool_entries = payload["credential_pool"]["nous"] + assert len(pool_entries) == 1 + assert pool_entries[0]["agent_key"] == token + assert pool_entries[0]["source"] == auth_mod.NOUS_DEVICE_CODE_SOURCE + + +def test_resolve_nous_runtime_credentials_trusts_invoke_jwt_exp_over_stale_metadata( + tmp_path, + monkeypatch, +): + import hermes_cli.auth as auth_mod + + hermes_home = tmp_path / "hermes" + token = _invoke_jwt(seconds=3600) + _setup_nous_auth( + hermes_home, + access_token=token, + scope=auth_mod.DEFAULT_NOUS_SCOPE, + expires_at="2000-01-01T00:00:00+00:00", + expires_in=0, + agent_key=token, + agent_key_expires_at="2000-01-01T00:00:00+00:00", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + def _unexpected_refresh(*args, **kwargs): + raise AssertionError("valid invoke JWT should not be refreshed because metadata is stale") + + def _unexpected_mint(*args, **kwargs): + raise AssertionError("valid invoke JWT should not fall back to legacy mint") + + monkeypatch.setattr(auth_mod, "_refresh_access_token", _unexpected_refresh) + monkeypatch.setattr(auth_mod, "_mint_agent_key", _unexpected_mint) + + creds = auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=300) + + assert creds["api_key"] == token + assert creds["source"] == "invoke_jwt" + payload = json.loads((hermes_home / "auth.json").read_text()) + singleton = payload["providers"]["nous"] + assert singleton["agent_key"] == token + assert datetime.fromisoformat(singleton["expires_at"]).timestamp() > time.time() + 300 + assert datetime.fromisoformat(singleton["agent_key_expires_at"]).timestamp() > time.time() + 300 + + +def test_resolve_nous_runtime_credentials_does_not_apply_legacy_ttl_to_invoke_jwt( + tmp_path, + monkeypatch, +): + import hermes_cli.auth as auth_mod + + hermes_home = tmp_path / "hermes" + token = _invoke_jwt(seconds=900) + _setup_nous_auth( + hermes_home, + access_token=token, + scope=auth_mod.DEFAULT_NOUS_SCOPE, + expires_at=_future_iso(900), + expires_in=900, + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + def _unexpected_mint(*args, **kwargs): + raise AssertionError("1800s legacy min TTL should not force opaque mint for invoke JWT") + + monkeypatch.setattr(auth_mod, "_mint_agent_key", _unexpected_mint) + + creds = auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=1800) + + assert creds["api_key"] == token + assert creds["source"] == "invoke_jwt" + payload = json.loads((hermes_home / "auth.json").read_text()) + assert payload["providers"]["nous"]["agent_key"] == token + assert payload["credential_pool"]["nous"][0]["agent_key"] == token + + +def test_resolve_nous_runtime_credentials_falls_back_when_invoke_scope_missing( + tmp_path, + monkeypatch, +): + import hermes_cli.auth as auth_mod + + hermes_home = tmp_path / "hermes" + token = _jwt_with_claims({ + "sub": "test-user", + "scope": "inference:mint_agent_key", + "exp": int(time.time() + 3600), + }) + _setup_nous_auth( + hermes_home, + access_token=token, + scope=auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE, + expires_at=_future_iso(3600), + expires_in=3600, + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + calls = [] + + def _fake_mint_agent_key(*, client, portal_base_url, access_token, min_ttl_seconds): + del client, portal_base_url, min_ttl_seconds + calls.append(access_token) + return _mint_payload(api_key="opaque-agent-key") + + monkeypatch.setattr(auth_mod, "_mint_agent_key", _fake_mint_agent_key) + + creds = auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=300) + + assert calls == [token] + assert creds["api_key"] == "opaque-agent-key" + assert creds["source"] == "portal" + payload = json.loads((hermes_home / "auth.json").read_text()) + assert payload["providers"]["nous"]["agent_key"] == "opaque-agent-key" + assert payload["credential_pool"]["nous"][0]["agent_key"] == "opaque-agent-key" + + +def test_nous_device_code_login_retries_legacy_scope_when_invoke_refused(monkeypatch): + import hermes_cli.auth as auth_mod + + scopes = [] + + def _fake_request_device_code(*, client, portal_base_url, client_id, scope): + del client, portal_base_url, client_id + scopes.append(scope) + if len(scopes) == 1: + request = httpx.Request("POST", "https://portal.example.com/api/oauth/device/code") + response = httpx.Response( + 400, + json={ + "error": "invalid_scope", + "error_description": "unsupported inference:invoke", + }, + request=request, + ) + raise httpx.HTTPStatusError("invalid_scope", request=request, response=response) + return { + "device_code": "device", + "user_code": "user", + "verification_uri": "https://portal.example.com/device", + "verification_uri_complete": "https://portal.example.com/device?code=user", + "expires_in": 600, + "interval": 1, + } + + def _fake_poll_for_token(**kwargs): + del kwargs + return { + "access_token": "access-legacy", + "refresh_token": "refresh-legacy", + "expires_in": 900, + "scope": auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE, + } + + def _fake_refresh(state, **kwargs): + del kwargs + refreshed = dict(state) + refreshed["agent_key"] = "opaque-agent-key" + refreshed["agent_key_expires_at"] = _future_iso(1800) + return refreshed + + monkeypatch.setattr(auth_mod, "_request_device_code", _fake_request_device_code) + monkeypatch.setattr(auth_mod, "_poll_for_token", _fake_poll_for_token) + monkeypatch.setattr(auth_mod, "refresh_nous_oauth_from_state", _fake_refresh) + + result = auth_mod._nous_device_code_login( + portal_base_url="https://portal.example.com", + inference_base_url="https://inference.example.com/v1", + open_browser=False, + timeout_seconds=1, + ) + + assert scopes == [auth_mod.DEFAULT_NOUS_SCOPE, auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE] + assert result["scope"] == auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE + assert result["agent_key"] == "opaque-agent-key" + + +def test_forced_legacy_env_skips_invoke_scope_and_jwt_storage(tmp_path, monkeypatch): + import hermes_cli.auth as auth_mod + + hermes_home = tmp_path / "hermes" + token = _invoke_jwt(seconds=3600) + _setup_nous_auth( + hermes_home, + access_token=token, + scope=auth_mod.DEFAULT_NOUS_SCOPE, + expires_at=_future_iso(3600), + expires_in=3600, + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv(auth_mod.NOUS_LEGACY_SESSION_KEYS_ENV, "true") + + mint_calls = [] + + def _fake_mint_agent_key(*, client, portal_base_url, access_token, min_ttl_seconds): + del client, portal_base_url, min_ttl_seconds + mint_calls.append(access_token) + return _mint_payload(api_key="forced-legacy-key") + + monkeypatch.setattr(auth_mod, "_mint_agent_key", _fake_mint_agent_key) + + creds = auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=300) + + assert mint_calls == [token] + assert creds["api_key"] == "forced-legacy-key" + payload = json.loads((hermes_home / "auth.json").read_text()) + assert payload["providers"]["nous"]["agent_key"] == "forced-legacy-key" + + requested_scopes = [] + + def _fake_request_device_code(*, client, portal_base_url, client_id, scope): + del client, portal_base_url, client_id + requested_scopes.append(scope) + return { + "device_code": "device", + "user_code": "user", + "verification_uri": "https://portal.example.com/device", + "verification_uri_complete": "https://portal.example.com/device?code=user", + "expires_in": 600, + "interval": 1, + } + + def _fake_poll_for_token(**kwargs): + del kwargs + return { + "access_token": "access-legacy", + "refresh_token": "refresh-legacy", + "expires_in": 900, + "scope": auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE, + } + + def _fake_refresh(state, **kwargs): + del kwargs + refreshed = dict(state) + refreshed["agent_key"] = "forced-legacy-login-key" + refreshed["agent_key_expires_at"] = _future_iso(1800) + return refreshed + + monkeypatch.setattr(auth_mod, "_request_device_code", _fake_request_device_code) + monkeypatch.setattr(auth_mod, "_poll_for_token", _fake_poll_for_token) + monkeypatch.setattr(auth_mod, "refresh_nous_oauth_from_state", _fake_refresh) + + auth_mod._nous_device_code_login( + portal_base_url="https://portal.example.com", + inference_base_url="https://inference.example.com/v1", + open_browser=False, + timeout_seconds=1, + ) + + assert requested_scopes == [auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE] + + +def test_nous_inference_auth_logs_do_not_include_secret_values( + tmp_path, + monkeypatch, + caplog, +): + import hermes_cli.auth as auth_mod + + hermes_home = tmp_path / "hermes" + token = _jwt_with_claims({ + "sub": "secret-user", + "scope": "inference:mint_agent_key", + "exp": int(time.time() + 3600), + }) + refresh_token = "refresh-secret-token" + opaque_key = "opaque-secret-agent-key" + _setup_nous_auth( + hermes_home, + access_token=token, + refresh_token=refresh_token, + scope=auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE, + expires_at=_future_iso(3600), + expires_in=3600, + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + def _fake_mint_agent_key(*, client, portal_base_url, access_token, min_ttl_seconds): + del client, portal_base_url, access_token, min_ttl_seconds + return _mint_payload(api_key=opaque_key) + + monkeypatch.setattr(auth_mod, "_mint_agent_key", _fake_mint_agent_key) + + caplog.set_level(logging.INFO, logger="hermes_cli.auth") + auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=300) + + logged = caplog.text + assert "legacy session key path" in logged + assert token not in logged + assert refresh_token not in logged + assert opaque_key not in logged + + def test_get_nous_auth_status_checks_credential_pool(tmp_path, monkeypatch): """get_nous_auth_status() should find Nous credentials in the pool even when the auth store has no Nous provider entry — this is the From 0bac7dd05bd56fd615ef4b5c499a60a42a8b32b6 Mon Sep 17 00:00:00 2001 From: Robin Fernandes Date: Sun, 17 May 2026 20:34:39 +1000 Subject: [PATCH 073/418] refactor(auth): collapse Nous inference fallback controls --- agent/auxiliary_client.py | 16 +- agent/credential_pool.py | 51 +- hermes_cli/auth.py | 528 ++++++++++++++------ hermes_cli/proxy/adapters/base.py | 15 + hermes_cli/proxy/adapters/nous_portal.py | 45 +- hermes_cli/proxy/server.py | 113 +++-- hermes_cli/web_server.py | 39 +- run_agent.py | 12 +- tests/agent/test_credential_pool.py | 84 +++- tests/hermes_cli/test_auth_nous_provider.py | 137 ++++- tests/hermes_cli/test_proxy.py | 112 ++++- tests/hermes_cli/test_web_oauth_dispatch.py | 139 +++++- tests/run_agent/test_run_agent.py | 2 +- 13 files changed, 1062 insertions(+), 231 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index b2733fd8a1b4..e67b37b00dad 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1252,12 +1252,20 @@ def _resolve_nous_runtime_api(*, force_refresh: bool = False) -> Optional[tuple[ or the credential pool. """ try: - from hermes_cli.auth import resolve_nous_runtime_credentials + from hermes_cli.auth import ( + NOUS_INFERENCE_AUTH_AUTO, + NOUS_INFERENCE_AUTH_LEGACY, + resolve_nous_runtime_credentials, + ) creds = resolve_nous_runtime_credentials( min_key_ttl_seconds=max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800"))), timeout_seconds=float(os.getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")), - force_mint=force_refresh, + auth_mode=( + NOUS_INFERENCE_AUTH_LEGACY + if force_refresh + else NOUS_INFERENCE_AUTH_AUTO + ), ) except Exception as exc: logger.debug("Auxiliary Nous runtime credential resolution failed: %s", exc) @@ -2501,12 +2509,12 @@ def _refresh_provider_credentials(provider: str) -> bool: _evict_cached_clients(normalized) return True if normalized == "nous": - from hermes_cli.auth import resolve_nous_runtime_credentials + from hermes_cli.auth import NOUS_INFERENCE_AUTH_LEGACY, resolve_nous_runtime_credentials creds = resolve_nous_runtime_credentials( min_key_ttl_seconds=max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800"))), timeout_seconds=float(os.getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")), - force_mint=True, + auth_mode=NOUS_INFERENCE_AUTH_LEGACY, ) if not str(creds.get("api_key", "") or "").strip(): return False diff --git a/agent/credential_pool.py b/agent/credential_pool.py index b1c41977d512..7c91a08d2aa9 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -831,7 +831,11 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po nous_state, min_key_ttl_seconds=DEFAULT_AGENT_KEY_MIN_TTL_SECONDS, force_refresh=force, - force_mint=force, + auth_mode=( + auth_mod.NOUS_INFERENCE_AUTH_LEGACY + if force + else auth_mod.NOUS_INFERENCE_AUTH_AUTO + ), ) # Apply returned fields: dataclass fields via replace, extras via dict update field_updates = {} @@ -952,25 +956,27 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po exc, reason="credential_pool_refresh_failure", ) + auth_mod._quarantine_nous_pool_entries( + auth_store, + exc, + reason="credential_pool_refresh_failure", + ) _save_provider_state(auth_store, "nous", state) _save_auth_store(auth_store) except Exception as clear_exc: logger.debug("Failed to clear terminal Nous OAuth state: %s", clear_exc) - cleared = replace( - entry, - access_token=None, - refresh_token=None, - agent_key=None, - agent_key_expires_at=None, - ) - self._replace_entry(entry, cleared) + singleton_sources = { + auth_mod.NOUS_DEVICE_CODE_SOURCE, + f"manual:{auth_mod.NOUS_DEVICE_CODE_SOURCE}", + } + self._entries = [ + item for item in self._entries + if item.source not in singleton_sources + ] + if self._current_id == entry.id: + self._current_id = None self._persist() - self._mark_exhausted( - cleared, - 401, - {"reason": getattr(exc, "code", None), "message": str(exc)}, - ) return None self._mark_exhausted(entry, None) return None @@ -1408,7 +1414,22 @@ def _is_suppressed(_p, _s): # type: ignore[misc] elif provider == "nous": state = _load_provider_state(auth_store, "nous") - if state and not _is_suppressed(provider, "device_code"): + has_runtime_material = bool( + isinstance(state, dict) + and ( + str(state.get("access_token") or "").strip() + or str(state.get("agent_key") or "").strip() + ) + ) + if state and not has_runtime_material: + retained = [ + entry for entry in entries + if entry.source not in {"device_code", "manual:device_code"} + ] + if len(retained) != len(entries): + entries[:] = retained + changed = True + if state and has_runtime_material and not _is_suppressed(provider, "device_code"): active_sources.add("device_code") # Prefer a user-supplied label embedded in the singleton state # (set by persist_nous_credentials(label=...) when the user ran diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 2a670589d486..783f2c0c6554 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -11,6 +11,12 @@ - resolve_provider() picks the active provider via priority chain - resolve_*_runtime_credentials() handles token refresh and key minting - logout_command() is the CLI entry point for clearing auth + +Nous authentication paths: +- Invoke JWT (preferred): use a scoped access_token directly for inference. +- Legacy session key (fallback): mint an opaque 24h key when JWT auth is + unavailable, or when HERMES_AGENT_USE_LEGACY_SESSION_KEYS is set for + debugging or rollback. """ from __future__ import annotations @@ -71,6 +77,15 @@ NOUS_INFERENCE_INVOKE_SCOPE = "inference:invoke" DEFAULT_NOUS_SCOPE = f"{NOUS_INFERENCE_INVOKE_SCOPE} {NOUS_LEGACY_AGENT_KEY_SCOPE}" NOUS_LEGACY_SESSION_KEYS_ENV = "HERMES_AGENT_USE_LEGACY_SESSION_KEYS" +NOUS_DEVICE_CODE_SOURCE = "device_code" +NOUS_INFERENCE_AUTH_AUTO = "auto" +NOUS_INFERENCE_AUTH_FRESH = "fresh" +NOUS_INFERENCE_AUTH_LEGACY = "legacy" +NOUS_INFERENCE_AUTH_MODES = frozenset({ + NOUS_INFERENCE_AUTH_AUTO, + NOUS_INFERENCE_AUTH_FRESH, + NOUS_INFERENCE_AUTH_LEGACY, +}) DEFAULT_AGENT_KEY_MIN_TTL_SECONDS = 30 * 60 # 30 minutes ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 # refresh 2 min before expiry NOUS_INVOKE_JWT_MIN_TTL_SECONDS = ACCESS_TOKEN_REFRESH_SKEW_SECONDS @@ -1554,6 +1569,8 @@ def _decode_jwt_claims(token: Any) -> Dict[str, Any]: def _scope_values(raw_scope: Any) -> set[str]: + # OAuth token responses normally return a space-separated string. Keep + # collection support for JWT ``scp`` claims and older stored test fixtures. scopes: set[str] = set() if isinstance(raw_scope, str): for part in raw_scope.replace(",", " ").split(): @@ -1575,28 +1592,61 @@ def _nous_scope_has_invoke(raw_scope: Any) -> bool: return NOUS_INFERENCE_INVOKE_SCOPE in _scope_values(raw_scope) -def _nous_invoke_jwt_is_usable( +def _normalize_nous_auth_mode(auth_mode: Optional[str]) -> str: + mode = str(auth_mode or NOUS_INFERENCE_AUTH_AUTO).strip().lower() + if mode not in NOUS_INFERENCE_AUTH_MODES: + allowed = ", ".join(sorted(NOUS_INFERENCE_AUTH_MODES)) + raise ValueError( + f"Invalid Nous inference auth mode {auth_mode!r}; expected one of: {allowed}" + ) + return mode + + +def _nous_invoke_jwt_status( token: Any, *, scope: Any = None, expires_at: Any = None, min_ttl_seconds: int = NOUS_INVOKE_JWT_MIN_TTL_SECONDS, -) -> bool: +) -> Optional[str]: + """Return None when the token can be used for inference, else a reason.""" claims = _decode_jwt_claims(token) if not claims: - return False + return "access_token_not_jwt" scopes = ( _scope_values(scope) | _scope_values(claims.get("scope")) | _scope_values(claims.get("scp")) ) if NOUS_INFERENCE_INVOKE_SCOPE not in scopes: - return False + return "missing_inference_invoke_scope" exp = claims.get("exp") skew = max(0, int(min_ttl_seconds)) if isinstance(exp, (int, float)): - return float(exp) > (time.time() + skew) - return not _is_expiring(expires_at, skew) + if float(exp) <= (time.time() + skew): + return "invoke_jwt_expiring" + return None + if _is_expiring(expires_at, skew): + return "invoke_jwt_expiry_unknown_or_expiring" + return None + + +def _nous_invoke_jwt_is_usable( + token: Any, + *, + scope: Any = None, + expires_at: Any = None, + min_ttl_seconds: int = NOUS_INVOKE_JWT_MIN_TTL_SECONDS, +) -> bool: + return ( + _nous_invoke_jwt_status( + token, + scope=scope, + expires_at=expires_at, + min_ttl_seconds=min_ttl_seconds, + ) + is None + ) def _nous_invoke_jwt_unavailable_reason( @@ -1606,23 +1656,115 @@ def _nous_invoke_jwt_unavailable_reason( expires_at: Any = None, min_ttl_seconds: int = NOUS_INVOKE_JWT_MIN_TTL_SECONDS, ) -> str: - claims = _decode_jwt_claims(token) - if not claims: - return "access_token_not_jwt" - scopes = ( - _scope_values(scope) - | _scope_values(claims.get("scope")) - | _scope_values(claims.get("scp")) + return ( + _nous_invoke_jwt_status( + token, + scope=scope, + expires_at=expires_at, + min_ttl_seconds=min_ttl_seconds, + ) + or "invoke_jwt_unavailable" + ) + + +def _nous_can_select_invoke_jwt(auth_mode: str = NOUS_INFERENCE_AUTH_AUTO) -> bool: + return ( + not _nous_legacy_session_keys_forced() + and _normalize_nous_auth_mode(auth_mode) != NOUS_INFERENCE_AUTH_LEGACY + ) + + +def _nous_legacy_session_key_reason( + token: Any, + *, + scope: Any = None, + expires_at: Any = None, + auth_mode: str = NOUS_INFERENCE_AUTH_AUTO, +) -> str: + if _normalize_nous_auth_mode(auth_mode) == NOUS_INFERENCE_AUTH_LEGACY: + return "forced_legacy_session_key" + if _nous_legacy_session_keys_forced(): + return "forced_legacy_session_keys" + return _nous_invoke_jwt_unavailable_reason( + token, + scope=scope, + expires_at=expires_at, + ) + + +def _nous_cached_agent_key_is_usable( + state: Dict[str, Any], + min_ttl_seconds: int, +) -> bool: + return _agent_key_is_usable(state, min_ttl_seconds) + + +def _choose_nous_inference_auth_path( + state: Dict[str, Any], + *, + access_token: Any = None, + min_key_ttl_seconds: int = DEFAULT_AGENT_KEY_MIN_TTL_SECONDS, + auth_mode: str = NOUS_INFERENCE_AUTH_AUTO, +) -> Tuple[str, Optional[str]]: + auth_mode = _normalize_nous_auth_mode(auth_mode) + token = state.get("access_token") if access_token is None else access_token + if ( + _nous_can_select_invoke_jwt(auth_mode) + and _nous_invoke_jwt_is_usable( + token, + scope=state.get("scope"), + expires_at=state.get("expires_at"), + ) + ): + return "invoke_jwt", None + if ( + auth_mode == NOUS_INFERENCE_AUTH_AUTO + and _nous_cached_agent_key_is_usable( + state, + max(60, int(min_key_ttl_seconds)), + ) + ): + return "legacy_session_key_cache", None + return ( + "legacy_session_key_mint", + _nous_legacy_session_key_reason( + token, + scope=state.get("scope"), + expires_at=state.get("expires_at"), + auth_mode=auth_mode, + ), + ) + + +def _log_nous_invoke_jwt_selected( + *, + access_token: Any, + sequence_id: Optional[str] = None, +) -> None: + logger.info("Nous inference auth: using NAS invoke JWT") + _oauth_trace( + "nous_invoke_jwt_selected", + sequence_id=sequence_id, + access_token_fp=_token_fingerprint(access_token), + ) + + +def _log_nous_legacy_session_key_selected( + reason: str, + *, + access_token: Any, + sequence_id: Optional[str] = None, +) -> None: + logger.info( + "Nous inference auth: using legacy session key path (%s)", + reason, + ) + _oauth_trace( + "nous_legacy_session_key_selected", + sequence_id=sequence_id, + reason=reason, + access_token_fp=_token_fingerprint(access_token), ) - if NOUS_INFERENCE_INVOKE_SCOPE not in scopes: - return "missing_inference_invoke_scope" - exp = claims.get("exp") - skew = max(0, int(min_ttl_seconds)) - if isinstance(exp, (int, float)) and float(exp) <= (time.time() + skew): - return "invoke_jwt_expiring" - if not isinstance(exp, (int, float)) and _is_expiring(expires_at, skew): - return "invoke_jwt_expiry_unknown_or_expiring" - return "invoke_jwt_unavailable" def _nous_jwt_expires_at(token: Any, fallback_expires_at: Any = None) -> Optional[str]: @@ -1645,7 +1787,17 @@ def _set_nous_agent_key_from_invoke_jwt( if not isinstance(access_token, str) or not access_token.strip(): return now = datetime.now(timezone.utc) - effective_obtained_at = obtained_at or now.isoformat() + existing_obtained_at = state.get("agent_key_obtained_at") + if obtained_at: + effective_obtained_at = obtained_at + elif ( + state.get("agent_key") == access_token + and isinstance(existing_obtained_at, str) + and existing_obtained_at.strip() + ): + effective_obtained_at = existing_obtained_at + else: + effective_obtained_at = now.isoformat() expires_at = _nous_jwt_expires_at(access_token, state.get("expires_at")) expires_epoch = _parse_iso_timestamp(expires_at) expires_in = ( @@ -1664,6 +1816,38 @@ def _set_nous_agent_key_from_invoke_jwt( state["agent_key_obtained_at"] = effective_obtained_at +def _select_nous_invoke_jwt( + state: Dict[str, Any], + *, + access_token: Any = None, + sequence_id: Optional[str] = None, +) -> None: + if isinstance(access_token, str) and access_token.strip(): + state["access_token"] = access_token + _set_nous_agent_key_from_invoke_jwt(state) + _log_nous_invoke_jwt_selected( + access_token=state.get("access_token"), + sequence_id=sequence_id, + ) + + +_NOUS_EFFECTIVE_STATE_IGNORED_KEYS = frozenset({ + # These are derived from expires_at/JWT exp and naturally tick down between + # reads. Persisting only these changes makes auth.json noisy and defeats + # the mtime-keyed auth-status cache. + "expires_in", + "agent_key_expires_in", +}) + + +def _nous_effective_provider_state(state: Dict[str, Any]) -> Dict[str, Any]: + return { + key: value + for key, value in state.items() + if key not in _NOUS_EFFECTIVE_STATE_IGNORED_KEYS + } + + def _codex_access_token_is_expiring(access_token: Any, skew_seconds: int) -> bool: claims = _decode_jwt_claims(access_token) exp = claims.get("exp") @@ -3476,6 +3660,57 @@ def _is_nous_invoke_scope_refusal(exc: Exception) -> bool: ) +def _nous_device_scope( + requested_scope: Optional[str], + *, + default_scope: str = DEFAULT_NOUS_SCOPE, +) -> Tuple[str, bool]: + explicit_scope = requested_scope is not None + scope = requested_scope or default_scope + if _nous_legacy_session_keys_forced(): + scope = NOUS_LEGACY_AGENT_KEY_SCOPE + return scope, explicit_scope + + +def _request_nous_device_code_with_scope_fallback( + *, + client: httpx.Client, + portal_base_url: str, + client_id: str, + scope: str, + allow_legacy_fallback: bool, +) -> Tuple[Dict[str, Any], str]: + try: + return ( + _request_device_code( + client=client, + portal_base_url=portal_base_url, + client_id=client_id, + scope=scope, + ), + scope, + ) + except Exception as exc: + if ( + allow_legacy_fallback + and _nous_scope_has_invoke(scope) + and _is_nous_invoke_scope_refusal(exc) + ): + logger.info("Nous inference auth: NAS refused invoke scope, retrying legacy scope") + _oauth_trace("nous_device_code_invoke_scope_refused") + retry_scope = NOUS_LEGACY_AGENT_KEY_SCOPE + return ( + _request_device_code( + client=client, + portal_base_url=portal_base_url, + client_id=client_id, + scope=retry_scope, + ), + retry_scope, + ) + raise + + def _poll_for_token( client: httpx.Client, portal_base_url: str, @@ -3817,6 +4052,39 @@ def _quarantine_nous_oauth_state( invalidate_nous_auth_status_cache() +def _quarantine_nous_pool_entries( + auth_store: Dict[str, Any], + error: AuthError, + *, + reason: str, +) -> bool: + """Remove singleton-seeded Nous pool entries that contain dead OAuth state.""" + pool = auth_store.get("credential_pool") + if not isinstance(pool, dict): + return False + entries = pool.get("nous") + if not isinstance(entries, list): + return False + + retained = [] + removed = False + singleton_sources = {NOUS_DEVICE_CODE_SOURCE, f"manual:{NOUS_DEVICE_CODE_SOURCE}"} + for entry in entries: + if isinstance(entry, dict) and entry.get("source") in singleton_sources: + removed = True + continue + retained.append(entry) + + if removed: + pool["nous"] = retained + _oauth_trace( + "nous_pool_device_code_quarantined", + reason=reason, + error_code=error.code, + ) + return removed + + def _try_import_shared_nous_state( *, timeout_seconds: float = 15.0, @@ -3842,7 +4110,7 @@ def _try_import_shared_nous_state( # Build a full state dict so refresh_nous_oauth_from_state has every # field it needs. force_refresh=True gets us a fresh access_token - # for this profile; force_mint=True gets us a fresh agent_key. + # for this profile; fresh auth mode avoids stale cached legacy keys. state: Dict[str, Any] = { "access_token": shared.get("access_token"), "refresh_token": shared.get("refresh_token"), @@ -3863,7 +4131,7 @@ def _try_import_shared_nous_state( min_key_ttl_seconds=min_key_ttl_seconds, timeout_seconds=timeout_seconds, force_refresh=True, - force_mint=True, + auth_mode=NOUS_INFERENCE_AUTH_FRESH, ) _write_shared_nous_state(refreshed) except AuthError as exc: @@ -4121,6 +4389,11 @@ def resolve_nous_access_token( exc, reason="managed_access_token_refresh_failure", ) + _quarantine_nous_pool_entries( + auth_store, + exc, + reason="managed_access_token_refresh_failure", + ) _save_provider_state(auth_store, "nous", state) _save_auth_store(auth_store) raise @@ -4167,9 +4440,10 @@ def refresh_nous_oauth_pure( insecure: Optional[bool] = None, ca_bundle: Optional[str] = None, force_refresh: bool = False, - force_mint: bool = False, + auth_mode: str = NOUS_INFERENCE_AUTH_AUTO, ) -> Dict[str, Any]: """Refresh Nous OAuth state without mutating auth.json.""" + auth_mode = _normalize_nous_auth_mode(auth_mode) state: Dict[str, Any] = { "access_token": access_token, "refresh_token": refresh_token, @@ -4229,38 +4503,17 @@ def refresh_nous_oauth_pure( now.timestamp() + access_ttl, tz=timezone.utc ).isoformat() - if ( - not legacy_session_keys - and _nous_invoke_jwt_is_usable( - state.get("access_token"), - scope=state.get("scope"), - expires_at=state.get("expires_at"), - ) - ): - _set_nous_agent_key_from_invoke_jwt(state) - logger.info("Nous inference auth: using NAS invoke JWT") - _oauth_trace( - "nous_invoke_jwt_selected", - access_token_fp=_token_fingerprint(state.get("access_token")), - ) - elif force_mint or not _agent_key_is_usable(state, min_agent_key_ttl): - fallback_reason = ( - "forced_legacy_session_keys" - if legacy_session_keys - else _nous_invoke_jwt_unavailable_reason( - state.get("access_token"), - scope=state.get("scope"), - expires_at=state.get("expires_at"), - ) - ) - logger.info( - "Nous inference auth: using legacy session key path (%s)", - fallback_reason, - ) - _oauth_trace( - "nous_legacy_session_key_selected", - reason=fallback_reason, - access_token_fp=_token_fingerprint(state.get("access_token")), + selected_auth_path, fallback_reason = _choose_nous_inference_auth_path( + state, + min_key_ttl_seconds=min_agent_key_ttl, + auth_mode=auth_mode, + ) + if selected_auth_path == "invoke_jwt": + _select_nous_invoke_jwt(state) + elif selected_auth_path == "legacy_session_key_mint": + _log_nous_legacy_session_key_selected( + fallback_reason or "legacy_session_key_required", + access_token=state.get("access_token"), ) mint_payload = _mint_agent_key( client=client, @@ -4288,7 +4541,7 @@ def refresh_nous_oauth_from_state( min_key_ttl_seconds: int = DEFAULT_AGENT_KEY_MIN_TTL_SECONDS, timeout_seconds: float = 15.0, force_refresh: bool = False, - force_mint: bool = False, + auth_mode: str = NOUS_INFERENCE_AUTH_AUTO, ) -> Dict[str, Any]: """Refresh Nous OAuth from a state dict. Thin wrapper around refresh_nous_oauth_pure.""" tls = state.get("tls") or {} @@ -4309,13 +4562,10 @@ def refresh_nous_oauth_from_state( insecure=tls.get("insecure"), ca_bundle=tls.get("ca_bundle"), force_refresh=force_refresh, - force_mint=force_mint, + auth_mode=auth_mode, ) -NOUS_DEVICE_CODE_SOURCE = "device_code" - - def persist_nous_credentials( creds: Dict[str, Any], *, @@ -4390,7 +4640,7 @@ def resolve_nous_runtime_credentials( timeout_seconds: float = 15.0, insecure: Optional[bool] = None, ca_bundle: Optional[str] = None, - force_mint: bool = False, + auth_mode: str = NOUS_INFERENCE_AUTH_AUTO, ) -> Dict[str, Any]: """ Resolve Nous inference credentials for runtime use. @@ -4402,6 +4652,7 @@ def resolve_nous_runtime_credentials( Returns dict with: provider, base_url, api_key, key_id, expires_at, expires_in, source ("invoke_jwt", "cache", or "portal"), and auth_path. """ + auth_mode = _normalize_nous_auth_mode(auth_mode) min_key_ttl_seconds = max(60, int(min_key_ttl_seconds)) sequence_id = uuid.uuid4().hex[:12] @@ -4413,6 +4664,9 @@ def resolve_nous_runtime_credentials( raise AuthError("Hermes is not logged into Nous Portal.", provider="nous", relogin_required=True) + persisted_state = dict(state) + state_persisted = False + portal_base_url = ( _optional_base_url(state.get("portal_base_url")) or os.getenv("HERMES_PORTAL_BASE_URL") @@ -4427,6 +4681,17 @@ def resolve_nous_runtime_credentials( client_id = str(state.get("client_id") or DEFAULT_NOUS_CLIENT_ID) def _persist_state(reason: str) -> None: + nonlocal persisted_state, state_persisted + if ( + _nous_effective_provider_state(state) + == _nous_effective_provider_state(persisted_state) + ): + _oauth_trace( + "nous_state_persist_skipped", + sequence_id=sequence_id, + reason=reason, + ) + return try: _save_provider_state(auth_store, "nous", state) _save_auth_store(auth_store) @@ -4445,6 +4710,8 @@ def _persist_state(reason: str) -> None: refresh_token_fp=_token_fingerprint(state.get("refresh_token")), access_token_fp=_token_fingerprint(state.get("access_token")), ) + persisted_state = dict(state) + state_persisted = True # Mirror post-refresh state to the shared store so sibling # profiles don't hold stale refresh_tokens after rotation. # Best-effort — any failure is logged and swallowed inside @@ -4456,7 +4723,7 @@ def _persist_state(reason: str) -> None: _oauth_trace( "nous_runtime_credentials_start", sequence_id=sequence_id, - force_mint=bool(force_mint), + auth_mode=auth_mode, min_key_ttl_seconds=min_key_ttl_seconds, refresh_token_fp=_token_fingerprint(state.get("refresh_token")), ) @@ -4520,6 +4787,11 @@ def _persist_state(reason: str) -> None: exc, reason="runtime_access_refresh_failure", ) + _quarantine_nous_pool_entries( + auth_store, + exc, + reason="runtime_access_refresh_failure", + ) _persist_state("terminal_runtime_access_refresh_failure") raise now = datetime.now(timezone.utc) @@ -4554,50 +4826,28 @@ def _persist_state(reason: str) -> None: # the opaque session key. used_cached_key = False mint_payload: Optional[Dict[str, Any]] = None - selected_auth_path = "legacy_session_key" - legacy_session_keys = _nous_legacy_session_keys_forced() + selected_auth_path, fallback_reason = _choose_nous_inference_auth_path( + state, + access_token=access_token, + min_key_ttl_seconds=min_key_ttl_seconds, + auth_mode=auth_mode, + ) - if ( - not legacy_session_keys - and _nous_invoke_jwt_is_usable( - access_token, - scope=state.get("scope"), - expires_at=state.get("expires_at"), - ) - ): - _set_nous_agent_key_from_invoke_jwt(state) - selected_auth_path = "invoke_jwt" - logger.info("Nous inference auth: using NAS invoke JWT") - _oauth_trace( - "nous_invoke_jwt_selected", + if selected_auth_path == "invoke_jwt": + _select_nous_invoke_jwt( + state, + access_token=access_token, sequence_id=sequence_id, - access_token_fp=_token_fingerprint(access_token), ) - elif not force_mint and _agent_key_is_usable(state, min_key_ttl_seconds): + elif selected_auth_path == "legacy_session_key_cache": used_cached_key = True - selected_auth_path = "legacy_session_key_cache" - logger.info("Nous inference auth: using cached legacy session key") + logger.info("Nous inference auth: using cached agent_key") _oauth_trace("agent_key_reuse", sequence_id=sequence_id) else: - fallback_reason = ( - "forced_legacy_session_keys" - if legacy_session_keys - else _nous_invoke_jwt_unavailable_reason( - access_token, - scope=state.get("scope"), - expires_at=state.get("expires_at"), - ) - ) - selected_auth_path = "legacy_session_key_mint" - logger.info( - "Nous inference auth: using legacy session key path (%s)", - fallback_reason, - ) - _oauth_trace( - "nous_legacy_session_key_selected", + _log_nous_legacy_session_key_selected( + fallback_reason or "legacy_session_key_required", + access_token=access_token, sequence_id=sequence_id, - reason=fallback_reason, - access_token_fp=_token_fingerprint(access_token), ) try: _oauth_trace( @@ -4646,6 +4896,11 @@ def _persist_state(reason: str) -> None: exc, reason="runtime_mint_retry_refresh_failure", ) + _quarantine_nous_pool_entries( + auth_store, + exc, + reason="runtime_mint_retry_refresh_failure", + ) _persist_state("terminal_runtime_mint_retry_refresh_failure") raise now = datetime.now(timezone.utc) @@ -4674,22 +4929,24 @@ def _persist_state(reason: str) -> None: # Persist retry refresh immediately for crash safety and cross-process visibility. _persist_state("post_refresh_mint_retry") - if ( - not legacy_session_keys - and _nous_invoke_jwt_is_usable( - access_token, - scope=state.get("scope"), - expires_at=state.get("expires_at"), - ) - ): - _set_nous_agent_key_from_invoke_jwt(state) + retry_auth_mode = ( + NOUS_INFERENCE_AUTH_LEGACY + if auth_mode == NOUS_INFERENCE_AUTH_LEGACY + else NOUS_INFERENCE_AUTH_FRESH + ) + retry_auth_path, _ = _choose_nous_inference_auth_path( + state, + access_token=access_token, + min_key_ttl_seconds=min_key_ttl_seconds, + auth_mode=retry_auth_mode, + ) + if retry_auth_path == "invoke_jwt": mint_payload = None selected_auth_path = "invoke_jwt" - logger.info("Nous inference auth: using NAS invoke JWT") - _oauth_trace( - "nous_invoke_jwt_selected", + _select_nous_invoke_jwt( + state, + access_token=access_token, sequence_id=sequence_id, - access_token_fp=_token_fingerprint(access_token), ) else: mint_payload = _mint_agent_key( @@ -4727,7 +4984,8 @@ def _persist_state(reason: str) -> None: _persist_state("resolve_nous_runtime_credentials_final") - _sync_nous_pool_from_auth_store() + if state_persisted: + _sync_nous_pool_from_auth_store() api_key = state.get("agent_key") if not isinstance(api_key, str) or not api_key: @@ -6433,10 +6691,7 @@ def _nous_device_code_login( or pconfig.inference_base_url ).rstrip("/") client_id = client_id or pconfig.client_id - explicit_scope = scope is not None - scope = scope or pconfig.scope - if _nous_legacy_session_keys_forced(): - scope = NOUS_LEGACY_AGENT_KEY_SCOPE + scope, explicit_scope = _nous_device_scope(scope, default_scope=pconfig.scope) timeout = httpx.Timeout(timeout_seconds) verify: bool | str = False if insecure else (ca_bundle if ca_bundle else True) @@ -6451,30 +6706,13 @@ def _nous_device_code_login( print(f"TLS verification: custom CA bundle ({ca_bundle})") with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}, verify=verify) as client: - try: - device_data = _request_device_code( - client=client, - portal_base_url=portal_base_url, - client_id=client_id, - scope=scope, - ) - except Exception as exc: - if ( - not explicit_scope - and _nous_scope_has_invoke(scope) - and _is_nous_invoke_scope_refusal(exc) - ): - logger.info("Nous inference auth: NAS refused invoke scope, retrying legacy scope") - _oauth_trace("nous_device_code_invoke_scope_refused") - scope = NOUS_LEGACY_AGENT_KEY_SCOPE - device_data = _request_device_code( - client=client, - portal_base_url=portal_base_url, - client_id=client_id, - scope=scope, - ) - else: - raise + device_data, scope = _request_nous_device_code_with_scope_fallback( + client=client, + portal_base_url=portal_base_url, + client_id=client_id, + scope=scope, + allow_legacy_fallback=not explicit_scope, + ) verification_url = str(device_data["verification_uri_complete"]) user_code = str(device_data["user_code"]) @@ -6543,7 +6781,7 @@ def _nous_device_code_login( min_key_ttl_seconds=min_key_ttl_seconds, timeout_seconds=timeout_seconds, force_refresh=False, - force_mint=True, + auth_mode=NOUS_INFERENCE_AUTH_FRESH, ) except AuthError as exc: if exc.code == "subscription_required": diff --git a/hermes_cli/proxy/adapters/base.py b/hermes_cli/proxy/adapters/base.py index 5ac8a5dcedd2..c7f36e25a2b4 100644 --- a/hermes_cli/proxy/adapters/base.py +++ b/hermes_cli/proxy/adapters/base.py @@ -81,6 +81,21 @@ def get_credential(self) -> UpstreamCredential: refresh fails. The proxy will return 401 to the client. """ + def get_retry_credential( + self, + *, + failed_credential: UpstreamCredential, + status_code: int, + ) -> Optional[UpstreamCredential]: + """Return an alternate credential after an upstream auth failure. + + The default is no retry. Providers can override this for one-shot + fallback paths, such as switching from a preferred token type to a + legacy bearer after the upstream rejects the first request. + """ + del failed_credential, status_code + return None + def describe(self) -> str: """One-line status summary for ``proxy status``.""" try: diff --git a/hermes_cli/proxy/adapters/nous_portal.py b/hermes_cli/proxy/adapters/nous_portal.py index b69f9d526443..a8cfd4cbada0 100644 --- a/hermes_cli/proxy/adapters/nous_portal.py +++ b/hermes_cli/proxy/adapters/nous_portal.py @@ -19,13 +19,16 @@ from hermes_cli.auth import ( AuthError, DEFAULT_NOUS_INFERENCE_URL, + NOUS_INFERENCE_AUTH_AUTO, + NOUS_INFERENCE_AUTH_LEGACY, _load_auth_store, _is_terminal_nous_refresh_error, _quarantine_nous_oauth_state, + _quarantine_nous_pool_entries, _save_auth_store, _write_shared_nous_state, refresh_nous_oauth_from_state, -) + ) from hermes_cli.proxy.adapters.base import UpstreamAdapter, UpstreamCredential logger = logging.getLogger(__name__) @@ -76,6 +79,21 @@ def is_authenticated(self) -> bool: ) def get_credential(self) -> UpstreamCredential: + return self._get_credential(auth_mode=NOUS_INFERENCE_AUTH_AUTO) + + def get_retry_credential( + self, + *, + failed_credential: UpstreamCredential, + status_code: int, + ) -> Optional[UpstreamCredential]: + del failed_credential + if status_code != 401: + return None + logger.info("proxy: Nous upstream rejected bearer; retrying with legacy session key") + return self._get_credential(auth_mode=NOUS_INFERENCE_AUTH_LEGACY) + + def _get_credential(self, *, auth_mode: str) -> UpstreamCredential: with self._lock: state = self._read_state() if state is None: @@ -84,7 +102,10 @@ def get_credential(self) -> UpstreamCredential: ) try: - refreshed = refresh_nous_oauth_from_state(state) + refreshed = refresh_nous_oauth_from_state( + state, + auth_mode=auth_mode, + ) except AuthError as exc: if _is_terminal_nous_refresh_error(exc): _quarantine_nous_oauth_state( @@ -92,7 +113,11 @@ def get_credential(self) -> UpstreamCredential: exc, reason="proxy_refresh_failure", ) - self._save_state(state) + self._save_state( + state, + quarantine_error=exc, + quarantine_reason="proxy_refresh_failure", + ) raise RuntimeError( f"Failed to refresh Nous Portal credentials: {exc}" ) from exc @@ -136,9 +161,21 @@ def _read_state(self) -> Optional[Dict[str, Any]]: return None return dict(state) # copy so the refresh helper can mutate freely - def _save_state(self, state: Dict[str, Any]) -> None: + def _save_state( + self, + state: Dict[str, Any], + *, + quarantine_error: Optional[AuthError] = None, + quarantine_reason: Optional[str] = None, + ) -> None: try: store = _load_auth_store() + if quarantine_error is not None and quarantine_reason: + _quarantine_nous_pool_entries( + store, + quarantine_error, + reason=quarantine_reason, + ) providers = store.setdefault("providers", {}) providers["nous"] = state _save_auth_store(store) diff --git a/hermes_cli/proxy/server.py b/hermes_cli/proxy/server.py index fa497f132918..a72f75d67eec 100644 --- a/hermes_cli/proxy/server.py +++ b/hermes_cli/proxy/server.py @@ -26,7 +26,7 @@ web = None # type: ignore[assignment] AIOHTTP_AVAILABLE = False -from hermes_cli.proxy.adapters.base import UpstreamAdapter +from hermes_cli.proxy.adapters.base import UpstreamAdapter, UpstreamCredential logger = logging.getLogger(__name__) @@ -136,50 +136,93 @@ async def handle_proxy(request: "web.Request") -> "web.StreamResponse": logger.warning("proxy: credential resolution failed: %s", exc) return _json_error(401, str(exc), code="upstream_auth_failed") - upstream_url = f"{cred.base_url.rstrip('/')}{rel_path}" - # Preserve query string verbatim. - if request.query_string: - upstream_url = f"{upstream_url}?{request.query_string}" - # Forward body verbatim. Read into memory once — request bodies for # chat/completions/embeddings are small (<1MB typically). If we ever # need to forward large multipart uploads we'll switch to streaming # the request body too. body = await request.read() - fwd_headers = _filter_request_headers(request.headers) - fwd_headers["Authorization"] = f"{cred.token_type} {cred.bearer}" + timeout = aiohttp.ClientTimeout(total=None, sock_connect=15, sock_read=300) - logger.debug( - "proxy: forwarding %s %s -> %s (body=%d bytes)", - request.method, rel_path, upstream_url, len(body), - ) + async def _send_upstream(active_cred: UpstreamCredential): + upstream_url = f"{active_cred.base_url.rstrip('/')}{rel_path}" + # Preserve query string verbatim. + if request.query_string: + upstream_url = f"{upstream_url}?{request.query_string}" - # Use a per-request session so connection state doesn't leak between - # clients. Could be optimized to a shared session later. - timeout = aiohttp.ClientTimeout(total=None, sock_connect=15, sock_read=300) - try: - session = aiohttp.ClientSession(timeout=timeout) - except Exception as exc: # pragma: no cover - aiohttp setup issue - return _json_error(500, f"proxy session init failed: {exc}") + fwd_headers = _filter_request_headers(request.headers) + fwd_headers["Authorization"] = f"{active_cred.token_type} {active_cred.bearer}" - try: - upstream_resp = await session.request( - request.method, - upstream_url, - data=body if body else None, - headers=fwd_headers, - allow_redirects=False, + logger.debug( + "proxy: forwarding %s %s -> %s (body=%d bytes)", + request.method, rel_path, upstream_url, len(body), ) - except aiohttp.ClientError as exc: - await session.close() - logger.warning("proxy: upstream connection failed: %s", exc) - return _json_error(502, f"upstream connection failed: {exc}", - code="upstream_unreachable") - except asyncio.TimeoutError: - await session.close() - return _json_error(504, "upstream request timed out", - code="upstream_timeout") + + try: + session = aiohttp.ClientSession(timeout=timeout) + except Exception as exc: # pragma: no cover - aiohttp setup issue + raise RuntimeError(f"proxy session init failed: {exc}") from exc + + try: + upstream_resp = await session.request( + request.method, + upstream_url, + data=body if body else None, + headers=fwd_headers, + allow_redirects=False, + ) + except Exception: + await session.close() + raise + return session, upstream_resp + + async def _open_upstream(active_cred: UpstreamCredential): + try: + return await _send_upstream(active_cred) + except RuntimeError as exc: + return _json_error(500, str(exc)), None + except aiohttp.ClientError as exc: + logger.warning("proxy: upstream connection failed: %s", exc) + return ( + _json_error( + 502, + f"upstream connection failed: {exc}", + code="upstream_unreachable", + ), + None, + ) + except asyncio.TimeoutError: + return ( + _json_error( + 504, + "upstream request timed out", + code="upstream_timeout", + ), + None, + ) + + session_or_response, upstream_resp = await _open_upstream(cred) + if upstream_resp is None: + return session_or_response + session = session_or_response + + if upstream_resp.status == 401: + try: + retry_cred = adapter.get_retry_credential( + failed_credential=cred, + status_code=upstream_resp.status, + ) + except Exception as exc: + logger.warning("proxy: retry credential resolution failed: %s", exc) + retry_cred = None + + if retry_cred is not None: + upstream_resp.release() + await session.close() + session_or_response, upstream_resp = await _open_upstream(retry_cred) + if upstream_resp is None: + return session_or_response + session = session_or_response # Stream response back. Headers first, then chunked body. resp = web.StreamResponse( diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 8a1e4aca2e1d..bfd47e9cc248 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1815,7 +1815,11 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]: so the UI can render the verification page link + user code. """ if provider_id == "nous": - from hermes_cli.auth import _request_device_code, PROVIDER_REGISTRY + from hermes_cli.auth import ( + _nous_device_scope, + _request_nous_device_code_with_scope_fallback, + PROVIDER_REGISTRY, + ) import httpx pconfig = PROVIDER_REGISTRY["nous"] portal_base_url = ( @@ -1824,22 +1828,31 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]: or pconfig.portal_base_url ).rstrip("/") client_id = pconfig.client_id - scope = pconfig.scope + scope, explicit_scope = _nous_device_scope(None, default_scope=pconfig.scope) + def _do_nous_device_request(): - with httpx.Client(timeout=httpx.Timeout(15.0), headers={"Accept": "application/json"}) as client: - return _request_device_code( + with httpx.Client( + timeout=httpx.Timeout(15.0), + headers={"Accept": "application/json"}, + ) as client: + return _request_nous_device_code_with_scope_fallback( client=client, portal_base_url=portal_base_url, client_id=client_id, scope=scope, + allow_legacy_fallback=not explicit_scope, ) - device_data = await asyncio.get_running_loop().run_in_executor(None, _do_nous_device_request) + + device_data, effective_scope = await asyncio.get_running_loop().run_in_executor( + None, _do_nous_device_request + ) sid, sess = _new_oauth_session("nous", "device_code") sess["device_code"] = str(device_data["device_code"]) sess["interval"] = int(device_data["interval"]) sess["expires_at"] = time.time() + int(device_data["expires_in"]) sess["portal_base_url"] = portal_base_url sess["client_id"] = client_id + sess["scope"] = effective_scope threading.Thread( target=_nous_poller, args=(sid,), daemon=True, name=f"oauth-poll-{sid[:6]}" ).start() @@ -1968,7 +1981,11 @@ def _do_minimax_request(): def _nous_poller(session_id: str) -> None: """Background poller that drives a Nous device-code flow to completion.""" - from hermes_cli.auth import _poll_for_token, refresh_nous_oauth_from_state + from hermes_cli.auth import ( + NOUS_INFERENCE_AUTH_FRESH, + _poll_for_token, + refresh_nous_oauth_from_state, + ) from datetime import datetime, timezone import httpx with _oauth_sessions_lock: @@ -1979,6 +1996,7 @@ def _nous_poller(session_id: str) -> None: client_id = sess["client_id"] device_code = sess["device_code"] interval = sess["interval"] + scope = sess.get("scope") expires_in = max(60, int(sess["expires_at"] - time.time())) try: with httpx.Client(timeout=httpx.Timeout(15.0), headers={"Accept": "application/json"}) as client: @@ -1997,7 +2015,7 @@ def _nous_poller(session_id: str) -> None: "portal_base_url": portal_base_url, "inference_base_url": token_data.get("inference_base_url"), "client_id": client_id, - "scope": token_data.get("scope"), + "scope": token_data.get("scope") or scope, "token_type": token_data.get("token_type", "Bearer"), "access_token": token_data["access_token"], "refresh_token": token_data.get("refresh_token"), @@ -2009,8 +2027,11 @@ def _nous_poller(session_id: str) -> None: "expires_in": token_ttl, } full_state = refresh_nous_oauth_from_state( - auth_state, min_key_ttl_seconds=300, timeout_seconds=15.0, - force_refresh=False, force_mint=True, + auth_state, + min_key_ttl_seconds=300, + timeout_seconds=15.0, + force_refresh=False, + auth_mode=NOUS_INFERENCE_AUTH_FRESH, ) from hermes_cli.auth import persist_nous_credentials persist_nous_credentials(full_state) diff --git a/run_agent.py b/run_agent.py index 6e9877a1182e..1244d372fdf8 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2628,12 +2628,20 @@ def _try_refresh_nous_client_credentials(self, *, force: bool = True) -> bool: return False try: - from hermes_cli.auth import resolve_nous_runtime_credentials + from hermes_cli.auth import ( + NOUS_INFERENCE_AUTH_AUTO, + NOUS_INFERENCE_AUTH_LEGACY, + resolve_nous_runtime_credentials, + ) creds = resolve_nous_runtime_credentials( min_key_ttl_seconds=max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800"))), timeout_seconds=float(os.getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")), - force_mint=force, + auth_mode=( + NOUS_INFERENCE_AUTH_LEGACY + if force + else NOUS_INFERENCE_AUTH_AUTO + ), ) except Exception as exc: logger.debug("Nous credential refresh failed: %s", exc) diff --git a/tests/agent/test_credential_pool.py b/tests/agent/test_credential_pool.py index f7eaf9fa2734..875b08d91f05 100644 --- a/tests/agent/test_credential_pool.py +++ b/tests/agent/test_credential_pool.py @@ -566,7 +566,7 @@ def test_load_pool_mirrors_nous_invoke_jwt_agent_key_runtime_api_key(tmp_path, m assert pool_entry["agent_key_expires_at"] == expires_at -def test_nous_pool_terminal_refresh_clears_tokens(tmp_path, monkeypatch): +def test_nous_pool_terminal_refresh_removes_device_code_entry(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared")) _write_auth_store( @@ -591,7 +591,7 @@ def test_nous_pool_terminal_refresh_clears_tokens(tmp_path, monkeypatch): }, ) - from agent.credential_pool import load_pool + from agent.credential_pool import PooledCredential, load_pool from hermes_cli import auth as auth_mod from hermes_cli.auth import AuthError @@ -606,18 +606,30 @@ def _terminal_refresh_failure(*_args, **_kwargs): relogin_required=True, ) + pool = load_pool("nous") + selected = pool.select() + assert selected is not None + assert selected.source == "device_code" + pool.add_entry(PooledCredential.from_dict("nous", { + "id": "legacy-seeded", + "source": "manual:device_code", + "auth_type": "oauth", + "access_token": "old-access-token", + "refresh_token": "old-refresh-token", + "agent_key": "old-agent-key", + })) + pool.add_entry(PooledCredential.from_dict("nous", { + "id": "manual-key", + "source": "manual", + "auth_type": "api_key", + "access_token": "manual-nous-key", + })) + monkeypatch.setattr(auth_mod, "refresh_nous_oauth_from_state", _terminal_refresh_failure) - pool = load_pool("nous") - assert pool.select() is not None assert pool.try_refresh_current() is None - entry = pool.entries()[0] - assert entry.last_status == "exhausted" - assert entry.last_error_code == 401 - assert entry.refresh_token is None - assert entry.access_token is None - assert entry.agent_key is None + assert [entry.id for entry in pool.entries()] == ["manual-key"] auth_payload = json.loads((tmp_path / "hermes" / "auth.json").read_text()) nous_state = auth_payload["providers"]["nous"] @@ -625,11 +637,63 @@ def _terminal_refresh_failure(*_args, **_kwargs): assert not nous_state.get("access_token") assert not nous_state.get("agent_key") assert nous_state["last_auth_error"]["code"] == "invalid_grant" + assert [entry["id"] for entry in auth_payload["credential_pool"]["nous"]] == ["manual-key"] assert pool.try_refresh_current() is None assert refresh_calls["count"] == 1 +def test_load_pool_removes_nous_device_code_when_singleton_quarantined(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + _write_auth_store( + tmp_path, + { + "version": 1, + "active_provider": "nous", + "providers": { + "nous": { + "portal_base_url": "https://portal.example.com", + "inference_base_url": "https://inference.example.com/v1", + "client_id": "hermes-cli", + "last_auth_error": {"code": "invalid_grant"}, + } + }, + "credential_pool": { + "nous": [ + { + "id": "seeded-current", + "source": "device_code", + "auth_type": "oauth", + "access_token": "stale-access", + "refresh_token": "stale-refresh", + "agent_key": "stale-agent", + }, + { + "id": "seeded-legacy", + "source": "manual:device_code", + "auth_type": "oauth", + "access_token": "older-stale-access", + }, + { + "id": "manual-key", + "source": "manual", + "auth_type": "api_key", + "access_token": "manual-nous-key", + }, + ] + }, + }, + ) + + from agent.credential_pool import load_pool + + pool = load_pool("nous") + + assert [entry.id for entry in pool.entries()] == ["manual-key"] + auth_payload = json.loads((tmp_path / "hermes" / "auth.json").read_text()) + assert [entry["id"] for entry in auth_payload["credential_pool"]["nous"]] == ["manual-key"] + + def test_load_pool_removes_stale_file_backed_singleton_entry(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) diff --git a/tests/hermes_cli/test_auth_nous_provider.py b/tests/hermes_cli/test_auth_nous_provider.py index 1d07737a857c..0bdb1330a293 100644 --- a/tests/hermes_cli/test_auth_nous_provider.py +++ b/tests/hermes_cli/test_auth_nous_provider.py @@ -231,6 +231,83 @@ def _unexpected_mint(*args, **kwargs): assert pool_entries[0]["source"] == auth_mod.NOUS_DEVICE_CODE_SOURCE +def test_resolve_nous_runtime_credentials_invoke_jwt_is_idempotent( + tmp_path, + monkeypatch, +): + import hermes_cli.auth as auth_mod + + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + exp = int(time.time() + 3600) + expires_at = datetime.fromtimestamp(exp, tz=timezone.utc).isoformat() + token = _jwt_with_claims({ + "sub": "test-user", + "scope": auth_mod.DEFAULT_NOUS_SCOPE, + "exp": exp, + }) + original_obtained_at = "2026-04-17T22:00:10+00:00" + auth_store = { + "version": 1, + "active_provider": "nous", + "providers": { + "nous": { + "portal_base_url": "https://portal.example.com", + "inference_base_url": "https://inference.example.com/v1", + "client_id": "hermes-cli", + "token_type": "Bearer", + "scope": auth_mod.DEFAULT_NOUS_SCOPE, + "access_token": token, + "refresh_token": "refresh-token", + "obtained_at": "2026-02-01T00:00:00+00:00", + "expires_in": 123, + "expires_at": expires_at, + "agent_key": token, + "agent_key_id": None, + "agent_key_expires_at": expires_at, + "agent_key_expires_in": 123, + "agent_key_reused": False, + "agent_key_obtained_at": original_obtained_at, + "tls": {"insecure": False, "ca_bundle": None}, + }, + }, + } + auth_path = hermes_home / "auth.json" + auth_path.write_text(json.dumps(auth_store, indent=2)) + before_content = auth_path.read_text() + before_mtime = auth_path.stat().st_mtime_ns + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + def _unexpected_mint(*args, **kwargs): + raise AssertionError("stable invoke JWT should not mint a legacy key") + + def _unexpected_shared_write(*args, **kwargs): + raise AssertionError("unchanged invoke JWT resolution should not sync shared store") + + sync_calls = [] + + monkeypatch.setattr(auth_mod, "_mint_agent_key", _unexpected_mint) + monkeypatch.setattr(auth_mod, "_write_shared_nous_state", _unexpected_shared_write) + monkeypatch.setattr( + auth_mod, + "_sync_nous_pool_from_auth_store", + lambda: sync_calls.append(True), + ) + + creds = auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=300) + + assert creds["api_key"] == token + assert creds["source"] == "invoke_jwt" + assert auth_path.read_text() == before_content + assert auth_path.stat().st_mtime_ns == before_mtime + assert sync_calls == [] + payload = json.loads(auth_path.read_text()) + assert ( + payload["providers"]["nous"]["agent_key_obtained_at"] + == original_obtained_at + ) + + def test_resolve_nous_runtime_credentials_trusts_invoke_jwt_exp_over_stale_metadata( tmp_path, monkeypatch, @@ -301,6 +378,41 @@ def _unexpected_mint(*args, **kwargs): assert payload["credential_pool"]["nous"][0]["agent_key"] == token +def test_legacy_auth_mode_bypasses_usable_invoke_jwt(tmp_path, monkeypatch): + import hermes_cli.auth as auth_mod + + hermes_home = tmp_path / "hermes" + token = _invoke_jwt(seconds=3600) + _setup_nous_auth( + hermes_home, + access_token=token, + scope=auth_mod.DEFAULT_NOUS_SCOPE, + expires_at=_future_iso(3600), + expires_in=3600, + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + mint_calls = [] + + def _fake_mint_agent_key(*, client, portal_base_url, access_token, min_ttl_seconds): + del client, portal_base_url, min_ttl_seconds + mint_calls.append(access_token) + return _mint_payload(api_key="legacy-after-jwt-401") + + monkeypatch.setattr(auth_mod, "_mint_agent_key", _fake_mint_agent_key) + + creds = auth_mod.resolve_nous_runtime_credentials( + min_key_ttl_seconds=300, + auth_mode=auth_mod.NOUS_INFERENCE_AUTH_LEGACY, + ) + + assert mint_calls == [token] + assert creds["api_key"] == "legacy-after-jwt-401" + assert creds["auth_path"] == "legacy_session_key_mint" + payload = json.loads((hermes_home / "auth.json").read_text()) + assert payload["providers"]["nous"]["agent_key"] == "legacy-after-jwt-401" + + def test_resolve_nous_runtime_credentials_falls_back_when_invoke_scope_missing( tmp_path, monkeypatch, @@ -735,6 +847,9 @@ def test_terminal_refresh_failure_quarantines_tokens( hermes_home = tmp_path / "hermes" _setup_nous_auth(hermes_home, refresh_token="refresh-old") monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + from agent.credential_pool import load_pool + + assert load_pool("nous").select() is not None shared_state = _full_state_fixture() shared_state["access_token"] = "access-old" @@ -765,6 +880,8 @@ def _terminal_refresh_failure(*, client, portal_base_url, client_id, refresh_tok assert not state_after_failure.get("agent_key") assert state_after_failure["last_auth_error"]["code"] == "invalid_grant" assert auth_mod._read_shared_nous_state() is None + payload = json.loads((hermes_home / "auth.json").read_text()) + assert payload.get("credential_pool", {}).get("nous") == [] with pytest.raises(AuthError, match="No access token found"): auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=300) @@ -780,6 +897,9 @@ def test_managed_access_token_refresh_failure_quarantines_tokens( hermes_home = tmp_path / "hermes" _setup_nous_auth(hermes_home, refresh_token="refresh-old") monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + from agent.credential_pool import load_pool + + assert load_pool("nous").select() is not None refresh_calls: list[str] = [] @@ -802,6 +922,8 @@ def _terminal_refresh_failure(*, client, portal_base_url, client_id, refresh_tok assert not state_after_failure.get("refresh_token") assert not state_after_failure.get("access_token") assert state_after_failure["last_auth_error"]["message"] == "Invalid refresh token" + payload = json.loads((hermes_home / "auth.json").read_text()) + assert payload.get("credential_pool", {}).get("nous") == [] with pytest.raises(AuthError, match="No access token found"): auth_mod.resolve_nous_access_token() @@ -1076,7 +1198,11 @@ def test_persist_nous_credentials_allows_recovery_from_401(tmp_path, monkeypatch calls after a Nous 401 — before the fix it would raise AuthError because providers.nous was empty. """ - from hermes_cli.auth import persist_nous_credentials, resolve_nous_runtime_credentials + from hermes_cli.auth import ( + NOUS_INFERENCE_AUTH_FRESH, + persist_nous_credentials, + resolve_nous_runtime_credentials, + ) hermes_home = tmp_path / "hermes" hermes_home.mkdir(parents=True, exist_ok=True) @@ -1104,7 +1230,10 @@ def _fake_mint_agent_key(*, client, portal_base_url, access_token, min_ttl_secon monkeypatch.setattr("hermes_cli.auth._refresh_access_token", _fake_refresh_access_token) monkeypatch.setattr("hermes_cli.auth._mint_agent_key", _fake_mint_agent_key) - creds = resolve_nous_runtime_credentials(min_key_ttl_seconds=300, force_mint=True) + creds = resolve_nous_runtime_credentials( + min_key_ttl_seconds=300, + auth_mode=NOUS_INFERENCE_AUTH_FRESH, + ) assert creds["api_key"] == "new-agent-key" @@ -1569,7 +1698,7 @@ def test_try_import_shared_rehydrates_on_success(shared_store_env, monkeypatch): def _fake_refresh(state, **kwargs): # Simulate portal returning fresh tokens + a new agent_key assert kwargs.get("force_refresh") is True - assert kwargs.get("force_mint") is True + assert kwargs.get("auth_mode") == auth_mod.NOUS_INFERENCE_AUTH_FRESH return { **state, "access_token": "fresh-access-tok", @@ -1697,7 +1826,7 @@ def _fake_mint_agent_key(*, client, portal_base_url, access_token, min_ttl_secon creds = auth_mod.resolve_nous_runtime_credentials( min_key_ttl_seconds=300, - force_mint=True, + auth_mode=auth_mod.NOUS_INFERENCE_AUTH_FRESH, ) assert creds["api_key"] == "agent-key-from-shared-token" diff --git a/tests/hermes_cli/test_proxy.py b/tests/hermes_cli/test_proxy.py index 3ab06eeb92f2..9303fb1c702c 100644 --- a/tests/hermes_cli/test_proxy.py +++ b/tests/hermes_cli/test_proxy.py @@ -141,6 +141,45 @@ def test_nous_adapter_get_credential_refreshes_and_persists(tmp_path, monkeypatc assert stored["providers"]["nous"]["agent_key"] == "minted-bearer" +def test_nous_adapter_retry_credential_forces_legacy_mint(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _write_auth_store(tmp_path, { + "access_token": "jwt-access", + "refresh_token": "refresh-tok", + "client_id": "hermes-cli", + "portal_base_url": "https://portal.nousresearch.com", + "inference_base_url": "https://inference-api.nousresearch.com/v1", + "agent_key": "jwt-access", + }) + + refreshed_state = { + "access_token": "jwt-access", + "refresh_token": "refresh-tok", + "client_id": "hermes-cli", + "portal_base_url": "https://portal.nousresearch.com", + "inference_base_url": "https://inference-api.nousresearch.com/v1", + "agent_key": "legacy-bearer", + "agent_key_expires_at": "2099-01-01T00:00:00Z", + } + + with patch( + "hermes_cli.proxy.adapters.nous_portal.refresh_nous_oauth_from_state", + return_value=refreshed_state, + ) as mock_refresh: + adapter = NousPortalAdapter() + cred = adapter.get_retry_credential( + failed_credential=UpstreamCredential( + bearer="jwt-access", + base_url="https://inference-api.nousresearch.com/v1", + ), + status_code=401, + ) + + assert cred is not None + assert cred.bearer == "legacy-bearer" + assert mock_refresh.call_args.kwargs["auth_mode"] == "legacy" + + def test_nous_adapter_get_credential_raises_when_not_logged_in(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path)) adapter = NousPortalAdapter() @@ -166,6 +205,7 @@ def test_nous_adapter_get_credential_raises_on_refresh_failure(tmp_path, monkeyp def test_nous_adapter_quarantines_terminal_refresh_failure(tmp_path, monkeypatch): from hermes_cli.auth import AuthError + from agent.credential_pool import load_pool monkeypatch.setenv("HERMES_HOME", str(tmp_path)) _write_auth_store(tmp_path, { @@ -173,6 +213,7 @@ def test_nous_adapter_quarantines_terminal_refresh_failure(tmp_path, monkeypatch "refresh_token": "refresh-tok", "agent_key": "stale-agent-key", }) + assert load_pool("nous").select() is not None with patch( "hermes_cli.proxy.adapters.nous_portal.refresh_nous_oauth_from_state", @@ -193,6 +234,7 @@ def test_nous_adapter_quarantines_terminal_refresh_failure(tmp_path, monkeypatch assert not nous_state.get("access_token") assert not nous_state.get("agent_key") assert nous_state["last_auth_error"]["code"] == "invalid_grant" + assert stored.get("credential_pool", {}).get("nous") == [] def test_nous_adapter_get_credential_raises_when_no_agent_key_returned(tmp_path, monkeypatch): @@ -291,12 +333,15 @@ class FakeAdapter(UpstreamAdapter): """A test adapter that returns a fixed credential without touching disk.""" def __init__(self, base_url: str, bearer: str = "test-bearer", - allowed=None, raise_on_credential=False): + allowed=None, raise_on_credential=False, + retry_bearer: str | None = None): self._base_url = base_url self._bearer = bearer self._allowed = frozenset(allowed or ["/chat/completions"]) self._raise = raise_on_credential + self._retry_bearer = retry_bearer self.calls = 0 + self.retry_calls = 0 @property def name(self): return "fake" @@ -318,6 +363,17 @@ def get_credential(self): expires_at="2099-01-01T00:00:00Z", ) + def get_retry_credential(self, *, failed_credential, status_code): + del failed_credential + self.retry_calls += 1 + if status_code != 401 or not self._retry_bearer: + return None + return UpstreamCredential( + bearer=self._retry_bearer, + base_url=self._base_url, + expires_at="2099-01-01T00:00:00Z", + ) + async def _start_runner(app: "web.Application"): """Spin up an aiohttp app on an ephemeral localhost port. Returns (runner, base_url).""" @@ -358,6 +414,25 @@ async def sse(request): return app +def _build_retrying_fake_upstream(captured: Dict[str, Any]) -> "web.Application": + async def maybe_unauthorized(request): + body = await request.read() + auth = request.headers.get("Authorization") + captured["requests"].append({ + "method": request.method, + "path": request.path, + "auth": auth, + "body": body.decode("utf-8") if body else "", + }) + if auth == "Bearer jwt-bearer": + return web.json_response({"error": "bad token"}, status=401) + return web.json_response({"ok": True}) + + app = web.Application() + app.router.add_route("*", "/v1/chat/completions", maybe_unauthorized) + return app + + def test_server_forwards_chat_completions(): async def run(): captured: Dict[str, Any] = {"requests": []} @@ -388,6 +463,41 @@ async def run(): asyncio.run(run()) +def test_server_retries_once_with_adapter_retry_credential_on_401(): + async def run(): + captured: Dict[str, Any] = {"requests": []} + upstream_runner, upstream_base = await _start_runner( + _build_retrying_fake_upstream(captured) + ) + adapter = FakeAdapter( + f"{upstream_base}/v1", + bearer="jwt-bearer", + retry_bearer="legacy-bearer", + ) + proxy_runner, proxy_base = await _start_runner(create_app(adapter)) + + try: + async with aiohttp.ClientSession() as session: + async with session.post( + f"{proxy_base}/v1/chat/completions", + json={"model": "Hermes-4-70B"}, + ) as resp: + assert resp.status == 200 + data = await resp.json() + assert data["ok"] is True + + assert adapter.retry_calls == 1 + assert [req["auth"] for req in captured["requests"]] == [ + "Bearer jwt-bearer", + "Bearer legacy-bearer", + ] + finally: + await proxy_runner.cleanup() + await upstream_runner.cleanup() + + asyncio.run(run()) + + def test_server_rejects_disallowed_path(): async def run(): adapter = FakeAdapter("http://unused.example/v1", allowed=["/chat/completions"]) diff --git a/tests/hermes_cli/test_web_oauth_dispatch.py b/tests/hermes_cli/test_web_oauth_dispatch.py index 23b72a303cf7..b9ee20ccae84 100644 --- a/tests/hermes_cli/test_web_oauth_dispatch.py +++ b/tests/hermes_cli/test_web_oauth_dispatch.py @@ -19,11 +19,12 @@ These tests pin the corrected behavior. """ +import asyncio import time from datetime import datetime, timezone from unittest.mock import patch -import pytest +import httpx from fastapi.testclient import TestClient from hermes_cli.web_server import _SESSION_TOKEN, app @@ -32,6 +33,32 @@ HEADERS = {"X-Hermes-Session-Token": _SESSION_TOKEN} +def _fake_nous_device_data(): + return { + "device_code": "device-code", + "user_code": "NOUS-1234", + "verification_uri": "https://portal.nousresearch.com/device", + "verification_uri_complete": ( + "https://portal.nousresearch.com/device?user_code=NOUS-1234" + ), + "expires_in": 600, + "interval": 5, + } + + +def _invoke_scope_refusal(): + request = httpx.Request("POST", "https://portal.nousresearch.com/oauth/device/code") + response = httpx.Response( + 400, + json={ + "error": "invalid_scope", + "error_description": "unsupported scope inference:invoke", + }, + request=request, + ) + return httpx.HTTPStatusError("invalid scope", request=request, response=response) + + def test_minimax_login_does_not_launch_anthropic_flow(): """Click 'Login' on MiniMax → MUST NOT return claude.ai auth_url.""" fake_user_code_resp = { @@ -48,6 +75,9 @@ def test_minimax_login_does_not_launch_anthropic_flow(): ), patch( "hermes_cli.auth._minimax_pkce_pair", return_value=("verifier-stub", "challenge-stub", "stub-state"), + ), patch( + "hermes_cli.web_server._minimax_poller", + return_value=None, ): resp = client.post( "/api/providers/oauth/minimax-oauth/start", @@ -69,6 +99,113 @@ def test_minimax_login_does_not_launch_anthropic_flow(): assert body["expires_in"] == 600 +def test_nous_dashboard_device_flow_honors_legacy_scope_override(monkeypatch): + from hermes_cli import auth as auth_mod + from hermes_cli import web_server as ws + + requested_scopes = [] + + def fake_request_device_code(**kwargs): + requested_scopes.append(kwargs["scope"]) + return _fake_nous_device_data() + + monkeypatch.setenv(auth_mod.NOUS_LEGACY_SESSION_KEYS_ENV, "true") + monkeypatch.setattr(auth_mod, "_request_device_code", fake_request_device_code) + monkeypatch.setattr(ws, "_nous_poller", lambda sid: None) + + result = asyncio.run(ws._start_device_code_flow("nous")) + try: + assert requested_scopes == [auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE] + assert result["flow"] == "device_code" + assert result["user_code"] == "NOUS-1234" + assert ( + ws._oauth_sessions[result["session_id"]]["scope"] + == auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE + ) + finally: + ws._oauth_sessions.pop(result["session_id"], None) + + +def test_nous_dashboard_device_flow_retries_legacy_scope_on_invoke_refusal(monkeypatch): + from hermes_cli import auth as auth_mod + from hermes_cli import web_server as ws + + requested_scopes = [] + + def fake_request_device_code(**kwargs): + requested_scopes.append(kwargs["scope"]) + if len(requested_scopes) == 1: + raise _invoke_scope_refusal() + return _fake_nous_device_data() + + monkeypatch.delenv(auth_mod.NOUS_LEGACY_SESSION_KEYS_ENV, raising=False) + monkeypatch.setattr(auth_mod, "_request_device_code", fake_request_device_code) + monkeypatch.setattr(ws, "_nous_poller", lambda sid: None) + + result = asyncio.run(ws._start_device_code_flow("nous")) + try: + assert requested_scopes == [ + auth_mod.DEFAULT_NOUS_SCOPE, + auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE, + ] + assert ( + ws._oauth_sessions[result["session_id"]]["scope"] + == auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE + ) + finally: + ws._oauth_sessions.pop(result["session_id"], None) + + +def test_nous_dashboard_poller_preserves_effective_scope_when_token_omits_scope(monkeypatch): + from hermes_cli import auth as auth_mod + from hermes_cli import web_server as ws + + session_id = "nous-effective-scope-test" + ws._oauth_sessions[session_id] = { + "session_id": session_id, + "provider": "nous", + "flow": "device_code", + "created_at": time.time(), + "status": "pending", + "error_message": None, + "portal_base_url": "https://portal.nousresearch.com", + "client_id": "hermes-cli", + "device_code": "device-code", + "interval": 5, + "expires_at": time.time() + 600, + "scope": auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE, + } + captured_state = {} + + def fake_refresh_nous_oauth_from_state(state, **kwargs): + captured_state.update(state) + return {**state, "agent_key": "legacy-agent-key"} + + monkeypatch.setattr( + auth_mod, + "_poll_for_token", + lambda **kwargs: { + "access_token": "access-token", + "refresh_token": "refresh-token", + "expires_in": 3600, + "token_type": "Bearer", + }, + ) + monkeypatch.setattr( + auth_mod, + "refresh_nous_oauth_from_state", + fake_refresh_nous_oauth_from_state, + ) + monkeypatch.setattr(auth_mod, "persist_nous_credentials", lambda state: None) + + try: + ws._nous_poller(session_id) + assert captured_state["scope"] == auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE + assert ws._oauth_sessions[session_id]["status"] == "approved" + finally: + ws._oauth_sessions.pop(session_id, None) + + def test_minimax_dashboard_poller_accepts_absolute_ms_expired_in(): """Dashboard MiniMax completion must accept unix-ms token expiry values.""" from hermes_cli import web_server as ws diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index a72359227a63..e569da316662 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -3667,7 +3667,7 @@ def _fake_openai(**kwargs): assert ok is True assert closed["value"] is True - assert captured["force_mint"] is True + assert captured["auth_mode"] == "legacy" assert rebuilt["kwargs"]["api_key"] == "new-nous-key" assert ( rebuilt["kwargs"]["base_url"] == "https://inference-api.nousresearch.com/v1" From 20bffa5b37ce121f6adc1c68b4759440a79473ec Mon Sep 17 00:00:00 2001 From: Robin Fernandes Date: Sun, 17 May 2026 21:18:53 +1000 Subject: [PATCH 074/418] refactor(auth): mostly cleanups and style changes --- agent/auxiliary_client.py | 17 ++- agent/credential_pool.py | 6 +- hermes_cli/auth.py | 146 +++++++++----------- hermes_cli/proxy/adapters/base.py | 2 +- hermes_cli/proxy/adapters/nous_portal.py | 21 +-- hermes_cli/web_server.py | 11 +- run_agent.py | 10 +- tests/hermes_cli/test_auth_nous_provider.py | 25 ++-- tests/hermes_cli/test_proxy.py | 30 +++- tests/run_agent/test_run_agent.py | 2 +- 10 files changed, 145 insertions(+), 125 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index e67b37b00dad..4d11804f4cb8 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1253,18 +1253,18 @@ def _resolve_nous_runtime_api(*, force_refresh: bool = False) -> Optional[tuple[ """ try: from hermes_cli.auth import ( - NOUS_INFERENCE_AUTH_AUTO, - NOUS_INFERENCE_AUTH_LEGACY, + NOUS_INFERENCE_AUTH_MODE_AUTO, + NOUS_INFERENCE_AUTH_MODE_LEGACY, resolve_nous_runtime_credentials, ) creds = resolve_nous_runtime_credentials( min_key_ttl_seconds=max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800"))), timeout_seconds=float(os.getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")), - auth_mode=( - NOUS_INFERENCE_AUTH_LEGACY + inference_auth_mode=( + NOUS_INFERENCE_AUTH_MODE_LEGACY if force_refresh - else NOUS_INFERENCE_AUTH_AUTO + else NOUS_INFERENCE_AUTH_MODE_AUTO ), ) except Exception as exc: @@ -2509,12 +2509,15 @@ def _refresh_provider_credentials(provider: str) -> bool: _evict_cached_clients(normalized) return True if normalized == "nous": - from hermes_cli.auth import NOUS_INFERENCE_AUTH_LEGACY, resolve_nous_runtime_credentials + from hermes_cli.auth import ( + NOUS_INFERENCE_AUTH_MODE_LEGACY, + resolve_nous_runtime_credentials, + ) creds = resolve_nous_runtime_credentials( min_key_ttl_seconds=max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800"))), timeout_seconds=float(os.getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")), - auth_mode=NOUS_INFERENCE_AUTH_LEGACY, + inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_LEGACY, ) if not str(creds.get("api_key", "") or "").strip(): return False diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 7c91a08d2aa9..7bdfe1c29739 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -831,10 +831,10 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po nous_state, min_key_ttl_seconds=DEFAULT_AGENT_KEY_MIN_TTL_SECONDS, force_refresh=force, - auth_mode=( - auth_mod.NOUS_INFERENCE_AUTH_LEGACY + inference_auth_mode=( + auth_mod.NOUS_INFERENCE_AUTH_MODE_LEGACY if force - else auth_mod.NOUS_INFERENCE_AUTH_AUTO + else auth_mod.NOUS_INFERENCE_AUTH_MODE_AUTO ), ) # Apply returned fields: dataclass fields via replace, extras via dict update diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 783f2c0c6554..e65d9da20c8f 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -78,13 +78,21 @@ DEFAULT_NOUS_SCOPE = f"{NOUS_INFERENCE_INVOKE_SCOPE} {NOUS_LEGACY_AGENT_KEY_SCOPE}" NOUS_LEGACY_SESSION_KEYS_ENV = "HERMES_AGENT_USE_LEGACY_SESSION_KEYS" NOUS_DEVICE_CODE_SOURCE = "device_code" -NOUS_INFERENCE_AUTH_AUTO = "auto" -NOUS_INFERENCE_AUTH_FRESH = "fresh" -NOUS_INFERENCE_AUTH_LEGACY = "legacy" +NOUS_INFERENCE_AUTH_MODE_AUTO = "auto" +NOUS_INFERENCE_AUTH_MODE_FRESH = "fresh" +NOUS_INFERENCE_AUTH_MODE_LEGACY = "legacy" NOUS_INFERENCE_AUTH_MODES = frozenset({ - NOUS_INFERENCE_AUTH_AUTO, - NOUS_INFERENCE_AUTH_FRESH, - NOUS_INFERENCE_AUTH_LEGACY, + NOUS_INFERENCE_AUTH_MODE_AUTO, + NOUS_INFERENCE_AUTH_MODE_FRESH, + NOUS_INFERENCE_AUTH_MODE_LEGACY, +}) +NOUS_AUTH_PATH_INVOKE_JWT = "invoke_jwt" +NOUS_AUTH_PATH_LEGACY_SESSION_KEY_CACHE = "legacy_session_key_cache" +NOUS_AUTH_PATH_LEGACY_SESSION_KEY_MINT = "legacy_session_key_mint" +NOUS_AUTH_PATHS = frozenset({ + NOUS_AUTH_PATH_INVOKE_JWT, + NOUS_AUTH_PATH_LEGACY_SESSION_KEY_CACHE, + NOUS_AUTH_PATH_LEGACY_SESSION_KEY_MINT, }) DEFAULT_AGENT_KEY_MIN_TTL_SECONDS = 30 * 60 # 30 minutes ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 # refresh 2 min before expiry @@ -1592,12 +1600,13 @@ def _nous_scope_has_invoke(raw_scope: Any) -> bool: return NOUS_INFERENCE_INVOKE_SCOPE in _scope_values(raw_scope) -def _normalize_nous_auth_mode(auth_mode: Optional[str]) -> str: - mode = str(auth_mode or NOUS_INFERENCE_AUTH_AUTO).strip().lower() +def _normalize_nous_inference_auth_mode(inference_auth_mode: Optional[str]) -> str: + mode = str(inference_auth_mode or NOUS_INFERENCE_AUTH_MODE_AUTO).strip().lower() if mode not in NOUS_INFERENCE_AUTH_MODES: allowed = ", ".join(sorted(NOUS_INFERENCE_AUTH_MODES)) raise ValueError( - f"Invalid Nous inference auth mode {auth_mode!r}; expected one of: {allowed}" + "Invalid Nous inference auth mode " + f"{inference_auth_mode!r}; expected one of: {allowed}" ) return mode @@ -1649,89 +1658,57 @@ def _nous_invoke_jwt_is_usable( ) -def _nous_invoke_jwt_unavailable_reason( - token: Any, - *, - scope: Any = None, - expires_at: Any = None, - min_ttl_seconds: int = NOUS_INVOKE_JWT_MIN_TTL_SECONDS, -) -> str: - return ( - _nous_invoke_jwt_status( - token, - scope=scope, - expires_at=expires_at, - min_ttl_seconds=min_ttl_seconds, - ) - or "invoke_jwt_unavailable" - ) - - -def _nous_can_select_invoke_jwt(auth_mode: str = NOUS_INFERENCE_AUTH_AUTO) -> bool: - return ( - not _nous_legacy_session_keys_forced() - and _normalize_nous_auth_mode(auth_mode) != NOUS_INFERENCE_AUTH_LEGACY - ) - - def _nous_legacy_session_key_reason( token: Any, *, scope: Any = None, expires_at: Any = None, - auth_mode: str = NOUS_INFERENCE_AUTH_AUTO, + inference_auth_mode: str = NOUS_INFERENCE_AUTH_MODE_AUTO, ) -> str: - if _normalize_nous_auth_mode(auth_mode) == NOUS_INFERENCE_AUTH_LEGACY: + if inference_auth_mode == NOUS_INFERENCE_AUTH_MODE_LEGACY: return "forced_legacy_session_key" if _nous_legacy_session_keys_forced(): return "forced_legacy_session_keys" - return _nous_invoke_jwt_unavailable_reason( - token, - scope=scope, - expires_at=expires_at, + return ( + _nous_invoke_jwt_status(token, scope=scope, expires_at=expires_at) + or "invoke_jwt_unavailable" ) -def _nous_cached_agent_key_is_usable( - state: Dict[str, Any], - min_ttl_seconds: int, -) -> bool: - return _agent_key_is_usable(state, min_ttl_seconds) - - def _choose_nous_inference_auth_path( state: Dict[str, Any], *, access_token: Any = None, min_key_ttl_seconds: int = DEFAULT_AGENT_KEY_MIN_TTL_SECONDS, - auth_mode: str = NOUS_INFERENCE_AUTH_AUTO, + inference_auth_mode: str = NOUS_INFERENCE_AUTH_MODE_AUTO, ) -> Tuple[str, Optional[str]]: - auth_mode = _normalize_nous_auth_mode(auth_mode) + inference_auth_mode = _normalize_nous_inference_auth_mode(inference_auth_mode) token = state.get("access_token") if access_token is None else access_token if ( - _nous_can_select_invoke_jwt(auth_mode) + not _nous_legacy_session_keys_forced() + and inference_auth_mode != NOUS_INFERENCE_AUTH_MODE_LEGACY and _nous_invoke_jwt_is_usable( token, scope=state.get("scope"), expires_at=state.get("expires_at"), ) ): - return "invoke_jwt", None + return NOUS_AUTH_PATH_INVOKE_JWT, None if ( - auth_mode == NOUS_INFERENCE_AUTH_AUTO - and _nous_cached_agent_key_is_usable( + inference_auth_mode == NOUS_INFERENCE_AUTH_MODE_AUTO + and _agent_key_is_usable( state, max(60, int(min_key_ttl_seconds)), ) ): - return "legacy_session_key_cache", None + return NOUS_AUTH_PATH_LEGACY_SESSION_KEY_CACHE, None return ( - "legacy_session_key_mint", + NOUS_AUTH_PATH_LEGACY_SESSION_KEY_MINT, _nous_legacy_session_key_reason( token, scope=state.get("scope"), expires_at=state.get("expires_at"), - auth_mode=auth_mode, + inference_auth_mode=inference_auth_mode, ), ) @@ -3660,7 +3637,7 @@ def _is_nous_invoke_scope_refusal(exc: Exception) -> bool: ) -def _nous_device_scope( +def _nous_device_scope_with_env_override( requested_scope: Optional[str], *, default_scope: str = DEFAULT_NOUS_SCOPE, @@ -4131,7 +4108,7 @@ def _try_import_shared_nous_state( min_key_ttl_seconds=min_key_ttl_seconds, timeout_seconds=timeout_seconds, force_refresh=True, - auth_mode=NOUS_INFERENCE_AUTH_FRESH, + inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_FRESH, ) _write_shared_nous_state(refreshed) except AuthError as exc: @@ -4440,10 +4417,10 @@ def refresh_nous_oauth_pure( insecure: Optional[bool] = None, ca_bundle: Optional[str] = None, force_refresh: bool = False, - auth_mode: str = NOUS_INFERENCE_AUTH_AUTO, + inference_auth_mode: str = NOUS_INFERENCE_AUTH_MODE_AUTO, ) -> Dict[str, Any]: """Refresh Nous OAuth state without mutating auth.json.""" - auth_mode = _normalize_nous_auth_mode(auth_mode) + inference_auth_mode = _normalize_nous_inference_auth_mode(inference_auth_mode) state: Dict[str, Any] = { "access_token": access_token, "refresh_token": refresh_token, @@ -4506,11 +4483,11 @@ def refresh_nous_oauth_pure( selected_auth_path, fallback_reason = _choose_nous_inference_auth_path( state, min_key_ttl_seconds=min_agent_key_ttl, - auth_mode=auth_mode, + inference_auth_mode=inference_auth_mode, ) - if selected_auth_path == "invoke_jwt": + if selected_auth_path == NOUS_AUTH_PATH_INVOKE_JWT: _select_nous_invoke_jwt(state) - elif selected_auth_path == "legacy_session_key_mint": + elif selected_auth_path == NOUS_AUTH_PATH_LEGACY_SESSION_KEY_MINT: _log_nous_legacy_session_key_selected( fallback_reason or "legacy_session_key_required", access_token=state.get("access_token"), @@ -4541,7 +4518,7 @@ def refresh_nous_oauth_from_state( min_key_ttl_seconds: int = DEFAULT_AGENT_KEY_MIN_TTL_SECONDS, timeout_seconds: float = 15.0, force_refresh: bool = False, - auth_mode: str = NOUS_INFERENCE_AUTH_AUTO, + inference_auth_mode: str = NOUS_INFERENCE_AUTH_MODE_AUTO, ) -> Dict[str, Any]: """Refresh Nous OAuth from a state dict. Thin wrapper around refresh_nous_oauth_pure.""" tls = state.get("tls") or {} @@ -4562,7 +4539,7 @@ def refresh_nous_oauth_from_state( insecure=tls.get("insecure"), ca_bundle=tls.get("ca_bundle"), force_refresh=force_refresh, - auth_mode=auth_mode, + inference_auth_mode=inference_auth_mode, ) @@ -4640,7 +4617,7 @@ def resolve_nous_runtime_credentials( timeout_seconds: float = 15.0, insecure: Optional[bool] = None, ca_bundle: Optional[str] = None, - auth_mode: str = NOUS_INFERENCE_AUTH_AUTO, + inference_auth_mode: str = NOUS_INFERENCE_AUTH_MODE_AUTO, ) -> Dict[str, Any]: """ Resolve Nous inference credentials for runtime use. @@ -4652,7 +4629,7 @@ def resolve_nous_runtime_credentials( Returns dict with: provider, base_url, api_key, key_id, expires_at, expires_in, source ("invoke_jwt", "cache", or "portal"), and auth_path. """ - auth_mode = _normalize_nous_auth_mode(auth_mode) + inference_auth_mode = _normalize_nous_inference_auth_mode(inference_auth_mode) min_key_ttl_seconds = max(60, int(min_key_ttl_seconds)) sequence_id = uuid.uuid4().hex[:12] @@ -4682,6 +4659,8 @@ def resolve_nous_runtime_credentials( def _persist_state(reason: str) -> None: nonlocal persisted_state, state_persisted + # Skip writes where only derived TTL countdowns changed; this keeps + # the mtime-keyed Nous auth-status cache warm during read paths. if ( _nous_effective_provider_state(state) == _nous_effective_provider_state(persisted_state) @@ -4723,7 +4702,7 @@ def _persist_state(reason: str) -> None: _oauth_trace( "nous_runtime_credentials_start", sequence_id=sequence_id, - auth_mode=auth_mode, + inference_auth_mode=inference_auth_mode, min_key_ttl_seconds=min_key_ttl_seconds, refresh_token_fp=_token_fingerprint(state.get("refresh_token")), ) @@ -4830,16 +4809,16 @@ def _persist_state(reason: str) -> None: state, access_token=access_token, min_key_ttl_seconds=min_key_ttl_seconds, - auth_mode=auth_mode, + inference_auth_mode=inference_auth_mode, ) - if selected_auth_path == "invoke_jwt": + if selected_auth_path == NOUS_AUTH_PATH_INVOKE_JWT: _select_nous_invoke_jwt( state, access_token=access_token, sequence_id=sequence_id, ) - elif selected_auth_path == "legacy_session_key_cache": + elif selected_auth_path == NOUS_AUTH_PATH_LEGACY_SESSION_KEY_CACHE: used_cached_key = True logger.info("Nous inference auth: using cached agent_key") _oauth_trace("agent_key_reuse", sequence_id=sequence_id) @@ -4929,20 +4908,20 @@ def _persist_state(reason: str) -> None: # Persist retry refresh immediately for crash safety and cross-process visibility. _persist_state("post_refresh_mint_retry") - retry_auth_mode = ( - NOUS_INFERENCE_AUTH_LEGACY - if auth_mode == NOUS_INFERENCE_AUTH_LEGACY - else NOUS_INFERENCE_AUTH_FRESH + retry_inference_auth_mode = ( + NOUS_INFERENCE_AUTH_MODE_LEGACY + if inference_auth_mode == NOUS_INFERENCE_AUTH_MODE_LEGACY + else NOUS_INFERENCE_AUTH_MODE_FRESH ) retry_auth_path, _ = _choose_nous_inference_auth_path( state, access_token=access_token, min_key_ttl_seconds=min_key_ttl_seconds, - auth_mode=retry_auth_mode, + inference_auth_mode=retry_inference_auth_mode, ) - if retry_auth_path == "invoke_jwt": + if retry_auth_path == NOUS_AUTH_PATH_INVOKE_JWT: mint_payload = None - selected_auth_path = "invoke_jwt" + selected_auth_path = NOUS_AUTH_PATH_INVOKE_JWT _select_nous_invoke_jwt( state, access_token=access_token, @@ -5008,8 +4987,8 @@ def _persist_state(reason: str) -> None: "expires_at": expires_at, "expires_in": expires_in, "source": ( - "invoke_jwt" - if selected_auth_path == "invoke_jwt" + NOUS_AUTH_PATH_INVOKE_JWT + if selected_auth_path == NOUS_AUTH_PATH_INVOKE_JWT else ("cache" if used_cached_key else "portal") ), "auth_path": selected_auth_path, @@ -6691,7 +6670,10 @@ def _nous_device_code_login( or pconfig.inference_base_url ).rstrip("/") client_id = client_id or pconfig.client_id - scope, explicit_scope = _nous_device_scope(scope, default_scope=pconfig.scope) + scope, explicit_scope = _nous_device_scope_with_env_override( + scope, + default_scope=pconfig.scope, + ) timeout = httpx.Timeout(timeout_seconds) verify: bool | str = False if insecure else (ca_bundle if ca_bundle else True) @@ -6781,7 +6763,7 @@ def _nous_device_code_login( min_key_ttl_seconds=min_key_ttl_seconds, timeout_seconds=timeout_seconds, force_refresh=False, - auth_mode=NOUS_INFERENCE_AUTH_FRESH, + inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_FRESH, ) except AuthError as exc: if exc.code == "subscription_required": diff --git a/hermes_cli/proxy/adapters/base.py b/hermes_cli/proxy/adapters/base.py index c7f36e25a2b4..db778e18fa9c 100644 --- a/hermes_cli/proxy/adapters/base.py +++ b/hermes_cli/proxy/adapters/base.py @@ -93,7 +93,7 @@ def get_retry_credential( fallback paths, such as switching from a preferred token type to a legacy bearer after the upstream rejects the first request. """ - del failed_credential, status_code + _ = failed_credential, status_code return None def describe(self) -> str: diff --git a/hermes_cli/proxy/adapters/nous_portal.py b/hermes_cli/proxy/adapters/nous_portal.py index a8cfd4cbada0..eda8f831773f 100644 --- a/hermes_cli/proxy/adapters/nous_portal.py +++ b/hermes_cli/proxy/adapters/nous_portal.py @@ -19,8 +19,8 @@ from hermes_cli.auth import ( AuthError, DEFAULT_NOUS_INFERENCE_URL, - NOUS_INFERENCE_AUTH_AUTO, - NOUS_INFERENCE_AUTH_LEGACY, + NOUS_INFERENCE_AUTH_MODE_AUTO, + NOUS_INFERENCE_AUTH_MODE_LEGACY, _load_auth_store, _is_terminal_nous_refresh_error, _quarantine_nous_oauth_state, @@ -28,7 +28,7 @@ _save_auth_store, _write_shared_nous_state, refresh_nous_oauth_from_state, - ) +) from hermes_cli.proxy.adapters.base import UpstreamAdapter, UpstreamCredential logger = logging.getLogger(__name__) @@ -79,7 +79,9 @@ def is_authenticated(self) -> bool: ) def get_credential(self) -> UpstreamCredential: - return self._get_credential(auth_mode=NOUS_INFERENCE_AUTH_AUTO) + return self._get_credential( + inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_AUTO, + ) def get_retry_credential( self, @@ -87,13 +89,16 @@ def get_retry_credential( failed_credential: UpstreamCredential, status_code: int, ) -> Optional[UpstreamCredential]: - del failed_credential if status_code != 401: return None + if failed_credential.bearer.count(".") != 2: + return None logger.info("proxy: Nous upstream rejected bearer; retrying with legacy session key") - return self._get_credential(auth_mode=NOUS_INFERENCE_AUTH_LEGACY) + return self._get_credential( + inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_LEGACY, + ) - def _get_credential(self, *, auth_mode: str) -> UpstreamCredential: + def _get_credential(self, *, inference_auth_mode: str) -> UpstreamCredential: with self._lock: state = self._read_state() if state is None: @@ -104,7 +109,7 @@ def _get_credential(self, *, auth_mode: str) -> UpstreamCredential: try: refreshed = refresh_nous_oauth_from_state( state, - auth_mode=auth_mode, + inference_auth_mode=inference_auth_mode, ) except AuthError as exc: if _is_terminal_nous_refresh_error(exc): diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index bfd47e9cc248..ebf053a62577 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1816,7 +1816,7 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]: """ if provider_id == "nous": from hermes_cli.auth import ( - _nous_device_scope, + _nous_device_scope_with_env_override, _request_nous_device_code_with_scope_fallback, PROVIDER_REGISTRY, ) @@ -1828,7 +1828,10 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]: or pconfig.portal_base_url ).rstrip("/") client_id = pconfig.client_id - scope, explicit_scope = _nous_device_scope(None, default_scope=pconfig.scope) + scope, explicit_scope = _nous_device_scope_with_env_override( + None, + default_scope=pconfig.scope, + ) def _do_nous_device_request(): with httpx.Client( @@ -1982,7 +1985,7 @@ def _do_minimax_request(): def _nous_poller(session_id: str) -> None: """Background poller that drives a Nous device-code flow to completion.""" from hermes_cli.auth import ( - NOUS_INFERENCE_AUTH_FRESH, + NOUS_INFERENCE_AUTH_MODE_FRESH, _poll_for_token, refresh_nous_oauth_from_state, ) @@ -2031,7 +2034,7 @@ def _nous_poller(session_id: str) -> None: min_key_ttl_seconds=300, timeout_seconds=15.0, force_refresh=False, - auth_mode=NOUS_INFERENCE_AUTH_FRESH, + inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_FRESH, ) from hermes_cli.auth import persist_nous_credentials persist_nous_credentials(full_state) diff --git a/run_agent.py b/run_agent.py index 1244d372fdf8..484f9f84fd95 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2629,18 +2629,18 @@ def _try_refresh_nous_client_credentials(self, *, force: bool = True) -> bool: try: from hermes_cli.auth import ( - NOUS_INFERENCE_AUTH_AUTO, - NOUS_INFERENCE_AUTH_LEGACY, + NOUS_INFERENCE_AUTH_MODE_AUTO, + NOUS_INFERENCE_AUTH_MODE_LEGACY, resolve_nous_runtime_credentials, ) creds = resolve_nous_runtime_credentials( min_key_ttl_seconds=max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800"))), timeout_seconds=float(os.getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")), - auth_mode=( - NOUS_INFERENCE_AUTH_LEGACY + inference_auth_mode=( + NOUS_INFERENCE_AUTH_MODE_LEGACY if force - else NOUS_INFERENCE_AUTH_AUTO + else NOUS_INFERENCE_AUTH_MODE_AUTO ), ) except Exception as exc: diff --git a/tests/hermes_cli/test_auth_nous_provider.py b/tests/hermes_cli/test_auth_nous_provider.py index 0bdb1330a293..93c86ebe8f28 100644 --- a/tests/hermes_cli/test_auth_nous_provider.py +++ b/tests/hermes_cli/test_auth_nous_provider.py @@ -217,8 +217,8 @@ def _unexpected_mint(*args, **kwargs): creds = auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=300) assert creds["api_key"] == token - assert creds["source"] == "invoke_jwt" - assert creds["auth_path"] == "invoke_jwt" + assert creds["source"] == auth_mod.NOUS_AUTH_PATH_INVOKE_JWT + assert creds["auth_path"] == auth_mod.NOUS_AUTH_PATH_INVOKE_JWT payload = json.loads((hermes_home / "auth.json").read_text()) singleton = payload["providers"]["nous"] @@ -297,7 +297,7 @@ def _unexpected_shared_write(*args, **kwargs): creds = auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=300) assert creds["api_key"] == token - assert creds["source"] == "invoke_jwt" + assert creds["source"] == auth_mod.NOUS_AUTH_PATH_INVOKE_JWT assert auth_path.read_text() == before_content assert auth_path.stat().st_mtime_ns == before_mtime assert sync_calls == [] @@ -339,7 +339,7 @@ def _unexpected_mint(*args, **kwargs): creds = auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=300) assert creds["api_key"] == token - assert creds["source"] == "invoke_jwt" + assert creds["source"] == auth_mod.NOUS_AUTH_PATH_INVOKE_JWT payload = json.loads((hermes_home / "auth.json").read_text()) singleton = payload["providers"]["nous"] assert singleton["agent_key"] == token @@ -372,7 +372,7 @@ def _unexpected_mint(*args, **kwargs): creds = auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=1800) assert creds["api_key"] == token - assert creds["source"] == "invoke_jwt" + assert creds["source"] == auth_mod.NOUS_AUTH_PATH_INVOKE_JWT payload = json.loads((hermes_home / "auth.json").read_text()) assert payload["providers"]["nous"]["agent_key"] == token assert payload["credential_pool"]["nous"][0]["agent_key"] == token @@ -403,12 +403,12 @@ def _fake_mint_agent_key(*, client, portal_base_url, access_token, min_ttl_secon creds = auth_mod.resolve_nous_runtime_credentials( min_key_ttl_seconds=300, - auth_mode=auth_mod.NOUS_INFERENCE_AUTH_LEGACY, + inference_auth_mode=auth_mod.NOUS_INFERENCE_AUTH_MODE_LEGACY, ) assert mint_calls == [token] assert creds["api_key"] == "legacy-after-jwt-401" - assert creds["auth_path"] == "legacy_session_key_mint" + assert creds["auth_path"] == auth_mod.NOUS_AUTH_PATH_LEGACY_SESSION_KEY_MINT payload = json.loads((hermes_home / "auth.json").read_text()) assert payload["providers"]["nous"]["agent_key"] == "legacy-after-jwt-401" @@ -1199,7 +1199,7 @@ def test_persist_nous_credentials_allows_recovery_from_401(tmp_path, monkeypatch providers.nous was empty. """ from hermes_cli.auth import ( - NOUS_INFERENCE_AUTH_FRESH, + NOUS_INFERENCE_AUTH_MODE_FRESH, persist_nous_credentials, resolve_nous_runtime_credentials, ) @@ -1232,7 +1232,7 @@ def _fake_mint_agent_key(*, client, portal_base_url, access_token, min_ttl_secon creds = resolve_nous_runtime_credentials( min_key_ttl_seconds=300, - auth_mode=NOUS_INFERENCE_AUTH_FRESH, + inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_FRESH, ) assert creds["api_key"] == "new-agent-key" @@ -1698,7 +1698,10 @@ def test_try_import_shared_rehydrates_on_success(shared_store_env, monkeypatch): def _fake_refresh(state, **kwargs): # Simulate portal returning fresh tokens + a new agent_key assert kwargs.get("force_refresh") is True - assert kwargs.get("auth_mode") == auth_mod.NOUS_INFERENCE_AUTH_FRESH + assert ( + kwargs.get("inference_auth_mode") + == auth_mod.NOUS_INFERENCE_AUTH_MODE_FRESH + ) return { **state, "access_token": "fresh-access-tok", @@ -1826,7 +1829,7 @@ def _fake_mint_agent_key(*, client, portal_base_url, access_token, min_ttl_secon creds = auth_mod.resolve_nous_runtime_credentials( min_key_ttl_seconds=300, - auth_mode=auth_mod.NOUS_INFERENCE_AUTH_FRESH, + inference_auth_mode=auth_mod.NOUS_INFERENCE_AUTH_MODE_FRESH, ) assert creds["api_key"] == "agent-key-from-shared-token" diff --git a/tests/hermes_cli/test_proxy.py b/tests/hermes_cli/test_proxy.py index 9303fb1c702c..45a098443f9c 100644 --- a/tests/hermes_cli/test_proxy.py +++ b/tests/hermes_cli/test_proxy.py @@ -169,7 +169,7 @@ def test_nous_adapter_retry_credential_forces_legacy_mint(tmp_path, monkeypatch) adapter = NousPortalAdapter() cred = adapter.get_retry_credential( failed_credential=UpstreamCredential( - bearer="jwt-access", + bearer="header.jwt.signature", base_url="https://inference-api.nousresearch.com/v1", ), status_code=401, @@ -177,7 +177,31 @@ def test_nous_adapter_retry_credential_forces_legacy_mint(tmp_path, monkeypatch) assert cred is not None assert cred.bearer == "legacy-bearer" - assert mock_refresh.call_args.kwargs["auth_mode"] == "legacy" + assert mock_refresh.call_args.kwargs["inference_auth_mode"] == "legacy" + + +def test_nous_adapter_retry_credential_skips_opaque_bearer(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _write_auth_store(tmp_path, { + "access_token": "jwt-access", + "refresh_token": "refresh-tok", + "agent_key": "opaque-bearer", + }) + + with patch( + "hermes_cli.proxy.adapters.nous_portal.refresh_nous_oauth_from_state", + ) as mock_refresh: + adapter = NousPortalAdapter() + cred = adapter.get_retry_credential( + failed_credential=UpstreamCredential( + bearer="opaque-bearer", + base_url="https://inference-api.nousresearch.com/v1", + ), + status_code=401, + ) + + assert cred is None + mock_refresh.assert_not_called() def test_nous_adapter_get_credential_raises_when_not_logged_in(tmp_path, monkeypatch): @@ -364,7 +388,7 @@ def get_credential(self): ) def get_retry_credential(self, *, failed_credential, status_code): - del failed_credential + _ = failed_credential self.retry_calls += 1 if status_code != 401 or not self._retry_bearer: return None diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index e569da316662..bc8a044e3adb 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -3667,7 +3667,7 @@ def _fake_openai(**kwargs): assert ok is True assert closed["value"] is True - assert captured["auth_mode"] == "legacy" + assert captured["inference_auth_mode"] == "legacy" assert rebuilt["kwargs"]["api_key"] == "new-nous-key" assert ( rebuilt["kwargs"]["base_url"] == "https://inference-api.nousresearch.com/v1" From 569bc94b59b687b5b6efa013f521756156f6e778 Mon Sep 17 00:00:00 2001 From: Robin Fernandes Date: Sun, 17 May 2026 22:29:40 +1000 Subject: [PATCH 075/418] fix(auth) fix a few cases where refresh tokens were not rotated. --- agent/credential_pool.py | 54 +++++++--------- hermes_cli/auth.py | 31 ++++++--- hermes_cli/proxy/adapters/nous_portal.py | 57 ++++++++--------- tests/agent/test_credential_pool.py | 2 +- tests/hermes_cli/test_auth_nous_provider.py | 70 +++++++++++++++++++++ tests/hermes_cli/test_proxy.py | 61 +++++++----------- 6 files changed, 166 insertions(+), 109 deletions(-) diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 7bdfe1c29739..98dbaf308397 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -623,18 +623,35 @@ def _sync_nous_entry_from_auth_store(self, entry: PooledCredential) -> PooledCre return entry store_refresh = state.get("refresh_token", "") store_access = state.get("access_token", "") - if store_refresh and store_refresh != entry.refresh_token: + comparable_updates = { + "access_token": store_access, + "refresh_token": store_refresh, + "expires_at": state.get("expires_at"), + "agent_key": state.get("agent_key"), + "agent_key_expires_at": state.get("agent_key_expires_at"), + "inference_base_url": state.get("inference_base_url"), + } + should_sync = any( + value not in (None, "") and getattr(entry, key, None) != value + for key, value in comparable_updates.items() + ) + if should_sync: logger.debug( - "Pool entry %s: syncing tokens from auth.json (Nous refresh token changed)", + "Pool entry %s: syncing Nous state from auth.json", entry.id, ) field_updates: Dict[str, Any] = { - "access_token": store_access, - "refresh_token": store_refresh, "last_status": None, "last_status_at": None, "last_error_code": None, + "last_error_reason": None, + "last_error_message": None, + "last_error_reset_at": None, } + if store_access: + field_updates["access_token"] = store_access + if store_refresh: + field_updates["refresh_token"] = store_refresh if state.get("expires_at"): field_updates["expires_at"] = state["expires_at"] if state.get("agent_key"): @@ -813,40 +830,15 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po synced = self._sync_nous_entry_from_auth_store(entry) if synced is not entry: entry = synced - nous_state = { - "access_token": entry.access_token, - "refresh_token": entry.refresh_token, - "client_id": entry.client_id, - "portal_base_url": entry.portal_base_url, - "inference_base_url": entry.inference_base_url, - "token_type": entry.token_type, - "scope": entry.scope, - "obtained_at": entry.obtained_at, - "expires_at": entry.expires_at, - "agent_key": entry.agent_key, - "agent_key_expires_at": entry.agent_key_expires_at, - "tls": entry.tls, - } - refreshed = auth_mod.refresh_nous_oauth_from_state( - nous_state, + auth_mod.resolve_nous_runtime_credentials( min_key_ttl_seconds=DEFAULT_AGENT_KEY_MIN_TTL_SECONDS, - force_refresh=force, inference_auth_mode=( auth_mod.NOUS_INFERENCE_AUTH_MODE_LEGACY if force else auth_mod.NOUS_INFERENCE_AUTH_MODE_AUTO ), ) - # Apply returned fields: dataclass fields via replace, extras via dict update - field_updates = {} - extra_updates = dict(entry.extra) - _field_names = {f.name for f in fields(entry)} - for k, v in refreshed.items(): - if k in _field_names: - field_updates[k] = v - elif k in _EXTRA_KEYS: - extra_updates[k] = v - updated = replace(entry, extra=extra_updates, **field_updates) + updated = self._sync_nous_entry_from_auth_store(entry) else: return entry except Exception as exc: diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index e65d9da20c8f..cb97a4c2300f 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -41,7 +41,7 @@ from datetime import datetime, timezone from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Callable, Dict, List, Optional, Tuple from urllib.parse import parse_qs, urlencode, urlparse import httpx @@ -89,11 +89,6 @@ NOUS_AUTH_PATH_INVOKE_JWT = "invoke_jwt" NOUS_AUTH_PATH_LEGACY_SESSION_KEY_CACHE = "legacy_session_key_cache" NOUS_AUTH_PATH_LEGACY_SESSION_KEY_MINT = "legacy_session_key_mint" -NOUS_AUTH_PATHS = frozenset({ - NOUS_AUTH_PATH_INVOKE_JWT, - NOUS_AUTH_PATH_LEGACY_SESSION_KEY_CACHE, - NOUS_AUTH_PATH_LEGACY_SESSION_KEY_MINT, -}) DEFAULT_AGENT_KEY_MIN_TTL_SECONDS = 30 * 60 # 30 minutes ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 # refresh 2 min before expiry NOUS_INVOKE_JWT_MIN_TTL_SECONDS = ACCESS_TOKEN_REFRESH_SKEW_SECONDS @@ -3991,7 +3986,7 @@ def _is_terminal_nous_refresh_error(exc: Exception) -> bool: return ( isinstance(exc, AuthError) and exc.provider == "nous" - and exc.code in {"invalid_grant", "invalid_token"} + and exc.code in {"invalid_grant", "invalid_token", "refresh_token_reused"} and bool(exc.relogin_required) ) @@ -4103,12 +4098,16 @@ def _try_import_shared_nous_state( "tls": {"insecure": False, "ca_bundle": None}, } + def _persist_shared_refresh(updated_state: Dict[str, Any], _reason: str) -> None: + _write_shared_nous_state(updated_state) + refreshed = refresh_nous_oauth_from_state( state, min_key_ttl_seconds=min_key_ttl_seconds, timeout_seconds=timeout_seconds, force_refresh=True, inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_FRESH, + on_state_update=_persist_shared_refresh, ) _write_shared_nous_state(refreshed) except AuthError as exc: @@ -4163,7 +4162,7 @@ def _refresh_access_token( code = str(error_payload.get("error", "invalid_grant")) description = str(error_payload.get("error_description") or "Refresh token exchange failed") - relogin = code in {"invalid_grant", "invalid_token"} + relogin = code in {"invalid_grant", "invalid_token", "refresh_token_reused"} # Detect the OAuth 2.1 "refresh token reuse" signal from the Nous portal # server and surface an actionable message. This fires when an external @@ -4173,7 +4172,7 @@ def _refresh_access_token( # retires the original RT, Hermes's next refresh uses it, and the whole # session chain gets revoked as a token-theft signal (#15099). lowered = description.lower() - if "reuse" in lowered or "reuse detected" in lowered: + if code == "refresh_token_reused" or "reuse" in lowered or "reuse detected" in lowered: description = ( "Nous Portal detected refresh-token reuse and revoked this session.\n" "This usually means an external process (monitoring script, " @@ -4185,6 +4184,7 @@ def _refresh_access_token( "instead.\n" "Re-authenticate with: hermes auth add nous" ) + relogin = True raise AuthError(description, provider="nous", code=code, relogin_required=relogin) @@ -4418,8 +4418,14 @@ def refresh_nous_oauth_pure( ca_bundle: Optional[str] = None, force_refresh: bool = False, inference_auth_mode: str = NOUS_INFERENCE_AUTH_MODE_AUTO, + on_state_update: Optional[Callable[[Dict[str, Any], str], None]] = None, ) -> Dict[str, Any]: - """Refresh Nous OAuth state without mutating auth.json.""" + """Refresh Nous OAuth state without mutating auth.json directly. + + ``on_state_update`` is called after a successful access-token refresh and + before any subsequent agent-key mint. Callers that own persistent state can + use it to save the newly rotated refresh token before later work can fail. + """ inference_auth_mode = _normalize_nous_inference_auth_mode(inference_auth_mode) state: Dict[str, Any] = { "access_token": access_token, @@ -4479,6 +4485,8 @@ def refresh_nous_oauth_pure( state["expires_at"] = datetime.fromtimestamp( now.timestamp() + access_ttl, tz=timezone.utc ).isoformat() + if on_state_update is not None: + on_state_update(dict(state), "post_refresh_access_token") selected_auth_path, fallback_reason = _choose_nous_inference_auth_path( state, @@ -4519,6 +4527,7 @@ def refresh_nous_oauth_from_state( timeout_seconds: float = 15.0, force_refresh: bool = False, inference_auth_mode: str = NOUS_INFERENCE_AUTH_MODE_AUTO, + on_state_update: Optional[Callable[[Dict[str, Any], str], None]] = None, ) -> Dict[str, Any]: """Refresh Nous OAuth from a state dict. Thin wrapper around refresh_nous_oauth_pure.""" tls = state.get("tls") or {} @@ -4540,6 +4549,7 @@ def refresh_nous_oauth_from_state( ca_bundle=tls.get("ca_bundle"), force_refresh=force_refresh, inference_auth_mode=inference_auth_mode, + on_state_update=on_state_update, ) @@ -4603,6 +4613,7 @@ def persist_nous_credentials( def _sync_nous_pool_from_auth_store() -> None: + """Best-effort pool reseed after providers.nous changes; never fail login.""" try: from agent.credential_pool import load_pool diff --git a/hermes_cli/proxy/adapters/nous_portal.py b/hermes_cli/proxy/adapters/nous_portal.py index eda8f831773f..9fb07a9c0532 100644 --- a/hermes_cli/proxy/adapters/nous_portal.py +++ b/hermes_cli/proxy/adapters/nous_portal.py @@ -1,13 +1,13 @@ """Nous Portal upstream adapter. -Reads the user's Nous OAuth state from ``~/.hermes/auth.json``, refreshes -the access token and resolves the ``agent_key`` compatibility credential -when needed, then exposes the upstream base URL plus bearer for the proxy -server to forward to. +Reads the user's Nous OAuth state from ``~/.hermes/auth.json`` through the +shared runtime resolver, refreshes the access token and resolves the +``agent_key`` compatibility credential when needed, then exposes the upstream +base URL plus bearer for the proxy server to forward to. The ``agent_key`` field may hold either a NAS invoke JWT or the legacy opaque session key. The refresh helper handles both — see -:func:`hermes_cli.auth.refresh_nous_oauth_from_state`. +:func:`hermes_cli.auth.resolve_nous_runtime_credentials`. """ from __future__ import annotations @@ -22,12 +22,13 @@ NOUS_INFERENCE_AUTH_MODE_AUTO, NOUS_INFERENCE_AUTH_MODE_LEGACY, _load_auth_store, + _auth_store_lock, _is_terminal_nous_refresh_error, _quarantine_nous_oauth_state, _quarantine_nous_pool_entries, _save_auth_store, _write_shared_nous_state, - refresh_nous_oauth_from_state, + resolve_nous_runtime_credentials, ) from hermes_cli.proxy.adapters.base import UpstreamAdapter, UpstreamCredential @@ -50,9 +51,8 @@ class NousPortalAdapter(UpstreamAdapter): """Proxy upstream for the Nous Portal inference API.""" def __init__(self) -> None: - # Lock guards _load → refresh → _save against parallel proxy requests - # racing to refresh expired tokens. Refresh itself is HTTP, so we - # hold the lock across the network call (brief; OAuth refresh is fast). + # Serialize proxy requests in this process; cross-process token refresh + # and persistence are handled by resolve_nous_runtime_credentials(). self._lock = threading.Lock() @property @@ -107,8 +107,7 @@ def _get_credential(self, *, inference_auth_mode: str) -> UpstreamCredential: ) try: - refreshed = refresh_nous_oauth_from_state( - state, + refreshed = resolve_nous_runtime_credentials( inference_auth_mode=inference_auth_mode, ) except AuthError as exc: @@ -131,22 +130,20 @@ def _get_credential(self, *, inference_auth_mode: str) -> UpstreamCredential: f"Failed to refresh Nous Portal credentials: {exc}" ) from exc - self._save_state(refreshed) - - agent_key = refreshed.get("agent_key") + agent_key = refreshed.get("api_key") if not agent_key: raise RuntimeError( "Nous Portal refresh did not return a usable agent_key. " "Try `hermes login nous` to re-authenticate." ) - base_url = refreshed.get("inference_base_url") or DEFAULT_NOUS_INFERENCE_URL + base_url = refreshed.get("base_url") or DEFAULT_NOUS_INFERENCE_URL base_url = base_url.rstrip("/") return UpstreamCredential( bearer=agent_key, base_url=base_url, - expires_at=refreshed.get("agent_key_expires_at"), + expires_at=refreshed.get("expires_at"), ) # ------------------------------------------------------------------ @@ -156,7 +153,8 @@ def _get_credential(self, *, inference_auth_mode: str) -> UpstreamCredential: def _read_state(self) -> Optional[Dict[str, Any]]: try: - store = _load_auth_store() + with _auth_store_lock(): + store = _load_auth_store() except Exception as exc: logger.warning("proxy: failed to load auth store: %s", exc) return None @@ -174,21 +172,20 @@ def _save_state( quarantine_reason: Optional[str] = None, ) -> None: try: - store = _load_auth_store() - if quarantine_error is not None and quarantine_reason: - _quarantine_nous_pool_entries( - store, - quarantine_error, - reason=quarantine_reason, - ) - providers = store.setdefault("providers", {}) - providers["nous"] = state - _save_auth_store(store) + with _auth_store_lock(): + store = _load_auth_store() + if quarantine_error is not None and quarantine_reason: + _quarantine_nous_pool_entries( + store, + quarantine_error, + reason=quarantine_reason, + ) + providers = store.setdefault("providers", {}) + providers["nous"] = state + _save_auth_store(store) _write_shared_nous_state(state) except Exception as exc: - # Best effort — we still return the fresh credential. The next - # request just won't see cached state, which means another refresh. - logger.warning("proxy: failed to persist refreshed Nous state: %s", exc) + logger.warning("proxy: failed to persist Nous quarantine state: %s", exc) __all__ = ["NousPortalAdapter"] diff --git a/tests/agent/test_credential_pool.py b/tests/agent/test_credential_pool.py index 875b08d91f05..c288619aedf7 100644 --- a/tests/agent/test_credential_pool.py +++ b/tests/agent/test_credential_pool.py @@ -625,7 +625,7 @@ def _terminal_refresh_failure(*_args, **_kwargs): "access_token": "manual-nous-key", })) - monkeypatch.setattr(auth_mod, "refresh_nous_oauth_from_state", _terminal_refresh_failure) + monkeypatch.setattr(auth_mod, "resolve_nous_runtime_credentials", _terminal_refresh_failure) assert pool.try_refresh_current() is None diff --git a/tests/hermes_cli/test_auth_nous_provider.py b/tests/hermes_cli/test_auth_nous_provider.py index 93c86ebe8f28..55903b118162 100644 --- a/tests/hermes_cli/test_auth_nous_provider.py +++ b/tests/hermes_cli/test_auth_nous_provider.py @@ -1426,6 +1426,36 @@ def post(self, *args, **kwargs): assert exc_info.value.relogin_required is True +def test_refresh_token_reuse_error_code_is_terminal(): + """Nous may return refresh_token_reused as the OAuth error code itself.""" + from hermes_cli import auth as auth_mod + + class _FakeResponse: + status_code = 400 + + def json(self): + return { + "error": "refresh_token_reused", + "error_description": "Refresh token reuse detected", + } + + class _FakeClient: + def post(self, *args, **kwargs): + return _FakeResponse() + + with pytest.raises(AuthError) as exc_info: + auth_mod._refresh_access_token( + client=_FakeClient(), + portal_base_url="https://portal.nousresearch.com", + client_id="hermes-cli", + refresh_token="rt_consumed_elsewhere", + ) + + assert exc_info.value.code == "refresh_token_reused" + assert exc_info.value.relogin_required is True + assert auth_mod._is_terminal_nous_refresh_error(exc_info.value) is True + + def test_refresh_token_exchange_sends_refresh_token_header(): """Nous refresh tokens must be sent in a header so sandbox proxies can substitute placeholder credentials without parsing form bodies. @@ -1686,6 +1716,46 @@ def _boom(*_args, **_kwargs): assert auth_mod._read_shared_nous_state() is None +def test_try_import_shared_persists_rotated_token_when_mint_fails( + shared_store_env, monkeypatch, +): + """A forced shared import refresh rotates the single-use token before minting. + + If the later agent-key mint fails, the shared store must still keep the + rotated refresh token; otherwise the next import attempt replays the + consumed token and trips refresh-token reuse. + """ + from hermes_cli import auth as auth_mod + + shared_state = _full_state_fixture() + shared_state["refresh_token"] = "refresh-old" + shared_state["access_token"] = "access-old" + auth_mod._write_shared_nous_state(shared_state) + + def _fake_refresh_access_token(*, client, portal_base_url, client_id, refresh_token): + assert refresh_token == "refresh-old" + return { + "access_token": "access-new", + "refresh_token": "refresh-new", + "expires_in": 900, + "token_type": "Bearer", + } + + def _fake_mint_agent_key(*, client, portal_base_url, access_token, min_ttl_seconds): + assert access_token == "access-new" + raise AuthError("credits exhausted", provider="nous", code="insufficient_credits") + + monkeypatch.setattr(auth_mod, "_refresh_access_token", _fake_refresh_access_token) + monkeypatch.setattr(auth_mod, "_mint_agent_key", _fake_mint_agent_key) + + assert auth_mod._try_import_shared_nous_state() is None + + shared_after = auth_mod._read_shared_nous_state() + assert shared_after is not None + assert shared_after["refresh_token"] == "refresh-new" + assert shared_after["access_token"] == "access-new" + + def test_try_import_shared_rehydrates_on_success(shared_store_env, monkeypatch): """Happy path: stored refresh_token is accepted, forced refresh+mint returns a fresh access_token + agent_key, and the returned dict has diff --git a/tests/hermes_cli/test_proxy.py b/tests/hermes_cli/test_proxy.py index 45a098443f9c..34a10bfa5ff2 100644 --- a/tests/hermes_cli/test_proxy.py +++ b/tests/hermes_cli/test_proxy.py @@ -103,7 +103,7 @@ def test_nous_adapter_authenticated_with_refresh_token_only(tmp_path, monkeypatc assert NousPortalAdapter().is_authenticated() -def test_nous_adapter_get_credential_refreshes_and_persists(tmp_path, monkeypatch): +def test_nous_adapter_get_credential_uses_runtime_resolver(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path)) _write_auth_store(tmp_path, { "access_token": "access-tok", @@ -114,32 +114,24 @@ def test_nous_adapter_get_credential_refreshes_and_persists(tmp_path, monkeypatc }) refreshed_state = { - "access_token": "access-tok", - "refresh_token": "refresh-tok", - "client_id": "hermes-cli", - "portal_base_url": "https://portal.nousresearch.com", - "inference_base_url": "https://inference-api.nousresearch.com/v1", - "agent_key": "minted-bearer", - "agent_key_expires_at": "2099-01-01T00:00:00Z", + "api_key": "minted-bearer", + "base_url": "https://inference-api.nousresearch.com/v1", + "expires_at": "2099-01-01T00:00:00Z", } with patch( - "hermes_cli.proxy.adapters.nous_portal.refresh_nous_oauth_from_state", + "hermes_cli.proxy.adapters.nous_portal.resolve_nous_runtime_credentials", return_value=refreshed_state, - ) as mock_refresh: + ) as mock_resolve: adapter = NousPortalAdapter() cred = adapter.get_credential() - mock_refresh.assert_called_once() + mock_resolve.assert_called_once() assert cred.bearer == "minted-bearer" assert cred.base_url == "https://inference-api.nousresearch.com/v1" assert cred.expires_at == "2099-01-01T00:00:00Z" assert cred.token_type == "Bearer" - # Verify state was persisted back - stored = json.loads((tmp_path / "auth.json").read_text()) - assert stored["providers"]["nous"]["agent_key"] == "minted-bearer" - def test_nous_adapter_retry_credential_forces_legacy_mint(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path)) @@ -153,19 +145,15 @@ def test_nous_adapter_retry_credential_forces_legacy_mint(tmp_path, monkeypatch) }) refreshed_state = { - "access_token": "jwt-access", - "refresh_token": "refresh-tok", - "client_id": "hermes-cli", - "portal_base_url": "https://portal.nousresearch.com", - "inference_base_url": "https://inference-api.nousresearch.com/v1", - "agent_key": "legacy-bearer", - "agent_key_expires_at": "2099-01-01T00:00:00Z", + "api_key": "legacy-bearer", + "base_url": "https://inference-api.nousresearch.com/v1", + "expires_at": "2099-01-01T00:00:00Z", } with patch( - "hermes_cli.proxy.adapters.nous_portal.refresh_nous_oauth_from_state", + "hermes_cli.proxy.adapters.nous_portal.resolve_nous_runtime_credentials", return_value=refreshed_state, - ) as mock_refresh: + ) as mock_resolve: adapter = NousPortalAdapter() cred = adapter.get_retry_credential( failed_credential=UpstreamCredential( @@ -177,7 +165,7 @@ def test_nous_adapter_retry_credential_forces_legacy_mint(tmp_path, monkeypatch) assert cred is not None assert cred.bearer == "legacy-bearer" - assert mock_refresh.call_args.kwargs["inference_auth_mode"] == "legacy" + assert mock_resolve.call_args.kwargs["inference_auth_mode"] == "legacy" def test_nous_adapter_retry_credential_skips_opaque_bearer(tmp_path, monkeypatch): @@ -189,8 +177,8 @@ def test_nous_adapter_retry_credential_skips_opaque_bearer(tmp_path, monkeypatch }) with patch( - "hermes_cli.proxy.adapters.nous_portal.refresh_nous_oauth_from_state", - ) as mock_refresh: + "hermes_cli.proxy.adapters.nous_portal.resolve_nous_runtime_credentials", + ) as mock_resolve: adapter = NousPortalAdapter() cred = adapter.get_retry_credential( failed_credential=UpstreamCredential( @@ -201,7 +189,7 @@ def test_nous_adapter_retry_credential_skips_opaque_bearer(tmp_path, monkeypatch ) assert cred is None - mock_refresh.assert_not_called() + mock_resolve.assert_not_called() def test_nous_adapter_get_credential_raises_when_not_logged_in(tmp_path, monkeypatch): @@ -219,7 +207,7 @@ def test_nous_adapter_get_credential_raises_on_refresh_failure(tmp_path, monkeyp }) with patch( - "hermes_cli.proxy.adapters.nous_portal.refresh_nous_oauth_from_state", + "hermes_cli.proxy.adapters.nous_portal.resolve_nous_runtime_credentials", side_effect=RuntimeError("Refresh session has been revoked"), ): adapter = NousPortalAdapter() @@ -240,7 +228,7 @@ def test_nous_adapter_quarantines_terminal_refresh_failure(tmp_path, monkeypatch assert load_pool("nous").select() is not None with patch( - "hermes_cli.proxy.adapters.nous_portal.refresh_nous_oauth_from_state", + "hermes_cli.proxy.adapters.nous_portal.resolve_nous_runtime_credentials", side_effect=AuthError( "Refresh session has been revoked", provider="nous", @@ -270,7 +258,7 @@ def test_nous_adapter_get_credential_raises_when_no_agent_key_returned(tmp_path, }) with patch( - "hermes_cli.proxy.adapters.nous_portal.refresh_nous_oauth_from_state", + "hermes_cli.proxy.adapters.nous_portal.resolve_nous_runtime_credentials", return_value={"access_token": "a", "refresh_token": "r"}, ): adapter = NousPortalAdapter() @@ -291,7 +279,7 @@ def test_nous_adapter_concurrent_refresh_serialized(tmp_path, monkeypatch): counter = [0] counter_lock = threading.Lock() - def serializing_refresh(state, **kwargs): + def serializing_refresh(**kwargs): # If another thread is already inside refresh, the lock is broken. if in_flight.is_set(): overlap_detected.set() @@ -305,10 +293,9 @@ def serializing_refresh(state, **kwargs): counter[0] += 1 idx = counter[0] return { - **state, - "agent_key": f"key-{idx}", - "agent_key_expires_at": "2099-01-01T00:00:00Z", - "inference_base_url": "https://inference-api.nousresearch.com/v1", + "api_key": f"key-{idx}", + "expires_at": "2099-01-01T00:00:00Z", + "base_url": "https://inference-api.nousresearch.com/v1", } finally: in_flight.clear() @@ -324,7 +311,7 @@ def worker(): errors.append(exc) with patch( - "hermes_cli.proxy.adapters.nous_portal.refresh_nous_oauth_from_state", + "hermes_cli.proxy.adapters.nous_portal.resolve_nous_runtime_credentials", side_effect=serializing_refresh, ): threads = [threading.Thread(target=worker) for _ in range(3)] From 24c209f1129a0f1f540c049a7b2e7ad7e032385b Mon Sep 17 00:00:00 2001 From: Bartok9 Date: Sat, 16 May 2026 03:36:36 -0400 Subject: [PATCH 076/418] fix(auxiliary): detect quota exhaustion as payment error; allow capacity-error fallback for explicit providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #26803 Root causes: 1. _is_payment_error() checked for billing keywords (credits, insufficient funds, billing, payment required) but missed daily token quota exhaustion phrases used by Bedrock, Vertex AI, and LiteLLM proxies — e.g. 'Too many tokens per day', 'quota exceeded', 'resource exhausted', 'daily limit'. These are functionally identical to credit exhaustion (provider cannot serve the request) but don't trigger fallback. 2. The call_llm() fallback chain was gated on resolved_provider == 'auto'. When a task resolves to a specific provider (e.g. 'custom' for a LiteLLM proxy, or 'openrouter'), capacity failures (payment/quota/connection) silently raise instead of trying alternatives. This is overly conservative: capacity errors mean the provider *cannot* serve the request regardless of user intent, so alternatives should always be tried. Fixes: - Add quota-related keywords to _is_payment_error(): quota_exceeded, too many tokens per day, daily limit, tokens per day, daily quota, resource exhausted (Vertex AI gRPC code). - Allow fallback for capacity errors (payment + connection) even when resolved_provider is not 'auto'. Rate-limit fallback stays gated on is_auto to honour explicit provider constraints for transient limits. - Apply both fixes to sync call_llm() and async acall_llm() paths. - Add 6 targeted tests for the new quota-error detection cases. --- agent/auxiliary_client.py | 43 ++++++++++++++++++++++------ tests/agent/test_auxiliary_client.py | 38 ++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 4d11804f4cb8..39fa378a9144 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -2096,7 +2096,13 @@ def _is_payment_error(exc: Exception) -> bool: """Detect payment/credit/quota exhaustion errors. Returns True for HTTP 402 (Payment Required) and for 429/other errors - whose message indicates billing exhaustion rather than rate limiting. + whose message indicates billing exhaustion or daily quota exhaustion + rather than transient rate limiting. + + Daily token quota errors (e.g. Bedrock "Too many tokens per day", + Vertex AI "quota exceeded") are functionally equivalent to credit + exhaustion — the provider cannot serve the request until the quota + resets — and should trigger the same provider-fallback logic. """ status = getattr(exc, "status_code", None) if status == 402: @@ -2104,10 +2110,19 @@ def _is_payment_error(exc: Exception) -> bool: err_lower = str(exc).lower() # OpenRouter and other providers include "credits" or "afford" in 402 bodies, # but sometimes wrap them in 429 or other codes. + # Daily quota exhaustion from Bedrock, Vertex AI, and similar providers + # uses different language but is semantically identical to credit exhaustion. if status in {402, 429, None}: - if any(kw in err_lower for kw in ("credits", "insufficient funds", - "can only afford", "billing", - "payment required")): + if any(kw in err_lower for kw in ( + "credits", "insufficient funds", + "can only afford", "billing", + "payment required", + # Daily / monthly quota exhaustion keywords + "quota exceeded", "quota_exceeded", + "too many tokens per day", "daily limit", + "tokens per day", "daily quota", + "resource exhausted", # Vertex AI / gRPC quota errors + )): return True return False @@ -4538,11 +4553,17 @@ def call_llm( or _is_connection_error(first_err) or _is_rate_limit_error(first_err) ) - # Only try alternative providers when the user didn't explicitly - # configure this task's provider. Explicit provider = hard constraint; - # auto (the default) = best-effort fallback chain. (#7559) + # Respect explicit provider choice for transient errors (auth, request + # validation, etc.) but allow fallback when the provider clearly cannot + # serve the request due to capacity: payment/quota exhaustion and + # connection failures are capacity problems, not request constraints. + # See #26803: daily token quota (429 + "too many tokens per day") must + # fall back just like a 402 credit error. is_auto = resolved_provider in {"auto", "", None} - if should_fallback and is_auto: + # Capacity errors bypass the explicit-provider gate: the provider + # literally cannot serve this request regardless of user intent. + is_capacity_error = _is_payment_error(first_err) or _is_connection_error(first_err) + if should_fallback and (is_auto or is_capacity_error): if _is_payment_error(first_err): reason = "payment error" # Resolve the actual provider label (resolved_provider may be @@ -4870,8 +4891,12 @@ async def async_call_llm( or _is_connection_error(first_err) or _is_rate_limit_error(first_err) ) + # Capacity errors (payment/quota/connection) bypass the explicit-provider + # gate — the provider cannot serve the request regardless of user intent. + # See #26803: daily token quota must fall back like a 402 credit error. is_auto = resolved_provider in {"auto", "", None} - if should_fallback and is_auto: + is_capacity_error = _is_payment_error(first_err) or _is_connection_error(first_err) + if should_fallback and (is_auto or is_capacity_error): if _is_payment_error(first_err): reason = "payment error" _mark_provider_unhealthy( diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 61af7585a215..6194d586928b 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -925,6 +925,44 @@ def test_no_status_code_no_message(self): exc = Exception("connection reset") assert _is_payment_error(exc) is False + # ── Daily / monthly quota exhaustion (#26803) ──────────────────────────── + + def test_429_quota_exceeded(self): + """Cloud provider quota exhaustion (e.g. Vertex AI) is a payment error.""" + exc = Exception("RESOURCE_EXHAUSTED: quota exceeded for project") + exc.status_code = 429 + assert _is_payment_error(exc) is True + + def test_429_too_many_tokens_per_day(self): + """Bedrock / LiteLLM daily token limit is a payment error.""" + exc = Exception("Too many tokens per day: 1000000 used, 1000000 limit") + exc.status_code = 429 + assert _is_payment_error(exc) is True + + def test_429_daily_limit_phrase(self): + """Generic 'daily limit' phrasing is a payment error.""" + exc = Exception("You have exceeded your daily limit.") + exc.status_code = 429 + assert _is_payment_error(exc) is True + + def test_429_resource_exhausted_grpc(self): + """Vertex AI gRPC RESOURCE_EXHAUSTED maps to payment error.""" + exc = Exception("resource exhausted") + exc.status_code = 429 + assert _is_payment_error(exc) is True + + def test_429_daily_quota_phrase(self): + """'daily quota' phrasing is a payment error.""" + exc = Exception("Daily quota of 500 requests reached.") + exc.status_code = 429 + assert _is_payment_error(exc) is True + + def test_429_transient_rate_limit_not_quota(self): + """Transient 429 rate limit without quota keywords is NOT a payment error.""" + exc = Exception("Rate limit exceeded. Retry after 10s.") + exc.status_code = 429 + assert _is_payment_error(exc) is False + class TestIsRateLimitError: """_is_rate_limit_error detects 429 rate-limit errors warranting fallback.""" From ec096cfbd8e0049aac360fd289a21a2759748410 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 17 May 2026 14:45:28 -0700 Subject: [PATCH 077/418] test(auxiliary): adapt eviction tests to capacity-error fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two TestAuxiliaryClientPoisonedCacheEviction tests were written when explicit-provider users got no fallback at all on connection errors — they asserted ConnectionError propagated after eviction because the fallback gate blocked the auto chain. After the #26803 fix in the previous commit, capacity errors (payment/quota/connection) now DO trigger fallback even on explicit providers. The tests still verify cache eviction (their actual contract) but now stub _try_payment_fallback so the fallback machinery does not attempt a real network call. --- tests/agent/test_auxiliary_client.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 6194d586928b..49d26825dded 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -2389,10 +2389,13 @@ def close(self): def test_call_llm_evicts_on_connection_error_with_explicit_provider(self): """Connection error on an explicit provider must drop the cached client. - This is the exact reporter scenario: ``auxiliary.compression.provider: - main`` (resolves to ``openai-codex``) → no fallback chain runs (not - auto), but the cached client was poisoned by a prior timeout and must - be evicted so the next call rebuilds. + Reporter scenario: ``auxiliary.compression.provider: main`` (resolves + to ``openai-codex``). After #26803, capacity errors (payment/quota/ + connection) DO trigger fallback even on explicit providers — so we + also stub ``_try_payment_fallback`` to ``(None, None, "")`` so the + connection error re-raises after eviction instead of escaping into + a real network call. The contract under test is cache eviction, + not the fallback gate. """ from agent.auxiliary_client import _client_cache, _client_cache_lock @@ -2412,6 +2415,9 @@ def test_call_llm_evicts_on_connection_error_with_explicit_provider(self): ), patch( "agent.auxiliary_client._get_cached_client", return_value=(poisoned, "gpt-5.5"), + ), patch( + "agent.auxiliary_client._try_payment_fallback", + return_value=(None, None, ""), ): with pytest.raises(ConnectionError): call_llm( @@ -2445,6 +2451,9 @@ async def test_async_call_llm_evicts_on_connection_error_with_explicit_provider( ), patch( "agent.auxiliary_client._get_cached_client", return_value=(poisoned, "gpt-5.5"), + ), patch( + "agent.auxiliary_client._try_payment_fallback", + return_value=(None, None, ""), ): with pytest.raises(ConnectionError): await async_call_llm( From a57424683759617040dd82082d85128deb236de4 Mon Sep 17 00:00:00 2001 From: zccyman Date: Sat, 16 May 2026 16:07:41 +0000 Subject: [PATCH 078/418] feat(auxiliary): add configurable fallback chains + main-agent safety net MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layered fallback for auxiliary tasks (compression, vision, tts, web_extract, session_search, etc.): 1. Primary aux provider (existing) 2. User-configured auxiliary..fallback_chain (new) 3. Main agent provider + model (new — last-resort safety net) 4. Warn user + re-raise original error (new) For users on 'auto' (no explicit aux provider), the existing _try_payment_fallback auto-detection chain runs instead — its Step 1 already IS the main agent model, so they get the same behaviour without configuration. The configured fallback_chain config schema comes from #26882 / @zccyman; the main-agent safety net + exhaustion warning were added on top. Closes #26882. Builds on the capacity-error gate fix in the previous commit (#26803 / @Bartok9). --- agent/auxiliary_client.py | 180 +++++++++++++++++++++++++++++++++++++- 1 file changed, 176 insertions(+), 4 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 39fa378a9144..ba78833248ea 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -2606,6 +2606,133 @@ def _try_payment_fallback( return None, None, "" +def _try_main_agent_model_fallback( + failed_provider: str, + task: str = None, + reason: str = "error", +) -> Tuple[Optional[Any], Optional[str], str]: + """Last-resort fallback to the user's main agent provider + model. + + Used after the configured fallback_chain is exhausted (or empty) for + users with an explicit auxiliary provider. This is the "safety net" + layer: if nothing the user asked for can serve the request, try the + main chat model before giving up. + + Skips when the failed provider already IS the main provider (no point + retrying the same backend that just failed). + + Returns: + (client, model, provider_label) or (None, None, "") if no fallback. + """ + main_provider = (_read_main_provider() or "").strip() + main_model = (_read_main_model() or "").strip() + if not main_provider or not main_model or main_provider.lower() in {"auto", ""}: + return None, None, "" + + skip = (failed_provider or "").lower().strip() + if main_provider.lower() == skip: + # The thing that failed IS the main model — nothing to fall back to. + return None, None, "" + if _is_provider_unhealthy(main_provider): + _log_skip_unhealthy(main_provider, task) + return None, None, "" + + try: + client, resolved_model = resolve_provider_client( + provider=main_provider, model=main_model, + ) + except Exception: + client, resolved_model = None, None + + if client is None: + return None, None, "" + + label = f"main-agent({main_provider})" + logger.info( + "Auxiliary %s: %s on %s — falling back to main agent model %s (%s)", + task or "call", reason, failed_provider, label, resolved_model or main_model, + ) + return client, resolved_model or main_model, label + + +def _try_configured_fallback_chain( + task: str, + failed_provider: str, + reason: str = "error", +) -> Tuple[Optional[Any], Optional[str], str]: + """Try user-configured fallback_chain for a specific auxiliary task. + + Reads auxiliary..fallback_chain from config.yaml and tries each + entry in order. Each entry must have at least ``provider``; ``model``, + ``base_url``, and ``api_key`` are optional. + + Returns: + (client, model, provider_label) or (None, None, "") if no fallback. + """ + if not task: + return None, None, "" + + task_config = _get_auxiliary_task_config(task) + chain = task_config.get("fallback_chain") + if not chain or not isinstance(chain, list): + return None, None, "" + + skip = failed_provider.lower().strip() + tried = [] + + for i, entry in enumerate(chain): + if not isinstance(entry, dict): + continue + fb_provider = str(entry.get("provider", "")).strip() + if not fb_provider or fb_provider.lower() == skip: + continue + fb_model = str(entry.get("model", "")).strip() or None + fb_base_url = str(entry.get("base_url", "")).strip() or None + fb_api_key = str(entry.get("api_key", "")).strip() or None + + label = f"fallback_chain[{i}]({fb_provider})" + + try: + fb_client = _resolve_single_provider( + fb_provider, fb_model, fb_base_url, fb_api_key) + except Exception: + fb_client = None + + if fb_client is not None: + logger.info( + "Auxiliary %s: %s on %s — configured fallback to %s (%s)", + task, reason, failed_provider, label, fb_model or "default", + ) + return fb_client, fb_model, label + tried.append(label) + + if tried: + logger.debug( + "Auxiliary %s: configured fallback_chain exhausted (tried: %s)", + task, ", ".join(tried), + ) + return None, None, "" + + +def _resolve_single_provider( + provider: str, + model: Optional[str] = None, + base_url: Optional[str] = None, + api_key: Optional[str] = None, +) -> Optional[Any]: + """Resolve a single provider entry from fallback_chain to an OpenAI client. + + Uses the existing provider resolution infrastructure where possible. + """ + # Reuse resolve_provider_client which handles provider→client mapping + client, resolved_model = resolve_provider_client( + provider=provider, + model=model, + base_url=base_url, + api_key=api_key, + ) + return client + def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Optional[OpenAI], Optional[str]]: """Full auto-detection chain. @@ -4579,8 +4706,24 @@ def call_llm( reason = "connection error" logger.info("Auxiliary %s: %s on %s (%s), trying fallback", task or "call", reason, resolved_provider, first_err) - fb_client, fb_model, fb_label = _try_payment_fallback( - resolved_provider, task, reason=reason) + + # Fallback order (#26882, #26803): + # 1. User-configured fallback_chain (per-task) if set + # 2. Main agent model (last-resort safety net) + # For auto users (no explicit aux provider), use the full + # auto-detection chain instead — its Step 1 IS the main agent + # model, so users on `auto` already get main-model fallback. + fb_client, fb_model, fb_label = (None, None, "") + if is_auto: + fb_client, fb_model, fb_label = _try_payment_fallback( + resolved_provider, task, reason=reason) + else: + fb_client, fb_model, fb_label = _try_configured_fallback_chain( + task, resolved_provider or "auto", reason=reason) + if fb_client is None: + fb_client, fb_model, fb_label = _try_main_agent_model_fallback( + resolved_provider, task, reason=reason) + if fb_client is not None: fb_kwargs = _build_call_kwargs( fb_label, fb_model, messages, @@ -4590,6 +4733,14 @@ def call_llm( base_url=str(getattr(fb_client, "base_url", "") or "")) return _validate_llm_response( fb_client.chat.completions.create(**fb_kwargs), task) + # All fallback layers exhausted — emit a single user-visible + # warning so the operator knows aux task is about to fail. + # (#26882) The error itself is re-raised below. + logger.warning( + "Auxiliary %s: %s on %s and all fallbacks exhausted " + "(fallback_chain + main agent model). Raising original error.", + task or "call", reason, resolved_provider, + ) # Connection/timeout errors leave the cached client poisoned (closed # httpx transport, half-read stream, dead async loop). Drop it from # the cache regardless of whether we found a fallback above so the @@ -4908,8 +5059,23 @@ async def async_call_llm( reason = "connection error" logger.info("Auxiliary %s (async): %s on %s (%s), trying fallback", task or "call", reason, resolved_provider, first_err) - fb_client, fb_model, fb_label = _try_payment_fallback( - resolved_provider, task, reason=reason) + + # Fallback order (#26882, #26803): + # 1. User-configured fallback_chain (per-task) if set + # 2. Main agent model (last-resort safety net) + # Auto users get the full auto-detection chain instead — its + # Step 1 IS the main agent model. + fb_client, fb_model, fb_label = (None, None, "") + if is_auto: + fb_client, fb_model, fb_label = _try_payment_fallback( + resolved_provider, task, reason=reason) + else: + fb_client, fb_model, fb_label = _try_configured_fallback_chain( + task, resolved_provider or "auto", reason=reason) + if fb_client is None: + fb_client, fb_model, fb_label = _try_main_agent_model_fallback( + resolved_provider, task, reason=reason) + if fb_client is not None: fb_kwargs = _build_call_kwargs( fb_label, fb_model, messages, @@ -4925,6 +5091,12 @@ async def async_call_llm( fb_kwargs["model"] = async_fb_model return _validate_llm_response( await async_fb.chat.completions.create(**fb_kwargs), task) + # All fallback layers exhausted — warn before re-raising. (#26882) + logger.warning( + "Auxiliary %s (async): %s on %s and all fallbacks exhausted " + "(fallback_chain + main agent model). Raising original error.", + task or "call", reason, resolved_provider, + ) # Mirror the sync path: drop poisoned clients on connection/timeout # so the next aux call rebuilds. See issue #23432. if _is_connection_error(first_err): From 034110e7ac08e01b077e23f30530c0791d06baee Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 17 May 2026 16:26:00 -0700 Subject: [PATCH 079/418] chore(release): map zccyman noreply email for #26998 --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 6bb3d2005831..d554e474fe65 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -184,6 +184,7 @@ "santoshhumagain1887@gmail.com": "npmisantosh", "39641663+luarss@users.noreply.github.com": "luarss", "16263913+zccyman@users.noreply.github.com": "zccyman", + "zccyman@users.noreply.github.com": "zccyman", # PR #26998 (auxiliary fallback chain) "ahmetosrak@Ahmet-MacBook-Air.local": "Osraka", "98612432+Osraka@users.noreply.github.com": "Osraka", "112634774+ryptotalent@users.noreply.github.com": "ryptotalent", From 766f263bd2453838bb34e98fbe048e09f9fefa25 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 17 May 2026 16:27:06 -0700 Subject: [PATCH 080/418] =?UTF-8?q?test(auxiliary):=20cover=20layered=20fa?= =?UTF-8?q?llback=20(chain=20=E2=86=92=20main=20agent=20=E2=86=92=20warn)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 7 new tests: TestAuxiliaryFallbackLayering (3): - configured_chain succeeds → main agent fallback NOT consulted - chain returns nothing → main agent fallback runs and succeeds - both exhausted → user-visible 'all fallbacks exhausted' warning fires before the original error is re-raised TestTryMainAgentModelFallback (4): - returns (None, None, "") when main provider is 'auto' - returns (None, None, "") when failed provider == main provider (no point retrying the same backend) - resolves the main provider's client when configured correctly - skips when main provider is marked unhealthy --- tests/agent/test_auxiliary_client.py | 134 +++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 49d26825dded..2522fa16197e 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -1151,6 +1151,140 @@ def test_429_rate_limit_triggers_fallback(self, monkeypatch): # Fallback client should have been used assert fallback_client.chat.completions.create.called + +class TestAuxiliaryFallbackLayering: + """Explicit-provider users get layered fallback: configured_chain → main agent → warn.""" + + def _make_payment_err(self): + exc = Exception("Payment Required: insufficient credits") + exc.status_code = 402 + return exc + + def test_explicit_provider_uses_configured_chain_first(self, monkeypatch, caplog): + """When a user has fallback_chain configured, it's tried BEFORE the main agent model.""" + monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") + + primary_client = MagicMock() + primary_client.chat.completions.create.side_effect = self._make_payment_err() + + chain_client = MagicMock() + chain_client.chat.completions.create.return_value = MagicMock(choices=[ + MagicMock(message=MagicMock(content="from configured chain")) + ]) + + main_called = MagicMock() + + with patch("agent.auxiliary_client._get_cached_client", + return_value=(primary_client, "glm-4v-flash")), \ + patch("agent.auxiliary_client._resolve_task_provider_model", + return_value=("glm", "glm-4v-flash", None, None, None)), \ + patch("agent.auxiliary_client._try_configured_fallback_chain", + return_value=(chain_client, "gpt-4o-mini", "fallback_chain[0](openai)")), \ + patch("agent.auxiliary_client._try_main_agent_model_fallback", + side_effect=main_called): + result = call_llm( + task="vision", + messages=[{"role": "user", "content": "hello"}], + ) + + assert chain_client.chat.completions.create.called + # Main agent fallback should NOT have been consulted — chain succeeded first + main_called.assert_not_called() + + def test_explicit_provider_falls_back_to_main_when_chain_exhausted(self, monkeypatch): + """If configured fallback_chain returns nothing, main agent model is tried next.""" + monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") + + primary_client = MagicMock() + primary_client.chat.completions.create.side_effect = self._make_payment_err() + + main_client = MagicMock() + main_client.chat.completions.create.return_value = MagicMock(choices=[ + MagicMock(message=MagicMock(content="from main agent")) + ]) + + with patch("agent.auxiliary_client._get_cached_client", + return_value=(primary_client, "glm-4v-flash")), \ + patch("agent.auxiliary_client._resolve_task_provider_model", + return_value=("glm", "glm-4v-flash", None, None, None)), \ + patch("agent.auxiliary_client._try_configured_fallback_chain", + return_value=(None, None, "")), \ + patch("agent.auxiliary_client._try_main_agent_model_fallback", + return_value=(main_client, "claude-sonnet-4", "main-agent(openrouter)")): + result = call_llm( + task="vision", + messages=[{"role": "user", "content": "hello"}], + ) + + assert main_client.chat.completions.create.called + + def test_warning_emitted_when_all_fallbacks_exhausted(self, monkeypatch, caplog): + """When chain AND main model both fail, a user-visible warning fires before re-raise.""" + monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") + + primary_client = MagicMock() + primary_client.chat.completions.create.side_effect = self._make_payment_err() + + with patch("agent.auxiliary_client._get_cached_client", + return_value=(primary_client, "glm-4v-flash")), \ + patch("agent.auxiliary_client._resolve_task_provider_model", + return_value=("glm", "glm-4v-flash", None, None, None)), \ + patch("agent.auxiliary_client._try_configured_fallback_chain", + return_value=(None, None, "")), \ + patch("agent.auxiliary_client._try_main_agent_model_fallback", + return_value=(None, None, "")), \ + caplog.at_level("WARNING", logger="agent.auxiliary_client"): + with pytest.raises(Exception, match="Payment Required"): + call_llm( + task="vision", + messages=[{"role": "user", "content": "hello"}], + ) + + assert any( + "all fallbacks exhausted" in r.message for r in caplog.records + ), f"Expected exhaustion warning, got: {[r.message for r in caplog.records]}" + + +class TestTryMainAgentModelFallback: + """_try_main_agent_model_fallback resolves the user's main provider+model as a safety net.""" + + def test_returns_none_when_main_provider_is_auto(self): + from agent.auxiliary_client import _try_main_agent_model_fallback + with patch("agent.auxiliary_client._read_main_provider", return_value="auto"), \ + patch("agent.auxiliary_client._read_main_model", return_value="some-model"): + client, model, label = _try_main_agent_model_fallback("glm", task="vision") + assert client is None and model is None and label == "" + + def test_returns_none_when_failed_provider_equals_main(self): + """If the thing that failed IS the main model, no point retrying it.""" + from agent.auxiliary_client import _try_main_agent_model_fallback + with patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"), \ + patch("agent.auxiliary_client._read_main_model", return_value="anthropic/claude-sonnet-4"): + client, model, label = _try_main_agent_model_fallback("openrouter", task="vision") + assert client is None and label == "" + + def test_resolves_main_provider_client(self): + from agent.auxiliary_client import _try_main_agent_model_fallback + fake_client = MagicMock() + with patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"), \ + patch("agent.auxiliary_client._read_main_model", return_value="anthropic/claude-sonnet-4"), \ + patch("agent.auxiliary_client._is_provider_unhealthy", return_value=False), \ + patch("agent.auxiliary_client.resolve_provider_client", + return_value=(fake_client, "anthropic/claude-sonnet-4")): + client, model, label = _try_main_agent_model_fallback("glm", task="vision") + assert client is fake_client + assert model == "anthropic/claude-sonnet-4" + assert label == "main-agent(openrouter)" + + def test_skips_when_main_provider_is_unhealthy(self): + from agent.auxiliary_client import _try_main_agent_model_fallback + with patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"), \ + patch("agent.auxiliary_client._read_main_model", return_value="anthropic/claude-sonnet-4"), \ + patch("agent.auxiliary_client._is_provider_unhealthy", return_value=True): + client, model, label = _try_main_agent_model_fallback("glm", task="vision") + assert client is None + + # --------------------------------------------------------------------------- # Gate: _resolve_api_key_provider must skip anthropic when not configured # --------------------------------------------------------------------------- From 43e566f77eaf01293086eb7cb99a21e240d60634 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 17 May 2026 16:53:11 -0700 Subject: [PATCH 081/418] docs(fallback): document layered auxiliary fallback ladder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new 'Auxiliary Capacity-Error Fallback' section to website/docs/user-guide/features/fallback-providers.md covering: - The 4-step ladder (primary → fallback_chain → main agent → warn) - Which errors trigger fallback (402, 429 quota, connection) vs which respect explicit provider choice (transient 429 rate limits) - Optional fallback_chain config schema with vision + compression examples - Recognized quota-error phrases (Bedrock, Vertex AI, generic) Updates the bottom summary table — every auxiliary task now shows 'Layered (see above)' instead of 'Auto-detection chain' since explicit-provider users also get the main-agent safety net. --- .../user-guide/features/fallback-providers.md | 69 ++++++++++++++++--- 1 file changed, 60 insertions(+), 9 deletions(-) diff --git a/website/docs/user-guide/features/fallback-providers.md b/website/docs/user-guide/features/fallback-providers.md index 72528796d557..b17102cb82e3 100644 --- a/website/docs/user-guide/features/fallback-providers.md +++ b/website/docs/user-guide/features/fallback-providers.md @@ -320,6 +320,55 @@ auxiliary: --- +## Auxiliary Capacity-Error Fallback + +When you set an explicit auxiliary provider (e.g. `auxiliary.vision.provider: glm`), Hermes treats that as your preferred choice — but if the provider literally cannot serve the request because of a **capacity error** (HTTP 402 payment required, HTTP 429 daily-quota exhaustion, connection failure), Hermes falls back through a layered chain instead of failing silently: + +1. **Primary aux provider** — the one you configured (tried first, always) +2. **`auxiliary..fallback_chain`** — your per-task override list, if you wrote one +3. **Main agent provider + model** — last-resort safety net (always tried, even if you didn't write a chain) +4. **Warn + re-raise** — if every layer fails, Hermes logs `Auxiliary : ... all fallbacks exhausted` at WARNING level and re-raises the original error + +Transient HTTP 429 rate limits (`Retry-After: ...`) are treated as request constraints, not capacity problems — they respect your explicit provider choice and do **not** trigger the fallback ladder. Only daily/monthly quota exhaustion, payment errors, and connection failures bypass the explicit-provider gate. + +For users on `provider: auto` (no explicit aux provider), the existing auto-detection chain runs in place of steps 2–3. Its first step is already the main agent model, so `auto` users get the same outcome with zero config. + +### Optional: per-task fallback chain + +If you want a different fallback ordering than "main agent model first", configure `fallback_chain` explicitly. Each entry needs at least `provider`; `model`, `base_url`, and `api_key` are optional. + +```yaml +auxiliary: + vision: + provider: glm + model: glm-4v-flash + fallback_chain: + - provider: openrouter + model: google/gemini-3-flash-preview + - provider: nous + model: anthropic/claude-sonnet-4 + + compression: + provider: openrouter + fallback_chain: + - provider: openai + model: gpt-4o-mini +``` + +You do **not** need to configure `fallback_chain` to get fallback — the main-agent safety net runs regardless. Use it only when you specifically want a different order than the default. + +### Provider quota errors that trigger fallback + +Hermes recognizes these as capacity-equivalent to 402 credit exhaustion (not transient rate limits): + +- Bedrock / LiteLLM: `Too many tokens per day`, `daily limit`, `tokens per day` +- Vertex AI / GCP: `quota exceeded`, `resource exhausted`, `RESOURCE_EXHAUSTED` +- Generic: `daily quota`, `quota_exceeded` + +If your provider returns a different phrase for daily-quota exhaustion and Hermes doesn't trigger fallback, that's a bug — open an issue with the exact error string. + +--- + ## Context Compression Fallback Context compression uses the `auxiliary.compression` config block to control which model and provider handles summarization: @@ -378,14 +427,16 @@ See [Scheduled Tasks (Cron)](/docs/user-guide/features/cron) for full configurat | Feature | Fallback Mechanism | Config Location | |---------|-------------------|----------------| | Main agent model | `fallback_model` in config.yaml — per-turn failover on errors (primary restored each turn) | `fallback_model:` (top-level) | -| Vision | Auto-detection chain + internal OpenRouter retry | `auxiliary.vision` | -| Web extraction | Auto-detection chain + internal OpenRouter retry | `auxiliary.web_extract` | -| Context compression | Auto-detection chain, degrades to no-summary if unavailable | `auxiliary.compression` | -| Session search | Auto-detection chain | `auxiliary.session_search` | -| Skills hub | Auto-detection chain | `auxiliary.skills_hub` | -| MCP helpers | Auto-detection chain | `auxiliary.mcp` | -| Approval classification | Auto-detection chain | `auxiliary.approval` | -| Title generation | Auto-detection chain | `auxiliary.title_generation` | -| Triage specifier | Auto-detection chain | `auxiliary.triage_specifier` | +| Auxiliary tasks (any) — auto users | Full auto-detection chain (main agent model first, then provider chain) on capacity errors | `auxiliary..provider: auto` | +| Auxiliary tasks (any) — explicit provider | `fallback_chain` (if set) → main agent model → warn + raise, on capacity errors only | `auxiliary..fallback_chain` | +| Vision | Layered (see above) + internal OpenRouter retry | `auxiliary.vision` | +| Web extraction | Layered (see above) + internal OpenRouter retry | `auxiliary.web_extract` | +| Context compression | Layered (see above); degrades to no-summary if all layers unavailable | `auxiliary.compression` | +| Session search | Layered (see above) | `auxiliary.session_search` | +| Skills hub | Layered (see above) | `auxiliary.skills_hub` | +| MCP helpers | Layered (see above) | `auxiliary.mcp` | +| Approval classification | Layered (see above) | `auxiliary.approval` | +| Title generation | Layered (see above) | `auxiliary.title_generation` | +| Triage specifier | Layered (see above) | `auxiliary.triage_specifier` | | Delegation | Provider override only (no automatic fallback) | `delegation.provider` / `delegation.model` | | Cron jobs | Per-job provider override only (no automatic fallback) | Per-job `provider` / `model` | From 9b91377bec1a4aafc66543f30406a6ddd5546cc4 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 17 May 2026 23:00:37 -0700 Subject: [PATCH 082/418] feat(grok): apply OpenAI execution guidance to xAI Grok / xai-oauth models (#27797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grok models hit the same failure modes that OPENAI_MODEL_EXECUTION_GUIDANCE addresses for GPT/Codex: claiming completion without tool calls ('to be honest, I didn't create the file yet'), suggesting workarounds instead of using existing tools (proposing a folder-based memory system when the memory tool exists), replying with plans instead of executing. TOOL_USE_ENFORCEMENT_GUIDANCE was already injected for any model whose name contains 'grok' (TOOL_USE_ENFORCEMENT_MODELS). This extends the follow-on family-specific block — OPENAI_MODEL_EXECUTION_GUIDANCE (tool_persistence / mandatory_tool_use / act_dont_ask / prerequisite_checks / verification / missing_context) — to grok-named models too. The OPENAI_ prefix is retained for backwards compat with imports/tests; docstring + inline comment now note that the body is family-agnostic and the prefix reflects origin, not exclusivity. Tests cover the OpenRouter slug (x-ai/grok-4.3) and the xai-oauth bare name (grok-4.3), plus a negative control on claude. E2E verified against a real AIAgent build of the system prompt for both xai-oauth and openrouter grok models. --- agent/prompt_builder.py | 4 ++++ agent/system_prompt.py | 5 ++++- tests/run_agent/test_run_agent.py | 34 +++++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 6bd36387835d..0db33e1cb365 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -274,6 +274,10 @@ def _strip_yaml_frontmatter(content: str) -> str: # where GPT models abandon work on partial results, skip prerequisite lookups, # hallucinate instead of using tools, and declare "done" without verification. # Inspired by patterns from OpenAI's GPT-5.4 prompting guide & OpenClaw PR #38953. +# Also applied to xAI Grok — same failure modes in practice (claims completion +# without tool calls, suggests workarounds instead of using existing tools, +# replies with plans/suggestions instead of executing). The body is +# family-agnostic; the OPENAI_ prefix reflects origin, not exclusivity. OPENAI_MODEL_EXECUTION_GUIDANCE = ( "# Execution discipline\n" "\n" diff --git a/agent/system_prompt.py b/agent/system_prompt.py index 52a574101f5d..d69d8c320995 100644 --- a/agent/system_prompt.py +++ b/agent/system_prompt.py @@ -156,7 +156,10 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) stable_parts.append(GOOGLE_MODEL_OPERATIONAL_GUIDANCE) # OpenAI GPT/Codex execution discipline (tool persistence, # prerequisite checks, verification, anti-hallucination). - if "gpt" in _model_lower or "codex" in _model_lower: + # Also applied to xAI Grok — same failure modes (claims completion + # without tool calls, suggests workarounds instead of using + # existing tools, replies with plans instead of executing). + if "gpt" in _model_lower or "codex" in _model_lower or "grok" in _model_lower: stable_parts.append(OPENAI_MODEL_EXECUTION_GUIDANCE) has_skills_tools = any(name in agent.valid_tool_names for name in ['skills_list', 'skill_view', 'skill_manage']) diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index bc8a044e3adb..55cc81862052 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -1074,6 +1074,40 @@ def test_auto_skips_for_claude(self): prompt = agent._build_system_prompt() assert TOOL_USE_ENFORCEMENT_GUIDANCE not in prompt + def test_auto_injects_for_grok(self): + """xAI Grok / xai-oauth models hit the same enforcement path as GPT.""" + from agent.prompt_builder import TOOL_USE_ENFORCEMENT_GUIDANCE + agent = self._make_agent(model="x-ai/grok-4.3", tool_use_enforcement="auto") + prompt = agent._build_system_prompt() + assert TOOL_USE_ENFORCEMENT_GUIDANCE in prompt + + def test_auto_injects_execution_guidance_for_grok(self): + """Grok also gets OPENAI_MODEL_EXECUTION_GUIDANCE (verification, + mandatory_tool_use, act_dont_ask). Same failure modes as GPT in + practice — claims completion without tool calls, suggests workarounds + instead of using existing tools. + """ + from agent.prompt_builder import OPENAI_MODEL_EXECUTION_GUIDANCE + agent = self._make_agent(model="x-ai/grok-4.3", tool_use_enforcement="auto") + prompt = agent._build_system_prompt() + assert OPENAI_MODEL_EXECUTION_GUIDANCE in prompt + + def test_auto_injects_execution_guidance_for_xai_oauth_model(self): + """xai-oauth bare model names (no slash) also match the grok pattern.""" + from agent.prompt_builder import OPENAI_MODEL_EXECUTION_GUIDANCE + agent = self._make_agent(model="grok-4.3", tool_use_enforcement="auto") + prompt = agent._build_system_prompt() + assert OPENAI_MODEL_EXECUTION_GUIDANCE in prompt + + def test_auto_does_not_inject_execution_guidance_for_claude(self): + """Sanity: execution guidance stays off for non-targeted families.""" + from agent.prompt_builder import OPENAI_MODEL_EXECUTION_GUIDANCE + agent = self._make_agent( + model="anthropic/claude-sonnet-4", tool_use_enforcement="auto" + ) + prompt = agent._build_system_prompt() + assert OPENAI_MODEL_EXECUTION_GUIDANCE not in prompt + def test_true_forces_for_all_models(self): from agent.prompt_builder import TOOL_USE_ENFORCEMENT_GUIDANCE agent = self._make_agent(model="anthropic/claude-sonnet-4", tool_use_enforcement=True) From 4a3f13b47b3e51bc3cd0f20c7d6642d0d425bd00 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 17 May 2026 17:23:15 -0700 Subject: [PATCH 083/418] perf(prompt-cache): date-only timestamp + loud gateway-DB roundtrip logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The system prompt's 'Conversation started:' line carried minute precision (%I:%M %p), making it byte-unstable across every rebuild path. Within a CLI session the in-memory cache held, but on the gateway path (fresh AIAgent per turn → restore from session DB), any silent failure in the read or write path dropped the cache stem and forced a full re-prefill on every subsequent turn. Local prefix-caching backends (llama.cpp / vLLM) saw this as KV-cache invalidation; remote prefix-caching providers saw it as an Anthropic-style cache miss. Three changes: 1. Date-only timestamp ('Sunday, May 17, 2026' instead of '... 03:42 PM'). System prompt now byte-stable for the full day. The model can still query exact time via tools when it actually needs it. Credit: @iamfoz (PR #20451). 2. Loud logging on session DB write failures. The update_system_prompt call used to log at DEBUG, hiding disk-full / locked-database / schema drift behind a silent fall-through that forced fresh rebuilds on every subsequent turn. Now WARN with the session id and exception so persistent issues show up in agent.log without verbose mode. 3. Three-way stored-state distinction on read. The previous 'session_row.get("system_prompt") or None' collapsed three states into one (missing row / null column / empty string). Now we tell them apart and WARN when a continuing session lands on null/empty (which means the previous turn's write never persisted — every subsequent turn rebuilds and the prefix cache misses every time). The restore block is extracted into _restore_or_build_system_prompt() so the prefix-cache path can be unit-tested in isolation. E2E proof: fresh AIAgent constructed for turn 2 across a minute-boundary sleep restores byte-identical bytes from the session DB. NULL stored prompt fires the new warning. Date-only timestamp survives the rebuild path. All on real SessionDB, no mocks. Tests: - tests/agent/test_system_prompt_restore.py (10 new tests) - tests/run_agent/test_run_agent.py::TestBuildSystemPrompt:: test_datetime_is_date_only_not_minute_precision Closes #20451 (date-only), #18547 (prefix stabilization), #8689 (stabilize timestamp across compression), #15866 (timestamp caching question), #8687 (compression timestamp), #27339 (claim #3: live timestamp in cached system prompt). Co-authored-by: Martyn Forryan <9133432+iamfoz@users.noreply.github.com> --- agent/conversation_loop.py | 140 ++++++++++---- agent/system_prompt.py | 8 +- tests/agent/test_system_prompt_restore.py | 223 ++++++++++++++++++++++ tests/run_agent/test_run_agent.py | 22 +++ 4 files changed, 355 insertions(+), 38 deletions(-) create mode 100644 tests/agent/test_system_prompt_restore.py diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 8096b754298d..d3d47a5a1012 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -82,6 +82,108 @@ def _ra(): return run_agent +def _restore_or_build_system_prompt(agent, system_message, conversation_history): + """Restore the cached system prompt from the session DB or build it fresh. + + Mutates ``agent._cached_system_prompt`` and persists a freshly-built + prompt back to the session DB on first build. Extracted from + ``run_conversation`` so the prefix-cache restore path can be tested in + isolation. + + Three-way state distinction for the stored row, surfaced via logs so + silent prefix-cache misses are visible in ``agent.log``: + + * ``missing`` — no session row yet (legitimate first turn). + * ``null`` — row exists, ``system_prompt`` column is NULL. + Legacy session predating system-prompt persistence, or a migration + leftover. Warns when ``conversation_history`` is non-empty. + * ``empty`` — row exists, ``system_prompt`` column is the empty + string. Indicates a previous-turn write that ran but stored + nothing (silent persistence bug). Always warns. + * ``present`` — row exists with a usable prompt → reused verbatim. + + Read or write failures against the session DB log at WARNING (not + DEBUG) so persistent issues (disk full, schema drift, lock contention) + surface without needing verbose mode. This used to be a debug-level + log that silently broke prefix-cache reuse on the gateway path + (which constructs a fresh ``AIAgent`` per turn and depends on this + DB roundtrip). + """ + stored_prompt = None + stored_state = "missing" + if conversation_history and agent._session_db: + try: + session_row = agent._session_db.get_session(agent.session_id) + if session_row is not None: + raw_prompt = session_row.get("system_prompt") + if raw_prompt is None: + stored_state = "null" + elif raw_prompt == "": + stored_state = "empty" + else: + stored_prompt = raw_prompt + stored_state = "present" + except Exception as exc: + logger.warning( + "Session DB get_session failed for system-prompt restore " + "(session=%s): %s. Falling back to fresh build — prefix " + "cache will miss for this turn.", + agent.session_id, exc, + ) + + if stored_prompt: + # Continuing session — reuse the exact system prompt from the + # previous turn so the Anthropic cache prefix matches. + agent._cached_system_prompt = stored_prompt + return + + if conversation_history and stored_state in ("null", "empty"): + # Continuing session whose stored prompt is unusable. The + # previous turn's write either never happened or wrote an empty + # string — either way every turn now rebuilds and the prefix + # cache misses every time. + logger.warning( + "Stored system prompt for session %s is %s; rebuilding " + "from scratch this turn. Prefix cache will miss until " + "the rebuild persists. Investigate the previous turn's " + "update_system_prompt write path.", + agent.session_id, stored_state, + ) + + # First turn of a new session (or recovering from a broken stored + # prompt) — build from scratch. + agent._cached_system_prompt = agent._build_system_prompt(system_message) + + # Plugin hook: on_session_start — fired once when a brand-new + # session is created (not on continuation). Plugins can use this + # to initialise session-scoped state (e.g. warm a memory cache). + try: + from hermes_cli.plugins import invoke_hook as _invoke_hook + _invoke_hook( + "on_session_start", + session_id=agent.session_id, + model=agent.model, + platform=getattr(agent, "platform", None) or "", + ) + except Exception as exc: + logger.warning("on_session_start hook failed: %s", exc) + + # Persist the system prompt snapshot in SQLite. Failure here used + # to log at DEBUG, which silently broke prefix-cache reuse on the + # gateway path (fresh AIAgent per turn → reads from this row every + # subsequent turn). + if agent._session_db: + try: + agent._session_db.update_system_prompt(agent.session_id, agent._cached_system_prompt) + except Exception as exc: + logger.warning( + "Session DB update_system_prompt failed for session %s: " + "%s. Subsequent turns will rebuild the system prompt and " + "miss the prefix cache.", + agent.session_id, exc, + ) + + def run_conversation( agent, user_message: str, @@ -313,43 +415,7 @@ def run_conversation( # producing a different system prompt and breaking the Anthropic # prefix cache. if agent._cached_system_prompt is None: - stored_prompt = None - if conversation_history and agent._session_db: - try: - session_row = agent._session_db.get_session(agent.session_id) - if session_row: - stored_prompt = session_row.get("system_prompt") or None - except Exception: - pass # Fall through to build fresh - - if stored_prompt: - # Continuing session — reuse the exact system prompt from - # the previous turn so the Anthropic cache prefix matches. - agent._cached_system_prompt = stored_prompt - else: - # First turn of a new session — build from scratch. - agent._cached_system_prompt = agent._build_system_prompt(system_message) - # Plugin hook: on_session_start - # Fired once when a brand-new session is created (not on - # continuation). Plugins can use this to initialise - # session-scoped state (e.g. warm a memory cache). - try: - from hermes_cli.plugins import invoke_hook as _invoke_hook - _invoke_hook( - "on_session_start", - session_id=agent.session_id, - model=agent.model, - platform=getattr(agent, "platform", None) or "", - ) - except Exception as exc: - logger.warning("on_session_start hook failed: %s", exc) - - # Store the system prompt snapshot in SQLite - if agent._session_db: - try: - agent._session_db.update_system_prompt(agent.session_id, agent._cached_system_prompt) - except Exception as e: - logger.debug("Session DB update_system_prompt failed: %s", e) + _restore_or_build_system_prompt(agent, system_message, conversation_history) active_system_prompt = agent._cached_system_prompt diff --git a/agent/system_prompt.py b/agent/system_prompt.py index d69d8c320995..a9815a2f2f46 100644 --- a/agent/system_prompt.py +++ b/agent/system_prompt.py @@ -258,7 +258,13 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) from hermes_time import now as _hermes_now now = _hermes_now() - timestamp_line = f"Conversation started: {now.strftime('%A, %B %d, %Y %I:%M %p')}" + # Date-only (not minute-precision) so the system prompt is byte-stable + # for the full day. Minute-precision changes invalidate prefix-cache KV + # on every rebuild path (compression boundary, fresh-agent gateway turns, + # session resume without a stored prompt). The model can still query the + # exact wall-clock time via tools when it actually needs it. + # Credit: @iamfoz (PR #20451). + timestamp_line = f"Conversation started: {now.strftime('%A, %B %d, %Y')}" if agent.pass_session_id and agent.session_id: timestamp_line += f"\nSession ID: {agent.session_id}" if agent.model: diff --git a/tests/agent/test_system_prompt_restore.py b/tests/agent/test_system_prompt_restore.py new file mode 100644 index 000000000000..ecfd57b1dfef --- /dev/null +++ b/tests/agent/test_system_prompt_restore.py @@ -0,0 +1,223 @@ +"""Tests for ``agent.conversation_loop._restore_or_build_system_prompt``. + +Validates the gateway DB-roundtrip path that keeps the system prompt +byte-stable across turns (fresh AIAgent → must restore from session DB +instead of rebuilding). Covers: + + * Successful restore from a stored prompt (present row). + * Legitimate first-turn build (no history). + * Silent-failure recovery paths: + - DB read raises → WARNING + fresh build + - Row has system_prompt=NULL → WARNING + fresh build + - Row has system_prompt="" → WARNING + fresh build + - DB write fails → WARNING (subsequent turns will miss cache) +""" + +from __future__ import annotations + +import logging +from unittest.mock import MagicMock + +import pytest + +from agent.conversation_loop import _restore_or_build_system_prompt + + +def _make_agent(session_db=None, prebuilt_prompt: str = "BUILT_PROMPT"): + """Construct the minimal agent fake the helper needs.""" + agent = MagicMock() + agent._cached_system_prompt = None + agent.session_id = "test-session-id" + agent.model = "test-model" + agent.platform = "cli" + agent._session_db = session_db + agent._build_system_prompt = MagicMock(return_value=prebuilt_prompt) + return agent + + +# --------------------------------------------------------------------------- +# Happy paths +# --------------------------------------------------------------------------- + + +class TestStoredPromptReuse: + def test_present_row_is_reused_verbatim(self, caplog): + """Continuing session with a stored prompt → reuse byte-for-byte.""" + stored = "Stored prompt from turn 1 — byte-identical reuse" + db = MagicMock() + db.get_session.return_value = {"system_prompt": stored} + agent = _make_agent(session_db=db) + + with caplog.at_level(logging.WARNING, logger="agent.conversation_loop"): + _restore_or_build_system_prompt(agent, None, [{"role": "user", "content": "hi"}]) + + assert agent._cached_system_prompt == stored + agent._build_system_prompt.assert_not_called() + db.update_system_prompt.assert_not_called() + # No warnings on the happy path + assert not [r for r in caplog.records if r.levelno >= logging.WARNING] + + def test_present_row_with_unicode_preserved(self): + """Non-ASCII bytes in the stored prompt are not mangled.""" + stored = "Stored prompt with unicode: ☤ ⚗ ◆ — and emoji 🦊" + db = MagicMock() + db.get_session.return_value = {"system_prompt": stored} + agent = _make_agent(session_db=db) + + _restore_or_build_system_prompt(agent, None, [{"role": "user", "content": "hi"}]) + assert agent._cached_system_prompt == stored + + +# --------------------------------------------------------------------------- +# Legitimate fresh-build paths (no history, no DB) +# --------------------------------------------------------------------------- + + +class TestLegitimateFreshBuild: + def test_no_history_skips_db_and_builds_fresh(self, caplog): + """First turn with empty history → build fresh, don't touch the DB.""" + db = MagicMock() + agent = _make_agent(session_db=db) + + with caplog.at_level(logging.WARNING, logger="agent.conversation_loop"): + _restore_or_build_system_prompt(agent, None, []) + + # No history → DB read skipped entirely + db.get_session.assert_not_called() + agent._build_system_prompt.assert_called_once_with(None) + assert agent._cached_system_prompt == "BUILT_PROMPT" + # Persisted to DB + db.update_system_prompt.assert_called_once_with(agent.session_id, "BUILT_PROMPT") + assert not [r for r in caplog.records if r.levelno >= logging.WARNING] + + def test_no_db_skips_persistence(self): + """When session DB is None, build and skip persistence silently.""" + agent = _make_agent(session_db=None) + _restore_or_build_system_prompt(agent, None, []) + agent._build_system_prompt.assert_called_once() + assert agent._cached_system_prompt == "BUILT_PROMPT" + + +# --------------------------------------------------------------------------- +# Silent-failure recovery — these are the new A/B logging paths +# --------------------------------------------------------------------------- + + +class TestSilentFailureWarnings: + def test_db_read_exception_warns_and_rebuilds(self, caplog): + """DB read raising → WARNING + fall through to fresh build.""" + db = MagicMock() + db.get_session.side_effect = RuntimeError("disk full") + agent = _make_agent(session_db=db) + + with caplog.at_level(logging.WARNING, logger="agent.conversation_loop"): + _restore_or_build_system_prompt(agent, None, [{"role": "user", "content": "hi"}]) + + # Built fresh + agent._build_system_prompt.assert_called_once() + assert agent._cached_system_prompt == "BUILT_PROMPT" + # Loud warning about the read failure + warnings = [r for r in caplog.records if r.levelno >= logging.WARNING] + assert any("get_session failed" in r.getMessage() for r in warnings), \ + f"Expected a get_session warning, got: {[r.getMessage() for r in warnings]}" + assert any("disk full" in r.getMessage() for r in warnings) + + def test_null_system_prompt_warns_about_unusable_stored_state(self, caplog): + """Row exists but system_prompt is NULL → WARNING + fresh build.""" + db = MagicMock() + db.get_session.return_value = {"system_prompt": None} + agent = _make_agent(session_db=db) + + with caplog.at_level(logging.WARNING, logger="agent.conversation_loop"): + _restore_or_build_system_prompt(agent, None, [{"role": "user", "content": "hi"}]) + + agent._build_system_prompt.assert_called_once() + warnings = [r.getMessage() for r in caplog.records if r.levelno >= logging.WARNING] + assert any("is null" in m and "rebuilding" in m for m in warnings), \ + f"Expected null-stored-prompt warning, got: {warnings}" + + def test_empty_system_prompt_warns_about_silent_persistence_bug(self, caplog): + """Row exists but system_prompt is '' → WARNING about silent write bug.""" + db = MagicMock() + db.get_session.return_value = {"system_prompt": ""} + agent = _make_agent(session_db=db) + + with caplog.at_level(logging.WARNING, logger="agent.conversation_loop"): + _restore_or_build_system_prompt(agent, None, [{"role": "user", "content": "hi"}]) + + agent._build_system_prompt.assert_called_once() + warnings = [r.getMessage() for r in caplog.records if r.levelno >= logging.WARNING] + assert any("is empty" in m and "rebuilding" in m for m in warnings), \ + f"Expected empty-stored-prompt warning, got: {warnings}" + + def test_db_write_failure_warns_loudly(self, caplog): + """update_system_prompt raising → WARNING (was DEBUG before).""" + db = MagicMock() + # No prior row (first turn) + db.get_session.return_value = None + db.update_system_prompt.side_effect = RuntimeError("database is locked") + agent = _make_agent(session_db=db) + + with caplog.at_level(logging.WARNING, logger="agent.conversation_loop"): + _restore_or_build_system_prompt(agent, None, []) + + # Built and assigned the cache anyway + agent._build_system_prompt.assert_called_once() + assert agent._cached_system_prompt == "BUILT_PROMPT" + # Warning surfaced + warnings = [r.getMessage() for r in caplog.records if r.levelno >= logging.WARNING] + assert any( + "update_system_prompt failed" in m and "database is locked" in m + for m in warnings + ), f"Expected write-failure warning, got: {warnings}" + + def test_no_history_with_null_row_does_not_warn(self, caplog): + """First turn (no history) hitting a null row is not surprising — no warn.""" + db = MagicMock() + db.get_session.return_value = {"system_prompt": None} + agent = _make_agent(session_db=db) + + with caplog.at_level(logging.WARNING, logger="agent.conversation_loop"): + # Empty history → DB read is skipped entirely + _restore_or_build_system_prompt(agent, None, []) + + db.get_session.assert_not_called() + # No "rebuilding from scratch" warning because history is empty + warnings = [r.getMessage() for r in caplog.records if r.levelno >= logging.WARNING] + assert not any("rebuilding" in m for m in warnings) + + +# --------------------------------------------------------------------------- +# Byte-stability invariant +# --------------------------------------------------------------------------- + + +class TestPromptStabilityInvariant: + def test_restored_prompt_is_byte_identical_to_stored(self): + """The restored prompt must equal the stored bytes exactly — no + normalization, trimming, or concat that could shift the prefix. + + This is the core invariant: any byte-level change at this point + invalidates KV cache on every prefix-cache backend. + """ + stored = ( + "You are Hermes Agent.\n" + "\n" + "Conversation started: Sunday, May 17, 2026\n" + "Session ID: 20260517_153500_abc123\n" + ) + db = MagicMock() + db.get_session.return_value = {"system_prompt": stored} + agent = _make_agent(session_db=db) + + _restore_or_build_system_prompt(agent, None, [{"role": "user", "content": "hi"}]) + + # Identity check — must be the same object reference for maximum + # confidence we're not slicing/copying/normalizing. + assert agent._cached_system_prompt == stored + # Byte-level check + assert agent._cached_system_prompt.encode("utf-8") == stored.encode("utf-8") + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 55cc81862052..9ff7ab28612a 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -989,6 +989,28 @@ def test_includes_datetime(self, agent): # Should contain current date info like "Conversation started:" assert "Conversation started:" in prompt + def test_datetime_is_date_only_not_minute_precision(self, agent): + """Timestamp must be date-only (no HH:MM) so the system prompt + stays byte-stable for the full day. Minute precision invalidates + prefix-cache KV on every rebuild path (compression, fresh-agent + gateway turns, session resume without a stored prompt).""" + prompt = agent._build_system_prompt() + # Find the line and strip it for inspection + for line in prompt.splitlines(): + if line.startswith("Conversation started:"): + # Must NOT contain AM/PM indicator (minute precision had %I:%M %p) + assert " AM" not in line and " PM" not in line, ( + f"Timestamp line has time-of-day, breaks daily cache stability: {line!r}" + ) + # Must NOT contain a colon followed by two digits (HH:MM pattern) + import re as _re + assert not _re.search(r":\d{2}", line), ( + f"Timestamp line has HH:MM, breaks daily cache stability: {line!r}" + ) + break + else: + assert False, "Expected a 'Conversation started:' line in the system prompt" + def test_includes_nous_subscription_prompt(self, agent, monkeypatch): monkeypatch.setattr(run_agent, "build_nous_subscription_prompt", lambda tool_names: "NOUS SUBSCRIPTION BLOCK") prompt = agent._build_system_prompt() From abf1af540193c30047ff3e7e759c330faf3a880f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 17 May 2026 23:28:45 -0700 Subject: [PATCH 084/418] =?UTF-8?q?feat(session=5Fsearch):=20single-shape?= =?UTF-8?q?=20tool=20with=20discovery,=20scroll,=20browse=20=E2=80=94=20no?= =?UTF-8?q?=20LLM=20(#27590)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(session_search): single-shape tool with discovery, scroll, browse — no LLM Replaces the LLM-summarized session_search with a single-shape tool that returns actual messages from the DB. Three calling shapes inferred from args (no mode parameter): 1. Discovery — pass query. FTS5 + anchored ±5 window + bookends per hit, all in one call. ~20ms on a real DB instead of ~90s for the previous three aux-LLM calls. 2. Scroll — pass session_id + around_message_id. Returns a window centered on the anchor. To paginate, re-anchor on the first/last id of the returned window. Boundary message appears in both windows as the orientation marker. ~1ms per scroll call. 3. Browse — no args. Recent sessions chronologically. Bookend_start (first 3 user+assistant msgs) and bookend_end (last 3) give the agent goal + resolution on every discovery hit, so a single tool call reconstructs a long session's arc without loading the whole transcript. The aux-LLM summary path is gone: it cost ~$0.30/call, took ~30s, and laundered FTS5 hits through a model that could confabulate when the right session wasn't in the hit list. The merged shape returns byte-for-byte content from SQLite. History: - PR #20238 (JabberELF) seeded the fast/summary dual-mode split. - PR #26419 (yoniebans) expanded to fast/guided/summary with bookends, multi-anchor drill-down, default-mode config, and a teaching skill. This PR collapses that toolkit into one shape with explicit scroll support, drops the summary path, drops the mode parameter, drops the config knob, drops the skill. JabberELF's seed work is acknowledged via the AUTHOR_MAP entry. Validation: - 38/38 tool tests pass (tests/tools/test_session_search.py) - 12/12 get_messages_around tests pass (tests/hermes_state/) - 11/11 get_anchored_view tests pass (tests/hermes_state/) - Full tests/tools/ run: 5168 passing, 2 failures pre-exist on main (test ordering in test_delegate.py, unrelated) - E2E against live state DB: discovery 20ms, scroll 1ms, browse 280ms; pagination forward+backward works with boundary-message orientation; error paths return clean tool_error responses Co-authored-by: JabberELF Co-authored-by: yoniebans * chore(session_search): prune dead LLM-summary config and docs Companion to the single-shape rewrite. The auxiliary.session_search config block, max_concurrency / extra_body tunables, and matching docs sections all referenced the removed LLM summarization path. Removing them so users don't try to tune knobs that nothing reads. - hermes_cli/config.py: drop dead auxiliary.session_search block from DEFAULT_CONFIG. Leftover keys in user config.yaml are harmless and ignored. - hermes_cli/tips.py: drop two tips referencing the removed max_concurrency / extra_body knobs. - website/docs/user-guide/configuration.md: drop 'Session Search Tuning' section and the auxiliary.session_search block from the example. - website/docs/user-guide/features/fallback-providers.md: drop session_search rows from the auxiliary-tasks tables and the dedicated tuning subsection. - website/docs/reference/tools-reference.md: rewrite the session_search entry to describe the new three-shape behaviour. - CONTRIBUTING.md: update the file-tree description. - tests/tools/test_llm_content_none_guard.py: remove TestSessionSearchContentNone class and test_session_search_tool_guarded — both guard against an unguarded .content.strip() call site in _summarize_session() that no longer exists. Validation: 97/97 targeted tests still pass (hermes_state + session_search + llm_content_none_guard). Config tests 55/55. --------- Co-authored-by: JabberELF Co-authored-by: yoniebans --- CONTRIBUTING.md | 2 +- agent/agent_runtime_helpers.py | 4 + agent/tool_executor.py | 4 + hermes_cli/config.py | 13 +- hermes_cli/tips.py | 2 - hermes_state.py | 233 ++++- scripts/release.py | 1 + tests/hermes_state/test_get_anchored_view.py | 161 +++ .../hermes_state/test_get_messages_around.py | 148 +++ tests/tools/test_llm_content_none_guard.py | 25 - tests/tools/test_session_search.py | 837 +++++++--------- tools/session_search_tool.py | 918 +++++++++--------- website/docs/reference/tools-reference.md | 2 +- website/docs/user-guide/configuration.md | 39 - .../user-guide/features/fallback-providers.md | 28 - 15 files changed, 1338 insertions(+), 1079 deletions(-) create mode 100644 tests/hermes_state/test_get_anchored_view.py create mode 100644 tests/hermes_state/test_get_messages_around.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 36b1e9df2d57..e5f9d095252d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -172,7 +172,7 @@ hermes-agent/ │ ├── vision_tools.py # Image analysis via multimodal models │ ├── delegate_tool.py # Subagent spawning and parallel task execution │ ├── code_execution_tool.py # Sandboxed Python with RPC tool access -│ ├── session_search_tool.py # Search past conversations with FTS5 + summarization +│ ├── session_search_tool.py # Search past conversations with FTS5 + anchored windows │ ├── cronjob_tools.py # Scheduled task management │ ├── skill_tools.py # Skill search, load, manage │ └── environments/ # Terminal execution backends diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index b5c703929464..61551a65dc9a 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1503,6 +1503,10 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i query=function_args.get("query", ""), role_filter=function_args.get("role_filter"), limit=function_args.get("limit", 3), + session_id=function_args.get("session_id"), + around_message_id=function_args.get("around_message_id"), + window=function_args.get("window", 5), + sort=function_args.get("sort"), db=session_db, current_session_id=agent.session_id, ) diff --git a/agent/tool_executor.py b/agent/tool_executor.py index a30cc3078bbb..12bc72551397 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -622,6 +622,10 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe query=function_args.get("query", ""), role_filter=function_args.get("role_filter"), limit=function_args.get("limit", 3), + session_id=function_args.get("session_id"), + around_message_id=function_args.get("around_message_id"), + window=function_args.get("window", 5), + sort=function_args.get("sort"), db=session_db, current_session_id=agent.session_id, ) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 3f9bdd69ed4d..6510532a7c77 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -871,15 +871,10 @@ def _ensure_hermes_home_managed(home: Path): "timeout": 120, # seconds — compression summarises large contexts; increase for local models "extra_body": {}, }, - "session_search": { - "provider": "auto", - "model": "", - "base_url": "", - "api_key": "", - "timeout": 30, - "extra_body": {}, - "max_concurrency": 3, # Clamp parallel summaries to avoid request-burst 429s on small providers - }, + # Note: session_search no longer uses an auxiliary LLM (PR #27590 — + # single-shape tool returns DB content directly). The old + # ``auxiliary.session_search.*`` block was removed here. Existing + # values in user config.yaml files are harmless leftovers and ignored. "skills_hub": { "provider": "auto", "model": "", diff --git a/hermes_cli/tips.py b/hermes_cli/tips.py index 51f4dd2c0b64..060c441a150d 100644 --- a/hermes_cli/tips.py +++ b/hermes_cli/tips.py @@ -458,8 +458,6 @@ 'image_gen.model in config.yaml picks the FAL model: flux-2/klein, gpt-image-2, nano-banana-pro, and more.', 'image_gen.provider routes image generation through a plugin (OpenAI Images, Codex, FAL) instead of the default.', 'AUXILIARY_VISION_BASE_URL + AUXILIARY_VISION_API_KEY point vision analysis at any OpenAI-compatible endpoint.', - 'auxiliary.session_search.max_concurrency bounds how many matched sessions are summarized in parallel (default 3).', - 'auxiliary.session_search.extra_body forwards provider-specific OpenAI-compatible fields on summarization calls.', # --- Security --- 'security.tirith_fail_open: false makes Hermes block commands when the tirith scanner itself errors out.', diff --git a/hermes_state.py b/hermes_state.py index f693f391f78e..51d9f0b406f9 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -25,7 +25,7 @@ from agent.memory_manager import sanitize_context from hermes_constants import get_hermes_home -from typing import Any, Callable, Dict, List, Optional, TypeVar +from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar logger = logging.getLogger(__name__) @@ -1618,6 +1618,204 @@ def get_messages(self, session_id: str) -> List[Dict[str, Any]]: result.append(msg) return result + def get_messages_around( + self, + session_id: str, + around_message_id: int, + window: int = 5, + ) -> Dict[str, Any]: + """Load a window of messages anchored on a specific message id. + + Returns a dict with: + - ``window``: up to ``window`` messages before the anchor, the anchor + itself, and up to ``window`` messages after, ordered by id ascending. + - ``messages_before``: count of messages strictly before the anchor + still in the session (== window unless we hit the start). + - ``messages_after``: count of messages strictly after the anchor + still in the session (== window unless we hit the end). + + Used by ``session_search`` for both the discovery shape (anchored on the + FTS5 match) and the scroll shape (anchored on any message id). The + ``messages_before`` / ``messages_after`` counts let the caller detect + session boundaries: when either is less than ``window``, the agent has + reached one end of the session. + + Returns an empty window when ``around_message_id`` is not a real id in + ``session_id`` — callers decide how to surface that. + """ + if window < 0: + window = 0 + with self._lock: + # Confirm the anchor exists in this session. + anchor_exists = self._conn.execute( + "SELECT 1 FROM messages WHERE id = ? AND session_id = ? LIMIT 1", + (around_message_id, session_id), + ).fetchone() + if not anchor_exists: + return {"window": [], "messages_before": 0, "messages_after": 0} + + # Two queries: anchor + before (DESC, take window+1), and after + # (ASC, take window). Final order is id ASC. + before_rows = self._conn.execute( + "SELECT * FROM messages " + "WHERE session_id = ? AND id <= ? " + "ORDER BY id DESC LIMIT ?", + (session_id, around_message_id, window + 1), + ).fetchall() + after_rows = self._conn.execute( + "SELECT * FROM messages " + "WHERE session_id = ? AND id > ? " + "ORDER BY id ASC LIMIT ?", + (session_id, around_message_id, window), + ).fetchall() + + # before_rows is DESC; reverse so it's ASC, then concatenate after_rows. + rows = list(reversed(before_rows)) + list(after_rows) + result = [] + for row in rows: + msg = dict(row) + if "content" in msg: + msg["content"] = self._decode_content(msg["content"]) + if msg.get("tool_calls"): + try: + msg["tool_calls"] = json.loads(msg["tool_calls"]) + except (json.JSONDecodeError, TypeError): + logger.warning( + "Failed to deserialize tool_calls in get_messages_around, falling back to []" + ) + msg["tool_calls"] = [] + result.append(msg) + + # before_rows includes the anchor itself; subtract 1 for the count of + # messages strictly before the anchor in the returned slice. + messages_before = max(0, len(before_rows) - 1) + messages_after = len(after_rows) + return { + "window": result, + "messages_before": messages_before, + "messages_after": messages_after, + } + + def get_anchored_view( + self, + session_id: str, + around_message_id: int, + window: int = 5, + bookend: int = 3, + keep_roles: Optional[Tuple[str, ...]] = ("user", "assistant"), + ) -> Dict[str, Any]: + """Return an anchored window plus session bookends. + + Built on top of ``get_messages_around``. Three slices: + + - ``window``: messages immediately surrounding the anchor. Filtered + to ``keep_roles`` (tool-response noise dropped by default), EXCEPT + the anchor itself is always preserved regardless of role. + - ``bookend_start``: first ``bookend`` user/assistant messages of the + session — but only those whose id is strictly before the window's + first message id. Empty when the window already overlaps the + session head. Empty-content messages (tool-call-only assistant + turns) are skipped so they don't crowd out actual prose openings. + - ``bookend_end``: last ``bookend`` user/assistant messages of the + session, same non-overlap rule at the tail. + + Bookends let an FTS5 hit anywhere in a long session yield the goal + (opening) and the resolution (closing) on a single call — without + loading the whole transcript. + + Returns ``{"window": [], "messages_before": 0, "messages_after": 0, + "bookend_start": [], "bookend_end": []}`` when the anchor isn't in + the session. + + ``keep_roles=None`` disables role filtering (raw window + raw + bookends). + """ + if bookend < 0: + bookend = 0 + + # Reuse the primitive — handles anchor-existence, content decoding, + # tool_calls deserialisation, and boundary counts. + primitive = self.get_messages_around( + session_id, around_message_id, window=window + ) + window_rows = primitive["window"] + if not window_rows: + return { + "window": [], + "messages_before": 0, + "messages_after": 0, + "bookend_start": [], + "bookend_end": [], + } + + # Apply role filter to the window, but never drop the anchor itself. + if keep_roles is not None: + keep_set = set(keep_roles) + filtered_window = [ + m for m in window_rows + if m.get("id") == around_message_id or m.get("role") in keep_set + ] + else: + filtered_window = window_rows + + window_min_id = window_rows[0]["id"] + window_max_id = window_rows[-1]["id"] + + # Fetch bookends only when there's room outside the window. SQL filters + # by id range, role, and non-empty content — tool-call-only assistant + # turns (content='' with tool_calls populated) are excluded so they + # don't crowd out actual prose openings/closings. + bookend_start_rows: List[Any] = [] + bookend_end_rows: List[Any] = [] + if bookend > 0: + with self._lock: + role_clause = "" + role_params: list = [] + if keep_roles is not None: + role_placeholders = ",".join("?" for _ in keep_roles) + role_clause = f" AND role IN ({role_placeholders})" + role_params = list(keep_roles) + + bookend_start_rows = self._conn.execute( + f"SELECT * FROM messages " + f"WHERE session_id = ? AND id < ?{role_clause} " + f"AND length(content) > 0 " + f"ORDER BY id ASC LIMIT ?", + (session_id, window_min_id, *role_params, bookend), + ).fetchall() + + bookend_end_rows = self._conn.execute( + f"SELECT * FROM messages " + f"WHERE session_id = ? AND id > ?{role_clause} " + f"AND length(content) > 0 " + f"ORDER BY id DESC LIMIT ?", + (session_id, window_max_id, *role_params, bookend), + ).fetchall() + # End rows came back DESC for the LIMIT cap; flip to ASC. + bookend_end_rows = list(reversed(bookend_end_rows)) + + def _hydrate(row) -> Dict[str, Any]: + msg = dict(row) + if "content" in msg: + msg["content"] = self._decode_content(msg["content"]) + if msg.get("tool_calls"): + try: + msg["tool_calls"] = json.loads(msg["tool_calls"]) + except (json.JSONDecodeError, TypeError): + logger.warning( + "Failed to deserialize tool_calls in get_anchored_view, falling back to []" + ) + msg["tool_calls"] = [] + return msg + + return { + "window": filtered_window, + "messages_before": primitive["messages_before"], + "messages_after": primitive["messages_after"], + "bookend_start": [_hydrate(r) for r in bookend_start_rows], + "bookend_end": [_hydrate(r) for r in bookend_end_rows], + } + def resolve_resume_session_id(self, session_id: str) -> str: """Redirect a resume target to the descendant session that holds the messages. @@ -1885,6 +2083,7 @@ def search_messages( role_filter: List[str] = None, limit: int = 20, offset: int = 0, + sort: str = None, ) -> List[Dict[str, Any]]: """ Full-text search across session messages using FTS5. @@ -1897,6 +2096,15 @@ def search_messages( Returns matching messages with session metadata, content snippet, and surrounding context (1 message before and after the match). + + ``sort`` controls temporal ordering: + - ``None`` (default): FTS5 BM25 relevance only. Time-neutral. + - ``"newest"``: order by message timestamp DESC, then by rank. + - ``"oldest"``: order by message timestamp ASC, then by rank. + + The short-CJK LIKE fallback already orders by timestamp DESC and + ignores ``sort``. The trigram CJK path honours ``sort`` like the main + FTS5 path. """ if not query or not query.strip(): return [] @@ -1905,6 +2113,25 @@ def search_messages( if not query: return [] + # Normalise sort. Anything not in the allowed set falls back to None + # (FTS5 rank-only) so callers can pass through user input without + # validation. + if isinstance(sort, str): + sort_norm = sort.strip().lower() + if sort_norm not in ("newest", "oldest"): + sort_norm = None + else: + sort_norm = None + + # ORDER BY shared across the main FTS5 path and trigram CJK path. + # With sort set, timestamp is primary and rank is the tiebreaker. + if sort_norm == "newest": + order_by_sql = "ORDER BY m.timestamp DESC, rank" + elif sort_norm == "oldest": + order_by_sql = "ORDER BY m.timestamp ASC, rank" + else: + order_by_sql = "ORDER BY rank" + # Build WHERE clauses dynamically where_clauses = ["messages_fts MATCH ?"] params: list = [query] @@ -1943,7 +2170,7 @@ def search_messages( JOIN messages m ON m.id = messages_fts.rowid JOIN sessions s ON s.id = m.session_id WHERE {where_sql} - ORDER BY rank + {order_by_sql} LIMIT ? OFFSET ? """ @@ -2012,7 +2239,7 @@ def search_messages( JOIN messages m ON m.id = messages_fts_trigram.rowid JOIN sessions s ON s.id = m.session_id WHERE {' AND '.join(tri_where)} - ORDER BY rank + {order_by_sql} LIMIT ? OFFSET ? """ tri_params.extend([limit, offset]) diff --git a/scripts/release.py b/scripts/release.py index d554e474fe65..e9f35d5433c4 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1058,6 +1058,7 @@ "openclaw@agent.local": "29206394", # PR #22194 salvage (sudo -S brute-force guard, #9590) "freedemon@gmail.com": "fr33d3m0n", # PR #21128 salvage (sudo stdin/askpass DANGEROUS, #17873 cat 4) "zhaowh3613@outlook.com": "VinceZcrikl", # PR #23647 salvage (npm UTF-8 decode on GBK Windows) + "abcdjmm970703@gmail.com": "JabberELF", # PR #20238 seed (session_search dual-mode, evolved into single-shape) "anton.kuenzi@gmail.com": "ZeterMordio", # PR #11754 salvage (zsh completion compdef + _arguments syntax) "23yntong@stu.edu.cn": "iuyup", # PR #6155 salvage (shell=True hardening) "86501179+1RB@users.noreply.github.com": "1RB", # PR #25462 salvage (discord forwarded messages) diff --git a/tests/hermes_state/test_get_anchored_view.py b/tests/hermes_state/test_get_anchored_view.py new file mode 100644 index 000000000000..b1bf2f5a06a3 --- /dev/null +++ b/tests/hermes_state/test_get_anchored_view.py @@ -0,0 +1,161 @@ +"""Tests for SessionDB.get_anchored_view — anchored window + session bookends. + +Used by the discovery shape of session_search: an FTS5 match becomes the +anchor, the call returns goal (bookend_start) + match (window) + resolution +(bookend_end) in a single round trip, no LLM. +""" +import pytest + +from hermes_state import SessionDB + + +@pytest.fixture +def db(tmp_path): + return SessionDB(tmp_path / "state.db") + + +def _seed_long_session(db, sid="s1", n=30): + """Create a long session with alternating user/assistant prose. Returns ids ascending.""" + db.create_session(sid, source="cli") + ids = [] + for i in range(n): + role = "user" if i % 2 == 0 else "assistant" + mid = db.append_message(sid, role=role, content=f"prose msg {i}") + ids.append(mid) + return ids + + +class TestWindowAndBookendShape: + def test_returns_window_with_bookend_start_and_end(self, db): + ids = _seed_long_session(db, n=30) + # Anchor mid-session + anchor = ids[15] + view = db.get_anchored_view("s1", anchor, window=3, bookend=3) + assert len(view["window"]) == 7 # ±3 + anchor + assert len(view["bookend_start"]) == 3 + assert len(view["bookend_end"]) == 3 + # bookend_start is the first 3 ids of the session + assert [m["id"] for m in view["bookend_start"]] == ids[:3] + # bookend_end is the last 3 ids of the session + assert [m["id"] for m in view["bookend_end"]] == ids[-3:] + + def test_window_anchor_marked_correctly(self, db): + ids = _seed_long_session(db, n=20) + anchor = ids[10] + view = db.get_anchored_view("s1", anchor, window=2, bookend=3) + # Anchor message is present in the window + anchor_msgs = [m for m in view["window"] if m["id"] == anchor] + assert len(anchor_msgs) == 1 + + +class TestBookendOverlap: + """Bookends shouldn't duplicate messages that are already in the window.""" + + def test_bookend_start_empty_when_window_covers_session_head(self, db): + ids = _seed_long_session(db, n=10) + # Anchor on msg 1 (id index 1), window=3 → covers ids[0..4] + anchor = ids[1] + view = db.get_anchored_view("s1", anchor, window=3, bookend=3) + # Window includes session head, so bookend_start should be empty + assert view["bookend_start"] == [] + # bookend_end is still populated + assert len(view["bookend_end"]) > 0 + + def test_bookend_end_empty_when_window_covers_session_tail(self, db): + ids = _seed_long_session(db, n=10) + # Anchor on second-to-last + anchor = ids[-2] + view = db.get_anchored_view("s1", anchor, window=3, bookend=3) + assert view["bookend_end"] == [] + assert len(view["bookend_start"]) > 0 + + def test_short_session_both_bookends_empty(self, db): + ids = _seed_long_session(db, n=5) + view = db.get_anchored_view("s1", ids[2], window=10, bookend=3) + # Window covers entire session + assert view["bookend_start"] == [] + assert view["bookend_end"] == [] + # And window has all 5 messages + assert len(view["window"]) == 5 + + +class TestRoleFiltering: + def test_tool_role_filtered_from_window(self, db): + db.create_session("s1", source="cli") + user_ids = [] + for i in range(5): + user_ids.append(db.append_message("s1", role="user", content=f"u{i}")) + db.append_message("s1", role="tool", content=f"tool output {i}", tool_name="x") + # Anchor on user message + view = db.get_anchored_view("s1", user_ids[2], window=5, bookend=0) + # No tool messages should appear in the window + roles = [m.get("role") for m in view["window"]] + assert "tool" not in roles + + def test_anchor_preserved_even_when_tool_role(self, db): + db.create_session("s1", source="cli") + db.append_message("s1", role="user", content="ask") + tool_id = db.append_message("s1", role="tool", content="tool output", tool_name="x") + db.append_message("s1", role="user", content="follow-up") + # Anchor on the tool message — should still appear despite default filter + view = db.get_anchored_view("s1", tool_id, window=5, bookend=0) + ids_in_window = [m["id"] for m in view["window"]] + assert tool_id in ids_in_window + + def test_keep_roles_none_disables_filter(self, db): + db.create_session("s1", source="cli") + anchor_id = db.append_message("s1", role="user", content="ask") + db.append_message("s1", role="tool", content="output", tool_name="x") + view = db.get_anchored_view("s1", anchor_id, window=5, bookend=0, keep_roles=None) + roles = [m.get("role") for m in view["window"]] + assert "tool" in roles + + +class TestEmptyContentFilter: + """Tool-call-only assistant turns (empty content) should be skipped in bookends.""" + + def test_empty_content_messages_excluded_from_bookends(self, db): + db.create_session("s1", source="cli") + # Real prose opener + opener = db.append_message("s1", role="user", content="Let's start the work") + # Empty content assistant turn (tool-call-only — common in agent loops) + db.append_message("s1", role="assistant", content="", tool_calls=[{"id": "t1", "function": {"name": "x", "arguments": "{}"}}]) + # More prose + for i in range(20): + db.append_message("s1", role="user" if i % 2 == 0 else "assistant", content=f"prose {i}") + # Another empty assistant near the end + db.append_message("s1", role="assistant", content="", tool_calls=[{"id": "t2", "function": {"name": "y", "arguments": "{}"}}]) + # Prose closer + closer = db.append_message("s1", role="assistant", content="Final decision: ship it.") + + # Anchor mid-session + view = db.get_anchored_view("s1", opener + 15, window=2, bookend=3) + # Bookend_start should not contain the empty-content tool-call turn + for m in view["bookend_start"]: + assert m.get("content"), "bookend_start should skip empty-content messages" + # Bookend_end should include the closer + end_contents = [m.get("content") for m in view["bookend_end"]] + assert any("Final decision" in (c or "") for c in end_contents) + + +class TestAnchorValidation: + def test_missing_anchor_returns_empty_view(self, db): + _seed_long_session(db, n=10) + view = db.get_anchored_view("s1", 999999, window=5, bookend=3) + assert view["window"] == [] + assert view["bookend_start"] == [] + assert view["bookend_end"] == [] + assert view["messages_before"] == 0 + assert view["messages_after"] == 0 + + +class TestSessionIsolation: + """Bookends must not cross session boundaries.""" + + def test_bookends_only_from_anchor_session(self, db): + ids1 = _seed_long_session(db, sid="s1", n=20) + _seed_long_session(db, sid="s2", n=20) + view = db.get_anchored_view("s1", ids1[10], window=2, bookend=3) + # All bookend messages should have session_id = s1 (or session_id col) + for m in view["bookend_start"] + view["bookend_end"]: + assert m.get("session_id") == "s1" diff --git a/tests/hermes_state/test_get_messages_around.py b/tests/hermes_state/test_get_messages_around.py new file mode 100644 index 000000000000..4569d2b12be5 --- /dev/null +++ b/tests/hermes_state/test_get_messages_around.py @@ -0,0 +1,148 @@ +"""Tests for SessionDB.get_messages_around (anchored-window primitive). + +Used by session_search both for the discovery shape (FTS5 match as anchor) +and the scroll shape (user-supplied anchor). Returns a window of messages +around the anchor plus before/after counts so callers can detect session +boundaries. +""" +import pytest + +from hermes_state import SessionDB + + +@pytest.fixture +def db(tmp_path): + return SessionDB(tmp_path / "state.db") + + +def _seed(db, sid="s1", n=10): + """Create session with n alternating user/assistant messages, return ids ascending.""" + db.create_session(sid, source="cli") + ids = [] + for i in range(n): + role = "user" if i % 2 == 0 else "assistant" + # append_message returns the new id + mid = db.append_message(sid, role=role, content=f"msg {i}") + ids.append(mid) + return ids + + +class TestBasicWindow: + def test_returns_window_around_anchor(self, db): + ids = _seed(db, n=10) + anchor = ids[5] + view = db.get_messages_around("s1", anchor, window=2) + # Expected: 2 before + anchor + 2 after = 5 messages + msgs = view["window"] + assert len(msgs) == 5 + assert [m["id"] for m in msgs] == [ids[3], ids[4], ids[5], ids[6], ids[7]] + assert view["messages_before"] == 2 + assert view["messages_after"] == 2 + + def test_window_zero_returns_only_anchor(self, db): + ids = _seed(db, n=5) + view = db.get_messages_around("s1", ids[2], window=0) + assert len(view["window"]) == 1 + assert view["window"][0]["id"] == ids[2] + assert view["messages_before"] == 0 + assert view["messages_after"] == 0 + + def test_negative_window_clamps_to_zero(self, db): + ids = _seed(db, n=5) + view = db.get_messages_around("s1", ids[2], window=-3) + # Just anchor, like window=0 + assert len(view["window"]) == 1 + assert view["window"][0]["id"] == ids[2] + + +class TestBoundaryDetection: + """messages_before / messages_after tell the agent it's at start/end.""" + + def test_at_session_start_messages_before_is_short(self, db): + ids = _seed(db, n=10) + # Anchor on first message; ask for window=5 + view = db.get_messages_around("s1", ids[0], window=5) + assert view["messages_before"] == 0 # nothing before the first msg + assert view["messages_after"] == 5 + # window contains anchor + 5 after = 6 messages + assert len(view["window"]) == 6 + + def test_at_session_end_messages_after_is_short(self, db): + ids = _seed(db, n=10) + view = db.get_messages_around("s1", ids[-1], window=5) + assert view["messages_before"] == 5 + assert view["messages_after"] == 0 + assert len(view["window"]) == 6 + + def test_window_larger_than_session(self, db): + ids = _seed(db, n=3) + view = db.get_messages_around("s1", ids[1], window=50) + # All 3 messages return, both boundaries hit + assert len(view["window"]) == 3 + assert view["messages_before"] == 1 + assert view["messages_after"] == 1 + + +class TestAnchorValidation: + def test_missing_anchor_returns_empty(self, db): + _seed(db, n=5) + view = db.get_messages_around("s1", 99999, window=5) + assert view["window"] == [] + assert view["messages_before"] == 0 + assert view["messages_after"] == 0 + + def test_anchor_in_different_session_returns_empty(self, db): + # Two sessions, ask for s1's anchor in s2's namespace + ids1 = _seed(db, sid="s1", n=5) + _seed(db, sid="s2", n=5) + view = db.get_messages_around("s2", ids1[2], window=2) + assert view["window"] == [] + + +class TestScrollPattern: + """The forward/backward scroll loop the agent will run.""" + + def test_scroll_forward_re_anchored_on_last_id(self, db): + ids = _seed(db, n=20) + anchor = ids[5] + v1 = db.get_messages_around("s1", anchor, window=3) + last_id = v1["window"][-1]["id"] + v2 = db.get_messages_around("s1", last_id, window=3) + # Boundary id (last_id) appears in both windows (in v2 it's the anchor) + assert last_id in [m["id"] for m in v1["window"]] + assert last_id in [m["id"] for m in v2["window"]] + # v2's window extends beyond v1 + assert max(m["id"] for m in v2["window"]) > max(m["id"] for m in v1["window"]) + + def test_scroll_backward_re_anchored_on_first_id(self, db): + ids = _seed(db, n=20) + anchor = ids[10] + v1 = db.get_messages_around("s1", anchor, window=3) + first_id = v1["window"][0]["id"] + v2 = db.get_messages_around("s1", first_id, window=3) + assert first_id in [m["id"] for m in v1["window"]] + assert first_id in [m["id"] for m in v2["window"]] + assert min(m["id"] for m in v2["window"]) < min(m["id"] for m in v1["window"]) + + +class TestContentHydration: + def test_content_is_decoded(self, db): + ids = _seed(db, n=3) + view = db.get_messages_around("s1", ids[1], window=1) + for m in view["window"]: + assert isinstance(m.get("content"), str) + assert m["content"].startswith("msg ") + + def test_tool_calls_deserialized(self, db): + db.create_session("s1", source="cli") + # Message with tool_calls (pass list — append_message JSON-encodes it) + tc_payload = [{"id": "t1", "function": {"name": "x", "arguments": "{}"}}] + db.append_message("s1", role="assistant", content="", tool_calls=tc_payload) + mid = db.append_message("s1", role="tool", content="result", tool_name="x") + + view = db.get_messages_around("s1", mid, window=2) + # Find the assistant message with tool_calls + asst = [m for m in view["window"] if m.get("role") == "assistant"] + assert asst, "expected an assistant message" + # tool_calls should be a list after hydration, not a string + assert isinstance(asst[0].get("tool_calls"), list) diff --git a/tests/tools/test_llm_content_none_guard.py b/tests/tools/test_llm_content_none_guard.py index b0adea8c7ada..5ecdc725d7d1 100644 --- a/tests/tools/test_llm_content_none_guard.py +++ b/tests/tools/test_llm_content_none_guard.py @@ -155,24 +155,6 @@ def test_none_content_safe_with_or_guard(self): assert content == "" -# ── session_search_tool (line 164) ──────────────────────────────────────── - -class TestSessionSearchContentNone: - """tools/session_search_tool.py — _summarize_session() return line""" - - def test_none_content_raises_before_fix(self): - response = _make_response(None) - - with pytest.raises(AttributeError): - response.choices[0].message.content.strip() - - def test_none_content_safe_with_or_guard(self): - response = _make_response(None) - - content = (response.choices[0].message.content or "").strip() - assert content == "" - - # ── integration: verify the actual source lines are guarded ─────────────── class TestSourceLinesAreGuarded: @@ -218,13 +200,6 @@ def test_skills_guard_guarded(self): ".content.strip() — apply `(... or \"\").strip()` guard" ) - def test_session_search_tool_guarded(self): - src = self._read_file("tools/session_search_tool.py") - assert ".message.content.strip()" not in src, ( - "tools/session_search_tool.py still has unguarded " - ".content.strip() — apply `(... or \"\").strip()` guard" - ) - # ── extract_content_or_reasoning() ──────────────────────────────────────── diff --git a/tests/tools/test_session_search.py b/tests/tools/test_session_search.py index 8e67f2303496..3f517aa1a4b6 100644 --- a/tests/tools/test_session_search.py +++ b/tests/tools/test_session_search.py @@ -1,578 +1,401 @@ -"""Tests for tools/session_search_tool.py — helper functions and search dispatcher.""" +"""Tests for the single-shape session_search tool. -import asyncio +Three calling shapes: + 1. DISCOVERY — pass query → FTS5 + anchored window + bookends per hit + 2. SCROLL — pass session_id + around_message_id → just the window + 3. BROWSE — no args → recent sessions chronologically + +All run zero LLM calls. +""" import json import time + import pytest +from hermes_state import SessionDB from tools.session_search_tool import ( - _format_timestamp, - _format_conversation, - _truncate_around_matches, - _get_session_search_max_concurrency, - _list_recent_sessions, - _HIDDEN_SESSION_SOURCES, - MAX_SESSION_CHARS, SESSION_SEARCH_SCHEMA, + _HIDDEN_SESSION_SOURCES, + _format_timestamp, + session_search, ) -# ========================================================================= -# Tool schema guidance -# ========================================================================= - -class TestHiddenSessionSources: - """Verify the _HIDDEN_SESSION_SOURCES constant used for third-party isolation.""" - - def test_tool_source_is_hidden(self): - assert "tool" in _HIDDEN_SESSION_SOURCES - - def test_standard_sources_not_hidden(self): - for src in ("cli", "telegram", "discord", "slack", "cron"): - assert src not in _HIDDEN_SESSION_SOURCES - - -class TestSessionSearchSchema: - def test_keeps_cross_session_recall_guidance_without_current_session_nudge(self): - description = SESSION_SEARCH_SCHEMA["description"] - assert "past conversations" in description - assert "recent turns of the current session" not in description +@pytest.fixture +def db(tmp_path): + return SessionDB(tmp_path / "state.db") + + +def _seed_modpack_sessions(db): + """Create three sessions about a modpack so FTS5 has hits to dedupe.""" + now = int(time.time()) + # Older session — modpack origin + db.create_session("s_oldest", source="cli") + db._conn.execute("UPDATE sessions SET started_at = ?, title = ? WHERE id = ?", + (now - 30000, "Building the Modpack", "s_oldest")) + db.append_message("s_oldest", role="user", content="Let's build a Minecraft modpack") + db.append_message("s_oldest", role="assistant", content="Great. Let me scaffold the modpack repo.") + db.append_message("s_oldest", role="user", content="Use NeoForge 1.21.1") + db.append_message("s_oldest", role="assistant", content="Done. Modpack repo created with NeoForge 1.21.1.") + db.append_message("s_oldest", role="assistant", content="Tier-0 mods installed; modpack smoke test passes.") + + # Middle session — modpack quest coverage + db.create_session("s_middle", source="cli") + db._conn.execute("UPDATE sessions SET started_at = ?, title = ? WHERE id = ?", + (now - 15000, "Modpack Quest Coverage", "s_middle")) + db.append_message("s_middle", role="user", content="Deep-dive every modpack reference quest guide") + db.append_message("s_middle", role="assistant", content="Surveying ATM10 questbook for modpack inspiration.") + db.append_message("s_middle", role="user", content="Update the modpack version too") + db.append_message("s_middle", role="assistant", content="Modpack version bumped 0.4 → 0.8.5; quest coverage page added.") + + # Newest session — modpack mob spawn fix + db.create_session("s_newest", source="cli") + db._conn.execute("UPDATE sessions SET started_at = ?, title = ? WHERE id = ?", + (now - 1000, "Modpack Mob Spawn Fix", "s_newest")) + db.append_message("s_newest", role="user", content="Fix the modpack mob spawning") + db.append_message("s_newest", role="assistant", content="Investigating elite mob gating in the modpack KubeJS.") + db.append_message("s_newest", role="assistant", content="Shipped commit b850442. Modpack alternator nerfed too.") + db._conn.commit() # ========================================================================= -# _format_timestamp +# Schema invariants # ========================================================================= -class TestFormatTimestamp: - def test_unix_float(self): - ts = 1700000000.0 # Nov 14, 2023 - result = _format_timestamp(ts) - assert "2023" in result or "November" in result +class TestSchema: + def test_schema_has_required_params(self): + params = SESSION_SEARCH_SCHEMA["parameters"]["properties"] + # Discovery shape + assert "query" in params + assert "limit" in params + assert "sort" in params + # Scroll shape + assert "session_id" in params + assert "around_message_id" in params + assert "window" in params + # Shared + assert "role_filter" in params + + def test_no_mode_parameter(self): + # Mode is inferred from which args are set — no explicit mode param + params = SESSION_SEARCH_SCHEMA["parameters"]["properties"] + assert "mode" not in params + + def test_sort_enum(self): + params = SESSION_SEARCH_SCHEMA["parameters"]["properties"] + assert params["sort"]["enum"] == ["newest", "oldest"] + + def test_schema_description_teaches_scroll(self): + desc = SESSION_SEARCH_SCHEMA["description"] + assert "SCROLL" in desc + assert "DISCOVERY" in desc + assert "BROWSE" in desc + # Must explain how to scroll + assert "scroll FORWARD" in desc or "messages[-1]" in desc + + def test_no_llm_promise_in_description(self): + # The new design never calls an LLM + desc = SESSION_SEARCH_SCHEMA["description"].lower() + assert "no llm" in desc + + +class TestHiddenSources: + def test_tool_source_hidden(self): + assert "tool" in _HIDDEN_SESSION_SOURCES - def test_unix_int(self): - result = _format_timestamp(1700000000) - assert isinstance(result, str) - assert len(result) > 5 - def test_iso_string(self): - result = _format_timestamp("2024-01-15T10:30:00") - assert isinstance(result, str) +class TestFormatTimestamp: + def test_unix_timestamp(self): + out = _format_timestamp(1700000000) + assert "2023" in out - def test_none_returns_unknown(self): + def test_none(self): assert _format_timestamp(None) == "unknown" - def test_numeric_string(self): - result = _format_timestamp("1700000000.0") - assert isinstance(result, str) - assert "unknown" not in result.lower() - - -# ========================================================================= -# _format_conversation -# ========================================================================= - -class TestFormatConversation: - def test_basic_messages(self): - msgs = [ - {"role": "user", "content": "Hello"}, - {"role": "assistant", "content": "Hi there!"}, - ] - result = _format_conversation(msgs) - assert "[USER]: Hello" in result - assert "[ASSISTANT]: Hi there!" in result - - def test_tool_message(self): - msgs = [ - {"role": "tool", "content": "search results", "tool_name": "web_search"}, - ] - result = _format_conversation(msgs) - assert "[TOOL:web_search]" in result - - def test_long_tool_output_truncated(self): - msgs = [ - {"role": "tool", "content": "x" * 1000, "tool_name": "terminal"}, - ] - result = _format_conversation(msgs) - assert "[truncated]" in result - - def test_assistant_with_tool_calls(self): - msgs = [ - { - "role": "assistant", - "content": "", - "tool_calls": [ - {"function": {"name": "web_search"}}, - {"function": {"name": "terminal"}}, - ], - }, - ] - result = _format_conversation(msgs) - assert "web_search" in result - assert "terminal" in result - - def test_empty_messages(self): - result = _format_conversation([]) - assert result == "" + def test_iso_string_passthrough(self): + out = _format_timestamp("not-a-number-string") + assert out == "not-a-number-string" # ========================================================================= -# _truncate_around_matches +# Browse shape (no args) # ========================================================================= -class TestTruncateAroundMatches: - def test_short_text_unchanged(self): - text = "Short text about docker" - result = _truncate_around_matches(text, "docker") - assert result == text - - def test_long_text_truncated(self): - # Create text longer than MAX_SESSION_CHARS with query term in middle - padding = "x" * (MAX_SESSION_CHARS + 5000) - text = padding + " KEYWORD_HERE " + padding - result = _truncate_around_matches(text, "KEYWORD_HERE") - assert len(result) <= MAX_SESSION_CHARS + 100 # +100 for prefix/suffix markers - assert "KEYWORD_HERE" in result - - def test_truncation_adds_markers(self): - text = "a" * 50000 + " target " + "b" * (MAX_SESSION_CHARS + 5000) - result = _truncate_around_matches(text, "target") - assert "truncated" in result.lower() - - def test_no_match_takes_from_start(self): - text = "x" * (MAX_SESSION_CHARS + 5000) - result = _truncate_around_matches(text, "nonexistent") - # Should take from the beginning - assert result.startswith("x") - - def test_match_at_beginning(self): - text = "KEYWORD " + "x" * (MAX_SESSION_CHARS + 5000) - result = _truncate_around_matches(text, "KEYWORD") - assert "KEYWORD" in result - - def test_multiword_phrase_match_beats_individual_term(self): - """Full phrase deep in text should be found even when a single term - appears much earlier in boilerplate.""" - boilerplate = "The project setup is complex. " * 500 # ~15K, has 'project' early - filler = "x" * (MAX_SESSION_CHARS + 20000) - target = "We reviewed the keystone project roadmap in detail." - text = boilerplate + filler + target + filler - result = _truncate_around_matches(text, "keystone project") - assert "keystone project" in result.lower() - - def test_multiword_proximity_cooccurrence(self): - """When exact phrase is absent, terms co-occurring within proximity - should be preferred over a lone early term.""" - early = "project " + "a" * (MAX_SESSION_CHARS + 20000) - # Place 'keystone' and 'project' near each other (but not as exact phrase) - cooccur = "this keystone initiative for the project was pivotal" - tail = "b" * (MAX_SESSION_CHARS + 20000) - text = early + cooccur + tail - result = _truncate_around_matches(text, "keystone project") - assert "keystone" in result.lower() - assert "project" in result.lower() - - def test_multiword_window_maximises_coverage(self): - """Sliding window should capture as many match clusters as possible.""" - # Place two phrase matches: one at ~50K, one at ~60K, both should fit - pre = "z" * 50000 - match1 = " alpha beta " - gap = "z" * 10000 - match2 = " alpha beta " - post = "z" * (MAX_SESSION_CHARS + 40000) - text = pre + match1 + gap + match2 + post - result = _truncate_around_matches(text, "alpha beta") - assert result.lower().count("alpha beta") == 2 - - -class TestSessionSearchConcurrency: - def test_defaults_to_three(self): - assert _get_session_search_max_concurrency() == 3 - - def test_reads_and_clamps_configured_value(self, monkeypatch): - monkeypatch.setattr( - "hermes_cli.config.load_config", - lambda: {"auxiliary": {"session_search": {"max_concurrency": 9}}}, - ) - assert _get_session_search_max_concurrency() == 5 - - def test_session_search_respects_configured_concurrency_limit(self, monkeypatch): - from unittest.mock import MagicMock - from tools.session_search_tool import session_search - - monkeypatch.setattr( - "hermes_cli.config.load_config", - lambda: {"auxiliary": {"session_search": {"max_concurrency": 1}}}, - ) - - max_seen = {"value": 0} - active = {"value": 0} - - async def fake_summarize(_text, _query, _meta): - active["value"] += 1 - max_seen["value"] = max(max_seen["value"], active["value"]) - await asyncio.sleep(0.01) - active["value"] -= 1 - return "summary" - - monkeypatch.setattr("tools.session_search_tool._summarize_session", fake_summarize) - monkeypatch.setattr("model_tools._run_async", lambda coro: asyncio.run(coro)) - - mock_db = MagicMock() - mock_db.search_messages.return_value = [ - {"session_id": "s1", "source": "cli", "session_started": 1709500000, "model": "test"}, - {"session_id": "s2", "source": "cli", "session_started": 1709500001, "model": "test"}, - {"session_id": "s3", "source": "cli", "session_started": 1709500002, "model": "test"}, - ] - mock_db.get_session.side_effect = lambda sid: { - "id": sid, - "parent_session_id": None, - "source": "cli", - "started_at": 1709500000, - } - mock_db.get_messages_as_conversation.side_effect = lambda sid: [ - {"role": "user", "content": f"message from {sid}"}, - {"role": "assistant", "content": "response"}, - ] - - result = json.loads(session_search(query="message", db=mock_db, limit=3)) - +class TestBrowseShape: + def test_no_args_returns_recent_sessions(self, db): + _seed_modpack_sessions(db) + result = json.loads(session_search(db=db)) assert result["success"] is True - assert result["count"] == 3 - assert max_seen["value"] == 1 - - -class TestRecentSessionListing: - def test_recent_mode_requests_last_active_ordering(self): - from unittest.mock import MagicMock + assert result["mode"] == "browse" + assert result["count"] >= 3 - mock_db = MagicMock() - mock_db.list_sessions_rich.return_value = [] + def test_browse_excludes_current_session(self, db): + _seed_modpack_sessions(db) + result = json.loads(session_search(db=db, current_session_id="s_newest")) + sids = [r["session_id"] for r in result["results"]] + assert "s_newest" not in sids - result = json.loads(_list_recent_sessions(mock_db, limit=5)) - - assert result["success"] is True - mock_db.list_sessions_rich.assert_called_once_with( - limit=10, - exclude_sources=["tool"], - order_by_last_active=True, - ) - - def test_current_child_session_excludes_root_lineage_even_when_child_id_is_longer(self): - from unittest.mock import MagicMock - - mock_db = MagicMock() - mock_db.list_sessions_rich.return_value = [ - { - "id": "root", - "title": "Current conversation", - "source": "cli", - "started_at": 1709500000, - "last_active": 1709500100, - "message_count": 4, - "preview": "current root", - "parent_session_id": None, - }, - { - "id": "other_session", - "title": "Other conversation", - "source": "cli", - "started_at": 1709400000, - "last_active": 1709400100, - "message_count": 3, - "preview": "other root", - "parent_session_id": None, - }, - ] - - def _get_session(session_id): - if session_id == "child_session_id_that_is_definitely_longer": - return {"parent_session_id": "root"} - if session_id == "root": - return {"parent_session_id": None} - return None - - mock_db.get_session.side_effect = _get_session - - result = json.loads(_list_recent_sessions( - mock_db, - limit=5, - current_session_id="child_session_id_that_is_definitely_longer", - )) - - assert result["success"] is True - assert [item["session_id"] for item in result["results"]] == ["other_session"] - assert all(item["session_id"] != "root" for item in result["results"]) + def test_browse_returns_titles(self, db): + _seed_modpack_sessions(db) + result = json.loads(session_search(db=db)) + titles = [r.get("title") for r in result["results"]] + assert any("Modpack" in (t or "") for t in titles) # ========================================================================= -# session_search (dispatcher) +# Discovery shape (with query) # ========================================================================= -class TestSessionSearch: - def test_no_db_lazily_opens_default_session_db(self, monkeypatch): - from unittest.mock import MagicMock - from tools.session_search_tool import session_search - - mock_db = MagicMock() - mock_db.search_messages.return_value = [] - - class FakeSessionDB: - def __new__(cls): - return mock_db - - import types - import sys +class TestDiscoveryShape: + def test_query_returns_anchored_windows(self, db): + _seed_modpack_sessions(db) + result = json.loads(session_search(query="modpack", db=db)) + assert result["success"] is True + assert result["mode"] == "discover" + assert result["count"] >= 1 + + def test_discovery_result_has_bookends_and_window(self, db): + _seed_modpack_sessions(db) + result = json.loads(session_search(query="modpack", limit=3, db=db)) + for hit in result["results"]: + assert "bookend_start" in hit + assert "messages" in hit + assert "bookend_end" in hit + assert "match_message_id" in hit + assert "snippet" in hit + assert "messages_before" in hit + assert "messages_after" in hit + + def test_match_message_id_is_anchor_in_window(self, db): + _seed_modpack_sessions(db) + result = json.loads(session_search(query="modpack", limit=3, db=db)) + for hit in result["results"]: + anchor_id = hit["match_message_id"] + window_ids = [m["id"] for m in hit["messages"]] + assert anchor_id in window_ids + + def test_no_results_returns_empty_list(self, db): + _seed_modpack_sessions(db) + result = json.loads(session_search(query="zzz_no_such_term_zzz", db=db)) + assert result["success"] is True + assert result["results"] == [] + assert result["count"] == 0 - fake_state = types.ModuleType("hermes_state") - fake_state.SessionDB = FakeSessionDB - monkeypatch.setitem(sys.modules, "hermes_state", fake_state) + def test_limit_clamped_to_max_10(self, db): + _seed_modpack_sessions(db) + # Pass huge limit; should not error and should cap + result = json.loads(session_search(query="modpack", limit=999, db=db)) + assert result["count"] <= 10 + + def test_limit_floor_to_1(self, db): + _seed_modpack_sessions(db) + result = json.loads(session_search(query="modpack", limit=0, db=db)) + # Result count depends on hits, but the limit must be at least 1 + assert result["count"] >= 0 + + def test_non_int_limit_falls_back(self, db): + _seed_modpack_sessions(db) + result = json.loads(session_search(query="modpack", limit="bogus", db=db)) + assert result["success"] is True - result = json.loads(session_search(query="test")) + def test_current_session_filtered_out(self, db): + _seed_modpack_sessions(db) + result = json.loads(session_search(query="modpack", db=db, current_session_id="s_newest")) + sids = [r["session_id"] for r in result["results"]] + assert "s_newest" not in sids + + +class TestDiscoverySort: + def test_sort_newest_orders_by_recency(self, db): + _seed_modpack_sessions(db) + result = json.loads(session_search(query="modpack", limit=3, sort="newest", db=db)) + # First result should be the most recent session + first = result["results"][0] + assert first["session_id"] == "s_newest" or "Newest" in (first.get("title") or "") + + def test_sort_oldest_orders_by_age(self, db): + _seed_modpack_sessions(db) + result = json.loads(session_search(query="modpack", limit=3, sort="oldest", db=db)) + first = result["results"][0] + assert first["session_id"] == "s_oldest" + + def test_invalid_sort_silently_ignored(self, db): + _seed_modpack_sessions(db) + # Should not error + result = json.loads(session_search(query="modpack", sort="bogus", db=db)) assert result["success"] is True - mock_db.search_messages.assert_called_once() - def test_empty_query_returns_error(self): - from tools.session_search_tool import session_search - mock_db = object() - result = json.loads(session_search(query="", db=mock_db)) - assert result["success"] is False - def test_whitespace_query_returns_error(self): - from tools.session_search_tool import session_search - mock_db = object() - result = json.loads(session_search(query=" ", db=mock_db)) - assert result["success"] is False +class TestRoleFilter: + def test_default_excludes_tool_role(self, db): + db.create_session("s1", source="cli") + db.append_message("s1", role="user", content="modpack question") + db.append_message("s1", role="tool", content="modpack tool output", tool_name="x") + result = json.loads(session_search(query="modpack", db=db)) + # The FTS5 match should be on the user message, not the tool message + if result["count"] > 0: + matched_role = result["results"][0]["matched_role"] + assert matched_role in ("user", "assistant") + + def test_explicit_tool_role_includes_tool(self, db): + db.create_session("s1", source="cli") + db.append_message("s1", role="tool", content="modpack tool output", tool_name="x") + result = json.loads(session_search(query="modpack", role_filter="tool", db=db)) + # Should now match the tool message + if result["count"] > 0: + assert result["results"][0]["matched_role"] == "tool" - def test_current_session_excluded(self): - """session_search should never return the current session.""" - from unittest.mock import MagicMock - from tools.session_search_tool import session_search - mock_db = MagicMock() - current_sid = "20260304_120000_abc123" +# ========================================================================= +# Scroll shape (session_id + around_message_id) +# ========================================================================= - # Simulate FTS5 returning matches only from the current session - mock_db.search_messages.return_value = [ - {"session_id": current_sid, "content": "test match", "source": "cli", - "session_started": 1709500000, "model": "test"}, - ] - mock_db.get_session.return_value = {"parent_session_id": None} +class TestScrollShape: + def test_scroll_returns_window_without_bookends(self, db): + _seed_modpack_sessions(db) + # Get an anchor first via discovery + disc = json.loads(session_search(query="modpack", limit=1, db=db)) + anchor_sid = disc["results"][0]["session_id"] + anchor_mid = disc["results"][0]["match_message_id"] + # Now scroll result = json.loads(session_search( - query="test", db=mock_db, current_session_id=current_sid, + session_id=anchor_sid, around_message_id=anchor_mid, window=2, db=db )) assert result["success"] is True - assert result["count"] == 0 - assert result["results"] == [] - - def test_current_session_excluded_keeps_others(self): - """Other sessions should still be returned when current is excluded.""" - from unittest.mock import MagicMock - from tools.session_search_tool import session_search - - mock_db = MagicMock() - current_sid = "20260304_120000_abc123" - other_sid = "20260303_100000_def456" - - mock_db.search_messages.return_value = [ - {"session_id": current_sid, "content": "match 1", "source": "cli", - "session_started": 1709500000, "model": "test"}, - {"session_id": other_sid, "content": "match 2", "source": "telegram", - "session_started": 1709400000, "model": "test"}, - ] - mock_db.get_session.return_value = {"parent_session_id": None} - mock_db.get_messages_as_conversation.return_value = [ - {"role": "user", "content": "hello"}, - {"role": "assistant", "content": "hi there"}, - ] - - # Mock async_call_llm to raise RuntimeError → summarizer returns None - from unittest.mock import AsyncMock, patch as _patch - with _patch("tools.session_search_tool.async_call_llm", - new_callable=AsyncMock, - side_effect=RuntimeError("no provider")): - result = json.loads(session_search( - query="test", db=mock_db, current_session_id=current_sid, - )) - - assert result["success"] is True - # Current session should be skipped, only other_sid should appear - assert result["sessions_searched"] == 1 - assert current_sid not in [r.get("session_id") for r in result.get("results", [])] - - def test_current_child_session_excludes_parent_lineage(self): - """Compression/delegation parents should be excluded for the active child session.""" - from unittest.mock import MagicMock - from tools.session_search_tool import session_search - - mock_db = MagicMock() - mock_db.search_messages.return_value = [ - {"session_id": "parent_sid", "content": "match", "source": "cli", - "session_started": 1709500000, "model": "test"}, - ] - - def _get_session(session_id): - if session_id == "child_sid": - return {"parent_session_id": "parent_sid"} - if session_id == "parent_sid": - return {"parent_session_id": None} - return None - - mock_db.get_session.side_effect = _get_session - + assert result["mode"] == "scroll" + assert "messages" in result + # Scroll shape has no bookends + assert "bookend_start" not in result + assert "bookend_end" not in result + + def test_scroll_window_clamped_to_20(self, db): + _seed_modpack_sessions(db) + disc = json.loads(session_search(query="modpack", limit=1, db=db)) + anchor_sid = disc["results"][0]["session_id"] + anchor_mid = disc["results"][0]["match_message_id"] result = json.loads(session_search( - query="test", db=mock_db, current_session_id="child_sid", + session_id=anchor_sid, around_message_id=anchor_mid, window=999, db=db )) + assert result["window"] == 20 - assert result["success"] is True - assert result["count"] == 0 - assert result["results"] == [] - assert result["sessions_searched"] == 0 - - def test_limit_none_coerced_to_default(self): - """Model sends limit=null → should fall back to 3, not TypeError.""" - from unittest.mock import MagicMock - from tools.session_search_tool import session_search - - mock_db = MagicMock() - mock_db.search_messages.return_value = [] - + def test_scroll_window_floor_to_1(self, db): + _seed_modpack_sessions(db) + disc = json.loads(session_search(query="modpack", limit=1, db=db)) + anchor_sid = disc["results"][0]["session_id"] + anchor_mid = disc["results"][0]["match_message_id"] result = json.loads(session_search( - query="test", db=mock_db, limit=None, + session_id=anchor_sid, around_message_id=anchor_mid, window=-5, db=db )) - assert result["success"] is True - - def test_limit_type_object_coerced_to_default(self): - """Model sends limit as a type object → should fall back to 3, not TypeError.""" - from unittest.mock import MagicMock - from tools.session_search_tool import session_search - - mock_db = MagicMock() - mock_db.search_messages.return_value = [] + assert result["window"] == 1 + def test_scroll_returns_messages_before_after_counts(self, db): + _seed_modpack_sessions(db) + disc = json.loads(session_search(query="modpack", limit=1, db=db)) + anchor_sid = disc["results"][0]["session_id"] + anchor_mid = disc["results"][0]["match_message_id"] result = json.loads(session_search( - query="test", db=mock_db, limit=int, + session_id=anchor_sid, around_message_id=anchor_mid, window=3, db=db )) - assert result["success"] is True - - def test_limit_string_coerced(self): - """Model sends limit as string '2' → should coerce to int.""" - from unittest.mock import MagicMock - from tools.session_search_tool import session_search - - mock_db = MagicMock() - mock_db.search_messages.return_value = [] - + assert "messages_before" in result + assert "messages_after" in result + + def test_scroll_anchor_in_window(self, db): + _seed_modpack_sessions(db) + disc = json.loads(session_search(query="modpack", limit=1, db=db)) + anchor_sid = disc["results"][0]["session_id"] + anchor_mid = disc["results"][0]["match_message_id"] result = json.loads(session_search( - query="test", db=mock_db, limit="2", + session_id=anchor_sid, around_message_id=anchor_mid, window=2, db=db )) - assert result["success"] is True + anchor_in_window = [m for m in result["messages"] if m["id"] == anchor_mid] + assert len(anchor_in_window) == 1 + assert anchor_in_window[0].get("anchor") is True - def test_limit_clamped_to_range(self): - """Negative or zero limit should be clamped to 1.""" - from unittest.mock import MagicMock - from tools.session_search_tool import session_search - - mock_db = MagicMock() - mock_db.search_messages.return_value = [] + def test_scroll_missing_anchor_errors(self, db): + _seed_modpack_sessions(db) + result = json.loads(session_search( + session_id="s_oldest", around_message_id=999999, db=db + )) + assert result["success"] is False + assert "not in" in result.get("error", "") + def test_scroll_missing_session_errors(self, db): result = json.loads(session_search( - query="test", db=mock_db, limit=-5, + session_id="nonexistent", around_message_id=1, db=db )) - assert result["success"] is True + assert result["success"] is False + def test_scroll_rejects_current_session_lineage(self, db): + _seed_modpack_sessions(db) + # Grab some valid id from s_oldest + disc = json.loads(session_search(query="modpack", limit=3, db=db)) + match = [r for r in disc["results"] if r["session_id"] == "s_oldest"] + if match: + mid = match[0]["match_message_id"] + result = json.loads(session_search( + session_id="s_oldest", around_message_id=mid, db=db, + current_session_id="s_oldest", + )) + assert result["success"] is False + assert "current session" in result.get("error", "").lower() + + def test_scroll_invalid_around_message_id_errors(self, db): + _seed_modpack_sessions(db) result = json.loads(session_search( - query="test", db=mock_db, limit=0, + session_id="s_oldest", around_message_id="not-an-int", db=db )) - assert result["success"] is True + assert result["success"] is False - def test_current_root_session_excludes_child_lineage(self): - """Delegation child hits should be excluded when they resolve to the current root session.""" - from unittest.mock import MagicMock - from tools.session_search_tool import session_search - mock_db = MagicMock() - mock_db.search_messages.return_value = [ - {"session_id": "child_sid", "content": "match", "source": "cli", - "session_started": 1709500000, "model": "test"}, - ] +class TestScrollPattern: + """The forward/backward scroll loop using tool output.""" - def _get_session(session_id): - if session_id == "root_sid": - return {"parent_session_id": None} - if session_id == "child_sid": - return {"parent_session_id": "root_sid"} - return None + def test_scroll_forward_from_last_id(self, db): + # Long session + db.create_session("s_long", source="cli") + ids = [] + for i in range(20): + ids.append(db.append_message("s_long", role="user" if i % 2 == 0 else "assistant", + content=f"long session msg {i}")) - mock_db.get_session.side_effect = _get_session + v1 = json.loads(session_search( + session_id="s_long", around_message_id=ids[5], window=3, db=db + )) + last_id = v1["messages"][-1]["id"] + v2 = json.loads(session_search( + session_id="s_long", around_message_id=last_id, window=3, db=db + )) + # Forward scroll: v2 should reach further than v1 + assert max(m["id"] for m in v2["messages"]) > max(m["id"] for m in v1["messages"]) + # Boundary id appears in both + assert last_id in [m["id"] for m in v1["messages"]] + assert last_id in [m["id"] for m in v2["messages"]] + + +# ========================================================================= +# Shape precedence +# ========================================================================= +class TestShapePrecedence: + def test_scroll_args_beat_query(self, db): + _seed_modpack_sessions(db) + disc = json.loads(session_search(query="modpack", limit=1, db=db)) + anchor_sid = disc["results"][0]["session_id"] + anchor_mid = disc["results"][0]["match_message_id"] + # Pass both query and scroll args — scroll should win result = json.loads(session_search( - query="test", db=mock_db, current_session_id="root_sid", + query="modpack", # would normally trigger discovery + session_id=anchor_sid, around_message_id=anchor_mid, db=db, )) + assert result["mode"] == "scroll" - assert result["success"] is True - assert result["count"] == 0 - assert result["results"] == [] - assert result["sessions_searched"] == 0 - - def test_source_from_resolved_parent_not_fts5_child(self): - """source in output must reflect the resolved parent session, not the child that matched FTS5. - - Regression test for #15909: when a delegation child session (source='telegram') - resolves to a parent (source='api_server'), the result entry must report - 'api_server', not 'telegram'. - """ - from unittest.mock import MagicMock, AsyncMock, patch as _patch - from tools.session_search_tool import session_search - - mock_db = MagicMock() - # FTS5 hit is in the child delegation session which carries source='telegram' - mock_db.search_messages.return_value = [ - { - "session_id": "child_sid", - "content": "hello world", - "source": "telegram", # child session source — wrong value to surface - "session_started": 1709400000, - "model": "gpt-4o-mini", - }, - ] - - def _get_session(session_id): - if session_id == "child_sid": - return { - "id": "child_sid", - "parent_session_id": "parent_sid", - "source": "telegram", - "started_at": 1709400000, - "model": "gpt-4o-mini", - } - if session_id == "parent_sid": - return { - "id": "parent_sid", - "parent_session_id": None, - "source": "api_server", # correct parent source - "started_at": 1709300000, - "model": "gpt-4o-mini", - } - return None - - mock_db.get_session.side_effect = _get_session - mock_db.get_messages_as_conversation.return_value = [ - {"role": "user", "content": "hello world"}, - {"role": "assistant", "content": "hi there"}, - ] - - with _patch( - "tools.session_search_tool.async_call_llm", - new_callable=AsyncMock, - side_effect=RuntimeError("no provider"), - ): - result = json.loads(session_search(query="hello world", db=mock_db)) + def test_empty_query_falls_back_to_browse(self, db): + _seed_modpack_sessions(db) + result = json.loads(session_search(query=" ", db=db)) + assert result["mode"] == "browse" - assert result["success"] is True - assert result["count"] == 1 - entry = result["results"][0] - assert entry["session_id"] == "parent_sid", "should report resolved parent session ID" - assert entry["source"] == "api_server", ( - f"source should be parent's 'api_server', got {entry['source']!r}" - ) + def test_non_string_query_falls_back_to_browse(self, db): + _seed_modpack_sessions(db) + result = json.loads(session_search(query=None, db=db)) # type: ignore + assert result["mode"] == "browse" diff --git a/tools/session_search_tool.py b/tools/session_search_tool.py index e73cce6bbd9c..65b9d32f1f70 100644 --- a/tools/session_search_tool.py +++ b/tools/session_search_tool.py @@ -2,52 +2,41 @@ """ Session Search Tool - Long-Term Conversation Recall -Searches past session transcripts in SQLite via FTS5, then summarizes the top -matching sessions using the configured auxiliary session_search model (same -pattern as web_extract). By default, auxiliary "auto" routing uses the main -chat provider/model unless the user overrides auxiliary.session_search. -Returns focused summaries of past conversations rather than raw transcripts, -keeping the main model's context window clean. - -Flow: - 1. FTS5 search finds matching messages ranked by relevance - 2. Groups by session, takes the top N unique sessions (default 3) - 3. Loads each session's conversation, truncates to ~100k chars centered on matches - 4. Sends to the configured auxiliary model with a focused summarization prompt - 5. Returns per-session summaries with metadata +Single-shape tool with three calling modes (inferred from args, no explicit +mode parameter): + + 1. DISCOVERY — pass ``query``. Runs FTS5, dedupes hits by session lineage, + returns top N sessions each with: snippet, ±5 message window around the + match, plus bookend_start (first 3 user+assistant msgs of session) and + bookend_end (last 3). Zero LLM cost. + + 2. SCROLL — pass ``session_id`` + ``around_message_id``. Returns a window + of ±window messages centered on the anchor, no FTS5, no bookends. To + scroll forward / backward, re-anchor on the last / first message id of + the returned window. + + 3. BROWSE — no args. Returns recent sessions chronologically (titles, + previews, timestamps). + +All three modes operate on the SQLite session DB via the FTS5 index and +the get_anchored_view / get_messages_around primitives in hermes_state. +No LLM calls anywhere — every shape returns actual messages from the DB. + +History: PR #20238 (JabberELF) seeded a fast/summary dual-mode split; the +toolkit expansion in PR #26419 (yoniebans) added the anchored drill-down, +bookends, and sort. This module merges all of that into a single calling +shape with no mode parameter, no summary LLM path, and explicit scroll +support. """ -import asyncio -import concurrent.futures import json import logging -import re -from typing import Dict, Any, List, Optional, Union +from typing import Any, Dict, List, Optional, Union -from agent.auxiliary_client import async_call_llm, extract_content_or_reasoning -MAX_SESSION_CHARS = 100_000 -MAX_SUMMARY_TOKENS = 10000 - - -def _get_session_search_max_concurrency(default: int = 3) -> int: - """Read auxiliary.session_search.max_concurrency with sane bounds.""" - try: - from hermes_cli.config import load_config - config = load_config() - except ImportError: - return default - aux = config.get("auxiliary", {}) if isinstance(config, dict) else {} - task_config = aux.get("session_search", {}) if isinstance(aux, dict) else {} - if not isinstance(task_config, dict): - return default - raw = task_config.get("max_concurrency") - if raw is None: - return default - try: - value = int(raw) - except (TypeError, ValueError): - return default - return max(1, min(value, 5)) +# Sources that are excluded from session browsing/searching by default. +# Third-party integrations tag their sessions with HERMES_SESSION_SOURCE=tool +# so they don't clutter the user's session history. +_HIDDEN_SESSION_SOURCES = ("tool",) def _format_timestamp(ts: Union[int, float, str, None]) -> str: @@ -69,233 +58,72 @@ def _format_timestamp(ts: Union[int, float, str, None]) -> str: return dt.strftime("%B %d, %Y at %I:%M %p") return ts except (ValueError, OSError, OverflowError) as e: - # Log specific errors for debugging while gracefully handling edge cases logging.debug("Failed to format timestamp %s: %s", ts, e, exc_info=True) except Exception as e: logging.debug("Unexpected error formatting timestamp %s: %s", ts, e, exc_info=True) return str(ts) -def _format_conversation(messages: List[Dict[str, Any]]) -> str: - """Format session messages into a readable transcript for summarization.""" - parts = [] - for msg in messages: - role = msg.get("role", "unknown").upper() - content = msg.get("content") or "" - tool_name = msg.get("tool_name") - - if role == "TOOL" and tool_name: - # Truncate long tool outputs - if len(content) > 500: - content = content[:250] + "\n...[truncated]...\n" + content[-250:] - parts.append(f"[TOOL:{tool_name}]: {content}") - elif role == "ASSISTANT": - # Include tool call names if present - tool_calls = msg.get("tool_calls") - if tool_calls and isinstance(tool_calls, list): - tc_names = [] - for tc in tool_calls: - if isinstance(tc, dict): - name = tc.get("name") or tc.get("function", {}).get("name", "?") - tc_names.append(name) - if tc_names: - parts.append(f"[ASSISTANT]: [Called: {', '.join(tc_names)}]") - if content: - parts.append(f"[ASSISTANT]: {content}") - else: - parts.append(f"[ASSISTANT]: {content}") - else: - parts.append(f"[{role}]: {content}") - - return "\n\n".join(parts) - - -def _truncate_around_matches( - full_text: str, query: str, max_chars: int = MAX_SESSION_CHARS -) -> str: - """ - Truncate a conversation transcript to *max_chars*, choosing a window - that maximises coverage of positions where the *query* actually appears. - - Strategy (in priority order): - 1. Try to find the full query as a phrase (case-insensitive). - 2. If no phrase hit, look for positions where all query terms appear - within a 200-char proximity window (co-occurrence). - 3. Fall back to individual term positions. - - Once candidate positions are collected the function picks the window - start that covers the most of them. - """ - if len(full_text) <= max_chars: - return full_text - - text_lower = full_text.lower() - query_lower = query.lower().strip() - match_positions: list[int] = [] - - # --- 1. Full-phrase search ------------------------------------------------ - phrase_pat = re.compile(re.escape(query_lower)) - match_positions = [m.start() for m in phrase_pat.finditer(text_lower)] - - # --- 2. Proximity co-occurrence of all terms (within 200 chars) ----------- - if not match_positions: - terms = query_lower.split() - if len(terms) > 1: - # Collect every occurrence of each term - term_positions: dict[str, list[int]] = {} - for t in terms: - term_positions[t] = [ - m.start() for m in re.finditer(re.escape(t), text_lower) - ] - # Slide through positions of the rarest term and check proximity - rarest = min(terms, key=lambda t: len(term_positions.get(t, []))) - for pos in term_positions.get(rarest, []): - if all( - any(abs(p - pos) < 200 for p in term_positions.get(t, [])) - for t in terms - if t != rarest - ): - match_positions.append(pos) - - # --- 3. Individual term positions (last resort) --------------------------- - if not match_positions: - terms = query_lower.split() - for t in terms: - for m in re.finditer(re.escape(t), text_lower): - match_positions.append(m.start()) - - if not match_positions: - # Nothing at all — take from the start - truncated = full_text[:max_chars] - suffix = "\n\n...[later conversation truncated]..." if max_chars < len(full_text) else "" - return truncated + suffix - - # --- Pick window that covers the most match positions --------------------- - match_positions.sort() - - best_start = 0 - best_count = 0 - for candidate in match_positions: - ws = max(0, candidate - max_chars // 4) # bias: 25% before, 75% after - we = ws + max_chars - if we > len(full_text): - ws = max(0, len(full_text) - max_chars) - we = len(full_text) - count = sum(1 for p in match_positions if ws <= p < we) - if count > best_count: - best_count = count - best_start = ws - - start = best_start - end = min(len(full_text), start + max_chars) - - truncated = full_text[start:end] - prefix = "...[earlier conversation truncated]...\n\n" if start > 0 else "" - suffix = "\n\n...[later conversation truncated]..." if end < len(full_text) else "" - return prefix + truncated + suffix - - -async def _summarize_session( - conversation_text: str, query: str, session_meta: Dict[str, Any] -) -> Optional[str]: - """Summarize a single session conversation focused on the search query.""" - system_prompt = ( - "You are reviewing a past conversation transcript to help recall what happened. " - "Summarize the conversation with a focus on the search topic. Include:\n" - "1. What the user asked about or wanted to accomplish\n" - "2. What actions were taken and what the outcomes were\n" - "3. Key decisions, solutions found, or conclusions reached\n" - "4. Any specific commands, files, URLs, or technical details that were important\n" - "5. Anything left unresolved or notable\n\n" - "Be thorough but concise. Preserve specific details (commands, paths, error messages) " - "that would be useful to recall. Write in past tense as a factual recap." - ) - - source = session_meta.get("source", "unknown") - started = _format_timestamp(session_meta.get("started_at")) - - user_prompt = ( - f"Search topic: {query}\n" - f"Session source: {source}\n" - f"Session date: {started}\n\n" - f"CONVERSATION TRANSCRIPT:\n{conversation_text}\n\n" - f"Summarize this conversation with focus on: {query}" - ) - - max_retries = 3 - for attempt in range(max_retries): +def _resolve_to_parent(db, session_id: str) -> str: + """Walk parent_session_id chain to the lineage root. Falls back to input on errors.""" + if not session_id: + return session_id + visited = set() + cur = session_id + while cur and cur not in visited: + visited.add(cur) try: - response = await async_call_llm( - task="session_search", - messages=[ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt}, - ], - temperature=0.1, - max_tokens=MAX_SUMMARY_TOKENS, - ) - content = extract_content_or_reasoning(response) - if content: - return content - # Reasoning-only / empty — let the retry loop handle it - logging.warning("Session search LLM returned empty content (attempt %d/%d)", attempt + 1, max_retries) - if attempt < max_retries - 1: - await asyncio.sleep(1 * (attempt + 1)) - continue - return content - except RuntimeError: - logging.warning("No auxiliary model available for session summarization") - return None + s = db.get_session(cur) + if not s: + break + parent = s.get("parent_session_id") + if not parent: + break + cur = parent except Exception as e: - if attempt < max_retries - 1: - await asyncio.sleep(1 * (attempt + 1)) - else: - logging.warning( - "Session summarization failed after %d attempts: %s", - max_retries, - e, - exc_info=True, - ) - return None - - -# Sources that are excluded from session browsing/searching by default. -# Third-party integrations (Paperclip agents, etc.) tag their sessions with -# HERMES_SESSION_SOURCE=tool so they don't clutter the user's session history. -_HIDDEN_SESSION_SOURCES = ("tool",) + logging.debug("Error resolving parent for %s: %s", cur, e, exc_info=True) + break + return cur + + +def _shape_message(m: Dict[str, Any], anchor_id: Optional[int] = None) -> Dict[str, Any]: + """Slim a message row for the tool response. Keeps content even if empty.""" + entry = { + "id": m.get("id"), + "role": m.get("role"), + "content": m.get("content"), + "timestamp": m.get("timestamp"), + } + if m.get("tool_name"): + entry["tool_name"] = m.get("tool_name") + if m.get("tool_calls"): + entry["tool_calls"] = m.get("tool_calls") + if m.get("tool_call_id"): + entry["tool_call_id"] = m.get("tool_call_id") + if anchor_id is not None and m.get("id") == anchor_id: + entry["anchor"] = True + # Strip None values to keep payload tight, but always keep content + # (absent content is meaningful — tool-call-only assistant turns). + return {k: v for k, v in entry.items() if v is not None or k in ("content",)} def _list_recent_sessions(db, limit: int, current_session_id: str = None) -> str: - """Return metadata for the most recent sessions (no LLM calls).""" + """Return metadata for the most recent sessions (no LLM calls, no FTS5).""" try: sessions = db.list_sessions_rich( limit=limit + 5, exclude_sources=list(_HIDDEN_SESSION_SOURCES), order_by_last_active=True, - ) # fetch extra to skip current - - # Resolve current session lineage to exclude it - current_root = None - if current_session_id: - try: - sid = current_session_id - visited = set() - current_root = current_session_id - while sid and sid not in visited: - visited.add(sid) - current_root = sid - s = db.get_session(sid) - parent = s.get("parent_session_id") if s else None - sid = parent if parent else None - except Exception: - current_root = current_session_id + ) # fetch extra so we can skip current + + current_root = _resolve_to_parent(db, current_session_id) if current_session_id else None results = [] for s in sessions: sid = s.get("id", "") if current_root and (sid == current_root or sid == current_session_id): continue - # Skip child/delegation sessions (they have parent_session_id) + # Skip child / delegation sessions if s.get("parent_session_id"): continue results.append({ @@ -312,234 +140,318 @@ def _list_recent_sessions(db, limit: int, current_session_id: str = None) -> str return json.dumps({ "success": True, - "mode": "recent", + "mode": "browse", "results": results, "count": len(results), - "message": f"Showing {len(results)} most recent sessions. Use a keyword query to search specific topics.", + "message": f"Showing {len(results)} most recent sessions. Pass a query= to search, or session_id+around_message_id to scroll.", }, ensure_ascii=False) except Exception as e: logging.error("Error listing recent sessions: %s", e, exc_info=True) return tool_error(f"Failed to list recent sessions: {e}", success=False) -def session_search( - query: str, - role_filter: str = None, - limit: int = 3, - db=None, +def _scroll( + db, + session_id: str, + around_message_id: int, + window: int = 5, current_session_id: str = None, ) -> str: - """ - Search past sessions and return focused summaries of matching conversations. + """Scroll shape: return a window of messages centered on an anchor. - Uses FTS5 to find matches, then summarizes the top sessions with the - configured auxiliary session_search model. - The current session is excluded from results since the agent already has that context. + No FTS5, no bookends — just the slice. The discovery shape's lineage + fixup is preserved: if the anchor doesn't live in the named session + but does live in a child session in the same lineage, rebind silently. """ - if db is None: - try: - from hermes_state import SessionDB + if not isinstance(session_id, str) or not session_id.strip(): + return tool_error("scroll requires session_id", success=False) + session_id = session_id.strip() - db = SessionDB() - except Exception: - logging.debug("SessionDB unavailable for session_search", exc_info=True) - from hermes_state import format_session_db_unavailable - return tool_error(format_session_db_unavailable(), success=False) + try: + around_message_id = int(around_message_id) + except (TypeError, ValueError): + return tool_error("scroll requires integer around_message_id", success=False) - # Defensive: models (especially open-source) may send non-int limit values - # (None when JSON null, string "int", or even a type object). Coerce to a - # safe integer before any arithmetic/comparison to prevent TypeError. - if not isinstance(limit, int): + # Window clamp [1, 20] + if not isinstance(window, int): try: - limit = int(limit) + window = int(window) except (TypeError, ValueError): - limit = 3 - limit = max(1, min(limit, 5)) # Clamp to [1, 5] - - # Recent sessions mode: when query is empty, return metadata for recent sessions. - # No LLM calls — just DB queries for titles, previews, timestamps. - if not query or not query.strip(): - return _list_recent_sessions(db, limit, current_session_id) + window = 5 + window = max(1, min(window, 20)) + + # Reject scrolling inside the active session lineage — those messages are + # already in context. + if current_session_id: + a_root = _resolve_to_parent(db, session_id) + c_root = _resolve_to_parent(db, current_session_id) + if a_root and c_root and a_root == c_root: + return tool_error( + "scroll rejected: anchor lives in the current session lineage (already in your active context)", + success=False, + ) - query = query.strip() + # Session existence check + try: + session_meta = db.get_session(session_id) or {} + except Exception as e: + logging.debug("get_session failed for %s: %s", session_id, e, exc_info=True) + session_meta = {} + if not session_meta: + return tool_error(f"session_id not found: {session_id}", success=False) + # Fetch the window try: - # Parse role filter - role_list = None - if role_filter and role_filter.strip(): - role_list = [r.strip() for r in role_filter.split(",") if r.strip()] + view = db.get_messages_around(session_id, around_message_id, window=window) + except Exception as e: + logging.error("get_messages_around failed: %s", e, exc_info=True) + return tool_error(f"failed to load messages: {e}", success=False) + + messages = view.get("window") or [] + + # Lineage rebind: caller may have paired a parent session_id with a + # message id that lives in a descendant (compaction / delegation creates + # child sessions). Locate the real owning session and refetch. + rebind_warning = None + if not messages: + owning = None + try: + conn = getattr(db, "_conn", None) + if conn is not None: + row = conn.execute( + "SELECT session_id FROM messages WHERE id = ?", + (around_message_id,), + ).fetchone() + owning = row[0] if row else None + except Exception as e: + logging.debug("owning-session lookup failed: %s", e, exc_info=True) + owning = None + if owning and owning != session_id: + a_root = _resolve_to_parent(db, session_id) + o_root = _resolve_to_parent(db, owning) + if a_root and o_root and a_root == o_root: + try: + rebind_view = db.get_messages_around(owning, around_message_id, window=window) + messages = rebind_view.get("window") or [] + if messages: + view = rebind_view + rebind_warning = ( + f"around_message_id {around_message_id} lives in {owning} " + f"(child of {session_id}); rebound transparently" + ) + try: + session_meta = db.get_session(owning) or session_meta + except Exception: + pass + session_id = owning + except Exception as e: + logging.debug("rebind get_messages_around failed: %s", e, exc_info=True) + + if not messages: + return tool_error( + f"around_message_id {around_message_id} not in session_id {session_id}", + success=False, + ) - # FTS5 search -- get matches ranked by relevance + response = { + "success": True, + "mode": "scroll", + "session_id": session_id, + "around_message_id": around_message_id, + "session_meta": { + "when": _format_timestamp(session_meta.get("started_at")), + "source": session_meta.get("source"), + "model": session_meta.get("model"), + "title": session_meta.get("title"), + }, + "window": window, + "messages": [_shape_message(m, anchor_id=around_message_id) for m in messages], + "messages_before": view.get("messages_before", 0), + "messages_after": view.get("messages_after", 0), + } + if rebind_warning: + response["warning"] = rebind_warning + return json.dumps(response, ensure_ascii=False) + + +def _discover( + db, + query: str, + role_filter: Optional[List[str]], + limit: int, + sort: Optional[str], + current_session_id: str = None, +) -> str: + """Discovery shape: FTS5 + anchored window + bookends per hit. Single call.""" + role_list = role_filter if role_filter else ["user", "assistant"] + + try: raw_results = db.search_messages( query=query, role_filter=role_list, exclude_sources=list(_HIDDEN_SESSION_SOURCES), - limit=50, # Get more matches to find unique sessions + limit=50, # widen so dedup-by-lineage can find distinct sessions offset=0, + sort=sort, ) + except Exception as e: + logging.error("FTS5 search failed: %s", e, exc_info=True) + return tool_error(f"Search failed: {e}", success=False) - if not raw_results: - return json.dumps({ - "success": True, - "query": query, - "results": [], - "count": 0, - "message": "No matching sessions found.", - }, ensure_ascii=False) - - # Resolve child sessions to their parent — delegation stores detailed - # content in child sessions, but the user's conversation is the parent. - def _resolve_to_parent(session_id: str) -> str: - """Walk delegation chain to find the root parent session ID.""" - visited = set() - sid = session_id - while sid and sid not in visited: - visited.add(sid) - try: - session = db.get_session(sid) - if not session: - break - parent = session.get("parent_session_id") - if parent: - sid = parent - else: - break - except Exception as e: - logging.debug( - "Error resolving parent for session %s: %s", - sid, - e, - exc_info=True, - ) - break - return sid - - current_lineage_root = ( - _resolve_to_parent(current_session_id) if current_session_id else None - ) + if not raw_results: + return json.dumps({ + "success": True, + "mode": "discover", + "query": query, + "results": [], + "count": 0, + "message": "No matching sessions found.", + }, ensure_ascii=False) - # Group by resolved (parent) session_id, dedup, skip the current - # session lineage. Compression and delegation create child sessions - # that still belong to the same active conversation. - seen_sessions = {} - for result in raw_results: - raw_sid = result["session_id"] - resolved_sid = _resolve_to_parent(raw_sid) - # Skip the current session lineage — the agent already has that - # context, even if older turns live in parent fragments. - if current_lineage_root and resolved_sid == current_lineage_root: - continue - if current_session_id and raw_sid == current_session_id: - continue - if resolved_sid not in seen_sessions: - result = dict(result) - result["session_id"] = resolved_sid - seen_sessions[resolved_sid] = result - if len(seen_sessions) >= limit: - break + current_lineage_root = _resolve_to_parent(db, current_session_id) if current_session_id else None + + # Dedupe by lineage. Keep the raw owning session_id on the surviving + # row — only that pairs validly with the FTS5 match id for the anchored + # window. parent_session_id is exposed separately when different. + seen_sessions = {} + for r in raw_results: + raw_sid = r["session_id"] + resolved_sid = _resolve_to_parent(db, raw_sid) + # Skip the current session lineage + if current_lineage_root and resolved_sid == current_lineage_root: + continue + if current_session_id and raw_sid == current_session_id: + continue + if resolved_sid not in seen_sessions: + row = dict(r) + row["_lineage_root"] = resolved_sid + seen_sessions[resolved_sid] = row + if len(seen_sessions) >= limit: + break + + results = [] + for lineage_root, match_info in seen_sessions.items(): + hit_sid = match_info.get("session_id") or lineage_root + msg_id = match_info.get("id") + try: + view = db.get_anchored_view(hit_sid, msg_id, window=5, bookend=3) + except Exception as e: + logging.warning("get_anchored_view failed for %s/%s: %s", hit_sid, msg_id, e, exc_info=True) + continue + + try: + session_meta = db.get_session(lineage_root) or {} + except Exception: + session_meta = {} + + entry = { + "session_id": hit_sid, + "when": _format_timestamp( + session_meta.get("started_at") or match_info.get("session_started") + ), + "source": session_meta.get("source") or match_info.get("source", "unknown"), + "model": session_meta.get("model") or match_info.get("model") or "unknown", + "title": session_meta.get("title") or None, + "matched_role": match_info.get("role"), + "match_message_id": msg_id, + "snippet": match_info.get("snippet") or "", + "bookend_start": [_shape_message(m) for m in (view.get("bookend_start") or [])], + "messages": [_shape_message(m, anchor_id=msg_id) for m in (view.get("window") or [])], + "bookend_end": [_shape_message(m) for m in (view.get("bookend_end") or [])], + "messages_before": view.get("messages_before", 0), + "messages_after": view.get("messages_after", 0), + } + if lineage_root and lineage_root != hit_sid: + entry["parent_session_id"] = lineage_root + results.append(entry) + + return json.dumps({ + "success": True, + "mode": "discover", + "query": query, + "results": results, + "count": len(results), + "sessions_searched": len(seen_sessions), + }, ensure_ascii=False) + + +def session_search( + query: str = "", + role_filter: str = None, + limit: int = 3, + db=None, + current_session_id: str = None, + # Scroll shape + session_id: str = None, + around_message_id: int = None, + window: int = 5, + # Discovery shape + sort: str = None, +) -> str: + """Single-shape tool. Mode inferred from which args are set. - # Prepare all sessions for parallel summarization - tasks = [] - for session_id, match_info in seen_sessions.items(): - try: - messages = db.get_messages_as_conversation(session_id) - if not messages: - continue - session_meta = db.get_session(session_id) or {} - conversation_text = _format_conversation(messages) - conversation_text = _truncate_around_matches(conversation_text, query) - tasks.append((session_id, match_info, conversation_text, session_meta)) - except Exception as e: - logging.warning( - "Failed to prepare session %s: %s", - session_id, - e, - exc_info=True, - ) - - # Summarize all sessions in parallel - async def _summarize_all() -> List[Union[str, Exception]]: - """Summarize all sessions with bounded concurrency.""" - max_concurrency = min(_get_session_search_max_concurrency(), max(1, len(tasks))) - semaphore = asyncio.Semaphore(max_concurrency) - - async def _bounded_summary(text: str, meta: Dict[str, Any]) -> Optional[str]: - async with semaphore: - return await _summarize_session(text, query, meta) - - coros = [ - _bounded_summary(text, meta) - for _, _, text, meta in tasks - ] - return await asyncio.gather(*coros, return_exceptions=True) + Discovery: pass ``query``. + Scroll: pass ``session_id`` + ``around_message_id``. + Browse: pass nothing. + Scroll wins over discovery when both are set — the agent has explicitly + asked for a slice of a known session. + """ + if db is None: try: - # Use _run_async() which properly manages event loops across - # CLI, gateway, and worker-thread contexts. The previous - # pattern (asyncio.run() in a ThreadPoolExecutor) created a - # disposable event loop that conflicted with cached - # AsyncOpenAI/httpx clients bound to a different loop, - # causing deadlocks in gateway mode (#2681). - from model_tools import _run_async - results = _run_async(_summarize_all()) - except concurrent.futures.TimeoutError: - logging.warning( - "Session summarization timed out after 60 seconds", - exc_info=True, - ) - return json.dumps({ - "success": False, - "error": "Session summarization timed out. Try a more specific query or reduce the limit.", - }, ensure_ascii=False) - - summaries = [] - for (session_id, match_info, conversation_text, session_meta), result in zip(tasks, results): - if isinstance(result, Exception): - logging.warning( - "Failed to summarize session %s: %s", - session_id, result, exc_info=True, - ) - result = None - - # Prefer resolved parent session metadata over FTS5 match metadata. - # match_info carries source/model from the *child* session that contained - # the FTS5 hit; after _resolve_to_parent() the session_id points to the - # root, so session_meta has the authoritative platform/source for the - # session the user actually cares about (#15909). - entry = { - "session_id": session_id, - "when": _format_timestamp( - session_meta.get("started_at") or match_info.get("session_started") - ), - "source": session_meta.get("source") or match_info.get("source", "unknown"), - "model": session_meta.get("model") or match_info.get("model"), - } + from hermes_state import SessionDB + db = SessionDB() + except Exception: + logging.debug("SessionDB unavailable for session_search", exc_info=True) + from hermes_state import format_session_db_unavailable + return tool_error(format_session_db_unavailable(), success=False) - if result: - entry["summary"] = result - else: - # Fallback: raw preview so matched sessions aren't silently - # dropped when the summarizer is unavailable (fixes #3409). - preview = (conversation_text[:500] + "\n…[truncated]") if conversation_text else "No preview available." - entry["summary"] = f"[Raw preview — summarization unavailable]\n{preview}" + # Scroll shape takes precedence — explicit anchor beats any query. + if (isinstance(session_id, str) and session_id.strip()) and around_message_id is not None: + return _scroll( + db=db, + session_id=session_id, + around_message_id=around_message_id, + window=window, + current_session_id=current_session_id, + ) - summaries.append(entry) + # Limit clamp [1, 10] + if not isinstance(limit, int): + try: + limit = int(limit) + except (TypeError, ValueError): + limit = 3 + limit = max(1, min(limit, 10)) - return json.dumps({ - "success": True, - "query": query, - "results": summaries, - "count": len(summaries), - "sessions_searched": len(seen_sessions), - }, ensure_ascii=False) + # Browse shape: no query → recent sessions. + if not query or not isinstance(query, str) or not query.strip(): + return _list_recent_sessions(db, limit, current_session_id) - except Exception as e: - logging.error("Session search failed: %s", e, exc_info=True) - return tool_error(f"Search failed: {str(e)}", success=False) + # Parse role_filter + role_list: Optional[List[str]] = None + if isinstance(role_filter, str) and role_filter.strip(): + role_list = [r.strip() for r in role_filter.split(",") if r.strip()] + + # Normalise sort + sort_norm: Optional[str] = None + if isinstance(sort, str): + candidate = sort.strip().lower() + if candidate in ("newest", "oldest"): + sort_norm = candidate + + return _discover( + db=db, + query=query.strip(), + role_filter=role_list, + limit=limit, + sort=sort_norm, + current_session_id=current_session_id, + ) def check_session_search_requirements() -> bool: - """Requires SQLite state database and an auxiliary text model.""" + """Requires the SQLite state database.""" try: from hermes_state import DEFAULT_DB_PATH return DEFAULT_DB_PATH.parent.exists() @@ -550,44 +462,117 @@ def check_session_search_requirements() -> bool: SESSION_SEARCH_SCHEMA = { "name": "session_search", "description": ( - "Search your long-term memory of past conversations, or browse recent sessions. This is your recall -- " - "every past session is searchable, and this tool summarizes what happened.\n\n" - "TWO MODES:\n" - "1. Recent sessions (no query): Call with no arguments to see what was worked on recently. " - "Returns titles, previews, and timestamps. Zero LLM cost, instant. " - "Start here when the user asks what were we working on or what did we do recently.\n" - "2. Keyword search (with query): Search for specific topics across all past sessions. " - "Returns LLM-generated summaries of matching sessions.\n\n" - "USE THIS PROACTIVELY when:\n" - "- The user says 'we did this before', 'remember when', 'last time', 'as I mentioned'\n" - "- The user asks about a topic you worked on before but don't have in current context\n" - "- The user references a project, person, or concept that seems familiar but isn't in memory\n" - "- You want to check if you've solved a similar problem before\n" - "- The user asks 'what did we do about X?' or 'how did we fix Y?'\n\n" - "Don't hesitate to search when it is actually cross-session -- it's fast and cheap. " - "Better to search and confirm than to guess or ask the user to repeat themselves.\n\n" - "Search syntax: keywords joined with OR for broad recall (elevenlabs OR baseten OR funding), " - "phrases for exact match (\"docker networking\"), boolean (python NOT java), prefix (deploy*). " - "IMPORTANT: Use OR between keywords for best results — FTS5 defaults to AND which misses " - "sessions that only mention some terms. If a broad OR query returns nothing, try individual " - "keyword searches in parallel. Returns summaries of the top matching sessions." + "Search past sessions stored in the local session DB, or scroll inside one. " + "FTS5-backed retrieval over the SQLite message store. No LLM calls — every " + "shape returns actual messages from the DB.\n\n" + "THREE CALLING SHAPES\n\n" + " 1) DISCOVERY — pass `query`:\n" + " session_search(query=\"auth refactor\", limit=3)\n" + " Runs FTS5, dedupes hits by session lineage, returns the top N sessions. " + "Each result carries:\n" + " - session_id, title, when, source\n" + " - snippet: FTS5-highlighted match excerpt\n" + " - bookend_start: first 3 user+assistant messages of the session " + "(the goal / kickoff)\n" + " - messages: ±5 messages around the FTS5 match, with the anchor message " + "flagged (the hit in context)\n" + " - bookend_end: last 3 user+assistant messages of the session " + "(the resolution / decisions)\n" + " - match_message_id, messages_before, messages_after\n" + " Bookends + window together let you reconstruct goal → match → resolution " + "without paying for the whole transcript.\n\n" + " 2) SCROLL — pass `session_id` + `around_message_id`:\n" + " session_search(session_id=\"...\", around_message_id=12345, window=10)\n" + " Returns a window of ±`window` messages centered on the anchor. No FTS5, " + "no bookends — just the slice. Use after a discovery call when you need more " + "context than the ±5 default window.\n" + " - To scroll FORWARD: pass messages[-1].id back as around_message_id.\n" + " - To scroll BACKWARD: pass messages[0].id back as around_message_id.\n" + " - The boundary message appears in both windows — orientation marker.\n" + " - When messages_before or messages_after is < window, you're at the " + "start or end of the session.\n\n" + " 3) BROWSE — no args:\n" + " session_search()\n" + " Returns recent sessions chronologically: titles, previews, timestamps. " + "Use when the user asks \"what was I working on\" without naming a topic.\n\n" + "FTS5 SYNTAX\n\n" + " AND is the default — multi-word queries require all terms. Use OR explicitly " + "for broader recall (`alpha OR beta OR gamma`), quoted phrases for exact match " + "(`\"docker networking\"`), boolean (`python NOT java`), or prefix wildcards " + "(`deploy*`).\n\n" + "WHEN TO USE\n\n" + " Reach for this on any \"what did we do about X\" / \"where did we leave Y\" / " + "\"find the session where Z\" question — before gh, web search, or filesystem " + "inspection. The session DB carries what was said when; external tools show " + "current world state." ), "parameters": { "type": "object", "properties": { "query": { "type": "string", - "description": "Search query — keywords, phrases, or boolean expressions to find in past sessions. Omit this parameter entirely to browse recent sessions instead (returns titles, previews, timestamps with no LLM cost).", - }, - "role_filter": { - "type": "string", - "description": "Optional: only search messages from specific roles (comma-separated). E.g. 'user,assistant' to skip tool outputs.", + "description": ( + "Search query (discovery shape). Keywords, phrases, or boolean " + "expressions to find in past sessions. Omit to browse recent " + "sessions. Ignored when session_id + around_message_id are set " + "(scroll shape)." + ), }, "limit": { "type": "integer", - "description": "Max sessions to summarize (default: 3, max: 5).", + "description": ( + "Discovery shape only. Max sessions to return (default 3, max 10). " + "Bump to 5–10 when the topic likely spans several sessions and you " + "want to pick the right one to scroll into." + ), "default": 3, }, + "sort": { + "type": "string", + "enum": ["newest", "oldest"], + "description": ( + "Discovery shape only. Temporal bias on top of FTS5 ranking. Omit " + "to keep relevance-only ordering (suitable for exploratory recall — " + "\"what do we know about X\"). Set 'newest' for recency-shaped " + "questions (\"where did we leave X\"). Set 'oldest' for " + "origin-shaped questions (\"how did X start\"). Ignored in scroll " + "and browse shapes." + ), + }, + "session_id": { + "type": "string", + "description": ( + "Scroll shape. Session to read inside. Use the session_id returned " + "from a prior discovery call. Must be paired with " + "around_message_id." + ), + }, + "around_message_id": { + "type": "integer", + "description": ( + "Scroll shape. Message id to center the window on. From a discovery " + "result use match_message_id, or any id seen in a prior window. To " + "scroll forward pass the last window message's id; to scroll " + "backward pass the first." + ), + }, + "window": { + "type": "integer", + "description": ( + "Scroll shape only. Messages to return on each side of the anchor " + "(anchor itself always included). Clamped to [1, 20]. Default 5." + ), + "default": 5, + }, + "role_filter": { + "type": "string", + "description": ( + "Optional. Comma-separated roles to include. Discovery defaults to " + "'user,assistant' (tool output is usually noise). Pass " + "'user,assistant,tool' to include tool output (debugging tool " + "behaviour) or 'tool' to search tool output only." + ), + }, }, "required": [], }, @@ -605,8 +590,13 @@ def check_session_search_requirements() -> bool: query=args.get("query") or "", role_filter=args.get("role_filter"), limit=args.get("limit", 3), + session_id=args.get("session_id"), + around_message_id=args.get("around_message_id"), + window=args.get("window", 5), + sort=args.get("sort"), db=kw.get("db"), - current_session_id=kw.get("current_session_id")), + current_session_id=kw.get("current_session_id"), + ), check_fn=check_session_search_requirements, emoji="🔍", ) diff --git a/website/docs/reference/tools-reference.md b/website/docs/reference/tools-reference.md index 64cf5e2dc096..db8a8102b633 100644 --- a/website/docs/reference/tools-reference.md +++ b/website/docs/reference/tools-reference.md @@ -152,7 +152,7 @@ Registered only when the agent is spawned by the kanban dispatcher (`HERMES_KANB | Tool | Description | Requires environment | |------|-------------|----------------------| -| `session_search` | Search your long-term memory of past conversations. This is your recall -- every past session is searchable, and this tool summarizes what happened. USE THIS PROACTIVELY when: - The user says 'we did this before', 'remember when', 'last ti… | — | +| `session_search` | Search past sessions stored in the local session DB, or scroll inside one. FTS5-backed retrieval; returns actual messages from the DB (no LLM calls). Three shapes: discovery (pass `query`), scroll (pass `session_id` + `around_message_id`), browse (no args). | — | ## `skills` toolset diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index d972b38b3848..204c6d39c248 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -780,7 +780,6 @@ $ hermes model [ ] vision currently: auto / main model [ ] web_extract currently: auto / main model -[ ] session_search currently: openrouter / google/gemini-2.5-flash [ ] title_generation currently: openrouter / google/gemini-3-flash-preview [ ] compression currently: auto / main model [ ] approval currently: auto / main model @@ -862,16 +861,6 @@ auxiliary: compression: timeout: 120 # seconds — compression summarizes long conversations, needs more time - # Session search — summarizes past session matches - session_search: - provider: "auto" - model: "" - base_url: "" - api_key: "" - timeout: 30 - max_concurrency: 3 # Limit parallel summaries to reduce request-burst 429s - extra_body: {} # Provider-specific OpenAI-compatible request fields - # Skills hub — skill matching and search skills_hub: provider: "auto" @@ -909,34 +898,6 @@ Each auxiliary task has a configurable `timeout` (in seconds). Defaults: vision Context compression has its own `compression:` block for thresholds and an `auxiliary.compression:` block for model/provider settings — see [Context Compression](#context-compression) above. The fallback model uses a `fallback_model:` block — see [Fallback Model](/docs/integrations/providers#fallback-model). All three follow the same provider/model/base_url pattern. ::: -### Session Search Tuning - -If you use a reasoning-heavy model for `auxiliary.session_search`, Hermes now gives you two built-in controls: - -- `auxiliary.session_search.max_concurrency`: limits how many matched sessions Hermes summarizes at once -- `auxiliary.session_search.extra_body`: forwards provider-specific OpenAI-compatible request fields on the summarization calls - -Example: - -```yaml -auxiliary: - session_search: - provider: "main" - model: "glm-4.5-air" - timeout: 60 - max_concurrency: 2 - extra_body: - enable_thinking: false -``` - -Use `max_concurrency` when your provider rate-limits request bursts and you want `session_search` to trade some parallelism for stability. - -Use `extra_body` only when your provider documents OpenAI-compatible request-body fields you want Hermes to pass through for that task. Hermes forwards the object as-is. - -:::warning -`extra_body` is only effective when your provider actually supports the field you send. If the provider does not expose a native OpenAI-compatible reasoning-off flag, Hermes cannot synthesize one on its behalf. -::: - ### OpenRouter routing & Pareto Code for auxiliary tasks When an auxiliary task resolves to OpenRouter (either explicitly or via `provider: "main"` while your main agent is on OpenRouter), the main agent's `provider_routing` and `openrouter.min_coding_score` settings **do not propagate** — by design, each auxiliary task is independent. To set OpenRouter provider preferences or use the [Pareto Code router](/docs/integrations/providers#openrouter-pareto-code-router) for a specific aux task, set them per-task via `extra_body`: diff --git a/website/docs/user-guide/features/fallback-providers.md b/website/docs/user-guide/features/fallback-providers.md index b17102cb82e3..6ae92e3bb20e 100644 --- a/website/docs/user-guide/features/fallback-providers.md +++ b/website/docs/user-guide/features/fallback-providers.md @@ -188,7 +188,6 @@ Hermes uses separate lightweight models for side tasks. Each task has its own pr | Vision | Image analysis, browser screenshots | `auxiliary.vision` | | Web Extract | Web page summarization | `auxiliary.web_extract` | | Compression | Context compression summaries | `auxiliary.compression` | -| Session Search | Past session summarization | `auxiliary.session_search` | | Skills Hub | Skill search and discovery | `auxiliary.skills_hub` | | MCP | MCP helper operations | `auxiliary.mcp` | | Approval | Smart command-approval classification | `auxiliary.approval` | @@ -235,13 +234,6 @@ auxiliary: provider: "auto" model: "" - session_search: - provider: "auto" - model: "" - timeout: 30 - max_concurrency: 3 - extra_body: {} - skills_hub: provider: "auto" model: "" @@ -270,25 +262,6 @@ fallback_model: # base_url: http://localhost:8000/v1 # Optional custom endpoint ``` -For `auxiliary.session_search`, Hermes also supports: - -- `max_concurrency` to limit how many session summaries run at once -- `extra_body` to pass provider-specific OpenAI-compatible request fields through on the summarization calls - -Example: - -```yaml -auxiliary: - session_search: - provider: main - model: glm-4.5-air - max_concurrency: 2 - extra_body: - enable_thinking: false -``` - -If your provider does not support a native OpenAI-compatible reasoning-control field, `extra_body` will not help for that part; in that case `max_concurrency` is still useful for reducing request-burst 429s. - All three — auxiliary, compression, fallback — work the same way: set `provider` to pick who handles the request, `model` to pick which model, and `base_url` to point at a custom endpoint (overrides provider). ### Provider Options for Auxiliary Tasks @@ -432,7 +405,6 @@ See [Scheduled Tasks (Cron)](/docs/user-guide/features/cron) for full configurat | Vision | Layered (see above) + internal OpenRouter retry | `auxiliary.vision` | | Web extraction | Layered (see above) + internal OpenRouter retry | `auxiliary.web_extract` | | Context compression | Layered (see above); degrades to no-summary if all layers unavailable | `auxiliary.compression` | -| Session search | Layered (see above) | `auxiliary.session_search` | | Skills hub | Layered (see above) | `auxiliary.skills_hub` | | MCP helpers | Layered (see above) | `auxiliary.mcp` | | Approval classification | Layered (see above) | `auxiliary.approval` | From ff078738ea0108548fc9c147140942fbeab7c833 Mon Sep 17 00:00:00 2001 From: wysie Date: Mon, 18 May 2026 12:39:50 +0800 Subject: [PATCH 085/418] fix(skills): load symlinked skill slash commands --- agent/skill_commands.py | 26 ++++++++++++++++++++++++-- tests/agent/test_skill_commands.py | 26 ++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/agent/skill_commands.py b/agent/skill_commands.py index 42e7c857434f..018d84865cde 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -58,13 +58,35 @@ def _load_skill_payload(skill_identifier: str, task_id: str | None = None) -> tu try: from tools.skills_tool import SKILLS_DIR, skill_view + from agent.skill_utils import get_external_skills_dirs identifier_path = Path(raw_identifier).expanduser() if identifier_path.is_absolute(): + normalized = None + trusted_roots = [SKILLS_DIR] try: - normalized = str(identifier_path.resolve().relative_to(SKILLS_DIR.resolve())) + trusted_roots.extend(get_external_skills_dirs()) except Exception: - normalized = raw_identifier + pass + + # Prefer the lexical path under a trusted skill root before + # resolving symlinks. Slash-command discovery can legitimately + # find a skill via ~/.hermes/skills/ where is a + # symlink to a checked-out skill elsewhere. Resolving first turns + # that trusted visible path into an arbitrary absolute path that + # skill_view() refuses to load. + for root in trusted_roots: + try: + normalized = str(identifier_path.relative_to(root)) + break + except ValueError: + continue + + if normalized is None: + try: + normalized = str(identifier_path.resolve().relative_to(SKILLS_DIR.resolve())) + except Exception: + normalized = raw_identifier else: normalized = raw_identifier.lstrip("/") diff --git a/tests/agent/test_skill_commands.py b/tests/agent/test_skill_commands.py index c11976ef9786..a206348c0da5 100644 --- a/tests/agent/test_skill_commands.py +++ b/tests/agent/test_skill_commands.py @@ -4,6 +4,8 @@ from pathlib import Path from unittest.mock import patch +import pytest + import tools.skills_tool as skills_tool_module from agent.skill_commands import ( build_preloaded_skills_prompt, @@ -125,6 +127,30 @@ def test_finds_skills_in_symlinked_category_dir(self, tmp_path): assert "/knowledge-brain" in result assert result["/knowledge-brain"]["name"] == "knowledge-brain" + def test_loads_skill_invocation_from_symlinked_skill_dir(self, tmp_path): + """Slash commands should load skills symlinked under the local skills dir.""" + external_root = tmp_path / "external" + skills_root = tmp_path / "skills" + skills_root.mkdir() + real_skill_dir = _make_skill( + external_root, + "impeccable", + body="Apply impeccable design craft.", + ) + symlink_path = skills_root / "impeccable" + try: + symlink_path.symlink_to(real_skill_dir, target_is_directory=True) + except (OSError, NotImplementedError) as exc: + pytest.skip(f"symlinks unavailable in test environment: {exc}") + + with patch("tools.skills_tool.SKILLS_DIR", skills_root): + result = scan_skill_commands() + message = build_skill_invocation_message("/impeccable") + + assert "/impeccable" in result + assert message is not None + assert "Apply impeccable design craft." in message + def test_get_skill_commands_rescans_when_platform_scope_changes(self, tmp_path): """Platform-specific disabled-skill caches must not leak across platforms. From 94c523f0c5c8f717c5294f9048d02dee2774b469 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 18 May 2026 00:36:17 -0700 Subject: [PATCH 086/418] docs(session_search): update all docs for the single-shape rewrite (#27840) Companion PR to #27590. Sweeps remaining stale references to the LLM-summary path that landed in main with #27590 but weren't fully caught in the followup cleanup commit. Real rewrites: - user-guide/sessions.md: 'Session Search Tool' section rewritten to describe the three calling shapes (discovery / scroll / browse) with worked examples. Adds the 'Optional parameters' subsection covering sort and role_filter. - user-guide/features/memory.md: 'Session Search' overview rewritten, comparison table updated (speed: ms instead of LLM summarization, added explicit free-cost row, link to sessions.md for details). Stale-claim sweeps: - user-guide/configuring-models.md: drop the 'Session Search' row from the aux-model override table (no aux model anymore), drop session search from the auxiliary-models list. - user-guide/features/codex-app-server-runtime.md: drop session_search from the ChatGPT-subscription cost note, drop the session_search block from the per-task override config example. - developer-guide/provider-runtime.md: drop 'session search summarization' from the auxiliary tasks list. - developer-guide/agent-loop.md: drop session search from the auxiliary fallback chain list. - user-guide/skills/.../autonomous-ai-agents-hermes-agent.md: drop session_search from the 'auxiliary models not working' debug step. Untouched (still accurate as tool-name mentions, not behavioral claims): - features/tools.md, features/honcho.md, features/acp.md - cli.md, sessions.md (other sections) - developer-guide/tools-runtime.md, agent-loop.md (line 157) - acp-internals.md, adding-tools.md, prompt-assembly.md - reference/toolsets-reference.md, reference/tools-reference.md --- website/docs/developer-guide/agent-loop.md | 2 +- .../docs/developer-guide/provider-runtime.md | 1 - website/docs/user-guide/configuring-models.md | 3 +- .../features/codex-app-server-runtime.md | 5 +- website/docs/user-guide/features/memory.md | 8 ++- website/docs/user-guide/sessions.md | 57 ++++++++++++++++--- .../autonomous-ai-agents-hermes-agent.md | 2 +- 7 files changed, 59 insertions(+), 19 deletions(-) diff --git a/website/docs/developer-guide/agent-loop.md b/website/docs/developer-guide/agent-loop.md index cf9cb1c1efd0..fdc0cc3c8f92 100644 --- a/website/docs/developer-guide/agent-loop.md +++ b/website/docs/developer-guide/agent-loop.md @@ -194,7 +194,7 @@ When the primary model fails (429 rate limit, 5xx server error, 401/403 auth err 3. On success, continue the conversation with the new provider 4. On 401/403, attempt credential refresh before failing over -The fallback system also covers auxiliary tasks independently — vision, compression, web extraction, and session search each have their own fallback chain configurable via the `auxiliary.*` config section. +The fallback system also covers auxiliary tasks independently — vision, compression, and web extraction each have their own fallback chain configurable via the `auxiliary.*` config section. ## Compression and Persistence diff --git a/website/docs/developer-guide/provider-runtime.md b/website/docs/developer-guide/provider-runtime.md index 830382479ffd..67c86b01c295 100644 --- a/website/docs/developer-guide/provider-runtime.md +++ b/website/docs/developer-guide/provider-runtime.md @@ -150,7 +150,6 @@ Auxiliary tasks such as: - vision - web extraction summarization - context compression summaries -- session search summarization - skills hub operations - MCP helper operations - memory flushes diff --git a/website/docs/user-guide/configuring-models.md b/website/docs/user-guide/configuring-models.md index 4c12fa7e7d15..a4ce79eea3fe 100644 --- a/website/docs/user-guide/configuring-models.md +++ b/website/docs/user-guide/configuring-models.md @@ -7,7 +7,7 @@ sidebar_position: 3 Hermes uses two kinds of model slots: - **Main model** — what the agent thinks with. Every user message, every tool-call loop, every streamed response goes through this model. -- **Auxiliary models** — smaller side-jobs the agent offloads. Context compression, vision (image analysis), web-page summarization, session search, approval scoring, MCP tool routing, session-title generation, and skill search. Each has its own slot and can be overridden independently. +- **Auxiliary models** — smaller side-jobs the agent offloads. Context compression, vision (image analysis), web-page summarization, approval scoring, MCP tool routing, session-title generation, and skill search. Each has its own slot and can be overridden independently. This page covers configuring both from the dashboard. If you prefer config files or the CLI, jump to [Alternative methods](#alternative-methods) at the bottom. @@ -52,7 +52,6 @@ Every auxiliary task defaults to `auto` — meaning Hermes uses your main model | **Title Gen** | Almost always. A $0.10/M flash model writes session titles as well as Opus. Default config sets this to `google/gemini-3-flash-preview` on OpenRouter. | | **Vision** | When your main model is a coding model without vision (e.g. Kimi, DeepSeek). Point it at `google/gemini-2.5-flash` or `gpt-4o-mini`. | | **Compression** | When you're burning reasoning tokens on Opus/M2.7 just to summarize context. A fast chat model does the job at 1/50th the cost. | -| **Session Search** | When recall queries fan out — default max_concurrency is 3. A cheap model keeps the bill predictable. | | **Approval** | For `approval_mode: smart` — a fast/cheap model (haiku, flash, gpt-5-mini) decides whether to auto-approve low-risk commands. Expensive models here are waste. | | **Web Extract** | When you use `web_extract` heavily. Same logic as compression — summarization doesn't need reasoning. | | **Skills Hub** | `hermes skills search` uses this. Usually fine at `auto`. | diff --git a/website/docs/user-guide/features/codex-app-server-runtime.md b/website/docs/user-guide/features/codex-app-server-runtime.md index 575250d9b018..130e790f06e9 100644 --- a/website/docs/user-guide/features/codex-app-server-runtime.md +++ b/website/docs/user-guide/features/codex-app-server-runtime.md @@ -242,7 +242,7 @@ default_permissions = ":read-only" ## Auxiliary tasks and ChatGPT subscription token cost -When this runtime is on with the `openai-codex` provider, **auxiliary tasks (title generation, context compression, vision auto-detect, session search summarization, the background self-improvement review fork) also flow through your ChatGPT subscription by default**, because Hermes' auxiliary client uses the main provider/model when no per-task override is set. +When this runtime is on with the `openai-codex` provider, **auxiliary tasks (title generation, context compression, vision auto-detect, the background self-improvement review fork) also flow through your ChatGPT subscription by default**, because Hermes' auxiliary client uses the main provider/model when no per-task override is set. This isn't specific to `codex_app_server` — it's true for the existing `codex_responses` path too — but it's more visible here because you're explicitly opting in for the subscription billing. @@ -259,9 +259,6 @@ auxiliary: vision_detect: provider: openrouter model: google/gemini-3-flash-preview - session_search: - provider: openrouter - model: google/gemini-3-flash-preview goal_judge: provider: openrouter model: google/gemini-3-flash-preview diff --git a/website/docs/user-guide/features/memory.md b/website/docs/user-guide/features/memory.md index 77f74d28a8b4..5c07df635782 100644 --- a/website/docs/user-guide/features/memory.md +++ b/website/docs/user-guide/features/memory.md @@ -177,19 +177,23 @@ Memory entries are scanned for injection and exfiltration patterns before being Beyond MEMORY.md and USER.md, the agent can search its past conversations using the `session_search` tool: - All CLI and messaging sessions are stored in SQLite (`~/.hermes/state.db`) with FTS5 full-text search -- Search queries return relevant past conversations with Gemini Flash summarization +- Search queries return actual messages from the DB — no LLM summarization, no truncation - The agent can find things it discussed weeks ago, even if they're not in its active memory +- The agent can also scroll forward/backward inside any session it finds ```bash hermes sessions list # Browse past sessions ``` +See [Session Search Tool](/docs/user-guide/sessions#session-search-tool) for the three calling shapes (discovery / scroll / browse) and the response format. + ### session_search vs memory | Feature | Persistent Memory | Session Search | |---------|------------------|----------------| | **Capacity** | ~1,300 tokens total | Unlimited (all sessions) | -| **Speed** | Instant (in system prompt) | Requires search + LLM summarization | +| **Speed** | Instant (in system prompt) | ~20ms FTS5 query, ~1ms scroll | +| **Cost** | Token cost in every prompt | Free — no LLM calls | | **Use case** | Key facts always available | Finding specific past conversations | | **Management** | Manually curated by agent | Automatic — all sessions stored | | **Token cost** | Fixed per session (~1,300 tokens) | On-demand (searched when needed) | diff --git a/website/docs/user-guide/sessions.md b/website/docs/user-guide/sessions.md index e90c3f60bcb0..2a663bf5ace8 100644 --- a/website/docs/user-guide/sessions.md +++ b/website/docs/user-guide/sessions.md @@ -366,25 +366,66 @@ For deeper analytics — token usage, cost estimates, tool breakdown, and activi ## Session Search Tool -The agent has a built-in `session_search` tool that performs full-text search across all past conversations using SQLite's FTS5 engine. +The agent has a built-in `session_search` tool that performs full-text search across all past conversations using SQLite's FTS5 engine — and lets the agent scroll through any session it finds. No LLM calls, no summarization, no truncation. Every shape returns actual messages from the DB. -### How It Works +### Three calling shapes -1. FTS5 searches matching messages ranked by relevance -2. Groups results by session, takes the top N unique sessions (default 3) -3. Loads each session's conversation, truncates to ~100K chars centered on matches -4. Sends to a fast summarization model for focused summaries -5. Returns per-session summaries with metadata and surrounding context +The tool infers what you want from which arguments you set. There's no `mode` parameter. + +**1. Discovery — pass `query`:** + +```python +session_search(query="auth refactor", limit=3) +``` + +Runs FTS5, dedupes hits by session lineage, returns the top N sessions. Each result carries: + +- `session_id`, `title`, `when`, `source` +- `snippet` — FTS5-highlighted match excerpt +- `bookend_start` — first 3 user+assistant messages of the session (the goal/kickoff) +- `messages` — ±5 messages around the FTS5 match, with the anchor message flagged (the hit in context) +- `bookend_end` — last 3 user+assistant messages of the session (the resolution/decisions) +- `match_message_id`, `messages_before`, `messages_after` + +Bookends + window together reconstruct goal → match → resolution without paying for the whole transcript. Typical wall time: 15–50ms on a real session DB. + +**2. Scroll — pass `session_id` + `around_message_id`:** + +```python +session_search(session_id="20260510_174648_805cc2", around_message_id=590803, window=10) +``` + +Returns a window of ±`window` messages centered on the anchor. No FTS5, no bookends — just the slice. Use after a discovery call when you need more context than the ±5 default window. + +- To scroll **forward**: pass `messages[-1].id` back as `around_message_id` +- To scroll **backward**: pass `messages[0].id` back as `around_message_id` +- The boundary message appears in both windows as an orientation marker +- When `messages_before` or `messages_after` is less than `window`, you're at the start or end of the session + +Typical wall time: 1–2ms per scroll call. + +**3. Browse — no args:** + +```python +session_search() +``` + +Returns recent sessions chronologically (titles, previews, timestamps). Useful when the user asks "what was I working on" without naming a topic. ### FTS5 Query Syntax The search supports standard FTS5 query syntax: -- Simple keywords: `docker deployment` +- Simple keywords: `docker deployment` (FTS5 defaults to AND) - Phrases: `"exact phrase"` - Boolean: `docker OR kubernetes`, `python NOT java` - Prefix: `deploy*` +### Optional parameters + +- `sort` — `newest` or `oldest`, on top of FTS5 ranking. Omit for relevance-only ordering (the default; suitable for exploratory recall). Use `newest` for "where did we leave X" questions, `oldest` for "how did X start" questions. +- `role_filter` — comma-separated roles to include. Discovery defaults to `user,assistant` (tool output is usually noise). Pass `user,assistant,tool` to include tool output (debugging tool behaviour) or `tool` to search tool output only. + ### When It's Used The agent is prompted to use session search automatically: diff --git a/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md b/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md index 5f2c8d16a2ab..ec0a4a92503a 100644 --- a/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md +++ b/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md @@ -853,7 +853,7 @@ Common gateway problems: - **Windows-specific issues** (`Alt+Enter` newline, WinError 10106, UTF-8 BOM config, test suite, line endings): see the dedicated **Windows-Specific Quirks** section above. ### Auxiliary models not working -If `auxiliary` tasks (vision, compression, session_search) fail silently, the `auto` provider can't find a backend. Either set `OPENROUTER_API_KEY` or `GOOGLE_API_KEY`, or explicitly configure each auxiliary task's provider: +If `auxiliary` tasks (vision, compression) fail silently, the `auto` provider can't find a backend. Either set `OPENROUTER_API_KEY` or `GOOGLE_API_KEY`, or explicitly configure each auxiliary task's provider: ```bash hermes config set auxiliary.vision.provider hermes config set auxiliary.vision.model From 41f1eddee30a01a7b3dd2c2efad6f0e3dca681aa Mon Sep 17 00:00:00 2001 From: kshitij <82637225+kshitijk4poor@users.noreply.github.com> Date: Mon, 18 May 2026 00:45:25 -0700 Subject: [PATCH 087/418] refactor(doctor): extract section banner + fail-and-issue helpers (#27830) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hermes_cli/doctor.py` had two recurring patterns: 1. **15 section headers** of the form `print() ; print(color("◆ Name", Colors.CYAN, Colors.BOLD))` bracketed by 3-line `# =====` / `# Check: X` / `# =====` comment banners. 2. **Paired `check_fail(...) ; issues.append(...)`** for every diagnostic that emits both a user-visible failure and an auto-fix instruction. Add two helpers and collapse the patterns: def _section(title): print() print(color(f"◆ {title}", Colors.CYAN, Colors.BOLD)) def _fail_and_issue(text, detail, fix, issues): check_fail(text, detail) issues.append(fix) Replacements: - 15 `# =====/# X/# =====` banner triples + section header pairs compressed to `_section(...)` - All 18 `check_fail + issues.append` pairs collapsed to `_fail_and_issue(...)` (single-line where the call fits under 120 chars, multi-line where it doesn't) - Net -5 LOC (`+128 / -133`) The LOC delta is modest after wrapping long calls onto multi-line form for readability — the real win is uniform call shape and removal of two parallel-pattern footguns. There is now exactly one way to emit a diagnostic that pairs a user-visible failure with a fix instruction. Behavior is byte-identical. `_section` produces the same blank line + bold-cyan output the inline two prints did, and `_fail_and_issue` does the same `check_fail + issues.append` sequence in the same order. Verified empirically by diffing live `run_doctor()` stdout from this branch against `origin/main` — `diff -q` reports zero differences. Test plan: - All 69 tests across test_doctor.py, test_doctor_command_install.py, and test_doctor_dedicated_provider_skip.py pass - `ruff check hermes_cli/doctor.py` clean - Live `run_doctor()` output byte-identical to origin/main Refs #23972 (Phase 2 tracker — dedup-only refactor in line with the "net-LOC-negative" discipline). --- hermes_cli/doctor.py | 261 +++++++++++++++++++++---------------------- 1 file changed, 128 insertions(+), 133 deletions(-) diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 87043bc26115..4440b386823f 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -195,6 +195,18 @@ def check_info(text: str): print(f" {color('→', Colors.CYAN)} {text}") +def _section(title: str) -> None: + """Print a doctor section banner: blank line + bold cyan ◆ title.""" + print() + print(color(f"◆ {title}", Colors.CYAN, Colors.BOLD)) + + +def _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None: + """Emit a check_fail and append the corresponding fix instruction.""" + check_fail(text, detail) + issues.append(fix) + + def _check_gateway_service_linger(issues: list[str]) -> None: """Warn when a systemd user gateway service will stop after logout.""" try: @@ -214,9 +226,7 @@ def _check_gateway_service_linger(issues: list[str]) -> None: if not unit_path.exists(): return - print() - print(color("◆ Gateway Service", Colors.CYAN, Colors.BOLD)) - + _section("Gateway Service") linger_enabled, linger_detail = get_systemd_linger_status() if linger_enabled is True: check_ok("Systemd linger enabled", "(gateway service survives logout)") @@ -373,11 +383,7 @@ def run_doctor(args): print(color("│ 🩺 Hermes Doctor │", Colors.CYAN)) print(color("└─────────────────────────────────────────────────────────┘", Colors.CYAN)) - # ========================================================================= - # Check: Security advisories (RUNS FIRST — these are the most urgent) - # ========================================================================= - print() - print(color("◆ Security Advisories", Colors.CYAN, Colors.BOLD)) + _section("Security Advisories") try: from hermes_cli.security_advisories import ( detect_compromised, @@ -423,12 +429,7 @@ def run_doctor(args): # Never let a bug in the advisory check block the rest of doctor. check_warn(f"Security advisory check failed: {e}") - # ========================================================================= - # Check: Python version - # ========================================================================= - print() - print(color("◆ Python Environment", Colors.CYAN, Colors.BOLD)) - + _section("Python Environment") py_version = sys.version_info if py_version >= (3, 11): check_ok(f"Python {py_version.major}.{py_version.minor}.{py_version.micro}") @@ -438,8 +439,12 @@ def run_doctor(args): elif py_version >= (3, 8): check_warn(f"Python {py_version.major}.{py_version.minor}.{py_version.micro}", "(3.10+ recommended)") else: - check_fail(f"Python {py_version.major}.{py_version.minor}.{py_version.micro}", "(3.10+ required)") - issues.append("Upgrade Python to 3.10+") + _fail_and_issue( + f"Python {py_version.major}.{py_version.minor}.{py_version.micro}", + "(3.10+ required)", + "Upgrade Python to 3.10+", + issues, + ) # Check if in virtual environment in_venv = sys.prefix != sys.base_prefix @@ -448,12 +453,7 @@ def run_doctor(args): else: check_warn("Not in virtual environment", "(recommended)") - # ========================================================================= - # Check: Required packages - # ========================================================================= - print() - print(color("◆ Required Packages", Colors.CYAN, Colors.BOLD)) - + _section("Required Packages") required_packages = [ ("openai", "OpenAI SDK"), ("rich", "Rich (terminal UI)"), @@ -473,8 +473,7 @@ def run_doctor(args): __import__(module) check_ok(name) except ImportError: - check_fail(name, "(missing)") - issues.append(f"Install {name}: {_python_install_cmd()} {module}") + _fail_and_issue(name, "(missing)", f"Install {name}: {_python_install_cmd()} {module}", issues) for module, name in optional_packages: try: @@ -483,12 +482,7 @@ def run_doctor(args): except ImportError: check_warn(name, "(optional, not installed)") - # ========================================================================= - # Check: Configuration files - # ========================================================================= - print() - print(color("◆ Configuration Files", Colors.CYAN, Colors.BOLD)) - + _section("Configuration Files") # Check ~/.hermes/.env (primary location for user config) env_path = HERMES_HOME / '.env' if env_path.exists(): @@ -611,14 +605,15 @@ def run_doctor(args): and not (provider_ids_to_accept & valid_provider_ids) ): known_list = ", ".join(sorted(known_providers)) if known_providers else "(unavailable)" - check_fail( + _fail_and_issue( f"model.provider '{provider_raw}' is not a recognised provider", f"(known: {known_list})", - ) - issues.append( - f"model.provider '{provider_raw}' is unknown. " - f"Valid providers: {known_list}. " - f"Fix: run 'hermes config set model.provider '" + ( + f"model.provider '{provider_raw}' is unknown. " + f"Valid providers: {known_list}. " + f"Fix: run 'hermes config set model.provider '" + ), + issues, ) # Warn if model is set to a provider-prefixed name on a provider that doesn't use them @@ -677,14 +672,15 @@ def run_doctor(args): or status.get("api_key") ) if not configured: - check_fail( + _fail_and_issue( f"model.provider '{runtime_provider}' is set but no API key is configured", "(check ~/.hermes/.env or run 'hermes setup')", - ) - issues.append( - f"No credentials found for provider '{runtime_provider}'. " - f"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, " - f"or switch providers with 'hermes config set model.provider '" + ( + f"No credentials found for provider '{runtime_provider}'. " + f"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, " + f"or switch providers with 'hermes config set model.provider '" + ), + issues, ) except Exception: pass @@ -768,8 +764,7 @@ def run_doctor(args): from hermes_cli.config import validate_config_structure config_issues = validate_config_structure() if config_issues: - print() - print(color("◆ Config Structure", Colors.CYAN, Colors.BOLD)) + _section("Config Structure") for ci in config_issues: if ci.severity == "error": check_fail(ci.message) @@ -782,12 +777,7 @@ def run_doctor(args): except Exception: pass - # ========================================================================= - # Check: Auth providers - # ========================================================================= - print() - print(color("◆ Auth Providers", Colors.CYAN, Colors.BOLD)) - + _section("Auth Providers") try: from hermes_cli.auth import ( get_nous_auth_status, @@ -859,12 +849,7 @@ def run_doctor(args): "(optional — only required to import tokens from an existing Codex CLI login)" ) - # ========================================================================= - # Check: Directory structure - # ========================================================================= - print() - print(color("◆ Directory Structure", Colors.CYAN, Colors.BOLD)) - + _section("Directory Structure") hermes_home = HERMES_HOME if hermes_home.exists(): check_ok(f"{_DHH} directory exists") @@ -976,13 +961,8 @@ def run_doctor(args): _check_gateway_service_linger(issues) - # ========================================================================= - # Check: Command installation (hermes bin symlink) - # ========================================================================= if sys.platform != "win32": - print() - print(color("◆ Command Installation", Colors.CYAN, Colors.BOLD)) - + _section("Command Installation") # Determine the venv entry point location _venv_bin = None for _venv_name in ("venv", ".venv"): @@ -1056,12 +1036,7 @@ def run_doctor(args): else: issues.append(f"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'") - # ========================================================================= - # Check: External tools - # ========================================================================= - print() - print(color("◆ External Tools", Colors.CYAN, Colors.BOLD)) - + _section("External Tools") # Git if _safe_which("git"): check_ok("git") @@ -1087,11 +1062,14 @@ def run_doctor(args): if result is not None and result.returncode == 0: check_ok("docker", "(daemon running)") else: - check_fail("docker daemon not running") - issues.append("Start Docker daemon") + _fail_and_issue("docker daemon not running", "", "Start Docker daemon", issues) else: - check_fail("docker not found", "(required for TERMINAL_ENV=docker)") - issues.append("Install Docker or change TERMINAL_ENV") + _fail_and_issue( + "docker not found", + "(required for TERMINAL_ENV=docker)", + "Install Docker or change TERMINAL_ENV", + issues, + ) elif _safe_which("docker"): check_ok("docker", "(optional)") elif _is_termux(): @@ -1126,11 +1104,14 @@ def run_doctor(args): if result is not None and result.returncode == 0: check_ok(f"SSH connection to {ssh_host}") else: - check_fail(f"SSH connection to {ssh_host}") - issues.append(f"Check SSH configuration for {ssh_host}") + _fail_and_issue(f"SSH connection to {ssh_host}", "", f"Check SSH configuration for {ssh_host}", issues) else: - check_fail("TERMINAL_SSH_HOST not set", "(required for TERMINAL_ENV=ssh)") - issues.append("Set TERMINAL_SSH_HOST in .env") + _fail_and_issue( + "TERMINAL_SSH_HOST not set", + "(required for TERMINAL_ENV=ssh)", + "Set TERMINAL_SSH_HOST in .env", + issues, + ) # Daytona (if using daytona backend) if terminal_env == "daytona": @@ -1138,14 +1119,22 @@ def run_doctor(args): if daytona_key: check_ok("Daytona API key", "(configured)") else: - check_fail("DAYTONA_API_KEY not set", "(required for TERMINAL_ENV=daytona)") - issues.append("Set DAYTONA_API_KEY environment variable") + _fail_and_issue( + "DAYTONA_API_KEY not set", + "(required for TERMINAL_ENV=daytona)", + "Set DAYTONA_API_KEY environment variable", + issues, + ) try: from daytona import Daytona # noqa: F401 — SDK presence check check_ok("daytona SDK", "(installed)") except ImportError: - check_fail("daytona SDK not installed", "(pip install daytona)") - issues.append("Install daytona SDK: pip install daytona") + _fail_and_issue( + "daytona SDK not installed", + "(pip install daytona)", + "Install daytona SDK: pip install daytona", + issues, + ) # Vercel Sandbox (if using vercel_sandbox backend) if terminal_env == "vercel_sandbox": @@ -1155,32 +1144,50 @@ def run_doctor(args): check_ok("Vercel runtime", f"({runtime})") else: supported = ", ".join(_SUPPORTED_VERCEL_RUNTIMES) - check_fail("Vercel runtime unsupported", f"({runtime}; use {supported})") - issues.append(f"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}") + _fail_and_issue( + "Vercel runtime unsupported", + f"({runtime}; use {supported})", + f"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}", + issues, + ) disk = os.getenv("TERMINAL_CONTAINER_DISK", "51200").strip() if disk in {"", "0", "51200"}: check_ok("Vercel disk setting", "(uses platform default)") else: - check_fail("Vercel custom disk unsupported", "(reset terminal.container_disk to 51200)") - issues.append("Vercel Sandbox does not support custom container_disk; use the shared default 51200") + _fail_and_issue( + "Vercel custom disk unsupported", + "(reset terminal.container_disk to 51200)", + "Vercel Sandbox does not support custom container_disk; use the shared default 51200", + issues, + ) if importlib.util.find_spec("vercel") is not None: check_ok("vercel SDK", "(installed)") else: - check_fail("vercel SDK not installed", "(pip install 'hermes-agent[vercel]')") - issues.append("Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'") + _fail_and_issue( + "vercel SDK not installed", + "(pip install 'hermes-agent[vercel]')", + "Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'", + issues, + ) auth_status = describe_vercel_auth() if auth_status.ok: check_ok("Vercel auth", f"({auth_status.label})") elif auth_status.label.startswith("partial"): - check_fail("Vercel auth incomplete", f"({auth_status.label})") - issues.append("Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together") + _fail_and_issue( + "Vercel auth incomplete", + f"({auth_status.label})", + "Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together", + issues, + ) else: - check_fail("Vercel auth not configured", f"({auth_status.label})") - issues.append( - "Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID" + _fail_and_issue( + "Vercel auth not configured", + f"({auth_status.label})", + "Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID", + issues, ) for line in auth_status.detail_lines: check_info(f"Vercel auth {line}") @@ -1320,12 +1327,7 @@ def run_doctor(args): for note in _termux_install_all_fallback_notes(): check_info(note) - # ========================================================================= - # Check: API connectivity - # ========================================================================= - print() - print(color("◆ API Connectivity", Colors.CYAN, Colors.BOLD)) - + _section("API Connectivity") # Refactor: every connectivity probe below is HTTP-bound and fully # independent. Running them in series spent ~5s wall on a typical # workstation (2s of that was boto3's IMDS lookup for AWS credentials, @@ -1673,12 +1675,7 @@ def _probe_bedrock() -> _ConnectivityResult: for _issue in _issues_to_add: issues.append(_issue) - # ========================================================================= - # Check: Tool Availability - # ========================================================================= - print() - print(color("◆ Tool Availability", Colors.CYAN, Colors.BOLD)) - + _section("Tool Availability") try: # Add project root to path for imports sys.path.insert(0, str(PROJECT_ROOT)) @@ -1706,12 +1703,7 @@ def _probe_bedrock() -> _ConnectivityResult: except Exception as e: check_warn("Could not check tool availability", f"({e})") - # ========================================================================= - # Check: Skills Hub - # ========================================================================= - print() - print(color("◆ Skills Hub", Colors.CYAN, Colors.BOLD)) - + _section("Skills Hub") hub_dir = HERMES_HOME / "skills" / ".hub" if hub_dir.exists(): check_ok("Skills Hub directory exists") @@ -1752,12 +1744,7 @@ def _gh_authenticated() -> bool: else: check_warn("No GITHUB_TOKEN", f"(60 req/hr rate limit — set in {_DHH}/.env for better rates)") - # ========================================================================= - # Memory Provider (only check the active provider, if any) - # ========================================================================= - print() - print(color("◆ Memory Provider", Colors.CYAN, Colors.BOLD)) - + _section("Memory Provider") _active_memory_provider = "" try: import yaml as _yaml @@ -1782,8 +1769,12 @@ def _gh_authenticated() -> bool: elif not hcfg.enabled: check_info(f"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)") elif not (hcfg.api_key or hcfg.base_url): - check_fail("Honcho API key or base URL not set", "run: hermes memory setup") - issues.append("No Honcho API key — run 'hermes memory setup'") + _fail_and_issue( + "Honcho API key or base URL not set", + "run: hermes memory setup", + "No Honcho API key — run 'hermes memory setup'", + issues, + ) else: from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client reset_honcho_client() @@ -1794,11 +1785,14 @@ def _gh_authenticated() -> bool: f"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}", ) except Exception as _e: - check_fail("Honcho connection failed", str(_e)) - issues.append(f"Honcho unreachable: {_e}") + _fail_and_issue("Honcho connection failed", str(_e), f"Honcho unreachable: {_e}", issues) except ImportError: - check_fail("honcho-ai not installed", "pip install honcho-ai") - issues.append("Honcho is set as memory provider but honcho-ai is not installed") + _fail_and_issue( + "honcho-ai not installed", + "pip install honcho-ai", + "Honcho is set as memory provider but honcho-ai is not installed", + issues, + ) except Exception as _e: check_warn("Honcho check failed", str(_e)) elif _active_memory_provider == "mem0": @@ -1810,11 +1804,19 @@ def _gh_authenticated() -> bool: check_ok("Mem0 API key configured") check_info(f"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}") else: - check_fail("Mem0 API key not set", "(set MEM0_API_KEY in .env or run hermes memory setup)") - issues.append("Mem0 is set as memory provider but API key is missing") + _fail_and_issue( + "Mem0 API key not set", + "(set MEM0_API_KEY in .env or run hermes memory setup)", + "Mem0 is set as memory provider but API key is missing", + issues, + ) except ImportError: - check_fail("Mem0 plugin not loadable", "pip install mem0ai") - issues.append("Mem0 is set as memory provider but mem0ai is not installed") + _fail_and_issue( + "Mem0 plugin not loadable", + "pip install mem0ai", + "Mem0 is set as memory provider but mem0ai is not installed", + issues, + ) except Exception as _e: check_warn("Mem0 check failed", str(_e)) else: @@ -1831,17 +1833,13 @@ def _gh_authenticated() -> bool: except Exception as _e: check_warn(f"{_active_memory_provider} check failed", str(_e)) - # ========================================================================= - # Profiles - # ========================================================================= try: from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists import re as _re named_profiles = [p for p in list_profiles() if not p.is_default] if named_profiles: - print() - print(color("◆ Profiles", Colors.CYAN, Colors.BOLD)) + _section("Profiles") check_ok(f"{len(named_profiles)} profile(s) found") wrapper_dir = _get_wrapper_dir() for p in named_profiles: @@ -1878,9 +1876,6 @@ def _gh_authenticated() -> bool: except Exception: pass - # ========================================================================= - # Summary - # ========================================================================= print() remaining_issues = issues + manual_issues if should_fix and fixed_count > 0: From 0fa46c613b364e435ea8a8ea6c3cb31c1a01ab50 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 18 May 2026 01:19:16 -0700 Subject: [PATCH 088/418] fix(yuanbao): persist message_id on @bot user transcript writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Yuanbao's QuoteContextMiddleware has a transcript-lookup fallback for when quote.desc is empty: it scans the session transcript for the quoted message_id and pulls ybres anchors out of its content. That fallback works for observed (silent) group messages because the platform writer attaches message_id (yuanbao.py:2091). It silently fails for @bot agent-processed messages because gateway/run.py wrote them as {role:user, content, timestamp} with no message_id, so quoting an earlier @bot turn that contained an image/file couldn't be resolved. Fix: attach event.message_id to the user transcript entry at all three write sites in gateway/run.py — the agent_failed_early branch, the no-new-messages edge case, and the normal agent path (first user-role entry in new_messages). Surfaces gap reported in #27425 (loongfay) using the existing fallback already on main; no new caches needed. Co-authored-by: loongfay --- gateway/run.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 818bd282ddbf..623d238af366 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -8072,9 +8072,12 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g # message so the next message can load a transcript that # reflects what was said. Skip the assistant error text since # it's a gateway-generated hint, not model output. (#7100) + _user_entry = {"role": "user", "content": message_text, "timestamp": ts} + if event.message_id: + _user_entry["message_id"] = str(event.message_id) self.session_store.append_to_transcript( session_entry.session_id, - {"role": "user", "content": message_text, "timestamp": ts}, + _user_entry, ) else: history_len = agent_result.get("history_offset", len(history)) @@ -8082,9 +8085,12 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g # If no new messages found (edge case), fall back to simple user/assistant if not new_messages: + _user_entry = {"role": "user", "content": message_text, "timestamp": ts} + if event.message_id: + _user_entry["message_id"] = str(event.message_id) self.session_store.append_to_transcript( session_entry.session_id, - {"role": "user", "content": message_text, "timestamp": ts} + _user_entry, ) if response: self.session_store.append_to_transcript( @@ -8097,12 +8103,25 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g # to prevent the duplicate-write bug (#860). We still write # to JSONL for backward compatibility and as a backup. agent_persisted = self._session_db is not None + # Attach the inbound platform message_id to the first user + # entry written this turn so platform-level quote-resolution + # (e.g. Yuanbao QuoteContextMiddleware's transcript fallback) + # can find earlier @bot messages by their original message_id. + _user_msg_id_attached = False for msg in new_messages: # Skip system messages (they're rebuilt each run) if msg.get("role") == "system": continue # Add timestamp to each message for debugging entry = {**msg, "timestamp": ts} + if ( + not _user_msg_id_attached + and msg.get("role") == "user" + and event.message_id + and "message_id" not in entry + ): + entry["message_id"] = str(event.message_id) + _user_msg_id_attached = True self.session_store.append_to_transcript( session_entry.session_id, entry, skip_db=agent_persisted, From 060ec02858eb9e441da234476a2356708605fcc4 Mon Sep 17 00:00:00 2001 From: HenkDz Date: Fri, 15 May 2026 19:32:31 +0100 Subject: [PATCH 089/418] docs: add ACP Zed edit approval diffs plan --- .../2026-05-15-acp-zed-edit-approval-diffs.md | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 docs/plans/2026-05-15-acp-zed-edit-approval-diffs.md diff --git a/docs/plans/2026-05-15-acp-zed-edit-approval-diffs.md b/docs/plans/2026-05-15-acp-zed-edit-approval-diffs.md new file mode 100644 index 000000000000..4946291d4b04 --- /dev/null +++ b/docs/plans/2026-05-15-acp-zed-edit-approval-diffs.md @@ -0,0 +1,152 @@ +# ACP Zed Pre-Edit Approval Diffs Implementation Plan + +> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. + +**Goal:** Gate file mutations in ACP/Zed behind explicit pre-edit approval with a structured diff, similar to Codex/Kimi edit review behavior. + +**Architecture:** Hermes already renders edit diffs after tools run. This PR adds a pre-mutation permission gate for file mutation tools. Intercept `write_file`, `patch`, and eventually `skill_manage` before they mutate disk; compute proposed old/new content; send ACP `session/request_permission` with `kind="edit"` and diff content; only execute the mutation after approval. Rejections return a clear tool result and leave files unchanged. + +**Tech Stack:** Python, ACP `request_permission`, `FileEditToolCallContent` / `acp.tool_diff_content`, Hermes file tools, pytest with temp files. + +--- + +### Task 1: Confirm current ACP diff/permission schema + +Run: + +```bash +/home/nour/.hermes/hermes-agent/venv/bin/python - <<'PY' +from acp.schema import RequestPermissionRequest, ToolCallUpdate +import acp, inspect +print(RequestPermissionRequest.model_fields) +print(ToolCallUpdate.model_fields) +print(inspect.signature(acp.tool_diff_content)) +PY +``` + +Record actual field names. Do not rely on stale examples. + +### Task 2: Add denied-write test + +**Objective:** A rejected `write_file` must not mutate disk. + +**Files:** +- Create/modify: `tests/acp/test_edit_approval.py` + +Test shape: + +```python +def test_write_file_rejected_by_acp_permission_does_not_mutate(tmp_path): + path = tmp_path / "demo.txt" + path.write_text("old") + + # Install fake ACP edit approval callback returning reject_once. + # Invoke the same interception function that the terminal/tool path will call. + + result = maybe_gate_file_edit( + tool_name="write_file", + args={"path": str(path), "content": "new"}, + approval_requester=fake_reject, + ) + + assert path.read_text() == "old" + assert "rejected" in result.lower() +``` + +The exact function name will be created in Task 4. + +### Task 3: Add approved-write test + +**Objective:** Approved writes proceed and include diff content in permission request. + +Assert: + +- fake requester received tool call `kind == "edit"` +- content includes diff block for `demo.txt` +- after approval, file content is changed + +### Task 4: Implement edit proposal computation + +**Files:** +- Create: `acp_adapter/edit_approval.py` + +Add pure helpers first: + +```python +@dataclass +class EditProposal: + path: str + old_text: str | None + new_text: str + title: str + + +def proposal_for_write_file(args: dict[str, Any]) -> EditProposal: + path = str(args["path"]) + old_text = Path(path).read_text(encoding="utf-8") if Path(path).exists() else None + new_text = str(args.get("content", "")) + return EditProposal(path=path, old_text=old_text, new_text=new_text, title=f"Edit {path}") +``` + +For `patch`, start with replace-mode only. V4A/multi-file patches can be a second task or second PR if too risky. + +### Task 5: Implement ACP permission requester + +**Files:** +- Modify: `acp_adapter/permissions.py` or new `acp_adapter/edit_approval.py` + +Build request with: + +```python +acp.tool_diff_content(path=proposal.path, old_text=proposal.old_text, new_text=proposal.new_text) +``` + +Options: + +- allow once +- reject once +- optionally allow always/reject always only after policy storage exists + +Default deny on exception/cancel/timeout. + +### Task 6: Intercept file mutation tools before execution + +**Objective:** Ensure mutation cannot happen before approval. + +**Files:** +- Likely modify: `model_tools.py` or `acp_adapter/server.py` session-context tool wrapper + +Do not bury this inside post-execution `acp_adapter/events.py`; that is too late. + +Preferred design: + +- set an ACP session contextvar around `agent.run_conversation(...)` +- in the central tool execution path, before dispatching `write_file`/`patch`, call the ACP edit approval gate if contextvar exists +- if rejected, return a normal tool result string like `{"success": false, "error": "Edit rejected by user"}` +- if approved, continue to original tool implementation + +### Task 7: Expand patch coverage + +Add tests for: + +- `patch` replace mode approved/rejected +- creating a new file via `write_file` +- missing old string -> should fail before approval or return normal patch error, but must not mutate +- permission requester exception -> deny and no mutation + +### Task 8: Verification + +Run: + +```bash +scripts/run_tests.sh tests/acp/test_edit_approval.py tests/acp/test_events.py tests/acp/test_tools.py -q +``` + +Then run manual Zed verification: + +1. Ask Hermes ACP to edit a small file. +2. Confirm Zed shows a diff before mutation. +3. Reject and verify file unchanged. +4. Approve and verify file changed. + +**Do not merge** without manual reject-path verification. From 9592e595a26b77754e9d538ad41272e88f1b9d30 Mon Sep 17 00:00:00 2001 From: HenkDz Date: Fri, 15 May 2026 23:28:44 +0100 Subject: [PATCH 090/418] feat(acp): require approval for editor file edits --- acp_adapter/edit_approval.py | 228 ++++++++++++++++++++++++++++++++ acp_adapter/server.py | 28 +++- model_tools.py | 14 ++ tests/acp/test_edit_approval.py | 179 +++++++++++++++++++++++++ 4 files changed, 448 insertions(+), 1 deletion(-) create mode 100644 acp_adapter/edit_approval.py create mode 100644 tests/acp/test_edit_approval.py diff --git a/acp_adapter/edit_approval.py b/acp_adapter/edit_approval.py new file mode 100644 index 000000000000..ebeab0bc7ec7 --- /dev/null +++ b/acp_adapter/edit_approval.py @@ -0,0 +1,228 @@ +"""Pre-execution ACP edit approval helpers. + +This module is intentionally isolated from the generic tool registry. ACP binds +an edit approval requester in a ContextVar for the duration of one ACP agent run; +CLI, gateway, and other sessions leave it unset and therefore bypass this guard. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from concurrent.futures import TimeoutError as FutureTimeout +from contextvars import ContextVar, Token +from dataclasses import dataclass +from itertools import count +from pathlib import Path +from typing import Any, Callable + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class EditProposal: + """A proposed single-file edit that can be shown to an ACP client.""" + + tool_name: str + path: str + old_text: str | None + new_text: str + arguments: dict[str, Any] + + +EditApprovalRequester = Callable[[EditProposal], bool] + +_EDIT_APPROVAL_REQUESTER: ContextVar[EditApprovalRequester | None] = ContextVar( + "ACP_EDIT_APPROVAL_REQUESTER", + default=None, +) +_PERMISSION_REQUEST_IDS = count(1) + + +def set_edit_approval_requester(requester: EditApprovalRequester | None) -> Token: + """Bind an ACP edit approval requester for the current context.""" + + return _EDIT_APPROVAL_REQUESTER.set(requester) + + +def reset_edit_approval_requester(token: Token) -> None: + """Restore a previous edit approval requester binding.""" + + _EDIT_APPROVAL_REQUESTER.reset(token) + + +def clear_edit_approval_requester() -> None: + """Clear the current requester; primarily used by tests.""" + + _EDIT_APPROVAL_REQUESTER.set(None) + + +def get_edit_approval_requester() -> EditApprovalRequester | None: + return _EDIT_APPROVAL_REQUESTER.get() + + +def _read_text_if_exists(path: str) -> str | None: + p = Path(path).expanduser() + if not p.exists(): + return None + if not p.is_file(): + raise OSError(f"Cannot edit non-file path: {path}") + return p.read_text(encoding="utf-8", errors="replace") + + +def _proposal_for_write_file(arguments: dict[str, Any]) -> EditProposal: + path = str(arguments.get("path") or "") + if not path: + raise ValueError("path required") + content = arguments.get("content") + if content is None: + raise ValueError("content required") + return EditProposal( + tool_name="write_file", + path=path, + old_text=_read_text_if_exists(path), + new_text=str(content), + arguments=dict(arguments), + ) + + +def _proposal_for_patch_replace(arguments: dict[str, Any]) -> EditProposal: + path = str(arguments.get("path") or "") + if not path: + raise ValueError("path required") + old_string = arguments.get("old_string") + new_string = arguments.get("new_string") + if old_string is None or new_string is None: + raise ValueError("old_string and new_string required") + + old_text = _read_text_if_exists(path) + if old_text is None: + raise ValueError(f"Failed to read file: {path}") + + from tools.fuzzy_match import fuzzy_find_and_replace + + new_text, match_count, _strategy, error = fuzzy_find_and_replace( + old_text, + str(old_string), + str(new_string), + bool(arguments.get("replace_all", False)), + ) + if error or match_count == 0: + raise ValueError(error or f"Could not find match for old_string in {path}") + + return EditProposal( + tool_name="patch", + path=path, + old_text=old_text, + new_text=new_text, + arguments=dict(arguments), + ) + + +def build_edit_proposal(tool_name: str, arguments: dict[str, Any]) -> EditProposal | None: + """Return an edit proposal for supported file mutation calls.""" + + if tool_name == "write_file": + return _proposal_for_write_file(arguments) + if tool_name == "patch" and arguments.get("mode", "replace") == "replace": + return _proposal_for_patch_replace(arguments) + return None + + +def maybe_require_edit_approval(tool_name: str, arguments: dict[str, Any]) -> str | None: + """Run ACP edit approval if bound. + + Returns a JSON tool-error string when the edit must be blocked, otherwise + ``None`` so dispatch can continue. Requester exceptions deny by default. + """ + + requester = get_edit_approval_requester() + if requester is None: + return None + + try: + proposal = build_edit_proposal(tool_name, arguments) + except Exception as exc: + logger.warning("Could not build ACP edit approval proposal for %s: %s", tool_name, exc) + return json.dumps({"error": f"Edit approval denied: could not prepare diff ({exc})"}, ensure_ascii=False) + + if proposal is None: + return None + + try: + approved = bool(requester(proposal)) + except Exception as exc: + logger.warning("ACP edit approval requester failed: %s", exc) + approved = False + + if approved: + return None + return json.dumps({"error": "Edit approval denied by ACP client; file was not modified."}, ensure_ascii=False) + + +def build_acp_edit_tool_call(proposal: EditProposal): + """Build the ToolCallUpdate payload for ACP request_permission.""" + + import acp + + tool_call_id = f"edit-approval-{next(_PERMISSION_REQUEST_IDS)}" + return acp.update_tool_call( + tool_call_id, + title=f"Approve edit: {proposal.path}", + kind="edit", + status="pending", + content=[ + acp.tool_diff_content( + path=proposal.path, + old_text=proposal.old_text, + new_text=proposal.new_text, + ) + ], + raw_input={"tool": proposal.tool_name, "arguments": proposal.arguments}, + ) + + +def make_acp_edit_approval_requester( + request_permission_fn: Callable, + loop: asyncio.AbstractEventLoop, + session_id: str, + timeout: float = 60.0, +) -> EditApprovalRequester: + """Return a sync requester that bridges edit proposals to ACP permissions.""" + + def _requester(proposal: EditProposal) -> bool: + from acp.schema import PermissionOption + from agent.async_utils import safe_schedule_threadsafe + + options = [ + PermissionOption(option_id="allow_once", kind="allow_once", name="Allow edit"), + PermissionOption(option_id="deny", kind="reject_once", name="Deny"), + ] + tool_call = build_acp_edit_tool_call(proposal) + coro = request_permission_fn( + session_id=session_id, + tool_call=tool_call, + options=options, + ) + future = safe_schedule_threadsafe( + coro, + loop, + logger=logger, + log_message="Edit approval request: failed to schedule on loop", + ) + if future is None: + return False + try: + response = future.result(timeout=timeout) + except (FutureTimeout, Exception) as exc: + future.cancel() + logger.warning("Edit approval request timed out or failed: %s", exc) + return False + outcome = getattr(response, "outcome", None) + return ( + getattr(outcome, "outcome", None) == "selected" + and getattr(outcome, "option_id", None) == "allow_once" + ) + + return _requester diff --git a/acp_adapter/server.py b/acp_adapter/server.py index 3031de161fde..ebec969205ce 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -1243,6 +1243,7 @@ async def prompt( tool_call_ids: dict[str, Deque[str]] = defaultdict(deque) tool_call_meta: dict[str, dict[str, Any]] = {} previous_approval_cb = None + edit_approval_requester = None streamed_message = False @@ -1259,6 +1260,16 @@ def stream_delta_cb(text: str) -> None: message_cb(text) approval_cb = make_approval_callback(conn.request_permission, loop, session_id) + try: + from acp_adapter.edit_approval import make_acp_edit_approval_requester + + edit_approval_requester = make_acp_edit_approval_requester( + conn.request_permission, + loop, + session_id, + ) + except Exception: + logger.debug("Could not create ACP edit approval requester", exc_info=True) else: tool_progress_cb = None reasoning_cb = None @@ -1288,9 +1299,10 @@ def stream_delta_cb(text: str) -> None: # which requires a notify_cb registered in _gateway_notify_cbs. previous_approval_cb = None previous_interactive = None + edit_approval_token = None def _run_agent() -> dict: - nonlocal previous_approval_cb, previous_interactive + nonlocal previous_approval_cb, previous_interactive, edit_approval_token # Bind HERMES_SESSION_KEY for this session so per-session caches # (e.g. the interactive sudo password cache in tools.terminal_tool) # scope to the ACP session rather than leaking across sessions @@ -1314,6 +1326,13 @@ def _run_agent() -> dict: _terminal_tool.set_approval_callback(approval_cb) except Exception: logger.debug("Could not set ACP approval callback", exc_info=True) + if edit_approval_requester: + try: + from acp_adapter.edit_approval import set_edit_approval_requester + + edit_approval_token = set_edit_approval_requester(edit_approval_requester) + except Exception: + logger.debug("Could not set ACP edit approval requester", exc_info=True) # Signal to tools.approval that we have an interactive callback # and the non-interactive auto-approve path must not fire. previous_interactive = os.environ.get("HERMES_INTERACTIVE") @@ -1341,6 +1360,13 @@ def _run_agent() -> dict: _terminal_tool.set_approval_callback(previous_approval_cb) except Exception: logger.debug("Could not restore approval callback", exc_info=True) + if edit_approval_token is not None: + try: + from acp_adapter.edit_approval import reset_edit_approval_requester + + reset_edit_approval_requester(edit_approval_token) + except Exception: + logger.debug("Could not restore ACP edit approval requester", exc_info=True) if session_tokens is not None and clear_session_vars is not None: try: clear_session_vars(session_tokens) diff --git a/model_tools.py b/model_tools.py index 1cbc83096ac9..ad938b5f18bb 100644 --- a/model_tools.py +++ b/model_tools.py @@ -788,6 +788,20 @@ def handle_function_call( if block_message is not None: return json.dumps({"error": block_message}, ensure_ascii=False) + # ACP/Zed edit approval runs before any file mutation. The requester + # is bound via ContextVar only for ACP sessions, so CLI/gateway paths + # are unaffected when it is unset. + try: + from acp_adapter.edit_approval import maybe_require_edit_approval + + edit_block_message = maybe_require_edit_approval(function_name, function_args) + if edit_block_message is not None: + return edit_block_message + except Exception as _edit_approval_err: + logger.debug("ACP edit approval guard error: %s", _edit_approval_err) + if function_name in {"write_file", "patch"}: + return json.dumps({"error": "Edit approval denied: approval guard failed"}, ensure_ascii=False) + # Notify the read-loop tracker when a non-read/search tool runs, # so the *consecutive* counter resets (reads after other work are fine). if function_name not in _READ_SEARCH_TOOLS: diff --git a/tests/acp/test_edit_approval.py b/tests/acp/test_edit_approval.py new file mode 100644 index 000000000000..2d68e22045b2 --- /dev/null +++ b/tests/acp/test_edit_approval.py @@ -0,0 +1,179 @@ +"""Tests for ACP pre-edit approval gating.""" + +from __future__ import annotations + +import json + +from acp_adapter.edit_approval import ( + EditProposal, + build_acp_edit_tool_call, + clear_edit_approval_requester, + set_edit_approval_requester, +) +from model_tools import handle_function_call + + +def teardown_function() -> None: + clear_edit_approval_requester() + + +def test_acp_permission_tool_call_uses_edit_kind_and_diff_content(): + proposal = EditProposal( + tool_name="write_file", + path="demo.txt", + old_text="old\n", + new_text="new\n", + arguments={"path": "demo.txt", "content": "new\n"}, + ) + + tool_call = build_acp_edit_tool_call(proposal) + + assert tool_call.kind == "edit" + assert tool_call.status == "pending" + assert tool_call.rawInput == {"tool": "write_file", "arguments": proposal.arguments} + assert len(tool_call.content) == 1 + diff = tool_call.content[0] + assert diff.path == "demo.txt" + assert diff.oldText == "old\n" + assert diff.newText == "new\n" + + +def test_write_file_rejection_does_not_mutate_existing_file(tmp_path): + target = tmp_path / "sample.txt" + target.write_text("before\n", encoding="utf-8") + + set_edit_approval_requester(lambda _proposal: False) + + result = json.loads( + handle_function_call( + "write_file", + {"path": str(target), "content": "after\n"}, + task_id="acp-edit-reject", + ) + ) + + assert "error" in result + assert "Edit approval denied" in result["error"] + assert target.read_text(encoding="utf-8") == "before\n" + + +def test_write_file_approval_mutates_and_request_includes_diff(tmp_path): + target = tmp_path / "sample.txt" + target.write_text("before\n", encoding="utf-8") + proposals = [] + + def approve(proposal): + proposals.append(proposal) + return True + + set_edit_approval_requester(approve) + + result = json.loads( + handle_function_call( + "write_file", + {"path": str(target), "content": "after\n"}, + task_id="acp-edit-approve", + ) + ) + + assert result.get("bytes_written") == len("after\n") + assert target.read_text(encoding="utf-8") == "after\n" + assert len(proposals) == 1 + proposal = proposals[0] + assert proposal.tool_name == "write_file" + assert proposal.path == str(target) + assert proposal.old_text == "before\n" + assert proposal.new_text == "after\n" + + +def test_write_file_new_file_request_has_empty_old_text(tmp_path): + target = tmp_path / "new.txt" + proposals = [] + + set_edit_approval_requester(lambda proposal: proposals.append(proposal) or True) + + result = json.loads( + handle_function_call( + "write_file", + {"path": str(target), "content": "created\n"}, + task_id="acp-edit-new-file", + ) + ) + + assert result.get("bytes_written") == len("created\n") + assert target.read_text(encoding="utf-8") == "created\n" + assert proposals[0].old_text is None + assert proposals[0].new_text == "created\n" + + +def test_requester_exception_denies_and_does_not_mutate(tmp_path): + target = tmp_path / "sample.txt" + target.write_text("before\n", encoding="utf-8") + + def boom(_proposal): + raise RuntimeError("zed disconnected") + + set_edit_approval_requester(boom) + + result = json.loads( + handle_function_call( + "write_file", + {"path": str(target), "content": "after\n"}, + task_id="acp-edit-exception", + ) + ) + + assert "error" in result + assert "Edit approval denied" in result["error"] + assert target.read_text(encoding="utf-8") == "before\n" + + +def test_patch_replace_rejection_does_not_mutate(tmp_path): + target = tmp_path / "sample.txt" + target.write_text("alpha\nbeta\n", encoding="utf-8") + + set_edit_approval_requester(lambda _proposal: False) + + result = json.loads( + handle_function_call( + "patch", + { + "mode": "replace", + "path": str(target), + "old_string": "beta\n", + "new_string": "gamma\n", + }, + task_id="acp-patch-reject", + ) + ) + + assert "error" in result + assert "Edit approval denied" in result["error"] + assert target.read_text(encoding="utf-8") == "alpha\nbeta\n" + + +def test_patch_replace_approval_request_includes_full_file_diff(tmp_path): + target = tmp_path / "sample.txt" + target.write_text("alpha\nbeta\n", encoding="utf-8") + proposals = [] + + set_edit_approval_requester(lambda proposal: proposals.append(proposal) or True) + + result = json.loads( + handle_function_call( + "patch", + { + "mode": "replace", + "path": str(target), + "old_string": "beta\n", + "new_string": "gamma\n", + }, + task_id="acp-patch-approve", + ) + ) + + assert result.get("success") is True + assert target.read_text(encoding="utf-8") == "alpha\ngamma\n" + assert proposals[0].tool_name == "patch" + assert proposals[0].old_text == "alpha\nbeta\n" + assert proposals[0].new_text == "alpha\ngamma\n" From 49b28d1646286a1bae20afb93ee3532dfba35888 Mon Sep 17 00:00:00 2001 From: HenkDz Date: Sat, 16 May 2026 11:55:49 +0100 Subject: [PATCH 091/418] fix(acp): avoid duplicate edit approval diffs --- acp_adapter/tools.py | 15 ++++--------- tests/acp/test_mcp_e2e.py | 13 ++++------- tests/acp/test_tools.py | 45 ++++++++++++++++++--------------------- 3 files changed, 29 insertions(+), 44 deletions(-) diff --git a/acp_adapter/tools.py b/acp_adapter/tools.py index 77a62e243bcd..e9ea747324b3 100644 --- a/acp_adapter/tools.py +++ b/acp_adapter/tools.py @@ -895,7 +895,7 @@ def _build_tool_complete_content( if len(display_result) > 5000: display_result = display_result[:4900] + f"\n... ({len(result)} chars total, truncated)" - if tool_name in {"write_file", "patch", "skill_manage"}: + if tool_name == "skill_manage": try: from agent.display import extract_edit_diff @@ -936,22 +936,15 @@ def build_tool_start( if tool_name == "patch": mode = arguments.get("mode", "replace") - if mode == "replace": - path = arguments.get("path", "") - old = arguments.get("old_string", "") - new = arguments.get("new_string", "") - content = [acp.tool_diff_content(path=path, new_text=new, old_text=old)] - else: - patch_text = arguments.get("patch", "") - content = _build_patch_mode_content(patch_text) + path = arguments.get("path") or "patch input" + content = [_text(f"Preparing {mode} edit for {path}. Approval prompt shows the diff.")] return acp.start_tool_call( tool_call_id, title, kind=kind, content=content, locations=locations, ) if tool_name == "write_file": path = arguments.get("path", "") - file_content = arguments.get("content", "") - content = [acp.tool_diff_content(path=path, new_text=file_content)] + content = [_text(f"Preparing write to {path}. Approval prompt shows the diff." if path else "Preparing file write. Approval prompt shows the diff.")] return acp.start_tool_call( tool_call_id, title, kind=kind, content=content, locations=locations, ) diff --git a/tests/acp/test_mcp_e2e.py b/tests/acp/test_mcp_e2e.py index dab460719804..00bf53b21f37 100644 --- a/tests/acp/test_mcp_e2e.py +++ b/tests/acp/test_mcp_e2e.py @@ -183,7 +183,7 @@ def mock_run_conversation(user_message, conversation_history=None, task_id=None, assert "hello" in complete_event.content[0].content.text assert complete_event.raw_output is None - def test_patch_mode_tool_start_emits_diff_blocks_for_v4a_patch(self): + def test_patch_mode_tool_start_defers_diff_to_edit_approval_prompt(self): update = build_tool_start( "tc-1", "patch", @@ -193,14 +193,9 @@ def test_patch_mode_tool_start_emits_diff_blocks_for_v4a_patch(self): }, ) - assert len(update.content) == 2 - assert update.content[0].type == "diff" - assert update.content[0].path == "src/app.py" - assert update.content[0].old_text == "old line" - assert update.content[0].new_text == "new line" - assert update.content[1].type == "diff" - assert update.content[1].path == "src/new.py" - assert update.content[1].new_text == "hello" + assert len(update.content) == 1 + assert update.content[0].type == "content" + assert "Approval prompt shows the diff" in update.content[0].content.text @pytest.mark.asyncio async def test_prompt_tool_results_paired_by_call_id(self, acp_agent, mock_manager): diff --git a/tests/acp/test_tools.py b/tests/acp/test_tools.py index dc62b296c696..11a427591da3 100644 --- a/tests/acp/test_tools.py +++ b/tests/acp/test_tools.py @@ -147,7 +147,7 @@ def test_unknown_tool_uses_name(self): class TestBuildToolStart: def test_build_tool_start_for_patch(self): - """patch should produce a FileEditToolCallContent (diff).""" + """patch start should not duplicate the edit-approval diff.""" args = { "path": "src/main.py", "old_string": "print('hello')", @@ -156,24 +156,23 @@ def test_build_tool_start_for_patch(self): result = build_tool_start("tc-1", "patch", args) assert isinstance(result, ToolCallStart) assert result.kind == "edit" - # The first content item should be a diff assert len(result.content) >= 1 - diff_item = result.content[0] - assert isinstance(diff_item, FileEditToolCallContent) - assert diff_item.path == "src/main.py" - assert diff_item.new_text == "print('world')" - assert diff_item.old_text == "print('hello')" + item = result.content[0] + assert isinstance(item, ContentToolCallContent) + assert "Approval prompt shows the diff" in item.content.text + assert "src/main.py" in item.content.text def test_build_tool_start_for_write_file(self): - """write_file should produce a FileEditToolCallContent (diff).""" + """write_file start should not duplicate the edit-approval diff.""" args = {"path": "new_file.py", "content": "print('hello')"} result = build_tool_start("tc-w1", "write_file", args) assert isinstance(result, ToolCallStart) assert result.kind == "edit" assert len(result.content) >= 1 - diff_item = result.content[0] - assert isinstance(diff_item, FileEditToolCallContent) - assert diff_item.path == "new_file.py" + item = result.content[0] + assert isinstance(item, ContentToolCallContent) + assert "Approval prompt shows the diff" in item.content.text + assert "new_file.py" in item.content.text def test_build_tool_start_for_terminal(self): """terminal should produce text content with the command.""" @@ -452,8 +451,8 @@ def test_build_tool_complete_truncates_large_output(self): assert len(display_text) < 6000 assert "truncated" in display_text - def test_build_tool_complete_for_patch_uses_diff_blocks(self): - """Completed patch calls should keep structured diff content for Zed.""" + def test_build_tool_complete_for_patch_summarizes_without_repeating_diff(self): + """Completed patch calls should not duplicate the edit-approval diff.""" patch_result = ( '{"success": true, "diff": "--- a/README.md\\n+++ b/README.md\\n@@ -1 +1,2 @@\\n old line\\n+new line\\n", ' '"files_modified": ["README.md"]}' @@ -461,18 +460,17 @@ def test_build_tool_complete_for_patch_uses_diff_blocks(self): result = build_tool_complete("tc-p1", "patch", patch_result) assert isinstance(result, ToolCallProgress) assert len(result.content) == 1 - diff_item = result.content[0] - assert isinstance(diff_item, FileEditToolCallContent) - assert diff_item.path == "README.md" - assert diff_item.old_text == "old line" - assert diff_item.new_text == "old line\nnew line" + item = result.content[0] + assert isinstance(item, ContentToolCallContent) + assert "✅ patch completed" in item.content.text + assert "README.md" in item.content.text def test_build_tool_complete_for_patch_falls_back_to_text_when_no_diff(self): result = build_tool_complete("tc-p2", "patch", '{"success": true}') assert isinstance(result, ToolCallProgress) assert isinstance(result.content[0], ContentToolCallContent) - def test_build_tool_complete_for_write_file_uses_snapshot_diff(self, tmp_path): + def test_build_tool_complete_for_write_file_summarizes_without_repeating_diff(self, tmp_path): target = tmp_path / "diff-test.txt" snapshot = type("Snapshot", (), {"paths": [target], "before": {str(target): None}})() target.write_text("hello from hermes\n", encoding="utf-8") @@ -486,11 +484,10 @@ def test_build_tool_complete_for_write_file_uses_snapshot_diff(self, tmp_path): ) assert isinstance(result, ToolCallProgress) assert len(result.content) == 1 - diff_item = result.content[0] - assert isinstance(diff_item, FileEditToolCallContent) - assert diff_item.path.endswith("diff-test.txt") - assert diff_item.old_text is None - assert diff_item.new_text == "hello from hermes" + item = result.content[0] + assert isinstance(item, ContentToolCallContent) + assert "✅ write_file completed" in item.content.text + assert "diff-test.txt" in item.content.text # --------------------------------------------------------------------------- From f70e0b85dd483d1c2b37e8bebe7a4241796726d9 Mon Sep 17 00:00:00 2001 From: HenkDz Date: Sat, 16 May 2026 12:06:21 +0100 Subject: [PATCH 092/418] feat(acp): add session-scoped edit auto-approval --- acp_adapter/edit_approval.py | 50 ++++++++++++++++++++++++ acp_adapter/server.py | 68 +++++++++++++++++++++++++++++++-- tests/acp/test_edit_approval.py | 24 ++++++++++++ tests/acp/test_server.py | 29 +++++++++++++- 4 files changed, 166 insertions(+), 5 deletions(-) diff --git a/acp_adapter/edit_approval.py b/acp_adapter/edit_approval.py index ebeab0bc7ec7..7c5fcaefd22f 100644 --- a/acp_adapter/edit_approval.py +++ b/acp_adapter/edit_approval.py @@ -40,6 +40,12 @@ class EditProposal: _PERMISSION_REQUEST_IDS = count(1) +SENSITIVE_AUTO_APPROVE_NAMES = {".env", ".env.local", ".env.production", "id_rsa", "id_ed25519"} +AUTO_APPROVE_ASK = "ask" +AUTO_APPROVE_WORKSPACE = "workspace_session" +AUTO_APPROVE_SESSION = "session" + + def set_edit_approval_requester(requester: EditApprovalRequester | None) -> Token: """Bind an ACP edit approval requester for the current context.""" @@ -130,6 +136,40 @@ def build_edit_proposal(tool_name: str, arguments: dict[str, Any]) -> EditPropos return None +def _is_sensitive_auto_approve_path(path: str) -> bool: + parts = Path(path).expanduser().parts + lowered = {part.lower() for part in parts} + if ".git" in lowered or ".ssh" in lowered: + return True + return Path(path).name.lower() in SENSITIVE_AUTO_APPROVE_NAMES + + +def should_auto_approve_edit(proposal: EditProposal, policy: str, cwd: str | None = None) -> bool: + """Return whether an ACP edit proposal may bypass the prompt for this session. + + This is intentionally session-scoped and conservative: sensitive paths still + ask even under autonomous policies. + """ + + policy = str(policy or AUTO_APPROVE_ASK).strip() + if policy == AUTO_APPROVE_ASK or _is_sensitive_auto_approve_path(proposal.path): + return False + path = Path(proposal.path).expanduser().resolve(strict=False) + if policy == AUTO_APPROVE_SESSION: + return True + if policy == AUTO_APPROVE_WORKSPACE: + if str(path).startswith("/tmp/"): + return True + if cwd: + root = Path(cwd).expanduser().resolve(strict=False) + try: + path.relative_to(root) + return True + except ValueError: + return False + return False + + def maybe_require_edit_approval(tool_name: str, arguments: dict[str, Any]) -> str | None: """Run ACP edit approval if bound. @@ -188,6 +228,7 @@ def make_acp_edit_approval_requester( loop: asyncio.AbstractEventLoop, session_id: str, timeout: float = 60.0, + auto_approve_getter: Callable[[], tuple[str, str | None]] | None = None, ) -> EditApprovalRequester: """Return a sync requester that bridges edit proposals to ACP permissions.""" @@ -195,6 +236,15 @@ def _requester(proposal: EditProposal) -> bool: from acp.schema import PermissionOption from agent.async_utils import safe_schedule_threadsafe + if auto_approve_getter is not None: + try: + policy, cwd = auto_approve_getter() + if should_auto_approve_edit(proposal, policy, cwd): + logger.info("Auto-approved ACP edit under policy %s: %s", policy, proposal.path) + return True + except Exception: + logger.debug("ACP edit auto-approval policy check failed", exc_info=True) + options = [ PermissionOption(option_id="allow_once", kind="allow_once", name="Allow edit"), PermissionOption(option_id="deny", kind="reject_once", name="Deny"), diff --git a/acp_adapter/server.py b/acp_adapter/server.py index ebec969205ce..62f8eafe6fc3 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -45,6 +45,8 @@ SetSessionModeResponse, ResourceContentBlock, SessionCapabilities, + SessionConfigOptionSelect, + SessionConfigSelectOption, SessionForkCapabilities, SessionListCapabilities, SessionModelState, @@ -495,6 +497,9 @@ class HermesACPAgent(acp.Agent): }, ) + _EDIT_APPROVAL_POLICY_CONFIG_ID = "edit_approval_policy" + _EDIT_APPROVAL_POLICY_DEFAULT = "ask" + def __init__(self, session_manager: SessionManager | None = None): super().__init__() self.session_manager = session_manager or SessionManager() @@ -507,6 +512,49 @@ def on_connect(self, conn: acp.Client) -> None: self._conn = conn logger.info("ACP client connected") + + def _session_config_options(self, state: SessionState) -> list[Any]: + values = getattr(state, "config_options", None) + if not isinstance(values, dict): + values = {} + current = str(values.get(self._EDIT_APPROVAL_POLICY_CONFIG_ID) or self._EDIT_APPROVAL_POLICY_DEFAULT) + allowed = {"ask", "workspace_session", "session"} + if current not in allowed: + current = self._EDIT_APPROVAL_POLICY_DEFAULT + return [ + SessionConfigOptionSelect( + id=self._EDIT_APPROVAL_POLICY_CONFIG_ID, + name="Edit approvals", + description="Control ACP edit approvals for this session.", + category="permissions", + type="select", + current_value=current, + options=[ + SessionConfigSelectOption( + value="ask", + name="Ask before edits", + description="Require approval for every file edit.", + ), + SessionConfigSelectOption( + value="workspace_session", + name="Auto-allow workspace edits", + description="Allow workspace and /tmp edits for this session; still asks for sensitive paths.", + ), + SessionConfigSelectOption( + value="session", + name="Auto-allow all edits this session", + description="Allow file edits for this session except sensitive paths.", + ), + ], + ) + ] + + def _edit_approval_policy_for_state(self, state: SessionState) -> tuple[str, str | None]: + values = getattr(state, "config_options", None) + if not isinstance(values, dict): + values = {} + return str(values.get(self._EDIT_APPROVAL_POLICY_CONFIG_ID) or self._EDIT_APPROVAL_POLICY_DEFAULT), state.cwd + @staticmethod def _encode_model_choice(provider: str | None, model: str | None) -> str: """Encode a model selection so ACP clients can keep provider context.""" @@ -992,6 +1040,7 @@ async def new_session( return NewSessionResponse( session_id=state.session_id, models=self._build_model_state(state), + config_options=self._session_config_options(state), ) async def load_session( @@ -1033,7 +1082,10 @@ async def load_session( ) self._schedule_available_commands_update(session_id) self._schedule_usage_update(state) - return LoadSessionResponse(models=self._build_model_state(state)) + return LoadSessionResponse( + models=self._build_model_state(state), + config_options=self._session_config_options(state), + ) async def resume_session( self, @@ -1062,7 +1114,10 @@ async def resume_session( ) self._schedule_available_commands_update(state.session_id) self._schedule_usage_update(state) - return ResumeSessionResponse(models=self._build_model_state(state)) + return ResumeSessionResponse( + models=self._build_model_state(state), + config_options=self._session_config_options(state), + ) async def cancel(self, session_id: str, **kwargs: Any) -> None: state = self.session_manager.get_session(session_id) @@ -1092,7 +1147,11 @@ async def fork_session( logger.info("Forked session %s -> %s", session_id, new_id) if new_id: self._schedule_available_commands_update(new_id) - return ForkSessionResponse(session_id=new_id) + return ForkSessionResponse( + session_id=new_id, + models=self._build_model_state(state) if state is not None else None, + config_options=self._session_config_options(state) if state is not None else None, + ) async def list_sessions( self, @@ -1267,6 +1326,7 @@ def stream_delta_cb(text: str) -> None: conn.request_permission, loop, session_id, + auto_approve_getter=lambda: self._edit_approval_policy_for_state(state), ) except Exception: logger.debug("Could not create ACP edit approval requester", exc_info=True) @@ -1810,4 +1870,4 @@ async def set_config_option( setattr(state, "config_options", options) self.session_manager.save_session(session_id) logger.info("Session %s: config option %s updated", session_id, config_id) - return SetSessionConfigOptionResponse(config_options=[]) + return SetSessionConfigOptionResponse(config_options=self._session_config_options(state)) diff --git a/tests/acp/test_edit_approval.py b/tests/acp/test_edit_approval.py index 2d68e22045b2..1b6660c3b2fe 100644 --- a/tests/acp/test_edit_approval.py +++ b/tests/acp/test_edit_approval.py @@ -3,12 +3,14 @@ from __future__ import annotations import json +from pathlib import Path from acp_adapter.edit_approval import ( EditProposal, build_acp_edit_tool_call, clear_edit_approval_requester, set_edit_approval_requester, + should_auto_approve_edit, ) from model_tools import handle_function_call @@ -177,3 +179,25 @@ def test_patch_replace_approval_request_includes_full_file_diff(tmp_path): assert proposals[0].tool_name == "patch" assert proposals[0].old_text == "alpha\nbeta\n" assert proposals[0].new_text == "alpha\ngamma\n" + + +def test_workspace_auto_approval_allows_workspace_and_tmp_but_not_sensitive(tmp_path): + workspace_file = tmp_path / "src.py" + tmp_file = Path("/tmp/hermes-acp-auto-approve-test.txt") + env_file = tmp_path / ".env" + + assert should_auto_approve_edit( + EditProposal("write_file", str(workspace_file), None, "x", {}), + "workspace_session", + str(tmp_path), + ) + assert should_auto_approve_edit( + EditProposal("write_file", str(tmp_file), None, "x", {}), + "workspace_session", + str(tmp_path), + ) + assert not should_auto_approve_edit( + EditProposal("write_file", str(env_file), None, "SECRET=x", {}), + "session", + str(tmp_path), + ) diff --git a/tests/acp/test_server.py b/tests/acp/test_server.py index 65dd6fd6b725..e17d9c618cb8 100644 --- a/tests/acp/test_server.py +++ b/tests/acp/test_server.py @@ -52,6 +52,32 @@ def agent(mock_manager): """HermesACPAgent backed by a mock session manager.""" return HermesACPAgent(session_manager=mock_manager) + @pytest.mark.asyncio + async def test_new_session_includes_edit_approval_config_option(self, agent): + resp = await agent.new_session(cwd="/tmp") + + assert resp.config_options + option = resp.config_options[0] + assert option.id == "edit_approval_policy" + assert option.current_value == "ask" + assert {choice.value for choice in option.options} == { + "ask", + "workspace_session", + "session", + } + + @pytest.mark.asyncio + async def test_set_config_option_persists_edit_approval_policy(self, agent): + resp = await agent.new_session(cwd="/tmp") + update = await agent.set_config_option( + "edit_approval_policy", + resp.session_id, + "workspace_session", + ) + + assert isinstance(update, SetSessionConfigOptionResponse) + assert update.config_options[0].current_value == "workspace_session" + # --------------------------------------------------------------------------- # initialize @@ -892,7 +918,8 @@ async def test_router_accepts_stable_session_config_methods(self, agent): ) assert mode_result == {} - assert config_result == {"configOptions": []} + assert config_result["configOptions"] + assert config_result["configOptions"][0]["id"] == "edit_approval_policy" @pytest.mark.asyncio async def test_router_accepts_unstable_model_switch_when_enabled(self, agent): From 029239860426a3b12c3c7e0a7ac1a5634ff7b0ce Mon Sep 17 00:00:00 2001 From: HenkDz Date: Sat, 16 May 2026 19:15:08 +0100 Subject: [PATCH 093/418] fix(acp): use modes for edit auto-approval --- acp_adapter/events.py | 16 ++++- acp_adapter/server.py | 129 +++++++++++++++++++++++---------------- acp_adapter/tools.py | 30 +++++++-- tests/acp/test_server.py | 59 +++++++++--------- tests/acp/test_tools.py | 20 ++++++ 5 files changed, 166 insertions(+), 88 deletions(-) diff --git a/acp_adapter/events.py b/acp_adapter/events.py index 00e940b9ee0d..ab82c0e7e3d5 100644 --- a/acp_adapter/events.py +++ b/acp_adapter/events.py @@ -117,6 +117,7 @@ def make_tool_progress_cb( loop: asyncio.AbstractEventLoop, tool_call_ids: Dict[str, Deque[str]], tool_call_meta: Dict[str, Dict[str, Any]], + edit_approval_policy_getter: Callable[[], tuple[str, str | None]] | None = None, ) -> Callable: """Create a ``tool_progress_callback`` for AIAgent. @@ -162,7 +163,20 @@ def _tool_progress(event_type: str, name: str = None, preview: str = None, args: logger.debug("Failed to capture ACP edit snapshot for %s", name, exc_info=True) tool_call_meta[tc_id] = {"args": args, "snapshot": snapshot} - update = build_tool_start(tc_id, name, args) + edit_diff = None + if name in {"write_file", "patch"} and edit_approval_policy_getter is not None: + try: + from acp_adapter.edit_approval import build_edit_proposal, should_auto_approve_edit + + proposal = build_edit_proposal(name, args) + if proposal is not None: + policy, cwd = edit_approval_policy_getter() + if should_auto_approve_edit(proposal, policy, cwd): + edit_diff = proposal + except Exception: + logger.debug("Failed to prepare auto-approved ACP edit diff for %s", name, exc_info=True) + + update = build_tool_start(tc_id, name, args, edit_diff=edit_diff) _send_update(conn, session_id, loop, update) return _tool_progress diff --git a/acp_adapter/server.py b/acp_adapter/server.py index 62f8eafe6fc3..e4fc336b66aa 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -45,10 +45,10 @@ SetSessionModeResponse, ResourceContentBlock, SessionCapabilities, - SessionConfigOptionSelect, - SessionConfigSelectOption, SessionForkCapabilities, SessionListCapabilities, + SessionMode, + SessionModeState, SessionModelState, SessionResumeCapabilities, SessionInfo, @@ -499,6 +499,17 @@ class HermesACPAgent(acp.Agent): _EDIT_APPROVAL_POLICY_CONFIG_ID = "edit_approval_policy" _EDIT_APPROVAL_POLICY_DEFAULT = "ask" + _MODE_DEFAULT = "default" + _MODE_ACCEPT_EDITS = "accept_edits" + _MODE_DONT_ASK = "dont_ask" + _MODE_TO_EDIT_APPROVAL_POLICY = { + _MODE_DEFAULT: "ask", + _MODE_ACCEPT_EDITS: "workspace_session", + _MODE_DONT_ASK: "session", + } + _EDIT_APPROVAL_POLICY_TO_MODE = { + value: key for key, value in _MODE_TO_EDIT_APPROVAL_POLICY.items() + } def __init__(self, session_manager: SessionManager | None = None): super().__init__() @@ -513,47 +524,43 @@ def on_connect(self, conn: acp.Client) -> None: logger.info("ACP client connected") - def _session_config_options(self, state: SessionState) -> list[Any]: - values = getattr(state, "config_options", None) - if not isinstance(values, dict): - values = {} - current = str(values.get(self._EDIT_APPROVAL_POLICY_CONFIG_ID) or self._EDIT_APPROVAL_POLICY_DEFAULT) - allowed = {"ask", "workspace_session", "session"} - if current not in allowed: - current = self._EDIT_APPROVAL_POLICY_DEFAULT - return [ - SessionConfigOptionSelect( - id=self._EDIT_APPROVAL_POLICY_CONFIG_ID, - name="Edit approvals", - description="Control ACP edit approvals for this session.", - category="permissions", - type="select", - current_value=current, - options=[ - SessionConfigSelectOption( - value="ask", - name="Ask before edits", - description="Require approval for every file edit.", - ), - SessionConfigSelectOption( - value="workspace_session", - name="Auto-allow workspace edits", - description="Allow workspace and /tmp edits for this session; still asks for sensitive paths.", - ), - SessionConfigSelectOption( - value="session", - name="Auto-allow all edits this session", - description="Allow file edits for this session except sensitive paths.", - ), - ], - ) - ] + def _session_modes(self, state: SessionState) -> SessionModeState: + """Return ACP session modes while preserving Zed's separate model picker. + + Zed renders ``config_options`` in the prominent selector slot where the + model picker was visible. Claude/Codex expose policy-like controls as ACP + modes, which coexist with the model picker, so Hermes maps edit approval + policy onto modes instead of advertising config options. + """ + + current = str(getattr(state, "mode", "") or self._MODE_DEFAULT) + if current not in self._MODE_TO_EDIT_APPROVAL_POLICY: + current = self._MODE_DEFAULT + return SessionModeState( + current_mode_id=current, + available_modes=[ + SessionMode( + id=self._MODE_DEFAULT, + name="Default", + description="Ask before edits.", + ), + SessionMode( + id=self._MODE_ACCEPT_EDITS, + name="Accept Edits", + description="Auto-allow workspace and /tmp edits; still asks for sensitive paths.", + ), + SessionMode( + id=self._MODE_DONT_ASK, + name="Don't Ask", + description="Auto-allow file edits for this session except sensitive paths.", + ), + ], + ) def _edit_approval_policy_for_state(self, state: SessionState) -> tuple[str, str | None]: - values = getattr(state, "config_options", None) - if not isinstance(values, dict): - values = {} - return str(values.get(self._EDIT_APPROVAL_POLICY_CONFIG_ID) or self._EDIT_APPROVAL_POLICY_DEFAULT), state.cwd + mode = str(getattr(state, "mode", "") or self._MODE_DEFAULT) + policy = self._MODE_TO_EDIT_APPROVAL_POLICY.get(mode, self._EDIT_APPROVAL_POLICY_DEFAULT) + return policy, state.cwd @staticmethod def _encode_model_choice(provider: str | None, model: str | None) -> str: @@ -1040,7 +1047,7 @@ async def new_session( return NewSessionResponse( session_id=state.session_id, models=self._build_model_state(state), - config_options=self._session_config_options(state), + modes=self._session_modes(state), ) async def load_session( @@ -1084,7 +1091,7 @@ async def load_session( self._schedule_usage_update(state) return LoadSessionResponse( models=self._build_model_state(state), - config_options=self._session_config_options(state), + modes=self._session_modes(state), ) async def resume_session( @@ -1116,7 +1123,7 @@ async def resume_session( self._schedule_usage_update(state) return ResumeSessionResponse( models=self._build_model_state(state), - config_options=self._session_config_options(state), + modes=self._session_modes(state), ) async def cancel(self, session_id: str, **kwargs: Any) -> None: @@ -1150,7 +1157,7 @@ async def fork_session( return ForkSessionResponse( session_id=new_id, models=self._build_model_state(state) if state is not None else None, - config_options=self._session_config_options(state) if state is not None else None, + modes=self._session_modes(state) if state is not None else None, ) async def list_sessions( @@ -1307,7 +1314,14 @@ async def prompt( streamed_message = False if conn: - tool_progress_cb = make_tool_progress_cb(conn, session_id, loop, tool_call_ids, tool_call_meta) + tool_progress_cb = make_tool_progress_cb( + conn, + session_id, + loop, + tool_call_ids, + tool_call_meta, + edit_approval_policy_getter=lambda: self._edit_approval_policy_for_state(state), + ) reasoning_cb = make_thinking_cb(conn, session_id, loop) step_cb = make_step_cb(conn, session_id, loop, tool_call_ids, tool_call_meta) message_cb = make_message_cb(conn, session_id, loop) @@ -1849,9 +1863,12 @@ async def set_session_mode( if state is None: logger.warning("Session %s: mode switch requested for missing session", session_id) return None - setattr(state, "mode", mode_id) + normalized_mode = str(mode_id or "").strip() + if normalized_mode not in self._MODE_TO_EDIT_APPROVAL_POLICY: + normalized_mode = self._MODE_DEFAULT + setattr(state, "mode", normalized_mode) self.session_manager.save_session(session_id) - logger.info("Session %s: mode switched to %s", session_id, mode_id) + logger.info("Session %s: mode switched to %s", session_id, normalized_mode) return SetSessionModeResponse() async def set_config_option( @@ -1863,11 +1880,15 @@ async def set_config_option( logger.warning("Session %s: config update requested for missing session", session_id) return None - options = getattr(state, "config_options", None) - if not isinstance(options, dict): - options = {} - options[str(config_id)] = value - setattr(state, "config_options", options) + if str(config_id) == self._EDIT_APPROVAL_POLICY_CONFIG_ID: + mode = self._EDIT_APPROVAL_POLICY_TO_MODE.get(str(value), self._MODE_DEFAULT) + setattr(state, "mode", mode) + else: + options = getattr(state, "config_options", None) + if not isinstance(options, dict): + options = {} + options[str(config_id)] = value + setattr(state, "config_options", options) self.session_manager.save_session(session_id) logger.info("Session %s: config option %s updated", session_id, config_id) - return SetSessionConfigOptionResponse(config_options=self._session_config_options(state)) + return SetSessionConfigOptionResponse(config_options=[]) diff --git a/acp_adapter/tools.py b/acp_adapter/tools.py index e9ea747324b3..6513f1bb55f4 100644 --- a/acp_adapter/tools.py +++ b/acp_adapter/tools.py @@ -928,6 +928,8 @@ def build_tool_start( tool_call_id: str, tool_name: str, arguments: Dict[str, Any], + *, + edit_diff: Any = None, ) -> ToolCallStart: """Create a ToolCallStart event for the given hermes tool invocation.""" kind = get_tool_kind(tool_name) @@ -935,16 +937,34 @@ def build_tool_start( locations = extract_locations(arguments) if tool_name == "patch": - mode = arguments.get("mode", "replace") - path = arguments.get("path") or "patch input" - content = [_text(f"Preparing {mode} edit for {path}. Approval prompt shows the diff.")] + if edit_diff is not None: + content = [ + acp.tool_diff_content( + path=edit_diff.path, + old_text=edit_diff.old_text, + new_text=edit_diff.new_text, + ) + ] + else: + mode = arguments.get("mode", "replace") + path = arguments.get("path") or "patch input" + content = [_text(f"Preparing {mode} edit for {path}. Approval prompt shows the diff.")] return acp.start_tool_call( tool_call_id, title, kind=kind, content=content, locations=locations, ) if tool_name == "write_file": - path = arguments.get("path", "") - content = [_text(f"Preparing write to {path}. Approval prompt shows the diff." if path else "Preparing file write. Approval prompt shows the diff.")] + if edit_diff is not None: + content = [ + acp.tool_diff_content( + path=edit_diff.path, + old_text=edit_diff.old_text, + new_text=edit_diff.new_text, + ) + ] + else: + path = arguments.get("path", "") + content = [_text(f"Preparing write to {path}. Approval prompt shows the diff." if path else "Preparing file write. Approval prompt shows the diff.")] return acp.start_tool_call( tool_call_id, title, kind=kind, content=content, locations=locations, ) diff --git a/tests/acp/test_server.py b/tests/acp/test_server.py index e17d9c618cb8..79b7e56f2b67 100644 --- a/tests/acp/test_server.py +++ b/tests/acp/test_server.py @@ -24,6 +24,7 @@ PromptResponse, ResumeSessionResponse, SessionModelState, + SessionModeState, SetSessionConfigOptionResponse, SetSessionModelResponse, SetSessionModeResponse, @@ -52,31 +53,34 @@ def agent(mock_manager): """HermesACPAgent backed by a mock session manager.""" return HermesACPAgent(session_manager=mock_manager) - @pytest.mark.asyncio - async def test_new_session_includes_edit_approval_config_option(self, agent): - resp = await agent.new_session(cwd="/tmp") - - assert resp.config_options - option = resp.config_options[0] - assert option.id == "edit_approval_policy" - assert option.current_value == "ask" - assert {choice.value for choice in option.options} == { - "ask", - "workspace_session", - "session", - } - @pytest.mark.asyncio - async def test_set_config_option_persists_edit_approval_policy(self, agent): - resp = await agent.new_session(cwd="/tmp") - update = await agent.set_config_option( - "edit_approval_policy", - resp.session_id, - "workspace_session", - ) +@pytest.mark.asyncio +async def test_new_session_exposes_edit_approvals_as_modes_not_config_options(agent): + resp = await agent.new_session(cwd="/tmp") + + assert resp.config_options is None + assert isinstance(resp.modes, SessionModeState) + assert resp.modes.current_mode_id == "default" + assert [(mode.id, mode.name) for mode in resp.modes.available_modes] == [ + ("default", "Default"), + ("accept_edits", "Accept Edits"), + ("dont_ask", "Don't Ask"), + ] + + +@pytest.mark.asyncio +async def test_set_config_option_persists_edit_approval_policy_without_advertising_config(agent): + resp = await agent.new_session(cwd="/tmp") + update = await agent.set_config_option( + "edit_approval_policy", + resp.session_id, + "workspace_session", + ) + state = agent.session_manager.get_session(resp.session_id) - assert isinstance(update, SetSessionConfigOptionResponse) - assert update.config_options[0].current_value == "workspace_session" + assert isinstance(update, SetSessionConfigOptionResponse) + assert update.config_options == [] + assert getattr(state, "mode", None) == "accept_edits" # --------------------------------------------------------------------------- @@ -891,11 +895,11 @@ class TestSessionConfiguration: @pytest.mark.asyncio async def test_set_session_mode_returns_response(self, agent): new_resp = await agent.new_session(cwd="/tmp") - resp = await agent.set_session_mode(mode_id="chat", session_id=new_resp.session_id) + resp = await agent.set_session_mode(mode_id="accept_edits", session_id=new_resp.session_id) state = agent.session_manager.get_session(new_resp.session_id) assert isinstance(resp, SetSessionModeResponse) - assert getattr(state, "mode", None) == "chat" + assert getattr(state, "mode", None) == "accept_edits" @pytest.mark.asyncio async def test_router_accepts_stable_session_config_methods(self, agent): @@ -904,7 +908,7 @@ async def test_router_accepts_stable_session_config_methods(self, agent): mode_result = await router( "session/set_mode", - {"modeId": "chat", "sessionId": new_resp.session_id}, + {"modeId": "accept_edits", "sessionId": new_resp.session_id}, False, ) config_result = await router( @@ -918,8 +922,7 @@ async def test_router_accepts_stable_session_config_methods(self, agent): ) assert mode_result == {} - assert config_result["configOptions"] - assert config_result["configOptions"][0]["id"] == "edit_approval_policy" + assert config_result["configOptions"] == [] @pytest.mark.asyncio async def test_router_accepts_unstable_model_switch_when_enabled(self, agent): diff --git a/tests/acp/test_tools.py b/tests/acp/test_tools.py index 11a427591da3..004b1f32f845 100644 --- a/tests/acp/test_tools.py +++ b/tests/acp/test_tools.py @@ -2,6 +2,7 @@ import pytest +from acp_adapter.edit_approval import EditProposal from acp_adapter.tools import ( TOOL_KIND_MAP, build_tool_complete, @@ -174,6 +175,25 @@ def test_build_tool_start_for_write_file(self): assert "Approval prompt shows the diff" in item.content.text assert "new_file.py" in item.content.text + def test_auto_approved_edit_start_shows_diff_content(self): + """Auto-approved edit starts need the diff because no approval card exists.""" + args = {"path": "/tmp/acp.txt", "old_string": "old", "new_string": "new"} + result = build_tool_start( + "tc-auto-edit", + "patch", + args, + edit_diff=EditProposal("patch", "/tmp/acp.txt", "old\n", "new\n", args), + ) + + assert isinstance(result, ToolCallStart) + assert result.kind == "edit" + assert len(result.content) == 1 + item = result.content[0] + assert isinstance(item, FileEditToolCallContent) + assert item.path == "/tmp/acp.txt" + assert item.old_text == "old\n" + assert item.new_text == "new\n" + def test_build_tool_start_for_terminal(self): """terminal should produce text content with the command.""" args = {"command": "ls -la /tmp"} From 8831eb5c70e2e99cda9983919100a335a9bd86b8 Mon Sep 17 00:00:00 2001 From: qWaitCrypto Date: Fri, 15 May 2026 14:46:33 +0800 Subject: [PATCH 094/418] fix(kanban): align worker terminal timeout with task runtime --- hermes_cli/kanban_db.py | 55 ++++++++ .../test_kanban_core_functionality.py | 118 ++++++++++++++++++ 2 files changed, 173 insertions(+) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 4bd4827e386e..bad382c339ed 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -3067,6 +3067,10 @@ def set_workspace_path( # and rotates on spawn if the file is larger than this at spawn time. DEFAULT_LOG_ROTATE_BYTES = 2 * 1024 * 1024 # 2 MiB +# Keep a little wall-clock budget for the worker to observe a terminal timeout +# and call kanban_block/kanban_complete before max_runtime_seconds kills it. +KANBAN_TERMINAL_TIMEOUT_GRACE_SECONDS = 30 + @dataclass class DispatchResult: @@ -4077,6 +4081,36 @@ def _resolve_hermes_argv() -> list[str]: return [sys.executable, "-m", "hermes_cli.main"] +def _worker_terminal_timeout_env( + max_runtime_seconds: Optional[int], + current_timeout: Optional[str], +) -> Optional[str]: + """Return a worker-scoped TERMINAL_TIMEOUT override, if needed. + + Kanban's ``max_runtime_seconds`` bounds the whole worker attempt. The + terminal tool has its own default timeout via ``TERMINAL_TIMEOUT``; when + the worker runtime is longer, raise only the child process default so a + long command is not killed by the generic terminal default first. + """ + if max_runtime_seconds is None: + return None + try: + runtime = int(max_runtime_seconds) + except (TypeError, ValueError): + return None + if runtime <= 0: + return None + + desired = max(1, runtime - KANBAN_TERMINAL_TIMEOUT_GRACE_SECONDS) + try: + existing = int(str(current_timeout).strip()) if current_timeout else 0 + except (TypeError, ValueError): + existing = 0 + if existing >= desired: + return None + return str(desired) + + def _default_spawn( task: Task, workspace: str, @@ -4132,6 +4166,18 @@ def _default_spawn( env["HERMES_KANBAN_RUN_ID"] = str(task.current_run_id) if task.claim_lock: env["HERMES_KANBAN_CLAIM_LOCK"] = task.claim_lock + terminal_timeout = _worker_terminal_timeout_env( + task.max_runtime_seconds, + env.get("TERMINAL_TIMEOUT"), + ) + if terminal_timeout is not None: + env["TERMINAL_TIMEOUT"] = terminal_timeout + foreground_timeout = _worker_terminal_timeout_env( + task.max_runtime_seconds, + env.get("TERMINAL_MAX_FOREGROUND_TIMEOUT"), + ) + if foreground_timeout is not None: + env["TERMINAL_MAX_FOREGROUND_TIMEOUT"] = foreground_timeout # Pin the shared board + workspaces root the dispatcher resolved, so # that even when the worker activates a profile (`hermes -p ` # rewrites HERMES_HOME), its kanban paths still match the @@ -4322,6 +4368,15 @@ def _cap(s: Optional[str], limit: int = _CTX_MAX_FIELD_BYTES) -> str: if task.tenant: lines.append(f"Tenant: {task.tenant}") lines.append(f"Workspace: {task.workspace_kind} @ {task.workspace_path or '(unresolved)'}") + if task.max_runtime_seconds is not None: + terminal_timeout = _worker_terminal_timeout_env( + task.max_runtime_seconds, + os.environ.get("TERMINAL_TIMEOUT"), + ) + effective_terminal_timeout = terminal_timeout or os.environ.get("TERMINAL_TIMEOUT") + lines.append(f"Max runtime: {task.max_runtime_seconds}s") + if effective_terminal_timeout: + lines.append(f"Terminal timeout: {effective_terminal_timeout}s") lines.append("") if task.body and task.body.strip(): diff --git a/tests/hermes_cli/test_kanban_core_functionality.py b/tests/hermes_cli/test_kanban_core_functionality.py index 35dc7ace9513..879a74dee510 100644 --- a/tests/hermes_cli/test_kanban_core_functionality.py +++ b/tests/hermes_cli/test_kanban_core_functionality.py @@ -2679,6 +2679,124 @@ def fake_popen(cmd, **kwargs): assert env.get("HERMES_PROFILE") == "some-profile" +def test_default_spawn_raises_terminal_timeout_to_task_runtime(kanban_home, monkeypatch): + """A task runtime cap should raise the worker's terminal default. + + This is worker-scoped env only: normal CLI/gateway terminal settings stay + untouched, but long kanban tasks no longer inherit a short generic + TERMINAL_TIMEOUT that kills their foreground command first. + """ + captured = {} + + class FakeProc: + pid = 123 + + def fake_popen(cmd, **kwargs): + captured["env"] = kwargs.get("env", {}) + return FakeProc() + + monkeypatch.setattr("subprocess.Popen", fake_popen) + monkeypatch.setenv("TERMINAL_TIMEOUT", "180") + monkeypatch.delenv("TERMINAL_MAX_FOREGROUND_TIMEOUT", raising=False) + + conn = kb.connect() + try: + tid = kb.create_task( + conn, + title="long worker", + assignee="ops", + max_runtime_seconds=3600, + ) + task = kb.get_task(conn, tid) + workspace = kb.resolve_workspace(task) + kb._default_spawn(task, str(workspace)) + finally: + conn.close() + + assert captured["env"]["TERMINAL_TIMEOUT"] == "3570" + assert captured["env"]["TERMINAL_MAX_FOREGROUND_TIMEOUT"] == "3570" + assert os.environ["TERMINAL_TIMEOUT"] == "180" + + +def test_default_spawn_preserves_longer_terminal_timeout(kanban_home, monkeypatch): + """Kanban should never lower an explicitly larger terminal timeout.""" + captured = {} + + class FakeProc: + pid = 124 + + def fake_popen(cmd, **kwargs): + captured["env"] = kwargs.get("env", {}) + return FakeProc() + + monkeypatch.setattr("subprocess.Popen", fake_popen) + monkeypatch.setenv("TERMINAL_TIMEOUT", "7200") + monkeypatch.setenv("TERMINAL_MAX_FOREGROUND_TIMEOUT", "7200") + + conn = kb.connect() + try: + tid = kb.create_task( + conn, + title="already tuned", + assignee="ops", + max_runtime_seconds=3600, + ) + task = kb.get_task(conn, tid) + workspace = kb.resolve_workspace(task) + kb._default_spawn(task, str(workspace)) + finally: + conn.close() + + assert captured["env"]["TERMINAL_TIMEOUT"] == "7200" + assert captured["env"]["TERMINAL_MAX_FOREGROUND_TIMEOUT"] == "7200" + + +def test_default_spawn_leaves_terminal_timeout_without_runtime_cap(kanban_home, monkeypatch): + """Uncapped tasks keep the existing terminal timeout behavior.""" + captured = {} + + class FakeProc: + pid = 125 + + def fake_popen(cmd, **kwargs): + captured["env"] = kwargs.get("env", {}) + return FakeProc() + + monkeypatch.setattr("subprocess.Popen", fake_popen) + monkeypatch.setenv("TERMINAL_TIMEOUT", "180") + monkeypatch.delenv("TERMINAL_MAX_FOREGROUND_TIMEOUT", raising=False) + + conn = kb.connect() + try: + tid = kb.create_task(conn, title="uncapped", assignee="ops") + task = kb.get_task(conn, tid) + workspace = kb.resolve_workspace(task) + kb._default_spawn(task, str(workspace)) + finally: + conn.close() + + assert captured["env"]["TERMINAL_TIMEOUT"] == "180" + assert "TERMINAL_MAX_FOREGROUND_TIMEOUT" not in captured["env"] + + +def test_build_worker_context_includes_runtime_timeout_budget(kanban_home, monkeypatch): + monkeypatch.setenv("TERMINAL_TIMEOUT", "180") + conn = kb.connect() + try: + tid = kb.create_task( + conn, + title="long context", + assignee="ops", + max_runtime_seconds=3600, + ) + ctx = kb.build_worker_context(conn, tid) + finally: + conn.close() + + assert "Max runtime: 3600s" in ctx + assert "Terminal timeout: 3570s" in ctx + + # --------------------------------------------------------------------------- # Per-task force-loaded skills From 6e60a8a09225d7395a3bd68246a39654251d7458 Mon Sep 17 00:00:00 2001 From: qWaitCrypto <119617223+qWaitCrypto@users.noreply.github.com> Date: Thu, 14 May 2026 17:46:58 +0800 Subject: [PATCH 095/418] feat(kanban): make worker log retention configurable --- hermes_cli/config.py | 5 ++ hermes_cli/kanban_db.py | 81 ++++++++++++++++--- .../test_kanban_core_functionality.py | 27 +++++++ 3 files changed, 103 insertions(+), 10 deletions(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 6510532a7c77..84898623fb70 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1486,6 +1486,11 @@ def _ensure_hermes_home_managed(home: Path): # same task/profile (spawn_failed, timed_out, or crashed). Reassignment # resets the streak for the new profile. "failure_limit": 2, + # Worker stdout/stderr logs rotate at spawn time. Defaults preserve + # the historical 2 MiB + one-backup behavior; long-running workers can + # raise these to keep more early failure evidence. + "worker_log_rotate_bytes": 2 * 1024 * 1024, + "worker_log_backup_count": 1, # Profile that decomposes tasks in the Triage column. When unset, # falls back to the default profile (the one `hermes` launches with # no -p flag). Set this to a dedicated 'orchestrator' profile if you diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index bad382c339ed..5b5fe456c956 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -3066,6 +3066,7 @@ def set_workspace_path( # Max bytes to keep in a single worker log file. The dispatcher truncates # and rotates on spawn if the file is larger than this at spawn time. DEFAULT_LOG_ROTATE_BYTES = 2 * 1024 * 1024 # 2 MiB +DEFAULT_LOG_BACKUP_COUNT = 1 # Keep a little wall-clock budget for the worker to observe a terminal timeout # and call kanban_block/kanban_complete before max_runtime_seconds kills it. @@ -4029,25 +4030,84 @@ def dispatch_once( return result -def _rotate_worker_log(log_path: Path, max_bytes: int) -> None: - """Rotate ```` to ``.1`` if it exceeds ``max_bytes``. +def _positive_int(value: Any, default: int, *, minimum: int = 1) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + return default + return parsed if parsed >= minimum else default + - Single-generation rotation — one old file kept, newer one replaces it. - Keeps disk usage bounded while still giving the user a chance to grab - the prior run's output. +def worker_log_rotation_config(kanban_cfg: Optional[dict] = None) -> tuple[int, int]: + """Return ``(rotate_bytes, backup_count)`` for worker log rotation. + + Defaults preserve the historical behavior: rotate at 2 MiB and keep one + backup generation (``.log.1``). Operators with long-running workers can + raise either value from ``config.yaml`` without changing dispatcher code. + """ + if kanban_cfg is None: + try: + from hermes_cli.config import load_config + + kanban_cfg = (load_config().get("kanban") or {}) + except Exception: + kanban_cfg = {} + max_bytes = _positive_int( + (kanban_cfg or {}).get("worker_log_rotate_bytes"), + DEFAULT_LOG_ROTATE_BYTES, + minimum=1, + ) + backup_count = _positive_int( + (kanban_cfg or {}).get("worker_log_backup_count"), + DEFAULT_LOG_BACKUP_COUNT, + minimum=0, + ) + return max_bytes, backup_count + + +def _rotated_log_path(log_path: Path, generation: int) -> Path: + return log_path.with_suffix(log_path.suffix + f".{generation}") + + +def _rotate_worker_log( + log_path: Path, + max_bytes: int, + backup_count: int = DEFAULT_LOG_BACKUP_COUNT, +) -> None: + """Rotate ```` when it exceeds ``max_bytes``. + + ``backup_count=1`` preserves the legacy single-generation behavior: + ```` moves to ``.1`` and any previous ``.1`` is replaced. + Higher values shift older generations up to ``backup_count``. """ try: if not log_path.exists(): return if log_path.stat().st_size <= max_bytes: return - rotated = log_path.with_suffix(log_path.suffix + ".1") + backup_count = _positive_int( + backup_count, + DEFAULT_LOG_BACKUP_COUNT, + minimum=0, + ) + if backup_count == 0: + log_path.unlink() + return + oldest = _rotated_log_path(log_path, backup_count) try: - if rotated.exists(): - rotated.unlink() + if oldest.exists(): + oldest.unlink() except OSError: pass - log_path.rename(rotated) + for generation in range(backup_count - 1, 0, -1): + src = _rotated_log_path(log_path, generation) + if not src.exists(): + continue + try: + src.rename(_rotated_log_path(log_path, generation + 1)) + except OSError: + pass + log_path.rename(_rotated_log_path(log_path, 1)) except OSError: pass @@ -4232,7 +4292,8 @@ def _default_spawn( log_dir = worker_logs_dir(board=board) log_dir.mkdir(parents=True, exist_ok=True) log_path = log_dir / f"{task.id}.log" - _rotate_worker_log(log_path, DEFAULT_LOG_ROTATE_BYTES) + rotate_bytes, backup_count = worker_log_rotation_config() + _rotate_worker_log(log_path, rotate_bytes, backup_count) # Use 'a' so a re-run on unblock appends rather than overwrites. log_f = open(log_path, "ab") diff --git a/tests/hermes_cli/test_kanban_core_functionality.py b/tests/hermes_cli/test_kanban_core_functionality.py index 879a74dee510..f9e05f99ba20 100644 --- a/tests/hermes_cli/test_kanban_core_functionality.py +++ b/tests/hermes_cli/test_kanban_core_functionality.py @@ -679,6 +679,33 @@ def test_worker_log_rotation_keeps_one_generation(kanban_home, tmp_path): assert (log_dir / "t_aaaa.log.1").exists() +def test_worker_log_rotation_keeps_configured_generations(kanban_home): + log_dir = kanban_home / "kanban" / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + target = log_dir / "t_multi.log" + target.write_text("current") + (log_dir / "t_multi.log.1").write_text("one") + (log_dir / "t_multi.log.2").write_text("two") + + kb._rotate_worker_log(target, max_bytes=1, backup_count=3) + + assert not target.exists() + assert (log_dir / "t_multi.log.1").read_text() == "current" + assert (log_dir / "t_multi.log.2").read_text() == "one" + assert (log_dir / "t_multi.log.3").read_text() == "two" + + +def test_worker_log_rotation_config_defaults_and_overrides(): + assert kb.worker_log_rotation_config({}) == ( + kb.DEFAULT_LOG_ROTATE_BYTES, + kb.DEFAULT_LOG_BACKUP_COUNT, + ) + assert kb.worker_log_rotation_config({ + "worker_log_rotate_bytes": 10, + "worker_log_backup_count": 4, + }) == (10, 4) + + def test_read_worker_log_tail(kanban_home): log_dir = kanban_home / "kanban" / "logs" log_dir.mkdir(parents=True, exist_ok=True) From d9fef0c8ab308a6c4258eb1449b40a685925bd67 Mon Sep 17 00:00:00 2001 From: qWaitCrypto <119617223+qWaitCrypto@users.noreply.github.com> Date: Thu, 14 May 2026 17:07:57 +0800 Subject: [PATCH 096/418] fix(kanban): align failure diagnostics with retry limit --- hermes_cli/kanban.py | 13 +++- hermes_cli/kanban_diagnostics.py | 70 +++++++++++++++++---- plugins/kanban/dashboard/plugin_api.py | 6 ++ tests/hermes_cli/test_kanban_diagnostics.py | 60 +++++++++++++++++- 4 files changed, 134 insertions(+), 15 deletions(-) diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index 55b1d4125a2d..12e3e71e9e33 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -1393,6 +1393,11 @@ def _cmd_diagnostics(args: argparse.Namespace) -> int: the dashboard uses, so CLI output matches what the UI shows. """ from hermes_cli import kanban_diagnostics as kd + from hermes_cli.config import load_config + + diag_config = kd.config_from_kanban_config( + (load_config().get("kanban") or {}) + ) with kb.connect() as conn: # Either one-task mode or fleet mode. @@ -1406,6 +1411,7 @@ def _cmd_diagnostics(args: argparse.Namespace) -> int: task, kb.list_events(conn, args.task), kb.list_runs(conn, args.task), + config=diag_config, ) } else: @@ -1433,7 +1439,12 @@ def _cmd_diagnostics(args: argparse.Namespace) -> int: diags_by_task = {} for r in rows: tid = r["id"] - dl = kd.compute_task_diagnostics(r, ev_by.get(tid, []), run_by.get(tid, [])) + dl = kd.compute_task_diagnostics( + r, + ev_by.get(tid, []), + run_by.get(tid, []), + config=diag_config, + ) if dl: diags_by_task[tid] = dl diff --git a/hermes_cli/kanban_diagnostics.py b/hermes_cli/kanban_diagnostics.py index 42c0c2043f21..2f8b7c8ed021 100644 --- a/hermes_cli/kanban_diagnostics.py +++ b/hermes_cli/kanban_diagnostics.py @@ -230,6 +230,14 @@ def _generic_recovery_actions(task: Any, *, running: bool) -> list[DiagnosticAct RuleFn = Callable[[Any, list[Any], list[Any], int, dict], list[Diagnostic]] +def _positive_int(value: Any, default: int) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + return default + return parsed if parsed >= 1 else default + + def _rule_hallucinated_cards(task, events, runs, now, cfg) -> list[Diagnostic]: """Blocked-hallucination gate fires: a worker called kanban_complete with created_cards that didn't exist or weren't created by the @@ -319,18 +327,19 @@ def _rule_repeated_failures(task, events, runs, now, cfg) -> list[Diagnostic]: all look the same: the kernel keeps retrying and the operator needs to intervene. - Threshold: cfg["failure_threshold"] (default 3). A threshold of 3 - is one below the circuit-breaker's default (5), so the diagnostic - surfaces BEFORE the breaker trips — giving operators a window to - fix the problem while the dispatcher's still retrying. + Threshold: cfg["failure_threshold"]. Runtime callers should derive + this from ``kanban.failure_limit`` unless the user explicitly set a + diagnostics threshold, so the signal does not lag behind the + dispatcher's circuit breaker. Accepts the legacy ``spawn_failure_threshold`` config key for back-compat. """ - threshold = int(cfg.get( + threshold = _positive_int(cfg.get( "failure_threshold", cfg.get("spawn_failure_threshold", 3), - )) + ), 3) + failure_limit = _positive_int(cfg.get("failure_limit"), threshold) # Read the new unified counter name, with a fallback to the legacy # column name so this rule keeps working against old DB rows the # caller somehow materialised without running the migration. @@ -402,10 +411,9 @@ def _rule_repeated_failures(task, events, runs, now, cfg) -> list[Diagnostic]: f"This task has failed {failures} times in a row " f"(most recent: {outcome_label}). Full last error:\n\n" f"{err_snippet}\n\n" - f"The dispatcher will keep retrying until the consecutive-" - f"failures counter trips the circuit breaker (default 5), " - f"at which point the task auto-blocks. Fix the root cause " - f"and reclaim to retry." + f"The dispatcher circuit breaker is configured for " + f"{failure_limit} consecutive non-success attempts. Fix the " + f"root cause and reclaim or unblock the task to retry." ) else: title = f"Agent {outcome_label} x{failures} (no error recorded)" @@ -427,6 +435,8 @@ def _rule_repeated_failures(task, events, runs, now, cfg) -> list[Diagnostic]: "consecutive_failures": failures, "most_recent_outcome": most_recent_outcome, "last_error": last_err, + "failure_threshold": threshold, + "failure_limit": failure_limit, }, )] @@ -716,9 +726,11 @@ def _rule_stranded_in_ready(task, events, runs, now, cfg) -> list[Diagnostic]: DEFAULT_CONFIG = { - "failure_threshold": 3, + # Match the dispatcher default (kanban.failure_limit) so repeated-failure + # diagnostics do not lag behind the default auto-block threshold. + "failure_threshold": 2, # Legacy alias accepted at read time by _rule_repeated_failures. - "spawn_failure_threshold": 3, + "spawn_failure_threshold": 2, "crash_threshold": 2, "blocked_stale_hours": 24, # Stranded-task threshold. 30 min by default — below that, the @@ -728,6 +740,28 @@ def _rule_stranded_in_ready(task, events, runs, now, cfg) -> list[Diagnostic]: } +def config_from_kanban_config(kanban_cfg: Optional[dict]) -> dict: + """Build diagnostics config from the runtime ``kanban`` config section. + + ``kanban.diagnostics.failure_threshold`` remains an explicit override. + Otherwise, derive the repeated-failure threshold from + ``kanban.failure_limit`` so CLI/dashboard diagnostics match the + dispatcher's actual circuit-breaker threshold. + """ + kanban_cfg = kanban_cfg or {} + diag_cfg = dict(kanban_cfg.get("diagnostics") or {}) + diag_cfg.setdefault( + "failure_limit", + kanban_cfg.get("failure_limit", DEFAULT_CONFIG["failure_threshold"]), + ) + if ( + "failure_threshold" not in diag_cfg + and "spawn_failure_threshold" not in diag_cfg + ): + diag_cfg["failure_threshold"] = diag_cfg["failure_limit"] + return diag_cfg + + def compute_task_diagnostics( task, events: list, @@ -743,7 +777,17 @@ def compute_task_diagnostics( most-recent ``last_seen_at``. """ now_ts = int(now if now is not None else time.time()) - cfg = {**DEFAULT_CONFIG, **(config or {})} + config = config or {} + cfg = {**DEFAULT_CONFIG, **config} + if ( + "failure_threshold" not in config + and "spawn_failure_threshold" not in config + and "failure_limit" in config + ): + cfg["failure_threshold"] = _positive_int( + config.get("failure_limit"), + DEFAULT_CONFIG["failure_threshold"], + ) out: list[Diagnostic] = [] for rule in _RULES: try: diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index 16e606638549..0a4685b4a54b 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -224,6 +224,11 @@ def _compute_task_diagnostics( rule definitions. """ from hermes_cli import kanban_diagnostics as kd + from hermes_cli.config import load_config + + diag_config = kd.config_from_kanban_config( + (load_config().get("kanban") or {}) + ) # Build the candidate task list. We need each task's row + its # events + its runs. Doing N separate queries works but scales @@ -270,6 +275,7 @@ def _compute_task_diagnostics( r, events_by_task.get(tid, []), runs_by_task.get(tid, []), + config=diag_config, ) if diags: out[tid] = [d.to_dict() for d in diags] diff --git a/tests/hermes_cli/test_kanban_diagnostics.py b/tests/hermes_cli/test_kanban_diagnostics.py index ad00e4136a80..53fdf4fc34b4 100644 --- a/tests/hermes_cli/test_kanban_diagnostics.py +++ b/tests/hermes_cli/test_kanban_diagnostics.py @@ -177,10 +177,68 @@ def test_repeated_failures_escalates_to_critical(): def test_repeated_failures_below_threshold_silent(): - task = _task(consecutive_failures=2) + task = _task(consecutive_failures=1) assert kd.compute_task_diagnostics(task, [], []) == [] +def test_repeated_failures_default_matches_dispatcher_failure_limit(): + """Default dispatcher auto-blocks at 2 failures, so diagnostics must + also surface at 2 instead of waiting for the stale threshold of 3. + """ + task = _task(status="blocked", consecutive_failures=2, + last_failure_error="elapsed 600s > limit 300s") + runs = [_run(outcome="timed_out", run_id=1)] + diags = kd.compute_task_diagnostics(task, [], runs) + repeated = [d for d in diags if d.kind == "repeated_failures"] + assert len(repeated) == 1 + d = repeated[0] + assert d.data["failure_threshold"] == 2 + assert d.data["failure_limit"] == 2 + assert "default 5" not in d.detail + assert "configured for 2" in d.detail + + +def test_repeated_failures_derives_threshold_from_kanban_failure_limit(): + task = _task(status="ready", consecutive_failures=2, + last_failure_error="Profile 'debugger' does not exist") + runs = [_run(outcome="spawn_failed", run_id=1)] + assert kd.compute_task_diagnostics( + task, [], runs, config={"failure_limit": 4} + ) == [] + + task = _task(status="blocked", consecutive_failures=4, + last_failure_error="Profile 'debugger' does not exist") + diags = kd.compute_task_diagnostics( + task, [], runs, config={"failure_limit": 4} + ) + repeated = [d for d in diags if d.kind == "repeated_failures"] + assert len(repeated) == 1 + assert repeated[0].data["failure_threshold"] == 4 + assert repeated[0].data["failure_limit"] == 4 + + +def test_repeated_failures_explicit_threshold_overrides_failure_limit(): + task = _task(status="ready", consecutive_failures=3, + last_failure_error="Profile 'debugger' does not exist") + runs = [_run(outcome="spawn_failed", run_id=1)] + diags = kd.compute_task_diagnostics( + task, [], runs, config={"failure_limit": 5, "failure_threshold": 3} + ) + repeated = [d for d in diags if d.kind == "repeated_failures"] + assert len(repeated) == 1 + assert repeated[0].data["failure_threshold"] == 3 + assert repeated[0].data["failure_limit"] == 5 + + +def test_config_from_kanban_config_preserves_explicit_diagnostics_threshold(): + cfg = kd.config_from_kanban_config({ + "failure_limit": 5, + "diagnostics": {"failure_threshold": 3}, + }) + assert cfg["failure_threshold"] == 3 + assert cfg["failure_limit"] == 5 + + def test_repeated_crashes_counts_trailing_streak_only(): task = _task(status="ready", assignee="crashy") runs = [ From dadc8aa25580ac1ecc65d6185dfc6bd0e1d6d279 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 18 May 2026 01:27:06 -0700 Subject: [PATCH 097/418] fix(kanban): surface unusable triage auxiliary model (auto-decompose aware) (#27871) Adds a 'triage_aux_unavailable' diagnostic for tasks stuck in triage when neither the active aux helper slot nor the main-model auto fallback is usable. Auto-decompose aware: - kanban.auto_decompose=True (default): primary is auxiliary.kanban_decomposer, triage_specifier is the fanout=false fallback. - kanban.auto_decompose=False: primary is auxiliary.triage_specifier (manual 'hermes kanban specify' path). Default aux slots use 'provider: auto' which falls back to the main model, so this rule only fires when both the explicit slot config AND the main-model auto fallback are absent. Quiet by default; informative when there is a real config gap. Also adds kd.config_from_runtime_config() that carries kanban + auxiliary + model keys through to diagnostics, and updates CLI/dashboard call sites to use it. config_from_kanban_config() is preserved for back-compat. Reworks the original PR #25640 idea (@qWaitCrypto) to align with the new auto-decompose dispatcher path landed in #27572. The original PR pointed only at auxiliary.triage_specifier, which is now the fallback rather than the primary helper. Co-authored-by: qWaitCrypto --- hermes_cli/kanban.py | 4 +- hermes_cli/kanban_diagnostics.py | 229 ++++++++++++++++++++ plugins/kanban/dashboard/plugin_api.py | 4 +- tests/hermes_cli/test_kanban_diagnostics.py | 124 +++++++++++ 4 files changed, 355 insertions(+), 6 deletions(-) diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index 12e3e71e9e33..edaee42f88b2 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -1395,9 +1395,7 @@ def _cmd_diagnostics(args: argparse.Namespace) -> int: from hermes_cli import kanban_diagnostics as kd from hermes_cli.config import load_config - diag_config = kd.config_from_kanban_config( - (load_config().get("kanban") or {}) - ) + diag_config = kd.config_from_runtime_config(load_config()) with kb.connect() as conn: # Either one-task mode or fleet mode. diff --git a/hermes_cli/kanban_diagnostics.py b/hermes_cli/kanban_diagnostics.py index 2f8b7c8ed021..8acd6dd93295 100644 --- a/hermes_cli/kanban_diagnostics.py +++ b/hermes_cli/kanban_diagnostics.py @@ -230,6 +230,98 @@ def _generic_recovery_actions(task: Any, *, running: bool) -> list[DiagnosticAct RuleFn = Callable[[Any, list[Any], list[Any], int, dict], list[Diagnostic]] +def _aux_slot_explicit(slot: Any) -> bool: + """Return True if the auxiliary slot has user-supplied non-default fields. + + Defaults from ``DEFAULT_CONFIG`` use ``provider: "auto"`` with empty + model/base_url/api_key — that path falls through to the main model. An + "explicit" config is one where the user actively set a provider (not + "auto"), or supplied a model / base_url / api_key. + """ + if not isinstance(slot, dict): + return False + provider = str(slot.get("provider") or "").strip().lower() + if provider and provider != "auto": + return True + for key in ("model", "base_url", "api_key"): + if str(slot.get(key) or "").strip(): + return True + return False + + +def _main_model_visible(raw_config: Any) -> bool: + """Best-effort check that a main model is configured. + + Diagnostics runs in the dashboard process which may not share the CLI's + runtime state, so we read the raw config dict. If we cannot prove the + main model is set, we err on the side of NOT firing the diagnostic. + """ + if not isinstance(raw_config, dict): + return False + model_cfg = raw_config.get("model") + if isinstance(model_cfg, dict): + provider = str(model_cfg.get("provider") or "").strip() + model = str( + model_cfg.get("default") + or model_cfg.get("model") + or model_cfg.get("name") + or "" + ).strip() + return bool(provider and model) + return bool(str(model_cfg or "").strip()) + + +def triage_aux_status(config: Optional[dict]) -> Optional[dict]: + """Inspect raw config and report whether triage paths look configured. + + Returns ``None`` when config context is unavailable (suppress diagnostic + to avoid noisy false positives in tests / low-level callers). Otherwise + returns a dict with: + + - ``auto_decompose``: bool — whether the dispatcher auto-runs decompose + - ``decomposer_explicit``: bool — user-supplied decomposer slot + - ``specifier_explicit``: bool — user-supplied specifier slot + - ``main_model_visible``: bool — main model can serve as auto fallback + """ + if not isinstance(config, dict): + return None + + explicit = config.get("triage_aux_status") + if isinstance(explicit, dict): + return explicit + + aux = config.get("auxiliary") + kanban_cfg = config.get("kanban") if isinstance(config.get("kanban"), dict) else {} + + # Have we been handed any config context at all? When neither auxiliary + # nor kanban nor model keys are present, the caller is a low-level test + # passing {} — stay silent. + if ( + not isinstance(aux, dict) + and not kanban_cfg + and "model" not in config + ): + return None + + decomposer_explicit = False + specifier_explicit = False + if isinstance(aux, dict): + decomposer_explicit = _aux_slot_explicit(aux.get("kanban_decomposer")) + specifier_explicit = _aux_slot_explicit(aux.get("triage_specifier")) + + # ``auto_decompose`` defaults to True per kanban DEFAULT_CONFIG. + auto_decompose = True + if isinstance(kanban_cfg, dict) and "auto_decompose" in kanban_cfg: + auto_decompose = bool(kanban_cfg.get("auto_decompose")) + + return { + "auto_decompose": auto_decompose, + "decomposer_explicit": decomposer_explicit, + "specifier_explicit": specifier_explicit, + "main_model_visible": _main_model_visible(config), + } + + def _positive_int(value: Any, default: int) -> int: try: parsed = int(value) @@ -285,6 +377,118 @@ def _rule_hallucinated_cards(task, events, runs, now, cfg) -> list[Diagnostic]: )] +def _rule_triage_aux_unavailable(task, events, runs, now, cfg) -> list[Diagnostic]: + """A triage task cannot leave triage without an auxiliary helper. + + With the auto-decompose dispatcher (kanban.auto_decompose, default True), + triage tasks fan out via ``auxiliary.kanban_decomposer`` and fall back to + ``auxiliary.triage_specifier`` when the decomposer returns ``fanout=false``. + With auto-decompose off, the user must run ``hermes kanban specify``, + which only needs ``auxiliary.triage_specifier``. + + The default slot is ``provider: auto`` → auto-falls back to the main model, + so this rule only fires when: + + - the relevant slot is explicitly set to something broken, OR + - the auto fallback has no main model to fall back to. + + Config context is required; pass {} from tests to keep the rule silent. + """ + if _task_field(task, "status") != "triage": + return [] + + status = triage_aux_status(cfg) + if status is None: + return [] + + auto_decompose = bool(status.get("auto_decompose")) + decomposer_explicit = bool(status.get("decomposer_explicit")) + specifier_explicit = bool(status.get("specifier_explicit")) + main_visible = bool(status.get("main_model_visible")) + + # Determine the primary slot and whether it is usable. + if auto_decompose: + primary_slot = "auxiliary.kanban_decomposer" + primary_explicit = decomposer_explicit + fallback_slot = "auxiliary.triage_specifier" + fallback_explicit = specifier_explicit + primary_desc = "decomposer" + detail_path = ( + "Auto-decompose is on, so the dispatcher needs " + "auxiliary.kanban_decomposer (with auxiliary.triage_specifier as " + "a fallback for non-fan-out tasks)." + ) + else: + primary_slot = "auxiliary.triage_specifier" + primary_explicit = specifier_explicit + fallback_slot = "auxiliary.kanban_decomposer" + fallback_explicit = decomposer_explicit + primary_desc = "specifier" + detail_path = ( + "Auto-decompose is off, so triage tasks need " + "`hermes kanban specify`, which uses auxiliary.triage_specifier." + ) + + # The primary slot is usable when either: it was explicitly configured by + # the user, OR the default `provider: auto` can fall back to the main + # model. If both fail, we have a real configuration gap. + if primary_explicit or main_visible: + return [] + + task_id = _task_field(task, "id") or "" + actions = [ + DiagnosticAction( + kind="cli_hint", + label=f"Configure {primary_slot}", + payload={ + "command": ( + f"hermes config set {primary_slot}.provider auto" + ) + }, + suggested=True, + ), + ] + if not fallback_explicit and not main_visible: + actions.append(DiagnosticAction( + kind="cli_hint", + label=f"Or configure fallback {fallback_slot}", + payload={ + "command": ( + f"hermes config set {fallback_slot}.provider auto" + ) + }, + )) + if not auto_decompose: + actions.append(DiagnosticAction( + kind="cli_hint", + label=f"Specify manually: hermes kanban specify {task_id}", + payload={"command": f"hermes kanban specify {task_id}"}, + )) + + return [Diagnostic( + kind="triage_aux_unavailable", + severity="warning", + title=f"Triage {primary_desc} has no usable model", + detail=( + f"This task is still in triage and no working auxiliary model is " + f"visible to the dispatcher. {detail_path} The default slot uses " + f"`provider: auto` which falls back to the main model, but no main " + f"model is configured either. Configure the slot directly or set a " + f"main model so the auto fallback can take over." + ), + actions=actions, + first_seen_at=now, + last_seen_at=now, + count=1, + data={ + "task_id": task_id, + "auto_decompose": auto_decompose, + "primary_slot": primary_slot, + "main_model_visible": main_visible, + }, + )] + + def _rule_prose_phantom_refs(task, events, runs, now, cfg) -> list[Diagnostic]: """Advisory prose-scan: the completion summary mentions ``t_`` ids that don't resolve. Non-blocking; surfaced as a warning only. @@ -705,6 +909,7 @@ def _rule_stranded_in_ready(task, events, runs, now, cfg) -> list[Diagnostic]: # severity ties. Add new rules here. _RULES: list[RuleFn] = [ _rule_hallucinated_cards, + _rule_triage_aux_unavailable, _rule_prose_phantom_refs, _rule_repeated_failures, _rule_repeated_crashes, @@ -717,6 +922,7 @@ def _rule_stranded_in_ready(task, events, runs, now, cfg) -> list[Diagnostic]: # rules are added. DIAGNOSTIC_KINDS = ( "hallucinated_cards", + "triage_aux_unavailable", "prose_phantom_refs", "repeated_failures", "repeated_crashes", @@ -762,6 +968,29 @@ def config_from_kanban_config(kanban_cfg: Optional[dict]) -> dict: return diag_cfg +def config_from_runtime_config(raw_config: Optional[dict]) -> dict: + """Build diagnostics config from the full Hermes runtime config. + + Carries through ``kanban``, ``auxiliary``, and ``model`` keys so triage- + aware rules can inspect the active aux-helper and main-model state. + Folds the ``kanban`` block through ``config_from_kanban_config`` so the + repeated-failure threshold derivation still applies. + """ + raw_config = raw_config or {} + if not isinstance(raw_config, dict): + return {} + cfg: dict = {} + kanban_cfg = raw_config.get("kanban") + if isinstance(kanban_cfg, dict): + cfg.update(config_from_kanban_config(kanban_cfg)) + cfg["kanban"] = kanban_cfg + for key in ("auxiliary", "model"): + value = raw_config.get(key) + if value is not None: + cfg[key] = value + return cfg + + def compute_task_diagnostics( task, events: list, diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index 0a4685b4a54b..92a9d75366ca 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -226,9 +226,7 @@ def _compute_task_diagnostics( from hermes_cli import kanban_diagnostics as kd from hermes_cli.config import load_config - diag_config = kd.config_from_kanban_config( - (load_config().get("kanban") or {}) - ) + diag_config = kd.config_from_runtime_config(load_config()) # Build the candidate task list. We need each task's row + its # events + its runs. Doing N separate queries works but scales diff --git a/tests/hermes_cli/test_kanban_diagnostics.py b/tests/hermes_cli/test_kanban_diagnostics.py index 53fdf4fc34b4..6329825ce145 100644 --- a/tests/hermes_cli/test_kanban_diagnostics.py +++ b/tests/hermes_cli/test_kanban_diagnostics.py @@ -613,3 +613,127 @@ def test_stranded_in_ready_works_on_real_db_row(kanban_home): assert stranded[0].data["assignee"] == "ghost" finally: conn.close() + + + +# --------------------------------------------------------------------------- +# triage_aux_unavailable rule — auto-decompose aware +# --------------------------------------------------------------------------- + + +def _triage_task(): + return _task(id="t_triage1", status="triage") + + +def test_triage_aux_unavailable_silent_without_config_context(): + """Low-level callers passing no config dict should not see this rule.""" + diags = kd.compute_task_diagnostics(_triage_task(), [], []) + assert [d for d in diags if d.kind == "triage_aux_unavailable"] == [] + + +def test_triage_aux_unavailable_silent_when_main_model_visible(): + """Default `provider: auto` falls back to the main model — no warning.""" + config = { + "auxiliary": {}, + "model": {"provider": "openrouter", "default": "qwen/qwen3"}, + "kanban": {"auto_decompose": True}, + } + diags = kd.compute_task_diagnostics(_triage_task(), [], [], config=config) + assert [d for d in diags if d.kind == "triage_aux_unavailable"] == [] + + +def test_triage_aux_unavailable_silent_when_decomposer_explicit(): + """User explicitly configured decomposer → no warning, even without main.""" + config = { + "auxiliary": { + "kanban_decomposer": {"provider": "openrouter", "model": "qwen/qwen3"}, + }, + "kanban": {"auto_decompose": True}, + } + diags = kd.compute_task_diagnostics(_triage_task(), [], [], config=config) + assert [d for d in diags if d.kind == "triage_aux_unavailable"] == [] + + +def test_triage_aux_unavailable_fires_auto_decompose_on_no_fallback(): + """auto_decompose=True, no decomposer, no main model → warn about decomposer.""" + config = { + "auxiliary": {}, + "kanban": {"auto_decompose": True}, + } + diags = kd.compute_task_diagnostics(_triage_task(), [], [], config=config) + triage = [d for d in diags if d.kind == "triage_aux_unavailable"] + assert len(triage) == 1 + d = triage[0] + assert d.severity == "warning" + assert "decomposer" in d.title.lower() + assert d.data["auto_decompose"] is True + assert d.data["primary_slot"] == "auxiliary.kanban_decomposer" + suggested = [a for a in d.actions if a.suggested] + assert suggested + assert "auxiliary.kanban_decomposer" in suggested[0].payload["command"] + + +def test_triage_aux_unavailable_fires_auto_decompose_off_points_at_specifier(): + """auto_decompose=False → primary is specifier, not decomposer.""" + config = { + "auxiliary": {}, + "kanban": {"auto_decompose": False}, + } + diags = kd.compute_task_diagnostics(_triage_task(), [], [], config=config) + triage = [d for d in diags if d.kind == "triage_aux_unavailable"] + assert len(triage) == 1 + d = triage[0] + assert "specifier" in d.title.lower() + assert d.data["auto_decompose"] is False + assert d.data["primary_slot"] == "auxiliary.triage_specifier" + # And it should offer the manual specify command as an action + labels = [a.label for a in d.actions] + assert any("hermes kanban specify" in l for l in labels) + + +def test_triage_aux_unavailable_skips_non_triage_tasks(): + config = {"auxiliary": {}, "kanban": {"auto_decompose": True}} + task = _task(status="todo") + diags = kd.compute_task_diagnostics(task, [], [], config=config) + assert [d for d in diags if d.kind == "triage_aux_unavailable"] == [] + + +def test_triage_aux_status_recognises_auto_default_as_not_explicit(): + """Default `provider: auto` with empty fields → not 'explicit'.""" + status = kd.triage_aux_status({ + "auxiliary": { + "kanban_decomposer": {"provider": "auto", "model": ""}, + }, + "kanban": {}, + }) + assert status is not None + assert status["decomposer_explicit"] is False + + +def test_triage_aux_status_recognises_explicit_model_only(): + """Even with provider=auto, a non-empty model counts as explicit.""" + status = kd.triage_aux_status({ + "auxiliary": { + "kanban_decomposer": {"provider": "auto", "model": "qwen/qwen3"}, + }, + "kanban": {}, + }) + assert status is not None + assert status["decomposer_explicit"] is True + + +def test_config_from_runtime_config_carries_aux_and_model(): + cfg = kd.config_from_runtime_config({ + "kanban": {"failure_limit": 5, "auto_decompose": False}, + "auxiliary": {"kanban_decomposer": {"provider": "openrouter"}}, + "model": {"provider": "openrouter", "default": "qwen/qwen3"}, + }) + assert cfg["failure_threshold"] == 5 + assert cfg["kanban"]["auto_decompose"] is False + assert cfg["auxiliary"]["kanban_decomposer"]["provider"] == "openrouter" + assert cfg["model"]["default"] == "qwen/qwen3" + + +def test_config_from_runtime_config_handles_empty_input(): + assert kd.config_from_runtime_config(None) == {} + assert kd.config_from_runtime_config({}) == {} From f2fdb9a178a0b646d0803ab0789914657dc8c361 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 18 May 2026 02:14:43 -0700 Subject: [PATCH 098/418] =?UTF-8?q?feat(gateway):=20deliverable=20mode=20?= =?UTF-8?q?=E2=80=94=20ship=20artifacts=20as=20native=20uploads=20from=20a?= =?UTF-8?q?ny=20agent=20surface=20(#27813)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent can now produce a chart, PDF, spreadsheet, or any other supported file type and have it land in Slack / Discord / Telegram / WhatsApp / etc. as a native attachment, just by mentioning the absolute path in its response. Same primitive works for kanban-worker completions: workers attach artifacts via kanban_complete(artifacts=[...]) and the gateway notifier uploads them alongside the completion message. Changes: - gateway/platforms/base.py: extract_local_files now covers PDFs, docx, spreadsheets (xlsx/csv/json/yaml), presentations (pptx), archives (zip/tar/gz), audio (mp3/wav/...), and html — not just images and video. Image/video extensions still embed inline; everything else routes to send_document via the existing dispatch partition in gateway/run.py. - tools/kanban_tools.py + hermes_cli/kanban_db.py: kanban_complete gains an explicit ``artifacts`` parameter. The handler stashes it in metadata.artifacts (for downstream workers) and the kernel promotes it onto the completed-event payload so the notifier can find it without a second SQL round-trip. - gateway/run.py: _kanban_notifier_watcher now calls a new helper _deliver_kanban_artifacts after sending the completion text. The helper reads payload.artifacts (preferred), falls back to scanning the payload summary and task.result with extract_local_files, then partitions images / videos / documents and uploads each via send_multiple_images / send_video / send_document. - website/docs/user-guide/features/deliverable-mode.md + sidebars.ts: user-facing docs page covering the extension list, the kanban artifacts pattern, and the MCP-for-connector-breadth recommendation. Tests: - tests/gateway/test_extract_local_files.py: 7 new test cases (documents, spreadsheets, presentations, audio, archives, html, chart-pdf canonical case). 44 passing, 0 regressions. - tests/tools/test_kanban_tools.py: 4 new cases covering the artifacts arg shape (list / string / merge with existing metadata / type rejection). 17 passing. - tests/hermes_cli/test_kanban_notify.py: 2 new cases covering full notifier → artifact-upload path and missing-file silent-skip. 12 passing. - E2E (real files, real kanban kernel, real BasePlatformAdapter): worker calls kanban_complete(artifacts=[png,pdf,csv]) → metadata + event payload land → notifier helper partitions correctly → send_multiple_images called once with the PNG, send_document called twice with PDF + CSV. What's NOT in this PR (deferred to follow-ups): - Ad-hoc "research this for two hours, ping the thread when done" slash command — covered today by kanban subscriptions; a dedicated slash command can ride a follow-up PR if needed. - Setup-wizard prompt for recommended MCP servers (Notion, GitHub, Linear, etc.) — docs page lists them; UI is a separate change. Plan and rationale captured in ~/.hermes/docs/perplexity-computer-parity.pdf (local doc, not shipped). --- gateway/platforms/base.py | 32 +++- gateway/run.py | 127 ++++++++++++++ hermes_cli/kanban_db.py | 14 ++ tests/gateway/test_extract_local_files.py | 63 ++++++- tests/hermes_cli/test_kanban_notify.py | 159 ++++++++++++++++++ tests/tools/test_kanban_tools.py | 87 ++++++++++ tools/kanban_tools.py | 66 +++++++- .../user-guide/features/deliverable-mode.md | 130 ++++++++++++++ website/sidebars.ts | 1 + 9 files changed, 671 insertions(+), 8 deletions(-) create mode 100644 website/docs/user-guide/features/deliverable-mode.md diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 96b56d29cc7d..34ebc385fa91 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -2157,12 +2157,20 @@ def extract_media(content: str) -> Tuple[List[Tuple[str, bool]], str]: @staticmethod def extract_local_files(content: str) -> Tuple[List[str], str]: """ - Detect bare local file paths in response text for native media delivery. + Detect bare local file paths in response text for native delivery. Matches absolute paths (/...) and tilde paths (~/) ending in common - image or video extensions. Validates each candidate with - ``os.path.isfile()`` to avoid false positives from URLs or - non-existent paths. + image, video, audio, or document extensions. Validates each + candidate with ``os.path.isfile()`` to avoid false positives from + URLs or non-existent paths. + + The extension list is broader than just images/video so the agent + can produce arbitrary artifacts (charts, PDFs, spreadsheets, code + archives, CSVs) and have them ship to the user as native uploads + without needing an explicit ``MEDIA:`` tag. Image / video + extensions still embed inline where the platform supports it; + document extensions route through ``send_document``. The dispatch + partition lives in ``gateway/run.py``. Paths inside fenced code blocks (``` ... ```) and inline code (`...`) are ignored so that code samples are never mutilated. @@ -2172,8 +2180,22 @@ def extract_local_files(content: str) -> Tuple[List[str], str]: raw path strings removed). """ _LOCAL_MEDIA_EXTS = ( - '.png', '.jpg', '.jpeg', '.gif', '.webp', + # Images (embed inline) + '.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tiff', '.svg', + # Video (embed inline where supported) '.mp4', '.mov', '.avi', '.mkv', '.webm', + # Audio (delivered as voice/audio where supported) + '.mp3', '.wav', '.ogg', '.m4a', '.flac', + # Documents (uploaded as file attachments) + '.pdf', '.docx', '.doc', '.odt', '.rtf', '.txt', '.md', + # Spreadsheets / data + '.xlsx', '.xls', '.ods', '.csv', '.tsv', '.json', '.xml', '.yaml', '.yml', + # Presentations + '.pptx', '.ppt', '.odp', '.key', + # Archives + '.zip', '.tar', '.gz', '.tgz', '.bz2', '.xz', '.7z', '.rar', + # Web / rendered output + '.html', '.htm', ) ext_part = '|'.join(e.lstrip('.') for e in _LOCAL_MEDIA_EXTS) diff --git a/gateway/run.py b/gateway/run.py index 623d238af366..e36acf444c2f 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -4474,6 +4474,29 @@ def _collect(): "kanban notifier: delivered %s event for %s to %s/%s on board %s", kind, sub["task_id"], platform_str, sub["chat_id"], board_slug, ) + # After delivering the text notification, surface + # any artifact paths the worker referenced in + # ``kanban_complete(summary=..., artifacts=[...])`` + # (or the legacy ``result`` field) as native + # uploads. ``extract_local_files`` finds bare + # absolute paths in the summary; + # ``send_document`` / ``send_image_file`` uploads + # them. Only fires on the ``completed`` event so + # we never spam attachments on retries. + if kind == "completed": + try: + await self._deliver_kanban_artifacts( + adapter=adapter, + chat_id=sub["chat_id"], + metadata=metadata, + event_payload=getattr(ev, "payload", None), + task=task, + ) + except Exception as art_exc: + logger.debug( + "kanban notifier: artifact delivery for %s failed: %s", + sub["task_id"], art_exc, + ) # Reset the failure counter on success. sub_fail_counts.pop(sub_key, None) except Exception as exc: @@ -4591,6 +4614,110 @@ def _kanban_rewind( finally: conn.close() + async def _deliver_kanban_artifacts( + self, + *, + adapter, + chat_id: str, + metadata: dict, + event_payload: Optional[dict], + task, + ) -> None: + """Upload artifact files referenced by a completed kanban task. + + Workers passing ``kanban_complete(artifacts=[...])`` ship absolute + file paths through the completion event so downstream humans get + the deliverable as a native upload instead of a path printed in + chat. + + Sources scanned, in priority order: + 1. ``event_payload['artifacts']`` (explicit list — preferred) + 2. ``event_payload['summary']`` (truncated first line) + 3. ``task.result`` (legacy fallback) + + Files are deduplicated, missing files are silently skipped (the + path may have been mentioned for reference only), and delivery + errors are logged but do not break the notifier loop. + """ + from pathlib import Path as _Path + + candidates: list[str] = [] + seen: set[str] = set() + + def _add(path: str) -> None: + if not path: + return + expanded = os.path.expanduser(path) + if expanded in seen: + return + if not os.path.isfile(expanded): + return + seen.add(expanded) + candidates.append(expanded) + + # 1. Explicit artifacts list in payload. + if isinstance(event_payload, dict): + raw = event_payload.get("artifacts") + if isinstance(raw, (list, tuple)): + for item in raw: + if isinstance(item, str): + _add(item) + + # 2. Paths embedded in the payload summary. + summary = event_payload.get("summary") + if isinstance(summary, str) and summary: + paths, _ = adapter.extract_local_files(summary) + for p in paths: + _add(p) + + # 3. Legacy: paths embedded in task.result. + if task is not None and getattr(task, "result", None): + result_text = str(task.result) + paths, _ = adapter.extract_local_files(result_text) + for p in paths: + _add(p) + + if not candidates: + return + + _IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".svg"} + _VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".3gp"} + + from urllib.parse import quote as _quote + + # Partition images so they ride a single send_multiple_images call + # on platforms that support batch image uploads (Signal/Slack RPCs). + image_paths = [p for p in candidates if _Path(p).suffix.lower() in _IMAGE_EXTS] + other_paths = [p for p in candidates if _Path(p).suffix.lower() not in _IMAGE_EXTS] + + if image_paths: + try: + batch = [(f"file://{_quote(p)}", "") for p in image_paths] + await adapter.send_multiple_images( + chat_id=chat_id, images=batch, metadata=metadata, + ) + except Exception as exc: + logger.warning( + "kanban notifier: image batch upload failed: %s", exc, + ) + + for path in other_paths: + ext = _Path(path).suffix.lower() + try: + if ext in _VIDEO_EXTS: + await adapter.send_video( + chat_id=chat_id, video_path=path, metadata=metadata, + ) + else: + await adapter.send_document( + chat_id=chat_id, file_path=path, metadata=metadata, + ) + except Exception as exc: + logger.warning( + "kanban notifier: artifact upload (%s) failed: %s", + path, exc, + ) + async def _kanban_dispatcher_watcher(self) -> None: """Embedded kanban dispatcher — one tick every `dispatch_interval_seconds`. diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 5b5fe456c956..4def6fc5d594 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -2479,6 +2479,20 @@ def complete_task( } if verified_cards: completed_payload["verified_cards"] = verified_cards + # Carry artifact paths in the event payload so the gateway + # notifier can upload them as native attachments alongside the + # completion message. Workers pass these via + # ``kanban_complete(artifacts=[...])`` which stashes the list in + # ``metadata["artifacts"]`` — we promote it onto the event so + # consumers don't have to fetch the run row to find it. + if isinstance(metadata, dict): + md_artifacts = metadata.get("artifacts") + if isinstance(md_artifacts, (list, tuple)): + cleaned_artifacts = [ + str(p).strip() for p in md_artifacts if isinstance(p, str) and str(p).strip() + ] + if cleaned_artifacts: + completed_payload["artifacts"] = cleaned_artifacts _append_event( conn, task_id, "completed", completed_payload, diff --git a/tests/gateway/test_extract_local_files.py b/tests/gateway/test_extract_local_files.py index dd93e6370f2e..568b311cb9b1 100644 --- a/tests/gateway/test_extract_local_files.py +++ b/tests/gateway/test_extract_local_files.py @@ -74,6 +74,58 @@ def test_image_extensions(self): assert len(paths) == 1, f"Failed for {ext}" assert paths[0] == f"/tmp/pic{ext}" + def test_document_extensions(self): + """Documents (PDF, Word, plain text, etc.) ship as file uploads.""" + for ext in (".pdf", ".docx", ".doc", ".odt", ".rtf", ".txt", ".md"): + text = f"Report at /tmp/report{ext} attached" + paths, _ = _extract(text) + assert len(paths) == 1, f"Failed for {ext}" + assert paths[0] == f"/tmp/report{ext}" + + def test_spreadsheet_and_data_extensions(self): + """Spreadsheets and structured data ship as file uploads.""" + for ext in (".xlsx", ".xls", ".csv", ".tsv", ".json", ".xml", ".yaml", ".yml"): + text = f"Data at /tmp/data{ext} ready" + paths, _ = _extract(text) + assert len(paths) == 1, f"Failed for {ext}" + assert paths[0] == f"/tmp/data{ext}" + + def test_presentation_extensions(self): + """Presentations ship as file uploads.""" + for ext in (".pptx", ".ppt", ".odp"): + text = f"Deck at /tmp/deck{ext} done" + paths, _ = _extract(text) + assert len(paths) == 1, f"Failed for {ext}" + assert paths[0] == f"/tmp/deck{ext}" + + def test_audio_extensions(self): + """Audio files are detected and routed by the gateway dispatch.""" + for ext in (".mp3", ".wav", ".ogg", ".m4a", ".flac"): + text = f"Audio at /tmp/sound{ext} ready" + paths, _ = _extract(text) + assert len(paths) == 1, f"Failed for {ext}" + assert paths[0] == f"/tmp/sound{ext}" + + def test_archive_extensions(self): + """Archives ship as file uploads.""" + for ext in (".zip", ".tar", ".gz", ".tgz", ".bz2", ".7z"): + text = f"Archive at /tmp/bundle{ext} ready" + paths, _ = _extract(text) + assert len(paths) == 1, f"Failed for {ext}" + assert paths[0] == f"/tmp/bundle{ext}" + + def test_html_extension(self): + paths, _ = _extract("Open /tmp/report.html in browser") + assert paths == ["/tmp/report.html"] + + def test_chart_pdf_path(self): + """Common case: agent renders a chart via matplotlib and references the file.""" + text = "Here is the comparison chart: /tmp/q3-sales.pdf" + paths, cleaned = _extract(text) + assert paths == ["/tmp/q3-sales.pdf"] + assert "/tmp/q3-sales.pdf" not in cleaned + assert "comparison chart" in cleaned + def test_case_insensitive_extension(self): paths, _ = _extract("See /tmp/PHOTO.PNG and /tmp/vid.MP4 now") assert len(paths) == 2 @@ -269,8 +321,15 @@ def test_empty_string(self): assert cleaned == "" def test_no_media_extensions(self): - """Non-media extensions should not be matched.""" - paths, _ = _extract("See /tmp/data.csv and /tmp/script.py and /tmp/notes.txt") + """Extensions outside the supported list should not be matched. + + ``.py`` and ``.log`` are intentionally excluded because (a) most + source files are quoted in inline code or fenced blocks anyway, + and (b) auto-shipping arbitrary source files would be a + surprise. Documents (.pdf, .docx), data (.csv, .json), + archives (.zip), and presentations (.pptx) ARE matched. + """ + paths, _ = _extract("See /tmp/script.py and /tmp/server.log here") assert paths == [] def test_path_with_spaces_not_matched(self): diff --git a/tests/hermes_cli/test_kanban_notify.py b/tests/hermes_cli/test_kanban_notify.py index ddfa4b40aa26..1ebf92705d7d 100644 --- a/tests/hermes_cli/test_kanban_notify.py +++ b/tests/hermes_cli/test_kanban_notify.py @@ -479,3 +479,162 @@ async def test_gateway_create_autosubscribes_on_explicit_board(kanban_home): assert kb.list_notify_subs(conn) == [] finally: conn.close() + + +@pytest.mark.asyncio +async def test_notifier_uploads_artifacts_on_completion(kanban_home, tmp_path): + """When a completed event carries ``artifacts`` in its payload, the + notifier uploads each file to the subscribed chat as a native + attachment. Images batch through send_multiple_images; documents + route through send_document. See the artifacts wiring in + gateway/run.py._deliver_kanban_artifacts. + """ + import hermes_cli.kanban_db as kb + from gateway.run import GatewayRunner + from gateway.config import Platform + from tools import kanban_tools as kt + + # Materialize real files so os.path.isfile passes inside the helper. + chart_path = tmp_path / "q3-revenue.png" + chart_path.write_bytes(b"PNG-fake-bytes") + report_path = tmp_path / "report.pdf" + report_path.write_bytes(b"%PDF-fake") + + conn = kb.connect() + try: + tid = kb.create_task(conn, title="render q3 chart", assignee="worker1") + kb.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat1") + finally: + conn.close() + + # Use the production handler so we exercise the full path: tool args + # → metadata.artifacts → event payload promotion. + import os + os.environ["HERMES_KANBAN_TASK"] = tid + try: + out = kt._handle_complete({ + "summary": "rendered the chart", + "artifacts": [str(chart_path), str(report_path)], + }) + finally: + os.environ.pop("HERMES_KANBAN_TASK", None) + import json as _json + assert _json.loads(out)["ok"] is True + + runner = object.__new__(GatewayRunner) + runner._running = True + runner._kanban_sub_fail_counts = {} + + fake_adapter = MagicMock() + fake_adapter.name = "telegram" + + sends: list = [] + images_uploaded: list = [] + documents_uploaded: list = [] + + async def _send(chat_id, msg, metadata=None): + sends.append((chat_id, msg)) + runner._running = False + + async def _send_images(chat_id, images, metadata=None, **_kw): + images_uploaded.extend(p for p, _ in images) + + async def _send_document(chat_id, file_path, metadata=None, **_kw): + documents_uploaded.append(file_path) + + fake_adapter.send = AsyncMock(side_effect=_send) + fake_adapter.send_multiple_images = AsyncMock(side_effect=_send_images) + fake_adapter.send_document = AsyncMock(side_effect=_send_document) + # extract_local_files is used internally for legacy path fallback; + # the real BasePlatformAdapter implementation lives there, so wire it. + from gateway.platforms.base import BasePlatformAdapter + fake_adapter.extract_local_files = BasePlatformAdapter.extract_local_files + + runner.adapters = {Platform.TELEGRAM: fake_adapter} + + _orig_sleep = asyncio.sleep + + async def _fast_sleep(_): + await _orig_sleep(0) + + with patch("gateway.run.asyncio.sleep", side_effect=_fast_sleep): + await asyncio.wait_for( + runner._kanban_notifier_watcher(interval=1), + timeout=10.0, + ) + + # The text completion notification fired. + assert len(sends) == 1 + # The PNG rode the image-batch path. + assert any("q3-revenue.png" in p for p in images_uploaded), images_uploaded + # The PDF rode the document path. + assert any("report.pdf" in p for p in documents_uploaded), documents_uploaded + + +@pytest.mark.asyncio +async def test_notifier_artifact_delivery_skips_missing_files(kanban_home, tmp_path): + """Missing artifact paths are silently skipped — they may have been + referenced by name only. The notifier must not crash and must still + deliver any artifacts that do exist.""" + import hermes_cli.kanban_db as kb + from gateway.run import GatewayRunner + from gateway.config import Platform + from tools import kanban_tools as kt + + real_pdf = tmp_path / "real.pdf" + real_pdf.write_bytes(b"%PDF-fake") + + conn = kb.connect() + try: + tid = kb.create_task(conn, title="t", assignee="worker1") + kb.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat1") + finally: + conn.close() + + import os + os.environ["HERMES_KANBAN_TASK"] = tid + try: + kt._handle_complete({ + "summary": "one real, one ghost", + "artifacts": [str(real_pdf), "/tmp/definitely-does-not-exist.pdf"], + }) + finally: + os.environ.pop("HERMES_KANBAN_TASK", None) + + runner = object.__new__(GatewayRunner) + runner._running = True + runner._kanban_sub_fail_counts = {} + + fake_adapter = MagicMock() + fake_adapter.name = "telegram" + + documents_uploaded: list = [] + + async def _send(chat_id, msg, metadata=None): + runner._running = False + + async def _send_document(chat_id, file_path, metadata=None, **_kw): + documents_uploaded.append(file_path) + + fake_adapter.send = AsyncMock(side_effect=_send) + fake_adapter.send_document = AsyncMock(side_effect=_send_document) + fake_adapter.send_multiple_images = AsyncMock() + from gateway.platforms.base import BasePlatformAdapter + fake_adapter.extract_local_files = BasePlatformAdapter.extract_local_files + + runner.adapters = {Platform.TELEGRAM: fake_adapter} + + _orig_sleep = asyncio.sleep + + async def _fast_sleep(_): + await _orig_sleep(0) + + with patch("gateway.run.asyncio.sleep", side_effect=_fast_sleep): + await asyncio.wait_for( + runner._kanban_notifier_watcher(interval=1), + timeout=10.0, + ) + + # Only the real file was uploaded. + assert len(documents_uploaded) == 1 + assert "real.pdf" in documents_uploaded[0] diff --git a/tests/tools/test_kanban_tools.py b/tests/tools/test_kanban_tools.py index c31ae6f08bb9..1dbd72ad937f 100644 --- a/tests/tools/test_kanban_tools.py +++ b/tests/tools/test_kanban_tools.py @@ -318,6 +318,93 @@ def test_complete_with_result_only(worker_env): assert d["ok"] is True +def test_complete_with_artifacts_lands_in_event_payload(worker_env): + """``artifacts=[...]`` rides into the completed event payload so the + gateway notifier can upload them as native attachments. See the + kanban notifier in gateway/run.py for the consumer side.""" + from hermes_cli import kanban_db as kb + from tools import kanban_tools as kt + + out = kt._handle_complete({ + "summary": "rendered the chart", + "artifacts": ["/tmp/q3-revenue.png", "/tmp/q3-report.pdf"], + }) + assert json.loads(out)["ok"] is True + + conn = kb.connect() + try: + events = kb.list_events(conn, worker_env) + # Find the completion event + completed = [e for e in events if e.kind == "completed"] + assert len(completed) == 1 + payload = completed[0].payload or {} + assert payload.get("artifacts") == [ + "/tmp/q3-revenue.png", + "/tmp/q3-report.pdf", + ] + # And the artifacts also live on metadata for downstream workers + run = kb.latest_run(conn, worker_env) + assert run.metadata.get("artifacts") == [ + "/tmp/q3-revenue.png", + "/tmp/q3-report.pdf", + ] + finally: + conn.close() + + +def test_complete_artifacts_accepts_single_string(worker_env): + """A bare string is auto-promoted to a single-element list for convenience.""" + from hermes_cli import kanban_db as kb + from tools import kanban_tools as kt + + out = kt._handle_complete({ + "summary": "one chart", + "artifacts": "/tmp/chart.png", + }) + assert json.loads(out)["ok"] is True + + conn = kb.connect() + try: + run = kb.latest_run(conn, worker_env) + assert run.metadata.get("artifacts") == ["/tmp/chart.png"] + finally: + conn.close() + + +def test_complete_artifacts_merges_with_explicit_metadata_field(worker_env): + """If the worker passes metadata.artifacts AND the top-level artifacts + param, merge the two without duplicates.""" + from hermes_cli import kanban_db as kb + from tools import kanban_tools as kt + + out = kt._handle_complete({ + "summary": "merged", + "metadata": {"artifacts": ["/tmp/a.png"], "other": "fact"}, + "artifacts": ["/tmp/b.pdf", "/tmp/a.png"], + }) + assert json.loads(out)["ok"] is True + + conn = kb.connect() + try: + run = kb.latest_run(conn, worker_env) + # Order: existing entries first, then new ones, deduplicated. + assert run.metadata.get("artifacts") == ["/tmp/a.png", "/tmp/b.pdf"] + assert run.metadata.get("other") == "fact" + finally: + conn.close() + + +def test_complete_rejects_non_list_artifacts(worker_env): + """Non-list, non-string artifacts should be rejected with a clear error.""" + from tools import kanban_tools as kt + out = kt._handle_complete({ + "summary": "bad shape", + "artifacts": {"not": "a list"}, + }) + err = json.loads(out).get("error", "") + assert "artifacts must be a list" in err + + def test_complete_rejects_no_handoff(worker_env): from tools import kanban_tools as kt out = kt._handle_complete({}) diff --git a/tools/kanban_tools.py b/tools/kanban_tools.py index fab0a68c92ba..eaf32a3a3746 100644 --- a/tools/kanban_tools.py +++ b/tools/kanban_tools.py @@ -371,6 +371,7 @@ def _handle_complete(args: dict, **kw) -> str: metadata = args.get("metadata") result = args.get("result") created_cards = args.get("created_cards") + artifacts = args.get("artifacts") if created_cards is not None: if isinstance(created_cards, str): # Accept a single id as a string for convenience. @@ -384,6 +385,45 @@ def _handle_complete(args: dict, **kw) -> str: created_cards = [ str(c).strip() for c in created_cards if str(c).strip() ] + if artifacts is not None: + if isinstance(artifacts, str): + # Accept a single path as a string for convenience. + artifacts = [artifacts] + if not isinstance(artifacts, (list, tuple)): + return tool_error( + f"artifacts must be a list of file paths, got " + f"{type(artifacts).__name__}" + ) + artifacts = [ + str(p).strip() for p in artifacts if str(p).strip() + ] + # Carry the artifact list inside metadata so it rides the + # existing completed-event payload without a schema change at + # the DB layer. The gateway notifier reads payload['artifacts'] + # off the completion event and uploads each path as a native + # attachment. + if artifacts: + if metadata is None: + metadata = {} + elif not isinstance(metadata, dict): + return tool_error( + f"metadata must be an object/dict, got " + f"{type(metadata).__name__}" + ) + # Don't overwrite an existing metadata.artifacts the worker + # passed manually — merge instead. + existing = metadata.get("artifacts") + if isinstance(existing, (list, tuple)): + merged: list[str] = [] + seen: set[str] = set() + for item in list(existing) + artifacts: + s = str(item).strip() + if s and s not in seen: + seen.add(s) + merged.append(s) + metadata["artifacts"] = merged + else: + metadata["artifacts"] = artifacts if not (summary or result): return tool_error( "provide at least one of: summary (preferred), result" @@ -760,7 +800,12 @@ def _handle_link(args: dict, **kw) -> str: "tasks via ``kanban_create`` during this run, list their ids " "in ``created_cards`` — the kernel verifies them so phantom " "references are caught before they leak into downstream " - "automation." + "automation. If you produced deliverable files (charts, PDFs, " + "spreadsheets, generated images), list their absolute paths " + "in ``artifacts`` — the gateway notifier will upload them as " + "native attachments to the human who subscribed to the task, " + "so the deliverable lands in their chat alongside the summary " + "instead of being a path they have to fetch by hand." ), "parameters": { "type": "object", @@ -811,6 +856,25 @@ def _handle_link(args: dict, **kw) -> str: "did not create any cards." ), }, + "artifacts": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "Optional list of absolute paths to deliverable " + "files you produced during this run — generated " + "charts, PDFs, spreadsheets, images, archives. " + "Examples: [\"/tmp/q3-revenue.png\", " + "\"/tmp/report.pdf\"]. The gateway notifier " + "uploads each path as a native attachment to the " + "subscribed chat (images embed inline, everything " + "else uploads as a file) so the deliverable " + "lands with the completion notification. Skip " + "intermediate scratch files and references that " + "are not the deliverable. The path must exist " + "on disk when the notifier runs; missing files " + "are silently skipped." + ), + }, }, "required": [], }, diff --git a/website/docs/user-guide/features/deliverable-mode.md b/website/docs/user-guide/features/deliverable-mode.md new file mode 100644 index 000000000000..e08e3966fa6c --- /dev/null +++ b/website/docs/user-guide/features/deliverable-mode.md @@ -0,0 +1,130 @@ +--- +title: Deliverable Mode (Artifacts in Chat) +sidebar_label: Deliverable Mode +description: How the agent ships generated charts, PDFs, spreadsheets, and other files as native attachments in messaging platforms. +--- + +# Deliverable Mode + +When Hermes Agent runs inside a messaging gateway (Slack, Discord, Telegram, +WhatsApp, Signal, etc.), it can deliver generated files directly into the +chat — not as paths the user has to copy, but as native attachments. + +A chart shows up as an inline image. A PDF report shows up as a file +download. A spreadsheet uploads as `.xlsx`. The agent does not need to +write a `MEDIA:` tag or do anything special — it just generates the file +and mentions its absolute path in the response. The gateway picks the path +out of the text, removes it from the visible message, and uploads the +file natively. + +## How it works + +Three pieces fit together: + +1. **The agent has tools that produce files.** `execute_code` for charts via + matplotlib, the `latex-pdf-report` skill for PDFs, the `powerpoint` skill + for decks, `image_generate` for images, `text_to_speech` for audio, and so + on. + +2. **The gateway scans agent responses for file paths.** Any absolute path + (`/tmp/...`) or home-relative path (`~/...`) ending in a supported + extension gets extracted. Paths inside code blocks and inline code are + ignored so code samples are never mutilated. + +3. **The gateway dispatches by file type.** Images embed inline where the + platform supports it; videos embed inline; audio routes to voice/audio + attachments; everything else uploads as a file attachment. + +## Supported file extensions + +| Category | Extensions | Delivery | +|---|---|---| +| Images | `.png .jpg .jpeg .gif .webp .bmp .tiff .svg` | Inline embed | +| Video | `.mp4 .mov .avi .mkv .webm` | Inline embed (where supported) | +| Audio | `.mp3 .wav .ogg .m4a .flac` | Voice / audio attachment | +| Documents | `.pdf .docx .doc .odt .rtf .txt .md` | File upload | +| Data | `.xlsx .xls .csv .tsv .json .xml .yaml .yml` | File upload | +| Presentations | `.pptx .ppt .odp` | File upload | +| Archives | `.zip .tar .gz .tgz .bz2 .7z` | File upload | +| Web | `.html .htm` | File upload | + +`.py`, `.log`, and other source-file extensions are intentionally excluded so +the agent doesn't auto-ship arbitrary source files; if you want to send code +to the user, use a code block. + +## Encouraging the agent to produce artifacts + +The agent doesn't reach for artifacts by default — it has to know to. +Two ways to nudge it: + +**Per-session:** ask explicitly ("send me the comparison as a chart", +"return the data as a CSV") or write your own custom-instructions / +personality entry that biases toward artifact-style replies on +messaging platforms. + +**Project-level:** add the bias to `AGENTS.md` / `CLAUDE.md` / +`.cursorrules` in a project the agent works from, or to your global +custom instructions in `~/.hermes/config.yaml` under `agent.custom_instructions`. + +The mechanic the agent has to use is simple: render the file to an +absolute path (e.g. `/tmp/q3-revenue.png`) and mention that path as +plain text in the reply. The gateway does the rest. Paths inside +fenced code blocks or backticks are ignored so code samples are never +mutilated. + +## Kanban: artifacts ride completion notifications + +If you use Hermes' kanban multi-agent workflow, workers can attach +deliverable files to their `kanban_complete` call: + +```python +kanban_complete( + summary="rendered Q3 revenue chart and report", + artifacts=[ + "/tmp/q3-revenue.png", + "/tmp/q3-report.pdf", + ], +) +``` + +When the gateway notifier delivers the "task completed" message to whoever +subscribed to the task in Slack/Telegram/etc., it also uploads each artifact +as a native attachment to that chat. The human gets the deliverable and the +summary in one place. + +Files that don't exist on disk when the notifier runs are silently skipped. + +## Connecting more services with MCP + +Beyond the artifact-delivery pipeline, the agent can reach into other +services via MCP (Model Context Protocol). The MCP ecosystem ships +community servers for most popular tools — install whichever you need: + +| Service | What it unlocks | +|---|---| +| **Notion** | Read/write Notion pages, databases, query workspace | +| **GitHub** | Issues, PRs, comments, repo search beyond the gh CLI | +| **Linear** | Tickets, projects, cycles | +| **Slack** | Workspace-wide search, read other channels | +| **Gmail** | Inbox triage, send mail, label management | +| **Salesforce** | Leads, opportunities, account data | +| **Snowflake / BigQuery** | SQL against data warehouses | +| **Google Drive** | File search, contents, share management | + +Install MCP servers via `~/.hermes/config.yaml` under the `mcp_servers` +section. See [MCP integration](./mcp.md) for the full setup guide. + +## Comparison to Perplexity Computer in Slack + +Perplexity Computer's Slack integration is built around the same idea: +the agent generates a deliverable (chart, PDF, slide deck) and posts it +back into the thread as a native attachment. Hermes Agent's deliverable +mode provides the same user-facing pattern locally: + +- Generation happens in the user's own venv / sandbox (no remote tenant). +- Files land in the chat via the same Slack `files.uploadV2` API. +- Connector breadth comes via MCP rather than a curated catalog of 400 + hosted integrations — install the ones you actually use. + +OAuth tokens stay on the user's machine in `auth.json` / `.env`. No hosted +token storage. No multi-tenant microVM. Same end result. diff --git a/website/sidebars.ts b/website/sidebars.ts index 1a0aa6fb0bb4..7ca300c9d54a 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -89,6 +89,7 @@ const sidebars: SidebarsConfig = { 'user-guide/features/vision', 'user-guide/features/image-generation', 'user-guide/features/tts', + 'user-guide/features/deliverable-mode', ], }, { From 6f5ec929a187739b0b06d2935cac4dc7537ac22c Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Mon, 18 May 2026 16:34:10 +0530 Subject: [PATCH 099/418] feat(config): add install-method stamping + Docker detection (#27843) * feat(config): add install-method stamping + Docker detection Dockerfile stamps "docker", install.sh stamps "git", and cmd_postinstall stamps "pip" into ~/.hermes/.install_method. detect_install_method() reads the stamp first, then falls back to managed-system / container / .git heuristics. Adds Docker upgrade guidance. Tracking: #27826 * fix(stamp): move Docker stamp to entrypoint, install.sh stamp after print_success The Dockerfile stamp was overwritten by the VOLUME overlay at container start. Moving it to entrypoint.sh ensures it persists. The install.sh stamp now writes after print_success so it only lands on full success. --- Dockerfile | 1 + docker/entrypoint.sh | 3 ++ hermes_cli/config.py | 41 +++++++++++++++++-- hermes_cli/main.py | 3 ++ scripts/install.sh | 2 + .../hermes_cli/test_pip_install_detection.py | 31 ++++++++++++-- 6 files changed, 74 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index bde3412ed7f3..6e8f02096361 100644 --- a/Dockerfile +++ b/Dockerfile @@ -115,5 +115,6 @@ RUN uv pip install --no-cache-dir --no-deps -e "." ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist ENV HERMES_HOME=/opt/data ENV PATH="/opt/data/.local/bin:${PATH}" +RUN mkdir -p /opt/data VOLUME [ "/opt/data" ] ENTRYPOINT [ "/usr/bin/tini", "-g", "--", "/opt/hermes/docker/entrypoint.sh" ] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 09e870543a20..9af045e226fd 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -61,6 +61,9 @@ fi # --- Running as hermes from here --- source "${INSTALL_DIR}/.venv/bin/activate" +# Stamp install method for detect_install_method() +echo "docker" > "${HERMES_HOME:=/opt/data}/.install_method" 2>/dev/null || true + # Create essential directory structure. Cache and platform directories # (cache/images, cache/audio, platforms/whatsapp, etc.) are created on # demand by the application — don't pre-create them here so new installs diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 84898623fb70..e69c51a4d3b4 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -188,21 +188,42 @@ def is_managed() -> bool: return get_managed_system() is not None +_NIX_UPDATE_MSG = "Update your Nix flake input and rebuild (e.g. nix flake update, nixos-rebuild, or home-manager switch)" + + def get_managed_update_command() -> Optional[str]: """Return the preferred upgrade command for a managed install.""" managed_system = get_managed_system() if managed_system == "Homebrew": return "brew upgrade hermes-agent" if managed_system == "NixOS": - return "sudo nixos-rebuild switch" + return _NIX_UPDATE_MSG return None def detect_install_method(project_root: Optional[Path] = None) -> str: - """Detect how Hermes was installed: 'nixos', 'homebrew', 'git', or 'pip'.""" + """Detect how Hermes was installed: 'docker', 'nixos', 'homebrew', 'git', or 'pip'. + + Resolution order: + 1. Stamped ``~/.hermes/.install_method`` file (written by installers) + 2. HERMES_MANAGED env / .managed marker (NixOS, Homebrew) + 3. Container detection (/.dockerenv, /run/.containerenv, cgroup) + 4. .git directory presence -> 'git' + 5. Fallback -> 'pip' + """ + stamp = get_hermes_home() / ".install_method" + try: + method = stamp.read_text(encoding="utf-8").strip().lower() + if method: + return method + except OSError: + pass managed = get_managed_system() if managed: return managed.lower().replace(" ", "-") + from hermes_constants import is_container + if is_container(): + return "docker" if project_root is None: project_root = Path(__file__).parent.parent.resolve() if (project_root / ".git").is_dir(): @@ -210,12 +231,24 @@ def detect_install_method(project_root: Optional[Path] = None) -> str: return "pip" +def stamp_install_method(method: str) -> None: + """Write the install method to ~/.hermes/.install_method.""" + stamp = get_hermes_home() / ".install_method" + try: + stamp.parent.mkdir(parents=True, exist_ok=True) + stamp.write_text(method + "\n", encoding="utf-8") + except OSError: + pass + + def recommended_update_command_for_method(method: str) -> str: - """Return the update command for a given install method.""" + """Return the update command or guidance for a given install method.""" if method == "nixos": - return "sudo nixos-rebuild switch" + return _NIX_UPDATE_MSG if method == "homebrew": return "brew upgrade hermes-agent" + if method == "docker": + return "docker pull nousresearch/hermes-agent:latest" if method == "pip": import shutil uv = shutil.which("uv") diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 575835b2c7d2..fe2875436734 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1735,8 +1735,11 @@ def cmd_setup(args): def cmd_postinstall(args): """One-shot bootstrap for pip users: install non-Python deps + run setup.""" + from hermes_cli.config import stamp_install_method from hermes_cli.dep_ensure import ensure_dependency + stamp_install_method("pip") + print("⚕ Hermes post-install bootstrap") print() diff --git a/scripts/install.sh b/scripts/install.sh index 9b1b7469bb84..c34c64267c6d 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1996,6 +1996,8 @@ main() { maybe_start_gateway print_success + + echo "git" > "$HERMES_HOME/.install_method" } if [ -n "$ENSURE_DEPS" ]; then diff --git a/tests/hermes_cli/test_pip_install_detection.py b/tests/hermes_cli/test_pip_install_detection.py index b0f4cbd75ad3..da3dd35e329a 100644 --- a/tests/hermes_cli/test_pip_install_detection.py +++ b/tests/hermes_cli/test_pip_install_detection.py @@ -4,7 +4,8 @@ def test_pip_install_detected_when_no_git_dir(tmp_path): """When PROJECT_ROOT has no .git, detect as pip install.""" - with patch("hermes_cli.config.get_managed_system", return_value=None): + with patch("hermes_cli.config.get_managed_system", return_value=None), \ + patch("hermes_cli.config.get_hermes_home", return_value=tmp_path): from hermes_cli.config import detect_install_method method = detect_install_method(project_root=tmp_path) assert method == "pip" @@ -13,7 +14,8 @@ def test_pip_install_detected_when_no_git_dir(tmp_path): def test_git_install_detected_when_git_dir_exists(tmp_path): """When PROJECT_ROOT has .git, detect as git install.""" (tmp_path / ".git").mkdir() - with patch("hermes_cli.config.get_managed_system", return_value=None): + with patch("hermes_cli.config.get_managed_system", return_value=None), \ + patch("hermes_cli.config.get_hermes_home", return_value=tmp_path): from hermes_cli.config import detect_install_method method = detect_install_method(project_root=tmp_path) assert method == "git" @@ -22,7 +24,8 @@ def test_git_install_detected_when_git_dir_exists(tmp_path): def test_managed_install_takes_precedence(tmp_path): """When HERMES_MANAGED is set, that takes precedence over git detection.""" (tmp_path / ".git").mkdir() - with patch("hermes_cli.config.get_managed_system", return_value="NixOS"): + with patch("hermes_cli.config.get_managed_system", return_value="NixOS"), \ + patch("hermes_cli.config.get_hermes_home", return_value=tmp_path): from hermes_cli.config import detect_install_method method = detect_install_method(project_root=tmp_path) assert method == "nixos" @@ -35,3 +38,25 @@ def test_recommended_update_command_pip(): assert "pip install" in cmd or "uv pip install" in cmd assert "--upgrade" in cmd assert "hermes-agent" in cmd + + +def test_stamp_file_takes_precedence(tmp_path): + (tmp_path / ".git").mkdir() + (tmp_path / ".install_method").write_text("docker\n") + with patch("hermes_cli.config.get_managed_system", return_value=None), \ + patch("hermes_cli.config.get_hermes_home", return_value=tmp_path): + from hermes_cli.config import detect_install_method + assert detect_install_method(project_root=tmp_path) == "docker" + + +def test_docker_detected_via_dockerenv(tmp_path): + with patch("hermes_cli.config.get_managed_system", return_value=None), \ + patch("hermes_cli.config.get_hermes_home", return_value=tmp_path), \ + patch("hermes_constants.is_container", return_value=True): + from hermes_cli.config import detect_install_method + assert detect_install_method(project_root=tmp_path) == "docker" + + +def test_recommended_update_command_docker(): + from hermes_cli.config import recommended_update_command_for_method + assert "docker pull" in recommended_update_command_for_method("docker") From e3a254d65b1b83d9ee75d4591113fa65a7f3a13d Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Mon, 18 May 2026 16:34:24 +0530 Subject: [PATCH 100/418] =?UTF-8?q?feat(dep=5Fensure):=20complete=20Window?= =?UTF-8?q?s=20bootstrap=20=E2=80=94=20dep=5Fensure=20+=20install.ps1=20+?= =?UTF-8?q?=20detection=20(#27845)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(dep_ensure): complete Windows bootstrap — dep_ensure + install.ps1 + detection dep_ensure.py gains Windows awareness: PowerShell invocation, platform- specific browser detection, (path, shell) tuple returns. install.ps1 gains -Ensure/-PostInstall modes using npm -g --prefix (aligned with install.sh) and agent-browser install for Chromium. browser_tool.py gains node/ in candidate dirs for Windows .cmd shims. Both install scripts bundled in pip wheel. Tracking: #27826 * fix(install.ps1): add --ignore-scripts to npm install for camofox @askjo/camofox-browser has a dependency (impit) whose postinstall script runs `npx only-allow pnpm`, which fails under npm. Adding --ignore-scripts avoids the spurious failure without affecting functionality. Tracking: #27826 * fix: remove duplicate install scripts from git CI already copies scripts/install.{sh,ps1} into hermes_cli/scripts/ during wheel build. No need to commit copies — .gitignore keeps them out, _find_install_script() falls back to scripts/ for git-clone users. Tracking: #27826 * fix: address review — remove env_extra, fix ps1 error handling - Remove unused env_extra parameter from ensure_dependency() - Invoke-EnsureMode node case now uses Test-Node consistently - Install-AgentBrowser uses throw instead of exit 1 --- .github/workflows/upload_to_pypi.yml | 3 +- hermes_cli/dep_ensure.py | 89 ++++++++++++--- pyproject.toml | 2 +- scripts/install.ps1 | 160 ++++++++++++++++++++++++++- tests/hermes_cli/test_dep_ensure.py | 134 ++++++++++++++++++++-- tools/browser_tool.py | 9 +- 6 files changed, 368 insertions(+), 29 deletions(-) diff --git a/.github/workflows/upload_to_pypi.yml b/.github/workflows/upload_to_pypi.yml index 95477ccf01fc..86e7ae477b33 100644 --- a/.github/workflows/upload_to_pypi.yml +++ b/.github/workflows/upload_to_pypi.yml @@ -71,10 +71,11 @@ jobs: test -f hermes_cli/web_dist/index.html || { echo "ERROR: web_dist not built"; exit 1; } test -f hermes_cli/tui_dist/entry.js || { echo "ERROR: tui_dist not built"; exit 1; } - - name: Bundle install.sh into wheel + - name: Bundle install scripts into wheel run: | mkdir -p hermes_cli/scripts cp scripts/install.sh hermes_cli/scripts/install.sh + cp scripts/install.ps1 hermes_cli/scripts/install.ps1 - name: Build wheel and sdist run: uv build --sdist --wheel diff --git a/hermes_cli/dep_ensure.py b/hermes_cli/dep_ensure.py index 1067b428f7b0..848e402396cc 100644 --- a/hermes_cli/dep_ensure.py +++ b/hermes_cli/dep_ensure.py @@ -16,11 +16,14 @@ from __future__ import annotations import os +import platform import shutil import subprocess import sys from pathlib import Path +_IS_WINDOWS = platform.system() == "Windows" + _DEP_CHECKS = { "node": lambda: shutil.which("node") is not None, "browser": lambda: ( @@ -41,7 +44,11 @@ def _has_system_browser() -> bool: - for name in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome"): + if _IS_WINDOWS: + names = ("chrome", "msedge", "chromium") + else: + names = ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome") + for name in names: if shutil.which(name): return True return False @@ -49,39 +56,67 @@ def _has_system_browser() -> bool: def _has_hermes_agent_browser() -> bool: from hermes_constants import get_hermes_home - return (get_hermes_home() / "node_modules" / ".bin" / "agent-browser").is_file() + home = get_hermes_home() + if _IS_WINDOWS: + # npm -g --prefix puts .cmd shims directly in the prefix dir on Windows + return (home / "node" / "agent-browser.cmd").is_file() + # install.sh installs globally into $HERMES_HOME/node/bin/ via npm -g --prefix + # Also check legacy node_modules/.bin/ path for git-clone installs. + return ( + (home / "node" / "bin" / "agent-browser").is_file() + or (home / "node_modules" / ".bin" / "agent-browser").is_file() + ) def _find_install_script( package_dir: Path | None = None, repo_root: Path | None = None, -) -> Path | None: - """Locate install.sh — bundled in wheel or in git checkout.""" +) -> tuple[Path | None, str | None]: + """Locate the install script — bundled in wheel or in git checkout. + + On Windows, prefers install.ps1; on POSIX, prefers install.sh. + Returns a (path, shell) tuple, or (None, None) if neither is found. + """ if package_dir is None: package_dir = Path(__file__).parent if repo_root is None: repo_root = package_dir.parent - bundled = package_dir / "scripts" / "install.sh" - if bundled.is_file(): - return bundled - repo = repo_root / "scripts" / "install.sh" - if repo.is_file(): - return repo - return None + if _IS_WINDOWS: + preferred = ("install.ps1", "powershell") + fallback = ("install.sh", "bash") + else: + preferred = ("install.sh", "bash") + fallback = ("install.ps1", "powershell") + + for script_name, shell in (preferred, fallback): + bundled = package_dir / "scripts" / script_name + if bundled.is_file(): + return bundled, shell + repo = repo_root / "scripts" / script_name + if repo.is_file(): + return repo, shell + return None, None -def ensure_dependency(dep: str, interactive: bool = True) -> bool: + +def ensure_dependency( + dep: str, + interactive: bool = True, +) -> bool: """Ensure a non-Python dependency is available. Returns True if available.""" check = _DEP_CHECKS.get(dep) - if check and check(): + if check is None: + # Unknown dep — don't silently forward to install script. + return False + if check(): return True - script = _find_install_script() + script, shell = _find_install_script() if script is None: if interactive: desc = _DEP_DESCRIPTIONS.get(dep, dep) - print(f" {desc} is not installed and install.sh was not found.") + print(f" {desc} is not installed and no install script was found.") print(f" Install {dep} manually and try again.") return False @@ -91,12 +126,30 @@ def ensure_dependency(dep: str, interactive: bool = True) -> bool: reply = input(f"{desc} is not installed. Install now? [Y/n] ").strip().lower() except (EOFError, KeyboardInterrupt): return False - if reply not in {"", "y", "yes"}: + if reply not in ("", "y", "yes"): return False + if shell == "powershell": + from hermes_constants import get_hermes_home + ps_bin = shutil.which("powershell") or shutil.which("pwsh") + if not ps_bin: + if interactive: + print(" PowerShell not found. Install PowerShell or run install.ps1 manually.") + return False + cmd = [ + ps_bin, + "-ExecutionPolicy", "Bypass", + "-File", str(script), + "-Ensure", dep, + "-HermesHome", str(get_hermes_home()), + ] + else: + cmd = ["bash", str(script), "--ensure", dep] + + run_env = {**os.environ, "IS_INTERACTIVE": "false"} result = subprocess.run( - ["bash", str(script), "--ensure", dep], - env={**os.environ, "IS_INTERACTIVE": "false"}, + cmd, + env=run_env, ) if result.returncode != 0: return False diff --git a/pyproject.toml b/pyproject.toml index ba66d0da7191..cb3c515e0217 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -210,7 +210,7 @@ hermes-acp = "acp_adapter.entry:main" py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_bootstrap", "hermes_constants", "hermes_state", "hermes_time", "hermes_logging", "utils"] [tool.setuptools.package-data] -hermes_cli = ["web_dist/**/*"] +hermes_cli = ["web_dist/**/*", "tui_dist/**/*", "scripts/install.sh", "scripts/install.ps1"] gateway = ["assets/**/*"] [tool.setuptools.packages.find] diff --git a/scripts/install.ps1 b/scripts/install.ps1 index c774e9a860cc..7fb618eca6e7 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -28,7 +28,11 @@ param( [string]$Stage, [switch]$ProtocolVersion, [switch]$NonInteractive, - [switch]$Json + [switch]$Json, + + # --- Ensure mode (dep_ensure.py entry point) --- + [string]$Ensure = "", + [switch]$PostInstall ) $ErrorActionPreference = "Stop" @@ -108,6 +112,105 @@ function Write-Err { Write-Host "[X] $Message" -ForegroundColor Red } +# --- Ensure-mode helpers --- + +function Resolve-NpmCmd { + $npmCmd = Get-Command npm -ErrorAction SilentlyContinue + if (-not $npmCmd) { return $null } + $npmExe = $npmCmd.Source + if ($npmExe -like "*.ps1") { + $npmCmdSibling = Join-Path (Split-Path $npmExe -Parent) "npm.cmd" + if (Test-Path $npmCmdSibling) { return $npmCmdSibling } + } + return $npmExe +} + +function Find-SystemBrowser { + $candidates = @( + "${env:ProgramFiles}\Google\Chrome\Application\chrome.exe", + "${env:ProgramFiles(x86)}\Google\Chrome\Application\chrome.exe", + "${env:LOCALAPPDATA}\Google\Chrome\Application\chrome.exe", + "${env:ProgramFiles}\Microsoft\Edge\Application\msedge.exe", + "${env:ProgramFiles(x86)}\Microsoft\Edge\Application\msedge.exe", + "${env:ProgramFiles}\Chromium\Application\chrome.exe", + "${env:LOCALAPPDATA}\Chromium\Application\chrome.exe" + ) + foreach ($p in $candidates) { + if (Test-Path $p) { return $p } + } + return $null +} + +function Write-BrowserEnv { + param([string]$BrowserPath) + if (-not (Test-Path $HermesHome)) { + New-Item -ItemType Directory -Force -Path $HermesHome | Out-Null + } + $envFile = Join-Path $HermesHome ".env" + if (-not (Test-Path $envFile)) { + Set-Content -Path $envFile -Value "AGENT_BROWSER_EXECUTABLE_PATH=$BrowserPath" -Encoding UTF8 + return + } + $content = Get-Content $envFile -Raw -ErrorAction SilentlyContinue + if ($content -and $content -match "AGENT_BROWSER_EXECUTABLE_PATH=") { return } + Add-Content -Path $envFile -Value "AGENT_BROWSER_EXECUTABLE_PATH=$BrowserPath" -Encoding UTF8 +} + +function Install-AgentBrowser { + param([switch]$SkipChromium) + $npm = Resolve-NpmCmd + if (-not $npm) { + Write-Err "npm not found -- install Node.js first" + throw "npm not found" + } + + Write-Info "Installing agent-browser via npm -g --prefix..." + $prefixDir = Join-Path $HermesHome "node" + if (-not (Test-Path $prefixDir)) { + New-Item -ItemType Directory -Path $prefixDir -Force | Out-Null + } + $npmLog = [System.IO.Path]::GetTempFileName() + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = "Continue" + & $npm install -g --prefix $prefixDir --silent --ignore-scripts "agent-browser@^0.26.0" "@askjo/camofox-browser@^1.5.2" 2>&1 | Tee-Object -FilePath $npmLog | Out-Null + $npmExit = $LASTEXITCODE + $ErrorActionPreference = $prevEAP + if ($npmExit -ne 0) { + $npmDetail = Get-Content $npmLog -Raw -ErrorAction SilentlyContinue + Remove-Item $npmLog -Force -ErrorAction SilentlyContinue + Write-Err "npm install -g failed (exit $npmExit): $npmDetail" + throw "npm install failed" + } + Remove-Item $npmLog -Force -ErrorAction SilentlyContinue + + if (-not $SkipChromium) { + $sysBrowser = Find-SystemBrowser + if ($sysBrowser) { + Write-BrowserEnv -BrowserPath $sysBrowser + Write-Info "System browser detected -- skipping Chromium download" + } else { + $abExe = Join-Path $prefixDir "agent-browser.cmd" + if (Test-Path $abExe) { + Write-Info "Installing Chromium via agent-browser install..." + $abLog = [System.IO.Path]::GetTempFileName() + $prevEAP = $ErrorActionPreference + $ErrorActionPreference = "Continue" + & $abExe install 2>&1 | Tee-Object -FilePath $abLog | Out-Null + $abExit = $LASTEXITCODE + $ErrorActionPreference = $prevEAP + if ($abExit -ne 0) { + $abDetail = Get-Content $abLog -Raw -ErrorAction SilentlyContinue + Write-Warn "Chromium install failed (exit $abExit): $abDetail" + } + Remove-Item $abLog -Force -ErrorAction SilentlyContinue + } else { + Write-Warn "agent-browser.cmd not found at $abExe" + } + } + } + Write-Success "Agent-browser ready" +} + # ============================================================================ # Dependency checks # ============================================================================ @@ -2043,6 +2146,48 @@ function Invoke-AllStages { } } +function Invoke-EnsureMode { + param([string]$Deps) + $depList = $Deps -split "," + foreach ($dep in $depList) { + $dep = $dep.Trim() + switch ($dep) { + "node" { + [void](Test-Node) + if (-not $script:HasNode) { + Write-Err "Node.js could not be installed" + exit 1 + } + } + "browser" { + [void](Test-Node) + if ($script:HasNode) { + Install-AgentBrowser + } else { + Write-Err "Node.js is required for browser tools but could not be installed" + exit 1 + } + } + "ripgrep" { + Write-Info "ripgrep: install manually on Windows (scoop install ripgrep)" + } + "ffmpeg" { + Write-Info "ffmpeg: install manually on Windows (scoop install ffmpeg)" + } + default { + Write-Err "Unknown dependency: $dep" + exit 1 + } + } + } +} + +function Invoke-PostInstallMode { + Write-Info "Running post-install setup..." + Invoke-EnsureMode -Deps "node,browser" + Write-Info "Post-install complete" +} + function Main { Write-Banner Invoke-AllStages @@ -2062,6 +2207,19 @@ function Main { # structured JSON error frame instead of a bare exception. try { + if ($Ensure -ne "") { + if ($PSBoundParameters.ContainsKey("Stage")) { + Write-Err "Cannot use -Ensure and -Stage simultaneously" + exit 1 + } + Invoke-EnsureMode -Deps $Ensure + exit 0 + } + if ($PostInstall) { + Invoke-PostInstallMode + exit 0 + } + if ($ProtocolVersion) { Write-Output $InstallStageProtocolVersion exit 0 diff --git a/tests/hermes_cli/test_dep_ensure.py b/tests/hermes_cli/test_dep_ensure.py index c980c290099e..77fee5b7ec5d 100644 --- a/tests/hermes_cli/test_dep_ensure.py +++ b/tests/hermes_cli/test_dep_ensure.py @@ -16,7 +16,7 @@ def test_ensure_dependency_returns_false_when_missing_noninteractive(): from hermes_cli.dep_ensure import ensure_dependency with patch("hermes_cli.dep_ensure.shutil") as mock_shutil: mock_shutil.which.return_value = None - with patch("hermes_cli.dep_ensure._find_install_script", return_value=None): + with patch("hermes_cli.dep_ensure._find_install_script", return_value=(None, None)): result = ensure_dependency("node", interactive=False) assert result is False @@ -27,9 +27,11 @@ def test_find_install_script_from_checkout(tmp_path): scripts_dir = tmp_path / "scripts" scripts_dir.mkdir() (scripts_dir / "install.sh").write_text("#!/bin/bash", encoding="utf-8") - result = _find_install_script(package_dir=tmp_path / "hermes_cli", repo_root=tmp_path) - assert result is not None - assert result.name == "install.sh" + with patch("hermes_cli.dep_ensure._IS_WINDOWS", False): + path, shell = _find_install_script(package_dir=tmp_path / "hermes_cli", repo_root=tmp_path) + assert path is not None + assert path.name == "install.sh" + assert shell == "bash" def test_find_install_script_from_wheel(tmp_path): @@ -38,6 +40,124 @@ def test_find_install_script_from_wheel(tmp_path): bundled = tmp_path / "hermes_cli" / "scripts" bundled.mkdir(parents=True) (bundled / "install.sh").write_text("#!/bin/bash", encoding="utf-8") - result = _find_install_script(package_dir=tmp_path / "hermes_cli", repo_root=tmp_path) - assert result is not None - assert result.name == "install.sh" + with patch("hermes_cli.dep_ensure._IS_WINDOWS", False): + path, shell = _find_install_script(package_dir=tmp_path / "hermes_cli", repo_root=tmp_path) + assert path is not None + assert path.name == "install.sh" + assert shell == "bash" + + +def test_find_install_script_prefers_ps1_on_windows(tmp_path): + """On Windows, _find_install_script should find install.ps1.""" + scripts_dir = tmp_path / "hermes_cli" / "scripts" + scripts_dir.mkdir(parents=True) + (scripts_dir / "install.ps1").write_text("# fake") + (scripts_dir / "install.sh").write_text("# fake") + from hermes_cli.dep_ensure import _find_install_script + with patch("hermes_cli.dep_ensure._IS_WINDOWS", True): + path, shell = _find_install_script(package_dir=tmp_path / "hermes_cli") + assert path == scripts_dir / "install.ps1" + assert shell == "powershell" + + +def test_find_install_script_returns_sh_on_posix(tmp_path): + """On POSIX, _find_install_script should find install.sh.""" + scripts_dir = tmp_path / "hermes_cli" / "scripts" + scripts_dir.mkdir(parents=True) + (scripts_dir / "install.ps1").write_text("# fake") + (scripts_dir / "install.sh").write_text("# fake") + from hermes_cli.dep_ensure import _find_install_script + with patch("hermes_cli.dep_ensure._IS_WINDOWS", False): + path, shell = _find_install_script(package_dir=tmp_path / "hermes_cli") + assert path == scripts_dir / "install.sh" + assert shell == "bash" + + +def test_find_install_script_falls_back_to_repo_root(tmp_path): + """When no bundled script, check repo root.""" + repo_root = tmp_path / "repo" + (repo_root / "scripts").mkdir(parents=True) + (repo_root / "scripts" / "install.sh").write_text("# fake") + from hermes_cli.dep_ensure import _find_install_script + with patch("hermes_cli.dep_ensure._IS_WINDOWS", False): + path, shell = _find_install_script(package_dir=tmp_path / "hermes_cli", repo_root=repo_root) + assert path == repo_root / "scripts" / "install.sh" + assert shell == "bash" + + +def test_find_install_script_returns_none_when_missing(tmp_path): + from hermes_cli.dep_ensure import _find_install_script + with patch("hermes_cli.dep_ensure._IS_WINDOWS", False): + result = _find_install_script(package_dir=tmp_path / "x", repo_root=tmp_path / "y") + assert result == (None, None) + + +def test_has_system_browser_checks_windows_names(): + from hermes_cli.dep_ensure import _has_system_browser + with patch("hermes_cli.dep_ensure._IS_WINDOWS", True), \ + patch("hermes_cli.dep_ensure.shutil") as mock_shutil: + mock_shutil.which.side_effect = lambda name: "/fake/msedge.exe" if name == "msedge" else None + assert _has_system_browser() is True + + +def test_has_system_browser_checks_posix_names(): + from hermes_cli.dep_ensure import _has_system_browser + with patch("hermes_cli.dep_ensure._IS_WINDOWS", False), \ + patch("hermes_cli.dep_ensure.shutil") as mock_shutil: + mock_shutil.which.return_value = None + assert _has_system_browser() is False + + +def test_has_hermes_agent_browser_windows_path(tmp_path): + node_dir = tmp_path / "node" + node_dir.mkdir(parents=True) + (node_dir / "agent-browser.cmd").write_text("@echo off") + from hermes_cli.dep_ensure import _has_hermes_agent_browser + with patch("hermes_cli.dep_ensure._IS_WINDOWS", True), \ + patch("hermes_constants.get_hermes_home", return_value=tmp_path): + assert _has_hermes_agent_browser() is True + + +def test_has_hermes_agent_browser_posix_path(tmp_path): + bin_dir = tmp_path / "node" / "bin" + bin_dir.mkdir(parents=True) + (bin_dir / "agent-browser").write_text("#!/bin/sh") + from hermes_cli.dep_ensure import _has_hermes_agent_browser + with patch("hermes_cli.dep_ensure._IS_WINDOWS", False), \ + patch("hermes_constants.get_hermes_home", return_value=tmp_path): + assert _has_hermes_agent_browser() is True + + +def test_has_hermes_agent_browser_legacy_node_modules_path(tmp_path): + """Legacy git-clone installs put agent-browser in $HERMES_HOME/node_modules/.bin/.""" + bin_dir = tmp_path / "node_modules" / ".bin" + bin_dir.mkdir(parents=True) + (bin_dir / "agent-browser").write_text("#!/bin/sh") + from hermes_cli.dep_ensure import _has_hermes_agent_browser + with patch("hermes_cli.dep_ensure._IS_WINDOWS", False), \ + patch("hermes_constants.get_hermes_home", return_value=tmp_path): + assert _has_hermes_agent_browser() is True + + +def test_ensure_dependency_uses_powershell_on_windows(tmp_path): + from hermes_cli.dep_ensure import ensure_dependency + scripts_dir = tmp_path / "scripts" + scripts_dir.mkdir(parents=True) + (scripts_dir / "install.ps1").write_text("# fake") + with patch("hermes_cli.dep_ensure._IS_WINDOWS", True), \ + patch("hermes_cli.dep_ensure._DEP_CHECKS", {"node": lambda: False}), \ + patch("hermes_cli.dep_ensure._find_install_script", return_value=(scripts_dir / "install.ps1", "powershell")), \ + patch("hermes_cli.dep_ensure.shutil") as mock_shutil, \ + patch("hermes_constants.get_hermes_home", return_value=tmp_path / "fakehome"), \ + patch("subprocess.run") as mock_run, \ + patch("sys.stdin") as mock_stdin: + mock_shutil.which.side_effect = lambda name: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" if name == "powershell" else None + mock_stdin.isatty.return_value = False + mock_run.return_value = type("R", (), {"returncode": 0})() + ensure_dependency("node", interactive=False) + cmd = mock_run.call_args[0][0] + assert "powershell" in cmd[0].lower() + assert "-Ensure" in cmd + assert cmd[cmd.index("-Ensure") + 1] == "node" + assert "-HermesHome" in cmd + assert str(tmp_path / "fakehome") in cmd diff --git a/tools/browser_tool.py b/tools/browser_tool.py index fb96649cb386..447f65007140 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -158,8 +158,9 @@ def _browser_candidate_path_dirs() -> list[str]: """Return ordered browser CLI PATH candidates shared by discovery and execution.""" hermes_home = get_hermes_home() hermes_node_bin = str(hermes_home / "node" / "bin") + hermes_node_root = str(hermes_home / "node") hermes_nm_bin = str(hermes_home / "node_modules" / ".bin") - return [hermes_node_bin, hermes_nm_bin, *list(_discover_homebrew_node_dirs()), *_SANE_PATH_DIRS] + return [hermes_node_bin, hermes_node_root, hermes_nm_bin, *list(_discover_homebrew_node_dirs()), *_SANE_PATH_DIRS] def _merge_browser_path(existing_path: str = "") -> str: @@ -1827,6 +1828,12 @@ def _find_agent_browser() -> str: if not recheck: hermes_nm = str(get_hermes_home() / "node_modules" / ".bin") recheck = shutil.which("agent-browser", path=hermes_nm) + if not recheck: + hermes_node_bin = str(get_hermes_home() / "node" / "bin") + recheck = shutil.which("agent-browser", path=hermes_node_bin) + if not recheck: + hermes_node_root = str(get_hermes_home() / "node") + recheck = shutil.which("agent-browser", path=hermes_node_root) if recheck: _cached_agent_browser = recheck _agent_browser_resolved = True From d9b6f75c0b0ffa3cdb3cbe63de3a8e1a5aa44e8f Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Mon, 18 May 2026 16:36:26 +0530 Subject: [PATCH 101/418] refactor(bootstrap): consolidate ACP browser bootstrap into install.{sh,ps1} (#27851) * refactor(bootstrap): consolidate ACP browser bootstrap into install.{sh,ps1} Delete 687 lines of duplicated browser bootstrap code from acp_adapter/bootstrap/. All browser installation now routes through dep_ensure -> install.{sh,ps1} --ensure, using agent-browser install for Chromium. install.sh gains ensure_browser() with macOS app-bundle detection and per-distro guidance. Tracking: #27826 * fix(install.sh): add --ignore-scripts to npm install for camofox @askjo/camofox-browser has a dependency (impit) whose postinstall script runs `npx only-allow pnpm`, which fails under npm. Adding --ignore-scripts avoids the spurious failure without affecting functionality. Tracking: #27826 * fix: add explicit return in ensure_browser, narrow exception in entry.py ensure_browser() now returns 0 explicitly on all success paths. _run_setup_browser() catches OSError instead of broad Exception, letting ImportError propagate as a real packaging bug. --- acp_adapter/bootstrap/__init__.py | 0 .../bootstrap/bootstrap_browser_tools.ps1 | 288 ------------- .../bootstrap/bootstrap_browser_tools.sh | 399 ------------------ acp_adapter/entry.py | 61 +-- scripts/install.sh | 110 +++-- tests/acp/test_entry.py | 111 ++--- 6 files changed, 139 insertions(+), 830 deletions(-) delete mode 100644 acp_adapter/bootstrap/__init__.py delete mode 100644 acp_adapter/bootstrap/bootstrap_browser_tools.ps1 delete mode 100755 acp_adapter/bootstrap/bootstrap_browser_tools.sh diff --git a/acp_adapter/bootstrap/__init__.py b/acp_adapter/bootstrap/__init__.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/acp_adapter/bootstrap/bootstrap_browser_tools.ps1 b/acp_adapter/bootstrap/bootstrap_browser_tools.ps1 deleted file mode 100644 index f840fd2d5592..000000000000 --- a/acp_adapter/bootstrap/bootstrap_browser_tools.ps1 +++ /dev/null @@ -1,288 +0,0 @@ -# bootstrap_browser_tools.ps1 — install agent-browser + Playwright Chromium -# into ~/.hermes/node/ for use by Hermes Agent's browser tools on Windows. -# -# Targets the registry-install path: users who got Hermes via -# `uvx --from 'hermes-agent[acp]==X' hermes-acp` don't have a repo clone, -# so the install.ps1 `npm install`-in-repo flow doesn't apply. This script -# is a self-contained, idempotent slice of install.ps1's browser block. -# -# Usage: -# .\bootstrap_browser_tools.ps1 # use defaults -# .\bootstrap_browser_tools.ps1 -Yes # accept Chromium download -# .\bootstrap_browser_tools.ps1 -SkipChromium # Node + agent-browser only -# -# Idempotent: re-running this is safe and fast. - -[CmdletBinding()] -param( - [switch]$Yes, - [switch]$SkipChromium -) - -$ErrorActionPreference = "Stop" -$NodeVersion = "22" - -# ───────────────────────────────────────────────────────────────────────── -# Logging -# ───────────────────────────────────────────────────────────────────────── - -function Write-Info { param([string]$msg) Write-Host "[*] $msg" -ForegroundColor Cyan } -function Write-Success { param([string]$msg) Write-Host "[+] $msg" -ForegroundColor Green } -function Write-Warn { param([string]$msg) Write-Host "[!] $msg" -ForegroundColor Yellow } -function Write-Err { param([string]$msg) Write-Host "[x] $msg" -ForegroundColor Red } - -# ───────────────────────────────────────────────────────────────────────── -# Paths -# ───────────────────────────────────────────────────────────────────────── - -$HermesHome = $env:HERMES_HOME -if (-not $HermesHome) { - $HermesHome = Join-Path $env:USERPROFILE ".hermes" -} -$NodePrefix = Join-Path $HermesHome "node" - -# ───────────────────────────────────────────────────────────────────────── -# Step 1: Node.js -# ───────────────────────────────────────────────────────────────────────── - -function Resolve-NpmExe { - # Same gotcha as install.ps1: prefer npm.cmd over npm.ps1 so the - # PowerShell execution policy doesn't block us. - $cmd = Get-Command npm -ErrorAction SilentlyContinue - if (-not $cmd) { return $null } - $npmExe = $cmd.Source - if ($npmExe -like "*.ps1") { - $sibling = Join-Path (Split-Path $npmExe -Parent) "npm.cmd" - if (Test-Path $sibling) { return $sibling } - } - return $npmExe -} - -function Resolve-NpxExe { - $cmd = Get-Command npx -ErrorAction SilentlyContinue - if (-not $cmd) { return $null } - $npxExe = $cmd.Source - if ($npxExe -like "*.ps1") { - $sibling = Join-Path (Split-Path $npxExe -Parent) "npx.cmd" - if (Test-Path $sibling) { return $sibling } - } - return $npxExe -} - -function Ensure-Node { - # System Node on PATH? - $sysNode = Get-Command node -ErrorAction SilentlyContinue - if ($sysNode) { - try { - $v = & $sysNode.Source --version - $major = [int]($v -replace '^v(\d+).*', '$1') - if ($major -ge 20) { - Write-Success "Node.js $v found on PATH" - return - } - Write-Warn "Node.js $v is older than v20 — installing managed Node." - } catch { - Write-Warn "Failed to query Node version: $_" - } - } - - # Hermes-managed Node? - $managedNode = Join-Path $NodePrefix "node.exe" - if (Test-Path $managedNode) { - $v = & $managedNode --version - Write-Success "Node.js $v found (Hermes-managed at $NodePrefix)" - # Prepend to current-process PATH so subsequent npm/npx calls find it. - $env:PATH = "$NodePrefix;$env:PATH" - return - } - - Write-Info "Installing Node.js $NodeVersion LTS into $NodePrefix ..." - - $arch = if ([Environment]::Is64BitOperatingSystem) { "x64" } else { "x86" } - $indexUrl = "https://nodejs.org/dist/latest-v${NodeVersion}.x/" - - try { - $indexPage = Invoke-WebRequest -Uri $indexUrl -UseBasicParsing - $matches = [regex]::Matches($indexPage.Content, "node-v${NodeVersion}\.\d+\.\d+-win-${arch}\.zip") - if ($matches.Count -eq 0) { - Write-Err "Could not locate Node.js $NodeVersion zip for win-$arch" - throw "no tarball" - } - $zipName = $matches[0].Value - $zipUrl = "$indexUrl$zipName" - - $tmpDir = Join-Path $env:TEMP "hermes-node-$([guid]::NewGuid().ToString('N'))" - New-Item -ItemType Directory -Force -Path $tmpDir | Out-Null - $zipPath = Join-Path $tmpDir $zipName - - Write-Info "Downloading $zipName ..." - Invoke-WebRequest -Uri $zipUrl -OutFile $zipPath -UseBasicParsing - - Expand-Archive -Path $zipPath -DestinationPath $tmpDir -Force - $extracted = Get-ChildItem -Path $tmpDir -Directory | Where-Object { $_.Name -like "node-v*" } | Select-Object -First 1 - - if (-not $extracted) { Write-Err "Node.js extraction failed"; throw "extract" } - - if (Test-Path $NodePrefix) { Remove-Item -Recurse -Force $NodePrefix } - New-Item -ItemType Directory -Force -Path $HermesHome | Out-Null - Move-Item -Path $extracted.FullName -Destination $NodePrefix - - Remove-Item -Recurse -Force $tmpDir -ErrorAction SilentlyContinue - - $env:PATH = "$NodePrefix;$env:PATH" - $v = & "$NodePrefix\node.exe" --version - Write-Success "Node.js $v installed to $NodePrefix" - } catch { - Write-Err "Node.js install failed: $_" - Write-Info "Install Node 20+ manually from https://nodejs.org/en/download/ and re-run." - throw - } -} - -# ───────────────────────────────────────────────────────────────────────── -# Step 2: agent-browser -# ───────────────────────────────────────────────────────────────────────── - -function Ensure-AgentBrowser { - $npmExe = Resolve-NpmExe - if (-not $npmExe) { - Write-Err "npm not on PATH after Node install — aborting" - throw "npm missing" - } - - # Already installed? - $existing = Get-Command agent-browser -ErrorAction SilentlyContinue - if ($existing) { - Write-Success "agent-browser already installed at $($existing.Source)" - return - } - - # When the user has system Node (winget / installer-based), `npm install - # -g` writes to a directory that may require admin rights. Force the - # prefix to the user-writable Hermes-managed Node directory so we never - # need elevation and the agent can always find the result. Mirrors the - # bash bootstrap's `--prefix $NODE_PREFIX` strategy. - New-Item -ItemType Directory -Force -Path $NodePrefix | Out-Null - - Write-Info "Installing agent-browser (npm, prefix=$NodePrefix)..." - & $npmExe install -g --prefix $NodePrefix --silent ` - "agent-browser@^0.26.0" "@askjo/camofox-browser@^1.5.2" - if ($LASTEXITCODE -ne 0) { - Write-Err "npm install -g agent-browser failed (exit $LASTEXITCODE)" - throw "npm install" - } - - # Windows npm global installs drop shims at $NodePrefix\ root (not bin/). - # Prepend to PATH so any subsequent npx call resolves them. - $env:PATH = "$NodePrefix;$env:PATH" - - Write-Success "agent-browser installed to $NodePrefix" -} - -# ───────────────────────────────────────────────────────────────────────── -# Step 3: Playwright Chromium -# ───────────────────────────────────────────────────────────────────────── - -function Find-SystemBrowser { - $candidates = @( - "C:\Program Files\Google\Chrome\Application\chrome.exe", - "C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", - "C:\Program Files\Chromium\Application\chromium.exe", - "${env:LOCALAPPDATA}\Google\Chrome\Application\chrome.exe", - "${env:LOCALAPPDATA}\Chromium\Application\chromium.exe" - ) - foreach ($p in $candidates) { - if (Test-Path $p) { return $p } - } - # Edge — Chromium-based, agent-browser can use it - foreach ($p in @( - "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe", - "C:\Program Files\Microsoft\Edge\Application\msedge.exe" - )) { - if (Test-Path $p) { return $p } - } - return $null -} - -function Write-BrowserEnv { - param([string]$BrowserPath) - $envFile = Join-Path $HermesHome ".env" - New-Item -ItemType Directory -Force -Path $HermesHome | Out-Null - if (Test-Path $envFile) { - $existing = Get-Content $envFile -Raw -ErrorAction SilentlyContinue - if ($existing -and ($existing -match "(?m)^AGENT_BROWSER_EXECUTABLE_PATH=")) { - return - } - } - Add-Content -Path $envFile -Value "" - Add-Content -Path $envFile -Value "# Hermes Agent browser tools — use the system Chrome/Chromium/Edge binary." - Add-Content -Path $envFile -Value "AGENT_BROWSER_EXECUTABLE_PATH=$BrowserPath" - Write-Success "Configured browser tools to use $BrowserPath" -} - -function Confirm-ChromiumDownload { - if ($Yes) { return $true } - if (-not [Environment]::UserInteractive) { - Write-Warn "Non-interactive shell — skipping Chromium prompt." - Write-Info "Re-run with -Yes to install Chromium (~400 MB download)." - return $false - } - $reply = Read-Host "Install Playwright Chromium (~400 MB download)? [y/N]" - return ($reply -match "^(y|yes)$") -} - -function Ensure-Chromium { - if ($SkipChromium) { - Write-Info "Skipping Chromium install (-SkipChromium)" - return - } - - # agent-browser on Windows expects a Playwright-managed Chromium under - # %LOCALAPPDATA%\ms-playwright. The system-browser shortcut from the - # Linux/macOS path doesn't apply the same way on Windows — Playwright's - # default launch path won't pick up a stock Chrome install without an - # explicit AGENT_BROWSER_EXECUTABLE_PATH. We still offer it as a - # fallback when the user doesn't want the download. - - if (-not (Confirm-ChromiumDownload)) { - $sys = Find-SystemBrowser - if ($sys) { - Write-Info "Using system browser at $sys (Chromium download skipped)." - Write-BrowserEnv -BrowserPath $sys - } else { - Write-Info "Chromium install skipped. Browser tools won't launch until" - Write-Info "Chromium is installed or AGENT_BROWSER_EXECUTABLE_PATH is set." - } - return - } - - $npxExe = Resolve-NpxExe - if (-not $npxExe) { - Write-Err "npx not on PATH — cannot install Playwright Chromium" - throw "npx missing" - } - - Write-Info "Installing Playwright Chromium (~400 MB) ..." - & $npxExe --yes playwright install chromium - if ($LASTEXITCODE -ne 0) { - Write-Err "Playwright Chromium install failed (exit $LASTEXITCODE)" - Write-Info "Try again later: npx --yes playwright install chromium" - throw "playwright" - } - Write-Success "Playwright Chromium installed" -} - -# ───────────────────────────────────────────────────────────────────────── -# Main -# ───────────────────────────────────────────────────────────────────────── - -Write-Info "Hermes Agent: bootstrapping browser tools" -Write-Info " HERMES_HOME = $HermesHome" -Write-Info " OS = Windows" - -Ensure-Node -Ensure-AgentBrowser -Ensure-Chromium - -Write-Success "Browser tools setup complete." -Write-Info "Hermes Agent will pick up agent-browser from $NodePrefix on next launch." diff --git a/acp_adapter/bootstrap/bootstrap_browser_tools.sh b/acp_adapter/bootstrap/bootstrap_browser_tools.sh deleted file mode 100755 index 9981069a6af0..000000000000 --- a/acp_adapter/bootstrap/bootstrap_browser_tools.sh +++ /dev/null @@ -1,399 +0,0 @@ -#!/usr/bin/env bash -# -# bootstrap_browser_tools.sh — install agent-browser + Playwright Chromium -# into ~/.hermes/node/ for use by Hermes Agent's browser tools. -# -# Targets the registry-install path: users who got Hermes via -# `uvx --from 'hermes-agent[acp]==X' hermes-acp` don't have a repo clone, -# so the install.sh `npm install`-in-repo flow doesn't apply. This script -# is a self-contained, idempotent slice of install.sh's browser block — -# safe to run from `hermes-acp --setup-browser`, from a fresh terminal, -# or from install.sh itself (it's a no-op when everything is already in place). -# -# Usage: -# bootstrap_browser_tools.sh # use defaults -# bootstrap_browser_tools.sh --yes # accept the ~400MB Chromium download -# bootstrap_browser_tools.sh --skip-chromium # only install Node + agent-browser -# HERMES_HOME=/custom/path bootstrap_browser_tools.sh -# -# Idempotent: re-running this is safe and fast. Each step checks whether -# the work is already done. - -set -euo pipefail - -# ───────────────────────────────────────────────────────────────────────── -# Config -# ───────────────────────────────────────────────────────────────────────── - -NODE_VERSION="22" -HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" -NODE_PREFIX="$HERMES_HOME/node" - -SKIP_CHROMIUM=false -ASSUME_YES=false - -# ───────────────────────────────────────────────────────────────────────── -# Logging -# ───────────────────────────────────────────────────────────────────────── - -if [ -t 1 ]; then - C_GREEN='\033[0;32m' - C_YELLOW='\033[0;33m' - C_BLUE='\033[0;34m' - C_RED='\033[0;31m' - C_RESET='\033[0m' -else - C_GREEN='' ; C_YELLOW='' ; C_BLUE='' ; C_RED='' ; C_RESET='' -fi - -log_info() { printf "${C_BLUE}[*]${C_RESET} %s\n" "$*"; } -log_success() { printf "${C_GREEN}[✓]${C_RESET} %s\n" "$*"; } -log_warn() { printf "${C_YELLOW}[!]${C_RESET} %s\n" "$*" >&2; } -log_error() { printf "${C_RED}[✗]${C_RESET} %s\n" "$*" >&2; } - -# ───────────────────────────────────────────────────────────────────────── -# Arg parsing -# ───────────────────────────────────────────────────────────────────────── - -while [ $# -gt 0 ]; do - case "$1" in - --skip-chromium) SKIP_CHROMIUM=true ;; - --yes|-y) ASSUME_YES=true ;; - -h|--help) - cat </dev/null 2>&1; then - local found_ver major - found_ver=$(node --version 2>/dev/null) - major=$(echo "$found_ver" | sed -E 's/^v([0-9]+).*/\1/') - if [ -n "$major" ] && [ "$major" -ge 20 ]; then - log_success "Node.js $found_ver found on PATH" - return 0 - fi - log_warn "Node.js $found_ver is older than v20 — installing managed Node." - fi - - if [ -x "$NODE_PREFIX/bin/node" ]; then - local found_ver - found_ver=$("$NODE_PREFIX/bin/node" --version 2>/dev/null || echo "?") - export PATH="$NODE_PREFIX/bin:$PATH" - log_success "Node.js $found_ver found (Hermes-managed at $NODE_PREFIX)" - return 0 - fi - - log_info "Installing Node.js $NODE_VERSION LTS into $NODE_PREFIX ..." - - local index_url="https://nodejs.org/dist/latest-v${NODE_VERSION}.x/" - local tarball_name - tarball_name=$(curl -fsSL "$index_url" \ - | grep -oE "node-v${NODE_VERSION}\.[0-9]+\.[0-9]+-${NODE_OS}-${NODE_ARCH}\.tar\.xz" \ - | head -1) - - if [ -z "$tarball_name" ]; then - tarball_name=$(curl -fsSL "$index_url" \ - | grep -oE "node-v${NODE_VERSION}\.[0-9]+\.[0-9]+-${NODE_OS}-${NODE_ARCH}\.tar\.gz" \ - | head -1) - fi - - if [ -z "$tarball_name" ]; then - log_error "Could not locate Node.js $NODE_VERSION tarball for $NODE_OS-$NODE_ARCH" - log_info "Install Node 20+ manually: https://nodejs.org/en/download/" - return 1 - fi - - local tmp_dir - tmp_dir=$(mktemp -d) - trap 'rm -rf "$tmp_dir"' RETURN - - log_info "Downloading $tarball_name ..." - if ! curl -fsSL "${index_url}${tarball_name}" -o "$tmp_dir/$tarball_name"; then - log_error "Node.js download failed" - return 1 - fi - - if [[ "$tarball_name" == *.tar.xz ]]; then - tar xf "$tmp_dir/$tarball_name" -C "$tmp_dir" - else - tar xzf "$tmp_dir/$tarball_name" -C "$tmp_dir" - fi - - local extracted_dir - extracted_dir=$(ls -d "$tmp_dir"/node-v* 2>/dev/null | head -1) - if [ ! -d "$extracted_dir" ]; then - log_error "Node.js extraction failed" - return 1 - fi - - mkdir -p "$HERMES_HOME" - rm -rf "$NODE_PREFIX" - mv "$extracted_dir" "$NODE_PREFIX" - - export PATH="$NODE_PREFIX/bin:$PATH" - - local installed_ver - installed_ver=$("$NODE_PREFIX/bin/node" --version 2>/dev/null || echo "?") - log_success "Node.js $installed_ver installed to $NODE_PREFIX" -} - -# ───────────────────────────────────────────────────────────────────────── -# Step 2: agent-browser + @askjo/camofox-browser via global npm install -# ───────────────────────────────────────────────────────────────────────── - -ensure_agent_browser() { - if ! command -v npm >/dev/null 2>&1; then - log_error "npm not on PATH after Node install — aborting" - return 1 - fi - - # _find_agent_browser() in tools/browser_tool.py walks ~/.hermes/node/bin - # plus a few standard prefixes, so installing globally into the managed - # Node prefix is enough — no PATH manipulation needed from the agent side. - if [ -x "$NODE_PREFIX/bin/agent-browser" ] || command -v agent-browser >/dev/null 2>&1; then - log_success "agent-browser already installed" - return 0 - fi - - # When the system's `npm` resolves to a root-owned prefix (e.g. - # /usr/lib/node_modules), `npm install -g` fails with EACCES without - # sudo. Force the prefix to the user-writable Hermes-managed Node - # directory so we never need sudo and the agent can always find the - # result. If we installed Node ourselves above, this is a no-op - # (managed Node already uses $NODE_PREFIX). If the user has system - # Node, we still drop agent-browser under $NODE_PREFIX/bin/ — which - # is exactly where _browser_candidate_path_dirs() looks first. - mkdir -p "$NODE_PREFIX" - - log_info "Installing agent-browser (npm, prefix=$NODE_PREFIX)..." - if ! npm install -g --prefix "$NODE_PREFIX" --silent \ - agent-browser@^0.26.0 \ - "@askjo/camofox-browser@^1.5.2"; then - log_error "npm install -g agent-browser failed" - return 1 - fi - - # macOS/Linux global installs place the shim into $NODE_PREFIX/bin/. - # Add it to PATH for any subsequent steps (npx playwright). - export PATH="$NODE_PREFIX/bin:$PATH" - - log_success "agent-browser installed to $NODE_PREFIX/bin/" -} - -# ───────────────────────────────────────────────────────────────────────── -# Step 3: Playwright Chromium -# ───────────────────────────────────────────────────────────────────────── - -confirm_chromium_download() { - if [ "$ASSUME_YES" = true ]; then return 0; fi - if [ ! -t 0 ]; then - log_warn "Non-interactive shell — skipping Chromium prompt." - log_info "Re-run with --yes to install Chromium (~400 MB download)." - return 1 - fi - printf "Install Playwright Chromium (~400 MB download)? [y/N] " - local reply="" - read -r reply || reply="" - case "$reply" in - y|Y|yes|YES) return 0 ;; - *) return 1 ;; - esac -} - -# Detect a usable system Chrome/Chromium. agent-browser's Chrome engine can -# use it instead of downloading Playwright's bundled Chromium, saving the -# download cost. Returns the path or empty string. -find_system_browser() { - local candidate - for candidate in google-chrome google-chrome-stable chromium chromium-browser chrome; do - if command -v "$candidate" >/dev/null 2>&1; then - command -v "$candidate" - return 0 - fi - done - # macOS app-bundle locations - if [ "$OS" = "macos" ]; then - for candidate in \ - "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ - "/Applications/Chromium.app/Contents/MacOS/Chromium" ; do - if [ -x "$candidate" ]; then - echo "$candidate" - return 0 - fi - done - fi - return 1 -} - -write_browser_env() { - local browser_path="$1" - local env_file="$HERMES_HOME/.env" - mkdir -p "$HERMES_HOME" - if [ -f "$env_file" ] && grep -q "^AGENT_BROWSER_EXECUTABLE_PATH=" "$env_file"; then - return 0 - fi - { - echo "" - echo "# Hermes Agent browser tools — use the system Chrome/Chromium binary." - echo "AGENT_BROWSER_EXECUTABLE_PATH=$browser_path" - } >> "$env_file" - log_success "Configured browser tools to use $browser_path" -} - -ensure_chromium() { - if [ "$SKIP_CHROMIUM" = true ]; then - log_info "Skipping Chromium install (--skip-chromium)" - return 0 - fi - - local system_browser - system_browser="$(find_system_browser 2>/dev/null || true)" - if [ -n "$system_browser" ]; then - log_success "Found system browser: $system_browser" - log_info "Skipping Playwright Chromium download; agent-browser will use it." - write_browser_env "$system_browser" - return 0 - fi - - if ! confirm_chromium_download; then - log_info "Chromium install skipped. Browser tools will only work if you" - log_info "set AGENT_BROWSER_EXECUTABLE_PATH or install Chromium later." - return 0 - fi - - if ! command -v npx >/dev/null 2>&1; then - log_error "npx not on PATH — cannot install Playwright Chromium" - return 1 - fi - - log_info "Installing Playwright Chromium (~400 MB) ..." - - # On apt-based distros, --with-deps requires sudo. Try non-interactively - # only — never prompt — and fall back to the bare browser-only install. - local installed=false - if [ "$OS" = "linux" ]; then - case "$DISTRO" in - ubuntu|debian|raspbian|pop|linuxmint|elementary|zorin|kali|parrot) - if [ "$(id -u)" -eq 0 ] || (command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null); then - log_info "Installing system deps with --with-deps (sudo available)" - if npx --yes playwright install --with-deps chromium; then - installed=true - fi - else - log_warn "sudo not available non-interactively — installing Chromium without system deps." - log_info "If browser tools fail to launch, an administrator should run:" - log_info " sudo npx playwright install-deps chromium" - fi - ;; - arch|manjaro|cachyos|endeavouros|garuda) - log_info "Arch-family system dependencies are not auto-installed." - log_info "If launch fails, run: sudo pacman -S nss atk at-spi2-core cups libdrm libxkbcommon mesa pango cairo alsa-lib" - ;; - fedora|rhel|centos|rocky|alma) - log_info "Fedora/RHEL system dependencies are not auto-installed." - log_info "If launch fails, run: sudo dnf install nss atk at-spi2-core cups-libs libdrm libxkbcommon mesa-libgbm pango cairo alsa-lib" - ;; - opensuse*|sles) - log_info "openSUSE system dependencies are not auto-installed." - ;; - esac - fi - - if [ "$installed" = false ]; then - if npx --yes playwright install chromium; then - installed=true - fi - fi - - if [ "$installed" = true ]; then - log_success "Playwright Chromium installed" - else - log_error "Playwright Chromium install failed" - log_info "Try again later: npx --yes playwright install chromium" - return 1 - fi -} - -# ───────────────────────────────────────────────────────────────────────── -# Main -# ───────────────────────────────────────────────────────────────────────── - -main() { - log_info "Hermes Agent: bootstrapping browser tools" - log_info " HERMES_HOME = $HERMES_HOME" - log_info " OS / arch = $NODE_OS-$NODE_ARCH ${DISTRO:+($DISTRO)}" - - ensure_node - ensure_agent_browser - ensure_chromium - - log_success "Browser tools setup complete." - log_info "Hermes Agent will pick up agent-browser from $NODE_PREFIX/bin/ on next launch." -} - -main diff --git a/acp_adapter/entry.py b/acp_adapter/entry.py index cf5c2ba9cfb0..9ce6281824c9 100644 --- a/acp_adapter/entry.py +++ b/acp_adapter/entry.py @@ -182,56 +182,31 @@ def _run_setup() -> None: def _run_setup_browser(assume_yes: bool = False) -> int: - """Bootstrap agent-browser + Playwright Chromium for the registry-install path. + """Bootstrap agent-browser + Chromium. - Shells out to the bundled platform-specific bootstrap script - (acp_adapter/bootstrap/bootstrap_browser_tools.{sh,ps1}) so the install - logic lives in one place — readable, debuggable, and shareable with - install.sh / install.ps1 if we ever want to call it from there too. + Routes through dep_ensure -> install.{sh,ps1} --ensure, sharing code + with ``hermes postinstall`` and the runtime lazy installer. - Returns the script's exit code (0 on success). + Returns 0 on success, 1 on failure. """ - import platform - import subprocess - - bootstrap_dir = Path(__file__).resolve().parent / "bootstrap" - - if platform.system() == "Windows": - script = bootstrap_dir / "bootstrap_browser_tools.ps1" - if not script.is_file(): - print( - f"Bootstrap script not found at {script} — wheel may be incomplete.", - file=sys.stderr, - ) + from hermes_cli.dep_ensure import ensure_dependency + + try: + node_ok = ensure_dependency("node", interactive=not assume_yes) + if not node_ok: + print("Node.js installation failed — cannot proceed with browser tools.", + file=sys.stderr) return 1 - cmd = [ - "powershell.exe", - "-NoProfile", - "-ExecutionPolicy", "Bypass", - "-File", str(script), - ] - if assume_yes: - cmd.append("-Yes") - else: - script = bootstrap_dir / "bootstrap_browser_tools.sh" - if not script.is_file(): - print( - f"Bootstrap script not found at {script} — wheel may be incomplete.", - file=sys.stderr, - ) + + browser_ok = ensure_dependency("browser", interactive=not assume_yes) + if not browser_ok: + print("Browser tools installation failed.", file=sys.stderr) return 1 - cmd = ["bash", str(script)] - if assume_yes: - cmd.append("--yes") - # stdio is inherited so the user sees the bootstrap's progress live. - try: - result = subprocess.run(cmd, check=False) - except FileNotFoundError as exc: - # bash / powershell.exe not on PATH - print(f"Could not launch browser bootstrap: {exc}", file=sys.stderr) + return 0 + except OSError as exc: + print(f"Browser bootstrap failed: {exc}", file=sys.stderr) return 1 - return result.returncode def main(argv: list[str] | None = None) -> None: diff --git a/scripts/install.sh b/scripts/install.sh index c34c64267c6d..3ece561a86f0 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1512,6 +1512,17 @@ find_system_browser() { fi done + if [ "$(uname)" = "Darwin" ]; then + for app in \ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ + "/Applications/Chromium.app/Contents/MacOS/Chromium"; do + if [ -x "$app" ]; then + echo "$app" + return 0 + fi + done + fi + return 1 } @@ -1534,10 +1545,15 @@ configure_browser_env_from_system_browser() { browser_path="$(find_system_browser 2>/dev/null || true)" fi - if [ -z "$browser_path" ] || [ ! -f "$env_file" ]; then + if [ -z "$browser_path" ]; then return 0 fi + mkdir -p "$HERMES_HOME" + if [ ! -f "$env_file" ]; then + touch "$env_file" + fi + if grep -q '^AGENT_BROWSER_EXECUTABLE_PATH=' "$env_file" 2>/dev/null; then log_info "AGENT_BROWSER_EXECUTABLE_PATH already configured" return 0 @@ -1888,6 +1904,73 @@ print_success() { fi } +ensure_browser() { + if ! command -v node >/dev/null 2>&1; then + local node_bin="$HERMES_HOME/node/bin/node" + if [ -x "$node_bin" ]; then + export PATH="$HERMES_HOME/node/bin:$PATH" + else + log_error "Node.js not found. Run with --ensure node first." + return 1 + fi + fi + + local npm_bin + npm_bin="$(command -v npm 2>/dev/null || echo "$HERMES_HOME/node/bin/npm")" + if [ ! -x "$npm_bin" ]; then + log_error "npm not found" + return 1 + fi + + log_info "Installing agent-browser..." + local log_file + log_file="$(mktemp)" + if ! "$npm_bin" install -g --prefix "$HERMES_HOME/node" --silent --ignore-scripts \ + "agent-browser@^0.26.0" \ + "@askjo/camofox-browser@^1.5.2" \ + >"$log_file" 2>&1; then + log_error "npm install failed:" + cat "$log_file" >&2 + rm -f "$log_file" + return 1 + fi + rm -f "$log_file" + export PATH="$HERMES_HOME/node/bin:$PATH" + + local sys_browser + sys_browser="$(find_system_browser 2>/dev/null || true)" + if [ -n "$sys_browser" ]; then + configure_browser_env_from_system_browser "$sys_browser" + log_info "System browser detected -- skipping Chromium download" + return 0 + fi + + log_info "Installing Chromium via agent-browser install..." + local ab_bin="$HERMES_HOME/node/bin/agent-browser" + if [ -x "$ab_bin" ]; then + "$ab_bin" install 2>/dev/null || { + log_warn "Chromium install failed. Browser tools may not work without a system browser." + + # OS-specific hints (detect_os sets $DISTRO) + case "${DISTRO:-unknown}" in + ubuntu|debian) + log_info "Try: sudo apt-get install -y chromium-browser" + ;; + arch) + log_info "Try: sudo pacman -S chromium" + ;; + fedora|rhel|centos) + log_info "Try: sudo dnf install -y chromium" + ;; + esac + } + else + log_warn "agent-browser not found at $ab_bin" + fi + + return 0 +} + ensure_mode() { detect_os @@ -1901,19 +1984,7 @@ ensure_mode() { browser) check_node if [ "$HAS_NODE" = true ]; then - DETECTED_BROWSER_EXECUTABLE="$(find_system_browser 2>/dev/null || true)" - if [ -z "$DETECTED_BROWSER_EXECUTABLE" ]; then - log_info "Installing agent-browser + Chromium..." - npm_bin="$(command -v npm 2>/dev/null || echo "")" - if [ -n "$npm_bin" ]; then - local agent_browser_dir="$HERMES_HOME/node_modules" - mkdir -p "$agent_browser_dir" - "$npm_bin" install --prefix "$HERMES_HOME" agent-browser 2>/dev/null || true - npx playwright install chromium 2>/dev/null || true - fi - else - log_success "System browser found: $DETECTED_BROWSER_EXECUTABLE" - fi + ensure_browser fi ;; ripgrep) @@ -1948,16 +2019,7 @@ postinstall_mode() { install_system_packages if [ "$HAS_NODE" = true ] && [ "$SKIP_BROWSER" = false ]; then - DETECTED_BROWSER_EXECUTABLE="$(find_system_browser 2>/dev/null || true)" - if [ -z "$DETECTED_BROWSER_EXECUTABLE" ]; then - log_info "Installing browser engine..." - npm_bin="$(command -v npm 2>/dev/null || echo "")" - if [ -n "$npm_bin" ]; then - npx playwright install chromium 2>/dev/null || true - fi - else - log_success "System browser found: $DETECTED_BROWSER_EXECUTABLE" - fi + ensure_browser fi HERMES_CMD="$(command -v hermes 2>/dev/null || echo "")" diff --git a/tests/acp/test_entry.py b/tests/acp/test_entry.py index 81d30cd868c3..1d881565bd90 100644 --- a/tests/acp/test_entry.py +++ b/tests/acp/test_entry.py @@ -94,103 +94,62 @@ def test_main_setup_skips_browser_prompt_on_no(monkeypatch): assert called == [] -def test_main_setup_browser_invokes_bundled_script(monkeypatch): - """`hermes-acp --setup-browser` must shell out to the bundled bootstrap - script — never reimplement the install logic inline.""" - monkeypatch.setattr("platform.system", lambda: "Linux") +def test_main_setup_browser_calls_ensure_dependency(monkeypatch): + """`hermes-acp --setup-browser` routes through dep_ensure.ensure_dependency.""" + calls = [] - captured = {} + def fake_ensure(dep, interactive=True): + calls.append((dep, interactive)) + return True - def fake_run(cmd, check=False): - captured["cmd"] = cmd - - class _R: - returncode = 0 - - return _R() - - monkeypatch.setattr("subprocess.run", fake_run) + monkeypatch.setattr("hermes_cli.dep_ensure.ensure_dependency", fake_ensure) entry.main(["--setup-browser"]) - assert captured["cmd"][0] == "bash" - assert captured["cmd"][1].endswith("bootstrap_browser_tools.sh") - # --yes is NOT passed when the flag is absent. - assert "--yes" not in captured["cmd"] + assert ("node", True) in calls + assert ("browser", True) in calls def test_main_setup_browser_forwards_yes_flag(monkeypatch): - monkeypatch.setattr("platform.system", lambda: "Linux") - - captured = {} - - def fake_run(cmd, check=False): - captured["cmd"] = cmd - - class _R: - returncode = 0 - - return _R() - - monkeypatch.setattr("subprocess.run", fake_run) - - entry.main(["--setup-browser", "--yes"]) + """--yes suppresses interactive prompts in ensure_dependency.""" + calls = [] - assert "--yes" in captured["cmd"] + def fake_ensure(dep, interactive=True): + calls.append((dep, interactive)) + return True - -def test_main_setup_browser_uses_powershell_on_windows(monkeypatch): - monkeypatch.setattr("platform.system", lambda: "Windows") - - captured = {} - - def fake_run(cmd, check=False): - captured["cmd"] = cmd - - class _R: - returncode = 0 - - return _R() - - monkeypatch.setattr("subprocess.run", fake_run) + monkeypatch.setattr("hermes_cli.dep_ensure.ensure_dependency", fake_ensure) entry.main(["--setup-browser", "--yes"]) - assert captured["cmd"][0] == "powershell.exe" - assert any(part.endswith("bootstrap_browser_tools.ps1") for part in captured["cmd"]) - assert "-Yes" in captured["cmd"] + assert ("node", False) in calls + assert ("browser", False) in calls -def test_main_setup_browser_propagates_failure(monkeypatch): - monkeypatch.setattr("platform.system", lambda: "Linux") +def test_main_setup_browser_stops_on_node_failure(monkeypatch): + """If node install fails, browser install is not attempted.""" + calls = [] - class _R: - returncode = 7 + def fake_ensure(dep, interactive=True): + calls.append(dep) + return dep != "node" # node fails - monkeypatch.setattr("subprocess.run", lambda cmd, check=False: _R()) + monkeypatch.setattr("hermes_cli.dep_ensure.ensure_dependency", fake_ensure) with pytest.raises(SystemExit) as excinfo: entry.main(["--setup-browser"]) - assert excinfo.value.code == 7 - - -def test_bootstrap_scripts_ship_with_package(): - """The package-data wiring (pyproject.toml) must include the bootstrap - scripts — otherwise `--setup-browser` 404s at runtime.""" - from pathlib import Path + assert excinfo.value.code == 1 + assert "node" in calls + assert "browser" not in calls - bootstrap_dir = Path(entry.__file__).resolve().parent / "bootstrap" - sh = bootstrap_dir / "bootstrap_browser_tools.sh" - ps1 = bootstrap_dir / "bootstrap_browser_tools.ps1" - assert sh.is_file(), f"missing bundled script: {sh}" - assert ps1.is_file(), f"missing bundled script: {ps1}" +def test_main_setup_browser_propagates_browser_failure(monkeypatch): + """If browser install fails, exit code is 1.""" + def fake_ensure(dep, interactive=True): + return dep != "browser" # browser fails - sh_text = sh.read_text(encoding="utf-8") - ps1_text = ps1.read_text(encoding="utf-8") + monkeypatch.setattr("hermes_cli.dep_ensure.ensure_dependency", fake_ensure) - # Sanity: scripts know how to find the Hermes-managed Node prefix. - assert "HERMES_HOME" in sh_text - assert "agent-browser" in sh_text - assert "HermesHome" in ps1_text - assert "agent-browser" in ps1_text + with pytest.raises(SystemExit) as excinfo: + entry.main(["--setup-browser"]) + assert excinfo.value.code == 1 From 609c485fc6d0a0c24a023cd1349ebd6ddbf60315 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Mon, 18 May 2026 08:42:33 -0400 Subject: [PATCH 102/418] Merge pull request #27971 from NousResearch/austin/fix/goal-statusbar fix(tui): keep /goal verdict out of compact status row --- .../createGatewayEventHandler.test.ts | 42 ++++++++++++++++++- ui-tui/src/app/createGatewayEventHandler.ts | 17 ++++++-- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index cd278eecdf93..0c7ec3b06ad8 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -4,7 +4,7 @@ import { createGatewayEventHandler } from '../app/createGatewayEventHandler.js' import { getOverlayState, resetOverlayState } from '../app/overlayStore.js' import { turnController } from '../app/turnController.js' import { getTurnState, resetTurnState } from '../app/turnStore.js' -import { patchUiState, resetUiState } from '../app/uiStore.js' +import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js' import { estimateTokensRough } from '../lib/text.js' import type { Msg } from '../types.js' @@ -132,6 +132,46 @@ describe('createGatewayEventHandler', () => { expect(ctx.system.sys).toHaveBeenCalledWith('compressing 968 messages (~123,400 tok)…') }) + it('keeps goal verdict text in transcript but shows a brief idle status (#goal statusbar)', () => { + const appended: Msg[] = [] + const ctx = buildCtx(appended) + const onEvent = createGatewayEventHandler(ctx) + const verdict = '✓ Goal achieved: long judge reason goes only in transcript, not merged with cwd label.' + + vi.useFakeTimers() + try { + onEvent({ + payload: { kind: 'goal', text: verdict }, + type: 'status.update' + } as any) + + expect(ctx.system.sys).toHaveBeenCalledWith(verdict) + expect(getUiState().status).toBe('✓ goal complete') + + vi.advanceTimersByTime(6001) + expect(getUiState().status).toBe('ready') + } finally { + vi.useRealTimers() + } + }) + + it('maps goal status.update prefixes to short status strings', () => { + const ctx = buildCtx([]) + const onEvent = createGatewayEventHandler(ctx) + + onEvent({ + payload: { kind: 'goal', text: '↻ Continuing toward goal (1/10): reason' }, + type: 'status.update' + } as any) + expect(getUiState().status).toBe('↻ goal continuing') + + onEvent({ + payload: { kind: 'goal', text: '⏸ Goal paused — budget exhausted.' }, + type: 'status.update' + } as any) + expect(getUiState().status).toBe('⏸ goal paused') + }) + it('surfaces self-improvement review summaries as a persistent system line', () => { const appended: Msg[] = [] const ctx = buildCtx(appended) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index ca269a131b4c..267334bfd72c 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -338,14 +338,23 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return } - setStatus(p.text) - - if (p.kind === 'compressing') { + if (p.kind === 'goal') { sys(p.text) + const brief = p.text.startsWith('✓') + ? '✓ goal complete' + : p.text.startsWith('↻') + ? '↻ goal continuing' + : p.text.startsWith('⏸') + ? '⏸ goal paused' + : 'ready' + setStatus(brief) + restoreStatusAfter(6000) return } - if (p.kind === 'goal') { + setStatus(p.text) + + if (p.kind === 'compressing') { sys(p.text) return } From ac1536b19f5765e082650615e0a5748f731f8c58 Mon Sep 17 00:00:00 2001 From: duyua9 Date: Mon, 18 May 2026 22:03:25 +0800 Subject: [PATCH 103/418] fix(web): render object config values structurally (#10949) --- web/src/components/AutoField.tsx | 95 +++++++++++++++++++++++++------- 1 file changed, 75 insertions(+), 20 deletions(-) diff --git a/web/src/components/AutoField.tsx b/web/src/components/AutoField.tsx index f7afd150b00b..0f96d4204257 100644 --- a/web/src/components/AutoField.tsx +++ b/web/src/components/AutoField.tsx @@ -17,6 +17,71 @@ function FieldHint({ schema, schemaKey }: { schema: Record; sch ); } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function formatScalar(value: unknown): string { + if (value === undefined || value === null) return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + return JSON.stringify(value); +} + +function NestedValueEditor({ + fieldKey, + value, + onChange, +}: { + fieldKey: string; + value: unknown; + onChange: (v: unknown) => void; +}) { + if (isRecord(value)) { + return ( +
+ {Object.entries(value).map(([subKey, subVal]) => ( +
+ + onChange({ ...value, [subKey]: next })} + /> +
+ ))} +
+ ); + } + + if (Array.isArray(value)) { + return ( +
+ {value.map((item, index) => ( +
+ + + onChange(value.map((existing, i) => (i === index ? next : existing))) + } + /> +
+ ))} +
+ ); + } + + return ( + onChange(e.target.value)} + className="text-xs" + /> + ); +} + export function AutoField({ schemaKey, schema, @@ -26,6 +91,16 @@ export function AutoField({ const rawLabel = schemaKey.split(".").pop() ?? schemaKey; const label = rawLabel.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); + if (isRecord(value) || (Array.isArray(value) && value.some((item) => isRecord(item)))) { + return ( +
+ + + +
+ ); + } + if (schema.type === "boolean") { return (
@@ -114,26 +189,6 @@ export function AutoField({ ); } - if (typeof value === "object" && value !== null && !Array.isArray(value)) { - const obj = value as Record; - return ( -
- - - {Object.entries(obj).map(([subKey, subVal]) => ( -
- - onChange({ ...obj, [subKey]: e.target.value })} - className="text-xs" - /> -
- ))} -
- ); - } - return (
From 6a20ad6c0a6cf9b078da4dd3710fe6cbf37241d2 Mon Sep 17 00:00:00 2001 From: "Brian D. Evans" Date: Mon, 18 May 2026 15:23:03 +0100 Subject: [PATCH 104/418] fix(dashboard): constrain theme picker dropdown height so themes are scrollable (#25213) (#25220) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header theme picker (`ThemeSwitcher`) renders a `role="listbox"` popup with no `max-height` or overflow. With 20+ community themes installed under `~/.hermes/dashboard-themes/`, the list extends past the viewport and themes at the top or bottom are unreachable — the user reports only 15 of 26 themes visible, with no scrollbar to access the rest. Sibling switchers (`LanguageSwitcher`, `SlashPopover`) already cap their listboxes (`max-h-80 overflow-y-auto` / `max-h-64 overflow-y-auto`); this just brings the theme picker into line. Scoped to the component instead of a global `div[role="listbox"]` CSS rule so other dropdowns aren't affected. `70dvh` matches the user's tested workaround and the `dvh` unit handles mobile browser UI chrome correctly (unlike `vh`). Fixes #25213. Co-authored-by: briandevans <252620095+briandevans@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) --- web/src/components/ThemeSwitcher.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/ThemeSwitcher.tsx b/web/src/components/ThemeSwitcher.tsx index 462ccaacfc94..90a3d11ebdb4 100644 --- a/web/src/components/ThemeSwitcher.tsx +++ b/web/src/components/ThemeSwitcher.tsx @@ -79,7 +79,7 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) { role="listbox" aria-label={t.theme?.title ?? "Theme"} className={cn( - "absolute z-50 min-w-[240px]", + "absolute z-50 min-w-[240px] max-h-[70dvh] overflow-y-auto", dropUp ? "left-0 bottom-full mb-1" : "right-0 top-full mt-1", "border border-current/20 bg-background-base/95 backdrop-blur-sm", "shadow-[0_12px_32px_-8px_rgba(0,0,0,0.6)]", From 4414a99d8c3ec4c38c816ad7a33d5c0ee0d61962 Mon Sep 17 00:00:00 2001 From: LeonSGP <154585401+LeonSGP43@users.noreply.github.com> Date: Mon, 18 May 2026 22:35:18 +0800 Subject: [PATCH 105/418] fix(kanban): stop forcing dashboard text to all caps (#26413) --- plugins/kanban/dashboard/dist/index.js | 2 +- plugins/kanban/dashboard/dist/style.css | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/plugins/kanban/dashboard/dist/index.js b/plugins/kanban/dashboard/dist/index.js index 3f6def61cef2..fb4d346582c2 100644 --- a/plugins/kanban/dashboard/dist/index.js +++ b/plugins/kanban/dashboard/dist/index.js @@ -1701,7 +1701,7 @@ return h("div", { className: "hermes-kanban-boardswitcher" }, h("div", { className: "hermes-kanban-boardswitcher-inner" }, h("div", { className: "flex flex-col gap-0.5" }, - h("div", { className: "text-[11px] uppercase tracking-wider text-muted-foreground" }, + h("div", { className: "text-[11px] tracking-wider text-muted-foreground" }, tx(t, "board", "Board")), h("div", { className: "flex items-center gap-2" }, h(Select, Object.assign({ diff --git a/plugins/kanban/dashboard/dist/style.css b/plugins/kanban/dashboard/dist/style.css index f3d66a88597b..afbe5915505b 100644 --- a/plugins/kanban/dashboard/dist/style.css +++ b/plugins/kanban/dashboard/dist/style.css @@ -465,7 +465,6 @@ .hermes-kanban-section-head { font-size: 0.72rem; font-weight: 600; - text-transform: uppercase; letter-spacing: 0.07em; color: var(--color-muted-foreground); } @@ -611,7 +610,6 @@ } .hermes-kanban-deps-label { font-size: 0.68rem; - text-transform: uppercase; letter-spacing: 0.08em; color: var(--color-muted-foreground); min-width: 4rem; @@ -691,7 +689,6 @@ border: 0; color: var(--color-muted-foreground); font-size: 0.7rem; - text-transform: uppercase; letter-spacing: 0.05em; cursor: pointer; padding: 0; @@ -869,7 +866,6 @@ .hermes-kanban-run-outcome { font-family: var(--font-mono, ui-monospace, monospace); font-weight: 600; - text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-foreground); } @@ -929,7 +925,6 @@ .hermes-kanban-run-meta-label { font-size: 0.65rem; font-weight: 600; - text-transform: uppercase; letter-spacing: 0.06em; color: var(--color-muted-foreground); padding-bottom: 0.15rem; From 16abb74eab2d0bd34efc0e94a17846507a0c952c Mon Sep 17 00:00:00 2001 From: Wesley Simplicio Date: Mon, 18 May 2026 11:48:21 -0300 Subject: [PATCH 106/418] fix(kanban): use selectChangeHandler for workspace, parent, and bulk-reassign selects (#24547) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SDK Select fires onValueChange(value) not onChange({target:{value}}), so all three bare onChange handlers silently received undefined from e.target. Replace raw onChange with selectChangeHandler() — the existing helper that wires both onValueChange and a guarded onChange — so selections register regardless of which event the SDK Select dispatches. Closes #24520 Co-authored-by: Claude Sonnet 4.6 --- plugins/kanban/dashboard/dist/index.js | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/plugins/kanban/dashboard/dist/index.js b/plugins/kanban/dashboard/dist/index.js index fb4d346582c2..9ed0d4ef2290 100644 --- a/plugins/kanban/dashboard/dist/index.js +++ b/plugins/kanban/dashboard/dist/index.js @@ -2027,11 +2027,10 @@ ), h("div", { className: "hermes-kanban-bulk-reassign", title: "Reassign selected tasks to a different Hermes profile. Pick a profile (or unassign) and click Apply." }, - h(Select, { + h(Select, Object.assign({ value: assignee, - onChange: function (e) { setAssignee(e.target.value); }, className: "h-7 text-xs", - }, + }, selectChangeHandler(setAssignee)), h(SelectOption, { value: "" }, "— reassign —"), h(SelectOption, { value: "__none__" }, "(unassign)"), props.assignees.map(function (a) { @@ -2542,12 +2541,11 @@ className: "h-7 text-xs", }), h("div", { className: "flex gap-2" }, - h(Select, { + h(Select, Object.assign({ value: workspaceKind, - onChange: function (e) { setWorkspaceKind(e.target.value); }, title: "scratch: isolated temp dir (default). worktree: git worktree on the assignee profile. dir: exact path (required below).", className: "h-7 text-xs w-28", - }, + }, selectChangeHandler(setWorkspaceKind)), h(SelectOption, { value: "scratch" }, "scratch"), h(SelectOption, { value: "worktree" }, "worktree"), h(SelectOption, { value: "dir" }, "dir"), @@ -2559,12 +2557,11 @@ className: "h-7 text-xs flex-1", }) : null, ), - h(Select, { + h(Select, Object.assign({ value: parent, - onChange: function (e) { setParent(e.target.value); }, className: "h-7 text-xs", title: "Optional parent task. A child stays blocked in its current column until the parent is marked done.", - }, + }, selectChangeHandler(setParent)), h(SelectOption, { value: "" }, tx(t, "noParent", "— no parent —")), (props.allTasks || []).map(function (task) { return h(SelectOption, { key: task.id, value: task.id }, From 73407b1e303a13782812675869047dfab60ca650 Mon Sep 17 00:00:00 2001 From: sharziki Date: Sat, 16 May 2026 13:03:31 -0400 Subject: [PATCH 107/418] fix(auth): send Bearer auth for Azure Foundry anthropic_messages endpoints Azure AI Foundry's Anthropic-style endpoint requires `Authorization: Bearer` instead of `x-api-key`. Add `azure.com` to `_requires_bearer_auth()` so the existing Bearer path at line 586 fires before the generic third-party branch sets `api_key` (x-api-key). Fixes #26970 --- agent/anthropic_adapter.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index e7e1a8acb6d5..469b0fc9bbf7 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -471,14 +471,18 @@ def _requires_bearer_auth(base_url: str | None) -> bool: """Return True for Anthropic-compatible providers that require Bearer auth. Some third-party /anthropic endpoints implement Anthropic's Messages API but - require Authorization: Bearer *** of Anthropic's native x-api-key header. - MiniMax's global and China Anthropic-compatible endpoints follow this pattern. + require Authorization: Bearer instead of Anthropic's native x-api-key header. + MiniMax's global and China Anthropic-compatible endpoints, and Azure AI + Foundry's Anthropic-style endpoint follow this pattern. """ normalized = _normalize_base_url_text(base_url) if not normalized: return False normalized = normalized.rstrip("/").lower() - return normalized.startswith(("https://api.minimax.io/anthropic", "https://api.minimaxi.com/anthropic")) + return ( + normalized.startswith(("https://api.minimax.io/anthropic", "https://api.minimaxi.com/anthropic")) + or "azure.com" in normalized + ) def _base_url_needs_context_1m_beta(base_url: str | None) -> bool: From f0c6d591488aa6df3940b8e5791e1c872f4b2888 Mon Sep 17 00:00:00 2001 From: teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 18 May 2026 09:23:50 -0700 Subject: [PATCH 108/418] fix(anthropic): scope MiniMax beta-strip to MiniMax only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-pick of @sharziki's #27022 routed Azure Foundry through _requires_bearer_auth, which also triggered the MiniMax-specific beta-strip in _common_betas_for_base_url — dropping the 1M-context beta from Azure even though Azure needs it for 1M context. Split the strip predicate: introduce _is_minimax_anthropic_endpoint so the fine-grained-tool-streaming and context-1m strips only fire for MiniMax hosts, leaving Azure's bearer-auth header swap intact without losing 1M context. Also add a regression test that asserts Azure gets Bearer auth, the api-version query param, and the context-1m-2025-08-07 beta. --- agent/anthropic_adapter.py | 21 +++++++++++++++++++-- tests/agent/test_anthropic_adapter.py | 21 +++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 469b0fc9bbf7..de9b7dd586c8 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -493,6 +493,21 @@ def _base_url_needs_context_1m_beta(base_url: str | None) -> bool: return "azure.com" in normalized +def _is_minimax_anthropic_endpoint(base_url: str | None) -> bool: + """Return True for MiniMax's Anthropic-compatible endpoints. + + MiniMax rejects the fine-grained-tool-streaming and context-1m betas; + those need to be stripped even though MiniMax also uses Bearer auth. + """ + normalized = _normalize_base_url_text(base_url) + if not normalized: + return False + normalized = normalized.rstrip("/").lower() + return normalized.startswith( + ("https://api.minimax.io/anthropic", "https://api.minimaxi.com/anthropic") + ) + + def _common_betas_for_base_url( base_url: str | None, *, @@ -502,7 +517,9 @@ def _common_betas_for_base_url( MiniMax's Anthropic-compatible endpoints (Bearer-auth) reject requests that include Anthropic's ``fine-grained-tool-streaming`` beta — every - tool-use message triggers a connection error. + tool-use message triggers a connection error. They also reject the + 1M-context beta. Azure AI Foundry's Anthropic endpoint also uses + Bearer auth but keeps both betas (it needs the 1M beta for 1M context). The ``context-1m-2025-08-07`` beta is not sent to native Anthropic by default because some subscriptions reject it. Add it only for endpoint @@ -515,7 +532,7 @@ def _common_betas_for_base_url( betas = list(_COMMON_BETAS) if _base_url_needs_context_1m_beta(base_url) and not drop_context_1m_beta: betas.append(_CONTEXT_1M_BETA) - if _requires_bearer_auth(base_url): + if _is_minimax_anthropic_endpoint(base_url): _stripped = {_TOOL_STREAMING_BETA, _CONTEXT_1M_BETA} return [b for b in betas if b not in _stripped] if drop_context_1m_beta: diff --git a/tests/agent/test_anthropic_adapter.py b/tests/agent/test_anthropic_adapter.py index c7119dfd3b0d..3d19c32dcaaa 100644 --- a/tests/agent/test_anthropic_adapter.py +++ b/tests/agent/test_anthropic_adapter.py @@ -155,6 +155,27 @@ def test_minimax_cn_anthropic_endpoint_omits_tool_streaming_beta(self): "anthropic-beta": "interleaved-thinking-2025-05-14" } + def test_azure_foundry_anthropic_endpoint_uses_bearer_auth(self): + """Azure AI Foundry's /anthropic endpoint requires Authorization: Bearer. + + Regression test for #26970: without this, builds set api_key (x-api-key) + and the endpoint returns HTTP 401. Also verifies that Azure retains the + 1M-context beta even though it now matches `_requires_bearer_auth`. + """ + with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk: + build_anthropic_client( + "azure-foundry-secret-123", + base_url="https://my-resource.openai.azure.com/anthropic", + ) + kwargs = mock_sdk.Anthropic.call_args[1] + assert kwargs["auth_token"] == "azure-foundry-secret-123" + assert "api_key" not in kwargs + # Azure endpoints still get the api-version query param plumbing. + assert kwargs.get("default_query") == {"api-version": "2025-04-15"} + # Azure keeps the 1M-context beta (it's not MiniMax). + betas = kwargs["default_headers"]["anthropic-beta"] + assert "context-1m-2025-08-07" in betas + class TestReadClaudeCodeCredentials: @pytest.fixture(autouse=True) From a86d2ad5574147f527c5cf998751ccb46af47745 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 18 May 2026 09:31:08 -0700 Subject: [PATCH 109/418] fix(kanban-dashboard): wire onValueChange on OrchestrationPanel Selects (#27893) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard SDK's setSearch(e.target.value)} @@ -256,12 +256,7 @@ export default function SkillsPage() {