Files
hermes-agent/agent/AGENTS.md
T
teknium1 3272fb35aa docs: profile-scope invariant in AGENTS.md — one process serves many profiles; out-of-turn code binds its scope
Root AGENTS.md § Code Shape Rules replaces "module-level constants are fine — they cache after
_apply_profile_override() sets HERMES_HOME" (true for `hermes -p x <cmd>`, inverted under the
multiplex gateway and the Desktop/dashboard `serve` backend, where os.environ holds the LAUNCH
profile) with the invariant: a profile = home + secret scope + terminal scope, bound per profile
ACTIVITY, and every execution point with no turn on the stack binds it explicitly. Names the real
seams: gateway/run.py::_profile_runtime_scope, tui_gateway @_profile_scoped +
_session_profile_runtime_scope (+ _profile_runtime_scope_tokens, launch_profile_policy ->
set_multiplex_active), cron/scheduler_provider.py::_profile_cron_scope,
gateway/run_agent_cache.py::_run_release_in_profile_scope, tools/environments/local.py::
served_profile_child_env, agent/memory_provider.py::spawn_context_thread. Adds a routing-table row
for profiles / multiplex / secret scope.

Area AGENTS.md paragraphs, one per seam, for gateway/ (activity-not-turn binding, hooks per
profile, adapter YAML never reaches os.environ, unserved shared-ingress reported via
_note_unserved_secondary_platform + needs_attention at the single writer), tui_gateway/ (RPC
binding is home AND secret AND terminal; HOME-only is half-bound; teardown chokepoint), cron/
(per-home tick lock, ticker scope incl. pre-loop code, kanban notifier routing, worker liveness by
(pid, worker_started_at) fingerprint, descendant fence as a path), hermes_cli/ (DEFAULT_CONFIG
key <-> reader parity, service-install matrix, -p vs multiplex home binding), tools/ (check_fn
reads through get_secret and is cached per hermes_home_key, one env builder per spawn, MCP trust
per profile), plugins/ (lifecycle hooks are bound by the caller; never cache the home from
initialize()), apps/desktop/src/ (pooled serve per (connection, profile); remote topologies),
agent/ (end-of-session flush is caller-bound; set_multiplex_active gates fail-closed).

Corrects the statements the multiplex model made wrong, in the same PR: root module-constant
sentence; hermes_cli "sets HERMES_HOME before any import" (+ cli-internals.md);
ADDING_A_PLATFORM.md §2 raw os.getenv loader (now an _ENV_STEPS row through config.py::_getenv)
and §4 platform_env_map in gateway/run.py (now _PLATFORM_ALLOWLIST_ENV in pairing.py + registry
allowed_users_env); platform_registry.py "may set os.environ (guard with not os.getenv)";
cron/AGENTS.md hardcoded ~/.hermes/cron/.tick.lock; gateway-internals.md agent:main as THE key
format, ~/.hermes/hooks/, single-profile `gateway stop`, plus a new "Multiplexed profiles"
section; tools/AGENTS.md os.getenv check_fn sample; "installed per turn" wording; "one temp
HERMES_HOME" E2E wording; multi-profile-gateways.md intro lists system units, Windows tasks, s6
and the Desktop backend.
2026-09-15 10:59:22 -07:00

8.5 KiB

agent/ — AIAgent, turn loop, prompt, compression

Applies on top of the root AGENTS.md (prompt-caching invariant, facade + siblings rules).

Shape

run_agent.py is the public facade: AIAgent is assembled from mixins (agent/turn_facade.py, client_lifecycle.py, stream_delivery.py, session_persistence.py, compression_facade.py, ...). Construction runs agent/agent_init.py::init_agent; a turn is agent/conversation_loop.py::run_conversation, which AIAgent.run_conversation forwards to after taking the session turn lease (turn_facade_lease.py). AIAgent.__init__ takes ~60 parameters (credentials, routing, callbacks, session context, budget, credential pool, ...) — read run_agent.py for the list; the subset you usually touch: base_url, api_key, provider, api_mode ("chat_completions" | "codex_responses" | ...), model (empty → resolved from config/provider later), max_iterations (default 500, shared with subagents), enabled_toolsets/disabled_toolsets, quiet_mode, save_trajectories, platform ("cli", "telegram", ...), session_id, skip_context_files, skip_memory, credential_pool. chat(message) -> str is the simple interface; run_conversation(user_message, system_message=None, conversation_history=None, task_id=None) -> dict returns final_response + messages.

Agent loop (agent/conversation_loop.py + agent/turn_*.py)

Entirely synchronous, with interrupt checks, budget tracking, and a one-turn grace call:

while (api_call_count < self.max_iterations and self.iteration_budget.remaining > 0) \
        or self._budget_grace_call:
    if self._interrupt_requested: break
    response = client.chat.completions.create(model=model, messages=messages, tools=tool_schemas)
    if response.tool_calls:
        for tc in response.tool_calls:
            messages.append(tool_result_message(handle_function_call(tc.name, tc.args, task_id)))
        api_call_count += 1
    else:
        return response.content

Each phase of an iteration is its own sibling, so a change to (say) overflow handling touches one ~600-line file: turn_preflight*, turn_iteration_prep, turn_request_assembly/turn_api_request, turn_api_call, turn_api_error, turn_response_intake/turn_response_check, turn_empty_response, turn_tool_round/turn_tool_validation, turn_overflow, turn_truncation, turn_context_compaction, turn_recovery, turn_retry_state, turn_stop_gates, turn_liveness, turn_usage, turn_final_response, turn_finalizer, turn_summary. Find the phase with grep -rn "def X" agent/turn_*.py.

Messages use OpenAI format {"role": "system|user|assistant|tool", ...}; reasoning content is stored in assistant_msg["reasoning"].

Agent-level tools (todo, memory, ...) are intercepted by agent/tool_executor.py through the INLINE_TOOL_EXECUTORS table in agent/inline_tool_executors.py before handle_function_call(). Adding one: register in that table (no if name == ... chain); tools/todo_tool.py is the pattern.

Message-flow invariants (every change is reviewed against these)

  • Prompt caching must not break. Never alter past context, change toolsets, reload memories, or rebuild the system prompt mid-conversation. The system prompt is byte-stable for the life of a conversation; the ONLY context mutation is compression. Anything that must inject content mid-conversation rides a user message or tool result, never the system prompt: skill slash commands (agent/skill_commands.py) inject as a user message; subdirectory AGENTS.md hints (agent/subdirectory_hints.py) append to the tool result (head+tail truncated past _MAX_HINT_CHARS = 32_000, with a warning).
  • Strict role alternation. Never two same-role messages in a row; never a synthetic user message injected mid-loop. The one exception is /steer, delivered as a standalone user row after a tool result (assistant(tool_calls) → tool → user is legal on every provider path) — never smeared onto the already-persisted tool row, which append-only persistence would leave divergent from the live request. Cron deliveries live in their own session for this reason.
  • Context files (agent/prompt_builder.py) load from the CWD only at startup and are capped (CONTEXT_FILE_MAX_CHARS / dynamic cap from the context window / context_file_max_chars). Never load an install-tree AGENTS.md as project context (PR #64611); subdirectory hints reject paths outside the working dir so ~/.codex/AGENTS.md / ~/.claude/CLAUDE.md never mix in.
  • _last_resolved_tool_names is a process-global in model_tools.py. _run_single_child() in tools/delegate_tool.py saves/restores it around subagent execution; code reading it may see a temporarily stale value during child runs.

Compression (agent/compression_facade.py, conversation_compression.py, turn_context_compaction.py)

Manual /compress on every surface (CLI, gateway, TUI, ACP) runs through agent/conversation_compression_manual.py::compress_now (one parser for here [N] / focus / --preview / --aggressive; surfaces only parse their own argv, install after_messages and render).

Two layers: gateway session hygiene (85% threshold) and the agent ContextCompressor (50%, configurable; per-model overrides; failure cooldown after provider-proven overflow). The algorithm prunes old tool results first (no LLM call), then picks boundaries, then generates a structured summary with the auxiliary compression model. In-place compaction keeps a single stable session id; native Responses/Codex compaction paths are provider-specific. Compression is the sanctioned cache break — keep it the only one. Full detail: website/docs/developer-guide/context-compression-and-caching.md.

Model and provider resolution

  • Runtime provider/model resolution and its precedence: website/docs/developer-guide/provider-runtime.md. Provider profiles are plugins (plugins/model-providers/<name>/, see plugins/AGENTS.md); agent/model_metadata.py holds context lengths and capabilities.
  • Auxiliary (side-LLM) work — curator, vision, embedding, title generation, session_search, compression — resolves through agent/auxiliary_client.py::_resolve_auto_route; each task can pin its own provider/model/base_url/reasoning_effort under auxiliary: in config.yaml.
  • Fallback models and credential pools are resolution-chain code: E2E them with real imports against a temp HERMES_HOME, not mocks (root rubric).

Memory, context engines, curator

agent/memory_provider.py (ABC) + agent/memory_manager.py (orchestrator) drive memory-provider plugins; agent/context_engine.py drives context-engine plugins; agent/image_gen_provider.py image-gen plugins (all in plugins/AGENTS.md). agent/curator.py + curator_backup.py implement the skill curator (skills/AGENTS.md). Cron sessions pass skip_memory=True by default — memory providers intentionally do not run during cron.

  • End-of-session memory extraction and provider on_session_end run wherever the session ends — turn, eviction, shutdown, tui_gateway teardown — and the CALLER binds the owning profile's scope first (_run_release_in_profile_scope, _session_profile_runtime_scope); the agent never derives its home from os.environ at flush time (Path(_session_db.db_path).parent is the ground truth). Provider background work starts through memory_provider.py::spawn_context_thread (copies the contextvars), never a bare threading.Thread; title_generator.py is the shape.
  • agent/secret_scope.py::get_secret fails closed (UnscopedSecretError) only after set_multiplex_active(True); the gateway, cron, migrate and serve set it. A new multi-home host must too, or every guard is silently off. Isolation is BETWEEN profiles; children inherit via copy_context; a child's UnscopedSecretError is a spawn-site bug, never grounds for an os.getenv fallthrough. Delegated children carry delegation_context.py:: DELEGATED_CHILD_ENV_MARKER valued as the fenced Kanban board root, not a bare flag.

Tests

Loop/phase tests go in tests/agent/; patch the binding the phase actually reads (siblings often from run_agent import X inside the function — root "patch where production reads"). Assert message-shape invariants (alternation, byte-stable system prompt) rather than snapshotting prompt text.

Long-form: website/docs/developer-guide/agent-loop.md, prompt-assembly.md, context-compression-and-caching.md, provider-runtime.md, session-storage.md, subagent-lifecycle-api.md.