Aleph
Concepts

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: GatewayFlowRequestOrchestratorAgentHarnessRunnerAgentHarness. 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 response

Phase 7 (2026-04-21) migrated SubagentTool to route through Orchestrator → HarnessRunner → AgentHarness. The legacy src/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:

  1. Thin dumb-loopsrc/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).
  2. 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.
  3. Event-sourced history — every turn is a SessionEvent appended to SqliteEventStore. Replay = the append log; checkpointing is not a separate framework.
  4. Compaction as a typed directive — the LLM never frees context. The ContextBudget consumer surfaces a LoopDirective; the harness carries out CompactToFit / SplitSession mechanically.

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):

CapTerminateReason variantOutcome
Model says DoneCompletednormal
cancel token firesCancelledcooperative abort
max_iterations exceededMaxIterationshit_limit = true
consecutive_failure_capConsecutiveFailureshit_limit = true
turn_timeout on ThinkTurnTimeout { phase: "Think" }hit_limit = true
StallTracker::is_stalledStallTimeout { elapsed_ms }hit_limit = true
Verifier chain VetoVerifierVeto { ... }one extra Continue
SplitSession succeededCompletedrebinds 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.

FieldDefaultEffect on Done / hit_limit
max_iterationsunboundedDone + hit_limit = true after N Continuations
consecutive_failure_capdisabledDone + hit_limit = true after N turns with errors > executed
turn_timeoutdisabledHarnessError::StalledTurn, set hit_limit = true
stall_configdisabledStallTimeout + hit_limit = true; cross-turn watchdog
parallel_tool_concurrencySome(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::spawn calls into agent_runtime::execute_via_harness, which constructs an AgentHarness per child with HarnessDeps descended from the parent's via chain.child().
  • The child's session key is the parent's SessionKey::Main wrapped in SessionKey::Subagent { parent, subagent_id } — serialised as subagent:agent:<main_agent_id>:<subagent_id>.
  • Cancellation is cooperative through the harness's tokio_util::sync::CancellationToken parameter; each AgentHarness checks cancel.is_cancelled() at the top of every turn.
  • Sub-agent failure is collected by AgentRuntime::run and surfaced as Result<LoopRunResult, String> — cancellation is an error string, not a boolean on LoopRunResult (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 valueMechanism
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.
interruptfind_steering_target_idExecutionEngine::cancel (cancel_txCancellationTokenExecutionError::Cancelled) → the inbound router's busy/retry restarts the message as a fresh run on the same session.
queueNo 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): interrupt cancels in-flight work including any sub-agents the running session spawned. Demoting to steer when sub-agents are active needs per-session subagent detection — not wired. Operators enabling interrupt accept this today.
  • Follow-up lane (defer-until-stop, Pi followUp): steer already 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.rsTurnState, TurnStep, HarnessError
  • src/harness/agent/{think,act,guardrails,prompt}.rs — sub-step files
  • src/orchestrator/ — AgentDef + FlowSpec resolution, AgentHarnessRunner
  • src/agents/runtime.rsAgentRuntime, LoopRunResult, SubagentTranscript
  • src/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, SessionEvent log
  • src/gateway/execution_engine/{mod.rs, steering.rs, gate.rs}BusyInputMode, BUSY_INPUT_MODE_KEY, try_inject_steering
  • src/gateway/inbound_router/{mod.rs, types.rs}ChannelPolicyConfig

See Also

On this page