AI-OS (Second-Brain)

myos/hermes/HERMES-ARCHITECTURE-DEEP.md

Hermes Agent — Deep Architecture Map

Deep-read pass for AIOS, 2026-07-04. Grounded in the actual source at /Users/arijitchowdhury/Dropbox/AI-Development/AI-OS/hermes-agent/ (Nous Research, MIT, hermes-agent v0.18.0, Python ≥3.11 <3.14). Every claim below is cited file:line. Supersedes/deepens the prior shallow notes (hermes-source-map.md, hermes-docs-overview.md) — those are still useful for the plugin/skill catalog and user-story synthesis; this doc is the ground-truth architecture reference.

Note on provenance: a peer Claude session (self-named "hermes-learner") was independently producing a file at this same path concurrently with this pass — see INBOUND-peer-session-teardown-2026-07-04.md in this directory. No collision occurred (no file existed here when this one was written), but flagging for team-lead: two sessions were pointed at the same deliverable. A handful of claims below that I did not independently verify are explicitly marked "peer-relayed, unverified" and sourced to that note.


Executive summary (plain English, 10 lines)

Hermes is one agent brain (AIAgent, run_agent.py) wearing three different faces: a terminal CLI, a messaging-gateway daemon (Telegram/Discord/Slack/...), and an editor-protocol server (Zed via ACP). All three build the same class with different keyword arguments and drive the same per-turn function, run_conversation() — a ~4,700-line spine in agent/conversation_loop.py that calls the model, detects tool calls, runs them (sequentially or on a thread pool), retries through ~20 named provider failure modes, compresses context when it gets too long, and writes everything to a SQLite database as it goes. Tools are a single registry keyed by name; anyone can add one via a plugin with zero core-file edits. Providers (30+, not "18+") are resolved through one big priority-ordered function down to a flat {provider, api_mode, api_key, base_url} dict, and each API "shape" (Anthropic, Bedrock, Gemini-native, Codex-Responses, generic OpenAI) gets its own adapter that normalizes back to one common response shape. Memory is two unrelated systems: a dumb, capped, always-loaded flat file (MEMORY.md/USER.md) and an optional pluggable "real" memory backend (Honcho, Mem0, etc.) that the built-in system barely talks to. Subagents are same-process, separate-thread, separate-object children with a hard depth cap of 1 — cheap to spawn, isolated by convention not by OS sandbox. The whole thing ships as one immutable, non-root Docker image supervised by s6-overlay, or an equally capable bare source install. For AIOS: this is a very good foundation to extend (plugins/tools/skills are the sanctioned "add capability" seam) but a risky one to gut (the turn loop, provider layer, and session store are large, interlocking, and not designed to be replaced wholesale).


1. What Hermes Is — top-level architecture, entry points, runtime model

1.1 Package identity

pyproject.toml:293-296 — name hermes-agent, version 0.18.0, description: "The self-improving AI agent — creates skills from experience, improves them during use, and runs anywhere." Every direct dependency is exact-pinned (pyproject.toml:310-330), a policy adopted 2026-05-12 after a supply-chain worm ("Mini Shai-Hulud") shipped through a range-pinned mistralai release — provider/backend-specific SDKs (anthropic, exa, firecrawl, fal, modal, daytona, telegram, discord, slack, honcho, mem0, ...) are kept out of the base install and lazy-installed on first use via tools/lazy_deps.py, specifically so one quarantined PyPI release can't break every fresh install (pyproject.toml:558-579 "Policy (2026-05-12)").

1.2 Three entry points, one class

Declared in pyproject.toml:592-595:

hermes       = hermes_cli.main:main       # CLI (hermes_cli/main.py)
hermes-agent = run_agent:main             # bare agent runtime, no CLI chrome
hermes-acp   = acp_adapter.entry:main     # Agent Client Protocol server (Zed et al.)
  • CLI: hermes_cli/main.py:main()cmd_chat() → either the TUI (ui-tui/, Node-based) or cli.py:main()'s prompt_toolkit loop. cli.py imports AIAgent lazily (cli.py:834, documented rationale: "bare interactive startup only needs the prompt").
  • Gateway: gateway/run.pyGatewayRunner (class def gateway/run.py:2668, subclasses GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, GatewaySlashCommandsMixin) owns one AIAgent per session key in an LRU cache (_AGENT_CACHE_MAX_SIZE = 128, gateway/run.py:66, eviction logic gateway/run.py:15742-15801). See §6 for the daemon/startup model.
  • ACP: acp_adapter/entry.pyacp_adapter/server.py:HermesACPAgent; acp_adapter/session.py maps one ACP session to one AIAgent (constructed at acp_adapter/session.py:656).

Load-bearing fact: all three transports construct run_agent.AIAgent(**kwargs) with different platform=/clarify_callback=/terminal-backend kwargs and then drive agent.run_conversation(user_message, ...). A fourth transport (e.g. a voice cockpit UI) is architecturally just "construct AIAgent, call run_conversation" — confirmed minimal pattern at acp_adapter/session.py:608-656.

1.3 The AIAgent class and the turn loop

run_agent.py is 5,979 lines total but is mostly a forwarder shell, not a monolith of logic (confirmed by direct read, run_agent.py:1-5979): - Module layout: docstring/imports (1-214), scaffolding helpers (224-361), _StreamErrorEvent exception (364-401), class AIAgent (403-5762), CLI main() (5763-5975). - AIAgent.__init__ (run_agent.py:426-573) is a pure forwarder: one lazy import (from agent.agent_init import init_agent, line 500) and one call passing all kwargs through. Exact parameter count, counted directly: 70 (not "60+" as the prior shallow note said). - Confirmed forwarders (2-4 line stubs delegating to agent/*.py): switch_model (798) → agent.agent_runtime_helpers; _invoke_tool (5652) → agent.agent_runtime_helpers.invoke_tool; _execute_tool_calls_concurrent/_sequential (5696/5701) → agent.tool_executor; _compress_context (5544) → agent.conversation_compression.compress_context; and critically run_conversation (5711-5734) is a 24-line forwarder to agent.conversation_loop.run_conversation — the real ~4,700-line turn function does not live in run_agent.py at all. - Real (non-forwarded) logic that does live directly on the AIAgent class: session/DB lifecycle (_ensure_db_session 600, _flush_messages_to_session_db 1725, _persist_session 1648), context-engine session transition (626, 695), credential/client construction (3916-4529), streaming plumbing (4536-4787), vision/image handling (4739-5162), and the tool-dispatch decision _execute_tool_calls (5597, picks sequential vs. concurrent).

1.4 One turn, in detail (agent/conversation_loop.py)

This is THE hot path. File is 5,245 lines; run_conversation() runs from line 518 to the return finalize_turn(...) at line 5227 — ~4,720 lines, larger than the file's own stale docstring claim of "~3,900 lines" (conversation_loop.py:1-6). Roughly 1,800 of those lines (2285-4106) are a single unhappy-path error-classification/recovery gauntlet, not sequential per-turn logic.

Numbered control flow: 1. Optional mixture-of-agents (MoA) config decoded from the message (conversation_loop.py:550-561). 2. Prologue delegated to build_turn_context() (agent/turn_context.py:119, called at conversation_loop.py:571-587): stdio guarding, retry-counter reset, message sanitization, system-prompt restore-or-build, preflight context compression (turn_context.py:337-428), the pre_llm_call plugin hook (turn_context.py:431-456), external-memory prefetch. on_session_start fires here too, but only on a genuinely new session (conversation_loop.py:361-370). 3. Codex app-server escape hatch: if agent.api_mode == "codex_app_server", Hermes's own loop is bypassed entirely for agent._run_codex_app_server_turn(...) (conversation_loop.py:624-631). 4. Main while loop (conversation_loop.py:633, bounded by max_iterations and a shared iteration_budget): a. Per-iteration housekeeping: interrupt check, budget consume, step_callback gateway event, skill-nudge counter, pre-API /steer drain (638-743). b. Message assembly (745-947): copies working messages, splices ephemeral memory/plugin context onto the current user message only (never persisted), strips internal fields, prepends the (cached) system message, applies Anthropic prompt-cache breakpoints (884-889), drops orphaned tool-result pairs, normalizes for KV-cache stability. c. Pre-API compression re-check (961-1053) — guards against a single turn ballooning between calls ("271k/272k Codex failure" cited in-comment). d. The model call (1083-1322): builds api_kwargs, fires pre_api_request hook (1184-1239), decides streaming, dispatches via hermes_cli.middleware.run_llm_execution_middleware. e. Response validated/normalized per api_mode (1342-1420) via agent._get_transport().normalize_response(...). f. Refusal handling (content-policy, 1641-1727) and truncation handling (finish_reason=="length", 1729-2029: thinking-exhaustion / stream-stall / truncated-tool-call retry (≤4) / text-continuation retry (≤4) / rollback). g. Usage/cost accounting + SQLite token-count write (2031-2240, §2 below). h. Tool-call path (4352-4717): validates/repairs tool names + JSON args, dedup/cap guardrails, appends assistant message, flushes to SessionDB before execution (4596-4607), then calls agent._execute_tool_calls(...) at line 4621 — this is the boundary into agent/tool_executor.py (§3). i. Final-response path (no tool calls, 4719-5164): layered recovery ladder for empty/thinking-only responses (partial-stream recovery → prior-turn fallback → post-tool-call nudge → thinking-only prefill continuation ≤2 → empty-response retry ≤3 → fallback-provider switch → terminal "(empty)" sentinel), else Codex ack-continuation, verify-on-stop nudge, pre_verify hook, append, break. j. Outer exception handler (5166-5221): fills orphaned tool-result placeholders, logs full traceback, apologizes near max_iterations. 5. Epilogue: finalize_turn(agent, ...) (agent/turn_finalizer.py:1, called conversation_loop.py:5226-5241) — final persistence, post_llm_call/on_session_end hooks, background memory/skill review spawn (§5).

Retry/fallback: no single fixed policy — classify_api_error() (agent/error_classifier.py, call site conversation_loop.py:2557, internals not independently traced — open question) branches into ~20 one-shot recovery paths (Unicode errors, image rejection, credential-pool rotation, per-provider 401 refresh for Codex/xAI/Vertex/Nous/Copilot/Anthropic, thinking-signature invalidation, 413/context-overflow compression ≤3 attempts, generic exhaustion → fallback chain → terminal). Backoff via jittered_backoff() (agent/retry_utils.py, base 2-5s / max 60-120s) or a provider Retry-After header (capped 600s).

Compression triggers — four distinct sites, not one, all calling the same agent._compress_context() (run_agent.py:5544) → agent.conversation_compression.compress_context() (agent/conversation_compression.py:394): 1. Preflight, before the loop starts (turn_context.py:337-428). 2. Pre-API-call re-check, every iteration (conversation_loop.py:995-1053). 3. Error-driven, on 413/context-overflow (conversation_loop.py:3072-3612, capped at 3 attempts). 4. Post-tool-execution, using the real provider-reported token count (conversation_loop.py:4680-4711).

Peer-relayed (unverified by this pass — see INBOUND-...md): the actual compressor (agent/context_compressor.py, ContextCompressor, config key context.engine) runs a 5-phase algorithm — prune old tool results → protect head/tail by token budget → LLM structured summary via an auxiliary model → iterative re-summary → reassemble with anti-injection markers — with an anti-thrash guard and a summary-failure cooldown. agent/context_breakdown.py is confirmed (by this pass) to be unrelated to triggering — it only computes a display breakdown for a /context command (context_breakdown.py:31-89, no should_compress logic found there).

Hook call sites (exact, cross-verified against hermes_cli/plugins.py's VALID_HOOKS):

Hook Site
pre_llm_call agent/turn_context.py:436 (once/turn)
pre_api_request / post_api_request conversation_loop.py:1189,1212 / :4194,4200
post_llm_call agent/turn_finalizer.py:369 (once/turn, end)
pre_tool_call agent/tool_executor.py:418-419,1037-1038
post_tool_call agent/tool_executor.py:139-140, ~10 call sites
on_session_start / on_session_end conversation_loop.py:363 (new session only) / turn_finalizer.py:494 (every turn)
pre_verify conversation_loop.py:5120

Session persistence within a turn (§ also covered in §5): system prompt written once per new/changed session (conversation_loop.py:389-398); token/cost counters per API call (2191-2207); an incremental flush before tool execution so a crash mid-tool-call doesn't lose state (4600, comment at 4596-4599); ~20+ early-return branches each call _persist_session() (28 total call sites of _persist_session/_flush_messages_to_session_db in the file); _persist_session (run_agent.py:1648) writes both a JSON transcript and the SQLite row, deduplicated via a stamp (_DB_PERSISTED_MARKER, run_agent.py:1725-1735) rather than positional slicing, specifically to survive message-sequence repairs.

Background nudges (memory/skill review) are decided during the turn but executed after it completes, in finalize_turn (agent/turn_finalizer.py:454-480), explicitly so review "never competes with the user's task for model attention" (comment at 470-471) — see §5.

Open questions from this file: agent/error_classifier.py internals unread; _should_parallelize_tool_batch description paraphrased from a docstring, not independently verified against the function body; hermes_cli.middleware's actual chain unread; TurnRetryState semantics inferred from usage, not read directly.


2. Core abstractions — tools, memory, planning, state, message/turn handling

2.1 AIAgent instance state after init (agent/agent_init.py)

init_agent(agent, ...) (agent/agent_init.py:166-1951, 1,786 lines — the module's own docstring claim of "~1,400 lines" is stale) is called from AIAgent.__init__. Same 70-parameter signature as __init__. ~164 distinct agent.<attr> = ... assignments (grep-counted). Key state by category, file:line: - Model/runtime identity: model, provider, api_mode (auto-detected, priority chain at agent_init.py:329-360: explicit api_mode → provider-name special cases → base-URL heuristics → default chat_completions), base_url, api_key. - Iteration/budget: max_iterations, iteration_budget (shared IterationBudget — a parent creates it, children inherit it). - Callbacks: 15 raw passthroughs (tool_progress_callback, thinking_callback, clarify_callback, event_callback, etc., 429-444). - Concurrency/interrupt: _tool_guardrails, _interrupt_requested, _client_lock (RLock), _pending_steer (steer-without-interrupt), _delegate_depth (0 at top level), _active_children (subagent tracking for interrupt propagation) — 447-483. - Tools: tools (via get_tool_definitions()), valid_tool_names, _tool_snapshot_generation (registry generation for staleness checks) — 1062-1093. - Prompt caching: _use_prompt_caching, _cache_ttl ("5m"/"1h") — 512-519. - Session/persistence: session_id, _session_db, _parent_session_id, _session_db_created (lazy row creation, false at init), _persist_disabled (blocks writes for background-review forks) — 1124-1203. - Memory: _memory_store (built-in MEMORY.md/USER.md, gated by config), _memory_manager (external plugin providers) — 1233-1267. Both described fully in §5. - Context compression: context_compressor, compression_enabled (default True), _compression_threshold_autoraised, rejects models below a 64K MINIMUM_CONTEXT_LENGTH (1394-1943, ValueError at 1718-1727). - Fallback chain: _fallback_chain (list of {provider, model}), _fallback_index, _fallback_activated — 1037-1046.

2.2 Turn/message handling abstraction

agent/turn_context.py (build_turn_context(), 506 lines) owns per-turn setup and returns a context object unpacked at conversation_loop.py:588-598. agent/turn_finalizer.py (507 lines, finalize_turn()) owns the epilogue. agent/tool_executor.py (1,646 lines) owns tool-call execution mechanics (§3). This is a consistent pattern across the codebase: run_agent.py and conversation_loop.py are orchestrating spines; the actual mechanics live in single-purpose sibling modules under agent/.

2.3 Planning / "skills" as procedural memory

Not a planner in the classic sense — Hermes has no dedicated planning module. "Planning" is achieved via: (a) the model's own reasoning inside one turn, (b) delegate_task for decomposing work across subagents (§3.5), and (c) skills — markdown files with YAML frontmatter, loaded on demand or auto-surfaced, living in skills/ (bundled core), optional-skills/ (opt-in catalog), and ~/.hermes/skills/ (user + agent-created). agent/skill_utils.py is the shared metadata layer; agent/skill_bundles.py adds named multi-skill bundles. Confirmed unchanged from prior shallow pass — not re-verified line-by-line in this deep pass (no fork was assigned to it).


3. Tools/capabilities — registration, invocation, extension model

3.1 Registration (tools/registry.py, 766 lines, read in full)

  • discover_builtin_tools() (tools/registry.py:58-75) AST-scans tools/*.py for top-level registry.register(...) calls and only imports matching files — registration is inspection-driven, not a hardcoded list.
  • register(name, toolset, schema, handler, check_fn=..., is_async=..., ...) (tools/registry.py:356-448) stores a ToolEntry (dataclass, 78-107) in self._tools. Re-registering an existing name under a different toolset is rejected unless override=True, itself gated by a plugin's allow_tool_override config opt-in (hermes_cli/plugins.py:417-424, raises PluginToolOverrideError if not permitted).
  • check_fn (an availability probe, e.g. "is Docker running") is lazily evaluated at get_definitions() time, TTL-cached 30s with a 60s "last-good" flake-suppression window (tools/registry.py:110-197) — a transient probe failure doesn't silently strip a whole toolset mid-turn.
  • ToolEntry.dynamic_schema_overrides (100-107, 521, 556-566): a zero-arg callable invoked on every get_definitions() call, shallow-merged onto the static schema — used by delegate_task to inject the user's actual max_concurrent_children/max_spawn_depth config values into the tool's own description text (tools/delegate_tool.py:3284-3304), so the model is never told stale defaults.

3.2 Dispatch — model call → registry → handler → result (numbered, exact citations)

  1. Model returns an OpenAI-style tool_calls entry; conversation_loop.py hands it to agent._execute_tool_calls (run_agent.py:5597), which decides parallelizability via _should_parallelize_tool_batch and forwards to agent.tool_executor.execute_tool_calls_concurrent() (tool_executor.py:306) or _sequential() (tool_executor.py:965).
  2. Both paths eventually call model_tools.handle_function_call(function_name, function_args, ...) (model_tools.py:904, call sites tool_executor.py:1406/1448).
  3. handle_function_call unwraps the Tool-Search bridge if needed (model_tools.py:950-1023), runs apply_tool_request_middleware (1026-1043), then the pre_tool_call plugin hook via hermes_cli.plugins.get_pre_tool_call_block_message() (1059-1092, defined hermes_cli/plugins.py:2049-2096) — a plugin can short-circuit here with {"action": "block", "message": ...}.
  4. ACP edit-approval guard for write_file/patch (model_tools.py:1097-1106).
  5. registry.dispatch(function_name, function_args, ...) wrapped through run_tool_execution_middleware (1137-1169).
  6. Inside ToolRegistry.dispatch() (tools/registry.py:574-600): entry lookup by name; async entries bridge via model_tools._run_async(entry.handler(...)), sync entries call the handler directly. All exceptions are caught and returned as json.dumps({"error": ...})dispatch() never raises out to the model.
  7. post_tool_call hook fires (model_tools.py:1178+); result appended as a role: "tool" message keyed by tool_call_id.

Concurrency mechanics (agent/tool_executor.py, confirmed by an earlier fork covering this file fully): fully sync/thread-based, no async/await anywhere in either tool_dispatch_helpers.py or tool_executor.py. Concurrent execution uses a custom DaemonThreadPoolExecutor (tools/daemon_pool.py) specifically because stock ThreadPoolExecutor workers are non-daemon and would block CLI exit on a wedged tool thread. max_workers = min(len(runnable_calls), 8). Results are collected into a pre-allocated, position-indexed list so output order always matches the model's original tool-call order regardless of completion order (required by the API contract). Parallelism-gating rules (agent/tool_dispatch_helpers.py:104-147): batch size >1; no clarify in the batch; path-scoped tools (read_file/write_file/patch) only parallelize on non-overlapping paths; everything else must be in an explicit _PARALLEL_SAFE_TOOLS allowlist or be an MCP tool that opted in. Asymmetry worth flagging: the sequential path has an inline elif chain that special-cases todo/memory/session_search/clarify/delegate_task/context-engine/memory-provider tools directly on the AIAgent; the concurrent path routes every tool through agent._invoke_tool uniformly — whether agent_runtime_helpers.invoke_tool re-implements the same special-casing was not confirmed (open question). No tool-call-level retry exists anywhere in either file — a failed tool call produces exactly one error-string result; retrying is entirely up to the model choosing to call the tool again next iteration.

3.3 Tool schema format

Plain-dict OpenAI function schema; get_definitions() (tools/registry.py:521-568) wraps it as {"type": "function", "function": {**schema, "name": entry.name}}. Example shape (tools/delegate_tool.py:3307-3330):

DELEGATE_TASK_SCHEMA = {
    "name": "delegate_task",
    "description": "...",
    "parameters": {"type": "object", "properties": {"goal": {...}, "tasks": {...}, "role": {...}}},
}

3.4 Toolsets — composition and platform selection (toolsets.py, 971 lines, read in full)

  • TOOLSETS: Dict[str, {"description", "tools": [...], "includes": [...]}] (95-582). resolve_toolset() (687-766) does a DFS union of tools + recursively-resolved includes with cycle protection; "all"/"*" union every toolset (712-718).
  • get_toolset(name, include_registry=True) (586-656) unions the static list with anything a plugin registered into that toolset name via registry.get_tool_names_for_toolset(name) — a plugin can extend an existing toolset (e.g. add a tool to "terminal") with zero edits to toolsets.py.
  • Platform selection: every messaging bundle (hermes-telegram, hermes-slack, ..., 447-576) = _HERMES_CORE_TOOLS (shared ~40-tool baseline, 31-80) + platform-specific extras. hermes-webhook deliberately uses a much smaller _HERMES_WEBHOOK_SAFE_TOOLS list (85-90) — an explicit security narrowing because webhook payloads can carry untrusted third-party content (prompt-injection risk).
  • The "coding" toolset (346-367) is a "posture" toolset, auto-selected per-session by agent/coding_context.py when working in a code workspace — drops messaging/tts/image_gen/spotify/home-assistant/cron/computer-use.
  • hermes-acp and hermes-api-server are hand-curated composites, not derived from the core list. hermes-gateway (577-581) is a pure includes union of every platform bundle.

3.5 Plugin extension model (hermes_cli/plugins.py, 2,286 lines, read in full)

Discovery order, later wins on name collision (1272-1424): bundled (<repo>/plugins/<name>/, 1289) → user (~/.hermes/plugins/<name>/, 1304) → project (./.hermes/plugins/<name>/, opt-in via HERMES_ENABLE_PROJECT_PLUGINS=1, 1312) → pip entry-points (hermes_agent.plugins group, 1324, 1613). Loading rule by kind: backend and bundled platform plugins auto-load (platform ones lazily-deferred, 1662-1701, to avoid eagerly importing ~20 heavy SDKs); everything else (standalone, user backends, entry-point plugins) is opt-in via plugins.enabled in config.yaml (1405-1424).

register(ctx) contract (1727): every plugin's __init__.py exposes a module-level register(ctx: PluginContext), called once at load. Minimal new-tool shape, using the real API:

# plugins/my_plugin/__init__.py
def register(ctx):
    ctx.register_tool(
        name="my_tool", toolset="my_toolset",
        schema={"description": "...", "parameters": {"type": "object", "properties": {...}}},
        handler=my_handler,            # def my_handler(args: dict, **kwargs) -> str (JSON)
        check_fn=lambda: True, is_async=False, emoji="🧩",
    )

ctx.register_tool (389-444) delegates to tools.registry.register(). ctx also exposes: register_hook, register_middleware, register_command/register_cli_command, register_skill, dispatch_tool (call other tools with parent-agent context auto-wired), and provider-registration hooks for web search, TTS, browser, image/video gen, transcription, platforms, context engines, dashboard auth, Slack actions, and auxiliary tasks — all the same "instance of ABC → registered into a side registry" pattern.

VALID_HOOKS (135-213) — the full lifecycle: pre_tool_call/post_tool_call, pre_llm_call/post_llm_call/pre_api_request/post_api_request/api_request_error, transform_llm_output/transform_tool_result/transform_terminal_output, pre_verify, session lifecycle (on_session_start/end/finalize/reset), subagent_start/subagent_stop, gateway (pre_gateway_dispatch), approval observers, kanban observers. Unknown hook names are accepted with a warning (forward-compat, 1115-1144).

A separate, simpler system — gateway hooks (gateway/hooks.py, ~/.hermes/hooks/<name>/, HOOK.yaml+handler.py) — exists only for gateway-scoped lifecycle events (gateway:startup, session:*, agent:*, command:*); use plugins for anything that also needs to run from CLI/ACP.

3.6 MCP integration (tools/mcp_tool.py)

MCPServerTask (1427-2551) is one asyncio Task per server; transport is stdio (subprocess + OSV malware preflight check, 1838-1854) or HTTP (SSE / new Streamable-HTTP / legacy Streamable-HTTP, gated by SDK version). Each MCP tool is registered into tools/registry.py with is_async=False — the sync/async bridge is instead the handler closure itself, which schedules the actual MCP call onto a dedicated background event-loop thread (_run_on_mcp_loop, 3141-3206) and polls for the result, so a user interrupt can cancel mid-call. Schema conversion (_normalize_mcp_input_schema, 3777-3914) repairs MCP inputSchema into Anthropic/Gemini/Kimi-safe JSON Schema (strips nullable unions, coerces missing type, prunes dangling required entries). Dynamic tool refresh on notifications/tools/list_changed is a diff-and-patch (only stale tools deregistered), not a full nuke-and-rebuild. Reconnection: exponential backoff (cap 60s, max 5 reconnect retries) plus a separate request-level circuit breaker (3 failures → 60s cooldown) — a dead server is parked, not orphaned, waiting for an external wake signal.

3.7 Subagent delegation (tools/delegate_tool.py, 3,445 lines, ~69% read)

Isolation model: same process, separate thread, separate AIAgent Python object — not a separate OS process. (Terminal commands the child runs get their own sandboxed process; the agent loop itself does not.) 1. Model calls delegate_task (registered tools/delegate_tool.py:3429-3445, toolset "delegation"). Depth guard: agent._delegate_depth vs. MAX_DEPTH=1 default (flat — no grandchildren unless explicitly raised, 2387-2402, 467-503). Operator kill-switch: is_spawn_paused() (2371-2375). 2. _build_child_agent() (1044-1397) constructs a fresh AIAgent on the main thread: no parent conversation history, own task_id/terminal session (platform="subagent", skip_context_files=True, skip_memory=True), a restricted toolset (DELEGATE_BLOCKED_TOOLS = {delegate_task, clarify, memory, send_message, execute_code, cronjob}, line 45-54, unless role="orchestrator" re-adds delegation under depth cap). Credentials/model/provider/fallback-chain inherited from the parent unless a delegation.provider/delegation.model config override applies. 3. _run_single_child() (1719-2317) submits child.run_conversation(...) onto a DaemonThreadPoolExecutor(max_workers=1) and blocks with future.result(timeout=...) (default: no timeout unless delegation.child_timeout_seconds is configured, floor 30s). A background heartbeat keeps the parent's activity timestamp alive during long child work. Batches fan out across DaemonThreadPoolExecutor(max_workers=max_concurrent_children) (default 3), polling with concurrent.futures.wait(timeout=0.5) so parent interrupts propagate mid-batch. 4. Result path (sync): only the child's final summary (result["final_response"]) plus telemetry (api_calls, tokens, tool_trace, cost) crosses back into the parent's context — intermediate child tool calls/reasoning never do (stated explicitly in the tool's own model-facing description). subagent_stop hook fires; child cost folds into the parent's session cost. 5. Result path (background/async, the default for the top-level agent): dispatch_async_delegation_batch() (tools/async_delegation.py:311+) runs the same aggregation closure without blocking the parent's turn; delegate_task returns immediately with {"status": "dispatched", ...}. On completion, a {"type": "async_delegation", ...} event is pushed onto tools.process_registry.process_registry.completion_queue, drained by the CLI/gateway loop (gateway/run.py:2459-2483, 14837-14847 — exact re-injection message shape not independently verified, open question) which re-injects the consolidated result as a new message on the parent's next tick. Stateless HTTP-API sessions fall back to synchronous execution since there's no channel to drain into later. 6. Cleanup (finally, 2266-2317): stops heartbeat, releases leased credentials, restores process-global tool-name state, removes from the parent's interrupt-propagation list, calls child.close() to tear down the child's own terminal sandboxes/browser daemons/HTTP clients.

Not read in this pass (flagged, doesn't affect the isolation/mechanics conclusion above): exact child system-prompt text construction, summary-budget spill-to-disk math, malformed-tasks-JSON repair, and detailed credential-pool-for-children resolution.


4. Model/provider wiring

4.1 Provider count and the two-registry wart

Prior shallow note said "18+ providers" — actual count is 30+ named providers across two independent, non-identical registries, plus unbounded user-defined providers:/ custom_providers: entries, plus a models.dev external catalog of 109+ providers layered on top by one of the two registries: - hermes_cli/auth.py:171 PROVIDER_REGISTRY — the registry actually consumed by credential resolution (hermes_cli/runtime_provider.py imports exclusively from here). - hermes_cli/providers.py:46 HERMES_OVERLAYS (34 entries) — layered over the models.dev catalog; consumed by model_switch.py, provider_catalog.py, main.py, doctor.py, chat-platform adapters. This is the display/catalog/model-picker layer, not the credential-resolution layer, despite providers.py's own docstring claiming "single source of truth... no parallel registries" (providers.py:17) — not fully true today; the two are kept in sync by hand.

4.2 Resolution flow (hermes_cli/runtime_provider.py:1509-2052, resolve_runtime_provider())

Priority-ordered, numbered: 1. Requested-provider resolution: --provider flag → config.yaml model.providerHERMES_INFERENCE_PROVIDER env → "auto" (535-551). 2. Virtual/special-case short-circuits: "moa" (mixture-of-agents stub), Azure-with-anthropic, "azure-foundry", "vertex" (bypasses the generic credential pool — Vertex auth is a service account, not a static key). 3. Named custom provider check (providers:/custom_providers: config entries, including ollama/vllm/llamacpp aliases). 4. Bare-base_url auto-route: if provider unset but a non-cloud base_url is configured (e.g. local Ollama), route straight to OpenRouter-shaped resolution — stops a stray ANTHROPIC_API_KEY env var from hijacking a local endpoint (bug #3846). 5. Canonical alias resolution via resolve_provider() (hermes_cli/auth.py) — a second, separately-maintained alias dict (_PROVIDER_ALIASES) partially overlapping providers.py's ALIASES — a real duplication risk (an alias added to one won't automatically appear in the other). 6. Explicit CLI creds (--api-key/--base-url) override everything. 7. Credential pool lookup (§4.4) for the resolved provider. 8. Provider-specific OAuth/API-key fallback branches, in order: nous → openai-codex → xai-oauth → qwen-oauth → minimax-oauth → copilot-acp (external subprocess) → anthropic (static key) → bedrock (dual-routes to anthropic_messages for Claude-on-Bedrock vs. bedrock_converse otherwise) → generic API-key-type registry entries (zai/GLM, Kimi, MiniMax, DeepSeek, Alibaba, StepFun, Arcee, GMI, NVIDIA, xAI, Copilot, xiaomi, tencent-tokenhub, opencode, lmstudio, ollama-cloud, novita, huggingface, kilo). 9. Final fallback: _resolve_openrouter_runtime(), with host-derived API-key selection.

Output: a flat dict {provider, api_mode, base_url, api_key, source, credential_pool?, ...}. api_mode — one of chat_completions / codex_responses / anthropic_messages / bedrock_converse / codex_app_server — is the real "which adapter" signal, not a class reference. This dict is what downstream code (agent init) branches on; the exact branch point inside agent_init.py that reads api_mode and imports the matching adapter module was not independently re-traced in this pass (cross-referenced from adjacent forks, not read line-by-line together).

4.3 Transport registry — the actual adapter-selection mechanism

agent/transports/anthropic.py defines AnthropicTransport(ProviderTransport), registered under api_mode="anthropic_messages" via register_transport("anthropic_messages", AnthropicTransport) (transports/anthropic.py:249-251). Every method (convert_messages, convert_tools, build_kwargs, normalize_response, extract_cache_stats, map_finish_reason) thinly delegates into anthropic_adapter.py functions. This transport-registry pattern is the likely general mechanism connecting api_mode strings to adapter modules (confirmed for Anthropic; not independently confirmed for Bedrock/Gemini/Codex transports in this pass).

4.4 Credential pools and OAuth (agent/credential_pool.py, 2,372 lines, read in full)

A credential pool is same-provider multi-credential failover, not cross-provider fallback (module docstring, line 1) — cross-provider fallback is the separate _fallback_chain mechanism in §2.1/§1.4. Each provider string gets its own CredentialPool instance holding a sorted list of PooledCredential entries. Selection strategies (config credential_pool_strategies.<provider>): fill_first (default, by priority), round_robin, random, least_used. OAuth entries are seeded per-provider (anthropic, nous, openai-codex, xai-oauth, qwen-oauth, minimax-oauth); actual refresh HTTP calls live in each adapter (anthropic_adapter.refresh_anthropic_oauth_pure, etc.) — the pool only orchestrates timing. Because several refresh tokens are single-use, there's heavy cross-process write-through syncing to avoid "refresh_token_reused" races. Failure handling: mark_exhausted_and_rotate() distinguishes terminal 401s (token invalidated/revoked → DEAD, excluded from rotation) from transient failures (429/402/generic → EXHAUSTED with a TTL — 5min for 401, 1hr for 429/default, or a provider-supplied reset_at parsed from the error text). credential_sources.py (443 lines) is not a loader — it's the removal-side registry for hermes auth remove, mapping ~9 source types to cleanup functions so removals aren't silently re-seeded on next load_pool().

4.5 Anthropic adapter (agent/anthropic_adapter.py, 2,787 lines, read in full)

Uses the official anthropic Python SDK (lazy-imported, ~220ms cold-import cost avoided until needed), floor pinned >=0.39.0, validated against ≥0.86.0 behavior in-comment. Pin in pyproject.toml:431: exact anthropic==0.87.0, called out for two named CVEs (CVE-2026-34450/34452). - Streaming: SDK-native .stream() context manager preferred by default (some Anthropic-compatible gateways are SSE-only); falls back to .create() only on a detected "stream unavailable" error. No manual SSE parsing. - Tool use: converts OpenAI-shape tool defs to Anthropic {name, description, input_schema}, strips nullable unions/top-level oneOf/allOf/anyOf Anthropic's validator rejects, merges consecutive tool-result messages (Anthropic requirement), strips orphaned tool_use/ tool_result pairs left over from context compression. - Prompt caching: marker plumbing lives in this file; the actual breakpoint-placement policy lives in agent/prompt_caching.py (119 lines) — a single strategy, system_and_3: 4 cache_control breakpoints total (system prompt + last 3 non-system messages), TTL "5m" (default) or "1h", decided purely by message position/count, not token thresholds. Entry point apply_anthropic_cache_control() called from conversation_loop.py:885. - Anthropic-specific features, all confirmed in-file: extended/adaptive thinking with a model-capability gate table; interleaved-thinking beta; a 1M-context beta deliberately withheld from native Anthropic by default (some subscriptions 400 on it) but added for Azure Foundry/Bedrock; a "fast mode" gated to Opus 4.6; vision/image source conversion; computer-use screenshot eviction (keeps only the last 3 tool-result images); citations pass-through. No PDF/document content-block builder was found in this file (open question — may live elsewhere). - Error handling: deliberately not in this file — every client is constructed with max_retries=0 specifically so the SDK's own retry (which ignores Retry-After) doesn't double-retry; real 429/500/credit handling is in conversation_loop.py's error-classification gauntlet (§1.4). - Auth: resolve_anthropic_token() — 5-tier priority: ANTHROPIC_TOKEN env → CLAUDE_CODE_OAUTH_TOKEN env → Claude Code credential file/keychain (auto-refresh) → credential-pool OAuth entry → ANTHROPIC_API_KEY env. Token-type auto-detection branches the auth header shape: OAuth-shaped tokens (sk-ant-*/JWT/cc-*) get a Bearer header plus Claude Code identity spoofinguser-agent: claude-code/<version>, x-app: cli (anthropic_adapter.py:812-822) — because "Anthropic routes OAuth requests based on user-agent and headers." This is real, in-file, verified behavior (not peer-relayed): Hermes's Claude Pro/Max OAuth path presents itself to Anthropic as the actual Claude Code CLI. Flag for any Claude-based AIOS build using a Claude Pro/Max subscription through Hermes — this is a ToS/compliance-adjacent design choice worth a deliberate decision, not an accident to inherit silently. Third-party Anthropic-protocol endpoints (MiniMax, Kimi, Azure Foundry, Bedrock) each get bespoke header/beta/auth quirks — e.g. Kimi's endpoint needs the same claude-code/0.1.0 User-Agent to be recognized as a coding agent.

4.6 Other adapters (agent/bedrock_adapter.py, vertex_adapter.py, gemini_native_adapter.py,

codex_responses_adapter.py, azure_identity_adapter.py — all read in full or near-full) - Bedrock (1,342 lines): AWS credential chain via boto3 (no OAuth); custom OpenAI↔Converse translation; normalizes back to an openai.ChatCompletion-shaped SimpleNamespace; automatic fallback from converse_stream to non-streaming converse() on IAM streaming-permission denial. A code comment (bedrock_adapter.py:439-441) says Claude-on- Bedrock should route through a separate AnthropicBedrock SDK client for full caching/thinking parity — no such call site was found in bedrock_adapter.py itself; peer-relayed (unverified by this pass) claims it exists as a genuine dual-path in anthropic_adapter.py. Flagging as unresolved rather than asserting either way. - Vertex (228 lines): architecturally distinct from the other three — it does not translate request/response payloads at all. It's purely an auth+base_url resolver (service account JSON or ADC, with an explicit multiplex guard against one profile authenticating as another's billing identity); the actual request rides the generic OpenAI-compatible chat_completions path. - Gemini native (1,017 lines): bypasses Google's OpenAI-compatible endpoint entirely ("brittle for Hermes's multi-turn agent/tool loop", in-file docstring) — raw httpx REST calls to models/{model}:generateContent, wrapped in an OpenAI-SDK-shaped facade (.chat.completions.create(...)) so the rest of the codebase can treat it interchangeably with a real OpenAI() client. Handles thoughtSignature passthrough, strict role-alternation merging, and a free-tier API-key probe that warns the user their key can't sustain an agent session. - Codex Responses (1,336 lines): stateless, pure format-conversion — no auth/client construction here at all. Notably: encrypted-reasoning-blob replay across a provider/model switch is explicitly guarded (a blob minted by one issuer 400s if replayed against another); tool-call-leak recovery detects when a model degenerates and emits a tool call as raw text instead of a structured item, forcing a retry rather than silently losing the call. - Azure identity (one-liner): keyless Microsoft Entra ID auth via DefaultAzureCredential, exposing a bearer-token callable pluggable directly into the OpenAI SDK's api_key= param.

Shared pattern across all adapters: every one normalizes its response to a SimpleNamespace mimicking openai.ChatCompletion, so conversation_loop.py only ever handles one shape regardless of provider. There is no shared tool-call translator — each payload-translating adapter (Bedrock, Gemini, Codex) hand-rolls its own conversion and its own strict-turn-alternation enforcement.


5. Persistence & memory

5.1 Canonical transcript store — hermes_state.py (5,862 lines, read in full)

SQLite, WAL mode with a graceful fallback (apply_wal_with_fallback(), 341-391 — falls back to journal_mode=DELETE only on NFS/SMB/FUSE-incompatible errors, never silently downgrades if the on-disk file already reports WAL). Schema is declaratively reconciled on every startup (_reconcile_columns(), 1288-1330 diffs PRAGMA table_info against the canonical SCHEMA_SQL and ADDs missing columns), not migrated column-by-column; SCHEMA_VERSION = 17 is retained only for non-declarative data migrations.

Core tables: sessions (40 columns — model, cost, billing, git branch/repo root, handoff state, self-referential parent_session_id FK), messages (role/content/tool_call fields, active soft-delete flag for rewind, compacted flag), state_meta (generic KV), compression_locks (race-prevention for concurrent compression attempts on the same session).

FTS5 full-text search: two standalone/inline-mode virtual tables (not content= external-content tables) — messages_fts (plain BM25) and messages_fts_trigram (tokenize='trigram', used only for CJK/substring search ≥3 CJK chars, degrades to LIKE if unavailable). Six triggers keep both in sync on insert/update/delete.

parent_session_id and compression-triggered session splitting are real, not invented (verified against the module docstring, resolve_resume_session_id(), get_compression_tip(), and a dedicated compression_locks race-prevention mechanism, corroborated by tests/test_hermes_state_compression_locks.py). Distinct from compression children: branch children (/branch-style forks) and delegate/subagent children (§3.7) also use parent_session_id but via separate marker conventions and are orphaned (not treated as compression continuations) on parent delete.

~90 read/write API functions on SessionDB cover session lifecycle, compression lock/cooldown, token/billing updates, message CRUD (including non-destructive archive_and_compact() for in-place compaction and destructive replace_messages() for /retry-/undo/compress), FTS5 search with a sanitizer against injection/quoting attacks, export, pruning/vacuum, and a cross-platform "handoff" state machine for session transfer.

5.2 Gateway session routing — gateway/session.py (2,177 lines, read in full)

Correction to the prior shallow note: SessionSource and SessionEntry are plain mutable @dataclasses, not frozen. SessionSource has far more fields than "platform+chat_id+ user_id+thread_id" — 17 fields including alt-IDs, group/guild scoping, a wire-invisible trust flag excluded from serialization.

Correction: session_key is not a hashbuild_session_key() (822-910) is a deterministic colon-joined string builder (namespace:platform:chat_type:chat_id:thread_id: participant_id). hashlib.sha256 is used only for PII redaction display text, never for the routing key itself.

Correction: sessions.json is a routing index only — its own embedded _README sentinel states this explicitly ("NOT the session list... ALL sessions live in ~/.hermes/state.db"). Read path self-heals by cross-checking each entry's session_id against SQLite and pruning stale routing entries; write path is atomic (tempfile.mkstemp + fsync + rename). Canonical transcript storage is 100% hermes_state.SessionDB.

get_or_create_session() (1436-1625): compute key → look up entry → heal compression-tip repointing → heal stale-session-in-DB cases → apply suspend/resume/reset policy → recover from DB on routing-index loss → else create fresh. Also handles crash/restart recovery (suspend_session/mark_resume_pending) and explicit /new//resume commands.

5.3 Memory — two unrelated systems (agent/memory_manager.py, 1,087 lines, read in full)

MemoryManager is a pure orchestration/routing layer with zero storage and zero retrieval logic of its own. It holds a list of pluggable MemoryProvider objects and dispatches sync_turn()/prefetch() calls to whichever is registered (Honcho, Mem0, Hindsight, RetainDB, ByteRover, Holographic, OpenViking, Supermemory — each a plugins/memory/<name>/ directory). prefetch_all() string-concatenates whatever each provider returns with no embeddings, no keyword logic, and no size cap of its own — retrieval quality is 100% delegated to whichever plugin (if any) is configured. agent._memory_manager is None if no external provider is configured in memory.provider. A dead-code note: the class has is_builtin branching for a provider named "builtin" that no actual MemoryProvider subclass implements — the module's own comments no longer describe reality here.

What most users actually experience as "Hermes memory" is a completely separate, much dumber systemtools/memory_tool.py, backing MEMORY.md/USER.md: - Format: flat freeform text entries joined by a \n§\n delimiter — not sections, just a list. - Hard character caps, not token-based (explicit design choice — "char counts are model-independent"): MEMORY.md default 2200 chars, USER.md default 1375 chars, both overridable via config.yaml. - Loaded once per session into a frozen snapshot baked into the system prompt at session start — mid-session writes update the live list + disk but do not rebuild the already-cached system prompt (preserves prompt-cache hit rate). Writes go through an atomic temp-file-and-rename under a file lock; a per-turn cap of 3 failed consolidation attempts forces the tool to stop retrying rather than loop forever. - The only bridge between the two systems: after every MemoryStore write, the write is mirrored out to whichever external MemoryManager provider is active — so Honcho/Mem0 can also learn what MEMORY.md/USER.md just learned. Nothing flows the other direction.

Prompt-injection mechanics (both systems, verified against conversation_loop.py): external- provider prefetch() context is wrapped in a <memory-context> fence and appended to the current turn's user message (memory_manager.py:336-350, spliced at conversation_loop.py:794,800-802) — ephemeral, never persisted, with a scrubber stripping any leaked fence tags from streamed output. MEMORY.md/USER.md content is injected into the system-prompt's volatile tier at session-start (agent/system_prompt.py:418-467), not per-turn.

Worked example, plugins/memory/honcho/__init__.py (verified by a follow-up pass): confirms the general shape above concretely — HonchoMemoryProvider.prefetch() reads a cached peer-representation plus a background-refreshed dialectic answer from Honcho's own API, which flows through MemoryManager.prefetch_all()build_memory_context_block() → the current turn's user message, exactly as described. Write-back goes through sync_turn() (background thread) to Honcho's add_message(). One notable gap found: memory-provider plugins are loaded through a narrower path than the general plugin system (§3.5) — a text-scan heuristic in hermes_cli/plugins.py looking for "register_memory_provider"/"MemoryProvider" strings, not the full PluginContext. And plugin.yaml's declared hooks: [on_session_end] field is dead/aspirational metadata — the actual loader reads a different key entirely and never consults hooks: for memory-provider plugins. Worth knowing before copying this file as a template: the YAML hook declaration in the existing worked examples doesn't do anything.

5.4 Cron scheduler (cron/, verified via a follow-up pass — full file reads, not skims)

Job definition is a plain Python dict (create_job(), cron/jobs.py:981-1024) — id, name, prompt, skills, model/provider/snapshots, schedule{kind: once|interval|cron}, repeat, enabled, state, next_run_at/last_run_at/last_status, deliver target, origin (platform/chat_id), enabled_toolsets, workdir.

Persistence is flat JSON, not a DB: JOBS_FILE = CRON_DIR / "jobs.json" (cron/jobs.py:66), under ~/.hermes/cron/jobs.json, deliberately per-profile (design note citing issue #4707 — a shared root would leak cross-profile credentials/config). Writes are tempfile+fsync+atomic replace, then chmod 0600. Per-run transcripts land separately at ~/.hermes/cron/output/{job_id}/{timestamp}.md, pruned to the last 50.

Scheduler tick: a plain daemon thread — while not stop_event.is_set(): tick(); wait(60s) (cron/scheduler_provider.py:166-194), not APScheduler/Celery. tick() (cron/scheduler.py:3274-3485) takes a cross-process file lock (fcntl.flock/msvcrt, non-blocking — a concurrent tick just returns) and compares precomputed next_run_at against now — croniter only computes the next run time, it doesn't evaluate the cron expression live every tick. next_run_at is advanced before execution, under the lock — converts recurring jobs from at-least-once to at-most-once across crashes. Handles one-shot grace windows and stale-catchup fast-forward so a backlog doesn't burst-fire.

Spawning a fresh AIAgent per job (run_job(), cron/scheduler.py:2325-3157): a genuinely clean slate — new SessionDB(), new session_id, no prior conversation history, skip_memory=True (explicit in-code rationale: "cron system prompts would corrupt user representations"), but the job's model/provider/toolsets/workdir are honored and the user's SOUL.md identity IS loaded (load_soul_identity=True). Identity isolation is real: platform/ chat_id are seeded as empty via ContextVars, explicitly NOT from job["origin"] — a cron job can never impersonate the user who scheduled it. disabled_toolsets always force-strips cronjob, messaging, clarify from a cron-spawned agent — it can't schedule more cron jobs or block on interactive input. Execution uses an inactivity timeout (polls every 5s, default 600s idle limit), not a wall-clock cap. Prompt assembly scans for prompt injection before use (CronPromptInjectionBlocked aborts the job rather than crashing the scheduler).

Four distinct locking layers: cross-process tick lock; a nestable jobs-store lock (in-process RLock + cross-process flock) around every load-modify-save; an in-memory dedup guard against re-dispatching an already-running job; and claim_dispatch/claim_job_for_fire (atomic claim-with-TTL, including a multi-machine CAS path for an external scheduler provider).

Lifecycle: one-shot jobs are removed after completion regardless of outcome; recurring jobs on failure simply wait for their next natural slot — no job-level retry (only the existing provider-fallback-chain retry for auth errors). Failures are always delivered to the user; successful [SILENT]-tagged runs are not.

tools/cronjob_tools.py's single cronjob(action=...) tool (create/list/update/pause/resume/ remove/run) validates schedule+prompt/script shape, scans for prompt injection, sandboxes script paths, and validates provider/base_url pairs can't be used to exfiltrate credentials; "run now" reuses the exact same execute→save→deliver→mark path as the ticker rather than a separate ad hoc runner.

Open (self-flagged by the pass that verified this): whether an external "Chronos" scheduler provider is actually implemented anywhere (scheduler_provider.py explicitly calls itself EXPERIMENTAL) vs. being a documented-but-unbuilt extension point, and whether the 4 named cron test files match the traced production code 1:1 (not diffed).


6. Deployment & runtime

6.1 Docker image (Dockerfile, 361 lines, read in full)

Debian 13.4 base, non-root (hermes user, UID 10000, overridable via HERMES_UID). s6-overlay is PID 1 (/init), replacing tini specifically because tini only reaped zombies but didn't supervise the main process/dashboard/per-profile gateways — s6-overlay does both (Dockerfile:22-89). A backward-compat shim symlinks /usr/bin/tini → /init for external orchestration templates still hardcoded to tini.

Build is layer-cached deliberately: package manifests copied and installed before source (npm install, uv sync --frozen --extra all --extra messaging --extra anthropic --extra bedrock --extra azure-identity --extra hindsight --extra matrix — note not --all-extras, to exclude RL-training and Termux-redundant extras from the production image), then frontend build (web/, ui-tui/), then the rest of the source. Anthropic/Bedrock/Azure-identity/Hindsight/ Matrix are baked into the image specifically so container users don't need runtime PyPI access for opt-in backends the containerized environment often blocks.

Immutability model: /opt/hermes is root-owned, read-only (a+rX,go-w) after build; $HERMES_HOME=/opt/data is the writable, volume-mounted data directory. Lazy-installed opt-in backends (Firecrawl, Exa, Feishu, ...) redirect their installs to /opt/data/lazy-packages (appended to the end of sys.path, so a lazily-installed package can only add modules, never shadow/downgrade a sealed core module) — HERMES_DISABLE_LAZY_INSTALLS=1 blocks the sealed venv from being mutated directly. A docker exec privilege-drop shim (/opt/hermes/bin/hermes) transparently re-execs as the hermes user when invoked as root, so files written via docker exec don't end up root-owned and unreadable to the supervised gateway process.

Entrypoint: ENTRYPOINT ["/init", "/opt/hermes/docker/main-wrapper.sh"], CMD [] — the wrapper handles arg routing (bare-exec vs. hermes subcommand vs. no-args), drops privilege via s6-setuidgid hermes, and execs so the container's exit code matches the program's.

6.2 docker-compose.yml (full read, follow-up pass)

Two services, both network_mode: host, both mounting ~/.hermes:/opt/data: gateway (command: ["gateway","run"]) and dashboard (command: ["dashboard","--host","127.0.0.1", "--no-open"]loopback-only by design, with an in-file comment explicitly warning against --host 0.0.0.0 without a reverse-proxy auth layer in front of it). Commented-out env blocks show Teams/Google Chat gateway wiring as opt-in. No EXPOSE anywhere in the Dockerfile — ports are env-configured (API_SERVER_PORT 8642 default, webhook 8644, dashboard 9119) and reached via host networking rather than published container ports.

6.3 Gateway daemon model (gateway/run.py, 20,208 lines — startup path + key sections

confirmed by direct + follow-up targeted reads) GatewayRunner (class def gateway/run.py:2668, exact line confirmed twice independently) is the daemon controller — one process, one GatewayConfig, an adapter map per platform (self.adapters: Dict[Platform, BasePlatformAdapter]), and (multi-profile mode) a second, parallel adapter map per non-default profile (self._profile_adapters, run.py:2709-2715) — so one gateway process can run multiple isolated Hermes "profiles" concurrently. A profile multiplexer flag flips agent.secret_scope.set_multiplex_active(True) (agent/secret_scope.py:9,32,63), making unscoped credential reads fail-closed — a missed migration crashes loudly instead of leaking one profile's key/config into another's request. This looks purpose-built for one host process serving multiple isolated personas (e.g. AIOS's own "personal OS" + "CurioQuest operator" split) — but it is not the default, and it was not confirmed whether multiplexed profiles get fully independent terminal/filesystem sandboxes or share one terminal.backend execution context (open question, §8).

start_gateway() (gateway/run.py:19566): records a boot fingerprint for drift detection (a live git pull under a long-running process is refused rather than silently running stale in-memory code against new disk state), then enforces a single-instance guard scoped to $HERMES_HOME via a PID file — --replace sends SIGTERM, waits up to 10s, escalates to SIGKILL, and explicitly aborts rather than proceeds if the old process still appears alive after SIGKILL (guards against two live gateways fighting over the same messaging-platform token, cited bug #19471). A --replace takeover writes a marker so the target's shutdown handler exits 0 (planned takeover) instead of 1 (which would trip systemd's Restart=on-failure into a flap loop). main() (run.py:20110-20160) tears down via os._exit(), not sys.exit(), deliberately bypassing atexit/thread-joining — a wedged non-daemon worker thread (blocked tool/LLM call) could otherwise hang interpreter shutdown forever (cited issue #53107). Together this confirms the intended production supervision model is systemd/s6-style process-manager-restarts-on-nonzero-exit.

_AGENT_CACHE_MAX_SIZE = 128, _AGENT_CACHE_IDLE_TTL_SECS = 3600.0 (run.py:66-67) — up to 128 live AIAgent instances held in memory simultaneously (one per active session key, an OrderedDict keyed by session_key, run.py:2855), explicitly to preserve prompt-caching across turns (in-file comment: without this cache, "~10x more cost on providers with prompt caching"). Eviction (_enforce_agent_cache_cap(), run.py:15741-15821) pops LRU entries once over the cap but skips entries mid-turn (matched by id() against a running-agents set) so the cache can temporarily exceed 128 rather than kill an active turn; a separate idle sweep evicts anything inactive past the 3600s TTL. Both schedule teardown on a daemon thread so a slow memory-provider shutdown never blocks the cache lock.

New finding: the s6-supervised main-hermes service (docker/s6-rc.d/main-hermes/run) is a literal no-opexec sleep infinity — kept only because s6-rc requires at least one non-empty "user" service. The real gateway process is not s6-supervised in the default compose config; it runs as the container's Docker CMD ("main program" of /init), so container-exit == gateway-exit. Per-profile gateways (multiplexing) do register as dynamic s6 services at runtime, reconciled on restart by cont-init.d/02-reconcile-profiles.

Platform adapter location — a genuine unresolved gap: gateway/platforms/ on disk only contains api_server.py, bluebubbles.py, msgraph_webhook.py, signal.py, webhook.py, weixin.py, whatsapp_cloud.py, yuanbao.py, qqbot/. Telegram/Discord/Slack/Matrix/ WhatsApp-Baileys adapter source was not located in this directory despite being referenced everywhere in config — likely inline in gateway/run.py itself or under plugins/, not confirmed. Flag if the next dig needs adapter-level detail for one of those platforms specifically.

The OpenAI-compatible API server (gateway/platforms/api_server.py, ~4,900 lines, startup section read): binds via aiohttp (API_SERVER_HOST default 127.0.0.1, API_SERVER_PORT default 8642). Refuses to start without an API_SERVER_KEY of at least 16 characters (api_server.py:4702-4727) — its own log message states "a guessable key is remote code execution," because the endpoint dispatches terminal-capable agent work. It separately warns when the server is network-accessible and terminal.backend == "local" (unsandboxed), and this warning names a real, already-happened incident:

This exact combination — the API server reachable over the network, paired with the unsandboxed local terminal backend — is the surface a prior real attack ("the hermes-0day campaign") used to write ~/.hermes/config.yaml and plant persistence (in-file comment, api_server.py:4816-4842).

This is not a theoretical risk — it's the codebase's own documented memory of a real compromise. Direct relevance to AIOS: any Hermes instance fronting a voice cockpit or remote API surface must either run a sandboxed terminal backend (docker/singularity/modal/daytona) or keep the API server strictly loopback-only behind an authenticated reverse proxy — never both "network reachable" and "local backend" at once.

6.4 Terminal execution backend abstraction (tools/environments/, confirmed via a follow-up

pass reading base.py in full plus every concrete backend module) BaseEnvironment (tools/environments/base.py, 955 lines) is an ABC: abstract cleanup(); overridable _run_bash(cmd_string, *, login, timeout, stdin_data) -> ProcessHandle (base.py:329-342, every backend must implement it); a shared template method execute() (base.py:889-935) wraps each command with a session-snapshot mechanisminit_session() (base.py:353-446) captures env vars/functions/aliases once and re-sources them before every subsequent call, so cwd/env persist across calls without a truly long-lived shell process. ProcessHandle is a Protocol that subprocess.Popen satisfies natively; SDK-driven backends with no real OS subprocess (Modal, Daytona) instead return a _ThreadedProcessHandle adapting a blocking call into the same interface via a background thread + pipe.

Resolves the open Daytona discrepancy from the initial pass: tools/environments/__init__.py's own docstring states the full list directly — "local, Docker, SSH, Singularity, and Daytona environments... Modal additionally has direct and Nous-managed modes." Confirmed six concrete backend modules: local.py (no isolation — real host subprocess), docker.py (bind-mount container), ssh.py (remote shell — isolation is whatever the remote host provides, none contributed by Hermes itself), singularity.py, modal.py + managed_modal.py (direct-SDK vs. Nous-Tool-Gateway-brokered — genuinely two implementations behind one terminal.modal_mode config key: auto/direct/managed), and daytona.py (lazy-imported only when selected). A shared FileSyncManager (file_sync.py) handles backends that don't share a filesystem with the host (SSH/Modal/Daytona); Docker/Singularity/local skip it. Selection is tools/terminal_tool.py::_create_environment() dispatching on env_type from terminal.backend in config.yaml (default "local") or the TERMINAL_ENV env override.

Isolation boundary, concretely: local = zero isolation (same OS user as the gateway process — UID 10000 hermes inside the Docker image, but the host's own user for a bare source install). Docker/Singularity/Modal/Daytona = a real container/sandbox boundary, with docker_mount_cwd_to_ workspace and docker_run_as_host_user both defaulting to False specifically to preserve that boundary (in-code comment: "passing host directories into a sandbox weakens isolation"). SSH contributes no isolation of its own — it delegates entirely to whatever the remote host provides, but it does mean the agent process and its own .env/credentials never leave the local machine.

6.5 config.yaml — full top-level key inventory (hermes_cli/config.py, DEFAULT_CONFIG at

line 904, confirmed via a follow-up pass) Path: get_hermes_home() / "config.yaml"get_hermes_home() defaults to ~/.hermes (Windows: %LOCALAPPDATA%\hermes), overridden by the HERMES_HOME env var (which the Docker image sets to /opt/data, bind-mounted from the host's ~/.hermes per docker-compose.yml). ~65 top-level sections, confirmed directly against source: model, providers, fallback_providers, credential_pool_strategies, toolsets, max_concurrent_sessions, max_live_sessions, agent, terminal, web, browser, checkpoints, context_file_max_chars, file_read_max_chars, mcp_discovery_timeout, tool_output, tool_loop_guardrails, compression, kanban, prompt_caching, openrouter, bedrock, auxiliary, display, dashboard, privacy, tts, stt, voice, human_delay, context, memory, delegation, prefill_messages_file, goals, moa, skills, curator, honcho, timezone, slack, discord, whatsapp, telegram, mattermost, matrix, approvals, command_allowlist, quick_commands, platform_hints, hooks, hooks_auto_accept, personalities, security, cron, code_execution, tools, logging, model_catalog, network, gateway, streaming, sessions, onboarding, updates, lsp, x_search, secrets, computer_use, desktop, vertex. The terminal block (config.py:1091-1183) is the actual execution-backend config: backend: "local" (the default), modal_mode: "auto", per-backend image overrides (defaulting to nikolaik/python-nodejs:python3.11-nodejs20 or its Singularity equivalent), container_cpu/memory/disk/persistent, and the two isolation-preserving False defaults noted in §6.4.

6.6 Config & secrets — env vars (categorized; representative, not exhaustive)

Note: .env.example itself could not be read directly in a follow-up pass (the permission system blocked both Read and Bash on that literal filename — reads as a filename-pattern secrets guard, not a directory-scope block); the category list below is cross-confirmed against both a direct read of .env.example (this pass) and website/docs/reference/environment-variables.md (the maintained canonical reference, used as a substitute by the follow-up pass) — the two agree on every category. - LLM provider keys (~30 providers): OPENROUTER_API_KEY, ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY/GEMINI_API_KEY, NOVITA_API_KEY, OLLAMA_API_KEY, GLM_API_KEY, KIMI_API_KEY (+ CN variant), ARCEEAI_API_KEY, MINIMAX_API_KEY (+ CN variant), OPENCODE_ZEN_API_KEY, OPENCODE_GO_API_KEY, HF_TOKEN, XIAOMI_API_KEY, DEEPSEEK_API_KEY, XAI_API_KEY, MISTRAL_API_KEY, AWS_REGION/AWS_PROFILE (Bedrock), DASHSCOPE_API_KEY — each with an optional *_BASE_URL override. Qwen uses OAuth via a local CLI login file (~/.qwen/oauth_creds.json), no key needed; CLAUDE_CODE_OAUTH_TOKEN is a distinct Anthropic OAuth path (§4.5). - Tool API keys: EXA_API_KEY, PARALLEL_API_KEY, FIRECRAWL_API_KEY, TAVILY_API_KEY, FAL_KEY, BROWSERBASE_API_KEY/PROJECT_ID, GROQ_API_KEY, ELEVENLABS_API_KEY, HONCHO_API_KEY (also requires ~/.honcho/config.json with enabled=true), DAYTONA_API_KEY, SUPERMEMORY_API_KEY, plus skill-scoped keys (NOTION_API_KEY, LINEAR_API_KEY, ...). - Terminal/sandbox: TERMINAL_ENV (backend selector), HERMES_DOCKER_BINARY (Podman-swap override), TERMINAL_*_IMAGE, TERMINAL_TIMEOUT/LIFETIME_SECONDS, TERMINAL_CONTAINER_CPU/ MEMORY/DISK, TERMINAL_SANDBOX_DIR, SSH vars (TERMINAL_SSH_HOST/USER/PORT/KEY), SUDO_PASSWORD (plaintext, explicitly flagged in-file as "trusted machines only"). - Browser automation: Browserbase (cloud, residential-proxy + advanced-stealth toggles) or local Camofox (CAMOFOX_URL, session-adoption vars). - Voice: VOICE_TOOLS_OPENAI_KEY (deliberately separate from the OpenRouter key), Groq/ ElevenLabs as alternate STT/TTS, local faster-whisper as the zero-key default. - Messaging platforms (the largest env-var block, ~250 vars across the canonical reference): every platform gets a bot-token/allowlist pair — Telegram (+ webhook-mode vars), Discord, Slack (bot + app token for Socket Mode), Google Chat (Pub/Sub pull subscription, service account scoped to roles/pubsub.subscriber on the subscription, not project-wide), WhatsApp (both self-hosted Baileys and WHATSAPP_CLOUD_* Meta Cloud API), Signal, Twilio/SMS, Email (IMAP/ SMTP), DingTalk, Feishu, WeCom, Weixin, BlueBubbles, QQ, Mattermost, Matrix (E2EE-capable), MS Graph/Teams (Bot Framework), LINE, ntfy, IRC, SimplX. GATEWAY_ALLOW_ALL_USERS=false / *_ALLOW_ALL_USERS is the default-deny gate every platform respects. - Gateway/API server: API_SERVER_ENABLED, API_SERVER_KEY (≥16 chars enforced, §6.3), API_SERVER_HOST (default loopback), API_SERVER_PORT (default 8642), WEBHOOK_ENABLED/PORT/ SECRET, GATEWAY_PROXY_URL/KEY, GATEWAY_RELAY_*. - Dashboard auth: HERMES_DASHBOARD_BASIC_AUTH_*, OAuth/OIDC client vars. - Agent behavior/security flags: HERMES_MAX_ITERATIONS, HERMES_YOLO_MODE, HERMES_IGNORE_USER_CONFIG, HERMES_SAFE_MODE, HERMES_REDACT_SECRETS (default true), HERMES_WRITE_SAFE_ROOT, HERMES_DISABLE_LAZY_INSTALLS, HERMES_ALLOW_PRIVATE_URLS (default off in gateway mode — an SSRF guard), HERMES_OAUTH_FILE. - Context compression: CONTEXT_COMPRESSION_ENABLED/THRESHOLD (default 0.85), summarizer model default google/gemini-3-flash-preview. Compression thresholds, fallback_providers, and provider_routing are explicitly config.yaml-only — no env-var equivalent exists. - Skills Hub: GITHUB_TOKEN (rate limits) or a full GitHub App identity for bot-authored PRs.


7. THE KEY QUESTION — what would it take to make Hermes the AIOS executioner?

Grounding assumption, from the vision docs: AIOS wants (a) a personal Working-OS agent + (b) a work-operator/CEO agent that runs Arijit's businesses, both reading/writing the Obsidian vault as source of truth, fronted eventually by a voice-first Jarvis cockpit, with Karpathy-wiki-style knowledge management.

7.1 Natural fits — extension points that align with the grain of the framework

  • Obsidian vault as a "memory provider" plugin, not a bolt-on. The MemoryProvider ABC (agent/memory_provider.py) is exactly the seam AIOS needs: prefetch(query) reads from the vault (grep/search/embedding, whatever Arijit wants), sync_turn()/on_memory_write() writes markdown notes back. This is a first-class, sanctioned extension point — Honcho/Mem0/ Hindsight are the existing worked examples (§5.3). Building plugins/memory/obsidian-vault/ following that exact shape is very low-risk and gets vault-as-SSOT "for free" through the existing pre_llm_call-style ephemeral injection mechanism (cache-safe, doesn't touch the system prompt).
  • Skills as the wiki/knowledge layer. Hermes's skill system (markdown + YAML frontmatter, agent-authored via /learn, curated by a background daemon) is structurally near-identical to this project's own record-knowledge/Karpathy-wiki pattern (confirmed by the prior shallow pass, §5 of hermes-docs-overview.md — "an llm-wiki skill directly analogous to this vault's own Karpathy-wiki pattern"). AIOS doesn't need to build a parallel knowledge system; it can point Hermes's skill directories at (or symlink into) the vault.
  • SOUL.md as the CEO/personal-OS identity switch. One file, loaded fresh per message, no restart, inherited uniformly by CLI/gateway/ACP (agent/prompt_builder.py:load_soul_md(), confirmed by the prior source-map pass). The "two Hermes builds" (personal OS vs. work-operator CEO) map cleanly onto two profiles (Hermes's own multi-profile primitive, confirmed in §6.3 — GatewayRunner._profile_adapters, isolated secret scopes) each with its own SOUL.md, rather than two forks of the codebase. This is a strong argument for not forking Hermes at all — running it twice, profile-separated, on the same VPS.
  • delegate_task as the CEO's "hire a team" primitive. The isolation model (§3.7 — same process, separate thread, separate AIAgent, capped depth, async-by-default with a completion queue) is a genuinely good fit for "CEO delegates to sub-agents that run parts of the business." The hard MAX_DEPTH=1 default is a real constraint worth a deliberate decision — the CEO metaphor implies the CEO's reports might themselves want to delegate (depth 2), which needs an explicit config raise and orchestrator-role opt-in (§3.7 step 2), not a code change.
  • Plugins/tools for CurioQuest-specific and business-specific capabilities. Zero core-file edits needed (§3.5) — a CurioQuest-ops plugin, a POD-business plugin, etc., are each just a plugins/<name>/ directory with a register(ctx).
  • The cron scheduler for the "proactive check-ins" and "recurring generative task" user stories the docs pass already flagged as AIOS-relevant patterns — now verified in depth (§5.4): per-job identity isolation (a scheduled task can never impersonate the user who scheduled it), a genuinely clean-slate AIAgent per job, and at-most-once execution semantics are all real, existing guarantees AIOS gets for free by using this primitive rather than building its own.

7.2 What fights the grain

  • The turn loop and provider layer are not designed to be swapped wholesale. conversation_loop.py (~4,700 lines) and the two-registry provider system (§4.1) are large, deeply interlocking, and have accumulated real inconsistencies (the auth.py/providers.py registry split; the Bedrock AnthropicBedrock-client open question) even within Nous's own codebase. AIOS should extend via the sanctioned seams (plugins, SOUL.md, memory providers, skills, toolsets), not attempt to replace or heavily patch this core — the blast radius of a change here is large and the maintainers' own doc structure (extensive per-module docstrings explaining "why extracted here") suggests they've already fought this complexity once.
  • Memory is deceptively simple by default, and that's a real gap for a "monitors everything" OS. The built-in MEMORY.md/USER.md system is deliberately dumb (flat file, char-capped, no embeddings, no ranking, frozen into the prompt at session start — §5.3). If AIOS wants Karpathy-wiki-grade recall (semantic search across a growing vault, not "load everything under 2200 chars"), that has to come from a real MemoryProvider plugin — building the Obsidian memory provider is not optional polish, it's load-bearing for the "vault as SSOT" mandate to actually mean anything at retrieval time, not just at write time.
  • The gateway is single-tenant-per-profile, not designed as a fleet-of-businesses orchestrator. Multi-profile support (§6.3) gives isolation between e.g. "personal OS" and "CurioQuest operator," but each profile is still one gateway config with one adapter map — there's no built-in concept of "N businesses, each with independent scaling/monitoring/on-call," which is closer to what the CEO metaphor implies at full scale. For an MVP (a couple of businesses) this is fine; it would need real design work to become a genuine multi-business platform. It also was not confirmed whether multiplexed profiles get independently sandboxed terminal execution or share one terminal.backend — worth resolving before running two businesses' agents on one box.
  • A real, documented prior compromise sets the security baseline for any remotely-reachable Hermes. The codebase's own API-server code (gateway/platforms/api_server.py:4816-4842, §6.3) names a specific past incident — "the hermes-0day campaign" — that exploited the combination of a network-reachable API server plus the unsandboxed local terminal backend to write ~/.hermes/config.yaml and plant persistence. AIOS's voice-cockpit ambition means some Hermes instance will eventually be reachable from outside the host; the API server refusing to start without a ≥16-char key is necessary but not sufficient — the terminal backend must also be sandboxed (docker/singularity/modal/daytona) for any Hermes instance that isn't strictly loopback-only. This is not a hypothetical hardening suggestion — it is the exact, named attack the framework's own maintainers have already had to write defensive warnings about.
  • Claude-specific compliance risk inherited silently. If AIOS's personal-OS Hermes uses a Claude Pro/Max subscription as its model backend, it inherits the Claude-Code-CLI OAuth masquerade behavior (§4.5) without any explicit opt-in surfaced to the operator — this needs a deliberate decision (use an Anthropic API key instead, or consciously accept the masquerade behavior), not a default to inherit unexamined.
  • Voice-first Jarvis cockpit is not a natural Hermes surface. Hermes's three transports (CLI, messaging gateway, ACP) are all text-first; the OpenAI-compatible HTTP API server (hermes dashboard / API server, confirmed to exist by the prior shallow pass, not re-verified in this deep pass) is the closest existing seam for a custom voice UI to talk to, but building "flashy, elegant, voice-driven cockpit" is greenfield frontend work sitting on top of Hermes via that API — not something Hermes's own UI surfaces (TUI, web dashboard) are designed to become.

7.3 Bottom line

Hermes is a strong "operating system kernel" to build AIOS's executioner on: the extension seams (plugins, skills, memory providers, SOUL.md, profiles, delegate_task) line up remarkably well with AIOS's stated shape (identity switch, wiki, CEO delegation, two builds). The riskiest part is not the core loop (leave it alone, extend around it) — it's building genuinely good retrieval on top of the vault (the memory-provider plugin) and making a conscious, documented decision about the Claude OAuth masquerade behavior before the personal-OS Hermes goes live on a Claude subscription.


8. Ranked open questions / follow-up digs

  1. [High] Does Claude-on-Bedrock actually route through a separate AnthropicBedrock SDK client for prompt-caching/thinking parity, or does it only ride the generic Converse path in bedrock_adapter.py? A comment claims the former; no call site was found in this pass. Only matters if AIOS ever runs Claude-via-Bedrock rather than direct Anthropic — otherwise low priority.
  2. [High] hermes_cli/auth.py (8,120 lines) was never read in full in this pass — only PROVIDER_REGISTRY's definition and resolve_provider()'s head were pulled via targeted reads. This is the file every credential/OAuth flow ultimately routes through. If AIOS builds anything touching auth (e.g. a custom credential source for a business's own API), read this file first.
  3. [High] agent/error_classifier.py (classify_api_error, FailoverReason) — the actual heuristics deciding "is this a context_overflow vs. payload_too_large vs. auth failure" from raw provider error text were never traced. Matters for AIOS if a custom model provider is added — its error strings need to match what this classifier expects, or failures will fall through to the generic terminal path instead of a graceful recovery branch.
  4. [Medium] Exact re-injection mechanics for completed async subagent delegations (gateway/run.py's completion-queue drain, call sites confirmed at run.py:2459-2483, 14837-14847 but the message-role/session-routing shape of the re-injection was not verified). Matters for the "CEO delegates work asynchronously and gets pinged later" pattern.
  5. [Medium] Whether agent_runtime_helpers.invoke_tool (the concurrent tool-dispatch path's sole target) re-implements the sequential path's inline special-casing for todo/memory/delegate_task/context-engine tools, or whether those tools genuinely behave differently when called concurrently vs. sequentially — a real asymmetry was found in tool_executor.py but not resolved.
  6. [Resolved] The Daytona terminal backend discrepancy is closed: tools/environments/__init__.py's own docstring and six concrete backend modules confirm all six (local, docker, ssh, singularity, modal [direct + Nous-managed], daytona) — §6.4. The earlier "five in .env.example" reading was just .env.example not surfacing every backend, not a real discrepancy in the framework.
  7. [Low] Whether there's a duplicate/conflicting Gemini thinking-config implementation between chat_completion_helpers.py and gemini_native_adapter.py (peer-relayed, unverified — flagged in INBOUND-peer-session-teardown-2026-07-04.md, not independently checked here).
  8. [Resolved] cron/ scheduler internals are now verified in depth — §5.4. Remaining minor gap: whether an external "Chronos" scheduler provider is actually implemented anywhere (scheduler_provider.py calls itself EXPERIMENTAL) vs. being a documented-but-unbuilt extension point — low priority unless AIOS specifically wants multi-machine cron coordination.
  9. [Medium, new] Whether Hermes's multi-profile multiplexing (gateway.multiplex_profiles, §6.3) gives each profile independently sandboxed terminal execution, or whether all profiles on one gateway process share a single terminal.backend context. Matters directly for AIOS's "one VPS, two Hermes personas (personal OS + CurioQuest operator)" plan — if terminal execution isn't profile-isolated, one business's agent could interfere with or observe the other's, undermining the credential fail-closed guarantee that is confirmed to exist at the secret-scope layer.
  10. [Process note, not architecture] During this research pass, several other teammate agents independently sent in their own findings on overlapping files (deployment/backends, cron, and additional memory-system detail) — folded into this doc where they filled genuine gaps and cross-checked cleanly against this pass's own independent findings (e.g. both passes independently confirmed SessionSource/SessionEntry are not frozen and session_key is not a hash). Separately, a distinct peer session ("hermes-learner") was concurrently producing output aimed at this same file path — see the provenance note at the top of this document and INBOUND-peer-session-teardown-2026-07-04.md. Worth confirming with team-lead whether the multi-agent overlap here was an intentional parallel run or a dispatch duplication, so the next deep-dive task can be scoped to avoid redundant effort (even though, in this case, the redundancy produced useful cross-validation rather than wasted work).