Agent Runtime
Orchestrator, AgentHarness (Think→Act), SubagentTool delegation, and per-channel busy-input policy.
The agent runtime is the path between an inbound chat request and a stream of LLM
turns: Gateway → FlowRequest → Orchestrator → AgentHarnessRunner →
AgentHarness. The loop itself lives in src/harness/; prompt assembly in
src/thinker/; delegation in src/agents/; sub-agent execution re-uses the same
AgentHarness so a child session is structurally identical to a top-level run.
Topology
Gateway (protocol adapters, inbound routing)
│ FlowRequest { agent_id, input, tool_service, trace_sink, identity, ... }
▼
Orchestrator (resolve AgentDef + FlowSpec, build HarnessDeps, dispatch)
│ AgentHarnessRunner::run
▼
AgentHarness (Think→Act loop, stop-hooks, context budget, compaction)
│ uses
├── SessionService (append-only history)
├── ToolService (tool catalog + execution)
├── Sandbox (exec environment, capability ledger)
└── AiProvider (LLM — failover wrapped inside `provider`)
│
▼
FlowOutcome → Gateway renders responsePhase 7 (2026-04-21) migrated
SubagentToolto route throughOrchestrator → HarnessRunner → AgentHarness. The legacysrc/agent_loop/path has been deleted; every agent run — top-level or sub-agent — uses the same harness.
Design Principles
The runtime is built around four R10/R11-driven commitments:
- Thin dumb-loop —
src/harness/schedules Think→Act turns. It does not classify intent, score tool relevance, judge completion, or pick recovery strategies. All of that lives in the LLM's single inference call (src/harness/CLAUDE.md/docs/reference/HARNESS_PHILOSOPHY.md). - Tool-side resource claims — concurrency comes from each tool declaring
Shared/Exclusive{Global, Paths}resource claims; intra-claim-group parallel, inter-group serial. No task-dependency graph. - Event-sourced history — every turn is a
SessionEventappended toSqliteEventStore. Replay = the append log; checkpointing is not a separate framework. - Compaction as a typed directive — the LLM never frees context. The
ContextBudgetconsumer surfaces aLoopDirective; the harness carries outCompactToFit/SplitSessionmechanically.
The Loop
AgentHarness::run is an async fn that loops on run_turn_internal until the
state machine reports Done or a cap trips. Termination sites (every one of
them writes the granular TerminateReason):
| Cap | TerminateReason variant | Outcome |
|---|---|---|
Model says Done | Completed | normal |
cancel token fires | Cancelled | cooperative abort |
max_iterations exceeded | MaxIterations | hit_limit = true |
consecutive_failure_cap | ConsecutiveFailures | hit_limit = true |
turn_timeout on Think | TurnTimeout { phase: "Think" } | hit_limit = true |
StallTracker::is_stalled | StallTimeout { elapsed_ms } | hit_limit = true |
Verifier chain Veto | VerifierVeto { ... } | one extra Continue |
SplitSession succeeded | Completed | rebinds to child session |
hit_limit is set under the precision complement terminate_reason so callers
that only look at the boolean keep working. After any cap, a grace_turn
(fire_boundary_grace_turn with GRACE_TIMEOUT_BUDGET = 30s) lets the model
produce a clean terminal summary before exit; the grace turn is fire-and-forget
on its own budget so a hung provider can never block shutdown.
Per-turn record (src/harness/trait_def.rs):
#[must_use]
pub struct TurnStep {
pub state: TurnState, // Continue | Done
pub executed: usize, // successful tool calls this turn
pub vetoed: bool, // verifier forced Continue
pub split_child: Option<SessionId>, // SplitSession rebind target
}
#[must_use]
pub enum TurnState { Continue, Done }Drop of a TurnStep silently loses the loop-control signal, so both types
carry #[must_use]. The 4-tuple of (state, executed, vetoed, split_child)
replaced an anonymous positional tuple that had accrued the same four fields by
successive bolt-ons.
Hard and soft caps (HarnessDeps)
AgentHarness::new(deps: HarnessDeps) carries the cap configuration. None of
these are mandatory; defaults match the production gateway wiring.
| Field | Default | Effect on Done / hit_limit |
|---|---|---|
max_iterations | unbounded | Done + hit_limit = true after N Continuations |
consecutive_failure_cap | disabled | Done + hit_limit = true after N turns with errors > executed |
turn_timeout | disabled | HarnessError::StalledTurn, set hit_limit = true |
stall_config | disabled | StallTimeout + hit_limit = true; cross-turn watchdog |
parallel_tool_concurrency | Some(8) | Cap on intra-batch join_all |
turn_timeout deliberately bounds only Think — Act runs under each tool's
own wall-clock budget, surfaced as a recoverable ToolError::Timeout rather
than a stalled-turn error (Phase 3 wage-clock dissolution, June 2026). The
turn-level stall watchdog and the tool-level execution budget are separate
instruments with different authors; mixing them would let a slow human
approval kill a run.
Sub-Agent Delegation
The top-level agent spawns sub-agents via SubagentTool
(src/agents/subagent_tool/). Sub-agent execution is the same AgentHarness
the gateway chat path uses — only the wiring differs:
SubagentTool::spawncalls intoagent_runtime::execute_via_harness, which constructs anAgentHarnessper child withHarnessDepsdescended from the parent's viachain.child().- The child's session key is the parent's
SessionKey::Mainwrapped inSessionKey::Subagent { parent, subagent_id }— serialised assubagent:agent:<main_agent_id>:<subagent_id>. - Cancellation is cooperative through the harness's
tokio_util::sync::CancellationTokenparameter; eachAgentHarnesscheckscancel.is_cancelled()at the top of every turn. - Sub-agent failure is collected by
AgentRuntime::runand surfaced asResult<LoopRunResult, String>— cancellation is an error string, not a boolean onLoopRunResult(src/agents/runtime.rs:28-37).
Main Agent (claude-opus-4) — runs via AgentHarness (Gateway chat)
│
├── Translator Sub-Agent (claude-haiku)
│ Session: subagent:agent:main:translator
│ Runtime: AgentHarness (via Orchestrator dispatch)
│
├── Code Reviewer Sub-Agent (claude-sonnet)
│ Session: subagent:agent:main:code-reviewer
│ Runtime: AgentHarness (via Orchestrator dispatch)
│
└── Research Sub-Agent (gpt-4o)
Session: subagent:agent:main:researcher
Runtime: AgentHarness (via Orchestrator dispatch)AgentRuntime is the only layer above AgentHarness for sub-agent runs; it
owns lifecycle tracing (SubagentStart / SubagentStop observer hooks),
transcript persistence, and the descended ChainContext. It does not own the
loop, the budget, or the recovery strategy — those stay on the harness.
Busy-Input Policy (Mid-Run Messages)
When a message lands on a session whose AgentHarness::run is already running,
the gateway's busy branch selects one of three policies. The policy is
explicit per channel (R7 — never inferred from message content) and stored
on ChannelPolicyConfig.busy_input_mode as a wire string, stamped into run
metadata under BUSY_INPUT_MODE_KEY. Absent on Panel/CLI paths → Steer.
| Wire value | Mechanism |
|---|---|
steer (default) | steering::try_inject_steering injects into the live event log; the running loop consumes it at its next turn boundary. Backpressure at MAX_PENDING_STEERING = 16; reconcile-preamble coalescing. |
interrupt | find_steering_target_id → ExecutionEngine::cancel (cancel_tx → CancellationToken → ExecutionError::Cancelled) → the inbound router's busy/retry restarts the message as a fresh run on the same session. |
queue | No mid-loop injection. The inbound router's per-session FIFO holds the message and delivers it as a fresh run after the current one finishes, bounded by a queue deadline. |
Opt a channel into a non-default mode via its config block:
[channels.ops-bot]
busy_input_mode = "interrupt" # default is "steer"Reference parity: hermes exposes HERMES_GATEWAY_BUSY_INPUT_MODE, openclaw
exposes QueueMode, Pi exposes streamingBehavior; Aleph previously hardcoded
Steer.
What's NOT wired today
- Subagent-aware demotion (hermes
#30170):interruptcancels in-flight work including any sub-agents the running session spawned. Demoting tosteerwhen sub-agents are active needs per-session subagent detection — not wired. Operators enablinginterruptaccept this today. - Follow-up lane (defer-until-stop, Pi
followUp):steeralready lets the model choose when to address an interjection; a separate defer-until-stop queue would add loop-touching drain logic.
Code Locations
src/harness/—AgentHarness(Think→Act driver, 12-file budget ratcheted)src/harness/trait_def.rs—TurnState,TurnStep,HarnessErrorsrc/harness/agent/{think,act,guardrails,prompt}.rs— sub-step filessrc/orchestrator/— AgentDef + FlowSpec resolution,AgentHarnessRunnersrc/agents/runtime.rs—AgentRuntime,LoopRunResult,SubagentTranscriptsrc/agents/subagent_tool/— SubagentTool (spawn, parse, loop_tool)src/agents/registry.rs,src/agents/teammates.rs,src/teams/,src/group_chat/— multi-agent surfaces (kept separate per P5 retraction)src/session/—SessionService,SqliteEventStore,SessionEventlogsrc/gateway/execution_engine/{mod.rs, steering.rs, gate.rs}—BusyInputMode,BUSY_INPUT_MODE_KEY,try_inject_steeringsrc/gateway/inbound_router/{mod.rs, types.rs}—ChannelPolicyConfig
See Also
- Agent Components — module map of the loop
- Orchestrator — AgentDef + FlowSpec dispatch
- Subagent Tree RPC — live subagent tree
- Tool Infrastructure — resource claims
- Harness Architecture — thin-harness philosophy
- Redlines R10 / R11 — the five "Don'ts"
MCP Integration
External MCP servers, transports, capability gates, on-demand discovery, and longest-prefix routing for resources and prompts.
Agent Components
Module map of the Think→Act loop — where the 12 Harness modules live, how they cooperate, and why there is no monolithic `components` directory.