agent has sync and async modes
The exarch agent tool has two modes because a sub-agent call may be a
dependency edge or an orchestration edge. sync returns the child’s answer
in the current tool_result; async returns an opaque start receipt now and
later posts a typed AgentResult to the per-session inbox. Async is not a ral
handle and not a durable job: it is an ephemeral, session-owned agent worker
with an id for status and cancellation.
Context
The current sync agent tool is fork-join, not background work. Same-batch
children may overlap under Session::dispatch’s thread::scope, but dispatch
joins every Staged::Spawned before the next provider request. That is correct
for dependency edges and useful for batch fan-out, but it still cannot express
the parent turn ending while the child continues.
Two facts bound the space:
- The wire constraint is inescapable. A
tool_useblock must be answered by itstool_resultin the next message, so an async tool cannot defer its result: it returns a stub synchronously and its real reply must re-enter the loop later. The turn still exitsReadyForUser(turn-ends-ready) however the stub returns. - The one thing in-turn concurrency cannot express is the parent turn ending before the child does. Running a batch of agents at once is a wall-clock win on a single turn; it is not the same as a sub-agent doing work off the parent’s critical path — spawned, then left running while the parent continues, its reply arriving when ready. That second shape is what an async agent is for, and it is the only shape that genuinely needs the child to outlive its turn.
The mechanics of a turn-outliving detached worker already exist for ral spawn
(concurrency-detached-vs-structured):
parented at the durable root so a foreground deadline cannot collaterally kill
it, reaped by the shared process::reaper death-clock, carrying its own cancel
scope. What ral spawn cannot host is an LLM turn — its worker body is ral
code, which lives below the Provider. An async agent is that same detached worker
with a turn loop for a body, which is precisely why it is born on the exarch
side, as a tool, rather than expressed in the language.
Decision
-
agentis bimodal.mode: "sync"means “run this child because I need its answer now”;mode: "async"means “start this child because I am orchestrating independent work”. Neither edge subsumes the other. Sync stays the compatibility default because today’sagentresult is the child’s final answer, not a start receipt. -
Sync keeps the existing virtues. A sync child returns in the current
tool_result: one model round-trip, deterministic placement at the call site, live child chrome, and no lifetime surface because the child cannot outlive the call. This is the right shape when the parent turn depends on the answer before it can continue. -
Async returns a start receipt, not the answer. The immediate
tool_resultsatisfies the wire protocol and carries{id, title, status: "started", log_dir}. Theidis an opaqueAgentId, not a content hash and not prose in a tag. It is the capability for later status and cancellation. -
The child escapes the dispatch scope. Today it is a
ScopedJoinHandleon thethread::scopeinSession::dispatch; an async child is a real worker owned by a session-lived agent registry. It survives the spawning turn and is not joined before the next tool boundary. -
The registry is ephemeral, session-owned, and listable while useful. Async is not long-running-work’s durable-job registry, but orchestration still needs recovery from context compaction and a way to stop stragglers.
agentslists live async agents by id, title, status, log directory, and elapsed time;agent_cancel(id)cancels one. Settled results auto-deliver through the inbox, so there is no requiredagent_collect(id)path. -
Provider ownership becomes shared, but cancellation becomes request-local. A worker outliving the dispatch frame cannot hold the borrowed
&'env Provider; the driver-held provider becomes anArc<Provider>, and an async child captures the current clone. A later/modelswitch replaces the driver’s currentArcbut does not rewrite already-running children.Arc<Provider>is necessary but not sufficient:Provider::complete/summarizemust also take a request cancellation handle rather than relying only on the process-global root-turn slot (cancel::is_set). The foreground turn’s handle is linked to the signal slot; an async child receives the registry’s handle, so two concurrent provider requests do not overwrite each other’s cancellation path. -
The reply re-enters through a typed inbox message. The child posts
InboxMsg::AgentResult { id, title, outcome, text, log_dir, elapsed, generation }at settle. The driver renders that host data as a synthetic user turn at the next turn boundary. It is not raw<agent=...>text in the prompt queue: source tags and drain boundaries are data, not parsed prose. -
Output is muted to the child’s own log. The event bus is per-turn and closes when the spawning turn ends, so a background child cannot stream to it — the same constraint that denies exarch
watch(a detached writer may hold only root- or handle-owned resources, never a foreground frame’s capture buffers). The child writes to its ownSessionLog; its final reply surfaces through the inbox. -
Lifetime is explicit registry policy. ral
spawninheritsdetached_ceilingthroughTurnFrame; an exarch-side agent worker does not. The async-agent registry therefore owns a worker cancel scope, arms the sharedprocess::reaperwith the same detached-worker ceiling, and drops the entry when the worker settles.Session::clearcancels all live async agents, increments a generation, and rejects any lateAgentResultfrom an older generation. Process exit drops the registry and root-aborts remaining children. -
No ral surface. This is an exarch tool mode, not the born-durable ral verb of long-running-work. The two share the detached-worker vocabulary but not their management surface: the durable-work path is a ral
Handle/ durable job withpoll/await/cancel; asyncagentis a session-owned LLM worker whose result is pushed to the inbox.Landed 2026-07-16 — superseded in letter, not in substance. The launch verbs (
amnemon,mnemon, and the rest of the harness family) are nowralbuiltins the model reaches by writing ral inside the one remaining tool (agent-tool-to-exarch-builtin), so “No ral surface” no longer holds literally. The distinction this bullet protects survives the move: a builtin is exarch-side, installed above ral-core, and answered by a host desk (shell.enquire) — it is still not the ral-coreHandle/poll/await/cancelverb this ADR and long-running-work rejected, and the worker body is still an LLM turn that cannot live in the language. See builtins for the landed verb and tools for what remains a tool.
Why this shape
- It preserves the distinction the model needs to express. Sync is a dependency edge; async is an orchestration edge. Keeping every call sync leaves orchestration on the parent turn’s critical path. Making every call async turns ordinary dependency calls into a two-turn protocol with a deferred result.
- It honours the wire constraint without making the model carry collection debt. A stub now, a pushed reply later; the harness owns the obligation to deliver the result. The id exists for status and cancellation, not as the required way to collect the answer.
- It composes with scheduled-wakeups:
a worker reply and a cron prompt are both
InboxMsgs with source tags, delivered at the turn boundary — distinct from tool-boundary-steering, which is user redirection drained mid-turn. Building the inbox once serves both. - It names the real new mechanisms. The reuse is detached-worker parenting
and the reaper, the inbox delivery seam, and
Session::fork’s snapshot model. The new code is providerArcownership, request-local provider cancellation, an off-scope spawn site, an ephemeral agent registry, explicit reaper arming, and stale-generation rejection on/clear.
Alternatives considered
- A ral
Handle(poll/await/cancel), the long-running-work Regime-1 shape. Rejected for this need: the affordance is an LLM tool mode, not a language construct, and the desired fan-in is push delivery. Async agents still have an id for status and cancellation, but a pushed result means the model does not have to retain that id to receive the answer. - No model-facing id. Rejected: pure auto-notify is enough only when every worker succeeds quickly. An orchestrator needs to inspect live children, cancel stragglers, and recover ids after compaction; the registry is therefore listable while entries are live.
- An explicit
agent_collect(id)as the primary path. Rejected: it makes the model carry a job id across turns and compaction merely to receive the answer. A deterministic collection verb can be added later if a workflow needs it, but inbox auto-delivery is the default fan-in. - Make every agent async. Rejected: it changes the meaning of a dependency call. The parent receives “started” instead of the answer, pays another model turn, and must reason about a later synthetic message even when it had no work to interleave.
- A separate
agent_spawntool. Rejected for now: the two modes are the same affordance with a different dependency edge. A requiredmodefield in new prompts makes the edge explicit without splitting docs, permissions, and UI chrome across two tool definitions. - Raw tag delivery (
<agent=...>...</agent>in the prompt queue). Rejected: tags are forgeable text and force drain policy, logging, and UI state to parse prose. The queue becomes a typed inbox; rendering to text happens only at the model boundary. - Lift the bus for live background output now. Deferred: muting matches the
existing
watchdenial and keeps the bus per-turn; live streaming from a background child is a larger, later change.
Open questions
- Mute vs live. Whether a background agent ever streams live (lifting the bus to session lifetime) or stays muted-to-log and surfaces only on inbox delivery.
- The ceiling for an LLM worker. One hour is the detached-worker default; a background agent doing genuinely long work may need a different bound — which is long-running-work’s open regime question surfacing again, here for a tool rather than a ral verb.
- Delivery batching. If several agents settle before the driver drains the inbox, either render one synthetic turn per agent or coalesce them into one ordered batch. The message type supports both; the frontend policy is open.
- Post-delivery retention. How long a settled registry entry remains listable after its result has been delivered — enough for UI/log discovery, not a durable job history.
- Dependence on the inbox. Delivery rests on the scheduled-wakeups inbox seam; if that is not built first, this ADR builds the inbox itself, for the same two consumers.
See also
concurrency-detached-vs-structured
(the detached-worker mechanics this reuses: root parenting, the reaper, the
ceiling), scheduled-wakeups (the inbox
delivery seam and the event-trigger this realises),
long-running-work (the ral born-durable
verb this is deliberately not),
background-tool-calls (the earlier
study that mapped provider sharing and a registry),
tool-boundary-steering (why sync
agents are still fork-join, and why inbox delivery is not steering),
turn-ends-ready, agent,
tools, and docs/SPEC.md §13.