An exchange ends ready

An exchange here is a human round — a prompt and all the work it draws, the same unit the fleet’s clock counts. It is not a unit of the context, which has only turns, each with a role (an-eviction-is-a-set-of-turns).

The session protocol is a state machine over the record-backed model projection (AgentLog) with two kinds of phase:

  • ReadyForUser — the exchange is settled;
  • the intermediate phases (AwaitingAssistantAfterUser, AwaitingToolResults, AwaitingAssistantAfterToolResults) carry an exchange in flight.

is_ready is the single predicate for “a fresh prompt is admissible”, and it is weaker than ReadyForUser. A record that opens a turn — a Protocol::UserPrompt, an imported Protocol::Import — is admissible in every phase but AwaitingToolResults: an exchange the model never replied to is abandoned by the next prompt, not closed by a fabricated one. Only outstanding tool calls hold the log, because their answer is genuinely owed and a dangling tool-call block is not a legal request (transcript-admission). admissible and is_ready, both in record/model/state.rs, read the same rule, so what a live door accepts and what a replayed log admits cannot drift.

What the phases sequence is tool-call pairing, not strict user/assistant alternation — which this projection has never maintained and does not aim to. Consecutive same-role messages are routine and reach the provider as written (transcript-admission); an unanswered tool_use block is the shape that is actually illegal, and it is the one the phases exist to prevent.

The invariant that keeps the loop sound: Agent::take_up never hands control back to Agent::attend’s loop until a fresh prompt is admissible, however the exchange ended — a clean reply, a user cancellation, or a surfaced provider error. Agent::deliberate commits a prompt (or a tool-result batch) before the round-trip it drives, so a failure between that commit and the next assistant reply leaves the machine in an AwaitingAssistant* phase with no reply recorded. That phase is now a legal resting place, and costs nothing: replied/cancelled call AgentLog::quiesce with QuiesceReason::Replied/Cancelled, take_up calls quiesce(QuiesceReason::Aborted) whenever is_ready does not already hold — whether deliberate returned cleanly or was caught out of a panic — and Agent::attend (with its bounded twin attend_backlog) repeats the check with a debug_assert once its own loop exits, a backstop against a future exit path that bypasses take_up.

quiesce records only what the log still owes, and never a turn the model did not take:

  • an answer to tool calls that never ran, whatever ended the exchange — the calls were really made, and “not executed” is really the answer;
  • a capstone for an exchange that ended on reply, because the fold cannot otherwise tell a reply from an interruption at the resting phase the two share, and would drop the child’s whole exchange from its own context.

Nothing else is synthesised, and an abandoned exchange is left exactly as it lies — in record.jsonl and in the context alike. Context::rendered sends every resident turn as written; the next prompt, following the interrupted exchange’s last tool results, is the interruption’s own mark. The unrun-call answer above is what keeps this legal: a closed exchange can never hold a dangling tool-call block, because take_up quiesces before any prompt is admitted.

The content must stay because an exchange in exarch is all the work since the last human prompt. One recorded session had a single exchange run fifty-seven tool turns; an Esc there, followed by “what are you struggling with”, left the model — its whole session replaced by a one-line “its content is not in your context” note — running git status and then reading its own record.jsonl to find out what it had been doing. Dropping an interrupted exchange is not tidiness, it is amnesia.

What the harness may not do is speak for the model. The placeholder before that ("[EXARCH // Request interrupted by user.]", an assistant capstone) rode forward into the model’s own history on every later round-trip, and in one recorded session the model — having seen itself apparently say (cancelled by user) a few turns earlier — emitted that string verbatim as a genuine stop_reason: "completed" reply. A System-role marker in its place is not the fix either: genai’s ChatRequest::iter_systems/join_systems hoist every system message anywhere in history into one preamble resent on every request, so a marker meant to stay pinned where it happened would colonise the system prompt instead.

Eviction is not stated at this granularity at all. The rule is that no edit touches the unclosed turn — the one being written, which exists only while a turn is open, so at ReadyForUser nothing is unclosed and a user rewind or clear (Protocol::Rewound and Protocol::Cleared, withdrawals rather than cuts, admitted whenever a new turn is) may empty the context. Context::resolve_cut refuses a run with that turn within, and plan_forced_eviction walks back from the newest-but-one frontier item, so a forced eviction cannot name it either (an-eviction-is-a-span). The reading door refuses the same turn, and that refusal names the closed turns of the work in hand, which are readable (an-eviction-is-a-set-of-turns).

The hard rule: a path that ends an exchange must leave a fresh prompt admissible. Add a new exchange-ending outcome through quiesce (extend QuiesceReason), never by returning with tool calls unanswered. Eviction reads is_ready too (agent can_evict).

See also session-record (the durable protocol and its model fold), agent (attend, take_up, deliberate).