← stitchkitDocumentation

ADR 0108 — What a stopped run already said is a declared policy

Context

AgentInputPolicy had two values. queue finishes the run in flight first; interrupt ends it. interrupt ended the run and kept everything it had produced: the partial assistant was committed with status: 'interrupted', and the next run’s projection sent that partial to the provider as an ordinary assistant turn.

Worse than including a stale sentence, it included one that did not look stale. interruptedCandidate appends a control: 'run-interrupted' part, and assistantMessages rendered text, reasoning, tool-call and tool-result — control was not among them. The marker was dropped on the way upstream. The model received a confident half-sentence with nothing to say it had been cut off, and continued a thought the user had already redirected.

So a third behaviour was wanted: end the run and discard what it produced. The tempting shape was a projection rule — “omit interrupted assistants” — and it is wrong, because two interruptions that are identical inside the runtime have opposite correct answers:

  • The user pressed stop. The partial was streamed to their screen and they read it. Dropping it makes the conversation lie to the model about what the human has seen.
  • A newer input superseded the run. Whether the partial reached anyone depends entirely on the delivery surface: a token stream shows it as it is produced, a surface that sends nothing until the run is done never showed it.

Decision

Whether a stopped run’s output stays in the conversation is declared by the application, because the fact it turns on is not observable from inside the runtime.

That fact is delivery, and delivery belongs to the transport. The runtime sees an abort; it cannot see a screen.

Three consequences follow.

A third input policy, not a flag on the second. supersede ends the run like interrupt and marks the terminal record so the projection can tell them apart: terminalReason: 'superseded', run state 'superseded', assistant status 'superseded'. A boolean beside 'interrupted' would collapse exactly the two states this separation exists to hold apart, in the field an operator reads first. The separation is the deliverable; encoding it as a modifier on the value it must be distinguished from returns the ambiguity one layer down.

Discarded from the prompt, never from the record. A superseded run keeps its parts in the snapshot. Run identity, admission receipts and the terminal CAS all depend on the record existing, and an operator reconstructing a conversation needs to see what was thrown away. “Discard” is a statement about the projection only.

The projection states what it did. An interrupted turn that is projected says so to the model, by default. Any part of a projected record that no projected content stands for is named in that record’s AgentHistoryProjectionDecision. There is deliberately no setting that reproduces the previous output: a silent drop is the defect, not a behaviour to stay compatible with.

The form of the marker is itself a choice, and the reason is structural. An assistant turn in provider history is a commitment — the model reads its own previous turn as something it said and stays consistent with it. A system line is context. 'assistant-marked' is right when the human read the text; 'system-note' is right when the fragment reached nobody. That is the same axis, restated at the level of one message.

Consequences

The core learns no product policy. Which conversation surface gets which mode is expressed through the function inputPolicy already accepts — one application can hold a surface that always supersedes and one that queues, and the runtime never learns which is which. → ADR 0002.

AgentTerminalReasonSchema, AgentRunStateSchema and AgentMessageStatusSchema each gain a member, which is breaking for an exhaustive switch, and the minor moves. The authority for breaking it is ADR 0103: stitchkit/agent-runtime is declared evolving, so its shape may be redefined in any minor with a marked breaking change and a migration section. That is a property of the surface and does not depend on who has installed the package — deliberately not “no consumer depends on it yet”, which is true today, cheaper to say, and expires the first time someone runs bun add.

A fourth behaviour stays out of scope: handing the input to the loop between tool calls without ending the run. It needs pending inputs delivered to prepareStep and attached to a running run’s inputMessageIds, which the accept path does today only for a run still queued — a durable-record change of its own size, tracked separately.

Alternatives considered

A projection rule with no new policy. “Omit interrupted assistants” is one line and answers the wrong question — it cannot distinguish the stop button from a supersede, which is the entire distinction.

A boolean on the interrupted state. Additive, and it puts the answer back out of reach in the field an operator reads first.

Letting the core infer delivery. It would have to know whether the transport streams, which is the domain knowledge ADR 0002 keeps out of the core.