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

This commit is contained in:
Brooklyn Nicholson
2026-05-21 13:53:53 -05:00
105 changed files with 5022 additions and 1714 deletions
+4 -42
View File
@@ -170,33 +170,7 @@ class TestFlushDeduplication:
# ---------------------------------------------------------------------------
class TestAppendToTranscriptSkipDb:
"""Verify skip_db=True writes JSONL but not SQLite."""
@pytest.fixture()
def store(self, tmp_path):
from gateway.config import GatewayConfig
from gateway.session import SessionStore
config = GatewayConfig()
with patch("gateway.session.SessionStore._ensure_loaded"):
s = SessionStore(sessions_dir=tmp_path, config=config)
s._db = None # no SQLite for these JSONL-focused tests
s._loaded = True
return s
def test_skip_db_writes_jsonl_only(self, store, tmp_path):
"""With skip_db=True, message appears in JSONL but not SQLite."""
session_id = "test-skip-db"
msg = {"role": "assistant", "content": "hello world"}
store.append_to_transcript(session_id, msg, skip_db=True)
# JSONL should have the message
jsonl_path = store.get_transcript_path(session_id)
assert jsonl_path.exists()
with open(jsonl_path) as f:
lines = f.readlines()
assert len(lines) == 1
parsed = json.loads(lines[0])
assert parsed["content"] == "hello world"
"""Verify skip_db=True skips the SQLite write."""
def test_skip_db_prevents_sqlite_write(self, tmp_path):
"""With skip_db=True and a real DB, message does NOT appear in SQLite."""
@@ -223,14 +197,8 @@ class TestAppendToTranscriptSkipDb:
rows = db.get_messages(session_id)
assert len(rows) == 0, f"Expected 0 DB rows with skip_db=True, got {len(rows)}"
# But JSONL should have it
jsonl_path = store.get_transcript_path(session_id)
with open(jsonl_path) as f:
lines = f.readlines()
assert len(lines) == 1
def test_default_writes_both(self, tmp_path):
"""Without skip_db, message appears in both JSONL and SQLite."""
def test_default_writes_to_sqlite(self, tmp_path):
"""Without skip_db, message appears in SQLite."""
from gateway.config import GatewayConfig
from gateway.session import SessionStore
from hermes_state import SessionDB
@@ -250,13 +218,7 @@ class TestAppendToTranscriptSkipDb:
msg = {"role": "user", "content": "test message"}
store.append_to_transcript(session_id, msg)
# JSONL should have the message
jsonl_path = store.get_transcript_path(session_id)
with open(jsonl_path) as f:
lines = f.readlines()
assert len(lines) == 1
# SQLite should also have the message
# SQLite should have the message
rows = db.get_messages(session_id)
assert len(rows) == 1
@@ -38,6 +38,9 @@ def _make_agent_stub(agent_cls):
agent._MEMORY_REVIEW_PROMPT = "review memory"
agent._SKILL_REVIEW_PROMPT = "review skills"
agent._COMBINED_REVIEW_PROMPT = "review both"
# Non-None so the test catches a missing-kwarg regression.
agent.enabled_toolsets = ["memory", "skills", "terminal"]
agent.disabled_toolsets = ["spotify", "feishu_doc"]
return agent
@@ -183,3 +186,54 @@ def test_review_fork_pins_session_start_and_session_id():
"Review fork did not inherit parent's session_id — "
"system-prompt rebuild paths would diverge."
)
def test_review_fork_inherits_parent_toolset_config():
"""``tools[]`` byte-stability: fork must inherit parent's toolset config."""
import run_agent
agent = _make_agent_stub(run_agent.AIAgent)
captured = {}
class _Recorder:
def __init__(self, *args, **kwargs):
captured["enabled_toolsets"] = kwargs.get("enabled_toolsets")
captured["disabled_toolsets"] = kwargs.get("disabled_toolsets")
self._cached_system_prompt = None
self._memory_write_origin = None
self._memory_write_context = None
self._memory_store = None
self._memory_enabled = None
self._user_profile_enabled = None
self._memory_nudge_interval = None
self._skill_nudge_interval = None
self.suppress_status_output = None
self.session_start = None
self.session_id = None
def run_conversation(self, *args, **kwargs):
raise RuntimeError("stop after recording — don't actually call the API")
def shutdown_memory_provider(self):
pass
def close(self):
pass
with patch.object(run_agent, "AIAgent", _Recorder), \
patch("threading.Thread", _SyncThread):
agent._spawn_background_review(
messages_snapshot=[],
review_memory=True,
review_skills=False,
)
assert captured.get("enabled_toolsets") == agent.enabled_toolsets, (
f"enabled_toolsets mismatch: {captured.get('enabled_toolsets')!r} "
f"vs expected {agent.enabled_toolsets!r}"
)
assert captured.get("disabled_toolsets") == agent.disabled_toolsets, (
f"disabled_toolsets mismatch: {captured.get('disabled_toolsets')!r} "
f"vs expected {agent.disabled_toolsets!r}"
)
@@ -38,6 +38,9 @@ def _make_agent_stub(agent_cls):
agent._MEMORY_REVIEW_PROMPT = "review memory"
agent._SKILL_REVIEW_PROMPT = "review skills"
agent._COMBINED_REVIEW_PROMPT = "review both"
# Non-None so the test catches a missing-kwarg regression.
agent.enabled_toolsets = ["memory", "skills", "terminal"]
agent.disabled_toolsets = ["spotify", "feishu_doc"]
return agent
@@ -52,13 +55,8 @@ class _SyncThread:
self._target()
def test_background_review_does_not_narrow_toolset_schema():
"""The review fork must NOT pass enabled_toolsets to AIAgent.
Narrowing the schema diverges the ``tools`` cache key from the parent's,
which sits above ``system`` in Anthropic's cache hierarchy and forces a
full prefix-cache miss on every review (see #25322, PR #17276).
"""
def test_background_review_matches_parent_toolset_config():
"""Fork must receive parent's toolset config so ``tools[]`` cache key matches."""
import run_agent
agent = _make_agent_stub(run_agent.AIAgent)
@@ -66,6 +64,7 @@ def test_background_review_does_not_narrow_toolset_schema():
def _capture_init(self, *args, **kwargs):
captured["enabled_toolsets"] = kwargs.get("enabled_toolsets", "UNSET")
captured["disabled_toolsets"] = kwargs.get("disabled_toolsets", "UNSET")
raise RuntimeError("stop after capturing init args")
with patch.object(run_agent.AIAgent, "__init__", _capture_init), \
@@ -77,11 +76,13 @@ def test_background_review_does_not_narrow_toolset_schema():
)
assert "enabled_toolsets" in captured, "AIAgent.__init__ was not called"
# The kwarg must be absent — letting AIAgent inherit the default full
# toolset so the schema bytes match the parent's.
assert captured["enabled_toolsets"] == "UNSET", (
f"Review fork narrowed the toolset schema (got {captured['enabled_toolsets']!r}), "
"which breaks prefix-cache parity with the parent."
assert captured["enabled_toolsets"] == agent.enabled_toolsets, (
f"enabled_toolsets mismatch: {captured['enabled_toolsets']!r} "
f"vs expected {agent.enabled_toolsets!r}"
)
assert captured["disabled_toolsets"] == agent.disabled_toolsets, (
f"disabled_toolsets mismatch: {captured['disabled_toolsets']!r} "
f"vs expected {agent.disabled_toolsets!r}"
)
@@ -19,11 +19,15 @@ Three distinct failure modes the user community hit during rollout:
one-line hint pointing the user at https://grok.com and ``/model``.
3. Multi-turn replay of ``codex_reasoning_items`` (with
``encrypted_content``) is now suppressed for ``is_xai_responses=True``
in ``_chat_messages_to_responses_input``. xAI's OAuth/SuperGrok
surface rejects replayed encrypted reasoning items; Grok still
reasons natively each turn, so coherence rides on visible message
text.
``encrypted_content``) was briefly suppressed for ``is_xai_responses``
in PR #26644 on the theory that xAI's OAuth/SuperGrok surface
rejected replayed encrypted reasoning items. That suppression was
reverted shortly after: xAI confirmed they explicitly want Hermes to
thread encrypted reasoning back across turns, and the original
multi-turn failure mode was actually the prelude-SSE issue closed by
Fix A above. The remaining tests here lock in that xAI receives
replayed reasoning AND that we ask xAI to echo it back in the
``include`` array.
"""
from types import SimpleNamespace
@@ -353,8 +357,15 @@ def test_codex_reasoning_replay_default_includes_encrypted_content():
assert reasoning[0]["encrypted_content"] == "enc_blob"
def test_codex_reasoning_replay_stripped_for_xai_oauth():
"""xAI OAuth surface must NOT receive replayed encrypted reasoning."""
def test_codex_reasoning_replay_includes_encrypted_content_for_xai():
"""xAI must receive replayed encrypted reasoning items (May 2026 reversal).
Earlier we stripped these on the theory that the OAuth/SuperGrok
surface rejected them. xAI subsequently confirmed they explicitly
want Hermes to thread encrypted reasoning back across turns for
cross-turn coherence — that's the whole point of the partnership
integration.
"""
from agent.codex_responses_adapter import _chat_messages_to_responses_input
msgs = [
@@ -365,10 +376,13 @@ def test_codex_reasoning_replay_stripped_for_xai_oauth():
items = _chat_messages_to_responses_input(msgs, is_xai_responses=True)
reasoning = [it for it in items if it.get("type") == "reasoning"]
assert reasoning == []
assert len(reasoning) == 1, (
"xAI must receive replayed reasoning items — see docstring for the "
"May 2026 reversal of the earlier suppression gate."
)
assert reasoning[0]["encrypted_content"] == "enc_blob"
# The assistant's visible text must still survive — coherence across
# turns rides on the message text alone.
# And the assistant's visible text must still be present alongside it.
assistant_items = [
it for it in items
if it.get("role") == "assistant" or it.get("type") == "message"
@@ -376,8 +390,12 @@ def test_codex_reasoning_replay_stripped_for_xai_oauth():
assert assistant_items, "assistant message must still be present"
def test_codex_transport_xai_request_omits_encrypted_content_include():
"""Verify the xAI ``include`` array no longer requests encrypted reasoning."""
def test_codex_transport_xai_request_includes_encrypted_content():
"""xAI ``include`` array must request ``reasoning.encrypted_content``.
This is the request-side half of the May 2026 reversal: we ask xAI
to echo back encrypted reasoning so the next turn can replay it.
"""
from agent.transports.codex import ResponsesApiTransport
transport = ResponsesApiTransport()
@@ -392,14 +410,11 @@ def test_codex_transport_xai_request_omits_encrypted_content_include():
reasoning_config={"enabled": True, "effort": "medium"},
is_xai_responses=True,
)
# Without this gate, xAI would echo back encrypted_content blobs we'd
# then store in codex_reasoning_items and replay next turn — which is
# exactly the multi-turn failure mode we're closing.
assert kwargs["include"] == []
assert kwargs["include"] == ["reasoning.encrypted_content"]
def test_codex_transport_xai_strips_replayed_reasoning_in_input():
"""End-to-end: build_kwargs on xai-oauth must strip prior reasoning."""
def test_codex_transport_xai_replays_reasoning_in_input():
"""End-to-end: build_kwargs on xAI must replay prior encrypted reasoning."""
from agent.transports.codex import ResponsesApiTransport
transport = ResponsesApiTransport()
@@ -418,7 +433,8 @@ def test_codex_transport_xai_strips_replayed_reasoning_in_input():
)
input_items = kwargs["input"]
reasoning_items = [it for it in input_items if it.get("type") == "reasoning"]
assert reasoning_items == []
assert len(reasoning_items) == 1
assert reasoning_items[0]["encrypted_content"] == "enc_blob"
def test_codex_transport_native_codex_still_replays_reasoning_in_input():
@@ -16,6 +16,7 @@ with ``APIConnectionError('Connection error.')`` whose cause was
That is the exact scenario this test reproduces at object level without a
network, so it runs in CI on every PR.
"""
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from run_agent import AIAgent
@@ -186,3 +187,32 @@ def test_replace_primary_openai_client_survives_repeated_rebuilds():
"Some _create_openai_client calls returned the same object across "
"a teardown — rebuild is not producing fresh clients"
)
def test_force_close_tcp_sockets_descends_httpcore_1_connection_wrapper():
"""httpcore 1.x stores the real stream below conn._connection."""
from agent.agent_runtime_helpers import force_close_tcp_sockets
class FakeSocket:
def __init__(self):
self.shutdown_calls = 0
self.close_calls = 0
def shutdown(self, _how):
self.shutdown_calls += 1
def close(self):
self.close_calls += 1
sock = FakeSocket()
stream = SimpleNamespace(_sock=sock)
http11 = SimpleNamespace(_network_stream=stream)
pool_entry = SimpleNamespace(_connection=http11)
pool = SimpleNamespace(_connections=[pool_entry])
transport = SimpleNamespace(_pool=pool)
http_client = SimpleNamespace(_transport=transport)
openai_client = SimpleNamespace(_client=http_client)
assert force_close_tcp_sockets(openai_client) == 1
assert sock.shutdown_calls == 1
assert sock.close_calls == 1
@@ -1,5 +1,6 @@
import sys
import threading
import time
import types
from types import SimpleNamespace
@@ -64,6 +65,7 @@ def _build_agent(shared_client=None):
agent.stream_delta_callback = None
agent._stream_callback = None
agent.reasoning_callback = None
agent.status_callback = None
return agent
@@ -93,6 +95,24 @@ def test_retry_after_api_connection_error_recreates_request_client(monkeypatch):
assert second_request.close_calls >= 1
def test_stale_non_stream_close_is_single_owner(monkeypatch):
def slow_responder(**kwargs):
time.sleep(0.1)
raise _connection_error()
request_client = FakeRequestClient(slow_responder)
factory = OpenAIFactory([request_client])
monkeypatch.setattr(run_agent, "OpenAI", factory)
agent = _build_agent()
agent._compute_non_stream_stale_timeout = lambda _messages: 0.01
with pytest.raises(APIConnectionError):
agent._interruptible_api_call({"model": agent.model, "messages": []})
assert request_client.close_calls == 1
def test_closed_shared_client_is_recreated_before_request(monkeypatch):
stale_shared = FakeSharedClient(lambda **kwargs: (_ for _ in ()).throw(AssertionError("stale shared client used")))
stale_shared._client.is_closed = True
@@ -168,3 +168,43 @@ class TestModelSupportsVision:
agent = _make_agent()
with patch("agent.models_dev.get_model_capabilities", side_effect=RuntimeError("boom")):
assert agent._model_supports_vision() is False
def test_top_level_model_override_wins(self):
agent = _make_agent()
agent.provider = "custom"
agent.model = "my-llava"
with patch("hermes_cli.config.load_config", return_value={"model": {"supports_vision": True}}), \
patch("agent.models_dev.get_model_capabilities", return_value=None):
assert agent._model_supports_vision() is True
def test_per_provider_per_model_override_wins(self):
agent = _make_agent()
agent.provider = "custom"
agent.model = "my-llava"
cfg = {"providers": {"custom": {"models": {"my-llava": {"supports_vision": True}}}}}
with patch("hermes_cli.config.load_config", return_value=cfg), \
patch("agent.models_dev.get_model_capabilities", return_value=None):
assert agent._model_supports_vision() is True
def test_named_custom_provider_resolved_via_config_provider(self):
# Named custom providers get runtime self.provider rewritten to
# "custom" while the config keeps the original name under
# model.provider. The override must still resolve.
agent = _make_agent()
agent.provider = "custom"
agent.model = "my-llava"
cfg = {
"model": {"provider": "my-vllm", "default": "my-llava"},
"providers": {"my-vllm": {"models": {"my-llava": {"supports_vision": True}}}},
}
with patch("hermes_cli.config.load_config", return_value=cfg), \
patch("agent.models_dev.get_model_capabilities", return_value=None):
assert agent._model_supports_vision() is True
def test_override_false_disables_vision_for_models_dev_models(self):
agent = _make_agent()
fake_caps = MagicMock()
fake_caps.supports_vision = True
with patch("hermes_cli.config.load_config", return_value={"model": {"supports_vision": False}}), \
patch("agent.models_dev.get_model_capabilities", return_value=fake_caps):
assert agent._model_supports_vision() is False