stitchkit 0.122.0 → 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 2 of 4
1
+ # stitchkit/agent-runtime — stitchkit docs slice, part 2 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,796 +7,683 @@
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
- ## Durable order
18
+ ## Bounded deferred tool catalogs
18
19
 
19
- `acceptInputAndAssignRun` is one atomic operation. It is followed by ownership
20
- acquisition, revision-checked assistant checkpoints and one terminal CAS. The
21
- process-local coordinator releases its lane only after terminal commit.
20
+ Use `createDeferredAgentToolSurface` when a measured large catalog makes
21
+ sending every schema on every provider step expensive. The controller mounts
22
+ the complete executable surface in process, but initially advertises only its
23
+ search tool, explicit `alwaysOn` tools and bounded policy pins:
22
24
 
23
- A queued admission is durable immediately, even if its predecessor is still awaiting ownership
24
- or its first checkpoint. Snapshots expose causal turn order rather than physical append order:
25
- the predecessor's assigned input(s) and assistant come before the successor input(s). Execution
26
- uses the same run boundary, so neither `prompt({ snapshot })` nor default/custom history
27
- projection can see inputs assigned to a later run. `inject` remains the explicit path that moves
28
- a successor input into the run already in flight.
25
+ ```ts
26
+ import { createDeferredAgentToolSurface } from 'stitchkit/agent-runtime'
27
+ import { composeToolLifecycle } from 'stitchkit/tools'
28
+
29
+ const deferred = createDeferredAgentToolSurface({
30
+ surfaces: {
31
+ member: { services: memberServices, runtimeTools, alwaysOn: ['ask_user'] },
32
+ broadcast: { services: broadcastServices, runtimeTools },
33
+ },
34
+ selectSurface: ({ context }) => context.mode,
35
+ pins: ({ context }) => context.skillTools,
36
+ search: {
37
+ name: 'tool_search',
38
+ maxQueryBytes: 1_024,
39
+ maxResults: 8,
40
+ maxResultBytes: 8_192,
41
+ select: ({ query, manifest }) => rankCatalog(query, manifest),
42
+ },
43
+ activation: {
44
+ maxSelectedTools: 8,
45
+ maxActiveTools: 16,
46
+ maxSchemaBytes: 32_768,
47
+ },
48
+ observe: event => recordDeferredToolEvidence(event),
49
+ })
29
50
 
30
- ```text
31
- input + queued run → running → execution settled → terminal CAS → successor
51
+ const runtime = createAgentRuntime({
52
+ // protocol, store, models and prompt...
53
+ tools: runContext => deferred.mount(runContext, {
54
+ context: runContext.context,
55
+ lifecycle: composeToolLifecycle(authLifecycle, runContext.toolFenceLifecycle),
56
+ }),
57
+ loop: { prepareStep: deferred.prepareStep(applicationPrepareStep) },
58
+ })
32
59
  ```
33
60
 
34
- ## What happens to a run when new input arrives
35
-
36
- `runs.inputPolicy` decides. It takes five values, or a function returning one:
37
-
38
- | policy | the run in flight | what it already produced |
39
- |--------|-------------------|--------------------------|
40
- | `queue` (default) | finishes first | kept |
41
- | `inject` | continues, and answers the new input too | kept, and built on |
42
- | `interrupt` | ends | kept, and marked as cut off |
43
- | `interrupt-next` | ends; this input runs before ordinary queued work | kept, and marked as cut off |
44
- | `supersede` | ends | discarded from the prompt, kept in the record |
45
-
46
- `interrupt-next` is the explicit priority path for an urgent input. If A is
47
- running, ordinary B is queued, and urgent C arrives, the coordinator aborts A,
48
- waits for A to settle and then runs C before B. B keeps its durable identity and
49
- eventually runs. Urgent inputs remain FIFO among themselves. They do not
50
- coalesce into an ordinary pending run: that would erase the priority boundary.
51
-
52
- `interrupt` and `supersede` differ in exactly one thing, and the question that
53
- picks between them is **not** "was the run interrupted" but **"did anyone see
54
- what it produced"**:
55
-
56
- - The user pressed **stop**. The partial answer was streamed to their screen and
57
- they read it. It belongs in the conversation — dropping it makes the history
58
- lie to the model about what the human has seen. That is `interrupt`.
59
- - A newer message **superseded** the run. Whether the partial reached anyone
60
- depends on the delivery surface: a token stream shows it as it is produced, a
61
- surface that sends nothing until the run is done never showed it at all. When
62
- it reached nobody, it is not part of the conversation. That is `supersede`.
63
-
64
- **Stitchkit cannot answer that question for you** — delivery belongs to the
65
- transport, and the runtime sees an abort, not a screen. Hence a declared policy
66
- (→ ADR 0108), and hence `inputPolicy` accepting a function, so one application
67
- can hold two surfaces with different rules without the core learning which is
68
- which:
61
+ The built-in selector deterministically ranks exact name, prefix/token and
62
+ description matches. A custom async selector returns names only; unknown and
63
+ duplicate output is removed against the local canonical manifest. Search never
64
+ executes the selected operation and never returns its schema. A later provider
65
+ step receives the real selected tools by name, so their direct lifecycle,
66
+ fencing, hooks, errors and `present.agent` output remain unchanged.
67
+
68
+ Selection is a versioned receipt in the ordinary durable tool-result history.
69
+ The latest valid same-run search replaces the earlier selection; parallel
70
+ searches in one step merge in call order. Recovery reconstructs it, while a
71
+ different run or finite surface cannot inherit it. Known inactive calls take a
72
+ recoverable `SEARCH_REQUIRED` search round; unknown names still fail. The
73
+ runtime marks that round with `reason: 'inactive_call'` beside the exact tool
74
+ name. The field is the runtime's to write; a model that sets it anyway gets
75
+ the same `SEARCH_REQUIRED` answer with an exact name, and with a query in words
76
+ the field is ignored and the query is ranked as usual.
77
+
78
+ Every ceiling is required. `maxSchemaBytes` measures UTF-8 bytes over canonical
79
+ name, description and presentation schema. It is useful for before/after
80
+ comparison, but provider serialization and tokenization differ: use existing
81
+ provider-reported input usage and cost to decide whether the extra search round
82
+ is beneficial. Controller evidence intentionally omits query text, prompts,
83
+ arguments and application context. → ADR 0129.
84
+
85
+ ### Published headless harness and structured runner
86
+
87
+ `stitchkit/agent-runtime/harness` publishes the resource-aware composition over
88
+ `createAgentRuntime`. `createHeadlessAgentHarness` accepts the existing protocol and store,
89
+ caller-provided model resolution, bounded instruction/skill/resource loading, direct tools and a
90
+ prompt-budget callback. Resource names are unique; count, UTF-8 bytes and diagnostics are bounded.
91
+ Invalid resources fail before a provider request. Diagnostics are evidence for the caller: their
92
+ observer is isolated, and the loader decides whether a reported condition is fatal.
93
+
94
+ `createAgentHarnessFileResources` is the optional filesystem implementation. It reads only
95
+ caller-declared absolute roots, refuses symlinks and reports provenance as
96
+ `rootId:relative/path`. Instructions enter the prompt immediately. Skills and ordinary resources
97
+ enter as bounded name/description summaries; `read_resource` loads one exact body later as
98
+ a normal direct typed tool. Custom loaders remain supported unchanged.
99
+
100
+ Each run may resolve a different provider adapter without changing the harness. The
101
+ `profile-applied` observation records the actual `AgentModelDescriptor`, resource kind/name/
102
+ provenance and sorted direct tool names; it intentionally contains no resource text, prompts,
103
+ arguments or credentials. Observer failure never changes the canonical run outcome.
104
+
105
+ [`packages/core/examples/agent-runtime/headless-agent-runner.ts`](../../packages/core/examples/agent-runtime/headless-agent-runner.ts)
106
+ is a reference structured control loop for `submit`, `interrupt`, `snapshot` and bounded `close`.
107
+ It starts no process and chooses no framing, authentication or transport. Importing either example
108
+ has no side effect; an external supervisor owns process placement and restart.
109
+
110
+ | Concern | Public Stitchkit composition | Boundary |
111
+ | --- | --- | --- |
112
+ | execution loop | `createAgentRuntime` | already available; do not copy a second loop |
113
+ | model choice | `defineModelRegistry` and `models.resolve` | provider credentials/discovery stay application-owned |
114
+ | resources | `createHeadlessAgentHarness` → `composeAgentPrompt` | paths, trust, precedence and watching stay application-owned |
115
+ | tools | `mountAgent` + `createAgentToolFenceLifecycle` | auth and domain effects stay application-owned |
116
+ | follow-up | `runs.inputPolicy: 'queue'` | durable FIFO after the current run |
117
+ | interrupt | `interrupt` or `interrupt-next` | `interrupt-next` terminates the active run and gives the new durable admission next priority |
118
+ | recovery | `runtime.recover` + `scanRecoverable` | replay safety and context reconstruction are explicit callbacks |
119
+ | reconnect | canonical snapshot + `advanceAgentMultiSessionCursor` | conversation/run-scoped gaps require resync |
120
+ | persistence | memory reference, public driver, or SQLite leaf | product rows/outbox remain outside the runtime store |
121
+ | executable lifecycle | structured runner example | framing, auth, process placement, restart and OS isolation stay host-owned |
122
+
123
+ `createAgentHarnessControlServer` adds transport-neutral correlated requests, shared observers and
124
+ one exclusive controller lease per conversation. `close()` on a connection detaches only that
125
+ client; it never closes the harness. Normal `event` delivery stays serialized. A bounded slow
126
+ consumer is detached and receives `resync-required` through the required synchronous
127
+ `onOverflow` callback; that callback closes/aborts the underlying transport, which then reconnects
128
+ and requests a fresh snapshot. It never writes a second frame concurrently with a blocked
129
+ `deliver`. Browser code uses `createAgentControlView`,
130
+ `reduceAgentControlSnapshot` and `reduceAgentControlEvent`; the reducer owns no renderer and marks a
131
+ conversation `resyncRequired` after a transient sequence gap.
132
+
133
+ Configure `loop.toolApproval` to use the installed AI SDK approval policy and
134
+ `loop.toolApprovalSecret` to sign exact requests. A request is stored on the terminal assistant
135
+ before any effect. `pendingApprovals()` derives unresolved requests from canonical messages;
136
+ `respondToApproval()` writes one tool-role approval response and queues a successor. The SDK
137
+ revalidates the signature/tool call/input before the original direct tool reaches its existing
138
+ fence. Reconnect and SQLite reopen require no promise registry. Remembered policy and a stronger
139
+ cross-crash exactly-once guarantee remain application concerns.
140
+
141
+ Approval requests suspend a call; they do not settle its result. History carries exact call and
142
+ approval identity across assistant/tool records, so an approved result can precede the next signed
143
+ request in a successor run. Automatic decisions follow the same chronology. Unknown/duplicate
144
+ responses and results with a different call or tool name are invalid; dropping an invalid active
145
+ approval input fails the run with a private diagnostic rather than starting a fresh model turn.
146
+
147
+ `stitchkit/agent-runtime/coding-tools` returns ordinary direct runtime tools named `read_file`,
148
+ `write_file`, `edit_file`, `list_directory`, `glob`, `search_files`, `run_command` and optional
149
+ `read_output`. Every call passes a required host authorization callback. An optional async/sync
150
+ `authorizePath({ path })` is the one operation-independent policy for direct file access and
151
+ discovery: direct read/write/edit refuses a denied path, while listing, glob and search omit it.
152
+ Denial is recursive on every surface — direct access asks about each named ancestor and the file,
153
+ outermost first, stopping at the first refusal; discovery rooted at `.` also asks about `.`.
154
+ Thus denying `credentials` denies everything
155
+ under it. The comparison is segment-wise, so it does not deny `credentials-backup`, and a path of
156
+ depth N costs N questions for direct access. Directory decisions happen before descent; file decisions and
157
+ `search_files.include` happen before opening content. Incompatible glob directory prefixes are
158
+ pruned before policy or directory opens. Requested paths use `/`: a backslash is
159
+ refused rather than normalised, because the workspace walk reads it as a separator and every
160
+ authorization callback would otherwise be asked about a different decomposition than the one opened. File paths are relative, bounded and contained after
161
+ descriptor-relative resolution: each ancestor is opened without following symlinks. Reads and edits
162
+ stay pinned through authorization; `write_file` plans before it and opens the parent after, because
163
+ the parent it creates does not exist yet. Reads revalidate the pinned file identity;
164
+ writes and patches revalidate the pinned parent identity; search and resource discovery descend
165
+ only through opened directories. Every filesystem tool selects its root capability before operation or path authorization and retains
166
+ that identity if a callback replaces the root path or configured alias. On Linux the ordinary
167
+ command launcher spawns the command with the pinned directory's `/proc` descriptor link as its cwd;
168
+ on macOS it inherits the pinned cwd through an isolated guard — one short-lived guard process per
169
+ command, tens of milliseconds, outside `shellTimeoutMs`, which counts from the command's start. The
170
+ application process never changes cwd. Where
171
+ contained descriptors are unavailable (Windows), `run_command` accepts a cwd whose realpath is
172
+ that same name under the root — a symlink segment refuses there too — and checks the directory's
173
+ identity again immediately before launch, after every host decision.
174
+ A custom sandbox launcher owns atomic cwd capture at its pathname handoff; the SDK refuses a
175
+ changed identity after policy/prepare and immediately before that handoff. A path that changed
176
+ during direct-access authorization refuses with `CONFLICT`.
177
+ A path spelled differently from the name stored on disk refuses before any policy reads it, so on a
178
+ case-insensitive volume a rule on `credentials` cannot be passed by asking for `CREDENTIALS`.
179
+ NUL bytes are refused before either host policy sees a path.
180
+ Reads and edits open file candidates without blocking on special files, then require a regular
181
+ file. A writerless FIFO therefore refuses instead of occupying a filesystem worker. `write_file`
182
+ supplies the exact missing-directory plan as `createsDirectories` to host authorization; the
183
+ descriptor walk may create only those entries. An ancestor that disappears after approval refuses
184
+ with `CONFLICT`, without expanding that plan. Linux traverses `/proc/self/fd`; macOS uses the package's
185
+ architecture-specific Node-API `openat` backend. Unsupported platforms, including FreeBSD until a
186
+ real backend is qualified, fail closed. The package needs no install-time compiler or script.
187
+ Other platforms fail these filesystem operations closed because Node exposes no equivalent
188
+ portable directory-handle-relative API. Writes/edits reject symlink targets, content is strict
189
+ UTF-8 and retained bytes are finite. Shell accepts a finite alias mapped by the host to an absolute executable plus an
190
+ argument array — never a shell command string — and uses only the explicitly supplied environment.
191
+ Arguments, output and time are bounded, while cancellation terminates the child. The configured
192
+ root and cwd are path boundaries, not a security sandbox: isolate the process when an executable
193
+ must not access the rest of the machine. Path admission does not constrain `run_command`; an
194
+ executable requires process isolation for that guarantee. → ADR 0172
195
+
196
+ An absolute path is still refused. When it is inside the canonical workspace root, the refusal
197
+ includes the exact workspace-relative `recoveryPath`; an absolute path outside the root has no
198
+ suggested replacement. Applying the suggestion still passes the same descriptor-relative
199
+ containment and symlink checks.
200
+
201
+ `edit_file` replaces one exact snippet. It inserts `newText` literally in both single and
202
+ `replaceAll` modes, including JavaScript replacement tokens such as `$$`, `$&`, `` $` `` and `$'`.
203
+ `oldText` is itself the freshness guard for the region it changes, so the digest is the optional `expectedSha256` and an edit is one call; pass the digest
204
+ `read_file` returned when you want the whole-file guarantee, and read the `sha256` it returns to
205
+ chain the next edit without re-reading. The read, the occurrence count and the construction of the
206
+ new content all happen inside one per-target lock, so two concurrent edits of different snippets in
207
+ one file cannot each build a file from the same base and have the second erase the first — the lock
208
+ is process-local, which covers many agents in one process and not two processes over one workspace.
209
+ It authorizes the exact replacement count, result digest and byte size, and deliberately does not
210
+ claim multi-file atomicity.
211
+
212
+ `write_file` creates missing parent directories inside the root and names them in
213
+ `createdDirectories`; the walk that finds them runs before authorization and reports them in the
214
+ authorization payload, so no directory is created before a host approves it.
215
+
216
+ **Every ordinary outcome is a refusal a model can act on** — a missing file is `NOT_FOUND`, an
217
+ existing file without `overwrite` is `CONFLICT`, an ambiguous snippet is `CONFLICT` carrying its
218
+ occurrence count, a path outside the root is `FORBIDDEN`, an unknown spilled-output reference is
219
+ `SPILL_REFERENCE_UNKNOWN` — each with a `hint` naming the next move.
220
+ Host-level causes stay scrubbed to `INTERNAL_SERVER_ERROR` and name nothing outside the workspace.
221
+ Every refusal code is a member of `STITCH_ERROR_STATUS`, so its status survives an envelope that
222
+ crosses a process boundary and is rebuilt from the wire.
223
+ → ADR 0139
224
+
225
+ `list_directory` marks excluded directories rather than hiding them, and `glob` reports
226
+ `skippedDirectories` beside its matches: an empty result from a tree whose files all live under an
227
+ excluded directory is not "no files", and a model told only "nothing found" concludes the wrong
228
+ thing. `search_files` takes `regex`, `context` lines and an anchored `include` pattern matched
229
+ against the whole workspace-relative path. `*` does not cross `/`, so files in subdirectories need
230
+ `**/`; `scannedFiles` counts files admitted by both that filter and host authorization that reached
231
+ comparison. One finite glob automaton matches files and prunes incompatible directory prefixes;
232
+ `glob` also confines its walk to the requested path. A `hint` can explain include filtering or
233
+ pruning, without counting files below unopened directories.
234
+
235
+ Search processes one file at a time. List, glob and search share directory, entry, serialized-output,
236
+ timeout and cancellation budgets, including canonical spelling checks before path authorization.
237
+ `list_directory` caps raw enumeration at the smaller of `maxListEntries` and `maxSearchEntries`;
238
+ its coverage scope lists symlinks while glob/search skip them. Glob patterns are limited to 4,096
239
+ UTF-16 units. A scoped glob measures depth from its requested base directory. In addition to `maxSearchFiles` (10,000), `maxSearchDepth`
240
+ (32), `maxSearchResults` (100) and `maxReadBytes` (262,144), hosts can configure
241
+ `maxSearchDirectories` (1,000 directory-open attempts, root included), `maxSearchEntries`
242
+ (20,000 entries observed by bounded readers), `maxSearchBytes` (16,777,216 aggregate content
243
+ bytes, including rejected UTF-8), `maxSearchOutputBytes` (65,536 bytes for the entire serialized
244
+ UTF-8 JSON, minimum 1,024) and `searchTimeoutMs` (5,000). An input whose minimum coverage
245
+ envelope cannot fit the output ceiling refuses before filesystem access. Directory readers cap
246
+ enumeration before materialization and sorting; reaching an entry or result ceiling is
247
+ conservatively partial, without reading a lookahead entry or match.
248
+
249
+ `list_directory`, `glob` and `search_files` return `coverage.complete` and `coverage.reasons`
250
+ to distinguish full empty results from partial work. Glob `resultTruncated` conservatively marks
251
+ the result ceiling even when no extra match was read.
252
+ Coverage names the include/exclusion/symlink scope, directory and entry counters, actual content
253
+ bytes and elapsed milliseconds. Limits, cancellation, denial, unreadable files and oversized
254
+ lines are explicit reasons. `errors` names only a workspace-relative path, operation and safe code;
255
+ `errorsTruncated` and `deniedTruncated` report shortened diagnostics. Excluded directories and
256
+ symlinks are outside the declared search scope. The caller's signal and deadline reach host
257
+ authorization, file reads and traversal; CPU checkpoints and asynchronous races stop further work.
258
+ Late opened handles are closed, while already-running kernel calls may finish later. Directory
259
+ enumeration and glob matching have internal checkpoints; cancellation stops their producer loops.
260
+ Host policy and visitor exceptions propagate to error hooks with their original cause, rather
261
+ than becoming filesystem diagnostics. Diagnostic byte admission is linear and checks the deadline
262
+ even for an empty result.
263
+
264
+ Regex searches use JavaScript `RegExp` syntax without flags — groups, alternation, classes,
265
+ anchors, `\b`, quantifiers and `(?ims-ims:…)` modifiers — with bounded cost per line. A line
266
+ whose backtracking work (paths × longest path × line length × (number of groups + 1), every
267
+ repetition capped at the line length beyond the iterations it requires) stays within 2^24 steps runs on the native engine, so ordinary `.*` searches over code
268
+ lines keep native speed and the deadline is observed between lines; any other line runs on a
269
+ cached linear automaton, so `(a+)+$` on a long line costs the same as `a+` and the deadline is
270
+ observed inside the line. Backreferences and
271
+ lookaround refuse before I/O; lines over 8,192 UTF-8 bytes are skipped with incomplete coverage.
272
+ Literal queries allow up to 8,192 UTF-16 units and include patterns up to 4,096.
273
+
274
+ With an optional `AgentCodingArtifactStore`, shell output beyond the inline
275
+ preview continues into an opaque bounded artifact and `read_output` reads slices without
276
+ exposing a host path. Without a store, the previous finite output-limit behavior is unchanged.
277
+
278
+ `run_command` treats every invocation as finite. `shellTimeoutMs` bounds normal execution and
279
+ `shellTerminationGraceMs` bounds settlement after timeout, caller cancellation, output overflow or
280
+ parent exit. POSIX hosts launch a dedicated process group and kill every descendant remaining in
281
+ that group; a process that deliberately escapes the group is outside the guarantee. Node's
282
+ portable Windows child-process API cannot kill a complete descendant tree without invoking a
283
+ second host executable, so Windows kills the direct child, destroys retained pipes at the grace
284
+ deadline and makes no descendant-cleanup claim. A signal already aborted before execution returns
285
+ `cancelled` without spawning.
286
+
287
+ Coding commands compose the shared native execution owner. Artifact retention and UTF-8
288
+ previews remain coding-tool policy; normal leader exit still forcibly stops remaining POSIX
289
+ group members before draining. A structural sandbox launcher uses the same settlement owner
290
+ with a finite output queue. Sandbox shutdown waits for actual owned completion and surfaces
291
+ cleanup refusal rather than treating it as successful teardown.
292
+
293
+ One `createAgentHarnessFileResources` instance represents one immutable discovery generation:
294
+ concurrent and repeated `load()` calls share it, so a direct resource read cannot cross into a
295
+ new catalog unexpectedly. Discovery uses the same descriptor-anchored traversal as coding search;
296
+ construct a new loader to refresh after filesystem changes.
297
+
298
+ Runtime events preserve reasoning/text/tool lifecycle order. Durable admission,
299
+ checkpoint, run-state and terminal events carry stable identities; transient
300
+ deltas carry one runtime epoch and monotonic sequence. On a transient gap,
301
+ reload the canonical snapshot and resume from the durable record instead of
302
+ persisting deltas as a second history.
303
+
304
+ Prompt `contextWindow` budgeting is a pre-request decision over the assembled
305
+ context. `AgentRun.usage` is cumulative measured/provider-reported execution
306
+ evidence across attempts and compaction. They answer different questions and
307
+ must not be substituted for each other. TTFT and throughput require timestamps
308
+ from the actual provider stream plus a provider-reported or tokenizer-measured
309
+ token count; character counts cannot produce exact token or throughput figures.
310
+
311
+ `recordIds` is optional. Supply stable application record IDs when an accepted-response transport must
312
+ return durable placeholders before the run finishes. `ticket.admission` resolves after the store
313
+ acceptance CAS and reports the canonical committed `input`, assigned `run`, a typed `pending`
314
+ assistant projection, compatibility IDs and snapshot version.
315
+ Those assigned IDs can differ from the proposal when an input coalesces into an existing queued
316
+ successor. Reuse the same `inputMessageId` for retries carrying the same idempotency key; input
317
+ identity is caller-stable, while the receipt reports the run/assistant identities that assignment
318
+ may change. Await `admission` first on the immediate accepted-response path. `ticket.accepted`
319
+ remains the signal-only compatibility surface and additionally waits for admission publication.
320
+
321
+ The in-memory store is a reference adapter and has process-local durability
322
+ only. Production applications normally call `createAgentRuntimeStore()` and
323
+ provide one database transaction driver. Its `transaction` owns the retry of a
324
+ serialization conflict, wherever the database reports it: on a statement or on
325
+ `COMMIT`, which some clients surface in a different shape. Pass
326
+ `armCommitConflict` to `runAgentStoreConformance` to prove it:
69
327
 
70
328
  ```ts
71
- runs: {
72
- inputPolicy: (input) =>
73
- protocol.parseContext(input.context).surface === 'operator' ? 'queue' : 'supersede',
74
- }
329
+ const store = createAgentRuntimeStore({
330
+ transaction: (work, options) =>
331
+ options?.access === 'read'
332
+ ? db.readTransaction(tx => work(tx))
333
+ : db.writeTransaction(tx => work(tx)),
334
+ head: {
335
+ load: (tx, conversationId) => loadRuntimeHead(tx, conversationId),
336
+ compareAndSwap: (tx, operation) => casRuntimeHead(tx, operation),
337
+ },
338
+ runs: {
339
+ load: (tx, identity) => loadRun(tx, identity),
340
+ loadByAssistantMessageId: (tx, identity) => loadRunByAssistant(tx, identity),
341
+ loadMany: (tx, identities) => loadRuns(tx, identities),
342
+ listActive: (tx, conversationId) => listActiveRuns(tx, conversationId),
343
+ save: (tx, record) => saveRun(tx, record),
344
+ },
345
+ admissions: {
346
+ load: (tx, identity) => loadAdmission(tx, identity),
347
+ loadByInputMessageId: (tx, identity) => loadAdmissionByInput(tx, identity),
348
+ create: (tx, receipt) => createAdmission(tx, receipt),
349
+ },
350
+ history: {
351
+ load: (tx, conversationId) => loadCanonicalMessages(tx, conversationId),
352
+ apply: (tx, mutation) => applyCanonicalHistoryMutation(tx, mutation),
353
+ },
354
+ scanRecoverable: page => scanRecoverableRuns(page),
355
+ })
75
356
  ```
76
357
 
77
- `input.context` is the **raw** context here — admission runs before the runtime
78
- parses it, so the callback narrows it itself with the protocol it already has.
358
+ Prompt budgeting keeps reservation deficits signed. If instructions,
359
+ `reservedOutput`, tool schemas, attachments and provider overhead already exceed
360
+ `contextWindow`, `composeAgentPrompt` returns `contextDecision: 'oversized'`
361
+ and a negative `availableHistoryTokens` even when history is empty. A `compact`
362
+ policy is returned only when removing history could actually make the prompt
363
+ fit. Exact equality (including a zero window with zero reservations/history)
364
+ fits; any unavailable component keeps the decision `unavailable`.
365
+
366
+ The same opaque `tx` reaches head, run, admission and history callbacks. The adapter maps rows
367
+ and supplies atomicity; Stitchkit owns transition validation and revision
368
+ arithmetic. The executable reference is
369
+ [`examples/agent-store-prisma/adapter.ts`](../../examples/agent-store-prisma/adapter.ts).
370
+ `compareAndSwap` returns either `{ outcome: 'applied' }` or
371
+ `{ outcome: 'conflict', actualVersion }`. Decide that outcome by reading the
372
+ version, comparing it and then writing unconditionally — inside the transaction
373
+ the adapter supplies, that is atomic. A conditional upsert is the shorter thing
374
+ to write and it is wrong twice: `ON CONFLICT ... WHERE` guards the update branch
375
+ only, so the first write to a conversation applies whatever `expectedVersion` it
376
+ was given, and the affected-row count that would report the outcome is the
377
+ driver's, which need not be the rows the statement moved. The head contains only
378
+ schema version, conversation identity and monotonic version. Runs and admission receipts are normalized records;
379
+ recovery queries active run states directly instead of maintaining a second projection.
380
+ An admission receipt retains its canonical input, and a terminal run retains its canonical
381
+ assistant, so physical product-history compaction cannot break idempotent retries.
382
+
383
+ ### Built-in SQLite persistence
384
+
385
+ Use the runtime-specific leaf; the neutral agent runtime never imports either
386
+ SQLite built-in:
79
387
 
80
- A superseded run ends with `terminalReason: 'superseded'`, run state
81
- `'superseded'` and an assistant message of status `'superseded'`. **The record
82
- is kept** — excluded from the projection, not deleted — so an operator can see
83
- what was thrown away, and run identity, admission receipts and the terminal CAS
84
- keep the row they depend on. Compaction leaves it alone for the same reason: a
85
- turn whose answer is never spoken is not a turn that may be summarised into one,
86
- because that would both feed the discarded text to the summariser and drop the
87
- record in `replacedMessageIds`.
88
-
89
- It is also outside the token budget. `selectAgentHistory` removes it with reason
90
- `'superseded'` and does not count it, so an abandoned fragment cannot push a
91
- real turn out of a context it never occupies.
92
-
93
- ### How an interrupted turn reaches the model
388
+ ```ts
389
+ // Bun
390
+ import { createBunSqliteAgentRuntimeStore } from 'stitchkit/agent-runtime/sqlite/bun'
94
391
 
95
- An interrupted turn is projected, and says so:
392
+ const sqlite = createBunSqliteAgentRuntimeStore({ filename: './agent-runtime.sqlite' })
393
+ const runtime = createAgentRuntime({ ...config, store: sqlite.store })
96
394
 
97
- ```text
98
- { role: 'assistant', content: [
99
- { type: 'text', text: 'We are the team, where would you like' },
100
- { type: 'text', text: '[interrupted: this turn was cut off before it finished]' },
101
- ]}
395
+ await runtime.close()
396
+ await sqlite.close()
102
397
  ```
103
398
 
104
- `history.interruptedAssistant` chooses the form, and the difference between the
105
- first two is structural rather than cosmetic. **An assistant turn in provider
106
- history is a commitment**: the model reads its own previous turn as something it
107
- said and stays consistent with it. A system line is context.
108
-
109
- | value | form | right when |
110
- |-------|------|-----------|
111
- | `assistant-marked` (default) | assistant turn plus a marker | the human read the text |
112
- | `system-note` | `[interrupted] partial response: …` as a system line | the fragment reached nobody |
113
- | `omit` | not projected at all | you want it gone from the request |
114
-
115
- There is deliberately no value that reproduces what the projection used to do,
116
- which was to send the partial as an ordinary assistant turn and drop its
117
- `control` marker on the way. That was the defect, not a behaviour to stay
118
- compatible with.
119
-
120
- `projectAgentHistoryDetailed` reports what reached the provider, including part
121
- types that no projected content stands for:
122
-
123
399
  ```ts
124
- const { decisions } = await projectAgentHistoryDetailed(snapshot.messages)
125
- // → { messageId: 'assistant-2', action: 'projected', reason: 'projected',
126
- // omittedParts: ['source', 'provider'] }
127
- // → { messageId: 'assistant-1', action: 'omitted', reason: 'superseded' }
128
- ```
400
+ // Node 22.5+
401
+ import { createNodeSqliteAgentRuntimeStore } from 'stitchkit/agent-runtime/sqlite/node'
129
402
 
130
- `runtime.stop(key, 'supersede')` is the same decision taken by hand: the
131
- process-local escape hatch chooses the reason, so a caller that knows the answer
132
- was never delivered can discard it without a newer input arriving.
133
-
134
- ### An input that joins a run in flight
135
-
136
- `inject` is right when the new input **refines** rather than redirects, and the
137
- steps already taken are still worth something: *"summarise this thread… actually,
138
- in bullet points"* should not throw away the reading the first message paid for.
139
-
140
- What happens:
141
-
142
- 1. The input is admitted exactly like any other — a committed user message and a
143
- **queued run**, durable before anything else happens.
144
- 2. At the running loop's next step boundary, that input joins its prompt. Only
145
- that input: an unrelated queued submission is never carried in.
146
- 3. When the run finishes, its terminal commit — **one transaction** — records
147
- the input as one the run answered and settles the queued successor with
148
- `terminalReason: 'absorbed'` and `absorbedIntoRunId` naming the run that
149
- answered it. Every ticket for that successor resolves to the same answer.
150
-
151
- Nothing durable happens between 1 and 3, and that is the design (→ ADR 0113).
152
- A run that crashes, is closed, or is interrupted after taking an input on
153
- commits no absorption at all, so what is left behind is an ordinary queued
154
- successor — the state every other policy already produces and recovery already
155
- handles. **There is no ordering in which an accepted input becomes
156
- unanswerable.** Only a run that *completes* may absorb; an interrupted one took
157
- the input into its prompt and then stopped, and does not get to say it answered
158
- it.
159
-
160
- An absorbed run has **no assistant message of its own** — it produced none, and
161
- writing an empty one would be a record claiming otherwise. Its answer is
162
- reachable through `absorbedIntoRunId`, and the store follows that pointer itself
163
- when a submission arrives on the absorbed run's idempotency key, so a retry
164
- after a restart returns the answer rather than an empty terminal record.
165
-
166
- It does publish one more `run-state` event, carrying `'superseded'`. It never
167
- enters the run executor, so that event is the only thing that tells a delivery
168
- surface following its `runId` that it is no longer queued.
169
-
170
- With `coalescePending`, one successor can carry several inputs, and an
171
- absorption covers a successor **whole or not at all** — a partial one would
172
- leave a terminal run with inputs nobody answered. Inputs that coalesce before
173
- the absorbing run's last step boundary join the same absorption. One that
174
- arrives after it cancels the absorption: the successor then answers all of its
175
- inputs itself, and the absorbing run has answered one of them too. A duplicate
176
- answer, never a missing one.
177
-
178
- (0.63.0 shipped a version of this that committed the absorption at the step
179
- boundary, before the answer existed, and it was withdrawn in 0.65.0. ADR 0113
180
- records what that ordering broke.)
181
-
182
- With `runs.coalescePending: true`, an active lane has at most one queued
183
- successor. Every later accepted input is atomically appended to that successor;
184
- its `AgentRun.inputMessageIds` records the whole batch and every input ticket
185
- resolves to the same terminal run. Coalescing never mutates the active run.
186
-
187
- `AbortSignal.aborted` is only a cooperation request. A successor does not begin
188
- while the predecessor still owns managed callbacks. A hung predecessor blocks
189
- the lane in the first version.
190
-
191
- Shutdown is two-phase, and both budgets carry the names they carry everywhere
192
- else in Stitchkit:
193
-
194
- ```ts
195
- const closed = await runtime.close({ gracePeriodMs: 30_000, forceTimeoutMs: 5_000 })
196
- if (!closed.settled) {
197
- console.warn(`exiting with ${closed.remaining} run(s) still in flight`)
198
- }
403
+ const sqlite = createNodeSqliteAgentRuntimeStore({ filename: './agent-runtime.sqlite' })
199
404
  ```
200
405
 
201
- `close` first rejects new process-local admissions and gives active runs
202
- `gracePeriodMs` to finish on their own. Only after that budget expires does it
203
- abort them with reason `shutdown`; `forceTimeoutMs` then bounds the settlement
204
- wait for a non-cooperative model or tool, measured from the abort — the two
205
- budgets add up rather than overlapping.
206
-
207
- **`close()` reports what it achieved rather than promising an outcome it cannot
208
- reach.** The result is `{ settled, timedOut, remaining }`: `settled` when every
209
- in-flight run finished, `timedOut` with a `remaining` count when the force
210
- budget expired first. There is no combination of budgets that is both bounded
211
- and guaranteed to leave nothing in flight — that is the trade the budgets exist
212
- to make, and the result is where you read which side of it you got:
213
-
214
- | budgets | behaviour | can return with a run in flight |
215
- |---------|-----------|---------------------------------|
216
- | neither | aborts immediately, waits for settlement | no — unbounded wait |
217
- | `gracePeriodMs` only | waits, aborts, then waits for settlement | no — unbounded wait |
218
- | `forceTimeoutMs` only | aborts immediately, waits at most that long | **yes** — `timedOut` |
219
- | both | waits, aborts, waits at most that long | **yes** — `timedOut` |
220
-
221
- A caller that wants the old shape still writes `await runtime.close(…)` and
222
- ignores the result. A caller deciding whether to exit the process reads it.
223
- Durably queued records rejected from the local queue remain recoverable through
224
- `scanRecoverable`; close never marks them terminal on its own.
225
-
226
- Use `await runtime.interrupt({ conversationId, runId })` when the interruption
227
- must be durable: it first commits `interrupt_requested`, then aborts the local
228
- coordinator signal. If provider completion races that revision change, the terminal path reloads
229
- the canonical snapshot. An already-terminal winner settles the ticket directly; a still-owned
230
- `interrupt_requested` run is committed as `interrupted`, and unrelated aggregate-head conflicts
231
- remain retriable while the run is active with the same owner and fencing token. A stale owner or
232
- fencing token remains a conflict. Only the execution that applies the terminal mutation publishes the
233
- **delivery** `terminal` event — a loser settles from canonical state without republishing the turn,
234
- and its `AgentRuntimeResult.metrics` is `undefined`. The **operator** `run-terminal` event is not
235
- gated that way: a losing execution still ran, and still spent whatever it spent, so it reports its
236
- own usage. The two channels answer to different readers — delivering a turn twice is a user's
237
- problem, and omitting a run's cost is an operator's.
238
- `runtime.stop(key)` is the process-local signal-only escape hatch.
239
-
240
- When an operator has positive evidence that another process owning one known
241
- run is gone, close that run through the runtime boundary rather than updating
242
- an adapter table or JSON payload directly:
406
+ Initialization creates only `stitchkit_agent_runtime_*` tables and records
407
+ schema version 2 in `stitchkit_agent_runtime_meta`; it does not use
408
+ `PRAGMA user_version` or mutate application tables. An unknown schema version or
409
+ unversioned partial Stitchkit schema is refused. The connection is owned by the
410
+ returned handle and closes only after accepted operations drain.
411
+
412
+ Each handle serializes its transactions. Separate connections use
413
+ `busy_timeout = 0`: a competing synchronous writer fails promptly with
414
+ SQLite's lock error, so an awaited transaction can resume and commit or roll
415
+ back. Retry that explicit error with an application-owned bounded policy; do
416
+ not configure a blocking busy timeout on the same JavaScript thread.
417
+
418
+ Aggregate reads and catalog pages use `BEGIN`, so a WAL writer may remain active
419
+ while the reader sees one committed snapshot. Mutations and purge use
420
+ `BEGIN IMMEDIATE`: they reserve the writer slot before reading state for CAS and
421
+ never attempt a deferred read-to-write upgrade. A custom driver may inspect the
422
+ additive `transaction` option `{ access: 'read' }`; ignoring it preserves the
423
+ previous write-safe behavior.
424
+
425
+ The adapter proves canonical durability across reopen and process restart. It
426
+ does not make external tool effects exactly once, join application projections
427
+ atomically or replace an application outbox. If a product row must commit with
428
+ an agent transition, implement `AgentRuntimeStoreDriver` over the application's
429
+ own transaction boundary instead.
430
+
431
+ Opening a version 1 store migrates it in one transaction, forward only — back
432
+ the file up first, a 0.85.x package refuses a migrated file. Existing
433
+ normalized state becomes one `runtime/baseline` event per conversation, dated
434
+ when the migration ran and carrying `asOf` for the last message it describes;
435
+ search and projections address that pre-migration history as one snapshot at
436
+ `seq 1`. New transitions continue the monotonic conversation sequence. Version
437
+ 2 requires SQLite FTS5 because event search is an advertised capability, so a
438
+ build without FTS5 is a startup error (`requires FTS5 support`) rather than an
439
+ empty search result; check with
440
+ `SELECT sqlite_compileoption_used('ENABLE_FTS5')`.
441
+
442
+ The handle returned by `createBunSqliteAgentRuntimeStore` /
443
+ `createNodeSqliteAgentRuntimeStore` is `{ store, conversations, database,
444
+ transaction, close }`. `database` is the shared connection and `transaction`
445
+ is one write transaction in the store's own serialization: the SQLite
446
+ companions — `createSqliteAgentProjectionStore`, `createSqliteAgentSpillStore`,
447
+ `createSqliteAgentChildManager`, `createAgentScheduleService` — take `{ sqlite }`
448
+ and write their rows and events through it, so a row and its event land
449
+ together or not at all.
450
+
451
+ `conversations.messages` pages the model's history: what compaction removed is
452
+ not in it, which is the point of compaction. A person reading back their own
453
+ conversation needs the other view, and asks for it explicitly:
243
454
 
244
455
  ```ts
245
- await runtime.abandon({
456
+ const page = await conversations.messages({
246
457
  conversationId,
247
- runId,
248
- expectedRevision: run.revision,
249
- staleOwner: true,
458
+ limit: 50,
459
+ direction: 'after',
460
+ includeCompacted: true,
250
461
  })
462
+ const removed = new Set(page.compacted)
251
463
  ```
252
464
 
253
- The revision protects against stale operator evidence. The mutation updates the
254
- canonical run and failed assistant atomically, removes the run from recovery
255
- indexes, and publishes the resulting `run-state`. A run that moved since it was
256
- read returns a conflict; `staleOwner: true` is mandatory evidence, not a default.
257
-
258
- The same evidence holds inside one process when a store failure outlives the
259
- run's own handling. A store call around execution that throws is retried for
260
- about half a second, then decided by where the run stood. Retries bound a call
261
- that fails, not one that hangs, so give the store its own timeouts:
262
-
263
- | Failure | Outcome of `result` |
264
- |---|---|
265
- | 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` |
266
- | the first assistant draft | resolves with reason `runtime_failure`, like a checkpoint later in the turn |
267
- | a conflict on a run another executor holds | rejects with the conflict; the record stays that executor's |
268
- | a conflict on a run still queued, behind another run | rejects with `AgentRunUnsettledError`; `recover()` or `resume` picks it up |
269
- | the terminal commit, or closing the run, retries spent | rejects with `AgentRunUnsettledError` |
270
-
271
- `AgentRunUnsettledError.run` is the record as last read, and the store may be ahead
272
- of it. Nothing in this process executes that run, and later turns of its
273
- conversation wait behind it. `recover()` skips a `running` run with a live owner, so
274
- a long-running process closes it itself once the store answers. Read the record
275
- first: with no owner or with `error.ownerId` it is this runtime's, and any other
276
- owner is executing it.
465
+ `items` stays one sequence in the order it happened, and `compacted` names the
466
+ ids inside it that the model no longer sees — the boundary is a mark on the
467
+ conversation, not a second list. A compaction summary sits at the position of
468
+ the last message it replaced, so the block reads through and the summary
469
+ arrives after it, where it was written; in the model's history, where every
470
+ replaced message is gone, it occupies the block's place either way. Without
471
+ the flag the page is exactly what it was, `compacted`
472
+ empty. The v1 → v2 migration baseline records the same thing: the whole
473
+ sequence, with `compacted` naming what had been folded away. A file migrated
474
+ by 0.86.0 has a baseline built from the active history only — the messages
475
+ themselves are still in the store and this read reaches them, but that one
476
+ event cannot be rewritten after the fact.
477
+
478
+ ### Retried provider streams
479
+
480
+ With `loop.retry` set, a provider stream that fails before any tool call in the
481
+ attempt is retried at the step boundary. Subscribers see `attempt-reset` for the
482
+ run before the next attempt's first delta and drop what the failed attempt
483
+ streamed; the durable draft is checkpointed after that output is discarded; an
484
+ input injected into the failed attempt is taken again by the retry; and the
485
+ failed attempt's reported usage stays in the run's spend. Transient events —
486
+ deltas, reasoning, `attempt-reset` — are numbered from 1 per run, counting only
487
+ what was published, so a cursor sees no gap on an ordinary stream.
488
+
489
+ ### Branch or restore with a new identity
490
+
491
+ Use `store.branchConversation()` to create an independent conversation. `importConversation()`
492
+ restores the original identity and is not a fork. The built-in memory and SQLite stores and
493
+ `createAgentRuntimeStore()` expose branching; a hand-written `AgentRuntimeStore` can advertise this
494
+ optional capability. The host authorizes the caller's source/archive access and destination
495
+ creation before calling this trusted store boundary.
277
496
 
278
497
  ```ts
279
- const settled = await ticket.result.catch(async (error) => {
280
- if (!(error instanceof AgentRunUnsettledError)) throw error
281
- const { conversationId, id: runId } = error.run
282
- const current = await store.loadRun({ conversationId, runId })
283
- if (current && [undefined, error.ownerId].includes(current.run.ownerId)) {
284
- await runtime.abandon({
285
- conversationId,
286
- runId,
287
- expectedRevision: current.run.revision,
288
- staleOwner: true,
289
- })
290
- }
291
- throw error
498
+ const copy = await sqlite.store.branchConversation({
499
+ requestKey: 'branch-request-1',
500
+ destinationConversationId: 'new-conversation',
501
+ source: { kind: 'conversation', conversationId: 'original', throughRunId: 'finished-run' },
502
+ // Required when selected history has spills: an explicit policy for this destination.
503
+ artifactAuthorization: { operation: 'read', path: 'approved-destination-workspace' },
292
504
  })
293
- ```
294
-
295
- A `queued` run lost to a run ahead of it, and `runtime.resume()` can run it instead
296
- once that one ends.
297
-
298
- ## Store operations
299
-
300
- `AgentRuntimeStore` remains the runtime-facing aggregate. Application adapters
301
- implement the smaller `AgentRuntimeStoreDriver` rather than these eleven members:
302
-
303
- - `acceptInputAndAssignRun`
304
- - `acquireRun`
305
- - `checkpointRunAssistant`
306
- - `requestRunInterrupt`
307
- - `recoverRun`
308
- - `commitRunTerminal`
309
- - `replaceCompactedRange`
310
- - `loadSnapshot` — the whole conversation; see **Reading a conversation** below
311
- - `loadRun` — one run by id, with the answer it produced if it has ended
312
- - `listActiveRuns` — the runs of one conversation that have not ended
313
- - `scanRecoverable` — one **bounded page** of recoverable runs; `recover()`
314
- calls this and nothing else, so an adapter that implements the interface has
315
- everything recovery needs
316
-
317
- ### Writing history no run owns
318
-
319
- `seedConversationInput` writes messages no run owns, exactly once per `seedKey`; the key is
320
- recorded beside the history, so compaction or replacement of the messages never brings them
321
- back. It places them by `placement`:
322
-
323
- - absent or `'lead'` — committed user instructions ahead of the whole conversation;
324
- - `'after-settled'` — an exchange that already happened without a model (a confirmed voice
325
- command and its receipt): committed `user` and completed `assistant` messages. They land after
326
- the settled history and ahead of the first message of any run still active, so an active run's
327
- input and answer stay adjacent and the next run reads the exchange before the input queued
328
- behind it. With no active run they are the tail.
329
505
 
330
- ```ts
331
- await store.seedConversationInput({
332
- conversationId,
333
- seedKey: `voice:${commandId}`,
334
- placement: 'after-settled',
335
- inputs: [ownerMessage, receiptMessage],
506
+ const restored = await sqlite.store.branchConversation({
507
+ requestKey: 'restore-request-1',
508
+ destinationConversationId: 'restored-as-new',
509
+ source: { kind: 'archive', bytes: archiveBytes },
336
510
  })
337
511
  ```
338
512
 
339
- The pair is ordinary history: it is in `loadSnapshot`, compaction may summarize it, and the next
340
- provider request carries it. No model is called and no run is created.
341
-
342
- ### Reading a conversation
343
-
344
- Two shapes of read, and the difference matters as a conversation grows.
345
-
346
- **Bounded.** `loadRun` and `listActiveRuns` read run records and the
347
- conversation head. Neither touches history, so neither grows with the length of
348
- the conversation. `loadRun` is how you resolve the `runId` that
349
- `submit().admission` hands back — it returns the run, the version it was read
350
- at, and, once the run is terminal, the answer it produced.
351
-
352
- **Unbounded.** `loadSnapshot` returns every message and every run, and so does
353
- every mutation result: the store's reducer validates its invariants against the
354
- whole conversation, and the runtime builds the next prompt from the snapshot the
355
- mutation returns. Ask for it when you need the conversation — composing a
356
- prompt, or compacting — and not to look one run up.
357
-
358
- So the cost of a run scales with the length of its conversation, not with the
359
- length of the turn. **Configure compaction** (see below) for anything
360
- long-running: it is the only thing in the framework that makes a conversation
361
- smaller, and without it a year-old assistant thread is read in full on every
362
- turn. This is a known limit, held deliberately rather than by omission — paged
363
- history would have to change what a snapshot *is*, and the store's invariants
364
- with it, and that is a decision on its own (→ ADR 0112).
365
-
366
- Every mutation carries an expected run revision or snapshot version. Input
367
- assignment additionally carries an idempotency identity. A conflict is a
368
- control outcome; stale data is never silently overwritten.
369
-
370
- Acquisition increments an optional monotonic `fencingToken`. The managed runtime carries it through
371
- checkpoint/terminal CAS and tool context, so a distributed adapter can reject an old owner even if
372
- an owner label is reused. Lease expiry and renewal remain application-owned.
373
-
374
- On startup, `runtime.recover({ resolveContext })` consumes bounded lightweight
375
- pages, then restores each conversation's durable execution order before any run
376
- acquires. Persisted `executionSequence` orders work that started; queued
377
- `interrupt-next` work precedes ordinary queued work with FIFO preserved inside
378
- each class. Scan identifiers, equal timestamps and page boundaries therefore
379
- never become queue order. Its safe default resumes queued runs and reports acquired or
380
- `interrupt_requested` runs as skipped. A policy may requeue acquired work only
381
- with explicit replay-safe evidence, or abandon it only with stale-owner
382
- evidence. Each attempted run returns its own outcome/error; a `resumed` or
383
- `requeued` outcome also exposes the terminal `result` promise. The outcome is
384
- reported only after durable acquisition, so a lost acquisition is `failed`
385
- rather than a successful-looking handoff. `pageSize`, `maxRuns`, and `signal`
386
- bound the pass. `runtime.resume(...)` remains available for one known queued
387
- record; its `accepted` promise likewise means that the recovered run acquired
388
- durable ownership, while `result` carries terminal completion.
389
-
390
- Canonical records currently write `schemaVersion: 1`. A durable adapter owns
391
- read-time migration of older rows: migrate to the current shape at its storage
392
- boundary, validate with the exported schema, and write only the current
393
- version. Core deliberately does not guess an application's database migration
394
- or silently accept an unknown future version.
395
-
396
- ### Purging a conversation
513
+ `throughRunId` selects its exact terminal/abandon event. Omit it to include the settled tip and
514
+ compaction recorded after the last run. The selected prefix must contain no queued or acquired
515
+ runs. A later active source run does not prevent branching an earlier settled prefix. A migrated
516
+ baseline has no earlier per-run event boundary; it can be copied at its settled tip.
517
+
518
+ The result is `{ outcome: 'created' | 'duplicate', receipt }`. Keep the receipt: it binds source
519
+ and destination identity, source kind, exact `sourceSeq`/`sourceVersion`, `sourceDigest`, request
520
+ fingerprint and creation time. A retry with the same destination/key and selector returns the
521
+ original receipt even after the source advances or the destination continues. Changing the
522
+ request conflicts; no existing destination is overwritten. Archive fingerprints bind the supplied
523
+ bytes. The source digest binds the selected canonical events, artifacts and reconstructed snapshot.
524
+ Run update timestamps reconstructed from events use their recorded `occurredAt`. Abandon transitions
525
+ retain the full failed assistant in the same event; a legacy archive must retain that exact assistant
526
+ in its runtime snapshot, otherwise copying refuses instead of inventing historical content or time.
527
+
528
+ No provider or tool runs during copying. The next ordinary runtime `submit` continues the copied
529
+ history. All engine identities and typed references are new, including repeated references to one
530
+ spill. Compacted content stays in the event history and SQLite inactive rows; the model reads the
531
+ same active summaries. A hash-only checkpoint is historical digest evidence, marked
532
+ `sourceSha256`; a later full terminal record must settle its content.
533
+
534
+ Recorded provider options and canonical reasoning/tool-call provider metadata are retained.
535
+ Provider message hashes and request references remain consistent after rekey. Metadata is
536
+ data, subject to the opaque JSON reference guard: an undocumented runtime reference key
537
+ refuses instead of carrying a source identity into the destination. Unknown message fields
538
+ and malformed request/message digests also refuse before any destination writes.
539
+
540
+ A branch grants no source authority. Terminal run owners/fences and approval signatures are
541
+ removed. State, schedules, children, retries, sandbox, approval rejection and durability facts are
542
+ retained as inert `runtime/branch-observation` records; they cannot repopulate operational state
543
+ or pending effects. Spills discard source authorization and require the explicit fresh
544
+ `artifactAuthorization`; normal destination read authorization still applies. No cached projection,
545
+ schedule row or child relation is cloned. Ordinary companion projections in the export have
546
+ `{ name, version, uptoSeq, value, updatedAt }` envelopes. Branch validates their envelopes,
547
+ unique names and checkpoints within the supplied journal, then discards their values. They are
548
+ derived application caches, including ones containing source references; their values are never
549
+ replayed as state or copied as authority. Rebuild destination projections with
550
+ `createSqliteAgentProjectionStore().advance(...)` over the destination ledger. An exact earlier
551
+ boundary never inherits a later cache value.
552
+
553
+ The store validates the complete archive before writing: contiguous events, a matching runtime
554
+ snapshot, known typed forms, reference closure, and spill bytes/digests. Unsupported provider
555
+ envelopes, encrypted invocation payloads, unknown event versions, malformed projection envelopes,
556
+ missing artifacts and undocumented reference fields in opaque JSON refuse atomically. They need
557
+ an owner-defined transfer codec; never recursively rewrite internal JSON in the application.
558
+ Third-party drivers must provide atomic write transactions and archive support for spills;
559
+ `history.restore` is required for migrated baselines with inactive rows.
560
+
561
+ ### Event ledger, projections and durable capabilities
562
+
563
+ `AgentRuntimeStore` exposes bounded `readEvents`, declared `appendEvent`, canonical
564
+ `exportConversation` and empty-target `importConversation`. Runtime transitions, exact provider
565
+ requests and completed provider response identities enter the same append-only ledger. A
566
+ `provider/response` payload binds `response: { id, provider? }` to its `runId`, retry `attempt` and
567
+ `stepNumber`; `step-finished.response` projects the same object to the operator sink. The optional
568
+ upstream name comes from the model provider's own `resolveResponseProvider` — `openRouterProvider`
569
+ supplies it — so the runtime carries the fact without learning any gateway's metadata key. The normalized
570
+ head/run/message tables remain the fast operational projection.
571
+
572
+ For `runtime/transition` payload inspection, use `AgentStoreTransitionRecordSchema` and its
573
+ inferred `AgentStoreTransitionRecord` type. A checkpoint record may hold either the full assistant
574
+ message or `{ id, status: 'streaming', sha256?, sourceSha256?, parts: number, bytes: number }`;
575
+ exactly one digest is present. Numeric `parts` counts parts; it is not an array of message parts.
576
+ `sourceSha256` preserves a branch's historical source digest. These hash-only records are
577
+ observations, not executable checkpoint inputs. `AgentStoreTransitionSchema` describes full
578
+ store mutations. Shape inspection does not replace branch's complete replay/reference validation.
579
+ Schema v4 archives written by 0.109.4 use the same journal representation.
580
+
581
+ Use `defineAgentProjection` with `createSqliteAgentProjectionStore` for a
582
+ deterministic versioned fold. Every value reports `uptoSeq`, so a caller can
583
+ distinguish current data from a projection that still has events to consume.
584
+ Changing the projection version folds from sequence one.
585
+
586
+ The built-ins are `agentSummaryProjection`, `agentUsageProjection` and
587
+ `agentOutlineProjection`; focused card, state-slot and schedule projections are
588
+ also available. `list(projection)` reads all materialized conversation rows in
589
+ one SQLite query instead of reopening their event logs.
590
+
591
+ Durable state is declared once and injected on every provider request:
397
592
 
398
593
  ```ts
399
- import { purgeAgentConversation } from 'stitchkit/agent-runtime';
594
+ const goal = defineStateSlot({
595
+ name: 'goal',
596
+ schema: z.object({ objective: z.string(), status: z.string() }),
597
+ })
400
598
 
401
- const result = await purgeAgentConversation(store, {
599
+ const slots = createAgentStateSlotStore({ store: sqlite.store, definitions: [goal] })
600
+ await slots.set({
402
601
  conversationId,
403
- expectedVersion: snapshot.version, // optional stale-intent protection
404
- });
405
- ```
406
-
407
- The optional `AgentRuntimeStore.purgeConversation` capability is implemented by the memory and
408
- initialized Bun/Node SQLite stores. The helper returns `unsupported` for stores without it; it
409
- never pretends deletion succeeded. Other outcomes are `purged`, `already_purged`,
410
- `active` (with `runIds`), or `conflict` (with `actualVersion`). Storage failures reject.
411
-
412
- Purge refuses queued, running and interrupt-requested runs. It does not interrupt, force-abandon
413
- or wait for a provider. Authorize the destructive request in the host, close its conversation
414
- ingress/attachments, then settle runs through normal interruption or explicitly justified recovery.
415
- Retry purge after they are terminal. An optional expected version must match the current snapshot;
416
- a version conflict requires a fresh deletion decision, not blind retry with a new version.
417
-
418
- Successful purge atomically removes messages (including compacted inactive rows), all runs and
419
- retained terminal assistants, admissions/idempotency receipts, conversation heads and their owned
420
- indexes. It retains only a permanent conversation-ID tombstone. Purging an unknown ID reserves it
421
- as well, so a submit paused before admission cannot create it later. Repeating the call returns
422
- `already_purged`, regardless of the original expected version. A lost response is safely retried
423
- with the same ID; **new chats require a new ID**.
424
-
425
- Every later runtime mutation rejects with `AgentConversationPurgedError`, including fresh and
426
- duplicate admissions, checkpoints, recovery and compaction. Existing controller leases grant no
427
- exception. Existing reads return empty snapshots, absent runs and empty message pages; purged
428
- conversations disappear from the SQLite catalog and recovery scans. Snapshots retain their
429
- existing empty `version: 0` shape; use the purge result, not an empty snapshot, as deletion evidence.
430
-
431
- SQLite commits the tombstone and all deletions in one `BEGIN IMMEDIATE` transaction. Failure before
432
- commit rolls everything back; reopen/recovery cannot restore committed deleted records. Same-thread
433
- connection contention rejects promptly without effects; retry after the competing operation settles.
434
- Initialization adds the tombstone table and write guards to schema v1 transactionally, without
435
- changing application tables or `user_version`. Guards also fence older writers. `initialize: false`
436
- on an original v1 database does not add purge capability; open an initialized writer first.
437
-
438
- Consumer-owned model selections, projections, attachment files, delivery/outbox records and event
439
- logs remain outside this boundary. Retain an authorized cleanup intent plus any needed opaque file
440
- references in consumer storage, purge first, then retry idempotent consumer cleanup. Invalidate local
441
- caches/subscriptions so an old delivered event cannot repopulate a UI. The library never traverses
442
- paths, revokes remote artifacts or claims a cross-store transaction. Nor does logical purge securely
443
- wipe database free pages, WAL or backups. Memory tombstones last only for that store instance.
444
-
445
- Custom normalized drivers may opt in through `AgentConversationPurgeDriver` on `driver.conversations`.
446
- Their transaction must serialize `isPurged`, head/active-run checks, `remove`, and **all** mutations
447
- for the same ID, including absent-ID admission. An optimistic head CAS alone is insufficient. `remove`
448
- must delete every owned payload/index and preserve the tombstone; errors must roll back the entire
449
- transaction. Drivers without this guarantee must leave the capability absent. See ADR 0138.
450
-
451
- ## Events and reconnect
452
-
453
- A subscriber that switches over `event.type` handles one transient event
454
- beyond deltas and reasoning: `attempt-reset`, published when `loop.retry`
455
- starts a new attempt, meaning everything this run streamed before it is
456
- withdrawn. `reduceAgentControlEvent` does that; a hand-written reducer must,
457
- or it keeps the failed attempt's partial text on screen.
458
-
459
- `publish` receives event classes with different guarantees:
460
-
461
- - `admission` follows a successful acceptance CAS and carries the same complete
462
- projection as `ticket.admission`;
463
-
464
- - `assistant-delta` is transient and ordered by
465
- `(runId, runtimeEpoch, sequence)`;
466
- - `reasoning-start`, `reasoning-delta` and `reasoning-end` are transient and
467
- ordered by the same identity; delta carries only the current text fragment,
468
- while provider metadata stays inside a validated canonical envelope;
469
- - `assistant-checkpoint` follows a successful checkpoint CAS;
470
- - `run-state` follows durable queue/acquire/interrupt transitions;
471
- - `run-operation` follows a successful operation CAS. Its `operation` is the
472
- durable `AgentRun.lastOperation`: `model-request` or `compaction`, with
473
- `started`, `first-output`, `completed`, `failed` or `cancelled` phase and the
474
- original timestamps. Concrete models use SDK call identity in an awaited model
475
- middleware; global provider IDs use a runtime-generated identity during awaited
476
- step preparation. Both durably admit the request before entering `doStream`, so a failed
477
- write prevents the provider call. This does not claim HTTP transmission is
478
- measured. First output is a
479
- non-empty text/reasoning/tool-argument delta or complete tool call; metadata,
480
- usage, files and sources do not count;
481
- - `tool-status` is transient lifecycle presentation with JSON-safe input on
482
- start and output on completion. A mounted typed failure carries the same safe
483
- `{ error, retryable, details?, _hint? }` envelope as the durable result; an unknown
484
- internal cause remains generic and stays in local observability only;
485
- - `tool-progress` is transient: what a running tool handler reported through
486
- `ctx.reportProgress` — `message`, `progress` and `total`, the fields an MCP host
487
- receives — with the call's `callId` and `toolName`. It never enters the
488
- model's input or the durable history. A message longer than
489
- `AGENT_TOOL_PROGRESS_MESSAGE_MAX` (280) is cut, reports of one call closer
490
- than 250 ms apart are dropped, and a report after the call's terminal
491
- `tool-status` or after its attempt was abandoned is dropped; the terminal
492
- status carries the final word. `reduceAgentControlEvent` keeps the last
493
- report of each running call in `transientByRun[runId].tools[callId]` and
494
- removes it on that call's `completed`, `failed` or `interrupted`. The runtime
495
- hands its reporter to a call through the SDK tool context, beside its step
496
- ledger; a tool that declares its own `contextSchema` keeps that contract and
497
- reports nowhere;
498
- - `terminal` follows the winning terminal CAS.
499
-
500
- `loop.checkpointEveryEvents` batches ordinary stream parts (default `20`) and
501
- does not batch live event delivery. Tool call/result/error/denial, approval
502
- request/response and step-finish boundaries force an assistant checkpoint and
503
- reset that batch. The checkpoint is awaited after the managed loop observes the
504
- normalized boundary. It does **not** promise persistence before a tool side
505
- effect: tool fencing supplies the real ownership checks around execution, while
506
- cross-crash effect idempotency stays application-owned. The following model
507
- step waits for the previous step-finish checkpoint before its own durable
508
- request admission and provider invocation. → ADR 0174.
509
-
510
- `compaction` names invocation of the configured callback, including a
511
- `not_needed` context-budget check; it does not by itself say that history was
512
- summarized. Operation timestamps are retained wall-clock observations and may
513
- move backwards after a clock correction. Do not subtract them for duration;
514
- phase order comes from durable snapshot versions/event order.
515
-
516
- These are post-commit notifications, not a transactional outbox: a process can
517
- crash between the database commit and `publish`. Reconnect should load canonical
518
- state. Exactly-once external delivery remains an application-owned outbox.
519
-
520
- ### External channel ingress and delivery
521
-
522
- The executable
523
- [`external-channel-harness.ts`](../../packages/core/examples/agent-runtime/external-channel-harness.ts)
524
- is the reference composition for webhooks, polling transports and local-device
525
- adapters. Its generic boundary injects a runtime, durable store and delivery
526
- adapter; the optional
527
- [`external-channel-grammy.ts`](../../packages/core/examples/agent-runtime/external-channel-grammy.ts)
528
- attaches the same ingress function to grammY polling or webhook lifecycle
529
- without putting provider types in the agent runtime.
530
-
531
- Inbound update identity, principal resolution, conversation mapping and reply
532
- target are application policy. Persist and deduplicate the update before
533
- `runtime.submit`, use the stored idempotency key on crash recovery, then persist
534
- the admitted `runId`. A duplicate arriving after restart resolves to that same
535
- mapping instead of creating another model turn.
536
-
537
- For output, choose `terminal-only` or `streaming` explicitly. Stable durable
538
- `eventId` values and transient `(runtimeEpoch, sequence)` identities become
539
- outbox keys; the application store assigns causal ordinals. A bounded channel
540
- is only a process-local wakeup and never the durable authority. Before sending,
541
- persist one of these states:
542
-
543
- | State | Recovery action |
544
- | --- | --- |
545
- | `not-dispatched` | may dispatch once |
546
- | `possibly-dispatched` | reconcile with the adapter or remain unresolved |
547
- | `acknowledged` | deduplicate; delivery is complete |
548
-
549
- The agent `terminal` event and the adapter's delivery receipt are intentionally
550
- different records. A successful run does not prove that a reply reached its
551
- channel, and a delivery failure never rewrites the canonical agent result.
552
- Shutdown stops ingress, drains accepted process-local work and leaves ambiguous
553
- outbox records visible for a later reconciliation pass. Stitchkit does not own
554
- a channel database, durable broker, provider payload schema or application
555
- identity policy.
556
-
557
- Durable event IDs are derived from run, event type and snapshot version. Use
558
- `advanceAgentRuntimeEventCursor` to classify delivery.
559
-
560
- **`gap` is reported for transient events only**, where `sequence` is a per-run
561
- counter the runtime really does increment once per event. Durable events carry
562
- the *conversation's* version, which advances on every mutation including the many
563
- that publish nothing — checkpoints, compaction, an acceptance that has not
564
- started — so two consecutive durable events are routinely several versions apart
565
- and adjacency says nothing. A durable loss is not detectable from the cursor;
566
- reload on reconnect, which is what a bounded fire-and-forget sink asks of you
567
- anyway. `createAgentRuntimeEventSink` adds a bounded failure-isolated lifecycle and typed
568
- projection/redaction hook. `onPublishError` records direct publisher failures without changing the
569
- already committed run.
570
-
571
- A named custom stop condition terminalizes with `policy_stop`; its `policyName`
572
- is persisted on the run and included in the terminal event/result. `max-steps`
573
- is the reserved built-in policy name. `loop.prepareStep` is the controlled AI
574
- SDK step boundary for changing active tools, model, instructions or messages.
575
- It cannot replace the managed tool set or bypass its lifecycle fence.
576
-
577
- Context can grow between steps as tool results and deferred schemas enter the
578
- provider prompt. When application budgeting can prove that the next assembled
579
- step exceeds the selected model window, refuse it by type before that provider
580
- call:
581
-
582
- ```ts
583
- prepareStep: (step) => {
584
- if (wouldExceedSelectedModelWindow(step)) {
585
- throw new AgentContextOverflowError('Prepared step exceeds the selected model window')
586
- }
587
- return chooseProductStepOptions(step)
588
- }
589
- ```
590
-
591
- That deliberate refusal ends the run as `context_overflow` on the durable
592
- record, delivery terminal and operator event. Stitchkit does not inspect error
593
- messages — it recognises failures by identity, not by text. A store refusing an
594
- owned mutation ends the run `storage_conflict`; `protocol.acceptTerminal`
595
- refusing a finished message ends it `output_rejected`; a check the SDK runs on
596
- what this runtime handed it ends it `runtime_failure`. Every error the runtime
597
- cannot identify as its own remains `provider_failure`, and operator-only
598
- observability retains its original cause in all of them.
599
-
600
- Completion validity belongs to the protocol and is checked before the terminal
601
- CAS. Protocols that require a visible answer opt in explicitly:
602
-
603
- ```ts
604
- const protocol = defineAgentProtocol({
605
- context: ContextSchema,
606
- inputMetadata: InputMetadataSchema,
607
- terminalAcceptance: 'require-output',
602
+ name: 'goal',
603
+ value: { objective: 'finish migration', status: 'active' },
604
+ actor: 'human',
608
605
  })
609
- ```
610
606
 
611
- The built-in rule accepts non-blank text, generated files, structured provider
612
- parts, and a named tool-only `policy_stop`. The default is `allow-empty` for
613
- protocols where an empty acknowledgement is meaningful. Pass a callback when
614
- the product has a narrower definition; it receives the candidate message,
615
- terminal reason and optional policy name, and runs before persistence. A false
616
- result fails the candidate instead of rewriting an already committed success.
617
-
618
- `loop.idleTimeoutMs` measures provider silence. It starts immediately before the
619
- provider call, resets on stream activity, and pauses while local tools execute.
620
- Resource preparation, child waits and approval waiting do not spend this budget.
621
- The host gives tools their own deadlines. A stalled provider still aborts with
622
- durable reason `timeout`; caller interruption and shutdown reach tools directly.
623
-
624
- Reconnect loads the durable snapshot. Missing transient deltas do not mean the
625
- canonical result was lost. Exactly-once external delivery requires the
626
- application's transactional outbox or stable-ID deduplication.
627
-
628
- ## Managed tools
629
-
630
- Durable `step` bodies return lossless JSON: finite numbers (excluding negative zero),
631
- strings, booleans, null, dense arrays and plain data objects. Return `null` explicitly
632
- for effect-only bodies and convert Date values to strings. Unsupported values are
633
- refused after the body, so external effects still require idempotency. First results
634
- and replay are detached snapshots; mutating a returned object cannot rewrite a record.
635
- The optional [sandbox coding profile](sandbox.md#coding-tool-integration) reuses the
636
- same tool mounting, authorization and output handling for namespace-backed commands.
637
-
638
- Always compose `toolFenceLifecycle` into `mountAgent`. It checks ownership
639
- before a managed side effect and again before accepting its result. Fence loss
640
- uses an internal control signal: it stops the old loop and is not sent to the
641
- model as a tool error.
642
-
643
- `mountAgent` sends failed runner outcomes through the AI SDK's tool-error
644
- channel. AgentRuntime persists them with `outcome: 'error'`, publishes the same
645
- safe envelope, and replays them as an error result on later turns. Classification
646
- comes from the execution channel, never from inspecting output fields: a valid
647
- successful result such as `{ error: 'domain value' }` remains successful. Typed
648
- `AppError` codes and safe details may reach the model; arbitrary thrown causes
649
- are retained for local hooks and reduced to `INTERNAL_SERVER_ERROR` outside.
650
-
651
- The framework cannot undo an already-started non-cooperative external effect.
652
- Pass the stable call/run idempotency identity into business mutations when the
653
- effect must be replay-safe.
654
-
655
- ## History, provider metadata and files
656
-
657
- `projectAgentHistory` converts canonical engine records into provider-valid AI
658
- SDK messages and pairs tool calls/results in persisted causal order. Adjacent
659
- parallel calls stay in one assistant round and their adjacent results stay in
660
- one tool round; a dependent call after those results starts a new assistant
661
- round, and trailing final text remains after the last tool result. Provider-required metadata is kept
662
- in a versioned opaque envelope and omitted from product delivery by default.
663
- `projectAgentHistoryDetailed` additionally returns one inspectable decision per canonical record;
664
- leading assistant records, crash drafts and unmatched tool chronology are never silently passed to
665
- the provider.
666
-
667
- `selectAgentHistory` is the non-destructive context-window selector. It removes only whole oldest
668
- complete turns, protects system/summary, incomplete and configured recent turns, and reports every
669
- keep/remove reason with measured/estimated/unavailable token provenance. An oversized protected turn
670
- returns `oversized`; unavailable accounting returns `unavailable` without invented arithmetic.
671
-
672
- `ComposedAgentPrompt.instructions` accepts the AI SDK `Instructions` contract.
673
- Use `adaptInstructions` when a provider needs metadata on the system message:
674
-
675
- ```ts
676
- prompt({
677
- context,
678
- signal,
679
- adaptInstructions: (content) => ({
680
- role: 'system',
681
- content,
682
- providerOptions: { openrouter: { cacheControl: { type: 'ephemeral' } } },
683
- }),
684
- })
607
+ const runtime = createAgentRuntime({ ...config, store: sqlite.store, stateSlots: [goal] })
685
608
  ```
686
609
 
687
- Attachments and generated files remain application-owned. The runtime only
688
- stores a reference returned by `persistGeneratedFile`; it never silently puts
689
- base64 blobs into the neutral history store.
690
-
691
- To send a stored attachment back to a multimodal model, configure
692
- `history.resolveFile`. It maps the neutral file reference to AI SDK file data
693
- (URL, bytes, provider reference or text). Without a resolver the
694
- `unresolvedFile` policy applies, and it is **`omit`** by default: the file part
695
- simply does not reach the provider. Choose `text` for a describing placeholder —
696
- the filename or media type, never the storage reference, because that string is
697
- an address inside your infrastructure and this content travels upstream — or
698
- `error` to fail loudly. The `error` message does name the reference: it is
699
- thrown into your process, where you are owed the whole story.
700
-
701
- ## Compaction
702
-
703
- `structuredCompaction` summarizes complete provider-valid turn groups outside
704
- the store lock, then calls `replaceCompactedRange` against the exact snapshot
705
- version. A concurrent input produces `conflict`; the stale summary is not
706
- applied. Summary records use the dedicated `summary` role and project to a
707
- provider system message. The consumer supplies the structured summary schema
708
- and prompt. Pass `previousSummary` for a direct call, or configure
709
- `readPreviousSummary` for runtime-managed compaction, to merge and atomically
710
- replace a leading summary on the next compaction.
711
- Set `maxAttempts` to allow bounded conflict recovery. Every retry reloads the snapshot, reselects the
712
- eligible range and recomputes the summary; the stale summary is never retried.
713
-
714
- ### Recovering a prefix after a failed turn
715
-
716
- The default evidence policy omits failed assistants. A failed turn therefore stops the
717
- compactable prefix, including completed turns after it. A terminal failure is not an
718
- unanswered tool call, but opting its partial evidence into a summary is an explicit host
719
- decision. Use the existing `evidencePolicy: { failedAssistant: 'assistant-marked' }` on
720
- `structuredCompaction` (or `selectCompactableHistory`) when that evidence is wanted.
721
- The same policy belongs on the summarizer's history projection and on any corresponding
722
- token-budget selection. Stored statuses remain unchanged.
723
-
724
- The summarizer receives **raw** `eligibleMessages`, not provider messages and not an
725
- automatic failure marker. Project them with the same policy before sending them to a
726
- model, and preserve failed outcomes in the application's structured summary and prompt:
610
+ `createAgentStateTools` binds `create_goal`, `update_goal`, `get_goal` and
611
+ `todo_write` to one conversation. Human changes call the same slot store with
612
+ `actor: 'human'`; they remain structured `state/set` facts rather than forged
613
+ chat messages.
614
+
615
+ `createSqliteAgentSpillStore` persists oversized output separately and records
616
+ its locator, hash and lifecycle in the ledger. `read_output` and
617
+ `search_output` re-run both locator authorization and the originating tool
618
+ authorization before reading bytes. A reference the store does not hold is a
619
+ refusal like any other — `SPILL_REFERENCE_UNKNOWN` / 404, naming the reference
620
+ and pointing at the one the spilling command returned. The lookup is scoped by
621
+ conversation, so a reference belonging to another conversation is absent rather
622
+ than forbidden, and the refusal cannot tell a model that it exists elsewhere. `createSqliteAgentEventSearch` returns the
623
+ exact conversation and event sequence; cross-conversation results are denied
624
+ unless `authorizeConversation` approves each target.
625
+ `createAgentEventSearchTools` supplies `session_search`, `session_trace` and
626
+ `session_event` over the same bounded APIs.
627
+
628
+ For credential-free failure tests, import `createFaultProviderServer` and
629
+ `createReplayAgentProvider` from `stitchkit/agent-runtime/testing`. Runtime
630
+ `loop.retry` applies an explicit `AgentRetryPolicy`: each decision is recorded
631
+ as `retry/scheduled`, each new attempt as `retry/started`, and an attempt that
632
+ has invoked a tool is never automatically replayed.
633
+
634
+ `createAgentScheduleService` persists `at`, `after` and `every` input. Repeating
635
+ schedules require an explicit IANA time zone. Dispatch uses a stable
636
+ `schedule:<id>:<occurrence>` idempotency key, and restart lateness is recorded.
637
+ Resolve after durable admission; return `{ status: 'retry', reason }` for a
638
+ transient refusal or `{ status: 'terminal', reason }` to stop the entire schedule in
639
+ `failed` state. Only those two shapes are failures: any other returned value — a
640
+ queue length, a receipt — means the callback returned, so it delivered. Throws retry
641
+ too. `maxAttempts` (default 1000, about sixteen hours at the retry ceiling) bounds
642
+ one occurrence: a one-off schedule then becomes `failed`, a recurring one gives up
643
+ that occurrence (`schedule/failed` with `skipped: true` and the `nextAt` it moves
644
+ to) and keeps its cadence. A failure reason is kept to 1000 characters. Backoff is 1, 2, 4, 8, 16, 32, then 60 seconds,
645
+ capped at 60 seconds and persisted across restart. `retryAt`, `attempts` and `lastError`
646
+ show retry diagnostics; success resets them. `nextAt` and `lateByMs` retain the original
647
+ occurrence time. Recurring schedules fire once after an idle stretch and advance to the
648
+ next future slot on their original cadence.
649
+
650
+ Dispatch receives an `AbortSignal`, has a 30-second deadline and holds a unique
651
+ 60-second lease. A late or cancelled attempt cannot settle or emit firing events.
652
+ Honor the signal and durably deduplicate the key: a crash after admission but before
653
+ settlement can replay delivery. This is at-least-once, not exactly-once.
654
+ Due selection is indexed, limited to 32 parallel deliveries per tick; one hung batch
655
+ can delay newly due work by at most its deadline. The service keeps one nearest-due
656
+ timer plus per-attempt deadline timers. Storage failures use a bounded recovery timer;
657
+ idle ticks do not write. SQLite v4 migration preserves due times and claims; stop older
658
+ scheduler processes before migration. See [ADR 0202](../decisions/0202-schedule-retries-preserve-occurrence-identity.md).
659
+ `createAgentScheduleTools` exposes the exact `schedule_after`, `schedule_at`,
660
+ `schedule_every`, `schedule_list` and `schedule_cancel` surface.
661
+
662
+ `createSqliteAgentChildManager` seeds a child from the parent's ledger through
663
+ an exact sequence, stores the graph, clamps requested limits to a durable parent
664
+ remainder and stops budget overruns at the next step boundary as
665
+ `policy_stop`. That enforcement runs inside the child's own runtime: the host that spawns it gives the child
666
+ `loop.stopPolicies: [agentChildBudgetStopPolicy({ manager, childConversationId })]`, which
667
+ calls `recordStepUsage` at every step boundary; `recordStepUsage` alone measures and decides,
668
+ and reports `enforced: false` when the deciding process holds no handle to stop. Given to `createAgentRuntime` as `children`, the manager stops the parent's children after the parent's terminal is durable — interrupted, cancelled, timed out or shut down; a successful, policy-stopped, superseded or failed parent leaves them to the conversation (ADR 0175 carries the table); without `children` nothing cascades; an unreachable child is recorded
669
+ as `lost`, not misreported as a provider failure.
670
+ `createAgentChildTools` adds `subagent`, `subagent_fork`, `list_agents`,
671
+ `send_message` and `interrupt_agent`; final output stays behind its bounded
672
+ result locator.
727
673
 
728
- ```ts
729
- const evidencePolicy: AgentHistoryEvidencePolicy = {
730
- failedAssistant: 'assistant-marked',
731
- }
732
-
733
- // Inside structuredCompaction({ evidencePolicy, ... }).summarize:
734
- const projected = await projectAgentHistoryDetailed(eligibleMessages, {
735
- evidencePolicy,
736
- incompleteToolTurn: 'error',
737
- })
738
- const failedMessageIds = eligibleMessages
739
- .filter(message => message.role === 'assistant' && message.status === 'failed')
740
- .map(message => message.id)
741
- // Pass projected.messages, projected.system and failedMessageIds to the host's
742
- // summarizer. Its schema/prompt must retain failures and uncertain effects as
743
- // such, merge previousSummary, and never describe partial output as success.
744
- ```
674
+ ## Durable order
745
675
 
746
- An opted-in failed assistant projects an explicit non-success marker even when it has
747
- no text or only control parts. A dangling call, unresolved approval, invalid result
748
- chronology or a user input with no assistant remains a barrier under either policy.
749
- Raw whole-turn completeness does not guarantee that every record can be projected:
750
- for example, a call and its result split across records may be rejected by the projector.
751
- `incompleteToolTurn: 'error'` makes that rejection stop summarization; the original history
752
- stays canonical. A host with such records must use a status-aware summarizer that explicitly
753
- represents every relevant record, or retain the range. Do not silently summarize an omitted
754
- exchange, filter the failed record out of the replacement range, rewrite its status or skip
755
- over an incomplete turn. The replacement remains one contiguous prefix under snapshot CAS.
756
-
757
- Provider chronology and external effect authority answer different questions (ADR 0269).
758
- A matching `tool-result` with `outcome: 'error'` and an application code such as `UNKNOWN`
759
- closes the provider exchange; it does **not** prove the external effect failed or succeeded.
760
- Compaction changes the model-history projection, not the canonical event journal or the
761
- retained terminal admission receipt. The `durability/effect` intent, accepted proof and
762
- uncertain outcome remain available after compaction and archive restore. A reconstructed
763
- `createLocalStepDurability` using the original `(conversationId, runId, effectName)` returns
764
- the accepted proof, reconciles a bare intent, or returns `uncertain` without calling `run`
765
- again. It does not deduplicate a new run or a newly named effect. Standalone effect callers
766
- still own the exclusive execution lease; sharing a ledger is not an atomic claim.
767
- An application-owned receipt outside that journal retains its own recovery rules and must
768
- not be discarded because a history turn became compactable.
769
-
770
- ### Compacting without the store
771
-
772
- The *selection* inside compaction needs no store, no version and no runtime, so
773
- it is published on its own as `selectCompactableHistory` — for an application
774
- that drives the model itself and keeps its own history (→ ADR 0142):
676
+ `acceptInputAndAssignRun` is one atomic operation. It is followed by ownership
677
+ acquisition, revision-checked assistant checkpoints and one terminal CAS. The
678
+ process-local coordinator releases its lane only after terminal commit.
775
679
 
776
- ```ts
777
- import { selectCompactableHistory } from 'stitchkit/agent-runtime'
680
+ A queued admission is durable immediately, even if its predecessor is still awaiting ownership
681
+ or its first checkpoint. Snapshots expose causal turn order rather than physical append order:
682
+ the predecessor's assigned input(s) and assistant come before the successor input(s). Execution
683
+ uses the same run boundary, so neither `prompt({ snapshot })` nor default/custom history
684
+ projection can see inputs assigned to a later run. `inject` remains the explicit path that moves
685
+ a successor input into the run already in flight.
778
686
 
779
- const { leadingSummary, compactable, retained } = selectCompactableHistory({
780
- messages,
781
- keepRecentTurns: 3,
782
- })
783
- if (compactable.length > 0) {
784
- const summary = await summarize(compactable, leadingSummary)
785
- messages = [summary, ...retained] // replace the old summary, never stack one
786
- }
687
+ ```text
688
+ input + queued run → running → execution settled → terminal CAS → successor
787
689
  ```
788
-
789
- `compactable` is the oldest **whole complete** turns and `retained` is
790
- everything the model must still hear; together they are the input, in order. A
791
- turn holding a tool call whose result never arrived is never eligible, and
792
- neither is anything after it: half a turn hands the provider a call with no
793
- result, which most of them refuse outright.
794
-
795
- `structuredCompaction` calls this same function, so the published selection and
796
- the runtime's cannot drift into two behaviours. What it adds is the part that
797
- does need the store — writing the result back under a version check.
798
-
799
- Deciding *when* to compact is yours: `AgentRuntimeRunContext.contextUsage`
800
- carries the last step's prompt size beside the model's window, and dividing them
801
- is one line where the threshold is decided. The core does not own that ratio, on
802
- purpose — see ADR 0142 for what it publishes and what it declines to.