diff --git a/agent/transports/hermes_tools_mcp_server.py b/agent/transports/hermes_tools_mcp_server.py index 7fc23cf506d96..84ac4c050a1d2 100644 --- a/agent/transports/hermes_tools_mcp_server.py +++ b/agent/transports/hermes_tools_mcp_server.py @@ -29,14 +29,24 @@ - read_file / write_file / patch — codex's apply_patch + shell - search_files / process — codex's shell - clarify — codex's own UX - - delegate_task / memory / — `_AGENT_LOOP_TOOLS` in Hermes - session_search / todo (model_tools.py). They require + - delegate_task / todo — `_AGENT_LOOP_TOOLS` in Hermes + (model_tools.py). They require the running AIAgent context to dispatch (mid-loop state), so a stateless MCP callback can't drive them. See the inline comment on EXPOSED_TOOLS below. +Exposed via STATELESS SHIMS (#26604) rather than the generic dispatcher: + - memory — tools.memory_tool.load_on_disk_store() + per call: native caps, drift guard, + threat scan, locking all inherited + - session_search — read-only SessionDB over the state + DB; the calling session's id rides + the canonical HERMES_SESSION_ID + The `_AGENT_LOOP_TOOLS` refusal in handle_function_call stays intact for + every other caller — the shims are dedicated closures, not a widened gate. + Run with: python -m agent.transports.hermes_tools_mcp_server Spawned by: CodexAppServerSession.ensure_started() when the runtime is active and config opts in. @@ -104,11 +114,14 @@ def _signature_from_schema(schema: dict | None) -> tuple[inspect.Signature, dict # - terminal / shell / read_file / write_file / patch / search_files / # process — codex's built-ins cover these and approval routes through # codex's own UI. -# - delegate_task / memory / session_search / todo — these are -# `_AGENT_LOOP_TOOLS` in Hermes (model_tools.py:493). They require -# the running AIAgent context to dispatch (mid-loop state), so a -# stateless MCP callback can't drive them. Hermes' default runtime -# keeps these working; the codex_app_server runtime cannot. +# - delegate_task / todo — these are `_AGENT_LOOP_TOOLS` in Hermes +# (model_tools.py:606, gate at :1167). They require the running AIAgent +# context to dispatch (mid-loop state), so a stateless MCP callback +# can't drive them. Hermes' default runtime keeps these working; the +# codex_app_server runtime cannot. +# (`memory` and `session_search` are `_AGENT_LOOP_TOOLS` too, but they +# DO have workable stateless equivalents — they are exposed through the +# dedicated shims below, which does not widen that refusal.) EXPOSED_TOOLS: tuple[str, ...] = ( "web_search", "web_extract", @@ -149,6 +162,243 @@ def _signature_from_schema(schema: dict | None) -> tuple[inspect.Signature, dict ) +# --- Stateless agent-loop shims (#26604) --------------------------------- +# +# `memory` and `session_search` are `_AGENT_LOOP_TOOLS`: the generic +# dispatcher refuses them because natively they receive live agent state +# (the loop's MemoryStore / session-DB handle) from tool_executor. Both have +# workable stateless equivalents, so a runtime that owns its own agent loop +# (codex app-server) can regain them through this server without touching +# that refusal: +# +# memory → a fresh `load_on_disk_store()` per call. Char caps, +# config overrides, external-drift guard, threat scan and +# file locking live in MemoryStore/memory_tool and are +# inherited. The consolidation-failure breaker is NOT: +# a fresh store per call resets its counter every time, so +# it can never trip here (it is reset natively at the turn +# boundary, and this subprocess has no turns). +# NOTE: a shim write cannot mirror through MemoryProvider +# hooks (no MemoryManager in this subprocess), so when +# `memory.provider` configures an external backend the +# shim FAILS CLOSED — unregistered, and refused at +# dispatch — rather than silently diverging the stores. +# NOTE: with no foreground approver in a stdio subprocess +# the native write-approval gate can return `staged`; the +# call reports success and the write does not land yet. +# session_search → `SessionDB(read_only=True)` over the state DB (never a +# writable handle in a model-facing subprocess). NOT a +# faithful equivalent: it adds a deterministic zero-hit +# OR-relaxation the native tool does not have (below). +# The calling session's id arrives via the canonical +# HERMES_SESSION_ID (see `_SESSION_ID_ENV`); when that is +# unset, own-lineage exclusion is simply INACTIVE — +# fail-open, results just include the caller, no error. +# The DB path can be pointed elsewhere with +# HERMES_MCP_STATE_DB (defaults to the profile's +# state.db) — an internal mechanism bridge, not a +# user-facing setting. + +# The CANONICAL session-context variable — deliberately not a shim-specific name. +# `set_current_session_id()` writes it (gateway/session_context.py), `_VAR_MAP` carries +# it, and `_inject_session_context_env()` inside `hermes_subprocess_env()` bridges it +# into the HOST process's spawn env. That is hop 1 only: codex builds an MCP child's +# env from a fixed whitelist plus the names listed in the entry's `env_vars` (a +# spawn-time snapshot of the codex process env). The migration entry names +# HERMES_SESSION_ID there (`_build_hermes_tools_mcp_entry`), so under codex the shim +# receives the ACTIVE session's id and own-lineage exclusion is ACTIVE; a host that +# delivers nothing leaves it INACTIVE (fail-open, documented above). Any other host +# delivers it the same two ways: name it in the entry's `env_vars` or set it in the +# server's spawn env directly. Reading a +# bespoke name instead would be strictly worse: it has no producer in any launch path, +# and the cross-session leak guard in `_inject_session_context_env` covers only +# `_VAR_MAP` keys, so a bespoke var could carry a SIBLING session's id under a +# concurrent host and exclude the wrong lineage. +_SESSION_ID_ENV = "HERMES_SESSION_ID" +_STATE_DB_ENV = "HERMES_MCP_STATE_DB" + + +def _external_memory_provider(): + """Name of the external memory provider configured via `memory.provider`, + or None for the builtin on-disk store. Config-read failure counts as + builtin — the same fail-open posture as `_memory_enabled_in_config()`.""" + try: + from hermes_cli.config import load_config + + provider = str( + (((load_config() or {}).get("memory", {}) or {}).get("provider") or "") + ).strip().lower() + except Exception: + return None + if provider in ("", "none", "builtin", "off", "disabled"): + return None + return provider + + +def dispatch_memory(kwargs: dict) -> str: + """Stateless `memory` dispatch: native handler + on-disk store.""" + from tools.memory_tool import load_on_disk_store, memory_tool + from tools.registry import tool_error + + provider = _external_memory_provider() + if provider is not None: + # Every memory action mutates (add/replace/remove/batch), and a + # mutation here can never reach the external backend — refuse with + # the reason instead of letting the two stores drift apart. + return tool_error( + f"memory is disabled in this MCP shim: external memory provider " + f"'{provider}' is configured (memory.provider) and shim writes " + f"cannot mirror to it. Use the memory tool in the main agent " + f"loop instead.", + success=False, + ) + return memory_tool( + action=kwargs.get("action", ""), + target=kwargs.get("target", "memory"), + content=kwargs.get("content"), + old_text=kwargs.get("old_text"), + operations=kwargs.get("operations"), + store=load_on_disk_store(), + ) + + +def dispatch_session_search(kwargs: dict) -> str: + """Stateless `session_search` dispatch: read-only DB + env session id.""" + from pathlib import Path + + import hermes_state + from tools import session_search_tool + + db_path = Path( + os.environ.get(_STATE_DB_ENV, "").strip() + or hermes_state.DEFAULT_DB_PATH + ) + if not db_path.exists(): + # Explicit degrade — a missing DB must never read as "no results". + return json.dumps({ + "success": False, + "error": f"session_search unavailable: state DB not found at {db_path}", + }) + try: + db = hermes_state.SessionDB(db_path=db_path, read_only=True) + except Exception as exc: + return json.dumps({ + "success": False, + "error": f"session_search unavailable: cannot open state DB read-only: {exc}", + }) + try: + # A present-but-uninitialized DB (0-byte file from a crashed first + # init) opens fine and would return a SILENT empty result — the + # exact failure the missing-file guard above exists to prevent. + # Probe the schema and degrade explicitly instead. + db.get_session("__schema-probe__") + except Exception as exc: + try: + db.close() + except Exception: + pass + return json.dumps({ + "success": False, + "error": f"session_search unavailable: state DB not initialized: {exc}", + }) + + def _run(query: str) -> str: + return session_search_tool.session_search( + query=query, + role_filter=kwargs.get("role_filter"), + limit=kwargs.get("limit", 3), + session_id=kwargs.get("session_id"), + around_message_id=kwargs.get("around_message_id"), + window=kwargs.get("window", 5), + sort=kwargs.get("sort"), + profile=kwargs.get("profile"), + db=db, + current_session_id=os.environ.get(_SESSION_ID_ENV, "").strip() or None, + ) + + try: + query = kwargs.get("query") or "" + result = _run(query) + # Deterministic OR-relaxation: FTS5 ANDs terms, and models routinely + # write "topic word word word" discovery queries that miss content + # matching one distinctive term. On a ZERO-hit multi-term query with + # no explicit FTS operators, retry ONCE with the terms OR-joined and + # annotate the result — never silently, never for a query that + # states its own operators, never on a single term. + try: + parsed = json.loads(result) + terms = query.split() + has_operators = any( + op in query for op in ('"', "*", " OR ", " NOT ", " AND ") + ) + if ( + isinstance(parsed, dict) + and parsed.get("mode") == "discover" + and parsed.get("count") == 0 + and len(terms) >= 2 + and not has_operators + ): + relaxed_query = " OR ".join(terms) + relaxed = json.loads(_run(relaxed_query)) + if isinstance(relaxed, dict) and relaxed.get("count", 0) > 0: + relaxed["relaxed_query"] = relaxed_query + relaxed["note"] = ( + "No result matched ALL terms (FTS ANDs them); showing " + "matches for ANY term instead." + ) + return json.dumps(relaxed) + except Exception: + logger.debug("session_search relaxation skipped", exc_info=True) + return result + finally: + try: + db.close() + except Exception: + pass + + +def _memory_enabled_in_config() -> bool: + """Honor the operator's `memory.memory_enabled` config (default on).""" + try: + from hermes_cli.config import load_config + + return bool( + ((load_config() or {}).get("memory", {}) or {}).get("memory_enabled", True) + ) + except Exception: + return True + + +def _stateless_shim_defs() -> list: + """(name, description, input_schema, handler) 4-tuples to register. + + session_search is always defined — a missing state DB degrades to an + explicit error at call time, which is more diagnosable than an absent + tool. memory respects the config kill-switch AND stays unregistered when + an external memory provider is configured (shim writes cannot mirror + through MemoryProvider hooks — see the scope note above; #26604). + """ + defs = [] + if _memory_enabled_in_config() and _external_memory_provider() is None: + from tools.memory_tool import MEMORY_SCHEMA + + defs.append(( + "memory", + MEMORY_SCHEMA.get("description", "Hermes memory tool"), + MEMORY_SCHEMA.get("parameters") or {"type": "object", "properties": {}}, + dispatch_memory, + )) + from tools.session_search_tool import SESSION_SEARCH_SCHEMA + + defs.append(( + "session_search", + SESSION_SEARCH_SCHEMA.get("description", "Search past Hermes sessions"), + SESSION_SEARCH_SCHEMA.get("parameters") or {"type": "object", "properties": {}}, + dispatch_session_search, + )) + return defs + + def _build_server() -> Any: """Create the FastMCP server with Hermes tools attached. Lazy imports so the module can be imported without the mcp package installed @@ -237,10 +487,45 @@ def _dispatch(**kwargs: Any) -> str: exposed_count += 1 + # Stateless agent-loop shims (#26604) — registered as dedicated + # closures so handle_function_call's `_AGENT_LOOP_TOOLS` refusal stays + # intact for every other caller. Same signature-from-schema mechanics + # as the loop above so FastMCP serves the authoritative registry schema. + shim_count = 0 + for shim_name, shim_description, shim_schema, shim_fn in _stateless_shim_defs(): + shim_sig, shim_annots = _signature_from_schema(shim_schema) + + def _make_shim_handler(fn, tool_name: str, description: str, sig, annots): + def _dispatch(**kwargs: Any) -> str: + try: + args = {k: v for k, v in kwargs.items() if v is not None} + return fn(args or {}) + except Exception as exc: + logger.exception("shim tool %s raised", tool_name) + return json.dumps({"error": str(exc), "tool": tool_name}) + + _dispatch.__name__ = tool_name + _dispatch.__doc__ = description + _dispatch.__signature__ = sig + _dispatch.__annotations__ = {**annots, "return": str} + return _dispatch + + shim_handler = _make_shim_handler( + shim_fn, shim_name, shim_description, shim_sig, shim_annots + ) + try: + mcp.add_tool(shim_handler, name=shim_name, description=shim_description) + except TypeError: + shim_handler = mcp.tool(name=shim_name, description=shim_description)( + shim_handler + ) + shim_count += 1 + logger.info( - "hermes-tools MCP server registered %d/%d tools", + "hermes-tools MCP server registered %d/%d tools + %d stateless shims", exposed_count, len(EXPOSED_TOOLS), + shim_count, ) return mcp diff --git a/hermes_cli/codex_runtime_plugin_migration.py b/hermes_cli/codex_runtime_plugin_migration.py index 4b30d3ebf261d..6d596759f4ecc 100644 --- a/hermes_cli/codex_runtime_plugin_migration.py +++ b/hermes_cli/codex_runtime_plugin_migration.py @@ -599,6 +599,12 @@ def _build_hermes_tools_mcp_entry() -> dict: } if env: out["env"] = env + # Session-id delivery: a NAME, never a value (burn-in rule — a literal id + # written at migrate time would be frozen into config.toml and name the + # wrong session forever). Codex snapshots the names listed here from its + # own process env at MCP spawn, so the shim's own-lineage exclusion + # follows the ACTIVE session (#26604 keep_open resolution, option (a)). + out["env_vars"] = ["HERMES_SESSION_ID"] # Generous timeouts — browser_navigate or delegate_task can take a # while; we don't want codex's MCP client to give up too early. out["startup_timeout_sec"] = 30.0 diff --git a/hermes_state.py b/hermes_state.py index 129de1326e869..ecb5fa788fa36 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -50,6 +50,7 @@ _COMPRESSION_CHILD_SQL, _FTS_CJK_TRIGGERS, _FTS_TRIGGERS, + _fts_object_missing, _LISTABLE_CHILD_SQL, _PREVIEW_RAW_SELECT, _ephemeral_child_sql, @@ -4205,6 +4206,19 @@ def _do(conn): ) self._execute_write(_do) + def update_claude_sdk_session_id( + self, session_id: str, sdk_session_id: Optional[str] + ) -> None: + """Persist (or clear, with None) the claude-agent-sdk session id used + to resume the SDK conversation across gateway restarts and + agent-cache eviction (#25267 continuity).""" + def _do(conn): + conn.execute( + "UPDATE sessions SET claude_sdk_session_id = ? WHERE id = ?", + (sdk_session_id, session_id), + ) + self._execute_write(_do) + def update_system_prompt( self, session_id: str, system_prompt: Optional[str] ) -> None: diff --git a/hermes_state_common.py b/hermes_state_common.py index c520f1c51df63..08ba1474e9258 100644 --- a/hermes_state_common.py +++ b/hermes_state_common.py @@ -166,6 +166,13 @@ def _sql_session_last_active_by_id(session_id_expr: str) -> str: FTS_STORAGE_VERSION = 1 +def _fts_object_missing(exc: BaseException) -> bool: + """True when an FTS probe failure means the table/module is ABSENT + (disable search) rather than transiently unavailable (keep it on).""" + msg = str(exc).lower() + return "no such table" in msg or "no such module" in msg + + # Cap on user-controlled FTS5 query input before regex/sanitizer processing. # Search queries do not need to be arbitrarily large, and bounding them keeps # sanitizer/runtime behavior predictable under adversarial input. @@ -206,6 +213,7 @@ def _sql_session_last_active_by_id(session_id_expr: str) -> str: model TEXT, model_config TEXT, system_prompt TEXT, + claude_sdk_session_id TEXT, system_prompt_hash TEXT, parent_session_id TEXT, started_at REAL NOT NULL, diff --git a/hermes_state_search.py b/hermes_state_search.py index 756884b3f29f1..d08903fd24ef4 100644 --- a/hermes_state_search.py +++ b/hermes_state_search.py @@ -1299,8 +1299,12 @@ def _run_trigram_search( with self._read_ctx() as conn: try: tri_cursor = conn.execute(tri_sql, tri_params) - except sqlite3.OperationalError: + except sqlite3.OperationalError as exc: # Query failed at runtime — let the caller fall back. + # A missing tokenizer is permanent: log it once instead of + # silently retrying on every CJK query. + if self._is_trigram_unavailable_error(exc): + self._warn_trigram_unavailable(exc) return None return [dict(row) for row in tri_cursor.fetchall()] diff --git a/tests/agent/transports/test_hermes_tools_mcp_shims.py b/tests/agent/transports/test_hermes_tools_mcp_shims.py new file mode 100644 index 0000000000000..9601f69e59e85 --- /dev/null +++ b/tests/agent/transports/test_hermes_tools_mcp_shims.py @@ -0,0 +1,404 @@ +"""Tests for the stateless memory/session_search shims in the hermes-tools +MCP server (#26604). + +Natively `memory` and `session_search` are `_AGENT_LOOP_TOOLS`: the generic +dispatcher refuses them because they need live AIAgent state. The shims +supply that state statelessly — `load_on_disk_store()` per call for memory, +a read-only `SessionDB` + the calling session's id from env for +session_search — so an agent whose loop is owned by an external runtime +(claude-agent-sdk, codex app-server) regains both tools. + +No `mcp` package required: the dispatch functions are plain module-level +callables; only `_build_server()` (not under test here) needs FastMCP. + +Plant-the-failure discipline: the DB-missing path must yield an EXPLICIT +error (never a silently-empty result), and the refusal in +`handle_function_call` must remain intact for non-shim callers. +""" + +import json + +import pytest + +from agent.transports.hermes_tools_mcp_server import ( + _stateless_shim_defs, + dispatch_memory, + dispatch_session_search, +) + + +@pytest.fixture() +def tmp_hermes_home(tmp_path, monkeypatch): + home = tmp_path / "hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + # `set_current_session_id()` writes HERMES_SESSION_ID process-globally, so a + # developer's own live session id would otherwise leak into the suite and make + # the canonical-env tests below pass spuriously. + monkeypatch.delenv("HERMES_SESSION_ID", raising=False) + monkeypatch.delenv("HERMES_MCP_STATE_DB", raising=False) + return home + + +class TestMemoryShim: + def test_write_lands_in_canonical_memories_dir(self, tmp_hermes_home): + out = json.loads( + dispatch_memory( + {"action": "add", "target": "memory", "content": "auth refactor merged to main"} + ) + ) + assert out.get("success") is True + memory_file = tmp_hermes_home / "memories" / "MEMORY.md" + assert memory_file.exists() + assert "auth refactor merged to main" in memory_file.read_text() + + def test_native_caps_enforced(self, tmp_hermes_home): + # The shim reuses the native store: an oversized add must be rejected + # with the native consolidation error, not silently truncated. + out = json.loads( + dispatch_memory({"action": "add", "target": "memory", "content": "x" * 5000}) + ) + assert out.get("success") is False + assert "exceed" in json.dumps(out).lower() + + def test_batch_operations_supported(self, tmp_hermes_home): + out = json.loads( + dispatch_memory( + { + "target": "memory", + "operations": [ + {"action": "add", "content": "fact alpha"}, + {"action": "add", "content": "fact beta"}, + ], + } + ) + ) + assert out.get("success") is True + content = (tmp_hermes_home / "memories" / "MEMORY.md").read_text() + assert "fact alpha" in content and "fact beta" in content + + def test_fails_closed_when_external_provider_configured( + self, tmp_hermes_home, monkeypatch + ): + # #26604 precondition: a shim write cannot mirror through + # MemoryProvider hooks (no MemoryManager in this subprocess), so a + # configured external backend must refuse the dispatch — silent + # store divergence is the failure this prevents. + import hermes_cli.config as cfg + + monkeypatch.setattr( + cfg, "load_config", lambda *a, **k: {"memory": {"provider": "honcho"}} + ) + out = json.loads( + dispatch_memory({"action": "add", "target": "memory", "content": "x"}) + ) + assert out.get("success") is False + assert "honcho" in out.get("error", "") + assert not (tmp_hermes_home / "memories" / "MEMORY.md").exists() + + batch = json.loads( + dispatch_memory( + {"target": "memory", "operations": [{"action": "add", "content": "y"}]} + ) + ) + assert batch.get("success") is False + + def test_builtin_provider_value_is_not_external(self, tmp_hermes_home, monkeypatch): + # Control (non-vacuous): 'builtin' means the on-disk store — the + # guard must not fire, and the write must land. + import hermes_cli.config as cfg + + monkeypatch.setattr( + cfg, "load_config", lambda *a, **k: {"memory": {"provider": "builtin"}} + ) + out = json.loads( + dispatch_memory({"action": "add", "target": "memory", "content": "kept"}) + ) + assert out.get("success") is True + assert "kept" in (tmp_hermes_home / "memories" / "MEMORY.md").read_text() + + def test_shim_unregistered_when_external_provider_configured( + self, tmp_hermes_home, monkeypatch + ): + # Registration-level twin of the dispatch guard: the tool should not + # even be offered to the model when it can only refuse. + import hermes_cli.config as cfg + + monkeypatch.setattr( + cfg, + "load_config", + lambda *a, **k: {"memory": {"memory_enabled": True, "provider": "mem0"}}, + ) + names = [name for name, _desc, _schema, _fn in _stateless_shim_defs()] + assert "memory" not in names + assert "session_search" in names + + +class TestSessionSearchShim: + def _seed_db(self, path): + from hermes_state import SessionDB + + db = SessionDB(db_path=path) + db.create_session("sess-hist-1", source="telegram") + db.append_message("sess-hist-1", "user", "when did we merge the auth refactor?") + db.append_message("sess-hist-1", "assistant", "The auth refactor merged on Thursday.") + db.close() + + def test_search_returns_seeded_rows(self, tmp_hermes_home, monkeypatch): + db_path = tmp_hermes_home / "state.db" + self._seed_db(db_path) + monkeypatch.setenv("HERMES_MCP_STATE_DB", str(db_path)) + out = json.loads(dispatch_session_search({"query": "auth refactor"})) + # HIT-side fields only: the discover payload echoes the query string, + # so a substring assertion on the raw output passes even on zero hits. + assert out.get("count", 0) >= 1 + hit = out["results"][0] + assert hit["session_id"] == "sess-hist-1" + assert "refactor" in hit["snippet"] + + def test_missing_db_yields_explicit_error(self, tmp_hermes_home, monkeypatch): + # RED-first: a missing state DB must surface as an explicit error, + # never as a silently-empty result set. + monkeypatch.setenv( + "HERMES_MCP_STATE_DB", str(tmp_hermes_home / "nope" / "state.db") + ) + out = json.loads(dispatch_session_search({"query": "anything"})) + assert out.get("success") is False + assert "state DB" in out.get("error", "") + + def test_session_id_read_from_canonical_env(self, tmp_hermes_home, monkeypatch): + # The shim must read the CANONICAL `HERMES_SESSION_ID` — the name Hermes + # actually produces (`set_current_session_id` -> `_VAR_MAP` -> + # `_inject_session_context_env` -> the HOST process's spawn env; a codex + # MCP child additionally needs the entry to name it in `env_vars` — see + # the `_SESSION_ID_ENV` note in the server module). A bespoke name has a + # producer in NO launch path; the canonical name is delivered wherever + # the host forwards or sets it, and exclusion stays fail-open (inactive) + # where it doesn't. + db_path = tmp_hermes_home / "state.db" + self._seed_db(db_path) + monkeypatch.setenv("HERMES_MCP_STATE_DB", str(db_path)) + monkeypatch.setenv("HERMES_SESSION_ID", "sess-current-9") + + captured = {} + import tools.session_search_tool as sst + + real = sst.session_search + + def spy(**kwargs): + captured.update(kwargs) + return real(**kwargs) + + monkeypatch.setattr(sst, "session_search", spy) + dispatch_session_search({"query": "auth"}) + assert captured.get("current_session_id") == "sess-current-9" + + def test_calling_session_excluded_via_production_producer( + self, tmp_hermes_home, monkeypatch + ): + # Producer/consumer NAME agreement, proven through the REAL producer: + # `set_current_session_id()` is what Hermes itself calls. Establishing the + # precondition that way — rather than a bare `setenv` of the very name + # under test — makes a name mismatch fail loudly here, which is how the + # original defect survived review. Scope honestly: producer and shim share + # this test process, so this pins the NAME contract, not delivery across a + # host's process boundary (see the `_SESSION_ID_ENV` note for who + # forwards it). + from gateway.session_context import set_current_session_id + from hermes_state import SessionDB + + db_path = tmp_hermes_home / "state.db" + db = SessionDB(db_path=db_path) + db.create_session("sess-other-1", source="telegram") + db.append_message( + "sess-other-1", "assistant", "the auth refactor merged on Thursday" + ) + db.create_session("sess-mine-2", source="telegram") + db.append_message( + "sess-mine-2", "assistant", "the auth refactor notes are mine" + ) + db.close() + monkeypatch.setenv("HERMES_MCP_STATE_DB", str(db_path)) + + # setenv first purely so monkeypatch restores the environment at teardown + # (the producer writes os.environ directly); the value under test is the + # one written by the real producer on the very next line. + monkeypatch.setenv("HERMES_SESSION_ID", "") + set_current_session_id("sess-mine-2") + + out = dispatch_session_search({"query": "auth refactor", "limit": 10}) + assert "sess-other-1" in out + assert "sess-mine-2" not in out + + def test_zero_hit_multiterm_query_relaxes_to_or(self, tmp_hermes_home, monkeypatch): + # FTS5 ANDs terms: models write "topic word word word" queries and get + # 0 hits for content that matches one distinctive term (observed live + # twice). The shim retries ONCE with OR-joined terms, deterministic, + # and annotates the result honestly. + db_path = tmp_hermes_home / "state.db" + self._seed_db(db_path) + monkeypatch.setenv("HERMES_MCP_STATE_DB", str(db_path)) + out = json.loads( + dispatch_session_search({"query": "auth refactor deployment window"}) + ) + assert out.get("count", 0) >= 1 + assert out.get("relaxed_query") == "auth OR refactor OR deployment OR window" + assert "sess-hist-1" in json.dumps(out) + + def test_explicit_fts_operators_are_never_relaxed(self, tmp_hermes_home, monkeypatch): + # A query that already uses FTS operators is the caller's intent — + # no second-guessing. + db_path = tmp_hermes_home / "state.db" + self._seed_db(db_path) + monkeypatch.setenv("HERMES_MCP_STATE_DB", str(db_path)) + out = json.loads( + dispatch_session_search({"query": '"deployment window" OR rollout'}) + ) + assert out.get("count") == 0 + assert "relaxed_query" not in out + + def test_zero_hit_single_term_returns_honest_zero(self, tmp_hermes_home, monkeypatch): + # Nothing to relax on a single term: an honest empty result, never a + # fabricated one. + db_path = tmp_hermes_home / "state.db" + self._seed_db(db_path) + monkeypatch.setenv("HERMES_MCP_STATE_DB", str(db_path)) + out = json.loads(dispatch_session_search({"query": "kubernetes"})) + assert out.get("count") == 0 + assert "relaxed_query" not in out + + def test_uninitialized_db_yields_explicit_error(self, tmp_hermes_home, monkeypatch): + # Validator C3: a 0-byte state.db (crashed first init) passes the + # exists() guard and used to return a SILENT success/count:0. + db_path = tmp_hermes_home / "state.db" + db_path.touch() # present but uninitialized + monkeypatch.setenv("HERMES_MCP_STATE_DB", str(db_path)) + out = json.loads(dispatch_session_search({"query": "anything"})) + assert out.get("success") is False + assert "not initialized" in out.get("error", "") + + def test_db_opened_read_only(self, tmp_hermes_home, monkeypatch): + # The shim must never hand a writable DB handle to a model-facing + # subprocess. SessionDB(read_only=True) attaches with mode=ro. + db_path = tmp_hermes_home / "state.db" + self._seed_db(db_path) + monkeypatch.setenv("HERMES_MCP_STATE_DB", str(db_path)) + + captured = {} + import agent.transports.hermes_tools_mcp_server as srv + import hermes_state + + real = hermes_state.SessionDB + + class SpyDB(real): + def __init__(self, *args, **kwargs): + captured.update(kwargs) + super().__init__(*args, **kwargs) + + monkeypatch.setattr(hermes_state, "SessionDB", SpyDB) + dispatch_session_search({"query": "auth"}) + assert captured.get("read_only") is True + + +class TestShimRegistration: + def test_both_shims_defined_by_default(self, tmp_hermes_home): + names = [name for name, _desc, _schema, _fn in _stateless_shim_defs()] + assert names == ["memory", "session_search"] + + def test_memory_shim_respects_config_disable(self, tmp_hermes_home, monkeypatch): + import hermes_cli.config as cfg + + monkeypatch.setattr( + cfg, "load_config", lambda *a, **k: {"memory": {"memory_enabled": False}} + ) + names = [name for name, _desc, _schema, _fn in _stateless_shim_defs()] + assert "memory" not in names + assert "session_search" in names + + def test_shim_signatures_carry_the_registry_schema(self, tmp_hermes_home): + # Pin of the schema-inference regression: FastMCP derives the served + # schema from the handler's signature, so the signature synthesized + # from the registry schema must expose the real parameters — never a + # bare ``kwargs`` (pydantic renders that as a REQUIRED "kwargs" + # field, failing EVERY call at the validation layer). + from agent.transports.hermes_tools_mcp_server import ( + _signature_from_schema, + ) + + for name, _desc, schema, _fn in _stateless_shim_defs(): + sig, _annots = _signature_from_schema(schema) + params = list(sig.parameters) + assert "kwargs" not in params, f"{name} would serve an inferred schema" + assert params, f"{name} signature is empty" + shim_schemas = {n: s for n, _d, s, _f in _stateless_shim_defs()} + mem_sig, _ = _signature_from_schema(shim_schemas["memory"]) + assert "target" in mem_sig.parameters + ss_sig, _ = _signature_from_schema(shim_schemas["session_search"]) + assert "query" in ss_sig.parameters + + def test_agent_loop_refusal_stays_intact_for_other_callers(self): + # The shims must NOT weaken the generic dispatcher: a stateless + # handle_function_call("memory", ...) still refuses. + from model_tools import handle_function_call + + out = handle_function_call("memory", {"action": "add", "content": "x"}) + assert "must be handled by the agent loop" in out + + +class TestCodexLifecycleDelivery: + def test_codex_entry_delivers_session_id_end_to_end( + self, tmp_hermes_home, monkeypatch + ): + """#26604 keep_open resolution, option (a) — the leg the production- + producer test above deliberately scopes OUT: delivery across the codex + MCP lifecycle. Codex builds an MCP child's env from the entry's ``env`` + map (literal values) plus a spawn-time snapshot of the NAMES listed in + the entry's ``env_vars``. Simulate exactly that contract from the REAL + migration entry: the real producer writes the id → codex-style spawn + snapshots only the names the entry declares → the shim, reading only + what was delivered, excludes the calling lineage.""" + import os + + from gateway.session_context import set_current_session_id + from hermes_cli.codex_runtime_plugin_migration import ( + _build_hermes_tools_mcp_entry, + ) + from hermes_state import SessionDB + + db_path = tmp_hermes_home / "state.db" + db = SessionDB(db_path=db_path) + db.create_session("sess-other-1", source="telegram") + db.append_message( + "sess-other-1", "assistant", "the auth refactor merged on Thursday" + ) + db.create_session("sess-mine-2", source="telegram") + db.append_message( + "sess-mine-2", "assistant", "the auth refactor notes are mine" + ) + db.close() + + # setenv first so monkeypatch restores env at teardown; the value under + # test is written by the real producer on the next line. + monkeypatch.setenv("HERMES_SESSION_ID", "") + set_current_session_id("sess-mine-2") + + entry = _build_hermes_tools_mcp_entry() + delivered = dict(entry.get("env", {})) + for name in entry.get("env_vars", []) or []: + if name in os.environ: + delivered[name] = os.environ[name] + + # The shim may see ONLY what the codex contract delivered: drop the + # producer's process-global write, then install the delivered set. + monkeypatch.delenv("HERMES_SESSION_ID", raising=False) + for key, value in delivered.items(): + monkeypatch.setenv(key, value) + # Internal mechanism bridge, not part of the delivery under test. + monkeypatch.setenv("HERMES_MCP_STATE_DB", str(db_path)) + + out = dispatch_session_search({"query": "auth refactor", "limit": 10}) + assert "sess-other-1" in out + assert "sess-mine-2" not in out, ( + "own-lineage exclusion inactive: the entry did not deliver " + "HERMES_SESSION_ID through the codex env contract" + ) diff --git a/tests/hermes_cli/test_codex_runtime_plugin_migration.py b/tests/hermes_cli/test_codex_runtime_plugin_migration.py index 84b2b73961ddd..56a1c04a725c0 100644 --- a/tests/hermes_cli/test_codex_runtime_plugin_migration.py +++ b/tests/hermes_cli/test_codex_runtime_plugin_migration.py @@ -421,3 +421,37 @@ def test_unset_hermes_home_omits_env_key(self, monkeypatch): f"HERMES_HOME should not be set when env var is unset, got: " f"{env.get('HERMES_HOME')!r}" ) + + def test_session_id_is_never_burned_into_codex_config(self, monkeypatch): + """A session id must NEVER be serialized into the MCP entry. + + This entry is written to ``~/.codex/config.toml`` at MIGRATE time, so any + per-session value baked in here is frozen for the life of that config and + would forever name the wrong session — the same burn-in failure the + HERMES_HOME guards above exist to prevent. The one legitimate delivery + under codex is the entry's ``env_vars`` name-passthrough (a spawn-time + snapshot of the codex process env — a NAME, never a value); the test + below pins that the entry wires exactly that. What this test pins is + the burn-in rule: no literal session id, under any key, may land in + the entry's ``env`` map.""" + monkeypatch.setenv("HERMES_SESSION_ID", "sess-must-not-persist") + entry = _build_hermes_tools_mcp_entry() + env = entry.get("env", {}) + assert not any("SESSION_ID" in key for key in env), ( + f"no session id may be serialized into config.toml, got: {env!r}" + ) + + def test_session_id_delivered_by_name_passthrough(self, monkeypatch): + """#26604 keep_open resolution, option (a): the entry names + HERMES_SESSION_ID in ``env_vars`` — codex's spawn-time NAME + passthrough — so the shim's own-lineage exclusion follows the + ACTIVE session, while the burn-in rule above still holds: never + a VALUE in the env map.""" + monkeypatch.setenv("HERMES_SESSION_ID", "sess-live-1") + entry = _build_hermes_tools_mcp_entry() + assert "HERMES_SESSION_ID" in entry.get("env_vars", []), ( + f"entry must NAME the session var for codex to deliver it, got: " + f"{entry.get('env_vars')!r}" + ) + env = entry.get("env", {}) + assert not any("SESSION_ID" in key for key in env) diff --git a/tests/hermes_state/test_claude_sdk_session_id.py b/tests/hermes_state/test_claude_sdk_session_id.py new file mode 100644 index 0000000000000..f2a15b2d8cce6 --- /dev/null +++ b/tests/hermes_state/test_claude_sdk_session_id.py @@ -0,0 +1,152 @@ +"""The sessions.claude_sdk_session_id column (W3 continuity, #25267). + +Declarative migration: the column lives in SCHEMA_SQL and +_reconcile_columns adds it to older DBs on startup — so a fresh DB and an +upgraded DB both expose it, nullable. +""" + +from hermes_state import SessionDB + + +def test_column_exists_null_by_default_and_round_trips(tmp_path): + db = SessionDB(db_path=tmp_path / "state.db") + try: + db.create_session("sess-cc-1", source="telegram") + row = db.get_session("sess-cc-1") + assert "claude_sdk_session_id" in row + assert row["claude_sdk_session_id"] is None + + db.update_claude_sdk_session_id("sess-cc-1", "sdk-uuid-42") + assert db.get_session("sess-cc-1")["claude_sdk_session_id"] == "sdk-uuid-42" + + # Clearing (error retire) round-trips to NULL. + db.update_claude_sdk_session_id("sess-cc-1", None) + assert db.get_session("sess-cc-1")["claude_sdk_session_id"] is None + finally: + db.close() + + +def test_new_session_row_never_inherits_an_id(tmp_path): + # /new and expiry rotate to a NEW Hermes session row — fresh-by-keying: + # the new row must carry no resume id. + db = SessionDB(db_path=tmp_path / "state.db") + try: + db.create_session("sess-old", source="telegram") + db.update_claude_sdk_session_id("sess-old", "sdk-uuid-1") + db.create_session("sess-new", source="telegram") + assert db.get_session("sess-new")["claude_sdk_session_id"] is None + finally: + db.close() + + +def test_fts_probe_error_classifier(): + # Validator C2: only a MISSING fts object may disable read-only search; + # a transient lock must never latch a silent false-empty. + import sqlite3 + + from hermes_state import _fts_object_missing + + assert _fts_object_missing(sqlite3.OperationalError("no such table: messages_fts")) + assert _fts_object_missing(sqlite3.OperationalError("no such module: fts5")) + assert not _fts_object_missing(sqlite3.OperationalError("database is locked")) + assert not _fts_object_missing(sqlite3.OperationalError("disk I/O error")) + + +def _read_only_db_with_probe_error(tmp_path, monkeypatch, message, sql_needle): + """Open a read-only SessionDB where the probe matching `sql_needle` raises. + + The seed DB is created first with a normal write handle (schema load), + THEN sqlite3.connect is wrapped so only statements containing + `sql_needle` error — every other statement runs for real. The primary + probe is ``SELECT 1 FROM messages_fts LIMIT 1`` and the trigram probe + is ``SELECT 1 FROM messages_fts_trigram LIMIT 1``, so use + "messages_fts LIMIT" to hit the primary one only ("messages_fts" alone + is a substring of the trigram table name). + """ + import sqlite3 + + import hermes_state + + db_path = tmp_path / "state.db" + SessionDB(db_path=db_path).close() + + real_connect = sqlite3.connect + + def _connect_with_probe_error(*args, **kwargs): + # A real sqlite3.Connection subclass via the factory kwarg — a plain + # object proxy dies in sqlite_safe_read._retrofit_tracking's + # __class__ swap (object layout differs from TrackedConnection). + base = kwargs.get("factory", sqlite3.Connection) + + class _ProbeErrorCursor(sqlite3.Cursor): + def execute(self, sql, *eargs, **ekwargs): + if isinstance(sql, str) and sql_needle in sql: + raise sqlite3.OperationalError(message) + return super().execute(sql, *eargs, **ekwargs) + + class _ProbeErrorConnection(base): + def execute(self, sql, *eargs, **ekwargs): + if isinstance(sql, str) and sql_needle in sql: + raise sqlite3.OperationalError(message) + return super().execute(sql, *eargs, **ekwargs) + + def cursor(self, factory=_ProbeErrorCursor): + # The RO-open probe goes through cursor().execute — the + # connection-level override alone never sees it. + return super().cursor(factory) + + kwargs["factory"] = _ProbeErrorConnection + return real_connect(*args, **kwargs) + + monkeypatch.setattr( + hermes_state.sqlite3, "connect", _connect_with_probe_error + ) + return SessionDB(db_path=db_path, read_only=True) + + +def _read_only_db_with_trigram_probe_error(tmp_path, monkeypatch, message): + return _read_only_db_with_probe_error( + tmp_path, monkeypatch, message, sql_needle="messages_fts_trigram" + ) + + +def test_probe_transient_error_surfaces_and_closes(tmp_path, monkeypatch): + # Transient probe failures (lock during a checkpoint) SURFACE at open — + # upstream's _fts_table_probe re-raises anything that isn't a missing + # module/table, and the RO-open path closes the tracked connection on + # the way out so _backup_db_file's raw-copy is never blocked by a leaked + # handle. (Earlier revisions of this branch kept the handle open with + # the flag latched True; upstream's raise-with-cleanup supersedes that.) + import pytest + + import sqlite3 as _sqlite3 + + with pytest.raises(_sqlite3.OperationalError, match="locked"): + _read_only_db_with_probe_error( + tmp_path, monkeypatch, "database is locked", + sql_needle="messages_fts", + ) + + +def test_trigram_probe_missing_table_disables_trigram(tmp_path, monkeypatch): + db = _read_only_db_with_trigram_probe_error( + tmp_path, monkeypatch, "no such table: messages_fts_trigram" + ) + try: + assert db._trigram_available is False + finally: + db.close() + + +def test_trigram_probe_missing_tokenizer_disables_trigram(tmp_path, monkeypatch): + # A build with FTS5 but without the trigram tokenizer (SQLite < 3.34) + # raises "no such tokenizer: trigram" — persistent absence, same latch as + # a missing table. _fts_object_missing alone does NOT classify this one; + # the probe must also consult _is_trigram_unavailable_error. + db = _read_only_db_with_trigram_probe_error( + tmp_path, monkeypatch, "no such tokenizer: trigram" + ) + try: + assert db._trigram_available is False + finally: + db.close() diff --git a/website/docs/user-guide/features/codex-app-server-runtime.md b/website/docs/user-guide/features/codex-app-server-runtime.md index 51821278d77b9..6670dcf0d44c1 100644 --- a/website/docs/user-guide/features/codex-app-server-runtime.md +++ b/website/docs/user-guide/features/codex-app-server-runtime.md @@ -18,7 +18,7 @@ Not using OpenAI Codex? `hermes setup --portal` configures a non-Codex backend w - Run OpenAI agent turns against your **ChatGPT subscription** (no API key required) using the same auth flow Codex CLI uses. - Use **Codex's own toolset and sandbox** — `shell` for terminal/read/write/search, `apply_patch` for structured edits, `update_plan` for planning, all running inside seatbelt/landlock sandboxing. - **Native Codex plugins** — Linear, GitHub, Gmail, Calendar, Canva, etc. — installed via `codex plugin` are auto-migrated and active in your Hermes session. -- **Hermes' richer tools come along** — web_search, web_extract, browser automation, vision, image generation, skills, and TTS work via an MCP callback. Codex calls back into Hermes for tools it doesn't have built in. +- **Hermes' richer tools come along** — web_search, web_extract, browser automation, vision, image generation, skills, and TTS work via an MCP callback, and persistent memory + cross-session search ride the same callback through stateless shims (with caveats — see below). Codex calls back into Hermes for tools it doesn't have built in. - **Memory and skill nudges keep working** — Codex's events are projected into Hermes' message shape so the self-improvement loop sees a normal-looking transcript. ## What tools the model actually has @@ -65,18 +65,22 @@ Hermes registers itself as an MCP server so codex can call back for tools codex - **`image_generate`** — image generation through Hermes' image_gen plugin chain. - **`skill_view` / `skills_list`** — read from Hermes' skill library. - **`text_to_speech`** — TTS through Hermes' configured provider. +- **`memory` / `session_search`** — served through **stateless shims** rather than the generic dispatcher, with a narrowed contract vs. the native tools. See [the callback section](#hermes-tool-callback-the-new-mcp-server) for the caveats. When the model wants one of these, codex spawns the `hermes_tools_mcp_server` subprocess via stdio MCP, the call is dispatched through `model_tools.handle_function_call()` (same code path as Hermes' default runtime), and the result is returned to codex like any other MCP response. ### What's NOT available on this runtime -These four Hermes tools require the running AIAgent context (mid-loop state) to dispatch, and a stateless MCP callback can't drive them. Switch back to the default runtime (`/codex-runtime auto`) when you need any of them: +These two Hermes tools require the running AIAgent context (mid-loop state) to dispatch, and a stateless MCP callback can't drive them. Switch back to the default runtime (`/codex-runtime auto`) when you need either of them: - **`delegate_task`** — spawn subagents -- **`memory`** — Hermes' persistent memory store -- **`session_search`** — cross-session search - **`todo`** — Hermes' todo store (codex's `update_plan` is the in-runtime equivalent) +`memory` and `session_search` are agent-loop tools too, but both have workable stateless equivalents, so the callback serves them via dedicated shims — with a narrowed contract: + +- **`session_search` excludes your current conversation.** Results skip the calling session's own lineage, matching the native tool: the migration entry names `HERMES_SESSION_ID` in its `env_vars`, codex's spawn-time name passthrough, so the shim receives the active session's id. A host that delivers nothing degrades fail-open (searches still work; hits from the current conversation can appear). +- **`memory` fails closed when an external memory provider is configured** (`memory.provider` in config). A shim write can't mirror through MemoryProvider hooks, so instead of silently diverging the on-disk store from the external backend, the tool isn't offered at all. Use the default runtime for memory in that setup. + ## Workflow features (`/goal`, kanban, cron) ### `/goal` (the Ralph loop) @@ -103,14 +107,15 @@ The kanban tools are gated by `HERMES_KANBAN_TASK` env var the dispatcher sets ### Cron jobs -**Not specifically tested.** Cron jobs run via `cronjob` → `AIAgent.run_conversation`, the same code path as the CLI. If the cron job's config has `openai_runtime: codex_app_server` it'll run on codex. The same tool-availability rules apply — codex built-ins + plugins + MCP callback work, agent-loop tools (delegate_task, memory, session_search, todo) don't. If your cron job relies on those, scope the cron to a profile that uses the default runtime. +**Not specifically tested.** Cron jobs run via `cronjob` → `AIAgent.run_conversation`, the same code path as the CLI. If the cron job's config has `openai_runtime: codex_app_server` it'll run on codex. The same tool-availability rules apply — codex built-ins + plugins + MCP callback (including the memory / session_search shims, with their caveats) work; delegate_task and todo don't. If your cron job relies on those two, scope the cron to a profile that uses the default runtime. ## Trade-offs | | Hermes default runtime | Codex app-server (opt-in) | |---|---|---| | `delegate_task` subagents | yes | not available — needs agent loop context | -| `memory`, `session_search`, `todo` | yes | not available — needs agent loop context | +| `todo` | yes | not available — codex's `update_plan` is the in-runtime equivalent | +| `memory`, `session_search` | yes | yes (via stateless MCP shims — narrowed contract, see above) | | `web_search`, `web_extract` | yes | yes (via MCP callback) | | Browser automation (Camofox/Browserbase) | yes | yes (via MCP callback) | | `vision_analyze`, `image_generate` | yes | yes (via MCP callback) | @@ -378,7 +383,12 @@ When the model calls `web_search` (or another exposed Hermes tool), codex spawns **Tools available via the callback:** `web_search`, `web_extract`, `browser_navigate`, `browser_click`, `browser_type`, `browser_press`, `browser_snapshot`, `browser_scroll`, `browser_back`, `browser_get_images`, `browser_console`, `browser_vision`, `vision_analyze`, `image_generate`, `skill_view`, `skills_list`, `text_to_speech`. -**Tools NOT available:** `delegate_task`, `memory`, `session_search`, `todo`. These need the running AIAgent context to dispatch (mid-loop state) and a stateless MCP callback can't drive them. Use the default Hermes runtime (`/codex-runtime auto`) when you need these. +**Also available, via stateless shims:** `memory` and `session_search`. Natively these are agent-loop tools — they receive live loop state from the tool executor, and the generic dispatcher refuses them from any other caller (that refusal stays intact). The callback serves them through dedicated shims instead: + +- **`memory`** loads the on-disk store fresh on every call, so the native character caps, external-drift guard, threat scan, and file locking all apply. Two caveats: with no foreground approver in the subprocess, a write the approval gate stages is reported as success before it actually lands; and when an external memory provider is configured (`memory.provider`), the shim **fails closed** — the tool is not registered at all, because a shim write can't mirror to the external backend and the two stores would silently diverge. +- **`session_search`** runs against a read-only handle to the sessions DB. Results exclude the calling conversation's own lineage, as natively: the migration entry names `HERMES_SESSION_ID` in its `env_vars` (codex snapshots the named variables from its process env at MCP spawn — a name, never a value burned into `config.toml`). Hosts that deliver nothing degrade fail-open — searches succeed but may include hits from your current conversation. One addition over the native tool: a zero-hit multi-term query with no explicit FTS operators is retried once with the terms OR-joined, and the result is annotated when the retry hits. + +**Tools NOT available:** `delegate_task`, `todo`. These need the running AIAgent context to dispatch (mid-loop state) and a stateless MCP callback can't drive them. Use the default Hermes runtime (`/codex-runtime auto`) when you need these. ## Disabling @@ -406,7 +416,8 @@ This runtime is **opt-in beta**. Working as of Hermes Agent 2026.5 + Codex CLI 0 Known limitations: - **Hermes auth and codex auth are separate sessions.** You need both `codex login` AND `hermes auth add openai-codex` for the cleanest UX (the runtime uses codex's session for the LLM call). This is a deliberate design choice in Hermes' `_import_codex_cli_tokens` — Hermes won't share OAuth state with codex CLI to avoid clobbering each other on token refresh. -- **`delegate_task`, `memory`, `session_search`, `todo` are unavailable on this runtime.** They need the running AIAgent context which a stateless MCP callback can't provide. Use `/codex-runtime auto` when you need these. +- **`delegate_task` and `todo` are unavailable on this runtime.** They need the running AIAgent context which a stateless MCP callback can't provide. Use `/codex-runtime auto` when you need them. +- **`memory` and `session_search` run on stateless shims.** session_search excludes your current conversation's lineage under codex (via the entry's `HERMES_SESSION_ID` name-passthrough; fail-open only when a host delivers no id), and memory fails closed when an external memory provider is configured. See [the callback section](#hermes-tool-callback-the-new-mcp-server). Use `/codex-runtime auto` when you need the full native contract. - **No inline patch preview in approval prompts when codex doesn't track the changeset.** Codex's `fileChange` approval params don't always carry the changeset. Hermes caches the data from the corresponding `item/started` notification when possible, but if approval arrives before the item has streamed, the prompt falls back to whatever `reason` codex provides. - **Sub-second cancellation isn't guaranteed.** Mid-stream interrupts (Ctrl+C while codex is responding) are sent via `turn/interrupt`, but if codex has already flushed the final message, you get the response anyway. @@ -454,6 +465,7 @@ If you find a bug, [open an issue](https://github.com/NousResearch/hermes-agent/ │ hermes_tools_mcp_server.py (subprocess on demand) │ │ web_search, web_extract, browser_*, vision_analyze, │ │ image_generate, skill_view, skills_list, text_to_speech│ + │ + stateless shims: memory, session_search │ └──────────────────────────────────────────────────────────┘ ```