stitchkit 0.122.1 → 0.122.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- # stitchkit/agent-runtime — stitchkit docs slice, part 3 of 4
1
+ # stitchkit/agent-runtime — stitchkit docs slice, part 3 of 5
2
2
 
3
3
  > Generated from the `docs/` tree by `bun run gen:llms`. The index of all slices is
4
4
  > `llms.txt` in this package.
@@ -7,539 +7,780 @@
7
7
  Parts of this slice:
8
8
  - `llms/agent-runtime.txt`: Guide: Agent application runtime (docs/guide/agent-runtime.md)
9
9
  - `llms/agent-runtime.2.txt`: Guide: Agent application runtime (docs/guide/agent-runtime.md)
10
- - `llms/agent-runtime.3.txt`: Guide: Agent application runtime (docs/guide/agent-runtime.md) · API reference: `stitchkit/agent-runtime` (docs/api/reference.md)
11
- - `llms/agent-runtime.4.txt`: API reference: `stitchkit-tui` (docs/api/reference.md)
10
+ - `llms/agent-runtime.3.txt`: Guide: Agent application runtime (docs/guide/agent-runtime.md)
11
+ - `llms/agent-runtime.4.txt`: Guide: Agent application runtime (docs/guide/agent-runtime.md) · API reference: `stitchkit/agent-runtime` (docs/api/reference.md)
12
+ - `llms/agent-runtime.5.txt`: API reference: `stitchkit-tui` (docs/api/reference.md)
12
13
 
13
14
  ==============================================================================
14
15
  # Guide: Agent application runtime (docs/guide/agent-runtime.md) (continued)
15
16
  ==============================================================================
16
17
 
17
- ## Observability
18
+ ## What happens to a run when new input arrives
19
+
20
+ `runs.inputPolicy` decides. It takes five values, or a function returning one:
21
+
22
+ | policy | the run in flight | what it already produced |
23
+ |--------|-------------------|--------------------------|
24
+ | `queue` (default) | finishes first | kept |
25
+ | `inject` | continues, and answers the new input too | kept, and built on |
26
+ | `interrupt` | ends | kept, and marked as cut off |
27
+ | `interrupt-next` | ends; this input runs before ordinary queued work | kept, and marked as cut off |
28
+ | `supersede` | ends | discarded from the prompt, kept in the record |
29
+
30
+ `interrupt-next` is the explicit priority path for an urgent input. If A is
31
+ running, ordinary B is queued, and urgent C arrives, the coordinator aborts A,
32
+ waits for A to settle and then runs C before B. B keeps its durable identity and
33
+ eventually runs. Urgent inputs remain FIFO among themselves. They do not
34
+ coalesce into an ordinary pending run: that would erase the priority boundary.
35
+
36
+ `interrupt` and `supersede` differ in exactly one thing, and the question that
37
+ picks between them is **not** "was the run interrupted" but **"did anyone see
38
+ what it produced"**:
39
+
40
+ - The user pressed **stop**. The partial answer was streamed to their screen and
41
+ they read it. It belongs in the conversation — dropping it makes the history
42
+ lie to the model about what the human has seen. That is `interrupt`.
43
+ - A newer message **superseded** the run. Whether the partial reached anyone
44
+ depends on the delivery surface: a token stream shows it as it is produced, a
45
+ surface that sends nothing until the run is done never showed it at all. When
46
+ it reached nobody, it is not part of the conversation. That is `supersede`.
47
+
48
+ **Stitchkit cannot answer that question for you** — delivery belongs to the
49
+ transport, and the runtime sees an abort, not a screen. Hence a declared policy
50
+ (→ ADR 0108), and hence `inputPolicy` accepting a function, so one application
51
+ can hold two surfaces with different rules without the core learning which is
52
+ which:
18
53
 
19
- `createAgentObservability` emits a separate operator-only `AgentRunEvent`. It
20
- reuses the same bounded sink lifecycle as request/tool observability without
21
- sending new event kinds to existing request sinks. Product events omit provider
22
- causes. Operator `internalCause` is also redacted by default; an operator-only sink must explicitly
23
- set `includeInternalCause` and own its retention policy.
54
+ ```ts
55
+ runs: {
56
+ inputPolicy: (input) =>
57
+ protocol.parseContext(input.context).surface === 'operator' ? 'queue' : 'supersede',
58
+ }
59
+ ```
24
60
 
25
- ### What a run says it spent
61
+ `input.context` is the **raw** context here — admission runs before the runtime
62
+ parses it, so the callback narrows it itself with the protocol it already has.
26
63
 
27
- Usage values carry `provider-reported`, `computed`, `estimated` or `unavailable`
28
- provenance, **per field**. Cost additionally carries an ISO currency code;
29
- OpenRouter-reported cost is normalized as USD.
64
+ A superseded run ends with `terminalReason: 'superseded'`, run state
65
+ `'superseded'` and an assistant message of status `'superseded'`. **The record
66
+ is kept** — excluded from the projection, not deleted — so an operator can see
67
+ what was thrown away, and run identity, admission receipts and the terminal CAS
68
+ keep the row they depend on. Compaction leaves it alone for the same reason: a
69
+ turn whose answer is never spoken is not a turn that may be summarised into one,
70
+ because that would both feed the discarded text to the summariser and drop the
71
+ record in `replacedMessageIds`.
30
72
 
31
- Read the provenance before the number (→ ADR 0109):
73
+ It is also outside the token budget. `selectAgentHistory` removes it with reason
74
+ `'superseded'` and does not count it, so an abandoned fragment cannot push a
75
+ real turn out of a context it never occupies.
32
76
 
33
- - **`provider-reported`** — the provider handed us exactly this. On
34
- `step-finished`, that is what a step's figures are.
35
- - **`computed`** — a total, added up over steps. **Every figure on a terminal
36
- event is this**, tokens included: the AI SDK's `totalUsage` is a sum it
37
- performed, not a number a provider reported for the run. It is not a figure to
38
- bill against unchanged.
39
- - **`unavailable`** — nobody reported it. Not zero.
77
+ ### How an interrupted turn reaches the model
40
78
 
41
- Two rules follow from that last one, and they differ by field on purpose:
79
+ An interrupted turn is projected, and says so:
42
80
 
43
- - **A token total with an unreported step is a floor**, labelled `computed`. A
44
- token count is a diagnostic, and a floor is a useful one.
45
- - **A cost with an unreported step is `unavailable`, not a floor.** Money is what
46
- people bill against, and "at least $1.00" reported as `$1.00` is the same class
47
- of lie this whole section exists to remove. One step that did not report its
48
- cost makes the run's cost unknown — not smaller. It also stays unknown: later
49
- steps reporting normally cannot revive it.
81
+ ```text
82
+ { role: 'assistant', content: [
83
+ { type: 'text', text: 'We are the team, where would you like' },
84
+ { type: 'text', text: '[interrupted: this turn was cut off before it finished]' },
85
+ ]}
86
+ ```
50
87
 
51
- **A terminal event always carries `usage`.** A run that ended before the provider
52
- reported anything — superseded, interrupted, timed out, shut down, failed —
53
- carries every field `unavailable`. That is deliberately different from a run that
54
- spent nothing, and an omitted object could not tell you which one you had.
88
+ `history.interruptedAssistant` chooses the form, and the difference between the
89
+ first two is structural rather than cosmetic. **An assistant turn in provider
90
+ history is a commitment**: the model reads its own previous turn as something it
91
+ said and stays consistent with it. A system line is context.
55
92
 
56
- Two costs in different currencies do not add: the sum reports `unavailable`
57
- rather than picking a label. The core records a currency and never converts one.
93
+ | value | form | right when |
94
+ |-------|------|-----------|
95
+ | `assistant-marked` (default) | assistant turn plus a marker | the human read the text |
96
+ | `system-note` | `[interrupted] partial response: …` as a system line | the fragment reached nobody |
97
+ | `omit` | not projected at all | you want it gone from the request |
58
98
 
59
- `step-finished.response` identifies the provider response for that one step. The terminal event
60
- does not copy the last response: response identity remains per-step, while every terminal usage
61
- figure remains a `computed` aggregate.
99
+ There is deliberately no value that reproduces what the projection used to do,
100
+ which was to send the partial as an ordinary assistant turn and drop its
101
+ `control` marker on the way. That was the defect, not a behaviour to stay
102
+ compatible with.
62
103
 
63
- **A run's figure is durable, and that is where to read it when a channel loses
64
- it.** `AgentRun.usage` is written at every checkpoint and again with the terminal
65
- record, so a crashed process leaves behind what it had already spent and a
66
- dropped event is not a lost number:
104
+ `projectAgentHistoryDetailed` reports what reached the provider, including part
105
+ types that no projected content stands for:
67
106
 
68
107
  ```ts
69
- const snapshot = await store.loadSnapshot(conversationId)
70
- const spent = snapshot.runs.find((run) => run.id === runId)?.usage
108
+ const { decisions } = await projectAgentHistoryDetailed(snapshot.messages)
109
+ // → { messageId: 'assistant-2', action: 'projected', reason: 'projected',
110
+ // omittedParts: ['source', 'provider'] }
111
+ // → { messageId: 'assistant-1', action: 'omitted', reason: 'superseded' }
71
112
  ```
72
113
 
73
- Two gaps are open and are stated rather than left to be discovered:
114
+ `runtime.stop(key, 'supersede')` is the same decision taken by hand: the
115
+ process-local escape hatch chooses the reason, so a caller that knows the answer
116
+ was never delivered can discard it without a newer input arriving.
117
+
118
+ ### An input that joins a run in flight
119
+
120
+ `inject` is right when the new input **refines** rather than redirects, and the
121
+ steps already taken are still worth something: *"summarise this thread… actually,
122
+ in bullet points"* should not throw away the reading the first message paid for.
123
+
124
+ What happens:
125
+
126
+ 1. The input is admitted exactly like any other — a committed user message and a
127
+ **queued run**, durable before anything else happens.
128
+ 2. At the running loop's next step boundary, that input joins its prompt. Only
129
+ that input: an unrelated queued submission is never carried in.
130
+ 3. When the run finishes, its terminal commit — **one transaction** — records
131
+ the input as one the run answered and settles the queued successor with
132
+ `terminalReason: 'absorbed'` and `absorbedIntoRunId` naming the run that
133
+ answered it. Every ticket for that successor resolves to the same answer.
134
+
135
+ Nothing durable happens between 1 and 3, and that is the design (→ ADR 0113).
136
+ A run that crashes, is closed, or is interrupted after taking an input on
137
+ commits no absorption at all, so what is left behind is an ordinary queued
138
+ successor — the state every other policy already produces and recovery already
139
+ handles. **There is no ordering in which an accepted input becomes
140
+ unanswerable.** Only a run that *completes* may absorb; an interrupted one took
141
+ the input into its prompt and then stopped, and does not get to say it answered
142
+ it.
143
+
144
+ An absorbed run has **no assistant message of its own** — it produced none, and
145
+ writing an empty one would be a record claiming otherwise. Its answer is
146
+ reachable through `absorbedIntoRunId`, and the store follows that pointer itself
147
+ when a submission arrives on the absorbed run's idempotency key, so a retry
148
+ after a restart returns the answer rather than an empty terminal record.
149
+
150
+ It does publish one more `run-state` event, carrying `'superseded'`. It never
151
+ enters the run executor, so that event is the only thing that tells a delivery
152
+ surface following its `runId` that it is no longer queued.
153
+
154
+ With `coalescePending`, one successor can carry several inputs, and an
155
+ absorption covers a successor **whole or not at all** — a partial one would
156
+ leave a terminal run with inputs nobody answered. Inputs that coalesce before
157
+ the absorbing run's last step boundary join the same absorption. One that
158
+ arrives after it cancels the absorption: the successor then answers all of its
159
+ inputs itself, and the absorbing run has answered one of them too. A duplicate
160
+ answer, never a missing one.
161
+
162
+ (0.63.0 shipped a version of this that committed the absorption at the step
163
+ boundary, before the answer existed, and it was withdrawn in 0.65.0. ADR 0113
164
+ records what that ordering broke.)
165
+
166
+ With `runs.coalescePending: true`, an active lane has at most one queued
167
+ successor. Every later accepted input is atomically appended to that successor;
168
+ its `AgentRun.inputMessageIds` records the whole batch and every input ticket
169
+ resolves to the same terminal run. Coalescing never mutates the active run.
170
+
171
+ `AbortSignal.aborted` is only a cooperation request. A successor does not begin
172
+ while the predecessor still owns managed callbacks. A hung predecessor blocks
173
+ the lane in the first version.
174
+
175
+ Shutdown is two-phase, and both budgets carry the names they carry everywhere
176
+ else in Stitchkit:
74
177
 
75
- - **Both event sinks are bounded and drop under load**, by arrival order — the
76
- event carrying the money is exactly as droppable as the one carrying nothing.
77
- The run record is the recovery, and this paragraph is the only place that says
78
- so.
79
- - **`AgentRuntimeResult.metrics` is `undefined` when this executor did not win
80
- the terminal race.** Read `result.run.usage` — the durable figure — rather than
81
- `result.metrics`, which is the channel that can go missing.
82
- - **Compaction spend is invisible unless you report it.** `config.history.compact`
83
- calls a model inside the turn and produces no step and no event of its own.
84
- Return `usage` from `AgentCompactionResult` and it joins the run's figure;
85
- omit it and the run under-reports by whatever summarising cost.
86
-
87
- What stitchkit does **not** keep is a ledger: one figure, on the run that
88
- produced it, never aggregated and never reconciled against a provider invoice
89
- (→ ADR 0110).
178
+ ```ts
179
+ const closed = await runtime.close({ gracePeriodMs: 30_000, forceTimeoutMs: 5_000 })
180
+ if (!closed.settled) {
181
+ console.warn(`exiting with ${closed.remaining} run(s) still in flight`)
182
+ }
183
+ ```
90
184
 
91
- ### Reconciling with the provider's own accounting
185
+ `close` first rejects new process-local admissions and gives active runs
186
+ `gracePeriodMs` to finish on their own. Only after that budget expires does it
187
+ abort them with reason `shutdown`; `forceTimeoutMs` then bounds the settlement
188
+ wait for a non-cooperative model or tool, measured from the abort — the two
189
+ budgets add up rather than overlapping.
190
+
191
+ **`close()` reports what it achieved rather than promising an outcome it cannot
192
+ reach.** The result is `{ settled, timedOut, remaining }`: `settled` when every
193
+ in-flight run finished, `timedOut` with a `remaining` count when the force
194
+ budget expired first. There is no combination of budgets that is both bounded
195
+ and guaranteed to leave nothing in flight — that is the trade the budgets exist
196
+ to make, and the result is where you read which side of it you got:
197
+
198
+ | budgets | behaviour | can return with a run in flight |
199
+ |---------|-----------|---------------------------------|
200
+ | neither | aborts immediately, waits for settlement | no — unbounded wait |
201
+ | `gracePeriodMs` only | waits, aborts, then waits for settlement | no — unbounded wait |
202
+ | `forceTimeoutMs` only | aborts immediately, waits at most that long | **yes** — `timedOut` |
203
+ | both | waits, aborts, waits at most that long | **yes** — `timedOut` |
204
+
205
+ A caller that wants the old shape still writes `await runtime.close(…)` and
206
+ ignores the result. A caller deciding whether to exit the process reads it.
207
+ Durably queued records rejected from the local queue remain recoverable through
208
+ `scanRecoverable`; close never marks them terminal on its own.
209
+
210
+ Use `await runtime.interrupt({ conversationId, runId })` when the interruption
211
+ must be durable: it first commits `interrupt_requested`, then aborts the local
212
+ coordinator signal. If provider completion races that revision change, the terminal path reloads
213
+ the canonical snapshot. An already-terminal winner settles the ticket directly; a still-owned
214
+ `interrupt_requested` run is committed as `interrupted`, and unrelated aggregate-head conflicts
215
+ remain retriable while the run is active with the same owner and fencing token. A stale owner or
216
+ fencing token remains a conflict. Only the execution that applies the terminal mutation publishes the
217
+ **delivery** `terminal` event — a loser settles from canonical state without republishing the turn,
218
+ and its `AgentRuntimeResult.metrics` is `undefined`. The **operator** `run-terminal` event is not
219
+ gated that way: a losing execution still ran, and still spent whatever it spent, so it reports its
220
+ own usage. The two channels answer to different readers — delivering a turn twice is a user's
221
+ problem, and omitting a run's cost is an operator's.
222
+ `runtime.stop(key)` is the process-local signal-only escape hatch.
223
+
224
+ When an operator has positive evidence that another process owning one known
225
+ run is gone, close that run through the runtime boundary rather than updating
226
+ an adapter table or JSON payload directly:
92
227
 
93
- Whether a provider bills for a call that was aborted mid-flight cannot be known
94
- inside the process: the authoritative number arrives later, from the provider's
95
- accounting. **stitchkit does not accept it back** (→ ADR 0110) — a terminal run
96
- is an absorbing state, and a write that reached it through the conversation
97
- aggregate could conflict a concurrent compaction into discarding a summary it had
98
- just paid a model to produce.
228
+ ```ts
229
+ await runtime.abandon({
230
+ conversationId,
231
+ runId,
232
+ expectedRevision: run.revision,
233
+ staleOwner: true,
234
+ })
235
+ ```
99
236
 
100
- The join is yours, and `runId` is the key:
237
+ The revision protects against stale operator evidence. The mutation updates the
238
+ canonical run and failed assistant atomically, removes the run from recovery
239
+ indexes, and publishes the resulting `run-state`. A run that moved since it was
240
+ read returns a conflict; `staleOwner: true` is mandatory evidence, not a default.
241
+
242
+ The same evidence holds inside one process when a store failure outlives the
243
+ run's own handling. A store call around execution that throws is retried for
244
+ about half a second, then decided by where the run stood. Retries bound a call
245
+ that fails, not one that hangs, so give the store its own timeouts:
246
+
247
+ | Failure | Outcome of `result` |
248
+ |---|---|
249
+ | reading or acquiring the run, retries spent, or a lost reply to the acquisition | resolves with reason `abandoned`, the cause on the operator's `run-terminal` |
250
+ | the first assistant draft | resolves with reason `runtime_failure`, like a checkpoint later in the turn |
251
+ | a conflict on a run another executor holds | rejects with the conflict; the record stays that executor's |
252
+ | a conflict on a run still queued, behind another run | rejects with `AgentRunUnsettledError`; `recover()` or `resume` picks it up |
253
+ | the terminal commit, or closing the run, retries spent | rejects with `AgentRunUnsettledError` |
254
+
255
+ `AgentRunUnsettledError.run` is the record as last read, and the store may be ahead
256
+ of it. Nothing in this process executes that run, and later turns of its
257
+ conversation wait behind it. `recover()` skips a `running` run with a live owner, so
258
+ a long-running process closes it itself once the store answers. Read the record
259
+ first: with no owner or with `error.ownerId` it is this runtime's, and any other
260
+ owner is executing it.
101
261
 
102
262
  ```ts
103
- // when the run terminates — write what the runtime observed
104
- await ledger.record({
105
- runId: terminal.run.id,
106
- conversationId: terminal.run.conversationId,
107
- // The durable figure, not `terminal.metrics` — that one is absent whenever
108
- // this executor did not win the terminal race.
109
- costUsd: terminal.run.usage?.cost?.value ?? null, // null when `unavailable`
110
- provenance: terminal.run.usage?.cost?.provenance ?? 'unavailable',
263
+ const settled = await ticket.result.catch(async (error) => {
264
+ if (!(error instanceof AgentRunUnsettledError)) throw error
265
+ const { conversationId, id: runId } = error.run
266
+ const current = await store.loadRun({ conversationId, runId })
267
+ if (current && [undefined, error.ownerId].includes(current.run.ownerId)) {
268
+ await runtime.abandon({
269
+ conversationId,
270
+ runId,
271
+ expectedRevision: current.run.revision,
272
+ staleOwner: true,
273
+ })
274
+ }
275
+ throw error
111
276
  })
277
+ ```
278
+
279
+ A `queued` run lost to a run ahead of it, and `runtime.resume()` can run it instead
280
+ once that one ends.
281
+
282
+ ## Store operations
283
+
284
+ `AgentRuntimeStore` remains the runtime-facing aggregate. Application adapters
285
+ implement the smaller `AgentRuntimeStoreDriver` rather than these eleven members:
286
+
287
+ - `acceptInputAndAssignRun`
288
+ - `acquireRun`
289
+ - `checkpointRunAssistant`
290
+ - `requestRunInterrupt`
291
+ - `recoverRun`
292
+ - `commitRunTerminal`
293
+ - `replaceCompactedRange`
294
+ - `loadSnapshot` — the whole conversation; see **Reading a conversation** below
295
+ - `loadRun` — one run by id, with the answer it produced if it has ended
296
+ - `listActiveRuns` — the runs of one conversation that have not ended
297
+ - `scanRecoverable` — one **bounded page** of recoverable runs; `recover()`
298
+ calls this and nothing else, so an adapter that implements the interface has
299
+ everything recovery needs
300
+
301
+ ### Writing history no run owns
302
+
303
+ `seedConversationInput` writes messages no run owns, exactly once per `seedKey`; the key is
304
+ recorded beside the history, so compaction or replacement of the messages never brings them
305
+ back. It places them by `placement`:
306
+
307
+ - absent or `'lead'` — committed user instructions ahead of the whole conversation;
308
+ - `'after-settled'` — an exchange that already happened without a model (a confirmed voice
309
+ command and its receipt): committed `user` and completed `assistant` messages. They land after
310
+ the settled history and ahead of the first message of any run still active, so an active run's
311
+ input and answer stay adjacent and the next run reads the exchange before the input queued
312
+ behind it. With no active run they are the tail.
112
313
 
113
- // later — the provider's accounting names the same generation
114
- await ledger.reconcile({ runId, costUsd: billed, provenance: 'provider-reported' })
314
+ ```ts
315
+ await store.seedConversationInput({
316
+ conversationId,
317
+ seedKey: `voice:${commandId}`,
318
+ placement: 'after-settled',
319
+ inputs: [ownerMessage, receiptMessage],
320
+ })
115
321
  ```
116
322
 
117
- Record the row even when the figure is `unavailable`: that row is the evidence
118
- that a run happened and cost something nobody has counted yet, and it is what the
119
- provider's later figure attaches to. A run with no row is a run you cannot
120
- reconcile.
323
+ The pair is ordinary history: it is in `loadSnapshot`, compaction may summarize it, and the next
324
+ provider request carries it. No model is called and no run is created.
325
+
326
+ ### Reading a conversation
327
+
328
+ Two shapes of read, and the difference matters as a conversation grows.
329
+
330
+ **Bounded.** `loadRun` and `listActiveRuns` read run records and the
331
+ conversation head. Neither touches history, so neither grows with the length of
332
+ the conversation. `loadRun` is how you resolve the `runId` that
333
+ `submit().admission` hands back — it returns the run, the version it was read
334
+ at, and, once the run is terminal, the answer it produced.
335
+
336
+ **Unbounded.** `loadSnapshot` returns every message and every run, and so does
337
+ every mutation result: the store's reducer validates its invariants against the
338
+ whole conversation, and the runtime builds the next prompt from the snapshot the
339
+ mutation returns. Ask for it when you need the conversation — composing a
340
+ prompt, or compacting — and not to look one run up.
341
+
342
+ So the cost of a run scales with the length of its conversation, not with the
343
+ length of the turn. **Configure compaction** (see below) for anything
344
+ long-running: it is the only thing in the framework that makes a conversation
345
+ smaller, and without it a year-old assistant thread is read in full on every
346
+ turn. This is a known limit, held deliberately rather than by omission — paged
347
+ history would have to change what a snapshot *is*, and the store's invariants
348
+ with it, and that is a decision on its own (→ ADR 0112).
349
+
350
+ Every mutation carries an expected run revision or snapshot version. Input
351
+ assignment additionally carries an idempotency identity. A conflict is a
352
+ control outcome; stale data is never silently overwritten.
353
+
354
+ Acquisition increments an optional monotonic `fencingToken`. The managed runtime carries it through
355
+ checkpoint/terminal CAS and tool context, so a distributed adapter can reject an old owner even if
356
+ an owner label is reused. Lease expiry and renewal remain application-owned.
357
+
358
+ On startup, `runtime.recover({ resolveContext })` consumes bounded lightweight
359
+ pages, then restores each conversation's durable execution order before any run
360
+ acquires. Persisted `executionSequence` orders work that started; queued
361
+ `interrupt-next` work precedes ordinary queued work with FIFO preserved inside
362
+ each class. Scan identifiers, equal timestamps and page boundaries therefore
363
+ never become queue order. Its safe default resumes queued runs and reports acquired or
364
+ `interrupt_requested` runs as skipped. A policy may requeue acquired work only
365
+ with explicit replay-safe evidence, or abandon it only with stale-owner
366
+ evidence. Each attempted run returns its own outcome/error; a `resumed` or
367
+ `requeued` outcome also exposes the terminal `result` promise. The outcome is
368
+ reported only after durable acquisition, so a lost acquisition is `failed`
369
+ rather than a successful-looking handoff. `pageSize`, `maxRuns`, and `signal`
370
+ bound the pass. `runtime.resume(...)` remains available for one known queued
371
+ record; its `accepted` promise likewise means that the recovered run acquired
372
+ durable ownership, while `result` carries terminal completion.
373
+
374
+ Canonical records currently write `schemaVersion: 1`. A durable adapter owns
375
+ read-time migration of older rows: migrate to the current shape at its storage
376
+ boundary, validate with the exported schema, and write only the current
377
+ version. Core deliberately does not guess an application's database migration
378
+ or silently accept an unknown future version.
379
+
380
+ ### Purging a conversation
121
381
 
122
- The sink deduplicates stable event IDs by default. Cross-crash exactly-once still requires a durable
123
- outbox.
382
+ ```ts
383
+ import { purgeAgentConversation } from 'stitchkit/agent-runtime';
124
384
 
125
- ## Deterministic race and adapter proof
385
+ const result = await purgeAgentConversation(store, {
386
+ conversationId,
387
+ expectedVersion: snapshot.version, // optional stale-intent protection
388
+ });
389
+ ```
126
390
 
127
- `stitchkit/agent-runtime/testing` exports `createAgentRaceBarrier`, `createAgentRaceDriver`,
128
- `createAgentRaceTrace` and `runAgentStoreConformance` — agent testing tools live beside the agent
129
- runtime, and `stitchkit/testing` imports none of it. Barriers have bounded teardown, traces assert exact partial order, and the
130
- helpers are exercised from packed Bun and Node consumers. `runAgentStoreConformance` runs duplicate,
131
- coalescing, collision, stale checkpoint, replay safety, terminal race, absorption, bounded reads,
132
- causal queued-history order, durable interrupt priority, compaction and recovery invariants against
133
- any fresh durable adapter.
391
+ The optional `AgentRuntimeStore.purgeConversation` capability is implemented by the memory and
392
+ initialized Bun/Node SQLite stores. The helper returns `unsupported` for stores without it; it
393
+ never pretends deletion succeeded. Other outcomes are `purged`, `already_purged`,
394
+ `active` (with `runIds`), or `conflict` (with `actualVersion`). Storage failures reject.
395
+
396
+ Purge refuses queued, running and interrupt-requested runs. It does not interrupt, force-abandon
397
+ or wait for a provider. Authorize the destructive request in the host, close its conversation
398
+ ingress/attachments, then settle runs through normal interruption or explicitly justified recovery.
399
+ Retry purge after they are terminal. An optional expected version must match the current snapshot;
400
+ a version conflict requires a fresh deletion decision, not blind retry with a new version.
401
+
402
+ Successful purge atomically removes messages (including compacted inactive rows), all runs and
403
+ retained terminal assistants, admissions/idempotency receipts, conversation heads and their owned
404
+ indexes. It retains only a permanent conversation-ID tombstone. Purging an unknown ID reserves it
405
+ as well, so a submit paused before admission cannot create it later. Repeating the call returns
406
+ `already_purged`, regardless of the original expected version. A lost response is safely retried
407
+ with the same ID; **new chats require a new ID**.
408
+
409
+ Every later runtime mutation rejects with `AgentConversationPurgedError`, including fresh and
410
+ duplicate admissions, checkpoints, recovery and compaction. Existing controller leases grant no
411
+ exception. Existing reads return empty snapshots, absent runs and empty message pages; purged
412
+ conversations disappear from the SQLite catalog and recovery scans. Snapshots retain their
413
+ existing empty `version: 0` shape; use the purge result, not an empty snapshot, as deletion evidence.
414
+
415
+ SQLite commits the tombstone and all deletions in one `BEGIN IMMEDIATE` transaction. Failure before
416
+ commit rolls everything back; reopen/recovery cannot restore committed deleted records. Same-thread
417
+ connection contention rejects promptly without effects; retry after the competing operation settles.
418
+ Initialization adds the tombstone table and write guards to schema v1 transactionally, without
419
+ changing application tables or `user_version`. Guards also fence older writers. `initialize: false`
420
+ on an original v1 database does not add purge capability; open an initialized writer first.
421
+
422
+ Consumer-owned model selections, projections, attachment files, delivery/outbox records and event
423
+ logs remain outside this boundary. Retain an authorized cleanup intent plus any needed opaque file
424
+ references in consumer storage, purge first, then retry idempotent consumer cleanup. Invalidate local
425
+ caches/subscriptions so an old delivered event cannot repopulate a UI. The library never traverses
426
+ paths, revokes remote artifacts or claims a cross-store transaction. Nor does logical purge securely
427
+ wipe database free pages, WAL or backups. Memory tombstones last only for that store instance.
428
+
429
+ Custom normalized drivers may opt in through `AgentConversationPurgeDriver` on `driver.conversations`.
430
+ Their transaction must serialize `isPurged`, head/active-run checks, `remove`, and **all** mutations
431
+ for the same ID, including absent-ID admission. An optimistic head CAS alone is insufficient. `remove`
432
+ must delete every owned payload/index and preserve the tombstone; errors must roll back the entire
433
+ transaction. Drivers without this guarantee must leave the capability absent. See ADR 0138.
434
+
435
+ ## Events and reconnect
436
+
437
+ A subscriber that switches over `event.type` handles one transient event
438
+ beyond deltas and reasoning: `attempt-reset`, published when `loop.retry`
439
+ starts a new attempt, meaning everything this run streamed before it is
440
+ withdrawn. `reduceAgentControlEvent` does that; a hand-written reducer must,
441
+ or it keeps the failed attempt's partial text on screen.
442
+
443
+ `publish` receives event classes with different guarantees:
444
+
445
+ - `admission` follows a successful acceptance CAS and carries the same complete
446
+ projection as `ticket.admission`;
447
+
448
+ - `assistant-delta` is transient and ordered by
449
+ `(runId, runtimeEpoch, sequence)`;
450
+ - `reasoning-start`, `reasoning-delta` and `reasoning-end` are transient and
451
+ ordered by the same identity; delta carries only the current text fragment,
452
+ while provider metadata stays inside a validated canonical envelope;
453
+ - `assistant-checkpoint` follows a successful checkpoint CAS;
454
+ - `run-state` follows durable queue/acquire/interrupt transitions;
455
+ - `run-operation` follows a successful operation CAS. Its `operation` is the
456
+ durable `AgentRun.lastOperation`: `model-request` or `compaction`, with
457
+ `started`, `first-output`, `completed`, `failed` or `cancelled` phase and the
458
+ original timestamps. Concrete models use SDK call identity in an awaited model
459
+ middleware; global provider IDs use a runtime-generated identity during awaited
460
+ step preparation. Both durably admit the request before entering `doStream`, so a failed
461
+ write prevents the provider call. This does not claim HTTP transmission is
462
+ measured. First output is a
463
+ non-empty text/reasoning/tool-argument delta or complete tool call; metadata,
464
+ usage, files and sources do not count;
465
+ - `tool-status` is transient lifecycle presentation with JSON-safe input on
466
+ start and output on completion. A mounted typed failure carries the same safe
467
+ `{ error, retryable, details?, _hint? }` envelope as the durable result; an unknown
468
+ internal cause remains generic and stays in local observability only;
469
+ - `tool-progress` is transient: what a running tool handler reported through
470
+ `ctx.reportProgress` — `message`, `progress` and `total`, the fields an MCP host
471
+ receives — with the call's `callId` and `toolName`. It never enters the
472
+ model's input or the durable history. A message longer than
473
+ `AGENT_TOOL_PROGRESS_MESSAGE_MAX` (280) is cut, reports of one call closer
474
+ than 250 ms apart are dropped, and a report after the call's terminal
475
+ `tool-status` or after its attempt was abandoned is dropped; the terminal
476
+ status carries the final word. `reduceAgentControlEvent` keeps the last
477
+ report of each running call in `transientByRun[runId].tools[callId]` and
478
+ removes it on that call's `completed`, `failed` or `interrupted`. The runtime
479
+ hands its reporter to a call through the SDK tool context, beside its step
480
+ ledger; a tool that declares its own `contextSchema` keeps that contract and
481
+ reports nowhere;
482
+ - `terminal` follows the winning terminal CAS.
483
+
484
+ `loop.checkpointEveryEvents` batches ordinary stream parts (default `20`) and
485
+ does not batch live event delivery. Tool call/result/error/denial, approval
486
+ request/response and step-finish boundaries force an assistant checkpoint and
487
+ reset that batch. The checkpoint is awaited after the managed loop observes the
488
+ normalized boundary. It does **not** promise persistence before a tool side
489
+ effect: tool fencing supplies the real ownership checks around execution, while
490
+ cross-crash effect idempotency stays application-owned. The following model
491
+ step waits for the previous step-finish checkpoint before its own durable
492
+ request admission and provider invocation. → ADR 0174.
493
+
494
+ `compaction` names invocation of the configured callback, including a
495
+ `not_needed` context-budget check; it does not by itself say that history was
496
+ summarized. Operation timestamps are retained wall-clock observations and may
497
+ move backwards after a clock correction. Do not subtract them for duration;
498
+ phase order comes from durable snapshot versions/event order.
499
+
500
+ These are post-commit notifications, not a transactional outbox: a process can
501
+ crash between the database commit and `publish`. Reconnect should load canonical
502
+ state. Exactly-once external delivery remains an application-owned outbox.
503
+
504
+ ### External channel ingress and delivery
505
+
506
+ The executable
507
+ [`external-channel-harness.ts`](../../packages/core/examples/agent-runtime/external-channel-harness.ts)
508
+ is the reference composition for webhooks, polling transports and local-device
509
+ adapters. Its generic boundary injects a runtime, durable store and delivery
510
+ adapter; the optional
511
+ [`external-channel-grammy.ts`](../../packages/core/examples/agent-runtime/external-channel-grammy.ts)
512
+ attaches the same ingress function to grammY polling or webhook lifecycle
513
+ without putting provider types in the agent runtime.
514
+
515
+ Inbound update identity, principal resolution, conversation mapping and reply
516
+ target are application policy. Persist and deduplicate the update before
517
+ `runtime.submit`, use the stored idempotency key on crash recovery, then persist
518
+ the admitted `runId`. A duplicate arriving after restart resolves to that same
519
+ mapping instead of creating another model turn.
520
+
521
+ For output, choose `terminal-only` or `streaming` explicitly. Stable durable
522
+ `eventId` values and transient `(runtimeEpoch, sequence)` identities become
523
+ outbox keys; the application store assigns causal ordinals. A bounded channel
524
+ is only a process-local wakeup and never the durable authority. Before sending,
525
+ persist one of these states:
526
+
527
+ | State | Recovery action |
528
+ | --- | --- |
529
+ | `not-dispatched` | may dispatch once |
530
+ | `possibly-dispatched` | reconcile with the adapter or remain unresolved |
531
+ | `acknowledged` | deduplicate; delivery is complete |
532
+
533
+ The agent `terminal` event and the adapter's delivery receipt are intentionally
534
+ different records. A successful run does not prove that a reply reached its
535
+ channel, and a delivery failure never rewrites the canonical agent result.
536
+ Shutdown stops ingress, drains accepted process-local work and leaves ambiguous
537
+ outbox records visible for a later reconciliation pass. Stitchkit does not own
538
+ a channel database, durable broker, provider payload schema or application
539
+ identity policy.
540
+
541
+ Durable event IDs are derived from run, event type and snapshot version. Use
542
+ `advanceAgentRuntimeEventCursor` to classify delivery.
543
+
544
+ **`gap` is reported for transient events only**, where `sequence` is a per-run
545
+ counter the runtime really does increment once per event. Durable events carry
546
+ the *conversation's* version, which advances on every mutation including the many
547
+ that publish nothing — checkpoints, compaction, an acceptance that has not
548
+ started — so two consecutive durable events are routinely several versions apart
549
+ and adjacency says nothing. A durable loss is not detectable from the cursor;
550
+ reload on reconnect, which is what a bounded fire-and-forget sink asks of you
551
+ anyway. `createAgentRuntimeEventSink` adds a bounded failure-isolated lifecycle and typed
552
+ projection/redaction hook. `onPublishError` records direct publisher failures without changing the
553
+ already committed run.
554
+
555
+ A named custom stop condition terminalizes with `policy_stop`; its `policyName`
556
+ is persisted on the run and included in the terminal event/result. `max-steps`
557
+ is the reserved built-in policy name. `loop.prepareStep` is the controlled AI
558
+ SDK step boundary for changing active tools, model, instructions or messages.
559
+ It cannot replace the managed tool set or bypass its lifecycle fence.
560
+
561
+ Context can grow between steps as tool results and deferred schemas enter the
562
+ provider prompt. When application budgeting can prove that the next assembled
563
+ step exceeds the selected model window, refuse it by type before that provider
564
+ call:
134
565
 
135
- It picks its conversation identities itself and passes them to `createStore(context)` **before the
136
- first mutation**, so an adapter whose runtime rows reference an application-owned conversation row
137
- can provision those parents; the optional `cleanup(context)` removes them again, and runs once
138
- whether the scenario passed or failed. A factory that owns no fixture state ignores the argument and
139
- keeps working unchanged.
566
+ ```ts
567
+ prepareStep: (step) => {
568
+ if (wouldExceedSelectedModelWindow(step)) {
569
+ throw new AgentContextOverflowError('Prepared step exceeds the selected model window')
570
+ }
571
+ return chooseProductStepOptions(step)
572
+ }
573
+ ```
140
574
 
141
- ## Plain completions and shared invocation receipts
575
+ That deliberate refusal ends the run as `context_overflow` on the durable
576
+ record, delivery terminal and operator event. Stitchkit does not inspect error
577
+ messages — it recognises failures by identity, not by text. A store refusing an
578
+ owned mutation ends the run `storage_conflict`; `protocol.acceptTerminal`
579
+ refusing a finished message ends it `output_rejected`; a check the SDK runs on
580
+ what this runtime handed it ends it `runtime_failure`. Every error the runtime
581
+ cannot identify as its own remains `provider_failure`, and operator-only
582
+ observability retains its original cause in all of them.
142
583
 
143
- Use `createModelInvocationLedger` from `stitchkit/agent-runtime` when a direct text
144
- completion and an agent must share causal provider evidence. It uses the existing
145
- runtime store, not a second database. The completion path sends exactly one user
146
- prompt, no framework instructions and no tools. It never starts an agent loop.
584
+ Completion validity belongs to the protocol and is checked before the terminal
585
+ CAS. Protocols that require a visible answer opt in explicitly:
147
586
 
148
587
  ```ts
149
- import { createModelInvocationLedger } from 'stitchkit/agent-runtime';
150
-
151
- const invocations = createModelInvocationLedger({
152
- store, // the same AgentRuntimeStore object passed to createAgentRuntime
153
- models, // an existing defineModelRegistry result
154
- payloadKey, // 32 bytes from the host's secret store; retain separately from archives
155
- authorize: async ({ context, conversationId, action, trace }) => {
156
- const session = await authenticate(context);
157
- await authorizeConversation(session, conversationId, action, trace);
158
- return { subject: session.subject }; // verified identity, never a body field
159
- },
160
- });
588
+ const protocol = defineAgentProtocol({
589
+ context: ContextSchema,
590
+ inputMetadata: InputMetadataSchema,
591
+ terminalAcceptance: 'require-output',
592
+ })
593
+ ```
161
594
 
162
- const completion = await invocations.complete({
163
- conversationId: 'evaluation-42',
164
- idempotencyKey: 'summary-case-3',
165
- trace: {
166
- operationId: 'summary-42', purpose: 'summarize', project: 'example',
167
- parent: { traceId: 'trace-42' }, experiment: 'evaluation', case: 'case-3',
168
- },
169
- prompt: 'Summarize these supplied facts.',
170
- models: ['primary', 'fallback'], // registry keys; repeat a key for an explicit retry
171
- settings: { maxOutputTokens: 500 },
172
- timeoutMs: 30_000,
173
- }, authenticatedContext);
174
-
175
- // Add to the existing createAgentRuntime configuration:
176
- const receiptConfig = {
177
- ledger: invocations,
178
- trace: ({ run }) => ({
179
- operationId: run.id, purpose: 'assistant-turn', project: 'example',
595
+ The built-in rule accepts non-blank text, generated files, structured provider
596
+ parts, and a named tool-only `policy_stop`. The default is `allow-empty` for
597
+ protocols where an empty acknowledgement is meaningful. Pass a callback when
598
+ the product has a narrower definition; it receives the candidate message,
599
+ terminal reason and optional policy name, and runs before persistence. A false
600
+ result fails the candidate instead of rewriting an already committed success.
601
+
602
+ `loop.idleTimeoutMs` measures provider silence. It starts immediately before the
603
+ provider call, resets on stream activity, and pauses while local tools execute.
604
+ Resource preparation, child waits and approval waiting do not spend this budget.
605
+ The host gives tools their own deadlines. A stalled provider still aborts with
606
+ durable reason `timeout`; caller interruption and shutdown reach tools directly.
607
+
608
+ Reconnect loads the durable snapshot. Missing transient deltas do not mean the
609
+ canonical result was lost. Exactly-once external delivery requires the
610
+ application's transactional outbox or stable-ID deduplication.
611
+
612
+ ## Managed tools
613
+
614
+ Durable `step` bodies return lossless JSON: finite numbers (excluding negative zero),
615
+ strings, booleans, null, dense arrays and plain data objects. Return `null` explicitly
616
+ for effect-only bodies and convert Date values to strings. Unsupported values are
617
+ refused after the body, so external effects still require idempotency. First results
618
+ and replay are detached snapshots; mutating a returned object cannot rewrite a record.
619
+ The optional [sandbox coding profile](sandbox.md#coding-tool-integration) reuses the
620
+ same tool mounting, authorization and output handling for namespace-backed commands.
621
+
622
+ Always compose `toolFenceLifecycle` into `mountAgent`. It checks ownership
623
+ before a managed side effect and again before accepting its result. Fence loss
624
+ uses an internal control signal: it stops the old loop and is not sent to the
625
+ model as a tool error.
626
+
627
+ `mountAgent` sends failed runner outcomes through the AI SDK's tool-error
628
+ channel. AgentRuntime persists them with `outcome: 'error'`, publishes the same
629
+ safe envelope, and replays them as an error result on later turns. Classification
630
+ comes from the execution channel, never from inspecting output fields: a valid
631
+ successful result such as `{ error: 'domain value' }` remains successful. Typed
632
+ `AppError` codes and safe details may reach the model; arbitrary thrown causes
633
+ are retained for local hooks and reduced to `INTERNAL_SERVER_ERROR` outside.
634
+
635
+ The framework cannot undo an already-started non-cooperative external effect.
636
+ Pass the stable call/run idempotency identity into business mutations when the
637
+ effect must be replay-safe.
638
+
639
+ ## History, provider metadata and files
640
+
641
+ `projectAgentHistory` converts canonical engine records into provider-valid AI
642
+ SDK messages and pairs tool calls/results in persisted causal order. Adjacent
643
+ parallel calls stay in one assistant round and their adjacent results stay in
644
+ one tool round; a dependent call after those results starts a new assistant
645
+ round, and trailing final text remains after the last tool result. Provider-required metadata is kept
646
+ in a versioned opaque envelope and omitted from product delivery by default.
647
+ `projectAgentHistoryDetailed` additionally returns one inspectable decision per canonical record;
648
+ leading assistant records, crash drafts and unmatched tool chronology are never silently passed to
649
+ the provider.
650
+
651
+ `selectAgentHistory` is the non-destructive context-window selector. It removes only whole oldest
652
+ complete turns, protects system/summary, incomplete and configured recent turns, and reports every
653
+ keep/remove reason with measured/estimated/unavailable token provenance. An oversized protected turn
654
+ returns `oversized`; unavailable accounting returns `unavailable` without invented arithmetic.
655
+
656
+ `ComposedAgentPrompt.instructions` accepts the AI SDK `Instructions` contract.
657
+ Use `adaptInstructions` when a provider needs metadata on the system message:
658
+
659
+ ```ts
660
+ prompt({
661
+ context,
662
+ signal,
663
+ adaptInstructions: (content) => ({
664
+ role: 'system',
665
+ content,
666
+ providerOptions: { openrouter: { cacheControl: { type: 'ephemeral' } } },
180
667
  }),
181
- };
182
- // createAgentRuntime({ ...existingConfig, invocations: receiptConfig });
668
+ })
669
+ ```
670
+
671
+ Attachments and generated files remain application-owned. The runtime only
672
+ stores a reference returned by `persistGeneratedFile`; it never silently puts
673
+ base64 blobs into the neutral history store.
674
+
675
+ To send a stored attachment back to a multimodal model, configure
676
+ `history.resolveFile`. It maps the neutral file reference to AI SDK file data
677
+ (URL, bytes, provider reference or text). Without a resolver the
678
+ `unresolvedFile` policy applies, and it is **`omit`** by default: the file part
679
+ simply does not reach the provider. Choose `text` for a describing placeholder —
680
+ the filename or media type, never the storage reference, because that string is
681
+ an address inside your infrastructure and this content travels upstream — or
682
+ `error` to fail loudly. The `error` message does name the reference: it is
683
+ thrown into your process, where you are owed the whole story.
684
+
685
+ ## Compaction
686
+
687
+ `structuredCompaction` summarizes complete provider-valid turn groups outside
688
+ the store lock, then calls `replaceCompactedRange` against the exact snapshot
689
+ version. A concurrent input produces `conflict`; the stale summary is not
690
+ applied. Summary records use the dedicated `summary` role and project to a
691
+ provider system message. The consumer supplies the structured summary schema
692
+ and prompt. Pass `previousSummary` for a direct call, or configure
693
+ `readPreviousSummary` for runtime-managed compaction, to merge and atomically
694
+ replace a leading summary on the next compaction.
695
+ Set `maxAttempts` to allow bounded conflict recovery. Every retry reloads the snapshot, reselects the
696
+ eligible range and recomputes the summary; the stale summary is never retried.
697
+
698
+ ### Recovering a prefix after a failed turn
699
+
700
+ The default evidence policy omits failed assistants. A failed turn therefore stops the
701
+ compactable prefix, including completed turns after it. A terminal failure is not an
702
+ unanswered tool call, but opting its partial evidence into a summary is an explicit host
703
+ decision. Use the existing `evidencePolicy: { failedAssistant: 'assistant-marked' }` on
704
+ `structuredCompaction` (or `selectCompactableHistory`) when that evidence is wanted.
705
+ The same policy belongs on the summarizer's history projection and on any corresponding
706
+ token-budget selection. Stored statuses remain unchanged.
707
+
708
+ The summarizer receives **raw** `eligibleMessages`, not provider messages and not an
709
+ automatic failure marker. Project them with the same policy before sending them to a
710
+ model, and preserve failed outcomes in the application's structured summary and prompt:
711
+
712
+ ```ts
713
+ const evidencePolicy: AgentHistoryEvidencePolicy = {
714
+ failedAssistant: 'assistant-marked',
715
+ }
716
+
717
+ // Inside structuredCompaction({ evidencePolicy, ... }).summarize:
718
+ const projected = await projectAgentHistoryDetailed(eligibleMessages, {
719
+ evidencePolicy,
720
+ incompleteToolTurn: 'error',
721
+ })
722
+ const failedMessageIds = eligibleMessages
723
+ .filter(message => message.role === 'assistant' && message.status === 'failed')
724
+ .map(message => message.id)
725
+ // Pass projected.messages, projected.system and failedMessageIds to the host's
726
+ // summarizer. Its schema/prompt must retain failures and uncertain effects as
727
+ // such, merge previousSummary, and never describe partial output as success.
728
+ ```
183
729
 
184
- const page = await invocations.read({
185
- conversationId: 'evaluation-42', limit: 100,
186
- }, authenticatedContext);
187
- // Continue with fromSeq: page.nextSeq while it is defined.
730
+ An opted-in failed assistant projects an explicit non-success marker even when it has
731
+ no text or only control parts. A dangling call, unresolved approval, invalid result
732
+ chronology or a user input with no assistant remains a barrier under either policy.
733
+ Raw whole-turn completeness does not guarantee that every record can be projected:
734
+ for example, a call and its result split across records may be rejected by the projector.
735
+ `incompleteToolTurn: 'error'` makes that rejection stop summarization; the original history
736
+ stays canonical. A host with such records must use a status-aware summarizer that explicitly
737
+ represents every relevant record, or retain the range. Do not silently summarize an omitted
738
+ exchange, filter the failed record out of the replacement range, rewrite its status or skip
739
+ over an incomplete turn. The replacement remains one contiguous prefix under snapshot CAS.
740
+
741
+ Provider chronology and external effect authority answer different questions (ADR 0269).
742
+ A matching `tool-result` with `outcome: 'error'` and an application code such as `UNKNOWN`
743
+ closes the provider exchange; it does **not** prove the external effect failed or succeeded.
744
+ Compaction changes the model-history projection, not the canonical event journal or the
745
+ retained terminal admission receipt. The `durability/effect` intent, accepted proof and
746
+ uncertain outcome remain available after compaction and archive restore. A reconstructed
747
+ `createLocalStepDurability` using the original `(conversationId, runId, effectName)` returns
748
+ the accepted proof, reconciles a bare intent, or returns `uncertain` without calling `run`
749
+ again. It does not deduplicate a new run or a newly named effect. Standalone effect callers
750
+ still own the exclusive execution lease; sharing a ledger is not an atomic claim.
751
+ An application-owned receipt outside that journal retains its own recovery rules and must
752
+ not be discarded because a history turn became compactable.
753
+
754
+ ### Compacting without the store
755
+
756
+ The *selection* inside compaction needs no store, no version and no runtime, so
757
+ it is published on its own as `selectCompactableHistory` — for an application
758
+ that drives the model itself and keeps its own history (→ ADR 0142):
759
+
760
+ ```ts
761
+ import { selectCompactableHistory } from 'stitchkit/agent-runtime'
762
+
763
+ const { leadingSummary, compactable, retained } = selectCompactableHistory({
764
+ messages,
765
+ keepRecentTurns: 3,
766
+ })
767
+ if (compactable.length > 0) {
768
+ const summary = await summarize(compactable, leadingSummary)
769
+ messages = [summary, ...retained] // replace the old summary, never stack one
770
+ }
188
771
  ```
189
772
 
190
- `complete` returns `invocationId`, `outcome` and, on success, `text`. `duplicate`
191
- returns the existing ID without provider execution or replaying plaintext. Changed
192
- input, trace or caller under the same key is a conflict. Model attempts are explicit
193
- and bounded to eight; SDK retries are disabled. Only provider failures trigger the
194
- next attempt. Abort, audit refusal and configuration errors do not. An admitted call
195
- whose process died stays claimed: inspect the receipt before choosing a new key.
196
-
197
- The agent retains its existing prompt, tools and loop. Both modes produce
198
- `invocation/started`, `provider/request`, `provider/response` and
199
- `invocation/finished` records through `read`. Each attempt has its own ID; response
200
- usage is counted once and keeps `unavailable` distinct from measured zero. Requested
201
- registry selection, sent adapter identity and effective provider identity are
202
- separate. Effective identity is null when not reported. A gateway's hidden retries
203
- are not visible as additional attempts.
204
-
205
- `currentModelInvocationAttempt()` returns the causal context inside a provider's
206
- `fetch` callback or streamed read. Use it to join a host transport audit to
207
- `operationId`, `invocationId`, `attemptId`, optional `runId` and `step`.
208
- `onAttempt` is an awaited admission hook; a rejected hook prevents the provider call.
209
- SDK payload hashes are not represented as hashes of HTTP wire bytes.
210
-
211
- Prompts and output are AES-256-GCM payload artifacts. `read` returns references and
212
- hashes; `readPayload({ conversationId, artifactId }, context)` separately authorizes
213
- `read-payload` and checks authentication tags and hashes. `payloadKey` must be 32
214
- bytes and is copied at construction. Keep it outside the event store/backups; retain
215
- the appropriate key when restoring an archive. `maxPayloadBytes` defaults to 4 MiB
216
- (range 1 KiB–64 MiB). Streaming output evidence keeps parts up to half of it; past
217
- that the evidence carries `truncated: { parts, sha256 }` over the whole stream, and
218
- the answer itself is never cut. A receipt that cannot be written never changes an
219
- outcome: `complete()` returns it with `receiptError` beside it, and a succeeded
220
- answer keeps its `text`. Request headers
221
- are excluded. Protect existing agent history independently: enabling receipts does
222
- not encrypt its ordinary conversation records.
223
-
224
- The built-in memory and SQLite stores implement `appendEventOnce`. Custom stores
225
- must implement its once-only check and append atomically, returning the original
226
- event on a duplicate key scoped by conversation and kind. A factory without this
227
- capability refuses receipt setup. The driver-backed implementation finds the keyed
228
- event by its identity through the driver's optional `events.find` — one index probe
229
- in SQLite — so admission costs the same on a long conversation as on a new one; a
230
- custom driver without `find` falls back to paging the log in one transaction.
231
- `store.findEventOnce({ conversationId, kind, key })` reads the same event back.
232
- Invocation payload artifacts are keyed by their artifact id the same way.
233
-
234
- ### Migration from direct SDK completions
235
-
236
- **Who must act:** applications opting into shared completion/agent receipts.
237
- Replace the direct `generateText` call with `invocations.complete`, configure host
238
- authorization and a retained payload key, and pass the same ledger in the agent's
239
- `invocations` option. Use the returned invocation ID and the paginated `read` method
240
- for receipt queries; do not import runtime internals. Available since 0.100.0;
241
- existing runtime configurations need no migration.
773
+ `compactable` is the oldest **whole complete** turns and `retained` is
774
+ everything the model must still hear; together they are the input, in order. A
775
+ turn holding a tool call whose result never arrived is never eligible, and
776
+ neither is anything after it: half a turn hands the provider a call with no
777
+ result, which most of them refuse outright.
242
778
 
243
- ==============================================================================
244
- # API reference: `stitchkit/agent-runtime` (docs/api/reference.md)
245
- ==============================================================================
779
+ `structuredCompaction` calls this same function, so the published selection and
780
+ the runtime's cannot drift into two behaviours. What it adds is the part that
781
+ does need the store — writing the result back under a version check.
246
782
 
247
- ## `stitchkit/agent-runtime`
248
-
249
- Server-only optional application runtime. See the
250
- [agent runtime guide](../guide/agent-runtime.md).
251
-
252
- | Export | Kind | Summary |
253
- |--------|------|---------|
254
- | `createAgentRuntime` | function | compose durable acceptance, stream loop, checkpoints, coordination, managed tools and winner-only terminal publication; reconciles same-owner terminal/interrupt/head CAS races before releasing the lane |
255
- | `createDeferredAgentToolSurface` | function | bounded canonical catalog search plus durable same-run activation of direct `mountAgent` tools; supports one immutable surface or a finite identity-specific registry |
256
- | `DeferredAgentToolSearchInputSchema` / `DeferredAgentToolMatchSchema` / `DeferredAgentToolReceiptSchema` | schema | bounded search input, public match and versioned durable selection receipt |
257
- | `DeferredAgentToolSurfaceConfig` / `DeferredAgentToolCommonConfig` / `DeferredAgentToolSurfaceDefinition` / `DeferredAgentToolController` | _type_ | finite catalogs, selector, pin, budget and `mount`/`prepareStep` composition contracts |
258
- | `DeferredAgentRuntimeToolDefinition` / `DeferredAgentToolMountConfig` | _type_ | peer-free Agent-only runtime definition and canonical mount configuration accepted by the controller |
259
- | `DeferredAgentToolManifestEntry` / `DeferredAgentToolSearchContext` / `DeferredAgentToolReceipt` | _type_ | canonical selector input and inferred durable receipt records |
260
- | `DeferredAgentToolEvent` | _type_ | PII-free search/step evidence with surface, provenance, counts, canonical schema bytes and ceilings |
261
- | `defineAgentProtocol` | function | declare context, input metadata, canonical parts and optional pre-CAS terminal acceptance (`allow-empty`, `require-output` or callback) |
262
- | `hasAgentTerminalOutput` | function | generic `require-output` predicate for non-blank text, generated files, structured provider parts and explicit tool-only policy stops |
263
- | `AgentMessageSchema` / `AgentRunSchema` / `AgentSnapshotSchema` | schema | versioned canonical engine records |
264
- | `AgentRunQueuePrioritySchema` | schema | durable opt-in priority for queued `interrupt-next` runs |
265
- | `AgentRuntimeStore` | _type_ | aggregate CAS transaction boundary for message, run and compaction mutations |
266
- | `BranchAgentConversationInputSchema` / `BranchAgentConversationInput` | schema / _type_ | strict branch request: stable request key, empty destination, settled conversation boundary or archive bytes, fresh artifact authorization |
267
- | `AgentConversationBranchReceiptSchema` / `AgentConversationBranchReceipt` | schema / _type_ | durable lineage: request fingerprint, source/destination identities, source sequence/version/digest and creation time |
268
- | `AgentConversationBranchResultSchema` / `AgentConversationBranchResult` | schema / _type_ | `created` or `duplicate` with the same durable receipt; `AgentRuntimeStore.branchConversation` is optional for hand-written stores |
269
- | `createAgentRuntimeStore` | function | build the aggregate store from one coherent transaction driver; framework owns every state transition |
270
- | `AgentRuntimeStoreDriver` | _type_ | ORM-neutral transaction over a bounded head, normalized runs/admissions, product history, a **required** `events: { append, list }` ledger table, an optional `archive: { export, import }` for driver-owned durable payloads, and indexed run recovery; optional `{ access: 'read' }` selects a coherent read transaction while absent options remain write-safe |
271
- | `recordAgentRetryDecision` / `AgentRetryPolicy` | function / _type_ | `loop.retry` policy and the ledger record of a retry decision: explicit retryability and backoff at a durable provider-step boundary |
272
- | `AgentRuntimeHeadSchema` | schema | constant-size conversation identity plus monotonic runtime version |
273
- | `AgentStoredRunSchema` | schema | canonical normalized run with an optional retained terminal assistant |
274
- | `AgentAdmissionReceiptSchema` | schema | durable idempotency receipt with canonical input and assigned run/assistant identities |
275
- | `AgentHistoryMutationSchema` | schema | typed canonical message mutation applied inside the winning state transaction |
276
- | `RecoverAgentRunSchema` | schema | explicit abandon/requeue recovery decision; acquired runs require replay-safe evidence |
277
- | `createMemoryAgentRuntimeStore` | function | process-local reference adapter, not production durability |
278
- | `purgeAgentConversation` | function | dispatch optional atomic deletion; returns `unsupported`, `active`, `conflict`, `purged` or `already_purged` |
279
- | `AgentConversationPurgeInputSchema` / `AgentConversationPurgeInput` | schema / _type_ | conversation ID and optional expected snapshot version |
280
- | `AgentConversationPurgeResultSchema` / `AgentConversationPurgeResult` | schema / _type_ | typed deletion/refusal outcomes; active refusal includes run IDs |
281
- | `AgentConversationPurgedError` | class | runtime mutation rejected because its conversation ID is permanently purged |
282
- | `AgentConversationPurgeDriver` | _type_ | optional `driver.conversations` capability: serialized tombstone read and atomic removal of all owned records |
283
- | `projectAgentHistory` | function | asynchronously project canonical records and resolved multimodal files into provider-valid AI SDK messages |
284
- | `defineModelRegistry` | function | typed language-model descriptors, capabilities and provider construction |
285
- | `AgentModelCatalogSchema` / `AgentModelCatalog` | schema / _type_ | provider-neutral complete/partial model catalog with separately sourced popularity, metrics, prices and observation time |
286
- | `AgentModelCatalogEntrySchema` / `AgentModelCatalogEntry` | schema / _type_ | one canonical provider model descriptor with optional price, popularity and metric evidence |
287
- | `AgentModelPriceSchema` / `AgentModelPrice` | schema / _type_ | normalized per-token input/output pricing and source currency |
288
- | `AgentModelPopularitySchema` / `AgentModelPopularity` | schema / _type_ | independently sourced ranked popularity observation with window and timestamp |
289
- | `AgentModelMetricSchema` / `AgentModelMetric` | schema / _type_ | independently sourced benchmark measurement with provenance and observation time |
290
- | `AgentModelCatalogProvider` | _type_ | abortable live catalog loader supplied by a provider adapter or application |
291
- | `AgentModelSearchInputSchema` / `AgentModelSearchInput` | schema / _type_ | bounded catalog text query and result ceiling |
292
- | `AgentModelSearchResultSchema` / `AgentModelSearchResult` | schema / _type_ | exact bounded catalog projection with total match count |
293
- | `searchAgentModelCatalog` | function | deterministic bounded search over a loaded canonical catalog |
294
- | `AgentModelSelectionSchema` / `AgentModelSelection` / `AgentModelSelectionStore` | schema / _type_ | durable per-conversation model choice; runtime resolvers receive run and snapshot to recover the model pinned to input metadata |
295
- | `createMemoryAgentModelSelectionStore` | function | process-local selection reference adapter |
296
- | `AgentConversationReader` | _type_ | optional bounded conversation-summary and message-history reader; `messages` takes `includeCompacted` and every page names its `compacted` ids; not part of the required runtime store contract |
297
- | `AgentConversationSummarySchema` / `AgentConversationSummary` | schema / _type_ | bounded durable conversation list item with version, activity and preview |
298
- | `AgentConversationPageSchema` / `AgentConversationPage` | schema / _type_ | cursor-paged conversation summaries |
299
- | `AgentConversationMessagePageSchema` / `AgentConversationMessagePage` | schema / _type_ | cursor-paged durable message history |
300
- | `composeAgentPrompt` | function | ordered prompt contributions and provenance-aware signed context budget; irreducible reservation deficits are `oversized`, not compactable history |
301
- | `SeedConversationInputSchema` / `SeedConversationInput` | schema / _type_ | atomic once-seeding input; partial imported identity sets are refused |
302
- | `AgentSeedReceiptSchema` / `AgentSeedReceipt` | schema / _type_ | durable seed receipt retained beyond compaction and implemented by custom store drivers |
303
- | `StepRunOptions` | _type_ | per-step options of the durability engine (`stitchkit/tools`) as the runtime drives it |
304
- | `DURABILITY_STEP_EVENT_KIND` | const | the ledger event kind a recorded step is written under |
305
- | `StepResultDecodeError` / `StepResultNotSerializableError` / `StepAbortedError` / `ParkRecordDecodeError` / `ParkAbortedError` | class | fail-closed decode of a recorded step or park, a body whose result cannot be serialized, an aborted step, and a park collapsed around an aborted waiter |
306
- | `structuredCompaction` | function | summarize a provider-valid snapshot range and replace it through CAS |
307
- | `selectCompactableHistory` | function | which oldest whole complete turns may be summarised away — the half of compaction that needs no store (→ ADR 0142) |
308
- | `SelectCompactableHistoryOptions` / `CompactableHistory` | _type_ | message list, retained-turn count and evidence policy in; `leadingSummary`, `compactable` and `retained` out |
309
- | `createAgentSessionCoordinator` | function | strict process-local queue/interrupt/supersede lifecycle |
310
- | `AgentRuntimeStopPolicy` | _type_ | named stop rule persisted and published on policy stop; `when` receives the AI SDK `steps` plus the run context `prepareStep` gets (`context`, `run`, `signal`, `toolFenceLifecycle`, `contextUsage`) |
311
- | `AgentRuntimePrepareStep` | _type_ | per-run controlled step callback with typed domain context and managed run signal/fence |
312
- | `AgentContextOverflowError` | class | deliberate application budget refusal thrown from `loop.prepareStep`; terminalizes as `context_overflow` without classifying arbitrary error text |
313
- | `AgentRuntimeRecordIds` | _type_ | optional caller-provided input, run and assistant IDs for stable application records |
314
- | `AgentRuntimeAdmission` | _type_ | canonical committed input, assigned run, pending assistant projection, compatibility IDs and snapshot version |
315
- | `AgentAdmissionEventSchema` | schema | post-commit admission projection; removes store rereads but does not imply exactly-once delivery |
316
- | `AgentRunMetricsSchema` | schema | optional provenance-aware usage and timings; `partial` says the provider never reported the run finished, so the figure beside it is not a confirmed total |
317
- | `AgentRuntimeRecoverOptions` | _type_ | bounded paged startup recovery with causal per-conversation scheduling, context resolver and explicit evidence policy |
318
- | `AgentRuntimeAbandonInput` | _type_ | one known orphan identity, expected revision and mandatory stale-owner evidence for `runtime.abandon()` |
319
- | `AgentRuntimeConflictError` | class | thrown when a store mutation loses to a concurrent writer — catchable by type from `stitchkit/agent-runtime` |
320
- | `AgentRunUnsettledError` | class | rejects `result` when the store never answered: `.run` is still active with no executor, close it with `runtime.abandon()` |
321
- | `AgentSessionCloseOptions` | _type_ | `gracePeriodMs` for natural settlement, then abort, then `forceTimeoutMs` for bounded settlement after it |
322
- | `AgentSessionCloseResult` | _type_ | what `close()` achieved: `settled`, or `timedOut` with `remaining` runs still in flight. Only omitting `forceTimeoutMs` guarantees nothing is in flight on return |
323
- | `AgentHistoryProjectionOptions` | _type_ | storage-neutral file resolver, explicit unresolved-file behavior, and how an interrupted turn reaches the model (`interruptedAssistant`) |
324
- | `createAgentToolFenceLifecycle` | function | pre-effect and post-effect run ownership fence for `mountAgent`; compose beside application idempotency for [durable operations](../guide/mcp-and-agents.md#durable-application-owned-execution) |
325
- | `AgentRuntimeEventSchema` | schema | transient stream lifecycle plus post-commit admission/checkpoint/run-state/run-operation/terminal projections |
326
- | `AgentRunOperationKindSchema` / `AgentRunOperationPhaseSchema` / `AgentRunOperationSchema` / `AgentRunOperation` | schemas / _type_ | latest durable model-request or compaction phase with operation/step identity and original timestamps; request admission is awaited before provider invocation |
327
- | `RecordRunOperationSchema` / `RecordRunOperation` | schema / _type_ | owner/fencing/revision-checked mutation of `AgentRun.lastOperation` |
328
- | `createAgentObservability` | function | separate agent-run sink over the shared bounded observability lifecycle |
329
- | `AgentProviderResponseSchema` / `AgentProviderResponse` | schema / _type_ | provider-assigned response ID and the optional upstream provider the model's own adapter resolves, shared by `step-finished.response` and durable `provider/response` facts |
330
- | `AgentStepFinishedEventSchema` / `AgentStepFinishedEvent` | schema / _type_ | one completed provider step with required usage and provider response identity |
331
-
332
- ### Durable capability layer
333
-
334
- | Export | Kind | Summary |
335
- |--------|------|---------|
336
- | `AgentConversationArchiveSchema` / `AgentConversationArchive` / `encodeAgentConversationArchive` / `decodeAgentConversationArchive` / `canonicalAgentJson` | schema / _type_ / functions | canonical validated conversation archive (`canonicalAgentJson` is `canonicalJson` with the node and byte limits at their maximum, reading `-0` as `0` like `z.json()`) with exact ledger events, recovery projection and durable spill payloads |
337
- | `AgentStoreEventEnvelopeSchema` / `AgentStoreEventEnvelope` / `AgentStoreEventKindSchema` / `AgentStoreEventKind` / `AgentStoreEventPageSchema` / `AgentStoreEventPage` | schema / _type_ | append-only event envelope, closed current vocabulary (including `provider/request` and `provider/response`) and bounded page |
338
- | `AppendAgentStoreEventSchema` / `AppendAgentStoreEvent` / `ReadAgentStoreEventsSchema` / `ReadAgentStoreEvents` | schema / _type_ | required append and bounded-read store contracts |
339
- | `AgentStoreTransitionSchema` / `AgentStoreTransition` / `AgentStoreEventDraft` / `AgentStoreEventDecodeAccepted` / `AgentStoreEventDecodeIgnored` / `AgentStoreEventDecodeResult` / `decodeAgentStoreEvent` | schema / _types_ / function | normalized runtime mutations, append drafts and strict current/ignorable future-event decoding outcomes |
340
- | `AgentStoreTransitionRecordSchema` / `AgentStoreTransitionRecord` | schema / _type_ | persisted journal payloads: full runtime mutations or hash-only streaming checkpoint observations with numeric part/byte counts; inspection does not authorize replay |
341
- | `defineAgentProjection` / `AgentProjectionDefinition` / `AgentProjectionValue` / `createAgentProjectionRegistry` / `createSqliteAgentProjectionStore` | functions / _type_ | deterministic folds with persisted version and honest `uptoSeq` checkpoints |
342
- | `agentSummaryProjection` / `agentUsageProjection` / `agentOutlineProjection` | constants | built-in list summary, provenance-carrying usage and request outline projections |
343
- | `agentConversationCardProjection` / `agentStateSlotsProjection` / `agentScheduleSummaryProjection` | constants | focused card, durable-state and schedule lifecycle projections |
344
- | `defineStateSlot` / `AgentStateSlotDefinition` / `AgentStateSlotStore` / `AgentStateSlotValue` / `AnyAgentStateSlot` / `createAgentStateSlotStore` / `renderAgentStateSlots` | functions / _types_ | typed durable state written as ledger events and injected on every provider request |
345
- | `agentGoalStateSlot` / `agentTodoStateSlot` / `createAgentStateTools` | constants / function | built-in goal/todo state and bound `goal_*` / `todo_write` Agent tools |
346
- | `AgentSandboxGradeSchema` / `AgentSandboxGrade` / `AgentSandboxRestrictionSchema` / `AgentSandboxRestriction` / `AgentProcessSandbox` | schema / _type_ | host-provided process sandbox capability and explicit restriction vocabulary |
347
- | `probeAgentProcessSandbox` / `missingSandboxRestrictions` / `recordAgentSandboxProbe` | functions | probe cached per sandbox object (keep the sandbox long-lived), fail-closed required-gap calculation and durable probe record; a probe that throws or returns no valid grade resolves to `unavailable` with a fixed reason, its cause emitted as a process warning (the coding tools instead carry it as the refusal's `cause` to the mount's `onToolError` hook) |
348
- | `AgentSandboxProcess` / `AgentSandboxOutputStream` | _types_ | structural child/output interface for optional lifecycle-owned `AgentProcessSandbox.spawn`, without Node ambient type dependencies |
349
- | `AgentEventSearchResultSchema` / `AgentEventSearchResult` / `createSqliteAgentEventSearch` / `createAgentEventSearchTools` | schema / _type_ / functions | authorized FTS5 search with exact event addresses and `session_*` tools |
350
- | `createSqliteAgentSpillStore` | function | durable content-address-checked artifact storage, bounded read/search, retention facts and archive participation |
351
- | `AgentScheduleSchema` / `AgentSchedule` / `AgentScheduleService` / `AgentScheduleDeliveryOutcomeSchema` / `AgentScheduleDeliveryOutcome` / `createAgentScheduleService` / `createAgentScheduleTools` | schemas / _types_ / functions | durable `at`/`after`/timezone-explicit `every`, indexed retry eligibility, typed retry/terminal failure (any other returned value delivers), `maxAttempts` (default 1000) per occurrence — a one-off ends `failed`, a recurring schedule skips to its next slot — bounded dispatch and fenced stable occurrence identity (ADR 0202) |
352
- | `AgentChildBudgetSchema` / `AgentChildBudget` / `AgentChildStateSchema` / `AgentChildState` / `AgentChildRecordSchema` / `AgentChildRecord` | schema / _type_ | durable child graph, bounded seed and measured budget state |
353
- | `AgentChildBlockingKindSchema` / `AgentChildBlockingKind` / `AgentChildBlockingSourceSchema` / `AgentChildBlockingSource` / `AgentChildBlockingEventSchema` / `AgentChildBlockingEvent` / `AgentChildBlockingDecision` | schemas / _types_ | parent-owned request identities; responses discriminate approval decisions from JSON input values |
354
- | `AgentChildHandle` / `AgentChildManager` / `SqliteAgentChildManagerConfig` / `createSqliteAgentChildManager` / `createAgentChildTools` / `agentChildBudgetStopPolicy` | _type_ / functions | host execution port, child lifecycle, cascade (given to `createAgentRuntime` as `children`), messaging, Agent tools, and the child runtime's own budget stop policy — `recordStepUsage` at every step boundary, `policy_stop` as `child-budget` when spent |
355
- | `AgentToolDefinition` | _type_ | peer-neutral shape returned by the bound agent-only state, search, schedule and child tool factories |
356
- | `AgentProviderStreamCutError` | class | explicit retryable provider stream truncation evidence |
357
-
358
- ### Complete runtime inventory
359
-
360
- The entrypoint deliberately exports the schemas beside their inferred types so persistence and
361
- transport adapters validate the same records. Runtime composition types are `AgentRuntime`,
362
- `AgentRuntimeConfig`, `AgentRuntimeInput`, `AgentRuntimeProtocolInput`, `AgentRuntimeRunContext`,
363
- `AgentRuntimeResult`, `AgentRuntimeInterruptInput`, `AgentRuntimeAbandonInput`, `AgentRuntimeRecoveryInput`,
364
- `AgentRuntimeRecoveryDecision`, `AgentRuntimeRecoveryOutcome`, `AgentRuntimePublisher`,
365
- `AgentInputPolicy`, `AgentStopReason`, `AgentCoordinatedRun`, `AgentRunTicket`,
366
- `AgentSessionCoordinator`, `AgentCompactionContext`, `AgentCompactionResult` and
367
- `StructuredCompactionConfig`.
368
-
369
- A provider refusal is classified rather than phrased. `classifyProviderFailure` returns an
370
- `AgentProviderFailure` — an `AgentProviderFailureReason` (`insufficient-credits`, `rate-limited`,
371
- `model-unavailable`, `context-overflow`, `timeout`, `cancelled`, `unknown`), the provider's `status`
372
- when it supplied one, whether the same request is `retryable` unchanged, and the `evidence` the
373
- answer rests on: `status` is the provider stating its own answer, `message` is us reading its prose,
374
- and `none` is an honest refusal to guess. The sentence a user reads stays with the application —
375
- its tone and its decision about what to admit are not the core's to make. `isToolResultFailure`
376
- recognises a failure carried inside a *successful* tool result, in both the bare and the
377
- `{ value: … }` envelope. Both are plain functions and need no runtime. → ADR 0141
378
-
379
- `normalizeOpenRouterUsage` is the same normalisation `openRouterProvider`
380
- applies, exported so an application calling the SDK directly gets provenance-correct numbers
381
- without adopting the runtime. Pass each completed SDK step's usage, including `raw`.
382
- Token provenance comes from the original OpenRouter fields: missing fields remain
383
- `unavailable`, even when the SDK substitutes zero. Aggregate usage without `raw` cannot
384
- establish reported token counts. Explicit zero remains `provider-reported`; cost is never
385
- estimated from token prices.
386
-
387
- `AgentContextUsage` reaches every step through `AgentRuntimeRunContext.contextUsage`: how full the
388
- model's context is, as `usedTokens` (an `AgentUsageValue`, so it carries the provenance that says
389
- where the number came from) beside the model's declared `contextWindow`. It is the **last completed
390
- step's prompt size**, not the run's cumulative input tokens — cumulative counts every step's prompt
391
- again and is a multiple of the real fill. Before the first step lands there is no provider-reported
392
- number and the provenance is `unavailable`, which is a different fact from zero. No fraction is
393
- exposed: dividing is one line where it is rendered, and the output reserve belongs to the
394
- consumer's prompt budget rather than to this layer.
395
-
396
- Canonical protocol exports are `AgentProtocol`, `AgentProtocolConfig`, `AgentTerminalAcceptance`,
397
- `AgentTerminalAcceptanceInput`, `hasAgentTerminalOutput`, `AgentRecordIdSchema`, `AgentRecordVersionSchema`,
398
- `AgentTimestampSchema`, `AgentJsonObjectSchema`, `AgentProviderEnvelopeSchema`,
399
- `AgentProviderEnvelope`, `AgentMessagePartSchema`, `AgentMessagePart`, `AgentTextPartSchema`,
400
- `AgentReasoningPartSchema`, `AgentFilePartSchema`, `AgentSourcePartSchema`,
401
- `AgentToolCallPartSchema`, `AgentToolResultPartSchema`, `AgentOpaquePartSchema`,
402
- `AgentControlPartSchema`, `AgentMessageRoleSchema`, `AgentMessageStatusSchema`, `AgentMessage`,
403
- `AgentAssistantPlaceholderSchema`, `AgentAssistantPlaceholder`, `AgentRunStateSchema`,
404
- `AgentTerminalReasonSchema`, `AgentTerminalReason`, `AgentRunQueuePrioritySchema`,
405
- `AgentRunQueuePriority`, `AgentRun`, `AgentSnapshot`,
406
- `AgentUsageValueSchema`, `AgentCostValueSchema`, `AgentUsageSchema`, `AgentUsage` and
407
- `AgentRunMetrics`.
408
-
409
- `AgentProvenanceSchema` / `AgentProvenance` is the entrypoint's single vocabulary for **how a
410
- number came to be known**: `provider-reported` (the provider stated it about a request it served),
411
- `measured` (this process counted it exactly, before any request was made), `computed` (arithmetic
412
- over other values — a sum of exact numbers is still `computed`), `estimated` (a heuristic) and
413
- `unavailable` (not known, so `value` is absent, which is a different fact from a reported zero).
414
- Each surface declares the subset it can produce: `AgentUsageValueSchema` and `AgentCostValueSchema`
415
- describe a request that has already happened and never say `measured`; `AgentTokenCountSchema`
416
- describes a prompt being composed and never says `provider-reported`. Every token count is an
417
- integer — `AgentUsageValueSchema` and `AgentTokenCountSchema` refuse a fractional `value`, and a
418
- provider figure that is not a whole number is normalised to `unavailable` rather than thrown.
419
- `AgentCostValueSchema.value` stays fractional, because money is.
420
-
421
- `runs.inputPolicy` takes `queue` (default), `inject`, `interrupt`, `interrupt-next` or `supersede`, or a function of
422
- the raw input returning one. `inject` lets a run in flight take a newly arrived input into its prompt
423
- at a step boundary and answer it too; the absorption is committed in the **same transaction** as that
424
- run's terminal record, via `CommitRunTerminal.absorb`, so a run that ends any other way leaves an
425
- ordinary queued successor. The absorbed run ends with `terminalReason: 'absorbed'`, run state
426
- `'superseded'`, `absorbedIntoRunId` naming the run that answered it, and **no assistant message of
427
- its own**; a submission on its idempotency key resolves through that pointer to the answer
428
- (→ ADR 0113).
429
-
430
- `interrupt-next` interrupts the active run, waits for its real settlement and then executes the new
431
- input before ordinary pending work. Ordinary work is not dropped or re-admitted, and urgent work is
432
- FIFO within its own class. `AgentRun.queuePriority` persists the pending class;
433
- `AgentRun.executionSequence` persists the actual first-acquisition order, so recovery and canonical
434
- history preserve the same `A → C → B` order across equal timestamps and scan pages (→ ADR 0127).
435
-
436
- With `queue`, a durable successor admission is not prompt eligibility: the current executor sees
437
- only records through its own run boundary. Snapshot history is normalized to causal turn order
438
- (assigned input(s), assistant, then successor input(s)) even when the storage codec physically
439
- appended the successor before the predecessor checkpoint.
440
-
441
- `AgentRuntimeStore` has two **bounded** reads beside `loadSnapshot`:
442
- `loadRun({ conversationId, runId })` returns an `AgentRunView` — the run, the conversation version it
443
- was read at, and the retained answer once the run is terminal — or `undefined`; `listActiveRuns(conversationId)`
444
- returns the runs that have not ended in durable execution/priority order. Neither reads history, so
445
- neither grows with the length of the conversation, and neither needs anything new from
446
- `AgentRuntimeStoreDriver`. `loadSnapshot` and every mutation result still carry the whole
447
- conversation — that is what the store's reducer validates against, and what the runtime builds a
448
- prompt from (→ ADR 0112).
449
-
450
- `AgentRuntimeStore.purgeConversation` is optional; `purgeAgentConversation(store, input)` explicitly
451
- handles unsupported stores. Official memory and initialized SQLite stores refuse active runs,
452
- remove every owned payload and reserve the conversation ID permanently. Empty snapshots are not
453
- deletion receipts. Consumer metadata, files, event logs and UI cache invalidation remain outside
454
- the transaction. See [purging a conversation](../guide/agent-runtime.md#purging-a-conversation).
455
-
456
- Store command/result exports are `AcceptInputAndAssignRun`, `AcceptInputAndAssignRunSchema`,
457
- `AcquireAgentRun`, `AcquireAgentRunSchema`, `CheckpointRunAssistant`,
458
- `CheckpointRunAssistantSchema`, `CommitRunTerminal`, `CommitRunTerminalSchema`,
459
- `RequestRunInterrupt`, `RequestRunInterruptSchema`, `AgentRunView`, `AgentRunViewSchema`,
460
- `runStateForTerminalReason`,
461
- `ACTIVE_AGENT_RUN_STATES`, `RecoverAgentRun`, `ReplaceCompactedRange`,
462
- `ReplaceCompactedRangeSchema`, `AgentStoreMutationResult`, `AgentStoreMutationResultSchema`,
463
- `AgentStoreAppliedSchema`, `AgentStoreConflictSchema`, `AgentStoreDuplicateSchema`,
464
- `AgentStoreNotFoundSchema`, `AgentAdmissionReceipt`, `AgentAdmissionReceiptSchema`,
465
- `AgentRuntimeHead`, `AgentStoredRun`, `AgentStoreCompareAndSwapResult`, `AgentHistoryMutation`,
466
- `AgentRecoverableDescriptor`, `AgentRecoverableDescriptorSchema`, `AgentRecoverablePage` and
467
- `AgentRecoverablePageSchema`.
468
-
469
- History and context-budget exports are `projectAgentHistoryDetailed`,
470
- `AgentHistoryProjectionDecision`, `AgentHistoryProjectionResult`, `selectAgentHistory`,
471
- `SelectAgentHistoryOptions`, `AgentHistoryBudgetDecision`, `AgentHistoryBudgetResult`,
472
- `AgentPromptBudget`, `AgentPromptSection`, `AgentPromptSectionContext`, `AgentTokenCount`,
473
- `AgentTokenCountSchema`, `ComposeAgentPromptOptions` and `ComposedAgentPrompt`. Whole-turn history
474
- selection never splits a tool chronology and reports why every canonical record was retained or
475
- removed. `AgentHistoryEvidencePolicy` is the shared opt-in for marked failed-assistant evidence in
476
- projection, budgeting and structured compaction; `isAssistantHistoryEvidence` applies it. The
477
- compatibility default omits it. Approval message schemas are
478
- `AgentToolApprovalRequestPartSchema` and `AgentToolApprovalResponsePartSchema`.
479
-
480
- With `failedAssistant: 'assistant-marked'`, `selectCompactableHistory` and
481
- `structuredCompaction` admit a failed prefix only when the entire turn's tool chronology
482
- is complete. `eligibleMessages` stays raw: the summarizer must use the same policy in
483
- `projectAgentHistoryDetailed` and preserve non-success/uncertain outcomes in its summary.
484
- Even an empty opted-in failed assistant carries a non-success marker in that projection.
485
- Use `incompleteToolTurn: 'error'` to refuse a range the projector cannot represent.
486
- Compaction never deletes canonical effect events or the retained terminal receipt;
487
- chronological completeness does not settle an external effect. See the
488
- [failed-prefix recovery contract](../guide/agent-runtime.md#recovering-a-prefix-after-a-failed-turn).
489
-
490
- Model exports are `AgentLanguageModelProvider`, `AgentModelCapability`,
491
- `AgentModelCapabilitySchema`, `AgentModelDescriptor`,
492
- `AgentModelDescriptorSchema`, `AgentModelRegistry`, `AgentModelRegistryConfig`,
493
- `AgentModelRegistrySnapshot`, `AgentModelRegistrySnapshotSchema`, `AgentModelSnapshotPolicy`,
494
- `AgentResolvedModel` and `validateAgentModelSnapshot`. Registry `preflight` validates availability,
495
- provider and required capabilities without constructing the model; runtime `models.preflight`
496
- runs before durable admission.
497
-
498
- Delivery exports are `AgentAdmissionEventSchema`, `AgentAttemptResetEventSchema`, `AgentCheckpointEventSchema`,
499
- `AgentRunStateEventSchema`, `AgentRunOperationEventSchema`, `AgentTerminalEventSchema`,
500
- `AgentTransientDeltaEventSchema`,
501
- `AgentReasoningStartEventSchema`, `AgentReasoningDeltaEventSchema`,
502
- `AgentReasoningEndEventSchema`, `AgentToolStatusEventSchema`, `AgentToolProgressEventSchema` (what a
503
- running tool reported through `ctx.reportProgress`, transient and never part of the model's input),
504
- `AgentRuntimeEvent`,
505
- `AgentRuntimeEventCursor`, `AgentRuntimeEventCursorSchema`, `AgentRuntimeCursorAdvance`,
506
- `advanceAgentRuntimeEventCursor`, `agentDurableEventId`, `AgentRuntimeEventSink`,
507
- `AgentRuntimeEventSinkConfig` and `createAgentRuntimeEventSink`. Cursor gaps require a canonical
508
- snapshot reload; the bounded sink isolates transport failure and supports a typed projection step.
509
-
510
- Managed effects and operator telemetry additionally export `AgentToolFenceConfig`,
511
- `AgentToolFenceContext`, `AgentObservability`, `AgentRunEvent`, `AgentRunEventSchema`,
512
- `AgentRunStartedEvent`, `AgentRunStartedEventSchema`, `AgentStepFinishedEvent`,
513
- `AgentStepFinishedEventSchema`, `AgentRunTerminalEvent`, `AgentRunTerminalEventSchema`,
514
- `AgentRunSinkConfig`, `AgentRunSinkDrop` and `AgentRunSinkError`. A monotonic run `fencingToken`
515
- may accompany checkpoint/terminal writes and tool context; internal causes are redacted unless an
516
- operator-only observability sink explicitly opts in.
517
-
518
- ### Completion and agent invocation receipts
519
-
520
- `createModelInvocationLedger` constructs a `ModelInvocationLedger` from
521
- `ModelInvocationConfig`: the existing store and model registry, a 32-byte payload
522
- key, required host authorization, optional bounded payload size and `onAttempt`.
523
- `complete(input, context, signal?)` executes only the supplied prompt, with no tools
524
- or framework instructions; repeated keys return the existing invocation ID. It
525
- resolves `{ invocationId, outcome, text?, receiptError? }`: `receiptError` says a
526
- receipt could not be written and never changes `outcome` or drops `text`.
527
- `read(input, context)` returns a bounded event page shared by agent and completion;
528
- `readPayload(input, context)` requires a separate authorization action to decrypt
529
- one protected artifact. See the [guide and migration example](../guide/agent-runtime.md#plain-completions-and-shared-invocation-receipts).
530
-
531
- | Export | Kind | Purpose |
532
- | --- | --- | --- |
533
- | `CompletionInvocationInputSchema` / `CompletionInvocationInput` | schema/type | strict prompt, model-attempt list, deadline, idempotency key and trace input |
534
- | `ModelInvocationTraceSchema` / `ModelInvocationTrace` | schema/type | operation, purpose, project, parent trace and optional experiment/case/profile |
535
- | `ModelInvocationRecordSchema` / `ModelInvocationRecord` | schema/type | discriminated invocation start/finish and per-attempt requested/sent/effective evidence |
536
- | `ModelInvocationAttemptContext` | type | conversation, operation, invocation, attempt, step and optional agent run identity |
537
- | `currentModelInvocationAttempt` | function | returns causal IDs inside the provider transport; undefined outside an invocation |
538
-
539
- The runtime `invocations` option takes this ledger and a trace callback; it requires
540
- the same store and preserves the existing tool loop. `AgentRuntimeStore.appendEventOnce`
541
- is an optional atomic capability required by receipt admission, and
542
- `AgentRuntimeStore.findEventOnce` reads the event it wrote by kind and key. Built-in
543
- stores supply both; custom adapters must honor the once-only transaction contract.
544
- A driver's optional `events.find(transaction, { conversationId, eventId })` makes
545
- both one lookup instead of a scan of the conversation log.
783
+ Deciding *when* to compact is yours: `AgentRuntimeRunContext.contextUsage`
784
+ carries the last step's prompt size beside the model's window, and dividing them
785
+ is one line where the threshold is decided. The core does not own that ratio, on
786
+ purpose — see ADR 0142 for what it publishes and what it declines to.