From 8d0a96a8bf7f8ee96f94da8f45ccfb8138b0d8c5 Mon Sep 17 00:00:00 2001
From: 0xbyt4 <35742124+0xbyt4@users.noreply.github.com>
Date: Tue, 17 Mar 2026 04:39:11 +0300
Subject: [PATCH 01/19] fix: context counter shows cached token count in status
bar
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Anthropic prompt caching splits input into cache_read_input_tokens,
cache_creation_input_tokens, and non-cached input_tokens. The context
counter only read input_tokens (non-cached portion), showing ~3 tokens
instead of the real ~18K total. Now includes cached portions for
Anthropic native provider only — other providers (OpenAI, OpenRouter,
Codex) already include cached tokens in their prompt_tokens field.
Before: 3/200K | 0%
After: 17.7K/200K | 9%
---
run_agent.py | 9 +++
tests/test_context_token_tracking.py | 115 +++++++++++++++++++++++++++
2 files changed, 124 insertions(+)
create mode 100644 tests/test_context_token_tracking.py
diff --git a/run_agent.py b/run_agent.py
index 6ae8170db3..ea004d2c85 100644
--- a/run_agent.py
+++ b/run_agent.py
@@ -5256,6 +5256,15 @@ class AIAgent:
if hasattr(response, 'usage') and response.usage:
if self.api_mode in ("codex_responses", "anthropic_messages"):
prompt_tokens = getattr(response.usage, 'input_tokens', 0) or 0
+ if self.api_mode == "anthropic_messages":
+ # Anthropic splits input into cache_read + cache_creation
+ # + non-cached input_tokens. Without adding the cached
+ # portions, the context bar shows only the tiny non-cached
+ # portion (e.g. 3 tokens) instead of the real total (~18K).
+ # Other providers (OpenAI/Codex) already include cached
+ # tokens in their input_tokens/prompt_tokens field.
+ prompt_tokens += getattr(response.usage, 'cache_read_input_tokens', 0) or 0
+ prompt_tokens += getattr(response.usage, 'cache_creation_input_tokens', 0) or 0
completion_tokens = getattr(response.usage, 'output_tokens', 0) or 0
total_tokens = (
getattr(response.usage, 'total_tokens', None)
diff --git a/tests/test_context_token_tracking.py b/tests/test_context_token_tracking.py
new file mode 100644
index 0000000000..2730f90eca
--- /dev/null
+++ b/tests/test_context_token_tracking.py
@@ -0,0 +1,115 @@
+"""Tests for context token tracking in run_agent.py's usage extraction.
+
+The context counter (status bar) must show the TOTAL prompt tokens including
+Anthropic's cached portions. This is an integration test for the token
+extraction in run_conversation(), not the ContextCompressor itself (which
+is tested in tests/agent/test_context_compressor.py).
+"""
+
+import sys
+import types
+from types import SimpleNamespace
+
+sys.modules.setdefault("fire", types.SimpleNamespace(Fire=lambda *a, **k: None))
+sys.modules.setdefault("firecrawl", types.SimpleNamespace(Firecrawl=object))
+sys.modules.setdefault("fal_client", types.SimpleNamespace())
+
+import run_agent
+
+
+def _patch_bootstrap(monkeypatch):
+ monkeypatch.setattr(run_agent, "get_tool_definitions", lambda **kwargs: [{
+ "type": "function",
+ "function": {"name": "t", "description": "t", "parameters": {"type": "object", "properties": {}}},
+ }])
+ monkeypatch.setattr(run_agent, "check_toolset_requirements", lambda: {})
+
+
+class _FakeAnthropicClient:
+ def close(self):
+ pass
+
+
+def _make_agent(monkeypatch, api_mode, provider, response_fn):
+ _patch_bootstrap(monkeypatch)
+ if api_mode == "anthropic_messages":
+ monkeypatch.setattr("agent.anthropic_adapter.build_anthropic_client", lambda k, b=None: _FakeAnthropicClient())
+
+ class _A(run_agent.AIAgent):
+ def __init__(self, *a, **kw):
+ kw.update(skip_context_files=True, skip_memory=True, max_iterations=4)
+ super().__init__(*a, **kw)
+ self._cleanup_task_resources = self._persist_session = lambda *a, **k: None
+ self._save_trajectory = self._save_session_log = lambda *a, **k: None
+
+ def run_conversation(self, msg, conversation_history=None, task_id=None):
+ self._interruptible_api_call = lambda kw: response_fn()
+ return super().run_conversation(msg, conversation_history=conversation_history, task_id=task_id)
+
+ return _A(model="test-model", api_key="test-key", provider=provider, api_mode=api_mode)
+
+
+def _anthropic_resp(input_tok, output_tok, cache_read=0, cache_creation=0):
+ usage_fields = {"input_tokens": input_tok, "output_tokens": output_tok}
+ if cache_read:
+ usage_fields["cache_read_input_tokens"] = cache_read
+ if cache_creation:
+ usage_fields["cache_creation_input_tokens"] = cache_creation
+ return SimpleNamespace(
+ content=[SimpleNamespace(type="text", text="ok")],
+ stop_reason="end_turn",
+ usage=SimpleNamespace(**usage_fields),
+ model="claude-sonnet-4-6",
+ )
+
+
+# -- Anthropic: cached tokens must be included --
+
+def test_anthropic_cache_read_and_creation_added(monkeypatch):
+ agent = _make_agent(monkeypatch, "anthropic_messages", "anthropic",
+ lambda: _anthropic_resp(3, 10, cache_read=15000, cache_creation=2000))
+ agent.run_conversation("hi")
+ assert agent.context_compressor.last_prompt_tokens == 17003 # 3+15000+2000
+ assert agent.session_prompt_tokens == 17003
+
+
+def test_anthropic_no_cache_fields(monkeypatch):
+ agent = _make_agent(monkeypatch, "anthropic_messages", "anthropic",
+ lambda: _anthropic_resp(500, 20))
+ agent.run_conversation("hi")
+ assert agent.context_compressor.last_prompt_tokens == 500
+
+
+def test_anthropic_cache_read_only(monkeypatch):
+ agent = _make_agent(monkeypatch, "anthropic_messages", "anthropic",
+ lambda: _anthropic_resp(5, 15, cache_read=17666, cache_creation=15))
+ agent.run_conversation("hi")
+ assert agent.context_compressor.last_prompt_tokens == 17686 # 5+17666+15
+
+
+# -- OpenAI: prompt_tokens already total --
+
+def test_openai_prompt_tokens_unchanged(monkeypatch):
+ resp = lambda: SimpleNamespace(
+ choices=[SimpleNamespace(index=0, message=SimpleNamespace(
+ role="assistant", content="ok", tool_calls=None, reasoning_content=None,
+ ), finish_reason="stop")],
+ usage=SimpleNamespace(prompt_tokens=5000, completion_tokens=100, total_tokens=5100),
+ model="gpt-4o",
+ )
+ agent = _make_agent(monkeypatch, "chat_completions", "openrouter", resp)
+ agent.run_conversation("hi")
+ assert agent.context_compressor.last_prompt_tokens == 5000
+
+
+# -- Codex: no cache fields, getattr returns 0 --
+
+def test_codex_no_cache_fields(monkeypatch):
+ resp = lambda: SimpleNamespace(
+ output=[SimpleNamespace(type="message", content=[SimpleNamespace(type="output_text", text="ok")])],
+ usage=SimpleNamespace(input_tokens=3000, output_tokens=50, total_tokens=3050),
+ status="completed", model="gpt-5-codex",
+ )
+ agent = _make_agent(monkeypatch, "codex_responses", "openai-codex", resp)
+ agent.run_conversation("hi")
+ assert agent.context_compressor.last_prompt_tokens == 3000
From 673f13215115682dcc0ab6c915e59f5c8de006fe Mon Sep 17 00:00:00 2001
From: Verne <1783491278@qq.com>
Date: Tue, 17 Mar 2026 11:05:28 +0800
Subject: [PATCH 02/19] fix(gateway): Recover stale service state
Repair stale launchd/systemd definitions during install and
teach launchd start to reload unloaded jobs before retrying.
Stop masking service restart failures by falling back to a
foreground gateway when a configured service manager is still
broken.
Refs: #1613
---
hermes_cli/gateway.py | 49 +++++++++-
tests/hermes_cli/test_gateway_service.py | 116 +++++++++++++++++++++++
2 files changed, 162 insertions(+), 3 deletions(-)
diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py
index bb2dd19285..73956dc916 100644
--- a/hermes_cli/gateway.py
+++ b/hermes_cli/gateway.py
@@ -562,6 +562,12 @@ def systemd_install(force: bool = False, system: bool = False, run_as_user: str
scope_flag = " --system" if system else ""
if unit_path.exists() and not force:
+ if not systemd_unit_is_current(system=system):
+ print(f"↻ Repairing outdated {_service_scope_label(system)} systemd service at: {unit_path}")
+ refresh_systemd_unit_if_needed(system=system)
+ subprocess.run(_systemctl_cmd(system) + ["enable", get_service_name()], check=True)
+ print(f"✓ {_service_scope_label(system).capitalize()} service definition updated")
+ return
print(f"Service already installed at: {unit_path}")
print("Use --force to reinstall")
return
@@ -787,6 +793,11 @@ def launchd_install(force: bool = False):
plist_path = get_launchd_plist_path()
if plist_path.exists() and not force:
+ if not launchd_plist_is_current():
+ print(f"↻ Repairing outdated launchd service at: {plist_path}")
+ refresh_launchd_plist_if_needed()
+ print("✓ Service definition updated")
+ return
print(f"Service already installed at: {plist_path}")
print("Use --force to reinstall")
return
@@ -816,7 +827,15 @@ def launchd_uninstall():
def launchd_start():
refresh_launchd_plist_if_needed()
- subprocess.run(["launchctl", "start", "ai.hermes.gateway"], check=True)
+ plist_path = get_launchd_plist_path()
+ try:
+ subprocess.run(["launchctl", "start", "ai.hermes.gateway"], check=True)
+ except subprocess.CalledProcessError as e:
+ if e.returncode != 3 or not plist_path.exists():
+ raise
+ print("↻ launchd job was unloaded; reloading service definition")
+ subprocess.run(["launchctl", "load", str(plist_path)], check=True)
+ subprocess.run(["launchctl", "start", "ai.hermes.gateway"], check=True)
print("✓ Service started")
def launchd_stop():
@@ -824,22 +843,36 @@ def launchd_stop():
print("✓ Service stopped")
def launchd_restart():
- refresh_launchd_plist_if_needed()
- launchd_stop()
+ try:
+ launchd_stop()
+ except subprocess.CalledProcessError as e:
+ if e.returncode != 3:
+ raise
+ print("↻ launchd job was unloaded; skipping stop")
launchd_start()
def launchd_status(deep: bool = False):
+ plist_path = get_launchd_plist_path()
result = subprocess.run(
["launchctl", "list", "ai.hermes.gateway"],
capture_output=True,
text=True
)
+
+ print(f"Launchd plist: {plist_path}")
+ if launchd_plist_is_current():
+ print("✓ Service definition matches the current Hermes install")
+ else:
+ print("⚠ Service definition is stale relative to the current Hermes install")
+ print(" Run: hermes gateway start")
if result.returncode == 0:
print("✓ Gateway service is loaded")
print(result.stdout)
else:
print("✗ Gateway service is not loaded")
+ print(" Service definition exists locally but launchd has not loaded it.")
+ print(" Run: hermes gateway start")
if deep:
log_file = get_hermes_home() / "logs" / "gateway.log"
@@ -1555,14 +1588,17 @@ def gateway_command(args):
# Try service first, fall back to killing and restarting
service_available = False
system = getattr(args, 'system', False)
+ service_configured = False
if is_linux() and (get_systemd_unit_path(system=False).exists() or get_systemd_unit_path(system=True).exists()):
+ service_configured = True
try:
systemd_restart(system=system)
service_available = True
except subprocess.CalledProcessError:
pass
elif is_macos() and get_launchd_plist_path().exists():
+ service_configured = True
try:
launchd_restart()
service_available = True
@@ -1586,6 +1622,13 @@ def gateway_command(args):
print(" hermes gateway restart")
return
+ if service_configured:
+ print()
+ print("✗ Gateway service restart failed.")
+ print(" The service definition exists, but the service manager did not recover it.")
+ print(" Fix the service, then retry: hermes gateway start")
+ sys.exit(1)
+
# Manual restart: kill existing processes
killed = kill_gateway_processes()
if killed:
diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py
index eeccf0c1ff..67277cbeac 100644
--- a/tests/hermes_cli/test_gateway_service.py
+++ b/tests/hermes_cli/test_gateway_service.py
@@ -7,6 +7,29 @@ import hermes_cli.gateway as gateway_cli
class TestSystemdServiceRefresh:
+ def test_systemd_install_repairs_outdated_unit_without_force(self, tmp_path, monkeypatch):
+ unit_path = tmp_path / "hermes-gateway.service"
+ unit_path.write_text("old unit\n", encoding="utf-8")
+
+ monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path)
+ monkeypatch.setattr(gateway_cli, "generate_systemd_unit", lambda system=False, run_as_user=None: "new unit\n")
+
+ calls = []
+
+ def fake_run(cmd, check=True, **kwargs):
+ calls.append(cmd)
+ return SimpleNamespace(returncode=0, stdout="", stderr="")
+
+ monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
+
+ gateway_cli.systemd_install()
+
+ assert unit_path.read_text(encoding="utf-8") == "new unit\n"
+ assert calls[:2] == [
+ ["systemctl", "--user", "daemon-reload"],
+ ["systemctl", "--user", "enable", gateway_cli.get_service_name()],
+ ]
+
def test_systemd_start_refreshes_outdated_unit(self, tmp_path, monkeypatch):
unit_path = tmp_path / "hermes-gateway.service"
unit_path.write_text("old unit\n", encoding="utf-8")
@@ -96,6 +119,71 @@ class TestGatewayStopCleanup:
assert kill_calls == [False]
+class TestLaunchdServiceRecovery:
+ def test_launchd_install_repairs_outdated_plist_without_force(self, tmp_path, monkeypatch):
+ plist_path = tmp_path / "ai.hermes.gateway.plist"
+ plist_path.write_text("old content", encoding="utf-8")
+
+ monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path)
+
+ calls = []
+
+ def fake_run(cmd, check=False, **kwargs):
+ calls.append(cmd)
+ return SimpleNamespace(returncode=0, stdout="", stderr="")
+
+ monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
+
+ gateway_cli.launchd_install()
+
+ assert "--replace" in plist_path.read_text(encoding="utf-8")
+ assert calls[:2] == [
+ ["launchctl", "unload", str(plist_path)],
+ ["launchctl", "load", str(plist_path)],
+ ]
+
+ def test_launchd_start_reloads_unloaded_job_and_retries(self, tmp_path, monkeypatch):
+ plist_path = tmp_path / "ai.hermes.gateway.plist"
+ plist_path.write_text(gateway_cli.generate_launchd_plist(), encoding="utf-8")
+
+ calls = []
+
+ def fake_run(cmd, check=False, **kwargs):
+ calls.append(cmd)
+ if cmd == ["launchctl", "start", "ai.hermes.gateway"] and calls.count(cmd) == 1:
+ raise gateway_cli.subprocess.CalledProcessError(3, cmd, stderr="Could not find service")
+ return SimpleNamespace(returncode=0, stdout="", stderr="")
+
+ monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path)
+ monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
+
+ gateway_cli.launchd_start()
+
+ assert calls == [
+ ["launchctl", "start", "ai.hermes.gateway"],
+ ["launchctl", "load", str(plist_path)],
+ ["launchctl", "start", "ai.hermes.gateway"],
+ ]
+
+ def test_launchd_status_reports_local_stale_plist_when_unloaded(self, tmp_path, monkeypatch, capsys):
+ plist_path = tmp_path / "ai.hermes.gateway.plist"
+ plist_path.write_text("old content", encoding="utf-8")
+
+ monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path)
+ monkeypatch.setattr(
+ gateway_cli.subprocess,
+ "run",
+ lambda *args, **kwargs: SimpleNamespace(returncode=113, stdout="", stderr="Could not find service"),
+ )
+
+ gateway_cli.launchd_status()
+
+ output = capsys.readouterr().out
+ assert str(plist_path) in output
+ assert "stale" in output.lower()
+ assert "not loaded" in output.lower()
+
+
class TestGatewayServiceDetection:
def test_is_service_running_checks_system_scope_when_user_scope_is_inactive(self, monkeypatch):
user_unit = SimpleNamespace(exists=lambda: True)
@@ -158,6 +246,34 @@ class TestGatewaySystemServiceRouting:
assert calls == [(False, False)]
+ def test_gateway_restart_does_not_fallback_to_foreground_when_launchd_restart_fails(self, tmp_path, monkeypatch):
+ plist_path = tmp_path / "ai.hermes.gateway.plist"
+ plist_path.write_text("plist\n", encoding="utf-8")
+
+ monkeypatch.setattr(gateway_cli, "is_linux", lambda: False)
+ monkeypatch.setattr(gateway_cli, "is_macos", lambda: True)
+ monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path)
+ monkeypatch.setattr(
+ gateway_cli,
+ "launchd_restart",
+ lambda: (_ for _ in ()).throw(
+ gateway_cli.subprocess.CalledProcessError(5, ["launchctl", "start", "ai.hermes.gateway"])
+ ),
+ )
+
+ run_calls = []
+ monkeypatch.setattr(gateway_cli, "run_gateway", lambda verbose=False, replace=False: run_calls.append((verbose, replace)))
+ monkeypatch.setattr(gateway_cli, "kill_gateway_processes", lambda force=False: 0)
+
+ try:
+ gateway_cli.gateway_command(SimpleNamespace(gateway_command="restart", system=False))
+ except SystemExit as exc:
+ assert exc.code == 1
+ else:
+ raise AssertionError("Expected gateway_command to exit when service restart fails")
+
+ assert run_calls == []
+
class TestEnsureUserSystemdEnv:
"""Tests for _ensure_user_systemd_env() D-Bus session bus auto-detection."""
From 285300528bf915700a4449cedb629565c9a0b327 Mon Sep 17 00:00:00 2001
From: DeadMan
Date: Mon, 16 Mar 2026 22:53:32 -0700
Subject: [PATCH 03/19] fix: isolate test_anthropic_adapter from local
credentials
Two tests lacked filesystem isolation causing them to pick up real
~/.claude/.credentials.json tokens on machines with Claude Code installed.
- test_prefers_oauth_token_over_api_key: add tmp_path, mock Path.home,
clear CLAUDE_CODE_OAUTH_TOKEN env
- test_falls_back_to_token: same isolation
Also commit run_agent.py generic-400 retry fix.
---
run_agent.py | 8 +++++++-
tests/test_anthropic_adapter.py | 8 ++++++--
2 files changed, 13 insertions(+), 3 deletions(-)
diff --git a/run_agent.py b/run_agent.py
index 6ae8170db3..d80a0d3982 100644
--- a/run_agent.py
+++ b/run_agent.py
@@ -5553,7 +5553,13 @@ class AIAgent:
# are programming bugs, not transient failures.
_RETRYABLE_STATUS_CODES = {413, 429, 529}
is_local_validation_error = isinstance(api_error, (ValueError, TypeError))
- is_client_status_error = isinstance(status_code, int) and 400 <= status_code < 500 and status_code not in _RETRYABLE_STATUS_CODES
+ # Detect generic 400s from Anthropic OAuth (transient server-side failures).
+ # Real invalid_request_error responses include a descriptive message;
+ # transient ones contain only "Error" or are empty. (ref: issue #1608)
+ _err_body = getattr(api_error, "body", None) or {}
+ _err_message = (_err_body.get("error", {}).get("message", "") if isinstance(_err_body, dict) else "")
+ _is_generic_400 = (status_code == 400 and _err_message.strip().lower() in ("error", ""))
+ is_client_status_error = isinstance(status_code, int) and 400 <= status_code < 500 and status_code not in _RETRYABLE_STATUS_CODES and not _is_generic_400
is_client_error = (is_local_validation_error or is_client_status_error or any(phrase in error_msg for phrase in [
'error code: 401', 'error code: 403',
'error code: 404', 'error code: 422',
diff --git a/tests/test_anthropic_adapter.py b/tests/test_anthropic_adapter.py
index e36f0b2c96..7203de7e05 100644
--- a/tests/test_anthropic_adapter.py
+++ b/tests/test_anthropic_adapter.py
@@ -144,9 +144,11 @@ class TestIsClaudeCodeTokenValid:
class TestResolveAnthropicToken:
- def test_prefers_oauth_token_over_api_key(self, monkeypatch):
+ def test_prefers_oauth_token_over_api_key(self, monkeypatch, tmp_path):
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-mykey")
monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-mytoken")
+ monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
+ monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
assert resolve_anthropic_token() == "sk-ant-oat01-mytoken"
def test_reports_claude_json_primary_key_source(self, monkeypatch, tmp_path):
@@ -174,9 +176,11 @@ class TestResolveAnthropicToken:
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
assert resolve_anthropic_token() == "sk-ant-api03-mykey"
- def test_falls_back_to_token(self, monkeypatch):
+ def test_falls_back_to_token(self, monkeypatch, tmp_path):
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-mytoken")
+ monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
+ monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
assert resolve_anthropic_token() == "sk-ant-oat01-mytoken"
def test_returns_none_with_no_creds(self, monkeypatch, tmp_path):
From 3576f44a577fcbc03a65e5fc3193b0d51dae45ea Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Tue, 17 Mar 2026 00:12:16 -0700
Subject: [PATCH 04/19] feat: add Vercel AI Gateway provider (#1628)
* feat: add Vercel AI Gateway as a first-class provider
Adds AI Gateway (ai-gateway.vercel.sh) as a new inference provider
with AI_GATEWAY_API_KEY authentication, live model discovery, and
reasoning support via extra_body.reasoning.
Based on PR #1492 by jerilynzheng.
* feat: add AI Gateway to setup wizard, doctor, and fallback providers
* test: add AI Gateway to api_key_providers test suite
* feat: add AI Gateway to hermes model CLI and model metadata
Wire AI Gateway into the interactive model selection menu and add
context lengths for AI Gateway model IDs in model_metadata.py.
* feat: use claude-haiku-4.5 as AI Gateway auxiliary model
* revert: use gemini-3-flash as AI Gateway auxiliary model
* fix: move AI Gateway below established providers in selection order
---------
Co-authored-by: jerilynzheng
Co-authored-by: jerilynzheng
---
agent/auxiliary_client.py | 1 +
agent/model_metadata.py | 9 ++++
hermes_cli/auth.py | 9 ++++
hermes_cli/doctor.py | 1 +
hermes_cli/main.py | 4 +-
hermes_cli/models.py | 53 ++++++++++++++++++-
hermes_cli/setup.py | 39 +++++++++++++-
hermes_constants.py | 4 ++
run_agent.py | 2 +
tests/test_api_key_providers.py | 39 +++++++++++++-
tests/test_provider_parity.py | 34 ++++++++++++
tests/test_runtime_provider_resolution.py | 14 +++++
.../docs/developer-guide/provider-runtime.md | 19 +++++--
.../docs/reference/environment-variables.md | 2 +
website/docs/user-guide/configuration.md | 1 +
.../user-guide/features/fallback-providers.md | 1 +
16 files changed, 223 insertions(+), 9 deletions(-)
diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py
index ff542a1134..cf740bc89e 100644
--- a/agent/auxiliary_client.py
+++ b/agent/auxiliary_client.py
@@ -57,6 +57,7 @@ _API_KEY_PROVIDER_AUX_MODELS: Dict[str, str] = {
"minimax": "MiniMax-M2.5-highspeed",
"minimax-cn": "MiniMax-M2.5-highspeed",
"anthropic": "claude-haiku-4-5-20251001",
+ "ai-gateway": "google/gemini-3-flash",
}
# OpenRouter app attribution headers
diff --git a/agent/model_metadata.py b/agent/model_metadata.py
index a609ea030a..755bc81b09 100644
--- a/agent/model_metadata.py
+++ b/agent/model_metadata.py
@@ -40,6 +40,8 @@ DEFAULT_CONTEXT_LENGTHS = {
"anthropic/claude-opus-4.6": 200000,
"anthropic/claude-sonnet-4": 200000,
"anthropic/claude-sonnet-4-20250514": 200000,
+ "anthropic/claude-sonnet-4.5": 200000,
+ "anthropic/claude-sonnet-4.6": 200000,
"anthropic/claude-haiku-4.5": 200000,
# Bare Anthropic model IDs (for native API provider)
"claude-opus-4-6": 200000,
@@ -50,11 +52,18 @@ DEFAULT_CONTEXT_LENGTHS = {
"claude-opus-4-20250514": 200000,
"claude-sonnet-4-20250514": 200000,
"claude-haiku-4-5-20251001": 200000,
+ "openai/gpt-5": 128000,
+ "openai/gpt-4.1": 1047576,
+ "openai/gpt-4.1-mini": 1047576,
"openai/gpt-4o": 128000,
"openai/gpt-4-turbo": 128000,
"openai/gpt-4o-mini": 128000,
+ "google/gemini-3-pro-preview": 1048576,
+ "google/gemini-3-flash": 1048576,
+ "google/gemini-2.5-flash": 1048576,
"google/gemini-2.0-flash": 1048576,
"google/gemini-2.5-pro": 1048576,
+ "deepseek/deepseek-v3.2": 65536,
"meta-llama/llama-3.3-70b-instruct": 131072,
"deepseek/deepseek-chat-v3": 65536,
"qwen/qwen-2.5-72b-instruct": 32768,
diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py
index 1863f0bb8d..c5d20082b7 100644
--- a/hermes_cli/auth.py
+++ b/hermes_cli/auth.py
@@ -155,6 +155,14 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
api_key_env_vars=("DEEPSEEK_API_KEY",),
base_url_env_var="DEEPSEEK_BASE_URL",
),
+ "ai-gateway": ProviderConfig(
+ id="ai-gateway",
+ name="AI Gateway",
+ auth_type="api_key",
+ inference_base_url="https://ai-gateway.vercel.sh/v1",
+ api_key_env_vars=("AI_GATEWAY_API_KEY",),
+ base_url_env_var="AI_GATEWAY_BASE_URL",
+ ),
}
@@ -532,6 +540,7 @@ def resolve_provider(
"kimi": "kimi-coding", "moonshot": "kimi-coding",
"minimax-china": "minimax-cn", "minimax_cn": "minimax-cn",
"claude": "anthropic", "claude-code": "anthropic",
+ "aigateway": "ai-gateway", "vercel": "ai-gateway", "vercel-ai-gateway": "ai-gateway",
}
normalized = _PROVIDER_ALIASES.get(normalized, normalized)
diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py
index 9cd0a8a9ea..33900b7cc2 100644
--- a/hermes_cli/doctor.py
+++ b/hermes_cli/doctor.py
@@ -570,6 +570,7 @@ def run_doctor(args):
# MiniMax APIs don't support /models endpoint — https://github.com/NousResearch/hermes-agent/issues/811
("MiniMax", ("MINIMAX_API_KEY",), None, "MINIMAX_BASE_URL", False),
("MiniMax (China)", ("MINIMAX_CN_API_KEY",), None, "MINIMAX_CN_BASE_URL", False),
+ ("AI Gateway", ("AI_GATEWAY_API_KEY",), "https://ai-gateway.vercel.sh/v1/models", "AI_GATEWAY_BASE_URL", True),
]
for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers:
_key = ""
diff --git a/hermes_cli/main.py b/hermes_cli/main.py
index 15f546cb1d..876bc38c8a 100644
--- a/hermes_cli/main.py
+++ b/hermes_cli/main.py
@@ -768,6 +768,7 @@ def cmd_model(args):
"kimi-coding": "Kimi / Moonshot",
"minimax": "MiniMax",
"minimax-cn": "MiniMax (China)",
+ "ai-gateway": "AI Gateway",
"custom": "Custom endpoint",
}
active_label = provider_labels.get(active, active)
@@ -787,6 +788,7 @@ def cmd_model(args):
("kimi-coding", "Kimi / Moonshot (Moonshot AI direct API)"),
("minimax", "MiniMax (global direct API)"),
("minimax-cn", "MiniMax China (domestic direct API)"),
+ ("ai-gateway", "AI Gateway (Vercel — 200+ models, pay-per-use)"),
]
# Add user-defined custom providers from config.yaml
@@ -855,7 +857,7 @@ def cmd_model(args):
_model_flow_anthropic(config, current_model)
elif selected_provider == "kimi-coding":
_model_flow_kimi(config, current_model)
- elif selected_provider in ("zai", "minimax", "minimax-cn"):
+ elif selected_provider in ("zai", "minimax", "minimax-cn", "ai-gateway"):
_model_flow_api_key_provider(config, selected_provider, current_model)
diff --git a/hermes_cli/models.py b/hermes_cli/models.py
index 13373afa9b..528273f954 100644
--- a/hermes_cli/models.py
+++ b/hermes_cli/models.py
@@ -8,6 +8,7 @@ Add, remove, or reorder entries here — both `hermes setup` and
from __future__ import annotations
import json
+import os
import urllib.request
import urllib.error
from difflib import get_close_matches
@@ -82,6 +83,20 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"deepseek-chat",
"deepseek-reasoner",
],
+ "ai-gateway": [
+ "anthropic/claude-opus-4.6",
+ "anthropic/claude-sonnet-4.6",
+ "anthropic/claude-sonnet-4.5",
+ "anthropic/claude-haiku-4.5",
+ "openai/gpt-5",
+ "openai/gpt-4.1",
+ "openai/gpt-4.1-mini",
+ "google/gemini-3-pro-preview",
+ "google/gemini-3-flash",
+ "google/gemini-2.5-pro",
+ "google/gemini-2.5-flash",
+ "deepseek/deepseek-v3.2",
+ ],
}
_PROVIDER_LABELS = {
@@ -94,6 +109,7 @@ _PROVIDER_LABELS = {
"minimax-cn": "MiniMax (China)",
"anthropic": "Anthropic",
"deepseek": "DeepSeek",
+ "ai-gateway": "AI Gateway",
"custom": "Custom endpoint",
}
@@ -109,6 +125,9 @@ _PROVIDER_ALIASES = {
"claude": "anthropic",
"claude-code": "anthropic",
"deep-seek": "deepseek",
+ "aigateway": "ai-gateway",
+ "vercel": "ai-gateway",
+ "vercel-ai-gateway": "ai-gateway",
}
@@ -142,7 +161,8 @@ def list_available_providers() -> list[dict[str, str]]:
# Canonical providers in display order
_PROVIDER_ORDER = [
"openrouter", "nous", "openai-codex",
- "zai", "kimi-coding", "minimax", "minimax-cn", "anthropic", "deepseek",
+ "zai", "kimi-coding", "minimax", "minimax-cn", "anthropic",
+ "ai-gateway", "deepseek",
]
# Build reverse alias map
aliases_for: dict[str, list[str]] = {}
@@ -372,6 +392,10 @@ def provider_model_ids(provider: Optional[str]) -> list[str]:
live = _fetch_anthropic_models()
if live:
return live
+ if normalized == "ai-gateway":
+ live = _fetch_ai_gateway_models()
+ if live:
+ return live
return list(_PROVIDER_MODELS.get(normalized, []))
@@ -475,6 +499,33 @@ def probe_api_models(
}
+def _fetch_ai_gateway_models(timeout: float = 5.0) -> Optional[list[str]]:
+ """Fetch available language models with tool-use from AI Gateway."""
+ api_key = os.getenv("AI_GATEWAY_API_KEY", "").strip()
+ if not api_key:
+ return None
+ base_url = os.getenv("AI_GATEWAY_BASE_URL", "").strip()
+ if not base_url:
+ from hermes_constants import AI_GATEWAY_BASE_URL
+ base_url = AI_GATEWAY_BASE_URL
+
+ url = base_url.rstrip("/") + "/models"
+ headers: dict[str, str] = {"Authorization": f"Bearer {api_key}"}
+ req = urllib.request.Request(url, headers=headers)
+ try:
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ data = json.loads(resp.read().decode())
+ return [
+ m["id"]
+ for m in data.get("data", [])
+ if m.get("id")
+ and m.get("type") == "language"
+ and "tool-use" in (m.get("tags") or [])
+ ]
+ except Exception:
+ return None
+
+
def fetch_api_models(
api_key: Optional[str],
base_url: Optional[str],
diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py
index e751811a18..c567dc7007 100644
--- a/hermes_cli/setup.py
+++ b/hermes_cli/setup.py
@@ -59,6 +59,7 @@ _DEFAULT_PROVIDER_MODELS = {
"kimi-coding": ["kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"],
"minimax": ["MiniMax-M2.5", "MiniMax-M2.5-highspeed", "MiniMax-M2.1"],
"minimax-cn": ["MiniMax-M2.5", "MiniMax-M2.5-highspeed", "MiniMax-M2.1"],
+ "ai-gateway": ["anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "openai/gpt-5", "google/gemini-3-flash"],
}
@@ -724,6 +725,7 @@ def setup_model_provider(config: dict):
"MiniMax (global endpoint)",
"MiniMax China (mainland China endpoint)",
"Anthropic (Claude models — API key or Claude Code subscription)",
+ "AI Gateway (Vercel — 200+ models, pay-per-use)",
]
if keep_label:
provider_choices.append(keep_label)
@@ -1232,7 +1234,39 @@ def setup_model_provider(config: dict):
_set_model_provider(config, "anthropic")
selected_base_url = ""
- # else: provider_idx == 9 (Keep current) — only shown when a provider already exists
+ elif provider_idx == 9: # AI Gateway
+ selected_provider = "ai-gateway"
+ print()
+ print_header("AI Gateway API Key")
+ pconfig = PROVIDER_REGISTRY["ai-gateway"]
+ print_info(f"Provider: {pconfig.name}")
+ print_info("Get your API key at: https://vercel.com/docs/ai-gateway")
+ print()
+
+ existing_key = get_env_value("AI_GATEWAY_API_KEY")
+ if existing_key:
+ print_info(f"Current: {existing_key[:8]}... (configured)")
+ if prompt_yes_no("Update API key?", False):
+ api_key = prompt(" AI Gateway API key", password=True)
+ if api_key:
+ save_env_value("AI_GATEWAY_API_KEY", api_key)
+ print_success("AI Gateway API key updated")
+ else:
+ api_key = prompt(" AI Gateway API key", password=True)
+ if api_key:
+ save_env_value("AI_GATEWAY_API_KEY", api_key)
+ print_success("AI Gateway API key saved")
+ else:
+ print_warning("Skipped - agent won't work without an API key")
+
+ # Clear custom endpoint vars if switching
+ if existing_custom:
+ save_env_value("OPENAI_BASE_URL", "")
+ save_env_value("OPENAI_API_KEY", "")
+ _update_config_for_provider("ai-gateway", pconfig.inference_base_url, default_model="anthropic/claude-opus-4.6")
+ _set_model_provider(config, "ai-gateway", pconfig.inference_base_url)
+
+ # else: provider_idx == 10 (Keep current) — only shown when a provider already exists
# Normalize "keep current" to an explicit provider so downstream logic
# doesn't fall back to the generic OpenRouter/static-model path.
if selected_provider is None:
@@ -1269,6 +1303,7 @@ def setup_model_provider(config: dict):
"minimax": "MiniMax",
"minimax-cn": "MiniMax CN",
"anthropic": "Anthropic",
+ "ai-gateway": "AI Gateway",
"custom": "your custom endpoint",
}
_prov_display = _prov_names.get(selected_provider, selected_provider or "your provider")
@@ -1402,7 +1437,7 @@ def setup_model_provider(config: dict):
_set_default_model(config, custom)
_update_config_for_provider("openai-codex", DEFAULT_CODEX_BASE_URL)
_set_model_provider(config, "openai-codex", DEFAULT_CODEX_BASE_URL)
- elif selected_provider in ("zai", "kimi-coding", "minimax", "minimax-cn"):
+ elif selected_provider in ("zai", "kimi-coding", "minimax", "minimax-cn", "ai-gateway"):
_setup_provider_model_selection(
config, selected_provider, current_model,
prompt_choice, prompt,
diff --git a/hermes_constants.py b/hermes_constants.py
index a81af04d3d..6a11fb37af 100644
--- a/hermes_constants.py
+++ b/hermes_constants.py
@@ -8,5 +8,9 @@ OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
OPENROUTER_MODELS_URL = f"{OPENROUTER_BASE_URL}/models"
OPENROUTER_CHAT_URL = f"{OPENROUTER_BASE_URL}/chat/completions"
+AI_GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh/v1"
+AI_GATEWAY_MODELS_URL = f"{AI_GATEWAY_BASE_URL}/models"
+AI_GATEWAY_CHAT_URL = f"{AI_GATEWAY_BASE_URL}/chat/completions"
+
NOUS_API_BASE_URL = "https://inference-api.nousresearch.com/v1"
NOUS_API_CHAT_URL = f"{NOUS_API_BASE_URL}/chat/completions"
diff --git a/run_agent.py b/run_agent.py
index 6ae8170db3..afee105e8e 100644
--- a/run_agent.py
+++ b/run_agent.py
@@ -3523,6 +3523,8 @@ class AIAgent:
base_url = (self.base_url or "").lower()
if "nousresearch" in base_url:
return True
+ if "ai-gateway.vercel.sh" in base_url:
+ return True
if "openrouter" not in base_url:
return False
if "api.mistral.ai" in base_url:
diff --git a/tests/test_api_key_providers.py b/tests/test_api_key_providers.py
index 01378569da..3ff377fbe1 100644
--- a/tests/test_api_key_providers.py
+++ b/tests/test_api_key_providers.py
@@ -1,4 +1,4 @@
-"""Tests for API-key provider support (z.ai/GLM, Kimi, MiniMax)."""
+"""Tests for API-key provider support (z.ai/GLM, Kimi, MiniMax, AI Gateway)."""
import os
import sys
@@ -37,6 +37,7 @@ class TestProviderRegistry:
("kimi-coding", "Kimi / Moonshot", "api_key"),
("minimax", "MiniMax", "api_key"),
("minimax-cn", "MiniMax (China)", "api_key"),
+ ("ai-gateway", "AI Gateway", "api_key"),
])
def test_provider_registered(self, provider_id, name, auth_type):
assert provider_id in PROVIDER_REGISTRY
@@ -65,11 +66,17 @@ class TestProviderRegistry:
assert pconfig.api_key_env_vars == ("MINIMAX_CN_API_KEY",)
assert pconfig.base_url_env_var == "MINIMAX_CN_BASE_URL"
+ def test_ai_gateway_env_vars(self):
+ pconfig = PROVIDER_REGISTRY["ai-gateway"]
+ assert pconfig.api_key_env_vars == ("AI_GATEWAY_API_KEY",)
+ assert pconfig.base_url_env_var == "AI_GATEWAY_BASE_URL"
+
def test_base_urls(self):
assert PROVIDER_REGISTRY["zai"].inference_base_url == "https://api.z.ai/api/paas/v4"
assert PROVIDER_REGISTRY["kimi-coding"].inference_base_url == "https://api.moonshot.ai/v1"
assert PROVIDER_REGISTRY["minimax"].inference_base_url == "https://api.minimax.io/v1"
assert PROVIDER_REGISTRY["minimax-cn"].inference_base_url == "https://api.minimaxi.com/v1"
+ assert PROVIDER_REGISTRY["ai-gateway"].inference_base_url == "https://ai-gateway.vercel.sh/v1"
def test_oauth_providers_unchanged(self):
"""Ensure we didn't break the existing OAuth providers."""
@@ -87,6 +94,7 @@ PROVIDER_ENV_VARS = (
"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
"GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY",
"KIMI_API_KEY", "KIMI_BASE_URL", "MINIMAX_API_KEY", "MINIMAX_CN_API_KEY",
+ "AI_GATEWAY_API_KEY", "AI_GATEWAY_BASE_URL",
"OPENAI_BASE_URL",
)
@@ -112,6 +120,9 @@ class TestResolveProvider:
def test_explicit_minimax_cn(self):
assert resolve_provider("minimax-cn") == "minimax-cn"
+ def test_explicit_ai_gateway(self):
+ assert resolve_provider("ai-gateway") == "ai-gateway"
+
def test_alias_glm(self):
assert resolve_provider("glm") == "zai"
@@ -130,6 +141,12 @@ class TestResolveProvider:
def test_alias_minimax_underscore(self):
assert resolve_provider("minimax_cn") == "minimax-cn"
+ def test_alias_aigateway(self):
+ assert resolve_provider("aigateway") == "ai-gateway"
+
+ def test_alias_vercel(self):
+ assert resolve_provider("vercel") == "ai-gateway"
+
def test_alias_case_insensitive(self):
assert resolve_provider("GLM") == "zai"
assert resolve_provider("Z-AI") == "zai"
@@ -163,6 +180,10 @@ class TestResolveProvider:
monkeypatch.setenv("MINIMAX_CN_API_KEY", "test-mm-cn-key")
assert resolve_provider("auto") == "minimax-cn"
+ def test_auto_detects_ai_gateway_key(self, monkeypatch):
+ monkeypatch.setenv("AI_GATEWAY_API_KEY", "test-gw-key")
+ assert resolve_provider("auto") == "ai-gateway"
+
def test_openrouter_takes_priority_over_glm(self, monkeypatch):
"""OpenRouter API key should win over GLM in auto-detection."""
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
@@ -248,6 +269,13 @@ class TestResolveApiKeyProviderCredentials:
assert creds["api_key"] == "mmcn-secret-key"
assert creds["base_url"] == "https://api.minimaxi.com/v1"
+ def test_resolve_ai_gateway_with_key(self, monkeypatch):
+ monkeypatch.setenv("AI_GATEWAY_API_KEY", "gw-secret-key")
+ creds = resolve_api_key_provider_credentials("ai-gateway")
+ assert creds["provider"] == "ai-gateway"
+ assert creds["api_key"] == "gw-secret-key"
+ assert creds["base_url"] == "https://ai-gateway.vercel.sh/v1"
+
def test_resolve_with_custom_base_url(self, monkeypatch):
monkeypatch.setenv("GLM_API_KEY", "glm-key")
monkeypatch.setenv("GLM_BASE_URL", "https://custom.glm.example/v4")
@@ -309,6 +337,15 @@ class TestRuntimeProviderResolution:
assert result["provider"] == "minimax"
assert result["api_key"] == "mm-key"
+ def test_runtime_ai_gateway(self, monkeypatch):
+ monkeypatch.setenv("AI_GATEWAY_API_KEY", "gw-key")
+ from hermes_cli.runtime_provider import resolve_runtime_provider
+ result = resolve_runtime_provider(requested="ai-gateway")
+ assert result["provider"] == "ai-gateway"
+ assert result["api_mode"] == "chat_completions"
+ assert result["api_key"] == "gw-key"
+ assert "ai-gateway.vercel.sh" in result["base_url"]
+
def test_runtime_auto_detects_api_key_provider(self, monkeypatch):
monkeypatch.setenv("KIMI_API_KEY", "auto-kimi-key")
from hermes_cli.runtime_provider import resolve_runtime_provider
diff --git a/tests/test_provider_parity.py b/tests/test_provider_parity.py
index dc976b8f17..e6d885604e 100644
--- a/tests/test_provider_parity.py
+++ b/tests/test_provider_parity.py
@@ -137,6 +137,40 @@ class TestBuildApiKwargsOpenRouter:
assert "codex_reasoning_items" in messages[1]
+class TestBuildApiKwargsAIGateway:
+ def test_uses_chat_completions_format(self, monkeypatch):
+ agent = _make_agent(monkeypatch, "ai-gateway", base_url="https://ai-gateway.vercel.sh/v1")
+ messages = [{"role": "user", "content": "hi"}]
+ kwargs = agent._build_api_kwargs(messages)
+ assert "messages" in kwargs
+ assert "model" in kwargs
+ assert kwargs["messages"][-1]["content"] == "hi"
+
+ def test_no_responses_api_fields(self, monkeypatch):
+ agent = _make_agent(monkeypatch, "ai-gateway", base_url="https://ai-gateway.vercel.sh/v1")
+ messages = [{"role": "user", "content": "hi"}]
+ kwargs = agent._build_api_kwargs(messages)
+ assert "input" not in kwargs
+ assert "instructions" not in kwargs
+ assert "store" not in kwargs
+
+ def test_includes_reasoning_in_extra_body(self, monkeypatch):
+ agent = _make_agent(monkeypatch, "ai-gateway", base_url="https://ai-gateway.vercel.sh/v1")
+ messages = [{"role": "user", "content": "hi"}]
+ kwargs = agent._build_api_kwargs(messages)
+ extra = kwargs.get("extra_body", {})
+ assert "reasoning" in extra
+ assert extra["reasoning"]["enabled"] is True
+
+ def test_includes_tools(self, monkeypatch):
+ agent = _make_agent(monkeypatch, "ai-gateway", base_url="https://ai-gateway.vercel.sh/v1")
+ messages = [{"role": "user", "content": "hi"}]
+ kwargs = agent._build_api_kwargs(messages)
+ assert "tools" in kwargs
+ tool_names = [t["function"]["name"] for t in kwargs["tools"]]
+ assert "web_search" in tool_names
+
+
class TestBuildApiKwargsNousPortal:
def test_includes_nous_product_tags(self, monkeypatch):
agent = _make_agent(monkeypatch, "nous", base_url="https://inference-api.nousresearch.com/v1")
diff --git a/tests/test_runtime_provider_resolution.py b/tests/test_runtime_provider_resolution.py
index 52d4a1d4fb..c02fb3cdc3 100644
--- a/tests/test_runtime_provider_resolution.py
+++ b/tests/test_runtime_provider_resolution.py
@@ -26,6 +26,20 @@ def test_resolve_runtime_provider_codex(monkeypatch):
assert resolved["requested_provider"] == "openai-codex"
+def test_resolve_runtime_provider_ai_gateway(monkeypatch):
+ monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "ai-gateway")
+ monkeypatch.setattr(rp, "_get_model_config", lambda: {})
+ monkeypatch.setenv("AI_GATEWAY_API_KEY", "test-ai-gw-key")
+
+ resolved = rp.resolve_runtime_provider(requested="ai-gateway")
+
+ assert resolved["provider"] == "ai-gateway"
+ assert resolved["api_mode"] == "chat_completions"
+ assert resolved["base_url"] == "https://ai-gateway.vercel.sh/v1"
+ assert resolved["api_key"] == "test-ai-gw-key"
+ assert resolved["requested_provider"] == "ai-gateway"
+
+
def test_resolve_runtime_provider_openrouter_explicit(monkeypatch):
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter")
monkeypatch.setattr(rp, "_get_model_config", lambda: {})
diff --git a/website/docs/developer-guide/provider-runtime.md b/website/docs/developer-guide/provider-runtime.md
index bf3c95090b..faa84d5f6d 100644
--- a/website/docs/developer-guide/provider-runtime.md
+++ b/website/docs/developer-guide/provider-runtime.md
@@ -37,6 +37,7 @@ That ordering matters because Hermes treats the saved model/provider choice as t
Current provider families include:
+- AI Gateway (Vercel)
- OpenRouter
- Nous Portal
- OpenAI Codex
@@ -68,11 +69,21 @@ This resolver is the main reason Hermes can share auth/runtime logic between:
- ACP editor sessions
- auxiliary model tasks
-## OpenRouter vs custom OpenAI-compatible base URLs
+## AI Gateway
-Hermes contains logic to avoid leaking the wrong API key to a custom endpoint when both `OPENROUTER_API_KEY` and `OPENAI_API_KEY` exist.
+Set `AI_GATEWAY_API_KEY` in `~/.hermes/.env` and run with `--provider ai-gateway`. Hermes fetches available models from the gateway's `/models` endpoint, filtering to language models with tool-use support.
-It also distinguishes between:
+## OpenRouter, AI Gateway, and custom OpenAI-compatible base URLs
+
+Hermes contains logic to avoid leaking the wrong API key to a custom endpoint when multiple provider keys exist (e.g. `OPENROUTER_API_KEY`, `AI_GATEWAY_API_KEY`, and `OPENAI_API_KEY`).
+
+Each provider's API key is scoped to its own base URL:
+
+- `OPENROUTER_API_KEY` is only sent to `openrouter.ai` endpoints
+- `AI_GATEWAY_API_KEY` is only sent to `ai-gateway.vercel.sh` endpoints
+- `OPENAI_API_KEY` is used for custom endpoints and as a fallback
+
+Hermes also distinguishes between:
- a real custom endpoint selected by the user
- the OpenRouter fallback path used when no custom endpoint is configured
@@ -80,7 +91,7 @@ It also distinguishes between:
That distinction is especially important for:
- local model servers
-- non-OpenRouter OpenAI-compatible APIs
+- non-OpenRouter/non-AI Gateway OpenAI-compatible APIs
- switching providers without re-running setup
- config-saved custom endpoints that should keep working even when `OPENAI_BASE_URL` is not exported in the current shell
diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md
index daaad87bc7..d10d66c1fa 100644
--- a/website/docs/reference/environment-variables.md
+++ b/website/docs/reference/environment-variables.md
@@ -14,6 +14,8 @@ All variables go in `~/.hermes/.env`. You can also set them with `hermes config
|----------|-------------|
| `OPENROUTER_API_KEY` | OpenRouter API key (recommended for flexibility) |
| `OPENROUTER_BASE_URL` | Override the OpenRouter-compatible base URL |
+| `AI_GATEWAY_API_KEY` | Vercel AI Gateway API key ([ai-gateway.vercel.sh](https://ai-gateway.vercel.sh)) |
+| `AI_GATEWAY_BASE_URL` | Override AI Gateway base URL (default: `https://ai-gateway.vercel.sh/v1`) |
| `OPENAI_API_KEY` | API key for custom OpenAI-compatible endpoints (used with `OPENAI_BASE_URL`) |
| `OPENAI_BASE_URL` | Base URL for custom endpoint (VLLM, SGLang, etc.) |
| `GLM_API_KEY` | z.ai / ZhipuAI GLM API key ([z.ai](https://z.ai)) |
diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md
index f55a65181d..abaabbad49 100644
--- a/website/docs/user-guide/configuration.md
+++ b/website/docs/user-guide/configuration.md
@@ -65,6 +65,7 @@ You need at least one way to connect to an LLM. Use `hermes model` to switch pro
| **OpenAI Codex** | `hermes model` (ChatGPT OAuth, uses Codex models) |
| **Anthropic** | `hermes model` (Claude Pro/Max via Claude Code auth, Anthropic API key, or manual setup-token) |
| **OpenRouter** | `OPENROUTER_API_KEY` in `~/.hermes/.env` |
+| **AI Gateway** | `AI_GATEWAY_API_KEY` in `~/.hermes/.env` (provider: `ai-gateway`) |
| **z.ai / GLM** | `GLM_API_KEY` in `~/.hermes/.env` (provider: `zai`) |
| **Kimi / Moonshot** | `KIMI_API_KEY` in `~/.hermes/.env` (provider: `kimi-coding`) |
| **MiniMax** | `MINIMAX_API_KEY` in `~/.hermes/.env` (provider: `minimax`) |
diff --git a/website/docs/user-guide/features/fallback-providers.md b/website/docs/user-guide/features/fallback-providers.md
index f94e380d43..5df658e8e5 100644
--- a/website/docs/user-guide/features/fallback-providers.md
+++ b/website/docs/user-guide/features/fallback-providers.md
@@ -34,6 +34,7 @@ Both `provider` and `model` are **required**. If either is missing, the fallback
| Provider | Value | Requirements |
|----------|-------|-------------|
+| AI Gateway | `ai-gateway` | `AI_GATEWAY_API_KEY` |
| OpenRouter | `openrouter` | `OPENROUTER_API_KEY` |
| Nous Portal | `nous` | `hermes login` (OAuth) |
| OpenAI Codex | `openai-codex` | `hermes model` (ChatGPT OAuth) |
From d44b6b7f1b094ef06302dda8069df2802466ca4e Mon Sep 17 00:00:00 2001
From: ShawnPana
Date: Tue, 17 Mar 2026 00:16:34 -0700
Subject: [PATCH 05/19] feat(browser): multi-provider cloud browser support +
Browser Use integration
Introduce a cloud browser provider abstraction so users can switch
between Local Browser, Browserbase, and Browser Use (or future providers)
via hermes tools / hermes setup.
Cloud browser providers are behind an ABC (tools/browser_providers/base.py)
so adding a new provider is a single-file addition with no changes to
browser_tool.py internals.
Changes:
- tools/browser_providers/ package with ABC, Browserbase extraction,
and Browser Use provider
- browser_tool.py refactored to use _PROVIDER_REGISTRY + _get_cloud_provider()
(cached) instead of hardcoded _is_local_mode() / _create_browserbase_session()
- tools_config.py: generic _is_provider_active() / _detect_active_provider_index()
replace TTS-only logic; Browser Use added as third browser option
- config.py: BROWSER_USE_API_KEY added to OPTIONAL_ENV_VARS + show_config + allowlist
- subprocess pipe hang fix: agent-browser daemon inherits pipe fds,
communicate() blocks. Replaced with Popen + temp files.
Original PR: #1208
Co-authored-by: ShawnPana
---
hermes_cli/config.py | 11 +-
hermes_cli/tools_config.py | 77 +++--
tools/browser_providers/__init__.py | 10 +
tools/browser_providers/base.py | 59 ++++
tools/browser_providers/browser_use.py | 107 +++++++
tools/browser_providers/browserbase.py | 206 +++++++++++++
tools/browser_tool.py | 400 ++++++++-----------------
7 files changed, 567 insertions(+), 303 deletions(-)
create mode 100644 tools/browser_providers/__init__.py
create mode 100644 tools/browser_providers/base.py
create mode 100644 tools/browser_providers/browser_use.py
create mode 100644 tools/browser_providers/browserbase.py
diff --git a/hermes_cli/config.py b/hermes_cli/config.py
index c3a4c701a5..0700890f43 100644
--- a/hermes_cli/config.py
+++ b/hermes_cli/config.py
@@ -507,6 +507,14 @@ OPTIONAL_ENV_VARS = {
"password": False,
"category": "tool",
},
+ "BROWSER_USE_API_KEY": {
+ "description": "Browser Use API key for cloud browser (optional — local browser works without this)",
+ "prompt": "Browser Use API key",
+ "url": "https://browser-use.com/",
+ "tools": ["browser_navigate", "browser_click"],
+ "password": True,
+ "category": "tool",
+ },
"FAL_KEY": {
"description": "FAL API key for image generation",
"prompt": "FAL API key",
@@ -1258,6 +1266,7 @@ def show_config():
("VOICE_TOOLS_OPENAI_KEY", "OpenAI (STT/TTS)"),
("FIRECRAWL_API_KEY", "Firecrawl"),
("BROWSERBASE_API_KEY", "Browserbase"),
+ ("BROWSER_USE_API_KEY", "Browser Use"),
("FAL_KEY", "FAL"),
]
@@ -1404,7 +1413,7 @@ def set_config_value(key: str, value: str):
# Check if it's an API key (goes to .env)
api_keys = [
'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'VOICE_TOOLS_OPENAI_KEY',
- 'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL', 'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID',
+ 'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL', 'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID', 'BROWSER_USE_API_KEY',
'FAL_KEY', 'TELEGRAM_BOT_TOKEN', 'DISCORD_BOT_TOKEN',
'TERMINAL_SSH_HOST', 'TERMINAL_SSH_USER', 'TERMINAL_SSH_KEY',
'SUDO_PASSWORD', 'SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN',
diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py
index b819fafa04..186100c020 100644
--- a/hermes_cli/tools_config.py
+++ b/hermes_cli/tools_config.py
@@ -190,6 +190,7 @@ TOOL_CATEGORIES = {
"name": "Local Browser",
"tag": "Free headless Chromium (no API key needed)",
"env_vars": [],
+ "browser_provider": None,
"post_setup": "browserbase", # Same npm install for agent-browser
},
{
@@ -199,6 +200,16 @@ TOOL_CATEGORIES = {
{"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": "browserbase",
+ },
+ {
+ "name": "Browser Use",
+ "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": "browserbase",
},
],
@@ -575,10 +586,10 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict):
configured = ""
env_vars = p.get("env_vars", [])
if not env_vars or all(get_env_value(v["key"]) for v in env_vars):
- if p.get("tts_provider") and config.get("tts", {}).get("provider") == p["tts_provider"]:
+ if _is_provider_active(p, config):
configured = " [active]"
elif not env_vars:
- configured = " [active]" if config.get("tts", {}).get("provider", "edge") == p.get("tts_provider", "") else ""
+ configured = ""
else:
configured = " [configured]"
provider_choices.append(f"{p['name']}{tag}{configured}")
@@ -587,15 +598,7 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict):
provider_choices.append("Skip — keep defaults / configure later")
# Detect current provider as default
- default_idx = 0
- for i, p in enumerate(providers):
- if p.get("tts_provider") and config.get("tts", {}).get("provider") == p["tts_provider"]:
- default_idx = i
- break
- env_vars = p.get("env_vars", [])
- if env_vars and all(get_env_value(v["key"]) for v in env_vars):
- default_idx = i
- break
+ default_idx = _detect_active_provider_index(providers, config)
provider_idx = _prompt_choice(f" {title}:", provider_choices, default_idx)
@@ -607,6 +610,28 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict):
_configure_provider(providers[provider_idx], config)
+def _is_provider_active(provider: dict, config: dict) -> bool:
+ """Check if a provider entry matches the currently active config."""
+ if provider.get("tts_provider"):
+ return config.get("tts", {}).get("provider") == provider["tts_provider"]
+ if "browser_provider" in provider:
+ current = config.get("browser", {}).get("cloud_provider")
+ return provider["browser_provider"] == current
+ return False
+
+
+def _detect_active_provider_index(providers: list, config: dict) -> int:
+ """Return the index of the currently active provider, or 0."""
+ for i, p in enumerate(providers):
+ if _is_provider_active(p, config):
+ return i
+ # Fallback: env vars present → likely configured
+ env_vars = p.get("env_vars", [])
+ if env_vars and all(get_env_value(v["key"]) for v in env_vars):
+ return i
+ return 0
+
+
def _configure_provider(provider: dict, config: dict):
"""Configure a single provider - prompt for API keys and set config."""
env_vars = provider.get("env_vars", [])
@@ -615,6 +640,15 @@ def _configure_provider(provider: dict, config: dict):
if provider.get("tts_provider"):
config.setdefault("tts", {})["provider"] = provider["tts_provider"]
+ # Set browser cloud provider in config if applicable
+ if "browser_provider" in provider:
+ bp = provider["browser_provider"]
+ if bp:
+ config.setdefault("browser", {})["cloud_provider"] = bp
+ _print_success(f" Browser cloud provider set to: {bp}")
+ else:
+ config.get("browser", {}).pop("cloud_provider", None)
+
if not env_vars:
_print_success(f" {provider['name']} - no configuration needed!")
return
@@ -767,7 +801,7 @@ def _configure_tool_category_for_reconfig(ts_key: str, cat: dict, config: dict):
configured = ""
env_vars = p.get("env_vars", [])
if not env_vars or all(get_env_value(v["key"]) for v in env_vars):
- if p.get("tts_provider") and config.get("tts", {}).get("provider") == p["tts_provider"]:
+ if _is_provider_active(p, config):
configured = " [active]"
elif not env_vars:
configured = ""
@@ -775,15 +809,7 @@ def _configure_tool_category_for_reconfig(ts_key: str, cat: dict, config: dict):
configured = " [configured]"
provider_choices.append(f"{p['name']}{tag}{configured}")
- default_idx = 0
- for i, p in enumerate(providers):
- if p.get("tts_provider") and config.get("tts", {}).get("provider") == p["tts_provider"]:
- default_idx = i
- break
- env_vars = p.get("env_vars", [])
- if env_vars and all(get_env_value(v["key"]) for v in env_vars):
- default_idx = i
- break
+ default_idx = _detect_active_provider_index(providers, config)
provider_idx = _prompt_choice(" Select provider:", provider_choices, default_idx)
_reconfigure_provider(providers[provider_idx], config)
@@ -797,6 +823,15 @@ def _reconfigure_provider(provider: dict, config: dict):
config.setdefault("tts", {})["provider"] = provider["tts_provider"]
_print_success(f" TTS provider set to: {provider['tts_provider']}")
+ if "browser_provider" in provider:
+ bp = provider["browser_provider"]
+ if bp:
+ config.setdefault("browser", {})["cloud_provider"] = bp
+ _print_success(f" Browser cloud provider set to: {bp}")
+ else:
+ config.get("browser", {}).pop("cloud_provider", None)
+ _print_success(f" Browser set to local mode")
+
if not env_vars:
_print_success(f" {provider['name']} - no configuration needed!")
return
diff --git a/tools/browser_providers/__init__.py b/tools/browser_providers/__init__.py
new file mode 100644
index 0000000000..7fa59ef04e
--- /dev/null
+++ b/tools/browser_providers/__init__.py
@@ -0,0 +1,10 @@
+"""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
new file mode 100644
index 0000000000..6b8e1ed4f6
--- /dev/null
+++ b/tools/browser_providers/base.py
@@ -0,0 +1,59 @@
+"""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
new file mode 100644
index 0000000000..48a618400f
--- /dev/null
+++ b/tools/browser_providers/browser_use.py
@@ -0,0 +1,107 @@
+"""Browser Use 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.browser-use.com/api/v2"
+
+
+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 bool(os.environ.get("BROWSER_USE_API_KEY"))
+
+ # ------------------------------------------------------------------
+ # Session lifecycle
+ # ------------------------------------------------------------------
+
+ def _headers(self) -> Dict[str, str]:
+ api_key = os.environ.get("BROWSER_USE_API_KEY")
+ if not api_key:
+ raise ValueError(
+ "BROWSER_USE_API_KEY environment variable is required. "
+ "Get your key at https://browser-use.com"
+ )
+ return {
+ "Content-Type": "application/json",
+ "X-Browser-Use-API-Key": api_key,
+ }
+
+ def create_session(self, task_id: str) -> Dict[str, object]:
+ response = requests.post(
+ f"{_BASE_URL}/browsers",
+ headers=self._headers(),
+ json={},
+ timeout=30,
+ )
+
+ if not response.ok:
+ raise RuntimeError(
+ f"Failed to create Browser Use session: "
+ f"{response.status_code} {response.text}"
+ )
+
+ session_data = response.json()
+ session_name = f"hermes_{task_id}_{uuid.uuid4().hex[:8]}"
+
+ logger.info("Created Browser Use session %s", session_name)
+
+ return {
+ "session_name": session_name,
+ "bb_session_id": session_data["id"],
+ "cdp_url": session_data["cdpUrl"],
+ "features": {"browser_use": True},
+ }
+
+ def close_session(self, session_id: str) -> bool:
+ try:
+ response = requests.patch(
+ f"{_BASE_URL}/browsers/{session_id}",
+ headers=self._headers(),
+ 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:
+ api_key = os.environ.get("BROWSER_USE_API_KEY")
+ if not api_key:
+ logger.warning("Cannot emergency-cleanup Browser Use session %s — missing credentials", session_id)
+ return
+ try:
+ requests.patch(
+ f"{_BASE_URL}/browsers/{session_id}",
+ headers={
+ "Content-Type": "application/json",
+ "X-Browser-Use-API-Key": api_key,
+ },
+ 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
new file mode 100644
index 0000000000..1aad8e6e07
--- /dev/null
+++ b/tools/browser_providers/browserbase.py
@@ -0,0 +1,206 @@
+"""Browserbase 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__)
+
+
+class BrowserbaseProvider(CloudBrowserProvider):
+ """Browserbase (https://browserbase.com) cloud browser backend."""
+
+ def provider_name(self) -> str:
+ return "Browserbase"
+
+ def is_configured(self) -> bool:
+ return bool(
+ os.environ.get("BROWSERBASE_API_KEY")
+ and os.environ.get("BROWSERBASE_PROJECT_ID")
+ )
+
+ # ------------------------------------------------------------------
+ # Session lifecycle
+ # ------------------------------------------------------------------
+
+ def _get_config(self) -> Dict[str, str]:
+ api_key = os.environ.get("BROWSERBASE_API_KEY")
+ project_id = os.environ.get("BROWSERBASE_PROJECT_ID")
+ if not api_key or not project_id:
+ raise ValueError(
+ "BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID environment "
+ "variables are required. Get your credentials at "
+ "https://browserbase.com"
+ )
+ return {"api_key": api_key, "project_id": project_id}
+
+ 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(
+ "https://api.browserbase.com/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(
+ "https://api.browserbase.com/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(
+ "https://api.browserbase.com/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"https://api.browserbase.com/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:
+ api_key = os.environ.get("BROWSERBASE_API_KEY")
+ project_id = os.environ.get("BROWSERBASE_PROJECT_ID")
+ if not api_key or not project_id:
+ logger.warning("Cannot emergency-cleanup Browserbase session %s — missing credentials", session_id)
+ return
+ try:
+ requests.post(
+ f"https://api.browserbase.com/v1/sessions/{session_id}",
+ headers={
+ "X-BB-API-Key": api_key,
+ "Content-Type": "application/json",
+ },
+ json={
+ "projectId": 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_tool.py b/tools/browser_tool.py
index e595e81054..d57eedee89 100644
--- a/tools/browser_tool.py
+++ b/tools/browser_tool.py
@@ -65,6 +65,9 @@ import requests
from typing import Dict, Any, Optional, List
from pathlib import Path
from agent.auxiliary_client import call_llm
+from tools.browser_providers.base import CloudBrowserProvider
+from tools.browser_providers.browserbase import BrowserbaseProvider
+from tools.browser_providers.browser_use import BrowserUseProvider
logger = logging.getLogger(__name__)
@@ -108,16 +111,43 @@ def _get_cdp_override() -> str:
return os.environ.get("BROWSER_CDP_URL", "").strip()
-def _is_local_mode() -> bool:
- """Return True when no Browserbase credentials are configured.
+# ============================================================================
+# Cloud Provider Registry
+# ============================================================================
- In local mode the browser tools launch a headless Chromium instance via
- ``agent-browser --session`` instead of connecting to a remote Browserbase
- session via ``--cdp``.
+_PROVIDER_REGISTRY: Dict[str, type] = {
+ "browserbase": BrowserbaseProvider,
+ "browser-use": BrowserUseProvider,
+}
+
+_cached_cloud_provider: Optional[CloudBrowserProvider] = None
+_cloud_provider_resolved = 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. If unset → local mode (None).
"""
- if _get_cdp_override():
- return False # CDP override takes priority
- return not (os.environ.get("BROWSERBASE_API_KEY") and os.environ.get("BROWSERBASE_PROJECT_ID"))
+ global _cached_cloud_provider, _cloud_provider_resolved
+ if _cloud_provider_resolved:
+ return _cached_cloud_provider
+
+ _cloud_provider_resolved = True
+ try:
+ hermes_home = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
+ config_path = hermes_home / "config.yaml"
+ if config_path.exists():
+ import yaml
+ with open(config_path) as f:
+ cfg = yaml.safe_load(f) or {}
+ provider_key = cfg.get("browser", {}).get("cloud_provider")
+ if provider_key and provider_key in _PROVIDER_REGISTRY:
+ _cached_cloud_provider = _PROVIDER_REGISTRY[provider_key]()
+ except Exception as e:
+ logger.debug("Could not read cloud_provider from config: %s", e)
+ return _cached_cloud_provider
def _socket_safe_tmpdir() -> str:
@@ -452,161 +482,6 @@ BROWSER_TOOL_SCHEMAS = [
# Utility Functions
# ============================================================================
-def _create_browserbase_session(task_id: str) -> Dict[str, str]:
- """
- Create a Browserbase session with stealth features.
-
- Browserbase Stealth Modes:
- - Basic Stealth: ALWAYS enabled automatically. Generates random fingerprints,
- viewports, and solves visual CAPTCHAs. No configuration needed.
- - Advanced Stealth: Uses custom Chromium build for better bot detection avoidance.
- Requires Scale Plan. Enable via BROWSERBASE_ADVANCED_STEALTH=true.
-
- Proxies are enabled by default to route traffic through residential IPs,
- which significantly improves CAPTCHA solving rates. Can be disabled via
- BROWSERBASE_PROXIES=false if needed.
-
- Args:
- task_id: Unique identifier for the task
-
- Returns:
- Dict with session_name, bb_session_id, cdp_url, and feature flags
- """
- import uuid
- import sys
-
- config = _get_browserbase_config()
-
- # Check for optional settings from environment
- # Proxies: enabled by default for better CAPTCHA solving
- enable_proxies = os.environ.get("BROWSERBASE_PROXIES", "true").lower() != "false"
- # Advanced Stealth: requires Scale Plan, disabled by default
- enable_advanced_stealth = os.environ.get("BROWSERBASE_ADVANCED_STEALTH", "false").lower() == "true"
- # keepAlive: enabled by default (requires paid plan) - allows reconnection after disconnects
- enable_keep_alive = os.environ.get("BROWSERBASE_KEEP_ALIVE", "true").lower() != "false"
- # Custom session timeout in milliseconds (optional) - extends session beyond project default
- custom_timeout_ms = os.environ.get("BROWSERBASE_SESSION_TIMEOUT")
-
- # Track which features are actually enabled for logging/debugging
- features_enabled = {
- "basic_stealth": True, # Always on
- "proxies": False,
- "advanced_stealth": False,
- "keep_alive": False,
- "custom_timeout": False,
- }
-
- # Build session configuration
- # Note: Basic stealth mode is ALWAYS active - no configuration needed
- session_config = {
- "projectId": config["project_id"],
- }
-
- # Enable keepAlive for session reconnection (default: true, requires paid plan)
- # Allows reconnecting to the same session after network hiccups
- if enable_keep_alive:
- session_config["keepAlive"] = True
-
- # Add custom timeout if specified (in milliseconds)
- # This extends session duration beyond project's default timeout
- 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)
-
- # Enable proxies for better CAPTCHA solving (default: true)
- # Routes traffic through residential IPs for more reliable access
- if enable_proxies:
- session_config["proxies"] = True
-
- # Add advanced stealth if enabled (requires Scale Plan)
- # Uses custom Chromium build to avoid bot detection altogether
- if enable_advanced_stealth:
- session_config["browserSettings"] = {
- "advancedStealth": True,
- }
-
- # Create session via Browserbase API
- response = requests.post(
- "https://api.browserbase.com/v1/sessions",
- headers={
- "Content-Type": "application/json",
- "X-BB-API-Key": config["api_key"],
- },
- json=session_config,
- timeout=30
- )
-
- # Track if we fell back from paid features
- proxies_fallback = False
- keepalive_fallback = False
-
- # Handle 402 Payment Required - likely paid features not available
- # Try to identify which feature caused the issue and retry without it
- if response.status_code == 402:
- # First try without keepAlive (most likely culprit for paid plan requirement)
- 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(
- "https://api.browserbase.com/v1/sessions",
- headers={
- "Content-Type": "application/json",
- "X-BB-API-Key": config["api_key"],
- },
- json=session_config,
- timeout=30
- )
-
- # If still 402, try without proxies too
- 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(
- "https://api.browserbase.com/v1/sessions",
- headers={
- "Content-Type": "application/json",
- "X-BB-API-Key": config["api_key"],
- },
- json=session_config,
- timeout=30
- )
-
- if not response.ok:
- raise RuntimeError(f"Failed to create Browserbase session: {response.status_code} {response.text}")
-
- session_data = response.json()
- session_name = f"hermes_{task_id}_{uuid.uuid4().hex[:8]}"
-
- # Update features based on what actually succeeded
- 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
-
- # Log session info for debugging
- feature_str = ", ".join(k for k, v in features_enabled.items() if v)
- logger.info("Created 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 _create_local_session(task_id: str) -> Dict[str, str]:
import uuid
session_name = f"h_{uuid.uuid4().hex[:10]}"
@@ -667,10 +542,12 @@ def _get_session_info(task_id: Optional[str] = None) -> Dict[str, str]:
cdp_override = _get_cdp_override()
if cdp_override:
session_info = _create_cdp_session(task_id, cdp_override)
- elif _is_local_mode():
- session_info = _create_local_session(task_id)
else:
- session_info = _create_browserbase_session(task_id)
+ provider = _get_cloud_provider()
+ if provider is None:
+ session_info = _create_local_session(task_id)
+ else:
+ session_info = provider.create_session(task_id)
with _cleanup_lock:
_active_sessions[task_id] = session_info
@@ -692,31 +569,6 @@ def _get_session_name(task_id: Optional[str] = None) -> str:
return session_info["session_name"]
-def _get_browserbase_config() -> Dict[str, str]:
- """
- Get Browserbase configuration from environment.
-
- Returns:
- Dict with api_key and project_id
-
- Raises:
- ValueError: If required env vars are not set
- """
- api_key = os.environ.get("BROWSERBASE_API_KEY")
- project_id = os.environ.get("BROWSERBASE_PROJECT_ID")
-
- if not api_key or not project_id:
- raise ValueError(
- "BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID environment variables are required. "
- "Get your credentials at https://browserbase.com"
- )
-
- return {
- "api_key": api_key,
- "project_id": project_id
- }
-
-
def _find_agent_browser() -> str:
"""
Find the agent-browser CLI executable.
@@ -859,27 +711,62 @@ def _run_browser_command(
browser_env["PATH"] = ":".join(path_parts)
browser_env["AGENT_BROWSER_SOCKET_DIR"] = task_socket_dir
- result = subprocess.run(
- cmd_parts,
- capture_output=True,
- text=True,
- timeout=timeout,
- env=browser_env,
- )
-
+ # Use temp files for stdout/stderr instead of pipes.
+ # agent-browser starts a background daemon that inherits file
+ # descriptors. With capture_output=True (pipes), the daemon keeps
+ # the pipe fds open after the CLI exits, so communicate() never
+ # sees EOF and blocks until the timeout fires.
+ stdout_path = os.path.join(task_socket_dir, f"_stdout_{command}")
+ stderr_path = os.path.join(task_socket_dir, f"_stderr_{command}")
+ stdout_fd = os.open(stdout_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
+ stderr_fd = os.open(stderr_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
+ try:
+ proc = subprocess.Popen(
+ cmd_parts,
+ stdout=stdout_fd,
+ stderr=stderr_fd,
+ stdin=subprocess.DEVNULL,
+ env=browser_env,
+ )
+ finally:
+ os.close(stdout_fd)
+ os.close(stderr_fd)
+
+ try:
+ proc.wait(timeout=timeout)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ proc.wait()
+ logger.warning("browser '%s' timed out after %ds (task=%s, socket_dir=%s)",
+ command, timeout, task_id, task_socket_dir)
+ return {"success": False, "error": f"Command timed out after {timeout} seconds"}
+
+ with open(stdout_path, "r") as f:
+ stdout = f.read()
+ with open(stderr_path, "r") as f:
+ stderr = f.read()
+ returncode = proc.returncode
+
+ # Clean up temp files (best-effort)
+ for p in (stdout_path, stderr_path):
+ try:
+ os.unlink(p)
+ except OSError:
+ pass
+
# Log stderr for diagnostics — use warning level on failure so it's visible
- if result.stderr and result.stderr.strip():
- level = logging.WARNING if result.returncode != 0 else logging.DEBUG
- logger.log(level, "browser '%s' stderr: %s", command, result.stderr.strip()[:500])
+ if stderr and stderr.strip():
+ level = logging.WARNING if returncode != 0 else logging.DEBUG
+ logger.log(level, "browser '%s' stderr: %s", command, stderr.strip()[:500])
# Log empty output as warning — common sign of broken agent-browser
- if not result.stdout.strip() and result.returncode == 0:
+ if not stdout.strip() and returncode == 0:
logger.warning("browser '%s' returned empty stdout with rc=0. "
"cmd=%s stderr=%s",
command, " ".join(cmd_parts[:4]) + "...",
- (result.stderr or "")[:200])
+ (stderr or "")[:200])
- stdout_text = result.stdout.strip()
+ stdout_text = stdout.strip()
if stdout_text:
try:
@@ -890,15 +777,15 @@ def _run_browser_command(
if not snap_data.get("snapshot") and not snap_data.get("refs"):
logger.warning("snapshot returned empty content. "
"Possible stale daemon or CDP connection issue. "
- "returncode=%s", result.returncode)
+ "returncode=%s", returncode)
return parsed
except json.JSONDecodeError:
raw = stdout_text[:2000]
logger.warning("browser '%s' returned non-JSON output (rc=%s): %s",
- command, result.returncode, raw[:500])
+ command, returncode, raw[:500])
if command == "screenshot":
- stderr_text = (result.stderr or "").strip()
+ stderr_text = (stderr or "").strip()
combined_text = "\n".join(
part for part in [stdout_text, stderr_text] if part
)
@@ -923,17 +810,13 @@ def _run_browser_command(
}
# Check for errors
- if result.returncode != 0:
- error_msg = result.stderr.strip() if result.stderr else f"Command failed with code {result.returncode}"
- logger.warning("browser '%s' failed (rc=%s): %s", command, result.returncode, error_msg[:300])
+ if returncode != 0:
+ error_msg = stderr.strip() if stderr else f"Command failed with code {returncode}"
+ logger.warning("browser '%s' failed (rc=%s): %s", command, returncode, error_msg[:300])
return {"success": False, "error": error_msg}
return {"success": True, "data": {}}
- except subprocess.TimeoutExpired:
- logger.warning("browser '%s' timed out after %ds (task=%s, socket_dir=%s)",
- command, timeout, task_id, task_socket_dir)
- return {"success": False, "error": f"Command timed out after {timeout} seconds"}
except Exception as e:
logger.warning("browser '%s' exception: %s", command, e, exc_info=True)
return {"success": False, "error": str(e)}
@@ -1509,7 +1392,8 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str]
if not result.get("success"):
error_detail = result.get("error", "Unknown error")
- mode = "local" if _is_local_mode() else "cloud"
+ _cp = _get_cloud_provider()
+ mode = "local" if _cp is None else f"cloud ({_cp.provider_name()})"
return json.dumps({
"success": False,
"error": f"Failed to take screenshot ({mode} mode): {error_detail}"
@@ -1521,7 +1405,8 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str]
# Check if screenshot file was created
if not screenshot_path.exists():
- mode = "local" if _is_local_mode() else "cloud"
+ _cp = _get_cloud_provider()
+ mode = "local" if _cp is None else f"cloud ({_cp.provider_name()})"
return json.dumps({
"success": False,
"error": (
@@ -1639,48 +1524,6 @@ def _cleanup_old_recordings(max_age_hours=72):
# Cleanup and Management Functions
# ============================================================================
-def _close_browserbase_session(session_id: str, api_key: str, project_id: str) -> bool:
- """
- Close a Browserbase session immediately via the API.
-
- Uses POST /v1/sessions/{id} with status=REQUEST_RELEASE to immediately
- terminate the session without waiting for keepAlive timeout.
-
- Args:
- session_id: The Browserbase session ID
- api_key: Browserbase API key
- project_id: Browserbase project ID
-
- Returns:
- True if session was successfully closed, False otherwise
- """
- try:
- # POST to update session status to REQUEST_RELEASE
- response = requests.post(
- f"https://api.browserbase.com/v1/sessions/{session_id}",
- headers={
- "X-BB-API-Key": api_key,
- "Content-Type": "application/json"
- },
- json={
- "projectId": 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 session %s: %s", session_id, e)
- return False
-
-
def cleanup_browser(task_id: Optional[str] = None) -> None:
"""
Clean up browser session for a task.
@@ -1721,15 +1564,14 @@ def cleanup_browser(task_id: Optional[str] = None) -> None:
_active_sessions.pop(task_id, None)
_session_last_activity.pop(task_id, None)
- # Cloud mode: close the Browserbase session via API
- if bb_session_id and not _is_local_mode():
- try:
- config = _get_browserbase_config()
- success = _close_browserbase_session(bb_session_id, config["api_key"], config["project_id"])
- if not success:
- logger.warning("Could not close BrowserBase session %s", bb_session_id)
- except Exception as e:
- logger.error("Exception during BrowserBase session close: %s", e)
+ # Cloud mode: close the cloud browser session via provider API
+ if bb_session_id:
+ provider = _get_cloud_provider()
+ if provider is not None:
+ try:
+ provider.close_session(bb_session_id)
+ except Exception as e:
+ logger.warning("Could not close cloud browser session: %s", e)
# Kill the daemon process and clean up socket directory
session_name = session_info.get("session_name", "")
@@ -1798,12 +1640,10 @@ def check_browser_requirements() -> bool:
except FileNotFoundError:
return False
- # In cloud mode, also require Browserbase credentials
- if not _is_local_mode():
- api_key = os.environ.get("BROWSERBASE_API_KEY")
- project_id = os.environ.get("BROWSERBASE_PROJECT_ID")
- if not api_key or not project_id:
- return False
+ # In cloud mode, also require provider credentials
+ provider = _get_cloud_provider()
+ if provider is not None and not provider.is_configured():
+ return False
return True
@@ -1819,7 +1659,8 @@ if __name__ == "__main__":
print("🌐 Browser Tool Module")
print("=" * 40)
- mode = "local" if _is_local_mode() else "cloud (Browserbase)"
+ _cp = _get_cloud_provider()
+ mode = "local" if _cp is None else f"cloud ({_cp.provider_name()})"
print(f" Mode: {mode}")
# Check requirements
@@ -1832,12 +1673,9 @@ if __name__ == "__main__":
except FileNotFoundError:
print(" - agent-browser CLI not found")
print(" Install: npm install -g agent-browser && agent-browser install --with-deps")
- if not _is_local_mode():
- if not os.environ.get("BROWSERBASE_API_KEY"):
- print(" - BROWSERBASE_API_KEY not set (required for cloud mode)")
- if not os.environ.get("BROWSERBASE_PROJECT_ID"):
- print(" - BROWSERBASE_PROJECT_ID not set (required for cloud mode)")
- print(" Tip: unset BROWSERBASE_API_KEY to use free local mode instead")
+ if _cp is not None and not _cp.is_configured():
+ print(f" - {_cp.provider_name()} credentials not configured")
+ print(" Tip: remove cloud_provider from config to use free local mode instead")
print("\n📋 Available Browser Tools:")
for schema in BROWSER_TOOL_SCHEMAS:
From 67546746d484ed4b9f1014ba4de70c3ca853f919 Mon Sep 17 00:00:00 2001
From: nidhi-singh02
Date: Tue, 17 Mar 2026 13:01:55 +0530
Subject: [PATCH 06/19] fix(gateway): overwrite stale PID in gateway_state.json
on restart
Signed-off-by: nidhi-singh02
---
gateway/status.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/gateway/status.py b/gateway/status.py
index dda6e23218..4d99960487 100644
--- a/gateway/status.py
+++ b/gateway/status.py
@@ -195,8 +195,8 @@ def write_runtime_status(
payload = _read_json_file(path) or _build_runtime_status_record()
payload.setdefault("platforms", {})
payload.setdefault("kind", _GATEWAY_KIND)
- payload.setdefault("pid", os.getpid())
- payload.setdefault("start_time", _get_process_start_time(os.getpid()))
+ payload["pid"] = os.getpid()
+ payload["start_time"] = _get_process_start_time(os.getpid())
payload["updated_at"] = _utc_now_iso()
if gateway_state is not None:
From 37862f74fa1e32c2c25699fc91f20310728d6d78 Mon Sep 17 00:00:00 2001
From: teknium1
Date: Tue, 17 Mar 2026 00:38:48 -0700
Subject: [PATCH 07/19] chore: release v0.3.0 (v2026.3.17)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Bump version 0.2.0 → 0.3.0
- Add comprehensive changelog (248 merged PRs, 15 contributors)
- CalVer tag: v2026.3.17
---
RELEASE_v0.3.0.md | 377 +++++++++++++++++++++++++++++++++++++++++
hermes_cli/__init__.py | 4 +-
pyproject.toml | 2 +-
3 files changed, 380 insertions(+), 3 deletions(-)
create mode 100644 RELEASE_v0.3.0.md
diff --git a/RELEASE_v0.3.0.md b/RELEASE_v0.3.0.md
new file mode 100644
index 0000000000..92f9276bcc
--- /dev/null
+++ b/RELEASE_v0.3.0.md
@@ -0,0 +1,377 @@
+# Hermes Agent v0.3.0 (v2026.3.17)
+
+**Release Date:** March 17, 2026
+
+> The streaming, plugins, and provider release — unified real-time token delivery, first-class plugin architecture, rebuilt provider system with Vercel AI Gateway, native Anthropic provider, smart approvals, live Chrome CDP browser connect, ACP IDE integration, Honcho memory, voice mode, persistent shell, and 50+ bug fixes across every platform.
+
+---
+
+## ✨ Highlights
+
+- **Unified Streaming Infrastructure** — Real-time token-by-token delivery in CLI and all gateway platforms. Responses stream as they're generated instead of arriving as a block. ([#1538](https://github.com/NousResearch/hermes-agent/pull/1538))
+
+- **First-Class Plugin Architecture** — Drop Python files into `~/.hermes/plugins/` to extend Hermes with custom tools, commands, and hooks. No forking required. ([#1544](https://github.com/NousResearch/hermes-agent/pull/1544), [#1555](https://github.com/NousResearch/hermes-agent/pull/1555))
+
+- **Native Anthropic Provider** — Direct Anthropic API calls with Claude Code credential auto-discovery, OAuth PKCE flows, and native prompt caching. No OpenRouter middleman needed. ([#1097](https://github.com/NousResearch/hermes-agent/pull/1097))
+
+- **Smart Approvals + /stop Command** — Codex-inspired approval system that learns which commands are safe and remembers your preferences. `/stop` kills the current agent run immediately. ([#1543](https://github.com/NousResearch/hermes-agent/pull/1543))
+
+- **Honcho Memory Integration** — Async memory writes, configurable recall modes, session title integration, and multi-user isolation in gateway mode. By @erosika. ([#736](https://github.com/NousResearch/hermes-agent/pull/736))
+
+- **Voice Mode** — Push-to-talk in CLI, voice notes in Telegram/Discord, Discord voice channel support, and local Whisper transcription via faster-whisper. ([#1299](https://github.com/NousResearch/hermes-agent/pull/1299), [#1185](https://github.com/NousResearch/hermes-agent/pull/1185), [#1429](https://github.com/NousResearch/hermes-agent/pull/1429))
+
+- **Concurrent Tool Execution** — Multiple independent tool calls now run in parallel via ThreadPoolExecutor, significantly reducing latency for multi-tool turns. ([#1152](https://github.com/NousResearch/hermes-agent/pull/1152))
+
+- **PII Redaction** — When `privacy.redact_pii` is enabled, personally identifiable information is automatically scrubbed before sending context to LLM providers. ([#1542](https://github.com/NousResearch/hermes-agent/pull/1542))
+
+- **`/browser connect` via CDP** — Attach browser tools to a live Chrome instance through Chrome DevTools Protocol. Debug, inspect, and interact with pages you already have open. ([#1549](https://github.com/NousResearch/hermes-agent/pull/1549))
+
+- **Vercel AI Gateway Provider** — Route Hermes through Vercel's AI Gateway for access to their model catalog and infrastructure. ([#1628](https://github.com/NousResearch/hermes-agent/pull/1628))
+
+- **Centralized Provider Router** — Rebuilt provider system with `call_llm` API, unified `/model` command, auto-detect provider on model switch, and direct endpoint overrides for auxiliary/delegation clients. ([#1003](https://github.com/NousResearch/hermes-agent/pull/1003), [#1506](https://github.com/NousResearch/hermes-agent/pull/1506), [#1375](https://github.com/NousResearch/hermes-agent/pull/1375))
+
+- **ACP Server (IDE Integration)** — VS Code, Zed, and JetBrains can now connect to Hermes as an agent backend, with full slash command support. ([#1254](https://github.com/NousResearch/hermes-agent/pull/1254), [#1532](https://github.com/NousResearch/hermes-agent/pull/1532))
+
+- **Persistent Shell Mode** — Local and SSH terminal backends can maintain shell state across tool calls — cd, env vars, and aliases persist. By @alt-glitch. ([#1067](https://github.com/NousResearch/hermes-agent/pull/1067), [#1483](https://github.com/NousResearch/hermes-agent/pull/1483))
+
+- **Agentic On-Policy Distillation (OPD)** — New RL training environment for distilling agent policies, expanding the Atropos training ecosystem. ([#1149](https://github.com/NousResearch/hermes-agent/pull/1149))
+
+---
+
+## 🏗️ Core Agent & Architecture
+
+### Provider & Model Support
+- **Centralized provider router** with `call_llm` API and unified `/model` command — switch models and providers seamlessly ([#1003](https://github.com/NousResearch/hermes-agent/pull/1003))
+- **Vercel AI Gateway** provider support ([#1628](https://github.com/NousResearch/hermes-agent/pull/1628))
+- **Auto-detect provider** when switching models via `/model` ([#1506](https://github.com/NousResearch/hermes-agent/pull/1506))
+- **Direct endpoint overrides** for auxiliary and delegation clients — point vision/subagent calls at specific endpoints ([#1375](https://github.com/NousResearch/hermes-agent/pull/1375))
+- **Native Anthropic auxiliary vision** — use Claude's native vision API instead of routing through OpenAI-compatible endpoints ([#1377](https://github.com/NousResearch/hermes-agent/pull/1377))
+- Anthropic OAuth flow improvements — auto-run `claude setup-token`, reauthentication, PKCE state persistence, identity fingerprinting ([#1132](https://github.com/NousResearch/hermes-agent/pull/1132), [#1360](https://github.com/NousResearch/hermes-agent/pull/1360), [#1396](https://github.com/NousResearch/hermes-agent/pull/1396), [#1597](https://github.com/NousResearch/hermes-agent/pull/1597))
+- Fix adaptive thinking without `budget_tokens` for Claude 4.6 models — by @ASRagab ([#1128](https://github.com/NousResearch/hermes-agent/pull/1128))
+- Fix Anthropic cache markers through adapter — by @brandtcormorant ([#1216](https://github.com/NousResearch/hermes-agent/pull/1216))
+- Retry Anthropic 429/529 errors and surface details to users — by @0xbyt4 ([#1585](https://github.com/NousResearch/hermes-agent/pull/1585))
+- Fix Anthropic adapter max_tokens, fallback crash, proxy base_url — by @0xbyt4 ([#1121](https://github.com/NousResearch/hermes-agent/pull/1121))
+- Fix DeepSeek V3 parser dropping multiple parallel tool calls — by @mr-emmett-one ([#1365](https://github.com/NousResearch/hermes-agent/pull/1365), [#1300](https://github.com/NousResearch/hermes-agent/pull/1300))
+- Accept unlisted models with warning instead of rejecting ([#1047](https://github.com/NousResearch/hermes-agent/pull/1047), [#1102](https://github.com/NousResearch/hermes-agent/pull/1102))
+- Skip reasoning params for unsupported OpenRouter models ([#1485](https://github.com/NousResearch/hermes-agent/pull/1485))
+- MiniMax Anthropic API compatibility fix ([#1623](https://github.com/NousResearch/hermes-agent/pull/1623))
+- Custom endpoint `/models` verification and `/v1` base URL suggestion ([#1480](https://github.com/NousResearch/hermes-agent/pull/1480))
+- Resolve delegation providers from `custom_providers` config ([#1328](https://github.com/NousResearch/hermes-agent/pull/1328))
+- Kimi model additions and User-Agent fix ([#1039](https://github.com/NousResearch/hermes-agent/pull/1039))
+- Strip `call_id`/`response_item_id` for Mistral compatibility ([#1058](https://github.com/NousResearch/hermes-agent/pull/1058))
+
+### Agent Loop & Conversation
+- **Anthropic Context Editing API** support ([#1147](https://github.com/NousResearch/hermes-agent/pull/1147))
+- Improved context compaction handoff summaries — compressor now preserves more actionable state ([#1273](https://github.com/NousResearch/hermes-agent/pull/1273))
+- Sync session_id after mid-run context compression ([#1160](https://github.com/NousResearch/hermes-agent/pull/1160))
+- Session hygiene threshold tuned to 50% for more proactive compression ([#1096](https://github.com/NousResearch/hermes-agent/pull/1096), [#1161](https://github.com/NousResearch/hermes-agent/pull/1161))
+- Include session ID in system prompt via `--pass-session-id` flag ([#1040](https://github.com/NousResearch/hermes-agent/pull/1040))
+- Prevent closed OpenAI client reuse across retries ([#1391](https://github.com/NousResearch/hermes-agent/pull/1391))
+- Sanitize chat payloads and provider precedence ([#1253](https://github.com/NousResearch/hermes-agent/pull/1253))
+- Handle dict tool call arguments from Codex and local backends ([#1393](https://github.com/NousResearch/hermes-agent/pull/1393), [#1440](https://github.com/NousResearch/hermes-agent/pull/1440))
+
+### Memory & Sessions
+- **Improve memory prioritization** — user preferences and corrections weighted above procedural knowledge ([#1548](https://github.com/NousResearch/hermes-agent/pull/1548))
+- Tighter memory and session recall guidance in system prompts ([#1329](https://github.com/NousResearch/hermes-agent/pull/1329))
+- Persist CLI token counts to session DB for `/insights` ([#1498](https://github.com/NousResearch/hermes-agent/pull/1498))
+- Keep Honcho recall out of the cached system prefix ([#1201](https://github.com/NousResearch/hermes-agent/pull/1201))
+- Correct `seed_ai_identity` to use `session.add_messages()` ([#1475](https://github.com/NousResearch/hermes-agent/pull/1475))
+- Isolate Honcho session routing for multi-user gateway ([#1500](https://github.com/NousResearch/hermes-agent/pull/1500))
+
+---
+
+## 📱 Messaging Platforms (Gateway)
+
+### Gateway Core
+- **System gateway service mode** — run as a system-level systemd service, not just user-level ([#1371](https://github.com/NousResearch/hermes-agent/pull/1371))
+- **Gateway install scope prompts** — choose user vs system scope during setup ([#1374](https://github.com/NousResearch/hermes-agent/pull/1374))
+- **Reasoning hot reload** — change reasoning settings without restarting the gateway ([#1275](https://github.com/NousResearch/hermes-agent/pull/1275))
+- Default group sessions to per-user isolation — no more shared state across users in group chats ([#1495](https://github.com/NousResearch/hermes-agent/pull/1495), [#1417](https://github.com/NousResearch/hermes-agent/pull/1417))
+- Harden gateway restart recovery ([#1310](https://github.com/NousResearch/hermes-agent/pull/1310))
+- Cancel active runs during shutdown ([#1427](https://github.com/NousResearch/hermes-agent/pull/1427))
+- SSL certificate auto-detection for NixOS and non-standard systems ([#1494](https://github.com/NousResearch/hermes-agent/pull/1494))
+- Auto-detect D-Bus session bus for `systemctl --user` on headless servers ([#1601](https://github.com/NousResearch/hermes-agent/pull/1601))
+- Auto-enable systemd linger during gateway install on headless servers ([#1334](https://github.com/NousResearch/hermes-agent/pull/1334))
+- Fall back to module entrypoint when `hermes` is not on PATH ([#1355](https://github.com/NousResearch/hermes-agent/pull/1355))
+- Fix dual gateways on macOS launchd after `hermes update` ([#1567](https://github.com/NousResearch/hermes-agent/pull/1567))
+- Remove recursive ExecStop from systemd units ([#1530](https://github.com/NousResearch/hermes-agent/pull/1530))
+- Prevent logging handler accumulation in gateway mode ([#1251](https://github.com/NousResearch/hermes-agent/pull/1251))
+- Restart on retryable startup failures — by @jplew ([#1517](https://github.com/NousResearch/hermes-agent/pull/1517))
+- Backfill model on gateway sessions after agent runs ([#1306](https://github.com/NousResearch/hermes-agent/pull/1306))
+- PID-based gateway kill and deferred config write ([#1499](https://github.com/NousResearch/hermes-agent/pull/1499))
+
+### Telegram
+- Buffer media groups to prevent self-interruption from photo bursts ([#1341](https://github.com/NousResearch/hermes-agent/pull/1341), [#1422](https://github.com/NousResearch/hermes-agent/pull/1422))
+- Retry on transient TLS failures during connect and send ([#1535](https://github.com/NousResearch/hermes-agent/pull/1535))
+- Harden polling conflict handling ([#1339](https://github.com/NousResearch/hermes-agent/pull/1339))
+- Escape chunk indicators and inline code in MarkdownV2 ([#1478](https://github.com/NousResearch/hermes-agent/pull/1478), [#1626](https://github.com/NousResearch/hermes-agent/pull/1626))
+- Check updater/app state before disconnect ([#1389](https://github.com/NousResearch/hermes-agent/pull/1389))
+
+### Discord
+- `/thread` command with `auto_thread` config and media metadata fixes ([#1178](https://github.com/NousResearch/hermes-agent/pull/1178))
+- Auto-thread on @mention, skip mention text in bot threads ([#1438](https://github.com/NousResearch/hermes-agent/pull/1438))
+- Retry without reply reference for system messages ([#1385](https://github.com/NousResearch/hermes-agent/pull/1385))
+- Preserve native document and video attachment support ([#1392](https://github.com/NousResearch/hermes-agent/pull/1392))
+- Defer discord adapter annotations to avoid optional import crashes ([#1314](https://github.com/NousResearch/hermes-agent/pull/1314))
+
+### Slack
+- Thread handling overhaul — progress messages, responses, and session isolation all respect threads ([#1103](https://github.com/NousResearch/hermes-agent/pull/1103))
+- Formatting, reactions, user resolution, and command improvements ([#1106](https://github.com/NousResearch/hermes-agent/pull/1106))
+- Fix MAX_MESSAGE_LENGTH 3900 → 39000 ([#1117](https://github.com/NousResearch/hermes-agent/pull/1117))
+- File upload fallback preserves thread context — by @0xbyt4 ([#1122](https://github.com/NousResearch/hermes-agent/pull/1122))
+- Improve setup guidance ([#1387](https://github.com/NousResearch/hermes-agent/pull/1387))
+
+### Email
+- Fix IMAP UID tracking and SMTP TLS verification ([#1305](https://github.com/NousResearch/hermes-agent/pull/1305))
+- Add `skip_attachments` option via config.yaml ([#1536](https://github.com/NousResearch/hermes-agent/pull/1536))
+
+### Home Assistant
+- Event filtering closed by default ([#1169](https://github.com/NousResearch/hermes-agent/pull/1169))
+
+---
+
+## 🖥️ CLI & User Experience
+
+### Interactive CLI
+- **Persistent CLI status bar** — always-visible model, provider, and token counts ([#1522](https://github.com/NousResearch/hermes-agent/pull/1522))
+- **File path autocomplete** in the input prompt ([#1545](https://github.com/NousResearch/hermes-agent/pull/1545))
+- **`/plan` command** — generate implementation plans from specs ([#1372](https://github.com/NousResearch/hermes-agent/pull/1372), [#1381](https://github.com/NousResearch/hermes-agent/pull/1381))
+- **Major `/rollback` improvements** — richer checkpoint history, clearer UX ([#1505](https://github.com/NousResearch/hermes-agent/pull/1505))
+- **Preload CLI skills on launch** — skills are ready before the first prompt ([#1359](https://github.com/NousResearch/hermes-agent/pull/1359))
+- **Centralized slash command registry** — all commands defined once, consumed everywhere ([#1603](https://github.com/NousResearch/hermes-agent/pull/1603))
+- `/bg` alias for `/background` ([#1590](https://github.com/NousResearch/hermes-agent/pull/1590))
+- Prefix matching for slash commands — `/mod` resolves to `/model` ([#1320](https://github.com/NousResearch/hermes-agent/pull/1320))
+- `/new`, `/reset`, `/clear` now start genuinely fresh sessions ([#1237](https://github.com/NousResearch/hermes-agent/pull/1237))
+- Accept session ID prefixes for session actions ([#1425](https://github.com/NousResearch/hermes-agent/pull/1425))
+- TUI prompt and accent output now respect active skin ([#1282](https://github.com/NousResearch/hermes-agent/pull/1282))
+- Centralize tool emoji metadata in registry + skin integration ([#1484](https://github.com/NousResearch/hermes-agent/pull/1484))
+- "View full command" option added to dangerous command approval — by @teknium1 based on design by community ([#887](https://github.com/NousResearch/hermes-agent/pull/887))
+- Non-blocking startup update check and banner deduplication ([#1386](https://github.com/NousResearch/hermes-agent/pull/1386))
+- `/reasoning` command output ordering and inline think extraction fixes ([#1031](https://github.com/NousResearch/hermes-agent/pull/1031))
+- Verbose mode shows full untruncated output ([#1472](https://github.com/NousResearch/hermes-agent/pull/1472))
+- Fix `/status` to report live state and tokens ([#1476](https://github.com/NousResearch/hermes-agent/pull/1476))
+- Seed a default global SOUL.md ([#1311](https://github.com/NousResearch/hermes-agent/pull/1311))
+
+### Setup & Configuration
+- **OpenClaw migration** during first-time setup — by @kshitijk4poor ([#981](https://github.com/NousResearch/hermes-agent/pull/981))
+- `hermes claw migrate` command + migration docs ([#1059](https://github.com/NousResearch/hermes-agent/pull/1059))
+- Smart vision setup that respects the user's chosen provider ([#1323](https://github.com/NousResearch/hermes-agent/pull/1323))
+- Handle headless setup flows end-to-end ([#1274](https://github.com/NousResearch/hermes-agent/pull/1274))
+- Prefer curses over `simple_term_menu` in setup.py ([#1487](https://github.com/NousResearch/hermes-agent/pull/1487))
+- Show effective model and provider in `/status` ([#1284](https://github.com/NousResearch/hermes-agent/pull/1284))
+- Config set examples use placeholder syntax ([#1322](https://github.com/NousResearch/hermes-agent/pull/1322))
+- Reload .env over stale shell overrides ([#1434](https://github.com/NousResearch/hermes-agent/pull/1434))
+- Fix is_coding_plan NameError crash — by @0xbyt4 ([#1123](https://github.com/NousResearch/hermes-agent/pull/1123))
+- Add missing packages to setuptools config — by @alt-glitch ([#912](https://github.com/NousResearch/hermes-agent/pull/912))
+- Installer: clarify why sudo is needed at every prompt ([#1602](https://github.com/NousResearch/hermes-agent/pull/1602))
+
+---
+
+## 🔧 Tool System
+
+### Terminal & Execution
+- **Persistent shell mode** for local and SSH backends — maintain shell state across tool calls — by @alt-glitch ([#1067](https://github.com/NousResearch/hermes-agent/pull/1067), [#1483](https://github.com/NousResearch/hermes-agent/pull/1483))
+- **Tirith pre-exec command scanning** — security layer that analyzes commands before execution ([#1256](https://github.com/NousResearch/hermes-agent/pull/1256))
+- Strip Hermes provider env vars from all subprocess environments ([#1157](https://github.com/NousResearch/hermes-agent/pull/1157), [#1172](https://github.com/NousResearch/hermes-agent/pull/1172), [#1399](https://github.com/NousResearch/hermes-agent/pull/1399), [#1419](https://github.com/NousResearch/hermes-agent/pull/1419)) — initial fix by @eren-karakus0
+- SSH preflight check ([#1486](https://github.com/NousResearch/hermes-agent/pull/1486))
+- Docker backend: make cwd workspace mount explicit opt-in ([#1534](https://github.com/NousResearch/hermes-agent/pull/1534))
+- Add project root to PYTHONPATH in execute_code sandbox ([#1383](https://github.com/NousResearch/hermes-agent/pull/1383))
+- Eliminate execute_code progress spam on gateway platforms ([#1098](https://github.com/NousResearch/hermes-agent/pull/1098))
+- Clearer docker backend preflight errors ([#1276](https://github.com/NousResearch/hermes-agent/pull/1276))
+
+### Browser
+- **`/browser connect`** — attach browser tools to a live Chrome instance via CDP ([#1549](https://github.com/NousResearch/hermes-agent/pull/1549))
+- Improve browser cleanup, local browser PATH setup, and screenshot recovery ([#1333](https://github.com/NousResearch/hermes-agent/pull/1333))
+
+### MCP
+- **Selective tool loading** with utility policies — filter which MCP tools are available ([#1302](https://github.com/NousResearch/hermes-agent/pull/1302))
+- Auto-reload MCP tools when `mcp_servers` config changes without restart ([#1474](https://github.com/NousResearch/hermes-agent/pull/1474))
+- Resolve npx stdio connection failures ([#1291](https://github.com/NousResearch/hermes-agent/pull/1291))
+- Preserve MCP toolsets when saving platform tool config ([#1421](https://github.com/NousResearch/hermes-agent/pull/1421))
+
+### Vision
+- Unify vision backend gating ([#1367](https://github.com/NousResearch/hermes-agent/pull/1367))
+- Surface actual error reason instead of generic message ([#1338](https://github.com/NousResearch/hermes-agent/pull/1338))
+- Make Claude image handling work end-to-end ([#1408](https://github.com/NousResearch/hermes-agent/pull/1408))
+
+### Cron
+- **Compress cron management into one tool** — single `cronjob` tool replaces multiple commands ([#1343](https://github.com/NousResearch/hermes-agent/pull/1343))
+- Suppress duplicate cron sends to auto-delivery targets ([#1357](https://github.com/NousResearch/hermes-agent/pull/1357))
+- Persist cron sessions to SQLite ([#1255](https://github.com/NousResearch/hermes-agent/pull/1255))
+- Per-job runtime overrides (provider, model, base_url) ([#1398](https://github.com/NousResearch/hermes-agent/pull/1398))
+- Atomic write in `save_job_output` to prevent data loss on crash ([#1173](https://github.com/NousResearch/hermes-agent/pull/1173))
+- Preserve thread context for `deliver=origin` ([#1437](https://github.com/NousResearch/hermes-agent/pull/1437))
+
+### Patch Tool
+- Avoid corrupting pipe chars in V4A patch apply ([#1286](https://github.com/NousResearch/hermes-agent/pull/1286))
+- Permissive `block_anchor` thresholds and unicode normalization ([#1539](https://github.com/NousResearch/hermes-agent/pull/1539))
+
+### Delegation
+- Add observability metadata to subagent results (model, tokens, duration, tool trace) ([#1175](https://github.com/NousResearch/hermes-agent/pull/1175))
+
+---
+
+## 🧩 Skills Ecosystem
+
+### Skills System
+- **Integrate skills.sh** as a hub source alongside ClawHub ([#1303](https://github.com/NousResearch/hermes-agent/pull/1303))
+- Secure skill env setup on load ([#1153](https://github.com/NousResearch/hermes-agent/pull/1153))
+- Honor policy table for dangerous verdicts ([#1330](https://github.com/NousResearch/hermes-agent/pull/1330))
+- Harden ClawHub skill search exact matches ([#1400](https://github.com/NousResearch/hermes-agent/pull/1400))
+- Fix ClawHub skill install — use `/download` ZIP endpoint ([#1060](https://github.com/NousResearch/hermes-agent/pull/1060))
+- Avoid mislabeling local skills as builtin — by @arceus77-7 ([#862](https://github.com/NousResearch/hermes-agent/pull/862))
+
+### New Skills
+- **Linear** project management ([#1230](https://github.com/NousResearch/hermes-agent/pull/1230))
+- **X/Twitter** via x-cli ([#1285](https://github.com/NousResearch/hermes-agent/pull/1285))
+- **Telephony** — Twilio, SMS, and AI calls ([#1289](https://github.com/NousResearch/hermes-agent/pull/1289))
+- **1Password** — by @arceus77-7 ([#883](https://github.com/NousResearch/hermes-agent/pull/883), [#1179](https://github.com/NousResearch/hermes-agent/pull/1179))
+- **NeuroSkill BCI** integration ([#1135](https://github.com/NousResearch/hermes-agent/pull/1135))
+- **Blender MCP** for 3D modeling ([#1531](https://github.com/NousResearch/hermes-agent/pull/1531))
+- **OSS Security Forensics** ([#1482](https://github.com/NousResearch/hermes-agent/pull/1482))
+- **Parallel CLI** research skill ([#1301](https://github.com/NousResearch/hermes-agent/pull/1301))
+- **OpenCode** CLI skill ([#1174](https://github.com/NousResearch/hermes-agent/pull/1174))
+- **ASCII Video** skill refactored — by @SHL0MS ([#1213](https://github.com/NousResearch/hermes-agent/pull/1213), [#1598](https://github.com/NousResearch/hermes-agent/pull/1598))
+
+---
+
+## 🎙️ Voice Mode
+
+- Voice mode foundation — push-to-talk CLI, Telegram/Discord voice notes ([#1299](https://github.com/NousResearch/hermes-agent/pull/1299))
+- Free local Whisper transcription via faster-whisper ([#1185](https://github.com/NousResearch/hermes-agent/pull/1185))
+- Discord voice channel reliability fixes ([#1429](https://github.com/NousResearch/hermes-agent/pull/1429))
+- Restore local STT fallback for gateway voice notes ([#1490](https://github.com/NousResearch/hermes-agent/pull/1490))
+- Honor `stt.enabled: false` across gateway transcription ([#1394](https://github.com/NousResearch/hermes-agent/pull/1394))
+- Fix bogus incapability message on Telegram voice notes (Issue [#1033](https://github.com/NousResearch/hermes-agent/issues/1033))
+
+---
+
+## 🔌 ACP (IDE Integration)
+
+- Restore ACP server implementation ([#1254](https://github.com/NousResearch/hermes-agent/pull/1254))
+- Support slash commands in ACP adapter ([#1532](https://github.com/NousResearch/hermes-agent/pull/1532))
+
+---
+
+## 🧪 RL Training
+
+- **Agentic On-Policy Distillation (OPD)** environment — new RL training environment for agent policy distillation ([#1149](https://github.com/NousResearch/hermes-agent/pull/1149))
+- Make tinker-atropos RL training fully optional ([#1062](https://github.com/NousResearch/hermes-agent/pull/1062))
+
+---
+
+## 🔒 Security & Reliability
+
+### Security Hardening
+- **Tirith pre-exec command scanning** — static analysis of terminal commands before execution ([#1256](https://github.com/NousResearch/hermes-agent/pull/1256))
+- **PII redaction** when `privacy.redact_pii` is enabled ([#1542](https://github.com/NousResearch/hermes-agent/pull/1542))
+- Strip Hermes provider/gateway/tool env vars from all subprocess environments ([#1157](https://github.com/NousResearch/hermes-agent/pull/1157), [#1172](https://github.com/NousResearch/hermes-agent/pull/1172), [#1399](https://github.com/NousResearch/hermes-agent/pull/1399), [#1419](https://github.com/NousResearch/hermes-agent/pull/1419))
+- Docker cwd workspace mount now explicit opt-in — never auto-mount host directories ([#1534](https://github.com/NousResearch/hermes-agent/pull/1534))
+- Escape parens and braces in fork bomb regex pattern ([#1397](https://github.com/NousResearch/hermes-agent/pull/1397))
+- Harden `.worktreeinclude` path containment ([#1388](https://github.com/NousResearch/hermes-agent/pull/1388))
+- Use description as `pattern_key` to prevent approval collisions ([#1395](https://github.com/NousResearch/hermes-agent/pull/1395))
+
+### Reliability
+- Guard init-time stdio writes ([#1271](https://github.com/NousResearch/hermes-agent/pull/1271))
+- Session log writes reuse shared atomic JSON helper ([#1280](https://github.com/NousResearch/hermes-agent/pull/1280))
+- Atomic temp cleanup protected on interrupts ([#1401](https://github.com/NousResearch/hermes-agent/pull/1401))
+
+---
+
+## 🐛 Notable Bug Fixes
+
+- **`/status` always showing 0 tokens** — now reports live state (Issue [#1465](https://github.com/NousResearch/hermes-agent/issues/1465), [#1476](https://github.com/NousResearch/hermes-agent/pull/1476))
+- **Custom model endpoints not working** — restored config-saved endpoint resolution (Issue [#1460](https://github.com/NousResearch/hermes-agent/issues/1460), [#1373](https://github.com/NousResearch/hermes-agent/pull/1373))
+- **MCP tools not visible until restart** — auto-reload on config change (Issue [#1036](https://github.com/NousResearch/hermes-agent/issues/1036), [#1474](https://github.com/NousResearch/hermes-agent/pull/1474))
+- **`hermes tools` removing MCP tools** — preserve MCP toolsets when saving (Issue [#1247](https://github.com/NousResearch/hermes-agent/issues/1247), [#1421](https://github.com/NousResearch/hermes-agent/pull/1421))
+- **Terminal subprocesses inheriting `OPENAI_BASE_URL`** breaking external tools (Issue [#1002](https://github.com/NousResearch/hermes-agent/issues/1002), [#1399](https://github.com/NousResearch/hermes-agent/pull/1399))
+- **Background process lost on gateway restart** — improved recovery (Issue [#1144](https://github.com/NousResearch/hermes-agent/issues/1144))
+- **Cron jobs not persisting state** — now stored in SQLite (Issue [#1416](https://github.com/NousResearch/hermes-agent/issues/1416), [#1255](https://github.com/NousResearch/hermes-agent/pull/1255))
+- **Cronjob `deliver: origin` not preserving thread context** (Issue [#1219](https://github.com/NousResearch/hermes-agent/issues/1219), [#1437](https://github.com/NousResearch/hermes-agent/pull/1437))
+- **Gateway systemd service failing to auto-restart** when browser processes orphaned (Issue [#1617](https://github.com/NousResearch/hermes-agent/issues/1617))
+- **`/background` completion report cut off in Telegram** (Issue [#1443](https://github.com/NousResearch/hermes-agent/issues/1443))
+- **Model switching not taking effect** (Issue [#1244](https://github.com/NousResearch/hermes-agent/issues/1244), [#1183](https://github.com/NousResearch/hermes-agent/pull/1183))
+- **`hermes doctor` reporting cronjob as unavailable** (Issue [#878](https://github.com/NousResearch/hermes-agent/issues/878), [#1180](https://github.com/NousResearch/hermes-agent/pull/1180))
+- **WhatsApp bridge messages not received** from mobile (Issue [#1142](https://github.com/NousResearch/hermes-agent/issues/1142))
+- **Setup wizard hanging on headless SSH** (Issue [#905](https://github.com/NousResearch/hermes-agent/issues/905), [#1274](https://github.com/NousResearch/hermes-agent/pull/1274))
+- **Log handler accumulation** degrading gateway performance (Issue [#990](https://github.com/NousResearch/hermes-agent/issues/990), [#1251](https://github.com/NousResearch/hermes-agent/pull/1251))
+- **Gateway NULL model in DB** (Issue [#987](https://github.com/NousResearch/hermes-agent/issues/987), [#1306](https://github.com/NousResearch/hermes-agent/pull/1306))
+- **Strict endpoints rejecting replayed tool_calls** (Issue [#893](https://github.com/NousResearch/hermes-agent/issues/893))
+- **Remaining hardcoded `~/.hermes` paths** — all now respect `HERMES_HOME` (Issue [#892](https://github.com/NousResearch/hermes-agent/issues/892), [#1233](https://github.com/NousResearch/hermes-agent/pull/1233))
+- **Delegate tool not working with custom inference providers** (Issue [#1011](https://github.com/NousResearch/hermes-agent/issues/1011), [#1328](https://github.com/NousResearch/hermes-agent/pull/1328))
+- **Skills Guard blocking official skills** (Issue [#1006](https://github.com/NousResearch/hermes-agent/issues/1006), [#1330](https://github.com/NousResearch/hermes-agent/pull/1330))
+- **Setup writing provider before model selection** (Issue [#1182](https://github.com/NousResearch/hermes-agent/issues/1182))
+- **`GatewayConfig.get()` AttributeError** crashing all message handling (Issue [#1158](https://github.com/NousResearch/hermes-agent/issues/1158), [#1287](https://github.com/NousResearch/hermes-agent/pull/1287))
+- **`/update` hard-failing with "command not found"** (Issue [#1049](https://github.com/NousResearch/hermes-agent/issues/1049))
+- **Image analysis failing silently** (Issue [#1034](https://github.com/NousResearch/hermes-agent/issues/1034), [#1338](https://github.com/NousResearch/hermes-agent/pull/1338))
+- **API `BadRequestError` from `'dict'` object has no attribute `'strip'`** (Issue [#1071](https://github.com/NousResearch/hermes-agent/issues/1071))
+- **Slash commands requiring exact full name** — now uses prefix matching (Issue [#928](https://github.com/NousResearch/hermes-agent/issues/928), [#1320](https://github.com/NousResearch/hermes-agent/pull/1320))
+- **Gateway stops responding when terminal is closed on headless** (Issue [#1005](https://github.com/NousResearch/hermes-agent/issues/1005))
+
+---
+
+## 🧪 Testing
+
+- Cover empty cached Anthropic tool-call turns ([#1222](https://github.com/NousResearch/hermes-agent/pull/1222))
+- Fix stale CI assumptions in parser and quick-command coverage ([#1236](https://github.com/NousResearch/hermes-agent/pull/1236))
+- Fix gateway async tests without implicit event loop ([#1278](https://github.com/NousResearch/hermes-agent/pull/1278))
+- Make gateway async tests xdist-safe ([#1281](https://github.com/NousResearch/hermes-agent/pull/1281))
+- Cross-timezone naive timestamp regression for cron ([#1319](https://github.com/NousResearch/hermes-agent/pull/1319))
+- Isolate codex provider tests from local env ([#1335](https://github.com/NousResearch/hermes-agent/pull/1335))
+- Lock retry replacement semantics ([#1379](https://github.com/NousResearch/hermes-agent/pull/1379))
+- Improve error logging in session search tool — by @aydnOktay ([#1533](https://github.com/NousResearch/hermes-agent/pull/1533))
+
+---
+
+## 📚 Documentation
+
+- Comprehensive SOUL.md guide ([#1315](https://github.com/NousResearch/hermes-agent/pull/1315))
+- Voice mode documentation ([#1316](https://github.com/NousResearch/hermes-agent/pull/1316), [#1362](https://github.com/NousResearch/hermes-agent/pull/1362))
+- Provider contribution guide ([#1361](https://github.com/NousResearch/hermes-agent/pull/1361))
+- ACP and internal systems implementation guides ([#1259](https://github.com/NousResearch/hermes-agent/pull/1259))
+- Expand Docusaurus coverage across CLI, tools, skills, and skins ([#1232](https://github.com/NousResearch/hermes-agent/pull/1232))
+- Terminal backend and Windows troubleshooting ([#1297](https://github.com/NousResearch/hermes-agent/pull/1297))
+- Skills hub reference section ([#1317](https://github.com/NousResearch/hermes-agent/pull/1317))
+- Checkpoint, /rollback, and git worktrees guide ([#1493](https://github.com/NousResearch/hermes-agent/pull/1493), [#1524](https://github.com/NousResearch/hermes-agent/pull/1524))
+- CLI status bar and /usage reference ([#1523](https://github.com/NousResearch/hermes-agent/pull/1523))
+- Fallback providers + /background command docs ([#1430](https://github.com/NousResearch/hermes-agent/pull/1430))
+- Gateway service scopes docs ([#1378](https://github.com/NousResearch/hermes-agent/pull/1378))
+- Slack thread reply behavior docs ([#1407](https://github.com/NousResearch/hermes-agent/pull/1407))
+- Redesigned landing page with Nous blue palette — by @austinpickett ([#974](https://github.com/NousResearch/hermes-agent/pull/974))
+- Fix several documentation typos — by @JackTheGit ([#953](https://github.com/NousResearch/hermes-agent/pull/953))
+- Stabilize website diagrams ([#1405](https://github.com/NousResearch/hermes-agent/pull/1405))
+- CLI vs messaging quick reference in README ([#1491](https://github.com/NousResearch/hermes-agent/pull/1491))
+- Add search to Docusaurus ([#1053](https://github.com/NousResearch/hermes-agent/pull/1053))
+- Home Assistant integration docs ([#1170](https://github.com/NousResearch/hermes-agent/pull/1170))
+
+---
+
+## 👥 Contributors
+
+### Core
+- **@teknium1** — 220+ PRs spanning every area of the codebase
+
+### Top Community Contributors
+
+- **@0xbyt4** (4 PRs) — Anthropic adapter fixes (max_tokens, fallback crash, 429/529 retry), Slack file upload thread context, setup NameError fix
+- **@erosika** (1 PR) — Honcho memory integration: async writes, memory modes, session title integration
+- **@SHL0MS** (2 PRs) — ASCII video skill design patterns and refactoring
+- **@alt-glitch** (2 PRs) — Persistent shell mode for local/SSH backends, setuptools packaging fix
+- **@arceus77-7** (2 PRs) — 1Password skill, fix skills list mislabeling
+- **@kshitijk4poor** (1 PR) — OpenClaw migration during setup wizard
+- **@ASRagab** (1 PR) — Fix adaptive thinking for Claude 4.6 models
+- **@eren-karakus0** (1 PR) — Strip Hermes provider env vars from subprocess environment
+- **@mr-emmett-one** (1 PR) — Fix DeepSeek V3 parser multi-tool call support
+- **@jplew** (1 PR) — Gateway restart on retryable startup failures
+- **@brandtcormorant** (1 PR) — Fix Anthropic cache control for empty text blocks
+- **@aydnOktay** (1 PR) — Improve error logging in session search tool
+- **@austinpickett** (1 PR) — Landing page redesign with Nous blue palette
+- **@JackTheGit** (1 PR) — Documentation typo fixes
+
+### All Contributors
+
+@0xbyt4, @alt-glitch, @arceus77-7, @ASRagab, @austinpickett, @aydnOktay, @brandtcormorant, @eren-karakus0, @erosika, @JackTheGit, @jplew, @kshitijk4poor, @mr-emmett-one, @SHL0MS, @teknium1
+
+---
+
+**Full Changelog**: [v2026.3.12...v2026.3.17](https://github.com/NousResearch/hermes-agent/compare/v2026.3.12...v2026.3.17)
diff --git a/hermes_cli/__init__.py b/hermes_cli/__init__.py
index 3c7adeea69..90f082720f 100644
--- a/hermes_cli/__init__.py
+++ b/hermes_cli/__init__.py
@@ -11,5 +11,5 @@ Provides subcommands for:
- hermes cron - Manage cron jobs
"""
-__version__ = "0.2.0"
-__release_date__ = "2026.3.12"
+__version__ = "0.3.0"
+__release_date__ = "2026.3.17"
diff --git a/pyproject.toml b/pyproject.toml
index fa248cd0e5..74d8f1178b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hermes-agent"
-version = "0.2.0"
+version = "0.3.0"
description = "The self-improving AI agent — creates skills from experience, improves them during use, and runs anywhere"
readme = "README.md"
requires-python = ">=3.11"
From 634c1f67523a3de4e95652dc000491fda154bf43 Mon Sep 17 00:00:00 2001
From: teknium1
Date: Tue, 17 Mar 2026 01:13:34 -0700
Subject: [PATCH 08/19] fix: sanitize corrupted .env files on read and during
migration
Fixes two corruption patterns that break API keys during updates:
1. Concatenated KEY=VALUE pairs on a single line due to missing newlines
(e.g. ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...). Uses a
known-keys set to safely detect and split concatenated entries without
false-splitting values that contain uppercase text.
2. Stale KEY=*** placeholder entries left by incomplete setup runs that
never get updated and shadow real credentials.
Changes:
- Add _sanitize_env_lines() that splits concatenated known keys and drops
*** placeholders
- Add sanitize_env_file() public API for explicit repair
- Call sanitization in save_env_value() on every read (self-healing)
- Call sanitize_env_file() at the start of migrate_config() so existing
corrupted files are repaired on update
- 12 new tests covering splits, placeholders, edge cases, and integration
---
hermes_cli/config.py | 124 ++++++++++++++++++++++++++++++
tests/hermes_cli/test_config.py | 132 ++++++++++++++++++++++++++++++++
2 files changed, 256 insertions(+)
diff --git a/hermes_cli/config.py b/hermes_cli/config.py
index c3a4c701a5..18bfc6de5d 100644
--- a/hermes_cli/config.py
+++ b/hermes_cli/config.py
@@ -25,6 +25,18 @@ from typing import Dict, Any, Optional, List, Tuple
_IS_WINDOWS = platform.system() == "Windows"
_ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
+# Env var names written to .env that aren't in OPTIONAL_ENV_VARS
+# (managed by setup/provider flows directly).
+_EXTRA_ENV_KEYS = frozenset({
+ "OPENAI_API_KEY", "OPENAI_BASE_URL",
+ "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN",
+ "AUXILIARY_VISION_MODEL",
+ "DISCORD_HOME_CHANNEL", "TELEGRAM_HOME_CHANNEL",
+ "SIGNAL_ACCOUNT", "SIGNAL_HTTP_URL",
+ "SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS",
+ "TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT",
+ "WHATSAPP_MODE", "WHATSAPP_ENABLED",
+})
import yaml
@@ -765,6 +777,14 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
Dict with migration results: {"env_added": [...], "config_added": [...], "warnings": [...]}
"""
results = {"env_added": [], "config_added": [], "warnings": []}
+
+ # ── Always: sanitize .env (split concatenated keys, drop *** placeholders) ──
+ try:
+ fixes = sanitize_env_file()
+ if fixes and not quiet:
+ print(f" ✓ Repaired .env file ({fixes} corrupted entries fixed)")
+ except Exception:
+ pass # best-effort; don't block migration on sanitize failure
# Check config version
current_ver, latest_ver = check_config_version()
@@ -1121,6 +1141,108 @@ def load_env() -> Dict[str, str]:
return env_vars
+def _sanitize_env_lines(lines: list) -> list:
+ """Fix corrupted .env lines before writing.
+
+ Handles two known corruption patterns:
+ 1. Concatenated KEY=VALUE pairs on a single line (missing newline between
+ entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).
+ 2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.
+
+ Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only
+ split on real Hermes env var names, avoiding false positives from values
+ that happen to contain uppercase text with ``=``.
+ """
+ # Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.
+ # Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.
+ known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS
+
+ sanitized: list[str] = []
+ for line in lines:
+ raw = line.rstrip("\r\n")
+ stripped = raw.strip()
+
+ # Preserve blank lines and comments
+ if not stripped or stripped.startswith("#"):
+ sanitized.append(raw + "\n")
+ continue
+
+ # Drop stale *** placeholder entries
+ if "=" in stripped:
+ _k, _, _v = stripped.partition("=")
+ if _v.strip().strip("'\"") == "***":
+ continue
+
+ # Detect concatenated KEY=VALUE pairs on one line.
+ # Search for known KEY= patterns at any position in the line.
+ split_positions = []
+ for key_name in known_keys:
+ needle = key_name + "="
+ idx = stripped.find(needle)
+ while idx >= 0:
+ split_positions.append(idx)
+ idx = stripped.find(needle, idx + len(needle))
+
+ if len(split_positions) > 1:
+ split_positions.sort()
+ # Deduplicate (shouldn't happen, but be safe)
+ split_positions = sorted(set(split_positions))
+ for i, pos in enumerate(split_positions):
+ end = split_positions[i + 1] if i + 1 < len(split_positions) else len(stripped)
+ part = stripped[pos:end].strip()
+ if part:
+ sanitized.append(part + "\n")
+ else:
+ sanitized.append(stripped + "\n")
+
+ return sanitized
+
+
+def sanitize_env_file() -> int:
+ """Read, sanitize, and rewrite ~/.hermes/.env in place.
+
+ Returns the number of lines that were fixed (concatenation splits +
+ placeholder removals). Returns 0 when no changes are needed.
+ """
+ env_path = get_env_path()
+ if not env_path.exists():
+ return 0
+
+ read_kw = {"encoding": "utf-8", "errors": "replace"} if _IS_WINDOWS else {}
+ write_kw = {"encoding": "utf-8"} if _IS_WINDOWS else {}
+
+ with open(env_path, **read_kw) as f:
+ original_lines = f.readlines()
+
+ sanitized = _sanitize_env_lines(original_lines)
+
+ if sanitized == original_lines:
+ return 0
+
+ # Count fixes: difference in line count (from splits) + removed lines
+ fixes = abs(len(sanitized) - len(original_lines))
+ if fixes == 0:
+ # Lines changed content (e.g. *** removal) even if count is same
+ fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)
+ fixes += abs(len(sanitized) - len(original_lines))
+
+ fd, tmp_path = tempfile.mkstemp(dir=str(env_path.parent), suffix=".tmp", prefix=".env_")
+ try:
+ with os.fdopen(fd, "w", **write_kw) as f:
+ f.writelines(sanitized)
+ f.flush()
+ os.fsync(f.fileno())
+ os.replace(tmp_path, env_path)
+ except BaseException:
+ try:
+ os.unlink(tmp_path)
+ except OSError:
+ pass
+ raise
+ _secure_file(env_path)
+ return fixes
+
+
def save_env_value(key: str, value: str):
"""Save or update a value in ~/.hermes/.env."""
if not _ENV_VAR_NAME_RE.match(key):
@@ -1138,6 +1260,8 @@ def save_env_value(key: str, value: str):
if env_path.exists():
with open(env_path, **read_kw) as f:
lines = f.readlines()
+ # Sanitize on every read: split concatenated keys, drop stale placeholders
+ lines = _sanitize_env_lines(lines)
# Find and update or append
found = False
diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py
index d6dc2af1d7..1a93d63919 100644
--- a/tests/hermes_cli/test_config.py
+++ b/tests/hermes_cli/test_config.py
@@ -15,6 +15,8 @@ from hermes_cli.config import (
save_config,
save_env_value,
save_env_value_secure,
+ sanitize_env_file,
+ _sanitize_env_lines,
)
@@ -203,3 +205,133 @@ class TestSaveConfigAtomicity:
raw = yaml.safe_load(f)
assert raw["model"] == "test/atomic-model"
assert raw["agent"]["max_turns"] == 77
+
+
+class TestSanitizeEnvLines:
+ """Tests for .env file corruption repair."""
+
+ def test_splits_concatenated_keys(self):
+ """Two KEY=VALUE pairs jammed on one line get split."""
+ lines = ["ANTHROPIC_API_KEY=sk-ant-xxxOPENAI_BASE_URL=https://api.openai.com/v1\n"]
+ result = _sanitize_env_lines(lines)
+ assert result == [
+ "ANTHROPIC_API_KEY=sk-ant-xxx\n",
+ "OPENAI_BASE_URL=https://api.openai.com/v1\n",
+ ]
+
+ def test_drops_stale_placeholder(self):
+ """KEY=*** entries are removed."""
+ lines = [
+ "OPENROUTER_API_KEY=sk-or-real\n",
+ "ANTHROPIC_TOKEN=***\n",
+ "FAL_KEY=fal-real\n",
+ ]
+ result = _sanitize_env_lines(lines)
+ assert result == [
+ "OPENROUTER_API_KEY=sk-or-real\n",
+ "FAL_KEY=fal-real\n",
+ ]
+
+ def test_drops_quoted_placeholder(self):
+ """KEY='***' and KEY=\"***\" are also removed."""
+ lines = ['ANTHROPIC_TOKEN="***"\n', "OTHER_KEY='***'\n"]
+ result = _sanitize_env_lines(lines)
+ assert result == []
+
+ def test_preserves_clean_file(self):
+ """A well-formed .env file passes through unchanged (modulo trailing newlines)."""
+ lines = [
+ "OPENROUTER_API_KEY=sk-or-xxx\n",
+ "FIRECRAWL_API_KEY=fc-xxx\n",
+ "# a comment\n",
+ "\n",
+ ]
+ result = _sanitize_env_lines(lines)
+ assert result == lines
+
+ def test_preserves_comments_and_blanks(self):
+ lines = ["# comment\n", "\n", "KEY=val\n"]
+ result = _sanitize_env_lines(lines)
+ assert result == lines
+
+ def test_adds_missing_trailing_newline(self):
+ """Lines missing trailing newline get one added."""
+ lines = ["FOO_BAR=baz"]
+ result = _sanitize_env_lines(lines)
+ assert result == ["FOO_BAR=baz\n"]
+
+ def test_three_concatenated_keys(self):
+ """Three known keys on one line all get separated."""
+ lines = ["FAL_KEY=111FIRECRAWL_API_KEY=222GITHUB_TOKEN=333\n"]
+ result = _sanitize_env_lines(lines)
+ assert result == [
+ "FAL_KEY=111\n",
+ "FIRECRAWL_API_KEY=222\n",
+ "GITHUB_TOKEN=333\n",
+ ]
+
+ def test_value_with_equals_sign_not_split(self):
+ """A value containing '=' shouldn't be falsely split (lowercase in value)."""
+ lines = ["OPENAI_BASE_URL=https://api.example.com/v1?key=abc123\n"]
+ result = _sanitize_env_lines(lines)
+ assert result == lines
+
+ def test_unknown_keys_not_split(self):
+ """Unknown key names on one line are NOT split (avoids false positives)."""
+ lines = ["CUSTOM_VAR=value123OTHER_THING=value456\n"]
+ result = _sanitize_env_lines(lines)
+ # Unknown keys stay on one line — no false split
+ assert len(result) == 1
+
+ def test_value_ending_with_digits_still_splits(self):
+ """Concatenation is detected even when value ends with digits."""
+ lines = ["OPENROUTER_API_KEY=sk-or-v1-abc123OPENAI_BASE_URL=https://api.openai.com/v1\n"]
+ result = _sanitize_env_lines(lines)
+ assert len(result) == 2
+ assert result[0].startswith("OPENROUTER_API_KEY=")
+ assert result[1].startswith("OPENAI_BASE_URL=")
+
+ def test_save_env_value_fixes_corruption_on_write(self, tmp_path):
+ """save_env_value sanitizes corrupted lines when writing a new key."""
+ env_file = tmp_path / ".env"
+ env_file.write_text(
+ "ANTHROPIC_API_KEY=sk-antOPENAI_BASE_URL=https://api.openai.com/v1\n"
+ "STALE_KEY=***\n"
+ )
+ with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
+ save_env_value("NEW_KEY", "new-value")
+
+ content = env_file.read_text()
+ lines = content.strip().split("\n")
+
+ # Corrupted line should be split, placeholder removed, new key added
+ assert "ANTHROPIC_API_KEY=sk-ant" in lines
+ assert "OPENAI_BASE_URL=https://api.openai.com/v1" in lines
+ assert "NEW_KEY=new-value" in lines
+ assert "STALE_KEY=***" not in content
+
+ def test_sanitize_env_file_returns_fix_count(self, tmp_path):
+ """sanitize_env_file reports how many entries were fixed."""
+ env_file = tmp_path / ".env"
+ env_file.write_text(
+ "FAL_KEY=good\n"
+ "OPENROUTER_API_KEY=valFIRECRAWL_API_KEY=val2\n"
+ "STALE=***\n"
+ )
+ with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
+ fixes = sanitize_env_file()
+ assert fixes > 0
+
+ # Verify file is now clean
+ content = env_file.read_text()
+ assert "STALE=***" not in content
+ assert "OPENROUTER_API_KEY=val\n" in content
+ assert "FIRECRAWL_API_KEY=val2\n" in content
+
+ def test_sanitize_env_file_noop_on_clean_file(self, tmp_path):
+ """No changes when file is already clean."""
+ env_file = tmp_path / ".env"
+ env_file.write_text("GOOD_KEY=good\nOTHER_KEY=other\n")
+ with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
+ fixes = sanitize_env_file()
+ assert fixes == 0
From b6a51c955eec5184969da71ed998c3defbc67487 Mon Sep 17 00:00:00 2001
From: teknium1
Date: Tue, 17 Mar 2026 01:26:23 -0700
Subject: [PATCH 09/19] fix: clear stale ANTHROPIC_TOKEN during migration,
remove false *** detection
- Remove *** placeholder detection from _sanitize_env_lines (was based on
confusing terminal redaction with literal file content)
- Add migrate_config() logic to clear stale ANTHROPIC_TOKEN when better
credentials exist (ANTHROPIC_API_KEY or Claude Code auto-discovery)
- Old ANTHROPIC_TOKEN values shadow Claude Code credential fallthrough,
breaking auth for users who updated without re-running setup
- Preserves ANTHROPIC_TOKEN when it's the only auth method available
- 3 new migration tests, updated existing tests
---
hermes_cli/config.py | 36 ++++++++++---
tests/hermes_cli/test_config.py | 94 ++++++++++++++++++++++++---------
2 files changed, 97 insertions(+), 33 deletions(-)
diff --git a/hermes_cli/config.py b/hermes_cli/config.py
index 18bfc6de5d..1cdf7853bb 100644
--- a/hermes_cli/config.py
+++ b/hermes_cli/config.py
@@ -778,13 +778,41 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
"""
results = {"env_added": [], "config_added": [], "warnings": []}
- # ── Always: sanitize .env (split concatenated keys, drop *** placeholders) ──
+ # ── Always: sanitize .env (split concatenated keys) ──
try:
fixes = sanitize_env_file()
if fixes and not quiet:
print(f" ✓ Repaired .env file ({fixes} corrupted entries fixed)")
except Exception:
pass # best-effort; don't block migration on sanitize failure
+
+ # ── Always: clear stale ANTHROPIC_TOKEN when better credentials exist ──
+ # Old setups left ANTHROPIC_TOKEN with an outdated value that shadows
+ # Claude Code auto-discovery (CLAUDE_CODE_OAUTH_TOKEN) or a direct
+ # ANTHROPIC_API_KEY.
+ try:
+ old_token = get_env_value("ANTHROPIC_TOKEN")
+ if old_token:
+ has_api_key = bool(get_env_value("ANTHROPIC_API_KEY"))
+ has_claude_code = False
+ try:
+ from agent.anthropic_adapter import (
+ read_claude_code_credentials,
+ is_claude_code_token_valid,
+ )
+ cc_creds = read_claude_code_credentials()
+ has_claude_code = bool(
+ cc_creds and is_claude_code_token_valid(cc_creds)
+ )
+ except Exception:
+ pass
+ if has_api_key or has_claude_code:
+ save_env_value("ANTHROPIC_TOKEN", "")
+ if not quiet:
+ source = "ANTHROPIC_API_KEY" if has_api_key else "Claude Code credentials"
+ print(f" ✓ Cleared stale ANTHROPIC_TOKEN (using {source} instead)")
+ except Exception:
+ pass
# Check config version
current_ver, latest_ver = check_config_version()
@@ -1167,12 +1195,6 @@ def _sanitize_env_lines(lines: list) -> list:
sanitized.append(raw + "\n")
continue
- # Drop stale *** placeholder entries
- if "=" in stripped:
- _k, _, _v = stripped.partition("=")
- if _v.strip().strip("'\"") == "***":
- continue
-
# Detect concatenated KEY=VALUE pairs on one line.
# Search for known KEY= patterns at any position in the line.
split_positions = []
diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py
index 1a93d63919..94e7f6a527 100644
--- a/tests/hermes_cli/test_config.py
+++ b/tests/hermes_cli/test_config.py
@@ -12,6 +12,7 @@ from hermes_cli.config import (
ensure_hermes_home,
load_config,
load_env,
+ migrate_config,
save_config,
save_env_value,
save_env_value_secure,
@@ -219,25 +220,6 @@ class TestSanitizeEnvLines:
"OPENAI_BASE_URL=https://api.openai.com/v1\n",
]
- def test_drops_stale_placeholder(self):
- """KEY=*** entries are removed."""
- lines = [
- "OPENROUTER_API_KEY=sk-or-real\n",
- "ANTHROPIC_TOKEN=***\n",
- "FAL_KEY=fal-real\n",
- ]
- result = _sanitize_env_lines(lines)
- assert result == [
- "OPENROUTER_API_KEY=sk-or-real\n",
- "FAL_KEY=fal-real\n",
- ]
-
- def test_drops_quoted_placeholder(self):
- """KEY='***' and KEY=\"***\" are also removed."""
- lines = ['ANTHROPIC_TOKEN="***"\n', "OTHER_KEY='***'\n"]
- result = _sanitize_env_lines(lines)
- assert result == []
-
def test_preserves_clean_file(self):
"""A well-formed .env file passes through unchanged (modulo trailing newlines)."""
lines = [
@@ -296,19 +278,18 @@ class TestSanitizeEnvLines:
env_file = tmp_path / ".env"
env_file.write_text(
"ANTHROPIC_API_KEY=sk-antOPENAI_BASE_URL=https://api.openai.com/v1\n"
- "STALE_KEY=***\n"
+ "FAL_KEY=existing\n"
)
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
- save_env_value("NEW_KEY", "new-value")
+ save_env_value("MESSAGING_CWD", "/tmp")
content = env_file.read_text()
lines = content.strip().split("\n")
- # Corrupted line should be split, placeholder removed, new key added
+ # Corrupted line should be split, new key added
assert "ANTHROPIC_API_KEY=sk-ant" in lines
assert "OPENAI_BASE_URL=https://api.openai.com/v1" in lines
- assert "NEW_KEY=new-value" in lines
- assert "STALE_KEY=***" not in content
+ assert "MESSAGING_CWD=/tmp" in lines
def test_sanitize_env_file_returns_fix_count(self, tmp_path):
"""sanitize_env_file reports how many entries were fixed."""
@@ -316,7 +297,6 @@ class TestSanitizeEnvLines:
env_file.write_text(
"FAL_KEY=good\n"
"OPENROUTER_API_KEY=valFIRECRAWL_API_KEY=val2\n"
- "STALE=***\n"
)
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
fixes = sanitize_env_file()
@@ -324,7 +304,6 @@ class TestSanitizeEnvLines:
# Verify file is now clean
content = env_file.read_text()
- assert "STALE=***" not in content
assert "OPENROUTER_API_KEY=val\n" in content
assert "FIRECRAWL_API_KEY=val2\n" in content
@@ -335,3 +314,66 @@ class TestSanitizeEnvLines:
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
fixes = sanitize_env_file()
assert fixes == 0
+
+
+class TestStaleAnthropicTokenMigration:
+ """Test that migrate_config clears stale ANTHROPIC_TOKEN."""
+
+ def test_clears_stale_token_when_api_key_exists(self, tmp_path):
+ """ANTHROPIC_TOKEN cleared when ANTHROPIC_API_KEY is also set."""
+ env_file = tmp_path / ".env"
+ env_file.write_text(
+ "ANTHROPIC_API_KEY=sk-ant-real-key\n"
+ "ANTHROPIC_TOKEN=old-stale-token\n"
+ )
+ with patch.dict(os.environ, {
+ "HERMES_HOME": str(tmp_path),
+ "ANTHROPIC_API_KEY": "sk-ant-real-key",
+ "ANTHROPIC_TOKEN": "old-stale-token",
+ }):
+ migrate_config(interactive=False, quiet=True)
+
+ env = load_env()
+ assert env.get("ANTHROPIC_TOKEN") == ""
+ assert env.get("ANTHROPIC_API_KEY") == "sk-ant-real-key"
+
+ def test_clears_stale_token_when_claude_code_available(self, tmp_path):
+ """ANTHROPIC_TOKEN cleared when Claude Code credentials exist."""
+ env_file = tmp_path / ".env"
+ env_file.write_text("ANTHROPIC_TOKEN=old-stale-token\n")
+
+ fake_creds = {"accessToken": "valid-token", "expiresAt": 0}
+ with patch.dict(os.environ, {
+ "HERMES_HOME": str(tmp_path),
+ "ANTHROPIC_TOKEN": "old-stale-token",
+ }):
+ with patch(
+ "agent.anthropic_adapter.read_claude_code_credentials",
+ return_value=fake_creds,
+ ), patch(
+ "agent.anthropic_adapter.is_claude_code_token_valid",
+ return_value=True,
+ ):
+ migrate_config(interactive=False, quiet=True)
+
+ env = load_env()
+ assert env.get("ANTHROPIC_TOKEN") == ""
+
+ def test_preserves_token_when_no_alternative(self, tmp_path):
+ """ANTHROPIC_TOKEN kept when no API key or Claude Code creds exist."""
+ env_file = tmp_path / ".env"
+ env_file.write_text("ANTHROPIC_TOKEN=only-auth-method\n")
+
+ with patch.dict(os.environ, {
+ "HERMES_HOME": str(tmp_path),
+ "ANTHROPIC_TOKEN": "only-auth-method",
+ }):
+ os.environ.pop("ANTHROPIC_API_KEY", None)
+ with patch(
+ "agent.anthropic_adapter.read_claude_code_credentials",
+ return_value=None,
+ ):
+ migrate_config(interactive=False, quiet=True)
+
+ env = load_env()
+ assert env.get("ANTHROPIC_TOKEN") == "only-auth-method"
From e9f1a8e39bfbe5358720bcc586aa4249abefda5e Mon Sep 17 00:00:00 2001
From: teknium1
Date: Tue, 17 Mar 2026 01:28:38 -0700
Subject: [PATCH 10/19] =?UTF-8?q?fix:=20gate=20ANTHROPIC=5FTOKEN=20cleanup?=
=?UTF-8?q?=20to=20config=20version=208=E2=86=929=20migration?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Bump _config_version 8 → 9
- Move stale ANTHROPIC_TOKEN clearing into 'if current_ver < 9' block
so it only runs once during the upgrade, not on every migrate_config()
- ANTHROPIC_TOKEN is still a valid auth path (OAuth flow), so we don't
want to clear it repeatedly — only during the one-time migration from
old setups that left it stale
- Add test_skips_on_version_9_or_later to verify one-time behavior
- All tests set config version 8 to trigger migration
---
hermes_cli/config.py | 56 ++++++++++++++++-----------------
tests/hermes_cli/test_config.py | 28 +++++++++++++++++
2 files changed, 55 insertions(+), 29 deletions(-)
diff --git a/hermes_cli/config.py b/hermes_cli/config.py
index 1cdf7853bb..0dde47bf78 100644
--- a/hermes_cli/config.py
+++ b/hermes_cli/config.py
@@ -349,7 +349,7 @@ DEFAULT_CONFIG = {
},
# Config schema version - bump this when adding new required fields
- "_config_version": 8,
+ "_config_version": 9,
}
# =============================================================================
@@ -786,34 +786,6 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
except Exception:
pass # best-effort; don't block migration on sanitize failure
- # ── Always: clear stale ANTHROPIC_TOKEN when better credentials exist ──
- # Old setups left ANTHROPIC_TOKEN with an outdated value that shadows
- # Claude Code auto-discovery (CLAUDE_CODE_OAUTH_TOKEN) or a direct
- # ANTHROPIC_API_KEY.
- try:
- old_token = get_env_value("ANTHROPIC_TOKEN")
- if old_token:
- has_api_key = bool(get_env_value("ANTHROPIC_API_KEY"))
- has_claude_code = False
- try:
- from agent.anthropic_adapter import (
- read_claude_code_credentials,
- is_claude_code_token_valid,
- )
- cc_creds = read_claude_code_credentials()
- has_claude_code = bool(
- cc_creds and is_claude_code_token_valid(cc_creds)
- )
- except Exception:
- pass
- if has_api_key or has_claude_code:
- save_env_value("ANTHROPIC_TOKEN", "")
- if not quiet:
- source = "ANTHROPIC_API_KEY" if has_api_key else "Claude Code credentials"
- print(f" ✓ Cleared stale ANTHROPIC_TOKEN (using {source} instead)")
- except Exception:
- pass
-
# Check config version
current_ver, latest_ver = check_config_version()
@@ -856,6 +828,32 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
tz_display = config["timezone"] or "(server-local)"
print(f" ✓ Added timezone to config.yaml: {tz_display}")
+ # ── Version 8 → 9: clear stale ANTHROPIC_TOKEN when better creds exist ──
+ if current_ver < 9:
+ try:
+ old_token = get_env_value("ANTHROPIC_TOKEN")
+ if old_token:
+ has_api_key = bool(get_env_value("ANTHROPIC_API_KEY"))
+ has_claude_code = False
+ try:
+ from agent.anthropic_adapter import (
+ read_claude_code_credentials,
+ is_claude_code_token_valid,
+ )
+ cc_creds = read_claude_code_credentials()
+ has_claude_code = bool(
+ cc_creds and is_claude_code_token_valid(cc_creds)
+ )
+ except Exception:
+ pass
+ if has_api_key or has_claude_code:
+ save_env_value("ANTHROPIC_TOKEN", "")
+ if not quiet:
+ source = "ANTHROPIC_API_KEY" if has_api_key else "Claude Code credentials"
+ print(f" ✓ Cleared stale ANTHROPIC_TOKEN (using {source} instead)")
+ except Exception:
+ pass
+
if current_ver < latest_ver and not quiet:
print(f"Config version: {current_ver} → {latest_ver}")
diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py
index 94e7f6a527..4c5a547e01 100644
--- a/tests/hermes_cli/test_config.py
+++ b/tests/hermes_cli/test_config.py
@@ -319,8 +319,16 @@ class TestSanitizeEnvLines:
class TestStaleAnthropicTokenMigration:
"""Test that migrate_config clears stale ANTHROPIC_TOKEN."""
+ def _write_config_version(self, tmp_path, version):
+ """Write a config.yaml with a specific _config_version."""
+ config_path = tmp_path / "config.yaml"
+ import yaml
+ config = {"_config_version": version}
+ config_path.write_text(yaml.safe_dump(config, sort_keys=False))
+
def test_clears_stale_token_when_api_key_exists(self, tmp_path):
"""ANTHROPIC_TOKEN cleared when ANTHROPIC_API_KEY is also set."""
+ self._write_config_version(tmp_path, 8)
env_file = tmp_path / ".env"
env_file.write_text(
"ANTHROPIC_API_KEY=sk-ant-real-key\n"
@@ -339,6 +347,7 @@ class TestStaleAnthropicTokenMigration:
def test_clears_stale_token_when_claude_code_available(self, tmp_path):
"""ANTHROPIC_TOKEN cleared when Claude Code credentials exist."""
+ self._write_config_version(tmp_path, 8)
env_file = tmp_path / ".env"
env_file.write_text("ANTHROPIC_TOKEN=old-stale-token\n")
@@ -361,6 +370,7 @@ class TestStaleAnthropicTokenMigration:
def test_preserves_token_when_no_alternative(self, tmp_path):
"""ANTHROPIC_TOKEN kept when no API key or Claude Code creds exist."""
+ self._write_config_version(tmp_path, 8)
env_file = tmp_path / ".env"
env_file.write_text("ANTHROPIC_TOKEN=only-auth-method\n")
@@ -377,3 +387,21 @@ class TestStaleAnthropicTokenMigration:
env = load_env()
assert env.get("ANTHROPIC_TOKEN") == "only-auth-method"
+
+ def test_skips_on_version_9_or_later(self, tmp_path):
+ """Migration doesn't fire when already at config version 9+."""
+ self._write_config_version(tmp_path, 9)
+ env_file = tmp_path / ".env"
+ env_file.write_text(
+ "ANTHROPIC_API_KEY=sk-ant-real-key\n"
+ "ANTHROPIC_TOKEN=should-stay\n"
+ )
+ with patch.dict(os.environ, {
+ "HERMES_HOME": str(tmp_path),
+ "ANTHROPIC_API_KEY": "sk-ant-real-key",
+ "ANTHROPIC_TOKEN": "should-stay",
+ }):
+ migrate_config(interactive=False, quiet=True)
+
+ env = load_env()
+ assert env.get("ANTHROPIC_TOKEN") == "should-stay"
From 1c61ab6bd9ecf2b92c77fcc882bffa82a660a60c Mon Sep 17 00:00:00 2001
From: teknium1
Date: Tue, 17 Mar 2026 01:31:20 -0700
Subject: [PATCH 11/19] =?UTF-8?q?fix:=20unconditionally=20clear=20ANTHROPI?=
=?UTF-8?q?C=5FTOKEN=20on=20v8=E2=86=92v9=20migration?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
No conditional checks — just clear it. The new auth flow doesn't use
this env var. Anyone upgrading gets it wiped once, then it's done.
---
hermes_cli/config.py | 24 ++--------
tests/hermes_cli/test_config.py | 84 +++++----------------------------
2 files changed, 17 insertions(+), 91 deletions(-)
diff --git a/hermes_cli/config.py b/hermes_cli/config.py
index 0dde47bf78..c17154bd89 100644
--- a/hermes_cli/config.py
+++ b/hermes_cli/config.py
@@ -828,29 +828,15 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
tz_display = config["timezone"] or "(server-local)"
print(f" ✓ Added timezone to config.yaml: {tz_display}")
- # ── Version 8 → 9: clear stale ANTHROPIC_TOKEN when better creds exist ──
+ # ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ──
+ # The new Anthropic auth flow no longer uses this env var.
if current_ver < 9:
try:
old_token = get_env_value("ANTHROPIC_TOKEN")
if old_token:
- has_api_key = bool(get_env_value("ANTHROPIC_API_KEY"))
- has_claude_code = False
- try:
- from agent.anthropic_adapter import (
- read_claude_code_credentials,
- is_claude_code_token_valid,
- )
- cc_creds = read_claude_code_credentials()
- has_claude_code = bool(
- cc_creds and is_claude_code_token_valid(cc_creds)
- )
- except Exception:
- pass
- if has_api_key or has_claude_code:
- save_env_value("ANTHROPIC_TOKEN", "")
- if not quiet:
- source = "ANTHROPIC_API_KEY" if has_api_key else "Claude Code credentials"
- print(f" ✓ Cleared stale ANTHROPIC_TOKEN (using {source} instead)")
+ save_env_value("ANTHROPIC_TOKEN", "")
+ if not quiet:
+ print(" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)")
except Exception:
pass
diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py
index 4c5a547e01..ba4f5c8445 100644
--- a/tests/hermes_cli/test_config.py
+++ b/tests/hermes_cli/test_config.py
@@ -316,92 +316,32 @@ class TestSanitizeEnvLines:
assert fixes == 0
-class TestStaleAnthropicTokenMigration:
- """Test that migrate_config clears stale ANTHROPIC_TOKEN."""
+class TestAnthropicTokenMigration:
+ """Test that config version 8→9 clears ANTHROPIC_TOKEN."""
def _write_config_version(self, tmp_path, version):
- """Write a config.yaml with a specific _config_version."""
config_path = tmp_path / "config.yaml"
import yaml
- config = {"_config_version": version}
- config_path.write_text(yaml.safe_dump(config, sort_keys=False))
+ config_path.write_text(yaml.safe_dump({"_config_version": version}))
- def test_clears_stale_token_when_api_key_exists(self, tmp_path):
- """ANTHROPIC_TOKEN cleared when ANTHROPIC_API_KEY is also set."""
+ def test_clears_token_on_upgrade_to_v9(self, tmp_path):
+ """ANTHROPIC_TOKEN is cleared unconditionally when upgrading to v9."""
self._write_config_version(tmp_path, 8)
- env_file = tmp_path / ".env"
- env_file.write_text(
- "ANTHROPIC_API_KEY=sk-ant-real-key\n"
- "ANTHROPIC_TOKEN=old-stale-token\n"
- )
+ (tmp_path / ".env").write_text("ANTHROPIC_TOKEN=old-token\n")
with patch.dict(os.environ, {
"HERMES_HOME": str(tmp_path),
- "ANTHROPIC_API_KEY": "sk-ant-real-key",
- "ANTHROPIC_TOKEN": "old-stale-token",
+ "ANTHROPIC_TOKEN": "old-token",
}):
migrate_config(interactive=False, quiet=True)
-
- env = load_env()
- assert env.get("ANTHROPIC_TOKEN") == ""
- assert env.get("ANTHROPIC_API_KEY") == "sk-ant-real-key"
-
- def test_clears_stale_token_when_claude_code_available(self, tmp_path):
- """ANTHROPIC_TOKEN cleared when Claude Code credentials exist."""
- self._write_config_version(tmp_path, 8)
- env_file = tmp_path / ".env"
- env_file.write_text("ANTHROPIC_TOKEN=old-stale-token\n")
-
- fake_creds = {"accessToken": "valid-token", "expiresAt": 0}
- with patch.dict(os.environ, {
- "HERMES_HOME": str(tmp_path),
- "ANTHROPIC_TOKEN": "old-stale-token",
- }):
- with patch(
- "agent.anthropic_adapter.read_claude_code_credentials",
- return_value=fake_creds,
- ), patch(
- "agent.anthropic_adapter.is_claude_code_token_valid",
- return_value=True,
- ):
- migrate_config(interactive=False, quiet=True)
-
- env = load_env()
- assert env.get("ANTHROPIC_TOKEN") == ""
-
- def test_preserves_token_when_no_alternative(self, tmp_path):
- """ANTHROPIC_TOKEN kept when no API key or Claude Code creds exist."""
- self._write_config_version(tmp_path, 8)
- env_file = tmp_path / ".env"
- env_file.write_text("ANTHROPIC_TOKEN=only-auth-method\n")
-
- with patch.dict(os.environ, {
- "HERMES_HOME": str(tmp_path),
- "ANTHROPIC_TOKEN": "only-auth-method",
- }):
- os.environ.pop("ANTHROPIC_API_KEY", None)
- with patch(
- "agent.anthropic_adapter.read_claude_code_credentials",
- return_value=None,
- ):
- migrate_config(interactive=False, quiet=True)
-
- env = load_env()
- assert env.get("ANTHROPIC_TOKEN") == "only-auth-method"
+ assert load_env().get("ANTHROPIC_TOKEN") == ""
def test_skips_on_version_9_or_later(self, tmp_path):
- """Migration doesn't fire when already at config version 9+."""
+ """Already at v9 — ANTHROPIC_TOKEN is not touched."""
self._write_config_version(tmp_path, 9)
- env_file = tmp_path / ".env"
- env_file.write_text(
- "ANTHROPIC_API_KEY=sk-ant-real-key\n"
- "ANTHROPIC_TOKEN=should-stay\n"
- )
+ (tmp_path / ".env").write_text("ANTHROPIC_TOKEN=current-token\n")
with patch.dict(os.environ, {
"HERMES_HOME": str(tmp_path),
- "ANTHROPIC_API_KEY": "sk-ant-real-key",
- "ANTHROPIC_TOKEN": "should-stay",
+ "ANTHROPIC_TOKEN": "current-token",
}):
migrate_config(interactive=False, quiet=True)
-
- env = load_env()
- assert env.get("ANTHROPIC_TOKEN") == "should-stay"
+ assert load_env().get("ANTHROPIC_TOKEN") == "current-token"
From c16870277cd224ebc53fdaf23cc31cb79a10acff Mon Sep 17 00:00:00 2001
From: teknium1
Date: Tue, 17 Mar 2026 01:35:02 -0700
Subject: [PATCH 12/19] test: add regression test for stale PID in
gateway_state.json (#1631)
Verifies that write_runtime_status() overwrites pid and start_time
from a previous process rather than preserving them via setdefault().
Covers the fix from PR #1632.
---
tests/gateway/test_status.py | 20 ++++++++++++++++++++
1 file changed, 20 insertions(+)
diff --git a/tests/gateway/test_status.py b/tests/gateway/test_status.py
index 892c4cbddb..96dfa537c1 100644
--- a/tests/gateway/test_status.py
+++ b/tests/gateway/test_status.py
@@ -44,6 +44,26 @@ class TestGatewayPidState:
class TestGatewayRuntimeStatus:
+ def test_write_runtime_status_overwrites_stale_pid_on_restart(self, tmp_path, monkeypatch):
+ """Regression: setdefault() preserved stale PID from previous process (#1631)."""
+ monkeypatch.setenv("HERMES_HOME", str(tmp_path))
+
+ # Simulate a previous gateway run that left a state file with a stale PID
+ state_path = tmp_path / "gateway_state.json"
+ state_path.write_text(json.dumps({
+ "pid": 99999,
+ "start_time": 1000.0,
+ "kind": "hermes-gateway",
+ "platforms": {},
+ "updated_at": "2025-01-01T00:00:00Z",
+ }))
+
+ status.write_runtime_status(gateway_state="running")
+
+ payload = status.read_runtime_status()
+ assert payload["pid"] == os.getpid(), "PID should be overwritten, not preserved via setdefault"
+ assert payload["start_time"] != 1000.0, "start_time should be overwritten on restart"
+
def test_write_runtime_status_records_platform_failure(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
From 4b96d10bc3560f504c5e94e98ba3c50ce15790db Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Tue, 17 Mar 2026 01:38:11 -0700
Subject: [PATCH 13/19] fix(cli): invalidate update-check cache after hermes
update
Signed-off-by: nidhi-singh02
Co-authored-by: nidhi-singh02
---
hermes_cli/main.py | 15 ++++++++++++++-
1 file changed, 14 insertions(+), 1 deletion(-)
diff --git a/hermes_cli/main.py b/hermes_cli/main.py
index 876bc38c8a..690b652ece 100644
--- a/hermes_cli/main.py
+++ b/hermes_cli/main.py
@@ -2124,7 +2124,17 @@ def _restore_stashed_changes(
print(" Review `git diff` / `git status` if Hermes behaves unexpectedly.")
return True
-
+def _invalidate_update_cache():
+ """Delete the update-check cache so ``hermes --version`` doesn't
+ report a stale "commits behind" count after a successful update."""
+ try:
+ cache_file = Path(os.getenv(
+ "HERMES_HOME", Path.home() / ".hermes"
+ )) / ".update_check"
+ if cache_file.exists():
+ cache_file.unlink()
+ except Exception:
+ pass
def cmd_update(args):
"""Update Hermes Agent to the latest version."""
@@ -2197,6 +2207,7 @@ def cmd_update(args):
commit_count = int(result.stdout.strip())
if commit_count == 0:
+ _invalidate_update_cache()
print("✓ Already up to date!")
return
@@ -2217,6 +2228,8 @@ def cmd_update(args):
prompt_user=prompt_for_restore,
)
+ _invalidate_update_cache()
+
# Reinstall Python dependencies (prefer uv for speed, fall back to pip)
print("→ Updating Python dependencies...")
uv_bin = shutil.which("uv")
From 949fac192f9975f4634dca843b6bafd8bf81899a Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Tue, 17 Mar 2026 01:40:02 -0700
Subject: [PATCH 14/19] fix(tools): remove unnecessary crontab requirement from
cronjob tool (#1638)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(tools): remove unnecessary crontab requirement from cronjob tool
The hermes cron system is internal — it uses a JSON-based scheduler
ticked by the gateway (cron/scheduler.py), not system crontab.
The check for shutil.which('crontab') was preventing the cronjob tool
from being available in environments without crontab installed (e.g.
minimal Ubuntu containers).
Changes:
- Remove shutil.which('crontab') check from check_cronjob_requirements()
- Remove unused shutil import
- Update docstring to clarify internal scheduler is used
- Update tests to reflect new behavior and add coverage for all
session modes (interactive, gateway, exec_ask)
Fixes #1589
* test: add HERMES_EXEC_ASK coverage for cronjob requirements
Adds missing test for the exec_ask session mode, complementing
the cherry-picked fix from PR #1633.
---------
Co-authored-by: Bartok9
---
tests/tools/test_cronjob_tools.py | 34 +++++++++++++++++++++++++------
tools/cronjob_tools.py | 8 ++------
2 files changed, 30 insertions(+), 12 deletions(-)
diff --git a/tests/tools/test_cronjob_tools.py b/tests/tools/test_cronjob_tools.py
index 2a91970832..d54b9066d2 100644
--- a/tests/tools/test_cronjob_tools.py
+++ b/tests/tools/test_cronjob_tools.py
@@ -62,22 +62,44 @@ class TestScanCronPrompt:
class TestCronjobRequirements:
- def test_requires_crontab_binary_even_in_interactive_mode(self, monkeypatch):
+ def test_requires_no_crontab_binary(self, monkeypatch):
+ """Cron is internal (JSON-based scheduler), no system crontab needed."""
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
monkeypatch.delenv("HERMES_EXEC_ASK", raising=False)
- monkeypatch.setattr("shutil.which", lambda name: None)
+ # Even with no crontab in PATH, the cronjob tool should be available
+ # because hermes uses an internal scheduler, not system crontab.
+ assert check_cronjob_requirements() is True
- assert check_cronjob_requirements() is False
-
- def test_accepts_interactive_mode_when_crontab_exists(self, monkeypatch):
+ def test_accepts_interactive_mode(self, monkeypatch):
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
monkeypatch.delenv("HERMES_EXEC_ASK", raising=False)
- monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/crontab")
assert check_cronjob_requirements() is True
+ def test_accepts_gateway_session(self, monkeypatch):
+ monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
+ monkeypatch.setenv("HERMES_GATEWAY_SESSION", "1")
+ monkeypatch.delenv("HERMES_EXEC_ASK", raising=False)
+
+ assert check_cronjob_requirements() is True
+
+ def test_accepts_exec_ask(self, monkeypatch):
+ monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
+ monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
+ monkeypatch.setenv("HERMES_EXEC_ASK", "1")
+
+ assert check_cronjob_requirements() is True
+
+ def test_rejects_when_no_session_env(self, monkeypatch):
+ """Without any session env vars, cronjob tool should not be available."""
+ monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
+ monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
+ monkeypatch.delenv("HERMES_EXEC_ASK", raising=False)
+
+ assert check_cronjob_requirements() is False
+
# =========================================================================
# schedule_cronjob
diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py
index 15971787b8..c16e2ece9c 100644
--- a/tools/cronjob_tools.py
+++ b/tools/cronjob_tools.py
@@ -8,7 +8,6 @@ Compatibility wrappers remain for direct Python callers and legacy tests.
import json
import os
import re
-import shutil
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
@@ -414,13 +413,10 @@ def check_cronjob_requirements() -> bool:
"""
Check if cronjob tools can be used.
- Requires 'crontab' executable to be present in the system PATH.
Available in interactive CLI mode and gateway/messaging platforms.
+ The cron system is internal (JSON file-based scheduler ticked by the gateway),
+ so no external crontab executable is required.
"""
- # Ensure the system can actually install and manage cron entries.
- if not shutil.which("crontab"):
- return False
-
return bool(
os.getenv("HERMES_INTERACTIVE")
or os.getenv("HERMES_GATEWAY_SESSION")
From 365d175100f2178fb4514734b75bcde33270eb7c Mon Sep 17 00:00:00 2001
From: Alex Ferrari
Date: Tue, 17 Mar 2026 06:02:50 +0100
Subject: [PATCH 15/19] fix: apply MarkdownV2 formatting in _send_telegram for
proper rendering
The _send_telegram() function was sending raw markdown text without
parse_mode, causing bold, links, and headers to render as plain text.
This fix reuses the gateway adapter's format_message() to convert
markdown to Telegram's MarkdownV2 format, with a fallback to plain
text if parsing fails.
---
tools/send_message_tool.py | 41 +++++++++++++++++++++++++++++++++-----
1 file changed, 36 insertions(+), 5 deletions(-)
diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py
index f7a87e760b..61f77a563f 100644
--- a/tools/send_message_tool.py
+++ b/tools/send_message_tool.py
@@ -308,9 +308,23 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
async def _send_telegram(token, chat_id, message, media_files=None, thread_id=None):
- """Send via Telegram Bot API (one-shot, no polling needed)."""
+ """Send via Telegram Bot API (one-shot, no polling needed).
+
+ Applies markdown→MarkdownV2 formatting (same as the gateway adapter)
+ so that bold, links, and headers render correctly.
+ """
try:
from telegram import Bot
+ from telegram.constants import ParseMode
+
+ # Reuse the gateway adapter's format_message for markdown→MarkdownV2
+ try:
+ from gateway.platforms.telegram import TelegramAdapter, _escape_mdv2, _strip_mdv2
+ _adapter = TelegramAdapter.__new__(TelegramAdapter)
+ formatted = _adapter.format_message(message)
+ except Exception:
+ # Fallback: send as-is if formatting unavailable
+ formatted = message
bot = Bot(token=token)
int_chat_id = int(chat_id)
@@ -322,10 +336,27 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No
last_msg = None
warnings = []
- if message.strip():
- last_msg = await bot.send_message(
- chat_id=int_chat_id, text=message, **thread_kwargs
- )
+ if formatted.strip():
+ try:
+ last_msg = await bot.send_message(
+ chat_id=int_chat_id, text=formatted,
+ parse_mode=ParseMode.MARKDOWN_V2, **thread_kwargs
+ )
+ except Exception as md_error:
+ # MarkdownV2 failed, fall back to plain text
+ if "parse" in str(md_error).lower() or "markdown" in str(md_error).lower():
+ logger.warning("MarkdownV2 parse failed in _send_telegram, falling back to plain text: %s", md_error)
+ try:
+ from gateway.platforms.telegram import _strip_mdv2
+ plain = _strip_mdv2(formatted)
+ except Exception:
+ plain = message
+ last_msg = await bot.send_message(
+ chat_id=int_chat_id, text=plain,
+ parse_mode=None, **thread_kwargs
+ )
+ else:
+ raise
for media_path, is_voice in media_files:
if not os.path.exists(media_path):
From 19eaf5d9567ccce767c2dcf624ef347b2094eed5 Mon Sep 17 00:00:00 2001
From: teknium1
Date: Tue, 17 Mar 2026 01:44:04 -0700
Subject: [PATCH 16/19] test: fix telegram mock to include ParseMode constant
The MarkdownV2 formatting change imports telegram.constants.ParseMode,
which the test mock didn't provide. Add ParseMode to the mock so
existing tests continue working.
---
tests/tools/test_send_message_tool.py | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py
index d55998942f..b5cb332004 100644
--- a/tests/tools/test_send_message_tool.py
+++ b/tests/tools/test_send_message_tool.py
@@ -25,8 +25,11 @@ def _make_config():
def _install_telegram_mock(monkeypatch, bot):
- telegram_mod = SimpleNamespace(Bot=lambda token: bot)
+ parse_mode = SimpleNamespace(MARKDOWN_V2="MarkdownV2")
+ constants_mod = SimpleNamespace(ParseMode=parse_mode)
+ telegram_mod = SimpleNamespace(Bot=lambda token: bot, constants=constants_mod)
monkeypatch.setitem(sys.modules, "telegram", telegram_mod)
+ monkeypatch.setitem(sys.modules, "telegram.constants", constants_mod)
class TestSendMessageTool:
From 37441183115371ad4a5e20078d24ce4e0ad443b3 Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Tue, 17 Mar 2026 01:47:32 -0700
Subject: [PATCH 17/19] feat(cli): two-stage /model autocomplete with ghost
text suggestions (#1641)
* feat(cli): two-stage /model autocomplete with ghost text suggestions
- SlashCommandCompleter: Tab-complete providers first (anthropic:, openrouter:, etc.)
then models within the selected provider
- SlashCommandAutoSuggest: inline ghost text for slash commands, subcommands,
and /model provider:model two-stage suggestions
- Custom Tab key binding: accepts provider completion and immediately
re-triggers completions to show that provider's models
- COMMANDS_BY_CATEGORY: structured format with explicit subcommands for
tab completion and ghost text (prompt, reasoning, voice, skills, cron, browser)
- SUBCOMMANDS dict auto-extracted from command definitions
- Model/provider info cached 60s for responsive completions
* fix: repair test regression and restore gold color from PR #1622
- Fix test_unknown_command_still_shows_error: patch _cprint instead of
console.print to match the _cprint switch in process_command()
- Restore gold color on 'Type /help' hint using _DIM + _GOLD constants
instead of bare \033[2m (was losing the #B8860B gold)
- Use _GOLD constant for ambiguous command message for consistency
- Add clarifying comment on SUBCOMMANDS regex fallback
---------
Co-authored-by: Lars van der Zande
---
cli.py | 81 ++++++++++--
hermes_cli/commands.py | 207 +++++++++++++++++++++++++++++-
tests/hermes_cli/test_commands.py | 181 ++++++++++++++++++++++++++
tests/test_cli_prefix_matching.py | 10 +-
tests/test_quick_commands.py | 9 +-
5 files changed, 466 insertions(+), 22 deletions(-)
diff --git a/cli.py b/cli.py
index 0cd37ece87..1914769c55 100755
--- a/cli.py
+++ b/cli.py
@@ -468,7 +468,7 @@ from hermes_cli.banner import (
VERSION, RELEASE_DATE, HERMES_AGENT_LOGO, HERMES_CADUCEUS, COMPACT_BANNER,
build_welcome_banner,
)
-from hermes_cli.commands import COMMANDS, SlashCommandCompleter
+from hermes_cli.commands import COMMANDS, SlashCommandCompleter, SlashCommandAutoSuggest
from hermes_cli import callbacks as _callbacks
from toolsets import get_all_toolsets, get_toolset_info, resolve_toolset, validate_toolset
@@ -3618,18 +3618,18 @@ class HermesCLI:
full_name = matches[0]
if full_name == typed_base:
# Already an exact token — no expansion possible; fall through
- self.console.print(f"[bold red]Unknown command: {cmd_lower}[/]")
- self.console.print("[dim #B8860B]Type /help for available commands[/]")
+ _cprint(f"\033[1;31mUnknown command: {cmd_lower}{_RST}")
+ _cprint(f"{_DIM}{_GOLD}Type /help for available commands{_RST}")
else:
remainder = cmd_original.strip()[len(typed_base):]
full_cmd = full_name + remainder
return self.process_command(full_cmd)
elif len(matches) > 1:
- self.console.print(f"[bold yellow]Ambiguous command: {cmd_lower}[/]")
- self.console.print(f"[dim]Did you mean: {', '.join(sorted(matches))}?[/]")
+ _cprint(f"{_GOLD}Ambiguous command: {cmd_lower}{_RST}")
+ _cprint(f"{_DIM}Did you mean: {', '.join(sorted(matches))}?{_RST}")
else:
- self.console.print(f"[bold red]Unknown command: {cmd_lower}[/]")
- self.console.print("[dim #B8860B]Type /help for available commands[/]")
+ _cprint(f"\033[1;31mUnknown command: {cmd_lower}{_RST}")
+ _cprint(f"{_DIM}{_GOLD}Type /help for available commands{_RST}")
return True
@@ -5746,6 +5746,34 @@ class HermesCLI:
"""Ctrl+Enter (c-j) inserts a newline. Most terminals send c-j for Ctrl+Enter."""
event.current_buffer.insert_text('\n')
+ @kb.add('tab', eager=True)
+ def handle_tab(event):
+ """Tab: accept completion and re-trigger if we just completed a provider.
+
+ After accepting a provider like 'anthropic:', the completion menu
+ closes and complete_while_typing doesn't fire (no keystroke).
+ This binding re-triggers completions so stage-2 models appear
+ immediately.
+ """
+ buf = event.current_buffer
+ if buf.complete_state:
+ completion = buf.complete_state.current_completion
+ if completion is None:
+ # Menu open but nothing selected — select first then grab it
+ buf.go_to_completion(0)
+ completion = buf.complete_state and buf.complete_state.current_completion
+ if completion is None:
+ return
+ # Accept the selected completion
+ buf.apply_completion(completion)
+ # If text now looks like "/model provider:", re-trigger completions
+ text = buf.document.text_before_cursor
+ if text.startswith("/model ") and text.endswith(":"):
+ buf.start_completion()
+ else:
+ # No menu open — start completions from scratch
+ buf.start_completion()
+
# --- Clarify tool: arrow-key navigation for multiple-choice questions ---
@kb.add('up', filter=Condition(lambda: bool(self._clarify_state) and not self._clarify_freetext))
@@ -6012,6 +6040,39 @@ class HermesCLI:
return cli_ref._get_tui_prompt_fragments()
# Create the input area with multiline (shift+enter), autocomplete, and paste handling
+ from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
+
+ def _get_model_completer_info() -> dict:
+ """Return provider/model info for /model autocomplete."""
+ try:
+ from hermes_cli.models import (
+ _PROVIDER_LABELS, _PROVIDER_MODELS, normalize_provider,
+ provider_model_ids,
+ )
+ current = getattr(cli_ref, "provider", None) or getattr(cli_ref, "requested_provider", "openrouter")
+ current = normalize_provider(current)
+
+ # Provider map: id -> label (only providers with known models)
+ providers = {}
+ for pid, plabel in _PROVIDER_LABELS.items():
+ providers[pid] = plabel
+
+ def models_for(provider_name: str) -> list[str]:
+ norm = normalize_provider(provider_name)
+ return provider_model_ids(norm)
+
+ return {
+ "current_provider": current,
+ "providers": providers,
+ "models_for": models_for,
+ }
+ except Exception:
+ return {}
+
+ _completer = SlashCommandCompleter(
+ skill_commands_provider=lambda: _skill_commands,
+ model_completer_provider=_get_model_completer_info,
+ )
input_area = TextArea(
height=Dimension(min=1, max=8, preferred=1),
prompt=get_prompt,
@@ -6020,8 +6081,12 @@ class HermesCLI:
wrap_lines=True,
read_only=Condition(lambda: bool(cli_ref._command_running)),
history=FileHistory(str(self._history_file)),
- completer=SlashCommandCompleter(skill_commands_provider=lambda: _skill_commands),
+ completer=_completer,
complete_while_typing=True,
+ auto_suggest=SlashCommandAutoSuggest(
+ history_suggest=AutoSuggestFromHistory(),
+ completer=_completer,
+ ),
)
# Dynamic height: accounts for both explicit newlines AND visual
diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py
index 9663c165a0..f8d50e3561 100644
--- a/hermes_cli/commands.py
+++ b/hermes_cli/commands.py
@@ -11,11 +11,13 @@ To add an alias: set ``aliases=("short",)`` on the existing ``CommandDef``.
from __future__ import annotations
import os
+import re
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
+from prompt_toolkit.auto_suggest import AutoSuggest, Suggestion
from prompt_toolkit.completion import Completer, Completion
@@ -32,6 +34,7 @@ class CommandDef:
category: str # "Session", "Configuration", etc.
aliases: tuple[str, ...] = () # alternative names: ("bg",)
args_hint: str = "" # argument placeholder: "", "[name]"
+ subcommands: tuple[str, ...] = () # tab-completable subcommands
cli_only: bool = False # only available in CLI
gateway_only: bool = False # only available in gateway/messaging
@@ -75,17 +78,18 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("provider", "Show available providers and current provider",
"Configuration"),
CommandDef("prompt", "View/set custom system prompt", "Configuration",
- cli_only=True, args_hint="[text]"),
+ cli_only=True, args_hint="[text]", subcommands=("clear",)),
CommandDef("personality", "Set a predefined personality", "Configuration",
args_hint="[name]"),
CommandDef("verbose", "Cycle tool progress display: off -> new -> all -> verbose",
"Configuration", cli_only=True),
CommandDef("reasoning", "Manage reasoning effort and display", "Configuration",
- args_hint="[level|show|hide]"),
+ args_hint="[level|show|hide]",
+ subcommands=("none", "low", "minimal", "medium", "high", "xhigh", "show", "hide", "on", "off")),
CommandDef("skin", "Show or change the display skin/theme", "Configuration",
cli_only=True, args_hint="[name]"),
CommandDef("voice", "Toggle voice mode", "Configuration",
- args_hint="[on|off|tts|status]"),
+ args_hint="[on|off|tts|status]", subcommands=("on", "off", "tts", "status")),
# Tools & Skills
CommandDef("tools", "List available tools", "Tools & Skills",
@@ -93,9 +97,11 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("toolsets", "List available toolsets", "Tools & Skills",
cli_only=True),
CommandDef("skills", "Search, install, inspect, or manage skills",
- "Tools & Skills", cli_only=True),
+ "Tools & Skills", cli_only=True,
+ subcommands=("search", "browse", "inspect", "install")),
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
- cli_only=True, args_hint="[subcommand]"),
+ cli_only=True, args_hint="[subcommand]",
+ subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")),
CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills",
aliases=("reload_mcp",)),
CommandDef("plugins", "List installed plugins and their status",
@@ -169,6 +175,26 @@ for _cmd in COMMAND_REGISTRY:
_cat[f"/{_alias}"] = COMMANDS[f"/{_alias}"]
+# Subcommands lookup: "/cmd" -> ["sub1", "sub2", ...]
+SUBCOMMANDS: dict[str, list[str]] = {}
+for _cmd in COMMAND_REGISTRY:
+ if _cmd.subcommands:
+ SUBCOMMANDS[f"/{_cmd.name}"] = list(_cmd.subcommands)
+
+# Also extract subcommands hinted in args_hint via pipe-separated patterns
+# e.g. args_hint="[on|off|tts|status]" for commands that don't have explicit subcommands.
+# NOTE: If a command already has explicit subcommands, this fallback is skipped.
+# Use the `subcommands` field on CommandDef for intentional tab-completable args.
+_PIPE_SUBS_RE = re.compile(r"[a-z]+(?:\|[a-z]+)+")
+for _cmd in COMMAND_REGISTRY:
+ key = f"/{_cmd.name}"
+ if key in SUBCOMMANDS or not _cmd.args_hint:
+ continue
+ m = _PIPE_SUBS_RE.search(_cmd.args_hint)
+ if m:
+ SUBCOMMANDS[key] = m.group(0).split("|")
+
+
# ---------------------------------------------------------------------------
# Gateway helpers
# ---------------------------------------------------------------------------
@@ -237,13 +263,34 @@ def slack_subcommand_map() -> dict[str, str]:
# ---------------------------------------------------------------------------
class SlashCommandCompleter(Completer):
- """Autocomplete for built-in slash commands and optional skill commands."""
+ """Autocomplete for built-in slash commands, subcommands, and skill commands."""
def __init__(
self,
skill_commands_provider: Callable[[], Mapping[str, dict[str, Any]]] | None = None,
+ model_completer_provider: Callable[[], dict[str, Any]] | None = None,
) -> None:
self._skill_commands_provider = skill_commands_provider
+ # model_completer_provider returns {"current_provider": str,
+ # "providers": {id: label, ...}, "models_for": callable(provider) -> list[str]}
+ self._model_completer_provider = model_completer_provider
+ self._model_info_cache: dict[str, Any] | None = None
+ self._model_info_cache_time: float = 0
+
+ def _get_model_info(self) -> dict[str, Any]:
+ """Get cached model/provider info for /model autocomplete."""
+ import time
+ now = time.monotonic()
+ if self._model_info_cache is not None and now - self._model_info_cache_time < 60:
+ return self._model_info_cache
+ if self._model_completer_provider is None:
+ return {}
+ try:
+ self._model_info_cache = self._model_completer_provider() or {}
+ self._model_info_cache_time = now
+ except Exception:
+ self._model_info_cache = self._model_info_cache or {}
+ return self._model_info_cache
def _iter_skill_commands(self) -> Mapping[str, dict[str, Any]]:
if self._skill_commands_provider is None:
@@ -348,6 +395,70 @@ class SlashCommandCompleter(Completer):
yield from self._path_completions(path_word)
return
+ # Check if we're completing a subcommand (base command already typed)
+ parts = text.split(maxsplit=1)
+ base_cmd = parts[0].lower()
+ if len(parts) > 1 or (len(parts) == 1 and text.endswith(" ")):
+ sub_text = parts[1] if len(parts) > 1 else ""
+ sub_lower = sub_text.lower()
+
+ # /model gets two-stage completion:
+ # Stage 1: provider names (with : suffix)
+ # Stage 2: after "provider:", list that provider's models
+ if base_cmd == "/model" and " " not in sub_text:
+ info = self._get_model_info()
+ if info:
+ current_prov = info.get("current_provider", "")
+ providers = info.get("providers", {})
+ models_for = info.get("models_for")
+
+ if ":" in sub_text:
+ # Stage 2: "anthropic:cl" → models for anthropic
+ prov_part, model_part = sub_text.split(":", 1)
+ model_lower = model_part.lower()
+ if models_for:
+ try:
+ prov_models = models_for(prov_part)
+ except Exception:
+ prov_models = []
+ for mid in prov_models:
+ if mid.lower().startswith(model_lower) and mid.lower() != model_lower:
+ full = f"{prov_part}:{mid}"
+ yield Completion(
+ full,
+ start_position=-len(sub_text),
+ display=mid,
+ )
+ else:
+ # Stage 1: providers sorted: non-current first, current last
+ for pid, plabel in sorted(
+ providers.items(),
+ key=lambda kv: (kv[0] == current_prov, kv[0]),
+ ):
+ display_name = f"{pid}:"
+ if display_name.lower().startswith(sub_lower):
+ meta = f"({plabel})" if plabel != pid else ""
+ if pid == current_prov:
+ meta = f"(current — {plabel})" if plabel != pid else "(current)"
+ yield Completion(
+ display_name,
+ start_position=-len(sub_text),
+ display=display_name,
+ display_meta=meta,
+ )
+ return
+
+ # Static subcommand completions
+ if " " not in sub_text and base_cmd in SUBCOMMANDS:
+ for sub in SUBCOMMANDS[base_cmd]:
+ if sub.startswith(sub_lower) and sub != sub_lower:
+ yield Completion(
+ sub,
+ start_position=-len(sub_text),
+ display=sub,
+ )
+ return
+
word = text[1:]
for cmd, desc in COMMANDS.items():
@@ -373,6 +484,90 @@ class SlashCommandCompleter(Completer):
)
+# ---------------------------------------------------------------------------
+# Inline auto-suggest (ghost text) for slash commands
+# ---------------------------------------------------------------------------
+
+class SlashCommandAutoSuggest(AutoSuggest):
+ """Inline ghost-text suggestions for slash commands and their subcommands.
+
+ Shows the rest of a command or subcommand in dim text as you type.
+ Falls back to history-based suggestions for non-slash input.
+ """
+
+ def __init__(
+ self,
+ history_suggest: AutoSuggest | None = None,
+ completer: SlashCommandCompleter | None = None,
+ ) -> None:
+ self._history = history_suggest
+ self._completer = completer # Reuse its model cache
+
+ def get_suggestion(self, buffer, document):
+ text = document.text_before_cursor
+
+ # Only suggest for slash commands
+ if not text.startswith("/"):
+ # Fall back to history for regular text
+ if self._history:
+ return self._history.get_suggestion(buffer, document)
+ return None
+
+ parts = text.split(maxsplit=1)
+ base_cmd = parts[0].lower()
+
+ if len(parts) == 1 and not text.endswith(" "):
+ # Still typing the command name: /upd → suggest "ate"
+ word = text[1:].lower()
+ for cmd in COMMANDS:
+ cmd_name = cmd[1:] # strip leading /
+ if cmd_name.startswith(word) and cmd_name != word:
+ return Suggestion(cmd_name[len(word):])
+ return None
+
+ # Command is complete — suggest subcommands or model names
+ sub_text = parts[1] if len(parts) > 1 else ""
+ sub_lower = sub_text.lower()
+
+ # /model gets two-stage ghost text
+ if base_cmd == "/model" and " " not in sub_text and self._completer:
+ info = self._completer._get_model_info()
+ if info:
+ providers = info.get("providers", {})
+ models_for = info.get("models_for")
+ current_prov = info.get("current_provider", "")
+
+ if ":" in sub_text:
+ # Stage 2: after provider:, suggest model
+ prov_part, model_part = sub_text.split(":", 1)
+ model_lower = model_part.lower()
+ if models_for:
+ try:
+ for mid in models_for(prov_part):
+ if mid.lower().startswith(model_lower) and mid.lower() != model_lower:
+ return Suggestion(mid[len(model_part):])
+ except Exception:
+ pass
+ else:
+ # Stage 1: suggest provider name with :
+ for pid in sorted(providers, key=lambda p: (p == current_prov, p)):
+ candidate = f"{pid}:"
+ if candidate.lower().startswith(sub_lower) and candidate.lower() != sub_lower:
+ return Suggestion(candidate[len(sub_text):])
+
+ # Static subcommands
+ if base_cmd in SUBCOMMANDS and SUBCOMMANDS[base_cmd]:
+ if " " not in sub_text:
+ for sub in SUBCOMMANDS[base_cmd]:
+ if sub.startswith(sub_lower) and sub != sub_lower:
+ return Suggestion(sub[len(sub_text):])
+
+ # Fall back to history
+ if self._history:
+ return self._history.get_suggestion(buffer, document)
+ return None
+
+
def _file_size_label(path: str) -> str:
"""Return a compact human-readable file size, or '' on error."""
try:
diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py
index 3c4fb82018..22678c96be 100644
--- a/tests/hermes_cli/test_commands.py
+++ b/tests/hermes_cli/test_commands.py
@@ -9,6 +9,8 @@ from hermes_cli.commands import (
COMMANDS_BY_CATEGORY,
CommandDef,
GATEWAY_KNOWN_COMMANDS,
+ SUBCOMMANDS,
+ SlashCommandAutoSuggest,
SlashCommandCompleter,
gateway_help_lines,
resolve_command,
@@ -323,3 +325,182 @@ class TestSlashCommandCompleter:
completions = _completions(completer, "/no-desc")
assert len(completions) == 1
assert "Skill command" in completions[0].display_meta_text
+
+
+# ── SUBCOMMANDS extraction ──────────────────────────────────────────────
+
+
+class TestSubcommands:
+ def test_explicit_subcommands_extracted(self):
+ """Commands with explicit subcommands on CommandDef are extracted."""
+ assert "/prompt" in SUBCOMMANDS
+ assert "clear" in SUBCOMMANDS["/prompt"]
+
+ def test_reasoning_has_subcommands(self):
+ assert "/reasoning" in SUBCOMMANDS
+ subs = SUBCOMMANDS["/reasoning"]
+ assert "high" in subs
+ assert "show" in subs
+ assert "hide" in subs
+
+ def test_voice_has_subcommands(self):
+ assert "/voice" in SUBCOMMANDS
+ assert "on" in SUBCOMMANDS["/voice"]
+ assert "off" in SUBCOMMANDS["/voice"]
+
+ def test_cron_has_subcommands(self):
+ assert "/cron" in SUBCOMMANDS
+ assert "list" in SUBCOMMANDS["/cron"]
+ assert "add" in SUBCOMMANDS["/cron"]
+
+ def test_commands_without_subcommands_not_in_dict(self):
+ """Plain commands should not appear in SUBCOMMANDS."""
+ assert "/help" not in SUBCOMMANDS
+ assert "/quit" not in SUBCOMMANDS
+ assert "/clear" not in SUBCOMMANDS
+
+
+# ── Subcommand tab completion ───────────────────────────────────────────
+
+
+class TestSubcommandCompletion:
+ def test_subcommand_completion_after_space(self):
+ """Typing '/reasoning ' then Tab should show subcommands."""
+ completions = _completions(SlashCommandCompleter(), "/reasoning ")
+ texts = {c.text for c in completions}
+ assert "high" in texts
+ assert "show" in texts
+
+ def test_subcommand_prefix_filters(self):
+ """Typing '/reasoning sh' should only show 'show'."""
+ completions = _completions(SlashCommandCompleter(), "/reasoning sh")
+ texts = {c.text for c in completions}
+ assert texts == {"show"}
+
+ def test_subcommand_exact_match_suppressed(self):
+ """Typing the full subcommand shouldn't re-suggest it."""
+ completions = _completions(SlashCommandCompleter(), "/reasoning show")
+ texts = {c.text for c in completions}
+ assert "show" not in texts
+
+ def test_no_subcommands_for_plain_command(self):
+ """Commands without subcommands yield nothing after space."""
+ completions = _completions(SlashCommandCompleter(), "/help ")
+ assert completions == []
+
+
+# ── Two-stage /model completion ─────────────────────────────────────────
+
+
+def _model_completer() -> SlashCommandCompleter:
+ """Build a completer with mock model/provider info."""
+ return SlashCommandCompleter(
+ model_completer_provider=lambda: {
+ "current_provider": "openrouter",
+ "providers": {
+ "anthropic": "Anthropic",
+ "openrouter": "OpenRouter",
+ "nous": "Nous Research",
+ },
+ "models_for": lambda p: {
+ "anthropic": ["claude-sonnet-4-20250514", "claude-opus-4-20250414"],
+ "openrouter": ["anthropic/claude-sonnet-4", "google/gemini-2.5-pro"],
+ "nous": ["hermes-3-llama-3.1-405b"],
+ }.get(p, []),
+ }
+ )
+
+
+class TestModelCompletion:
+ def test_stage1_shows_providers(self):
+ completions = _completions(_model_completer(), "/model ")
+ texts = {c.text for c in completions}
+ assert "anthropic:" in texts
+ assert "openrouter:" in texts
+ assert "nous:" in texts
+
+ def test_stage1_current_provider_last(self):
+ completions = _completions(_model_completer(), "/model ")
+ texts = [c.text for c in completions]
+ assert texts[-1] == "openrouter:"
+
+ def test_stage1_current_provider_labeled(self):
+ completions = _completions(_model_completer(), "/model ")
+ for c in completions:
+ if c.text == "openrouter:":
+ assert "current" in c.display_meta_text.lower()
+ break
+ else:
+ raise AssertionError("openrouter: not found in completions")
+
+ def test_stage1_prefix_filters(self):
+ completions = _completions(_model_completer(), "/model an")
+ texts = {c.text for c in completions}
+ assert texts == {"anthropic:"}
+
+ def test_stage2_shows_models(self):
+ completions = _completions(_model_completer(), "/model anthropic:")
+ texts = {c.text for c in completions}
+ assert "anthropic:claude-sonnet-4-20250514" in texts
+ assert "anthropic:claude-opus-4-20250414" in texts
+
+ def test_stage2_prefix_filters_models(self):
+ completions = _completions(_model_completer(), "/model anthropic:claude-s")
+ texts = {c.text for c in completions}
+ assert "anthropic:claude-sonnet-4-20250514" in texts
+ assert "anthropic:claude-opus-4-20250414" not in texts
+
+ def test_stage2_no_model_provider_returns_empty(self):
+ completions = _completions(SlashCommandCompleter(), "/model ")
+ assert completions == []
+
+
+# ── Ghost text (SlashCommandAutoSuggest) ────────────────────────────────
+
+
+def _suggestion(text: str, completer=None) -> str | None:
+ """Get ghost text suggestion for given input."""
+ suggest = SlashCommandAutoSuggest(completer=completer)
+ doc = Document(text=text)
+
+ class FakeBuffer:
+ pass
+
+ result = suggest.get_suggestion(FakeBuffer(), doc)
+ return result.text if result else None
+
+
+class TestGhostText:
+ def test_command_name_suggestion(self):
+ """/he → 'lp'"""
+ assert _suggestion("/he") == "lp"
+
+ def test_command_name_suggestion_reasoning(self):
+ """/rea → 'soning'"""
+ assert _suggestion("/rea") == "soning"
+
+ def test_no_suggestion_for_complete_command(self):
+ assert _suggestion("/help") is None
+
+ def test_subcommand_suggestion(self):
+ """/reasoning h → 'igh'"""
+ assert _suggestion("/reasoning h") == "igh"
+
+ def test_subcommand_suggestion_show(self):
+ """/reasoning sh → 'ow'"""
+ assert _suggestion("/reasoning sh") == "ow"
+
+ def test_no_suggestion_for_non_slash(self):
+ assert _suggestion("hello") is None
+
+ def test_model_stage1_ghost_text(self):
+ """/model a → 'nthropic:'"""
+ completer = _model_completer()
+ assert _suggestion("/model a", completer=completer) == "nthropic:"
+
+ def test_model_stage2_ghost_text(self):
+ """/model anthropic:cl → rest of first matching model"""
+ completer = _model_completer()
+ s = _suggestion("/model anthropic:cl", completer=completer)
+ assert s is not None
+ assert s.startswith("aude-")
diff --git a/tests/test_cli_prefix_matching.py b/tests/test_cli_prefix_matching.py
index d5174555ef..eafa324f37 100644
--- a/tests/test_cli_prefix_matching.py
+++ b/tests/test_cli_prefix_matching.py
@@ -72,15 +72,17 @@ class TestSlashCommandPrefixMatching:
def test_ambiguous_prefix_shows_suggestions(self):
"""/re matches multiple commands — should show ambiguous message."""
cli_obj = _make_cli()
- cli_obj.process_command("/re")
- printed = " ".join(str(c) for c in cli_obj.console.print.call_args_list)
+ with patch("cli._cprint") as mock_cprint:
+ cli_obj.process_command("/re")
+ printed = " ".join(str(c) for c in mock_cprint.call_args_list)
assert "Ambiguous" in printed or "Did you mean" in printed
def test_unknown_command_shows_error(self):
"""/xyz should show unknown command error."""
cli_obj = _make_cli()
- cli_obj.process_command("/xyz")
- printed = " ".join(str(c) for c in cli_obj.console.print.call_args_list)
+ with patch("cli._cprint") as mock_cprint:
+ cli_obj.process_command("/xyz")
+ printed = " ".join(str(c) for c in mock_cprint.call_args_list)
assert "Unknown command" in printed
def test_exact_command_still_works(self):
diff --git a/tests/test_quick_commands.py b/tests/test_quick_commands.py
index e53f7a3e48..9708b1fb31 100644
--- a/tests/test_quick_commands.py
+++ b/tests/test_quick_commands.py
@@ -72,10 +72,11 @@ class TestCLIQuickCommands:
def test_unknown_command_still_shows_error(self):
cli = self._make_cli({})
- cli.process_command("/nonexistent")
- cli.console.print.assert_called()
- args = cli.console.print.call_args_list[0][0][0]
- assert "unknown command" in args.lower()
+ with patch("cli._cprint") as mock_cprint:
+ cli.process_command("/nonexistent")
+ mock_cprint.assert_called()
+ printed = " ".join(str(c) for c in mock_cprint.call_args_list)
+ assert "unknown command" in printed.lower()
def test_timeout_shows_error(self):
cli = self._make_cli({"slow": {"type": "exec", "command": "sleep 100"}})
From 4920c5940fe09c97b0ab15ff2076583399d94a67 Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Tue, 17 Mar 2026 01:47:34 -0700
Subject: [PATCH 18/19] feat: auto-detect local file paths in gateway responses
for native media delivery (#1640)
Small models (7B-14B) can't reliably use MEDIA: or IMAGE: syntax. This
adds extract_local_files() to BasePlatformAdapter that regex-detects
bare local file paths ending in image/video extensions, validates them
with os.path.isfile(), and delivers them as native platform attachments.
Hardened over the original PR:
- Code-block exclusion: paths inside fenced blocks and inline code are
skipped so code samples are never mutilated
- URL rejection: negative lookbehind prevents matching path segments
inside HTTP URLs
- Relative path rejection: ./foo.png no longer matches
- Tilde path cleanup: raw ~/... form is removed from response text
- Deduplication by expanded path
- Added .webm to _VIDEO_EXTS
- Fallback to send_document for unrecognized media extensions
Based on PR #1636 by sudoingX.
Co-authored-by: sudoingX
---
gateway/platforms/base.py | 107 +++++++-
tests/gateway/test_extract_local_files.py | 317 ++++++++++++++++++++++
2 files changed, 421 insertions(+), 3 deletions(-)
create mode 100644 tests/gateway/test_extract_local_files.py
diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py
index 1ec60f7623..2e34242e42 100644
--- a/gateway/platforms/base.py
+++ b/gateway/platforms/base.py
@@ -727,7 +727,75 @@ class BasePlatformAdapter(ABC):
cleaned = re.sub(r'\n{3,}', '\n\n', cleaned).strip()
return media, cleaned
-
+
+ @staticmethod
+ def extract_local_files(content: str) -> Tuple[List[str], str]:
+ """
+ Detect bare local file paths in response text for native media 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.
+
+ Paths inside fenced code blocks (``` ... ```) and inline code
+ (`...`) are ignored so that code samples are never mutilated.
+
+ Returns:
+ Tuple of (list of expanded file paths, cleaned text with the
+ raw path strings removed).
+ """
+ _LOCAL_MEDIA_EXTS = (
+ '.png', '.jpg', '.jpeg', '.gif', '.webp',
+ '.mp4', '.mov', '.avi', '.mkv', '.webm',
+ )
+ ext_part = '|'.join(e.lstrip('.') for e in _LOCAL_MEDIA_EXTS)
+
+ # (? bool:
+ return any(s <= pos < e for s, e in code_spans)
+
+ found: list = [] # (raw_match_text, expanded_path)
+ for match in path_re.finditer(content):
+ if _in_code(match.start()):
+ continue
+ raw = match.group(0)
+ expanded = os.path.expanduser(raw)
+ if os.path.isfile(expanded):
+ found.append((raw, expanded))
+
+ # Deduplicate by expanded path, preserving discovery order
+ seen: set = set()
+ unique: list = []
+ for raw, expanded in found:
+ if expanded not in seen:
+ seen.add(expanded)
+ unique.append((raw, expanded))
+
+ paths = [expanded for _, expanded in unique]
+
+ cleaned = content
+ if unique:
+ for raw, _exp in unique:
+ cleaned = cleaned.replace(raw, '')
+ cleaned = re.sub(r'\n{3,}', '\n\n', cleaned).strip()
+
+ return paths, cleaned
+
async def _keep_typing(self, chat_id: str, interval: float = 2.0, metadata=None) -> None:
"""
Continuously send typing indicator until cancelled.
@@ -842,6 +910,12 @@ class BasePlatformAdapter(ABC):
images, text_content = self.extract_images(response)
if images:
logger.info("[%s] extract_images found %d image(s) in response (%d chars)", self.name, len(images), len(response))
+
+ # Auto-detect bare local file paths for native media delivery
+ # (helps small models that don't use MEDIA: syntax)
+ local_files, text_content = self.extract_local_files(text_content)
+ if local_files:
+ logger.info("[%s] extract_local_files found %d file(s) in response", self.name, len(local_files))
# Auto-TTS: if voice message, generate audio FIRST (before sending text)
# Skipped when the chat has voice mode disabled (/voice off)
@@ -935,7 +1009,7 @@ class BasePlatformAdapter(ABC):
# Send extracted media files — route by file type
_AUDIO_EXTS = {'.ogg', '.opus', '.mp3', '.wav', '.m4a'}
- _VIDEO_EXTS = {'.mp4', '.mov', '.avi', '.mkv', '.3gp'}
+ _VIDEO_EXTS = {'.mp4', '.mov', '.avi', '.mkv', '.webm', '.3gp'}
_IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.webp', '.gif'}
for media_path, is_voice in media_files:
@@ -972,7 +1046,34 @@ class BasePlatformAdapter(ABC):
print(f"[{self.name}] Failed to send media ({ext}): {media_result.error}")
except Exception as media_err:
print(f"[{self.name}] Error sending media: {media_err}")
-
+
+ # Send auto-detected local files as native attachments
+ for file_path in local_files:
+ if human_delay > 0:
+ await asyncio.sleep(human_delay)
+ try:
+ ext = Path(file_path).suffix.lower()
+ if ext in _IMAGE_EXTS:
+ await self.send_image_file(
+ chat_id=event.source.chat_id,
+ image_path=file_path,
+ metadata=_thread_metadata,
+ )
+ elif ext in _VIDEO_EXTS:
+ await self.send_video(
+ chat_id=event.source.chat_id,
+ video_path=file_path,
+ metadata=_thread_metadata,
+ )
+ else:
+ await self.send_document(
+ chat_id=event.source.chat_id,
+ file_path=file_path,
+ metadata=_thread_metadata,
+ )
+ except Exception as file_err:
+ logger.error("[%s] Error sending local file %s: %s", self.name, file_path, file_err)
+
# Check if there's a pending message that was queued during our processing
if session_key in self._pending_messages:
pending_event = self._pending_messages.pop(session_key)
diff --git a/tests/gateway/test_extract_local_files.py b/tests/gateway/test_extract_local_files.py
new file mode 100644
index 0000000000..dd93e6370f
--- /dev/null
+++ b/tests/gateway/test_extract_local_files.py
@@ -0,0 +1,317 @@
+"""
+Tests for extract_local_files() — auto-detection of bare local file paths
+in model response text for native media delivery.
+
+Covers: path matching, code-block exclusion, URL rejection, tilde expansion,
+deduplication, text cleanup, and extension routing.
+
+Based on PR #1636 by sudoingX (salvaged + hardened).
+"""
+
+import os
+from unittest.mock import patch
+
+import pytest
+
+from gateway.platforms.base import BasePlatformAdapter
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _extract(content: str, existing_files: set[str] | None = None):
+ """
+ Run extract_local_files with os.path.isfile mocked to return True
+ for any path in *existing_files* (expanded form). If *existing_files*
+ is None every path passes.
+ """
+ existing = existing_files
+
+ def fake_isfile(p):
+ if existing is None:
+ return True
+ return p in existing
+
+ def fake_expanduser(p):
+ if p.startswith("~/"):
+ return "/home/user" + p[1:]
+ return p
+
+ with patch("os.path.isfile", side_effect=fake_isfile), \
+ patch("os.path.expanduser", side_effect=fake_expanduser):
+ return BasePlatformAdapter.extract_local_files(content)
+
+
+# ---------------------------------------------------------------------------
+# Basic detection
+# ---------------------------------------------------------------------------
+
+class TestBasicDetection:
+
+ def test_absolute_path_image(self):
+ paths, cleaned = _extract("Here is the screenshot /root/screenshots/game.png enjoy")
+ assert paths == ["/root/screenshots/game.png"]
+ assert "/root/screenshots/game.png" not in cleaned
+ assert "Here is the screenshot" in cleaned
+
+ def test_tilde_path_image(self):
+ paths, cleaned = _extract("Check out ~/photos/cat.jpg for the cat")
+ assert paths == ["/home/user/photos/cat.jpg"]
+ assert "~/photos/cat.jpg" not in cleaned
+
+ def test_video_extensions(self):
+ for ext in (".mp4", ".mov", ".avi", ".mkv", ".webm"):
+ text = f"Video at /tmp/clip{ext} here"
+ paths, _ = _extract(text)
+ assert len(paths) == 1, f"Failed for {ext}"
+ assert paths[0] == f"/tmp/clip{ext}"
+
+ def test_image_extensions(self):
+ for ext in (".png", ".jpg", ".jpeg", ".gif", ".webp"):
+ text = f"Image at /tmp/pic{ext} here"
+ paths, _ = _extract(text)
+ assert len(paths) == 1, f"Failed for {ext}"
+ assert paths[0] == f"/tmp/pic{ext}"
+
+ def test_case_insensitive_extension(self):
+ paths, _ = _extract("See /tmp/PHOTO.PNG and /tmp/vid.MP4 now")
+ assert len(paths) == 2
+
+ def test_multiple_paths(self):
+ text = "First /tmp/a.png then /tmp/b.jpg and /tmp/c.mp4 done"
+ paths, cleaned = _extract(text)
+ assert len(paths) == 3
+ assert "/tmp/a.png" in paths
+ assert "/tmp/b.jpg" in paths
+ assert "/tmp/c.mp4" in paths
+ for p in paths:
+ assert p not in cleaned
+
+ def test_path_at_line_start(self):
+ paths, _ = _extract("/var/data/image.png")
+ assert paths == ["/var/data/image.png"]
+
+ def test_path_at_end_of_line(self):
+ paths, _ = _extract("saved to /var/data/image.png")
+ assert paths == ["/var/data/image.png"]
+
+ def test_path_with_dots_in_directory(self):
+ paths, _ = _extract("See /opt/my.app/assets/logo.png here")
+ assert paths == ["/opt/my.app/assets/logo.png"]
+
+ def test_path_with_hyphens(self):
+ paths, _ = _extract("File at /tmp/my-screenshot-2024.png done")
+ assert paths == ["/tmp/my-screenshot-2024.png"]
+
+
+# ---------------------------------------------------------------------------
+# Non-existent files are skipped
+# ---------------------------------------------------------------------------
+
+class TestIsfileGuard:
+
+ def test_nonexistent_path_skipped(self):
+ """Paths that don't exist on disk are not extracted."""
+ paths, cleaned = _extract(
+ "See /tmp/nope.png here",
+ existing_files=set(), # nothing exists
+ )
+ assert paths == []
+ assert "/tmp/nope.png" in cleaned # not stripped
+
+ def test_only_existing_paths_extracted(self):
+ """Mix of existing and non-existing — only existing are returned."""
+ paths, cleaned = _extract(
+ "A /tmp/real.png and /tmp/fake.jpg end",
+ existing_files={"/tmp/real.png"},
+ )
+ assert paths == ["/tmp/real.png"]
+ assert "/tmp/real.png" not in cleaned
+ assert "/tmp/fake.jpg" in cleaned
+
+
+# ---------------------------------------------------------------------------
+# URL false-positive prevention
+# ---------------------------------------------------------------------------
+
+class TestURLRejection:
+
+ def test_https_url_not_matched(self):
+ """Paths embedded in HTTP URLs must not be extracted."""
+ paths, cleaned = _extract("Visit https://example.com/images/photo.png for details")
+ # The regex lookbehind should prevent matching the URL's path segment
+ # Even if it did match, isfile would be False for /images/photo.png
+ # (we mock isfile to True-for-all here, so the lookbehind is the guard)
+ assert paths == []
+ assert "https://example.com/images/photo.png" in cleaned
+
+ def test_http_url_not_matched(self):
+ paths, _ = _extract("See http://cdn.example.com/assets/banner.jpg here")
+ assert paths == []
+
+ def test_file_url_not_matched(self):
+ paths, _ = _extract("Open file:///home/user/doc.png in browser")
+ # file:// has :// before /home so lookbehind blocks it
+ assert paths == []
+
+
+# ---------------------------------------------------------------------------
+# Code block exclusion
+# ---------------------------------------------------------------------------
+
+class TestCodeBlockExclusion:
+
+ def test_fenced_code_block_skipped(self):
+ text = "Here's how:\n```python\nimg = open('/tmp/image.png')\n```\nDone."
+ paths, cleaned = _extract(text)
+ assert paths == []
+ assert "/tmp/image.png" in cleaned # not stripped
+
+ def test_inline_code_skipped(self):
+ text = "Use the path `/tmp/image.png` in your config"
+ paths, cleaned = _extract(text)
+ assert paths == []
+ assert "`/tmp/image.png`" in cleaned
+
+ def test_path_outside_code_block_still_matched(self):
+ text = (
+ "```\ncode: /tmp/inside.png\n```\n"
+ "But this one is real: /tmp/outside.png"
+ )
+ paths, _ = _extract(text, existing_files={"/tmp/outside.png"})
+ assert paths == ["/tmp/outside.png"]
+
+ def test_mixed_inline_code_and_bare_path(self):
+ text = "Config uses `/etc/app/bg.png` but output is /tmp/result.jpg"
+ paths, cleaned = _extract(text, existing_files={"/tmp/result.jpg"})
+ assert paths == ["/tmp/result.jpg"]
+ assert "`/etc/app/bg.png`" in cleaned
+ assert "/tmp/result.jpg" not in cleaned
+
+ def test_multiline_fenced_block(self):
+ text = (
+ "```bash\n"
+ "cp /source/a.png /dest/b.png\n"
+ "mv /source/c.mp4 /dest/d.mp4\n"
+ "```\n"
+ "Files are ready."
+ )
+ paths, _ = _extract(text)
+ assert paths == []
+
+
+# ---------------------------------------------------------------------------
+# Deduplication
+# ---------------------------------------------------------------------------
+
+class TestDeduplication:
+
+ def test_duplicate_paths_deduplicated(self):
+ text = "See /tmp/img.png and also /tmp/img.png again"
+ paths, _ = _extract(text)
+ assert paths == ["/tmp/img.png"]
+
+ def test_tilde_and_expanded_same_file(self):
+ """~/photos/a.png and /home/user/photos/a.png are the same file."""
+ text = "See ~/photos/a.png and /home/user/photos/a.png here"
+ paths, _ = _extract(text, existing_files={"/home/user/photos/a.png"})
+ assert len(paths) == 1
+ assert paths[0] == "/home/user/photos/a.png"
+
+
+# ---------------------------------------------------------------------------
+# Text cleanup
+# ---------------------------------------------------------------------------
+
+class TestTextCleanup:
+
+ def test_path_removed_from_text(self):
+ paths, cleaned = _extract("Before /tmp/x.png after")
+ assert "Before" in cleaned
+ assert "after" in cleaned
+ assert "/tmp/x.png" not in cleaned
+
+ def test_excessive_blank_lines_collapsed(self):
+ text = "Before\n\n\n/tmp/x.png\n\n\nAfter"
+ _, cleaned = _extract(text)
+ assert "\n\n\n" not in cleaned
+
+ def test_no_paths_text_unchanged(self):
+ text = "This is a normal response with no file paths."
+ paths, cleaned = _extract(text)
+ assert paths == []
+ assert cleaned == text
+
+ def test_tilde_form_cleaned_from_text(self):
+ """The raw ~/... form should be removed, not the expanded /home/user/... form."""
+ text = "Output saved to ~/result.png for review"
+ paths, cleaned = _extract(text)
+ assert paths == ["/home/user/result.png"]
+ assert "~/result.png" not in cleaned
+
+ def test_only_path_in_text(self):
+ """If the response is just a path, cleaned text is empty."""
+ paths, cleaned = _extract("/tmp/screenshot.png")
+ assert paths == ["/tmp/screenshot.png"]
+ assert cleaned == ""
+
+
+# ---------------------------------------------------------------------------
+# Edge cases
+# ---------------------------------------------------------------------------
+
+class TestEdgeCases:
+
+ def test_empty_string(self):
+ paths, cleaned = _extract("")
+ assert paths == []
+ 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")
+ assert paths == []
+
+ def test_path_with_spaces_not_matched(self):
+ """Paths with spaces are intentionally not matched (avoids false positives)."""
+ paths, _ = _extract("File at /tmp/my file.png here")
+ assert paths == []
+
+ def test_windows_path_not_matched(self):
+ """Windows-style paths should not match."""
+ paths, _ = _extract("See C:\\Users\\test\\image.png")
+ assert paths == []
+
+ def test_relative_path_not_matched(self):
+ """Relative paths like ./image.png should not match."""
+ paths, _ = _extract("File at ./screenshots/image.png here")
+ assert paths == []
+
+ def test_bare_filename_not_matched(self):
+ """Just 'image.png' without a path should not match."""
+ paths, _ = _extract("Open image.png to see")
+ assert paths == []
+
+ def test_path_followed_by_punctuation(self):
+ """Path followed by comma, period, paren should still match."""
+ for suffix in [",", ".", ")", ":", ";"]:
+ text = f"See /tmp/img.png{suffix} details"
+ paths, _ = _extract(text)
+ assert len(paths) == 1, f"Failed with suffix '{suffix}'"
+
+ def test_path_in_parentheses(self):
+ paths, _ = _extract("(see /tmp/img.png)")
+ assert paths == ["/tmp/img.png"]
+
+ def test_path_in_quotes(self):
+ paths, _ = _extract('The file is "/tmp/img.png" right here')
+ assert paths == ["/tmp/img.png"]
+
+ def test_deep_nested_path(self):
+ paths, _ = _extract("At /a/b/c/d/e/f/g/h/image.png end")
+ assert paths == ["/a/b/c/d/e/f/g/h/image.png"]
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])
From 8e20a7e035191279810af736e2169b5bbf04428f Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Tue, 17 Mar 2026 01:47:35 -0700
Subject: [PATCH 19/19] fix(gateway): strip MEDIA: and [[audio_as_voice]] tags
from message body
* fix(gateway): strip MEDIA: and [[audio_as_voice]] tags from message body
Closes #1561
* fix: remove redundant re import, use existing import
---------
Co-authored-by: mettin4
---
gateway/platforms/base.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py
index 2e34242e42..a262d900e6 100644
--- a/gateway/platforms/base.py
+++ b/gateway/platforms/base.py
@@ -908,6 +908,9 @@ class BasePlatformAdapter(ABC):
# Extract image URLs and send them as native platform attachments
images, text_content = self.extract_images(response)
+ # Strip any remaining internal directives from message body (fixes #1561)
+ text_content = text_content.replace("[[audio_as_voice]]", "").strip()
+ text_content = re.sub(r"MEDIA:\s*\S+", "", text_content).strip()
if images:
logger.info("[%s] extract_images found %d image(s) in response (%d chars)", self.name, len(images), len(response))