Skip to content

ADR-0016 Architecture decision record

Agent-turn mechanics are jr2-internal: ambient coordinates, host-side offsets, absorbed retries, fresh sessions ​

Second of the workflow-API redesign trio (ADR-0015 has the context; full record in docs/design/workflow-api/). agentRun's consumer input shrinks to workflow vocabulary — { agent, prompt } plus opt-ins below — because everything else it used to take was jr2 handing the consumer values jr2 already had. Each absorption follows, with what it deletes from a jr-shaped workflow.

The opt-ins include two optional dials, model and thinkingLevel (ADR-0018 draws the identity-vs-dial line and states where each is validated). The absorption principle is what admits them: what this ADR absorbed was values jr2 already held and was handing back to the consumer to hand in again — mechanism wearing a consumer's clothes. A dial is the opposite case: a genuine per-call choice that jr2 cannot derive, because Agents are instance-scoped and only the invoking workflow knows how hard this turn is. Both are serializable strings, so they ride the persisted child input; a restore re-attaches to a Submission whose model is already fixed server-side and never re-resolves them.

Ambient endpoint and sandbox (deletes the turn() helper and all handle threading) ​

workspace()'s running state co-invokes a registrar actor that records its handles in a WeakMap<ActorRef, Handles>; agentRun walks self._parent to the nearest registered ancestor. Invoked-actor registration (not an entry action) is what makes this restore-safe: invoked actors restart on snapshot restore, entry actions do not. Not under a workspace → fail loudly, unless input carries an explicit endpoint (the dev-stub, workspace-less path). Body-facing workspace handles are { repos, branch } (ADR-0012).

ADR-0013's token scoping survives and strengthens: the registration records the Sandbox resolved from the enclosing wrapper — the same deterministic workspaceName() the token was minted for — so the consumer can no longer forget to pass it (the baba71f incident: sandbox omitted, every tool call 403'd, fail-closed but silent). The parent chain resolves the enclosing wrapper structurally, never a sibling's; cross-feature event injection stays impossible.

Offsets live in a host ledger (deletes context.offsets and the agent.offset event) ​

agentRun reports iid → offset through the run binding into a ledger persisted beside the snapshot in the same RunBlob save. Iids are globally unique, so restore's recursive nested-context offset folding dies with the context leg; restore still rewrites the child's persisted input (ADR-0007's invariant stands verbatim — only the offset's location moved). The Harness wire does the heavy lifting (ADR-0027): wait reconnects from the admitted offset, and abort is the deliberate terminal cancel (ADR-0002).

The ledger's key is the iid, and an iid is not a Turn. Two Turns that continue one conversation share it, and nothing removes a Turn's entry when the Turn ends. So the input mapper also mints a Turn ID (turnId), one per invocation, and it persists with the child's input. The actor stamps it on every admission that it ledgers. The restore re-attaches a child only to an admission with the child's own Turn ID. An admission with another Turn ID is an earlier Turn's: the child's admission was not ledgered, so the child keeps its prompt and is admitted again. If its admit reached the Harness before the crash, the prompt is then sent twice. This is ADR-0007's known durability edge, an at-most-once re-POST, and it now applies to a continued Turn as it does to a first one. Before the Turn ID, a crash in that window restored the Turn onto the earlier Turn's Submission. The Turn then followed a stream that was not its own, and its prompt was never sent. A plain setup() Machine that passes finalized input mints no Turn ID, and the key alone decides.

Retries and nudges are absorbed; the workflow sees one terminal agent.fault (deletes the retry bookkeeping) ​

Two fault classes, deliberately not one knob (ADR-0035 later adds the third: runaway):

  • Infra faults (stream drop, pod restart, provider error): provider-stream retry lives inside the turn in the Harness, and wait reconnects from the offset ledger without a deadline; a restarted Harness or a lost Workspace ends the Turn through the Sandbox watch (ADR-0021), and a Harness that answers 404 is a lost conversation (ADR-0027). The one exception is a restarted Harness while the Agent's state is busy, or with a pick's answer unread: that cuts the Turn and faults nothing — the actor waits for Rest and re-admits on the same conversation, with the answer as a notice (ADR-0024, ADR-0031). A re-admission is mechanism, like a nudge: telemetry on the feed, no marker, and it spends no budget, because the model did nothing wrong.

  • No-signal (the agent stops while its Turn is still open — jr's dominant failure mode): the wire treats that as a normal completed Submission, so recovery is jr2's. The ask-again rule: while the Turn is open, a model that stopped is asked again once its Allowed picks are not empty, and never with an empty list. A delivered pick does not end the question — a pick with no target, or one into a mode, keeps the Turn open (ADR-0024) — only the Turn ending does. One rule, asked at three moments:

    • The step boundary. Inside the Submission, the Harness restates the Turn part when the Allowed picks changed since the model last read them (ADR-0029); that message keeps the engine's loop going and costs no Submission. It never restates an empty list to a model that stopped.
    • The nudge. The Submission settled with picks left: the Agent's Actor admits a nudge on the same conversation — "Your previous turn ended, and the workflow is still waiting for your pick." — absorbing ADR-0006's "forced final pick is a Machine-level re-prompt" into the actor.
    • The wake. The Submission settled with none left: the model stopped correctly, and the Turn waits on a Gate, a child's invoke or a timer — the Allowed picks are empty while the Agent's state is not at Rest (ADR-0024). The actor watches the invoking Machine and admits the same nudge the first time its Allowed picks are not empty, read after the mailbox drains like Rest itself, so a state that sends to itself on its way to more work wakes nobody. The watch ends with the state, so a Turn that ended is never woken, and a Turn that was never meant to pick waits until its state exits.

    The nudge and the wake are one Submission kind and spend one budget: a Machine that opens and closes picks under a stopped model cannot ask it without bound. All three read one Allowed list, the one the surface serves (ADR-0029). Their admission is for the conversation the Turn holds, so it says kept (ADR-0062). A Harness that lost the conversation while the Turn waited refuses it and seats none, and the Turn is admitted again on a new conversation with its task, or faults when it has no task to send. That ask was spent already, so the budget counts it once.

Budgets are defaulted knobs, not Machine context. Exhaustion emits the single terminal agent.fault { reason }; where it routes (escalation, a triage/investigator state, a Failure) is workflow policy, but it must route in a Machine: the walk refuses a state that invokes an Agent with no agent.fault route on itself or an ancestor, and an agent.fault that no transition takes faults the run (ADR-0074). In a Chart the route is optional: the Open body sends an unrouted fault to the Machine that invoked it, whose route takes it (ADR-0070). Attempts surface as run-feed/visualize telemetry projected to { child, attempt } — no iids on the open observation feed (ADR-0014 unchanged).

Sessions are fresh by default; continuation is opt-in ​

jr's most deliberate design decision is the lossy handoff — every relaunch is a fresh session; revision coders read the notes and the code, never the prior agent's conversation. That is the default: each agentRun invocation is a new conversation. continue: true opts into continuation (ADR-0057): the Turn lands on this Agent's one conversation in this Machine instance, iid <run>/<machine actor path>/<agent> plus the epoch jr2 bumps on a terminal fault; the per-invocation prompt lands as the next user turn; the tool menu still re-derives from the invoking state (one conversation travels across the states of its Machine). Mid-turn restore re-attaches the in-flight turn in both modes — continue governs only what a new invocation means. The author names nothing: in a state machine the conversation's identity is already on the page. (This corrects the jr-parity exercise's "same iid = jr's resume machinery, free" — an inversion of jr's actual semantics.)

Two live invocations of one continued iid — parallel states — are refused at the registration table: one live surface per address. A Submission that arrives behind a live one on the Harness queues: prompts for one instance enter one per-instance queue in admission order, and a Submission is promoted only when it is the first unsettled one for its conversation (ADR-0027 states this as jr2's own semantics). A queued prompt waits behind work that may never finish, which is why ADR-0024 both ends the turn and orders the abort ahead of the next send.

The minting doctrine — addresses are computed by jr2, never by the workflow — covers gate ids too: a gate with no authored id derives one from its actor path below the run root, through the same walk mintIid uses (actorPath in registration.ts; ADR-0011). The two mint at different seats for a reason: iids must be minted in the input mapper because minting has a random component (the fresh-session suffix) and a ledger read (the continued conversation's epoch) — the input is what persists — while a path-derived gate id is deterministic from machine structure, so it is recomputed at every actor (re)start and is restore-stable by construction. The mapper rule is about non-determinism, not a blanket rule about where ids are born.

The docs of Kit version 0.5.1. Each page is also at its .md URL.