An exchange ends ready
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 span — a
Protocol::UserPrompt, an imported Protocol::ContextMessage — 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 in
record/model.rs and is_ready 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, the step cap, or a
surfaced provider error. Agent::deliberate commits a prompt (or a tool-result
batch) before the round-trip it drives, so a failure or a capped step count
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 turn from its own view.
Nothing else is synthesised, and an abandoned exchange is left exactly as it
lies. record.jsonl keeps it unabridged for the TUI, resume, and the human
audit trail; the model reads none of its content. A closed span whose own
fold does not settle renders as exactly one User-role note in place of
everything it held — which catches by the same test the exchange abandoned
with pending tool ids, whose dangling tool-call block must never reach the
wire. The note is cause-neutral, a cancel and an abort being told apart only
by a Forensic record this fold never sees, and says whether tools had been
called: their effects outlive the context the exchange lost, so a model that
reads no trace of them would re-run them.
Its voice is the point. The harness may state a fact about the conversation;
it must never put words in the model’s mouth. The
placeholder this replaced ("[EXARCH // Request interrupted by user.]") 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.
is_live_exchange — the exchange a context edit may not name — stays keyed to
ReadyForUser rather than is_ready, so an edit can never land on an exchange
a deliberation is still driving.
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. Compaction
reads is_ready too (agent can_compact).
See also session-record (the durable protocol and
its model fold),
agent (attend, take_up, deliberate).