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-agentv0.18.0, Python ≥3.11 <3.14). Every claim below is citedfile: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.mdin 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) orcli.py:main()'s prompt_toolkit loop.cli.pyimportsAIAgentlazily (cli.py:834, documented rationale: "bare interactive startup only needs the prompt"). - Gateway:
gateway/run.py—GatewayRunner(class defgateway/run.py:2668, subclassesGatewayAuthorizationMixin,GatewayKanbanWatchersMixin,GatewaySlashCommandsMixin) owns oneAIAgentper session key in an LRU cache (_AGENT_CACHE_MAX_SIZE = 128,gateway/run.py:66, eviction logicgateway/run.py:15742-15801). See §6 for the daemon/startup model. - ACP:
acp_adapter/entry.py→acp_adapter/server.py:HermesACPAgent;acp_adapter/session.pymaps one ACP session to oneAIAgent(constructed atacp_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-scanstools/*.pyfor top-levelregistry.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 aToolEntry(dataclass, 78-107) inself._tools. Re-registering an existing name under a different toolset is rejected unlessoverride=True, itself gated by a plugin'sallow_tool_overrideconfig opt-in (hermes_cli/plugins.py:417-424, raisesPluginToolOverrideErrorif not permitted).check_fn(an availability probe, e.g. "is Docker running") is lazily evaluated atget_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 everyget_definitions()call, shallow-merged onto the static schema — used bydelegate_taskto inject the user's actualmax_concurrent_children/max_spawn_depthconfig 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)
- Model returns an OpenAI-style
tool_callsentry;conversation_loop.pyhands it toagent._execute_tool_calls(run_agent.py:5597), which decides parallelizability via_should_parallelize_tool_batchand forwards toagent.tool_executor.execute_tool_calls_concurrent()(tool_executor.py:306) or_sequential()(tool_executor.py:965). - Both paths eventually call
model_tools.handle_function_call(function_name, function_args, ...)(model_tools.py:904, call sitestool_executor.py:1406/1448). handle_function_callunwraps the Tool-Search bridge if needed (model_tools.py:950-1023), runsapply_tool_request_middleware(1026-1043), then thepre_tool_callplugin hook viahermes_cli.plugins.get_pre_tool_call_block_message()(1059-1092, definedhermes_cli/plugins.py:2049-2096) — a plugin can short-circuit here with{"action": "block", "message": ...}.- ACP edit-approval guard for
write_file/patch(model_tools.py:1097-1106). registry.dispatch(function_name, function_args, ...)wrapped throughrun_tool_execution_middleware(1137-1169).- Inside
ToolRegistry.dispatch()(tools/registry.py:574-600): entry lookup by name; async entries bridge viamodel_tools._run_async(entry.handler(...)), sync entries call the handler directly. All exceptions are caught and returned asjson.dumps({"error": ...})—dispatch()never raises out to the model. post_tool_callhook fires (model_tools.py:1178+); result appended as arole: "tool"message keyed bytool_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 oftools+ recursively-resolvedincludeswith 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 viaregistry.get_tool_names_for_toolset(name)— a plugin can extend an existing toolset (e.g. add a tool to"terminal") with zero edits totoolsets.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-webhookdeliberately uses a much smaller_HERMES_WEBHOOK_SAFE_TOOLSlist (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 byagent/coding_context.pywhen working in a code workspace — drops messaging/tts/image_gen/spotify/home-assistant/cron/computer-use. hermes-acpandhermes-api-serverare hand-curated composites, not derived from the core list.hermes-gateway(577-581) is a pureincludesunion 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.provider →
HERMES_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 spoofing — user-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 hash — build_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
system — tools/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-op — exec 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
localterminal backend — is the surface a prior real attack ("the hermes-0day campaign") used to write~/.hermes/config.yamland 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 mechanism —
init_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
MemoryProviderABC (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). Buildingplugins/memory/obsidian-vault/following that exact shape is very low-risk and gets vault-as-SSOT "for free" through the existingpre_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 ownrecord-knowledge/Karpathy-wiki pattern (confirmed by the prior shallow pass, §5 ofhermes-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_taskas the CEO's "hire a team" primitive. The isolation model (§3.7 — same process, separate thread, separateAIAgent, 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 hardMAX_DEPTH=1default 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 aregister(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
AIAgentper 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
MemoryProviderplugin — 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 unsandboxedlocalterminal backend to write~/.hermes/config.yamland 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
- [High] Does Claude-on-Bedrock actually route through a separate
AnthropicBedrockSDK client for prompt-caching/thinking parity, or does it only ride the generic Converse path inbedrock_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. - [High]
hermes_cli/auth.py(8,120 lines) was never read in full in this pass — onlyPROVIDER_REGISTRY's definition andresolve_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. - [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. - [Medium] Exact re-injection mechanics for completed async subagent delegations
(
gateway/run.py's completion-queue drain, call sites confirmed atrun.py:2459-2483, 14837-14847but 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. - [Medium] Whether
agent_runtime_helpers.invoke_tool(the concurrent tool-dispatch path's sole target) re-implements the sequential path's inline special-casing fortodo/memory/delegate_task/context-engine tools, or whether those tools genuinely behave differently when called concurrently vs. sequentially — a real asymmetry was found intool_executor.pybut not resolved. - [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.examplenot surfacing every backend, not a real discrepancy in the framework. - [Low] Whether there's a duplicate/conflicting Gemini thinking-config implementation
between
chat_completion_helpers.pyandgemini_native_adapter.py(peer-relayed, unverified — flagged inINBOUND-peer-session-teardown-2026-07-04.md, not independently checked here). - [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.pycalls itself EXPERIMENTAL) vs. being a documented-but-unbuilt extension point — low priority unless AIOS specifically wants multi-machine cron coordination. - [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 singleterminal.backendcontext. 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. - [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/SessionEntryare not frozen andsession_keyis 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 andINBOUND-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).