Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui

# Conflicts:
#	cli.py
#	hermes_cli/main.py
#	run_agent.py
#	tests/hermes_cli/test_cmd_update.py
#	tools/mcp_tool.py
#	web/src/lib/gatewayClient.ts
This commit is contained in:
Brooklyn Nicholson
2026-05-18 01:26:56 -05:00
260 changed files with 24547 additions and 13573 deletions
+1 -1
View File
@@ -269,7 +269,7 @@ def _scan_for_plugin_adapter_antipattern(source: str) -> list[str]:
and isinstance(func.value.value, ast.Name)
and func.value.value.id == "sys"
and func.value.attr == "path"
and func.attr in ("insert", "append", "extend")
and func.attr in {"insert", "append", "extend"}
):
target_name = f"sys.path.{func.attr}"
@@ -16,8 +16,8 @@ def _would_warn():
"MATRIX_ALLOWED_USERS", "DINGTALK_ALLOWED_USERS", "FEISHU_ALLOWED_USERS", "WECOM_ALLOWED_USERS",
"GATEWAY_ALLOWED_USERS")
)
_allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes") or any(
os.getenv(v, "").lower() in ("true", "1", "yes")
_allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"} or any(
os.getenv(v, "").lower() in {"true", "1", "yes"}
for v in ("TELEGRAM_ALLOW_ALL_USERS", "DISCORD_ALLOW_ALL_USERS",
"WHATSAPP_ALLOW_ALL_USERS", "SLACK_ALLOW_ALL_USERS",
"SIGNAL_ALLOW_ALL_USERS", "EMAIL_ALLOW_ALL_USERS",
+92
View File
@@ -445,7 +445,12 @@ class TestHealthEndpoint:
async with TestClient(TestServer(app)) as cli:
resp = await cli.get("/health")
assert resp.status == 200
assert resp.headers.get("Content-Security-Policy") == "default-src 'none'; frame-ancestors 'none'"
assert resp.headers.get("Permissions-Policy") == "camera=(), microphone=(), geolocation=()"
assert resp.headers.get("Strict-Transport-Security") == "max-age=31536000; includeSubDomains"
assert resp.headers.get("X-Content-Type-Options") == "nosniff"
assert resp.headers.get("X-Frame-Options") == "DENY"
assert resp.headers.get("X-XSS-Protection") == "0"
assert resp.headers.get("Referrer-Policy") == "no-referrer"
@pytest.mark.asyncio
@@ -704,6 +709,37 @@ class TestChatCompletionsEndpoint:
assert "[DONE]" in body
assert "Hello!" in body
@pytest.mark.asyncio
async def test_stream_string_false_returns_json_completion(self, adapter):
"""Quoted false must not route chat completions into SSE mode."""
mock_result = {
"final_response": "Hello! How can I help you today?",
"messages": [],
"api_calls": 1,
}
app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
mock_run.return_value = (
mock_result,
{"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
)
resp = await cli.post(
"/v1/chat/completions",
json={
"model": "hermes-agent",
"messages": [{"role": "user", "content": "Hello"}],
"stream": "false",
},
)
assert resp.status == 200
assert "text/event-stream" not in resp.headers.get("Content-Type", "")
data = await resp.json()
assert data["object"] == "chat.completion"
assert data["choices"][0]["message"]["content"] == mock_result["final_response"]
@pytest.mark.asyncio
async def test_stream_task_done_callback_enqueues_eos_for_chat_completions(self, adapter):
"""Regression guard for #24451: completion callback must signal SSE EOS."""
@@ -1655,6 +1691,31 @@ class TestResponsesEndpoint:
# The response has an ID but it shouldn't be retrievable
assert adapter._response_store.get(data["id"]) is None
@pytest.mark.asyncio
async def test_store_string_false_does_not_store(self, adapter):
"""Quoted false must preserve ephemeral store=false semantics."""
mock_result = {"final_response": "OK", "messages": [], "api_calls": 1}
app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
mock_run.return_value = (
mock_result,
{"input_tokens": 0, "output_tokens": 0, "total_tokens": 0},
)
resp = await cli.post(
"/v1/responses",
json={
"model": "hermes-agent",
"input": "Hello",
"store": "false",
},
)
assert resp.status == 200
data = await resp.json()
assert adapter._response_store.get(data["id"]) is None
@pytest.mark.asyncio
async def test_instructions_inherited_from_previous(self, adapter):
"""If no instructions provided, carry forward from previous response."""
@@ -1749,6 +1810,37 @@ class TestResponsesStreaming:
assert "Hello" in body
assert " world" in body
@pytest.mark.asyncio
async def test_stream_string_false_returns_json_response(self, adapter):
"""Quoted false must not route Responses API requests into SSE mode."""
mock_result = {
"final_response": "Paris is the capital of France.",
"messages": [],
"api_calls": 1,
}
app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
mock_run.return_value = (
mock_result,
{"input_tokens": 0, "output_tokens": 0, "total_tokens": 0},
)
resp = await cli.post(
"/v1/responses",
json={
"model": "hermes-agent",
"input": "What is the capital of France?",
"stream": "false",
},
)
assert resp.status == 200
assert "text/event-stream" not in resp.headers.get("Content-Type", "")
data = await resp.json()
assert data["object"] == "response"
assert data["output"][0]["content"][0]["text"] == mock_result["final_response"]
@pytest.mark.asyncio
async def test_stream_task_done_callback_enqueues_eos_for_responses(self, adapter):
"""Regression guard for #24451 on /v1/responses streaming path."""
+22
View File
@@ -335,6 +335,28 @@ class TestRunEvents:
"approval_not_pending",
}
@pytest.mark.asyncio
async def test_approval_string_false_does_not_resolve_all(self, adapter):
"""Quoted false must not fan out approval resolution across the queue."""
app = _create_runs_app(adapter)
run_id = "run_bool_parse"
adapter._run_statuses[run_id] = {"run_id": run_id, "status": "running"}
adapter._run_approval_sessions[run_id] = "session-123"
async with TestClient(TestServer(app)) as cli:
with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve:
approval_resp = await cli.post(
f"/v1/runs/{run_id}/approval",
json={"choice": "once", "all": "false"},
)
assert approval_resp.status == 200
mock_resolve.assert_called_once_with(
"session-123",
"once",
resolve_all=False,
)
@pytest.mark.asyncio
async def test_events_not_found_returns_404(self, adapter):
app = _create_runs_app(adapter)
+1
View File
@@ -316,6 +316,7 @@ class TestRunBackgroundTask:
assert mock_adapter.send.call_args.kwargs["metadata"] == {
"thread_id": "20197",
"telegram_dm_topic_reply_fallback": True,
"direct_messages_topic_id": "20197",
"telegram_reply_to_message_id": "463",
}
+5
View File
@@ -101,6 +101,11 @@ class TestBlueBubblesHelpers:
adapter = _make_adapter(monkeypatch)
assert adapter.format_message("**Hello** `world`") == "Hello world"
def test_format_message_preserves_underscores_in_identifiers(self, monkeypatch):
adapter = _make_adapter(monkeypatch)
text = "Use /api_v2 with FEATURE_FLAG_NAME and config_file.json"
assert adapter.format_message(text) == text
def test_strip_markdown_headers(self, monkeypatch):
adapter = _make_adapter(monkeypatch)
assert adapter.format_message("## Heading\ntext") == "Heading\ntext"
+2 -2
View File
@@ -44,7 +44,7 @@ def _simulate_config_bridge(cfg: dict, initial_env: dict | None = None):
val = terminal_cfg[cfg_key]
# Skip cwd placeholder values — don't overwrite already-resolved
# TERMINAL_CWD. Mirrors the fix in gateway/run.py.
if cfg_key == "cwd" and str(val) in (".", "auto", "cwd"):
if cfg_key == "cwd" and str(val) in {".", "auto", "cwd"}:
continue
# Expand shell tilde so subprocess.Popen never receives a literal
# "~/" which the kernel rejects.
@@ -70,7 +70,7 @@ def _simulate_config_bridge(cfg: dict, initial_env: dict | None = None):
# --- Replicate lines 144-147: MESSAGING_CWD fallback ---
configured_cwd = env.get("TERMINAL_CWD", "")
if not configured_cwd or configured_cwd in (".", "auto", "cwd"):
if not configured_cwd or configured_cwd in {".", "auto", "cwd"}:
messaging_cwd = env.get("MESSAGING_CWD") or "/root" # Path.home() for root
env["TERMINAL_CWD"] = messaging_cwd
@@ -48,7 +48,7 @@ class TestDiscordSystemMessageFilter(unittest.TestCase):
return False
# System message filter (the fix being tested)
if message.type not in (discord.MessageType.default, discord.MessageType.reply):
if message.type not in {discord.MessageType.default, discord.MessageType.reply}:
return False
return True # message accepted
+1 -1
View File
@@ -2740,7 +2740,7 @@ class _FakeAiohttpSession:
def _install_fake_aiohttp(monkeypatch, session):
fake_aiohttp = types.SimpleNamespace(
ClientSession=lambda timeout=None: session,
ClientSession=lambda timeout=None, **kwargs: session,
ClientTimeout=lambda total=None: None,
)
monkeypatch.setitem(sys.modules, "aiohttp", fake_aiohttp)
+204
View File
@@ -2257,6 +2257,210 @@ class TestMatrixOnRoomMessageFilter:
ev = self._mk_event(sender="@alice:example.org", body="hello bot")
await self.adapter._on_room_message(ev)
self.adapter._handle_text_message.assert_awaited_once()
class TestMatrixClockSkewWarning:
"""Clock-skew detector for #12614.
Reporter's host clock was set ~2 hours ahead of real time. The grace
filter `event_ts < startup_ts - 5` then drops every live event because
server timestamps look "older than startup". When this happens well
after startup (>30s), the adapter logs a one-shot WARNING pointing the
user at NTP instead of failing silently.
"""
def setup_method(self):
self.adapter = _make_adapter()
self.adapter._user_id = "@bot:example.org"
self.adapter._handle_text_message = AsyncMock()
self.adapter._handle_media_message = AsyncMock()
@staticmethod
def _mk_event(sender, ts_ms, event_id=None):
ev = MagicMock()
ev.room_id = "!room:example.org"
ev.sender = sender
ev.event_id = event_id or f"$evt-{sender}-{ts_ms}"
ev.timestamp = ts_ms
ev.server_timestamp = ts_ms
ev.content = {"msgtype": "m.text", "body": "hi"}
return ev
@pytest.mark.asyncio
async def test_late_drops_emit_one_shot_clock_skew_warning(self, caplog):
import logging
import time as _t
# Simulate the reporter's environment: host clock is ~2 hours ahead
# of server time. Startup happened "in the future" relative to the
# real-world events we're now receiving.
now = _t.time()
self.adapter._startup_ts = now - 60 # bot started 60s ago (wall clock)
# Server events are dated 2h before startup_ts (skewed clock).
skewed_event_ts_ms = int((self.adapter._startup_ts - 7200) * 1000)
with caplog.at_level(logging.WARNING, logger="gateway.platforms.matrix"):
for i in range(5):
ev = self._mk_event(
sender=f"@alice{i}:example.org", ts_ms=skewed_event_ts_ms
)
await self.adapter._on_room_message(ev)
# Handler should never be invoked — all events failed the grace check.
self.adapter._handle_text_message.assert_not_called()
# Exactly one WARNING from THIS logger should be emitted. Filter by
# logger name so unrelated stdlib/library warnings can't satisfy the
# assertion.
skew_warnings = [
r for r in caplog.records
if r.name == "gateway.platforms.matrix"
and r.levelname == "WARNING"
and "set-ntp" in r.getMessage()
]
assert len(skew_warnings) == 1, (
f"expected exactly 1 clock-skew warning, got {len(skew_warnings)}"
)
msg = skew_warnings[0].getMessage()
assert "7200" in msg, f"skew value missing from message: {msg!r}"
# Pin the counter so a regression in the gating logic (e.g. warning
# at threshold 1 or 5, or not stopping after warn) is caught.
assert self.adapter._late_grace_drops == 3
assert self.adapter._clock_skew_warned is True
@pytest.mark.asyncio
async def test_initial_sync_drops_do_not_warn(self, caplog):
"""During the first 30s after startup, old events are normal backfill."""
import logging
import time as _t
now = _t.time()
# Startup was 1s ago — we're still in the initial-sync window.
self.adapter._startup_ts = now - 1
old_ts_ms = int((self.adapter._startup_ts - 3600) * 1000)
with caplog.at_level(logging.WARNING, logger="gateway.platforms.matrix"):
for i in range(5):
ev = self._mk_event(
sender=f"@alice{i}:example.org", ts_ms=old_ts_ms
)
await self.adapter._on_room_message(ev)
# Backfill drops are silent — no clock-skew warning fired.
assert self.adapter._clock_skew_warned is False
skew_warnings = [
r for r in caplog.records
if r.name == "gateway.platforms.matrix"
and "set-ntp" in r.getMessage()
]
assert skew_warnings == []
@pytest.mark.asyncio
async def test_fewer_than_three_late_drops_do_not_warn(self, caplog):
"""A single delayed backfill event after 30s shouldn't trigger NTP advice."""
import logging
import time as _t
now = _t.time()
self.adapter._startup_ts = now - 120 # extra slack vs the 30s gate
old_ts_ms = int((self.adapter._startup_ts - 3600) * 1000)
with caplog.at_level(logging.WARNING, logger="gateway.platforms.matrix"):
for i in range(2): # only 2 late drops — under the threshold
ev = self._mk_event(
sender=f"@alice{i}:example.org", ts_ms=old_ts_ms
)
await self.adapter._on_room_message(ev)
assert self.adapter._late_grace_drops == 2
assert self.adapter._clock_skew_warned is False
@pytest.mark.asyncio
async def test_varied_backfill_skews_do_not_warn(self, caplog):
"""Backfill from a freshly-invited room delivers events of varied age.
A genuine clock-skew bug produces drops with a *constant* offset
(every event is ~X seconds older than wall clock). Joining an old
room post-startup delivers events spanning hours-to-days; those
skews vary wildly and must NOT trigger the NTP warning.
"""
import logging
import time as _t
now = _t.time()
self.adapter._startup_ts = now - 120
# Each event has a different age, ranging from 1h to 30d ago.
ages_in_hours = [1, 24, 168, 720, 4] # 1h, 1d, 1w, 30d, 4h
with caplog.at_level(logging.WARNING, logger="gateway.platforms.matrix"):
for i, hrs in enumerate(ages_in_hours):
ts_ms = int((self.adapter._startup_ts - hrs * 3600) * 1000)
ev = self._mk_event(
sender=f"@alice{i}:example.org", ts_ms=ts_ms
)
await self.adapter._on_room_message(ev)
# The varied-skew guard should keep the counter from reaching 3.
assert self.adapter._late_grace_drops < 3
assert self.adapter._clock_skew_warned is False
skew_warnings = [
r for r in caplog.records
if r.name == "gateway.platforms.matrix"
and "set-ntp" in r.getMessage()
]
assert skew_warnings == []
@pytest.mark.asyncio
async def test_state_reset_allows_warning_to_fire_again(self, caplog):
"""After the reset block at top of connect() runs, the warning is rearmed.
Reconnect lifecycle: the user fixes NTP, restarts the bot, and the
new connect() call resets _late_grace_drops / _clock_skew_warned at
the top. This test exercises the rearm path by:
1. Tripping the warning once (state: warned=True).
2. Running the same reset block connect() runs.
3. Tripping the warning a second time the second warning should
fire because the state was cleared.
"""
import logging
import time as _t
now = _t.time()
self.adapter._startup_ts = now - 60
skewed_ms = int((self.adapter._startup_ts - 7200) * 1000)
with caplog.at_level(logging.WARNING, logger="gateway.platforms.matrix"):
for i in range(3):
ev = self._mk_event(
sender=f"@alice{i}:example.org", ts_ms=skewed_ms,
event_id=f"$first-{i}",
)
await self.adapter._on_room_message(ev)
assert self.adapter._clock_skew_warned is True
# Mirror the reset block in connect() (matrix.py around line 855).
self.adapter._startup_ts = _t.time() - 60
self.adapter._late_grace_drops = 0
self.adapter._late_grace_skew = 0.0
self.adapter._clock_skew_warned = False
# Same skewed-clock scenario should warn AGAIN after reset.
skewed_ms2 = int((self.adapter._startup_ts - 7200) * 1000)
for i in range(3):
ev = self._mk_event(
sender=f"@bob{i}:example.org", ts_ms=skewed_ms2,
event_id=f"$second-{i}",
)
await self.adapter._on_room_message(ev)
skew_warnings = [
r for r in caplog.records
if r.name == "gateway.platforms.matrix"
and "set-ntp" in r.getMessage()
]
assert len(skew_warnings) == 2, (
f"expected 2 warnings (one per connect cycle), got {len(skew_warnings)}"
)
# ---------------------------------------------------------------------------
# DM auto-thread
# ---------------------------------------------------------------------------
@@ -76,12 +76,12 @@ def test_checker_returns_true_when_configured(platform, checker, monkeypatch):
elif platform == Platform.SMS:
monkeypatch.setenv("TWILIO_ACCOUNT_SID", "ACtest")
mock_config.extra = {}
elif platform in (
elif platform in {
Platform.API_SERVER,
Platform.WEBHOOK,
Platform.MSGRAPH_WEBHOOK,
Platform.WHATSAPP,
):
}:
mock_config.extra = {}
elif platform == Platform.FEISHU:
mock_config.extra = {"app_id": "app"}
+1 -1
View File
@@ -1076,7 +1076,7 @@ class TestBuildApprovalKeyboard:
parsed = parse_approval_button_data(btn.action.data)
assert parsed is not None
assert parsed[0] == session_key
assert parsed[1] in ("allow-once", "allow-always", "deny")
assert parsed[1] in {"allow-once", "allow-always", "deny"}
class TestBuildUpdatePromptKeyboard:
+10 -1
View File
@@ -33,7 +33,16 @@ async def test_restart_command_while_busy_requests_drain_without_interrupt(monke
result = await runner._handle_message(event)
assert result == t("gateway.draining", count=1)
expected = t("gateway.draining", count=1)
assert result == expected
# Guard against the silent-degradation regression in #22266: if the i18n
# catalog cannot be resolved (e.g. xdist workers losing the locales path)
# then ``t("gateway.draining", count=1)`` returns the bare key
# ``"gateway.draining"`` instead of the formatted English string, and both
# sides of the equality above would still match. Assert on the catalog
# output explicitly so a broken locale resolution fails loudly here.
assert expected != "gateway.draining"
assert "Draining" in expected and "1" in expected
running_agent.interrupt.assert_not_called()
runner.request_restart.assert_called_once_with(detached=True, via_service=False)
+1 -1
View File
@@ -89,7 +89,7 @@ def _build_agent_history(history: list) -> list:
agent_history: list = []
for msg in history:
role = msg.get("role")
if not role or role in ("session_meta", "system"):
if not role or role in {"session_meta", "system"}:
continue
has_tool_calls = "tool_calls" in msg
has_tool_call_id = "tool_call_id" in msg
+1 -1
View File
@@ -108,7 +108,7 @@ async def test_finalize_before_reset(mock_invoke_hook):
await runner._handle_reset_command(_make_event("/new"))
calls = [c for c in mock_invoke_hook.call_args_list
if c[0][0] in ("on_session_finalize", "on_session_reset")]
if c[0][0] in {"on_session_finalize", "on_session_reset"}]
hook_names = [c[0][0] for c in calls]
assert hook_names == ["on_session_finalize", "on_session_reset"]
@@ -187,7 +187,7 @@ fallback_providers:
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
def fake_resolve_runtime_provider(*, requested=None, explicit_base_url=None, explicit_api_key=None):
if requested in (None, "", "openai-codex"):
if requested in {None, "", "openai-codex"}:
from hermes_cli.auth import AuthError
raise AuthError("No Codex credentials stored. Run `hermes auth` to authenticate.")
assert requested == "openrouter"
+1 -1
View File
@@ -763,7 +763,7 @@ def _install_fake_aiohttp(monkeypatch, session):
"""Replace ``aiohttp`` in ``sys.modules`` so ``import aiohttp as _aiohttp``
inside ``_standalone_send`` picks up our fake."""
fake_aiohttp = types.SimpleNamespace(
ClientSession=lambda timeout=None: session,
ClientSession=lambda timeout=None, **kwargs: session,
ClientTimeout=lambda total=None: None,
)
monkeypatch.setitem(sys.modules, "aiohttp", fake_aiohttp)
@@ -407,6 +407,7 @@ async def test_gateway_runner_busy_ack_replies_to_triggering_message_for_telegra
assert adapter.calls[0]["metadata"] == {
"thread_id": "20197",
"telegram_dm_topic_reply_fallback": True,
"direct_messages_topic_id": "20197",
"telegram_reply_to_message_id": "463",
}
+1 -1
View File
@@ -31,7 +31,7 @@ def _filter_history(history: list) -> list:
role = msg.get("role")
if not role:
continue
if role in ("session_meta",):
if role in {"session_meta",}:
continue
if role == "system":
continue
+2
View File
@@ -237,6 +237,8 @@ class TestUpdateCommandGatewayFlag:
cmd_string = call_args[-1] if isinstance(call_args, list) else str(call_args)
assert "--gateway" in cmd_string
assert "PYTHONUNBUFFERED" in cmd_string
assert "rc=$?" in cmd_string
assert "status=$?" not in cmd_string
assert "stream progress" in result
+1
View File
@@ -461,6 +461,7 @@ class TestSendVoiceReply:
assert call_kwargs["metadata"] == {
"thread_id": "20197",
"telegram_dm_topic_reply_fallback": True,
"direct_messages_topic_id": "20197",
"telegram_reply_to_message_id": "462",
}