Map: exarch / agent
agent.rs is the turn driver. An Agent is the uniform node of a run: the
canonical event log (AgentLog), the persistent
`Shell` behind its own IdentityTransport (the
canonical turn and probe vocabulary,
enquiry-channel), the agent
Capabilities, its own inbox, nudger, cancel::Token, an owned
hot-swappable ProviderHandle, and the inherited
interactive flag. What every node shares — the registry, the one
`FleetBus`, and the transport Engine — lives on the
thin `Fleet`, not on the node. There is no
Session, no is_root: every distinction reduces to position in the tree,
read from parent: Option<AgentId> together with interactive and the
registry’s own engagement state. Output
caps are fixed agent/digest.rs constants, not per-agent state.
The trunk is the parent-less node (parent = None). An interactive node
built to converse — the interactive trunk, and every /branch tab
(branch-minimal) — withholds reply and
parks for its human, but both behaviours fall out of construction and position,
never an is_root branch:
returns(a) // fixed at construction: fork true, branch false, trunk ¬interactive
conversing(a) = a.interactive ∧ ¬returns(a)
park_mode(a) = Quiesce if conversing(a) ∧ ¬registry.is_live(a) // /clear or /close reaped it
Held if conversing(a) // immune to cancellation
Engaged if a.parent ∧ returns(a) ∧ registry.engaged(a) // parks, but a terminate cause still ends it
HeldByChildren if a has live descendants
UntilCancelled if a.schedules.armed()
Quiesce otherwise
returns (agent.rs) is a construction-fixed field — one bit read by
returns(), parking’s conversing predicate, the desk’s reply refusal
(builtins), and the prompt’s per-agent builtin index
(resolved at Agent::assemble from the same bit, alongside allow_schedule
for the self-wakeup family, so no agent is shown a verb the desk would
certainly refuse) — the single source of truth, so the nudge layer, parking,
reply availability, and the advertised vocabulary cannot disagree
(reply-terminates-returning-agents).
park_mode (agent.rs, returning a ParkMode of Held / Engaged /
HeldByChildren / UntilCancelled / Quiesce, bus.rs) is the should_park
verdict: a conversing node parks Held, immune to cancellation; a returning,
parented agent the registry has recorded a human exchange with parks
Engaged, the same wait except a terminate-class cause still ends it, since
an exchange is not a conversation — unless the registry no longer lists it,
since an unlisted conversing node is unreachable and parking it would be a
zombie; live descendants hold it
until their results drain; a live self-schedule holds it until cancelled;
otherwise it terminates at quiescence — the one-shot contract a headless trunk
and a settled sub-agent both satisfy. --chat builds the trunk with no system
prompt and no tool at all (tool_enabled: false) — a bare conversation, the
same drive loop.
The drive loop
Three nested loops, the same for trunk and child alike:
drive— the per-agent lifetime. The trunk publishes its sticky cancel token for the OS-signal path (cancel::publish, held for the whole drive, replacing the old per-turnmint_root); a sub-agent publishes nothing, since its token is reached through the registry cascade, not the slot. Each pass pulls the next turn from this agent’s inbox vianext_or_idle(|| self.park_mode(), …), which re-evaluates the park verdict on everyCondvarwake — so the idle lease’s terminate-cause cancel, or the last live child settling, is seen on the very next wake. A genuine turn boundary resets the nudge latches and clears the sticky cancel token (cancel::Token::reset), so a prior turn’s Esc cannot bleed into the next; a self-nudge is the same turn continuing and resets neither.replyhard- terminates the loop regardless of focus or a self-armed schedule — the agent returns its value and is gone. At the single exit the loop winds a stranded prompt back throughquiesceso the nextappend_useris always admissible (turn-ends-ready); the trunk then deregisters itself (a child is removed by its spawn site throughsettle), so the fleet empties when the last agent leaves. On a caught worker panic (pump→Ok(None)) it rebuilds the live shell’s dynamic context from thedurable: Mobilesnapshot the worker refreshed at the last clean tool-call boundary, rolling the panicking call’s grant/env/cwd/handler effects back while completed calls’ bindings survive (panic-recovery; the IO half is core’sTurnGuard, restored when the turn unwinds — a turn, end to end).apply— one provider round-trip loop over the agent’s own provider (self.provider.current(), read once at the top of the turn so a/modelswap lands next turn, never mid-turn): render the transcript, stream a reply throughprovider.complete, admit then append the assistant message, dispatch any tool calls, append their results, optionally append a drained steering prompt, repeat until the model emits no tool call. The admission step (admit_assistant, at the commit boundary) enforces the transcript-admission invariant: it repairs a non-object tool-callfn_argumentsto{}(X2) and substitutes a stub for an otherwise-empty assistant message (X7), so every committed message serialises to a request a strict backend accepts.StopReason::MaxTokenswith no captured tool call raisesProviderError::Truncatedafter appending the partial, so acontinuenudge keeps the work as context; with captured tool calls it dispatches them and continues instead, since returningTruncatedthere would strand the protocol inAwaitingToolResultsand fail the nudge’s nextappend_user(X6). A hardMAX_STEPSceiling (250) ends a turn whose model never stops calling tools — the headless/autonomous counterpart to interactive Esc — returningTurnOutcome::Capped. That outcome matches no nudge rule, so the driver treats it as terminal; re-driving would only spend the ceiling again.dispatch— runs the turn’s tool-call batch in order. Every call returns aSessionToolResultsynchronously — and there is onlyralto call (tools); a spawn verb inside it hands the script a start receipt after launching the detached child. Once every requested tool id has a result, dispatch drains this agent’s inbox’s tool-boundary steering. A non-slash steering prompt is appended after the complete tool-result batch, and the next loop asks the provider with the user’s steering in context (tool-boundary-steering). A sub-agent has no human writer, so its inbox holds no steering and this is always empty.
Worker-reap and large-binding notices need no drain at drive’s top:
core’s own engine pushes both as `notice surface classes at the ready
boundary of the turn that produced them
(enquiry-channel), decoded by
shell-eval’s decode_surface into Kind::Notice at
the emit seam. A reap notice names a worker removed by policy — the lease
chain’s idle or backstop bound on a running worker, or the retention sweep
expiring a settled entry’s unclaimed result — rather than one an eliminator
observed away. Transcript and TUI only — the rendered one-liner is
cards’s reap_card, the completion card’s sibling — never
model-facing, since delivery of a reap to the model itself is deferred.
What drive’s
top still runs, each pass its own ready boundary: reconcile_service_pins
(the protected services pin is (re-)born or dies here), reap_bindings
(below), and check_disk_warn.
The retention clock is the agent’s ral-call epoch (Agent::ral_epoch):
incremented once at the top of every run_shell call — a failed eval is
still a call — and swept into the registry right after the evaluation
returns (Shell::advance_worker_epoch with
shell-eval’s SETTLED_WORKER_RETENTION), which
stamps entries first observed settled and expires the unclaimed. The counter
starts at 0, a fork’s child starts its own at 0, and /clear does not
rewind it — the cleared registry is empty anyway, and a monotone counter is
the one the binding-lease ledger’s own committed-turn clock coincides with
one-to-one (decisions/260629_agent-binding-reaping) — two ticks of the
same drum, read by two different ledgers. Retention notices need no
plumbing of their own: they ride the same drain above.
The binding-lease ledger is armed at the same two places that mint the
first durable MobileSnapshot — Agent::assemble (the trunk, every fork,
and for_test) and Agent::replace_shell (/clear) — each calling
Shell::arm_binding_lease with shell-eval’s
BINDING_IDLE_CALLS (256) and LARGE_BINDING_BYTES (1 MiB) right after
seed_session_dir and right before shell.mobile_snapshot(), so seeding,
arming, and checkpointing stay one visible sequence. The large-binding
residency nudge rides the pushed `notice channel above.
Agent::reap_bindings, called at the drive loop’s top, keeps the prune half
host-called: Shell::prune_idle_bindings prunes idle top-level names and
hands back the post-prune MobileSnapshot (a snapshot cannot ride the
surface seam), which the agent adopts as Agent::durable in the same
statement it emits one compact Notice::Prune Kind::Notice naming what
fell — transcript and TUI only, the same posture as a reap notice. The
verb’s signature pairs the notices with the checkpoint, so a later panic
rollback can never resurrect a name this pass just pruned.
/resources is the probe fold over the same accumulators
(probe-convention): routed exactly as
/clear — an InboxMsg::Command drained at the turn boundary, handled by
the TUI’s Control against the agent the drive loop owns —
Agent::resource_rows surveys what this thread may legally read — the worker
registry’s running/settled split with the nearest time-to-reap and the
binding-ledger figures read as data through the transport’s Enquiry desk
(probe_workers and its sibling probes,
enquiry-channel), plus inbox depth per
source, the event log’s mirror length and history bytes, log-dir and scratch
disk walked at invocation, and the sub-agent idle lease as two rows (nearest
time-to-reap, and the demote threshold) — and
emit_resources posts one Kind::Resources
carrying the raw rows beside their rendered card — the Kind::Io pairing,
so transcript.jsonl records the figures. The frontend appends the rows
for the accumulators it owns (viewports, views, the bus) at render time;
neither half reaches across a thread. Probing never mutates and never
renews a lease — enumeration is not observation — and the fold is never
model-facing.
The inbox’s per-source depth is a real quota now, not just a probe figure.
Mailbox/Inbox::push return
Result<(), InboxReject> from one shared rule (Shared::try_push), split
by source: the three idempotent sources (user, schedule, nudge)
always succeed, coalescing instead of growing the queue — a
ScheduledWakeup replaces a still-queued wakeup for the same schedule id
(newest wins), consecutive UserSteering pushes merge with a blank line
(never across a slash line, which would silently change its
turn-boundary classification), and an exact-duplicate Nudge is a no-op.
The other four (AgentResult, AgentMessage, Command, Surface) are
quota-checked against INBOX_SOURCE_CAP (64) and the shared
INBOX_TOTAL_CAP (256) and rejected, never dropped, once full — every
producer surfaces the rejection to its own caller: AgentRegistry::message
returns MessageError::RecipientInboxFull (the message builtin reports it),
a rejected slash command reports through the UI’s error line, and a
rejected spawn completion or surfaced batch — which has no synchronous
caller left to return to — records straight to the durable
transcript.jsonl instead of the live bus, so holding the rejection report
never extends a bus sender’s lifetime past the turn that queued it.
The headless-completion gate is gone with expect_action: the one role flag
that did not fit the parent collapse is dropped, not relocated. The nudges that
remain — must_reply for a returning agent (returns()), continue on
truncation, empty/early-stop repair, the one-shot latch that turns a headless
root’s first reply back for self-verification before honouring the next,
and the one pinned-state reminder — are
driven off the same react rule. Live descendants make the agent wait:
must_reply is suspended, and pin/no-pin reminders wait too, since the agent has
already delegated the next actionable fact. Once the descendants settle, the
rules resume against the still-live pin register. The pinned-state reminder is
uniform for every pin kind (tasks, goals, any other pinned state alike) and
every actionable agent role: budget-free while anything is pinned, independent
of and additive with must_reply — a returning agent that finishes without
replying while it still holds pinned state is nudged for both after its
children have landed. Exhausted transport and rate-limit failures are provider
facts, so they surface as Kind::ProviderError and do not post a model-visible
self-nudge.
The Fleet
fleet.rs is the thin run-as-a-whole: { agents: AgentRegistry, bus: FleetBus, engine: Arc<provider::Engine> }. It owns no turn logic — the trunk
and each child drive themselves; the fleet is only where the frontend reads “all
live agents” and “the bus to drain”. The
frontend (`tui::run` / headless::run) builds it from
handles the trunk already minted at construction, so fleet and nodes never
disagree about what is shared.
- The fleet is alive while the registry is non-empty — the literal “dies
when no active agents
remain”. An agent removes itself at termination (
reply, quiescence, cancel); a conversing node stays until/quit(or/close) because it parks; a headless trunk leaves at quiescence; a sub-agent leaves on settle. There is no human-less daemon: nothing lingers without a present human, running work, or a bounded self-schedule. - The idle lease is dynamic; focus is not. A leased child
(
Registration::lease, armed only for a returning sub-agent) is reaped once its idle span — measured off the registry’s last-human-exchange clock, seeded at birth — exceeds its bound: the reaper (fleet/registry.rs’slease_fire) re-arms itself for the remaining margin on every fire that finds the entry still live and under bound, and cascades the subtree withCancelCause::Deadlineonce it is not. The one thing that renews the clock is a delivered human message (AgentRegistry::steer); nothing else does — not the TUI’sTABcursor, a plain, presentation-onlyAgentIdlocal to the frontend (tui::tabs::Tabs::focus) that neither the registry norpark_modeever reads, not the model-facingmessagebuiltin, not a/resourcesprobe. A returning agent’sreplycancels its proper descendants and ends it even mid-conversation, regardless of which tab the human’s cursor sits on.
Cancellation cascades the subtree, across both layers
The single cascade serves the deliberate teardowns — agent-cancel, a
returning agent’s reply, and the /clear / /close subtree reaps.
AgentRegistry lives in fleet/registry.rs; an entry’s name is its
identity — unique among live entries, enforced at register
(RegisterError::NameTaken; the trunk holds TRUNK_NAME) and the handle the
message/agent-cancel verbs resolve descendants by. Each entry carries a
parent link, so the registry is the
spawn tree: AgentRegistry::cancel(id) walks descendants and cancels the whole
subtree, cancel_descendants(root) abandons a returning agent’s children without
advancing the global generation, and clear_subtree(root) reaps a subtree and
bumps the generation, so a late result or deferred surface batch from a cleared
generation is still dropped. Each cancelled node is stopped across both
layers: its cooperative Token (read by the drive loop between steps and
raced by the provider’s mid-stream cancel) and its own session’s durable root
(eval_root: Option<DurableRoot> on the entry, minted from
Shell::cancel_handle at registration), so a ral eval already in flight
unwinds at the evaluator’s poll points instead of grinding to its
timeout_secs wall. The trunk registers no eval-root — its session outlives
any cancel; Esc reaches its turn through the published foreground slot
(per-agent-eval-cancel,
cancellation). Esc / Ctrl-C, by contrast, are a
per-tab turn interrupt, not a cascade: they stop only the focused agent’s
current turn (AgentRegistry::interrupt(id), or cancel::raise_interrupt on
the trunk), leaving its descendants running
(cancel-per-tab); the focused agent’s
sticky token is cleared at each turn boundary (Token::reset).
Cancelling eval_root already reaches a cancelled node’s own detached ral
workers with no edge of its own: a worker’s cancel scope is a child of its
shell’s durable root, and every CancelScope::is_cancelled walks its
ancestors. What the cascade does not reach is a node that ends without
ever being cancelled — the ordinary reply/settle path, or the trunk’s own
end-of-drive deregister — since neither touches the registry’s cascade
primitive at all. Agent’s Drop closes that gap in one place: it cancels
every worker still registered on its own shell and clears its own armed
schedules unconditionally, the same law clear already applies explicitly
below, so a settled-but-never-cancelled agent leaks neither
(residency, session-ledger).
The provider is per-agent and hot-swappable
ProviderHandle is owned by the Agent, not threaded through drive’s
parameters. drive reads self.provider.current() once per turn. /model swaps
the focused agent’s handle directly on the UI thread (via the registry), so a
swap on one agent never disturbs another. fork seeds the child’s own handle
from the parent’s current provider (ProviderHandle::new(self.provider.current())),
so the child inherits the model in force at spawn and may diverge afterward.
Lifecycle: clear, compact, fork
clear rebuilds the focused agent without carrying cancellation residue forward:
it obtains a fresh shell from boot_root_shell (the scratch-seeding wrapper
around bootstrap::boot_shell, where stale-interrupt discard and cancel
re-chaining live), truncates and restarts the event log, clears the schedule
registry, and cascades cancel to its subtree. Before the outgoing shell is
replaced, clear cancels every worker still registered on it
(Shell::cancel_workers) — explicit destruction outranks every lease, the
durable class included — reaching it through the transport while it is still
unambiguously this shell, since there is no way back to it once the
transport is swapped. A worker settling after the cancel still tries to flush
its deferred done batch through the boundary it captured before the clear;
the same generation guard (deferred_sink’s admission check,
shell-eval) that already drops a stale agent result
drops that flush too, so no pre-clear worker output survives into the rebuilt
context. It is the focused agent’s, not a fleet-wide reset.
compact runs provider.summarize over the history when context pressure
crosses the window’s reserve (digest.rs’s compaction_due — used tokens
into the top 15% of a known window; COMPACT_THRESHOLD, 500 KiB of serialised
history, is the fallback when the window is unknown) and AgentLog::can_compact
holds (no pending tool results). It is called at the top of apply, where the agent is
ReadyForUser (turn-ends-ready) and the gate
actually holds — every provider round-trip passes through here, so long
autonomous and headless turns stay bounded without an interactive /compact. A
turn-boundary Esc bails before the summarize request
(esc-non-escalating-interrupt).
A successful AgentLog::apply_compaction physically drains
events[..suffix_start] from the in-memory mirror after the archival
Compacted breadcrumb is durably recorded — event_count/history_bytes
shrink to summary + suffix, heap reclamation rather than just a narrower
read-time view. events.json is append-only and untouched either way: a
failed compaction (tool results pending) drops nothing, and a successful
one never rewrites what is already on disk
(leases-and-budgets, “Compaction
physically drops the model prefix in memory”).
Agent::check_disk_warn is the disk half of the same ADR (“Disk: report
and warn only”) — report-and-warn only, never rotation or deletion.
Unconfigured (config::disk_warn_bytes absent, the default) it is a no-op
by construction: no walk, no cost, ever. Configured, it rides the same
ral_epoch the settled-worker and binding-lease sweeps already read,
amortized to once every DISK_WARN_CHECK_INTERVAL (32) calls, at the same
ready boundary as reconcile_service_pins/reap_bindings in drive’s loop.
Crossing the ceiling (session log dir + EXARCH_SCRATCH, summed via the
existing resources::dir_size) emits one Kind::SystemNote, latched until
a later check finds the total back under — one warning per excursion, not
one per boundary.
A fork builds the child Agent for sub-agent spawning through
Shell::fork_session (the flow matrix) rather than
hand-copying fields after a bare Shell::new. It takes the child’s
Capabilities as an argument, so the spawn site owns the authority decision
(the parent’s verbatim, or parent ⊓ base via `policy::narrow`).
The child sets parent: Some(self.id) — the tree edge that routes its result and
drives the subtree cascade — and registers itself in the fleet’s shared registry.
It snapshots the parent’s whole lexical scope (prelude, agent library, every
accumulated binding), its dynamic context (cwd, env, grants, handlers), and the
installed builtin table, and starts fresh in everything else — fresh control
counters and a freshly-defaulted SessionState, so it holds no terminal
authority (TerminalAccess::Denied, no lease — a sub-agent is not the
foreground agent and can never seize the controlling terminal the TUI owns).
There is no flow-back: the child’s cd, env, and new bindings die with it. Every
agent may spawn, but each fork hands the child one less unit of fuel than the
parent holds (SPAWN_FUEL = 3 at the trunk; the parent’s own fuel is never
debited, so fuel bounds depth, not fan-out); at fuel == 0 the desk refuses
agent-start with the exhaustion text — the
spawn verb stays advertised, the desk is the wall
(spawn-fuel-ceiling) — so a delegation
chain bottoms out by refusal a fixed number of generations down. The fork
mirrors on the bus as Kind::Born / Kind::Died regardless of remaining fuel.
fork_with(caps, returns) is the shared fork core — a returning child passes
true; branch is fork_with(self.caps, returns: false) plus
inherit_context, minting a conversing peer tab with the parent’s verbatim
authority (branch-minimal). A builtin
spawn takes the decomposed path instead: the agent verb’s body forks the
session into the turn’s nursery (Shell::fork_into_nursery), and the desk’s
agent-start arm adopts it and calls Agent::assemble at one less unit of
fuel (builtins). The `mnemon memory mode
additionally forks
the parent’s AgentLog and imports its model-visible context before assembly
(subagent-memory-modes); AgentLog
drops a pending unanswered assistant tool-call frame when the parent is
mid-dispatch, so the child inherits a request context rather than a dangling
provider protocol. Both memory modes seed the launch prompt through the
child’s inbox, so the prompt enters through the same turn path.
Routing the fork through core matters because the builtin table is the easiest
thing to drop. The exarch host builtins — view-text, grep-files,
edit-hash, edit-replace, explore-dir, fff, the skill loaders
(builtins) — live in the agent’s dispatch table,
outside Mobile, and the view-text-around helper in agent.ral calls
view-text. A fork that copied only mobile.scope and mobile.context would
leave the child’s view-text-around resolving to nothing and falling through
to a failed PATH lookup. fork_session copies agent.builtins as part of the
flow matrix, so the decision lives in one place and the table cannot be
silently severed at this call site.
digest.rs holds clip and the fixed per-section byte caps for what the
model sees in history: each tool-result section has its own cap
(VALUE_CAP 20 KiB, STDOUT_CAP/STDERR_CAP 10 KiB), alongside separate caps
for opaque error blobs (OPAQUE_CAP), agent replies (AGENT_REPLY_CAP), and
the history-compaction threshold. An oversize section keeps a head+tail digest
and elides the middle, with a banner nudging the model to scope the query at
its source and re-read in slices — the same rendering the transcript records,
so the user never sees more of a result than the model does. run_shell here
threads to shell-eval.
See also
agents (the role model these nodes realise — one parent
predicate, the conversing trunk vs returning agents),
tools (the one ral tool the
provider sees) and builtins (the harness verbs the
desk answers), frontend (the bus, the inbox, the
registry, and the two frontends), provider,
policy, exarch,
residency (the resident ledger this cascade and the
worker/schedule teardown edge are chapters of).