stitchkit 0.96.1 → 0.97.0
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.
- package/CHANGELOG.md +22 -0
- package/dist/agent-runtime/schedule-contract.d.ts +52 -0
- package/dist/agent-runtime/schedule-contract.d.ts.map +1 -0
- package/dist/agent-runtime/schedule-dispatch.d.ts +26 -0
- package/dist/agent-runtime/schedule-dispatch.d.ts.map +1 -0
- package/dist/agent-runtime/schedule-records.d.ts +8 -14
- package/dist/agent-runtime/schedule-records.d.ts.map +1 -1
- package/dist/agent-runtime/schedule-tools.d.ts +1 -1
- package/dist/agent-runtime/schedule-tools.d.ts.map +1 -1
- package/dist/agent-runtime/schedules.d.ts +7 -92
- package/dist/agent-runtime/schedules.d.ts.map +1 -1
- package/dist/agent-runtime/sqlite-schema.d.ts.map +1 -1
- package/dist/agent-runtime/store-migrations/v3-to-v4.d.ts +4 -0
- package/dist/agent-runtime/store-migrations/v3-to-v4.d.ts.map +1 -0
- package/dist/entrypoints/agent-runtime/sqlite/bun.js +1 -1
- package/dist/entrypoints/agent-runtime/sqlite/node.js +1 -1
- package/dist/entrypoints/agent-runtime.d.ts +2 -1
- package/dist/entrypoints/agent-runtime.d.ts.map +1 -1
- package/dist/entrypoints/agent-runtime.js +289 -214
- package/dist/{index-p9e0w8hs.js → index-1m2thnq0.js} +25 -1
- package/llms/agent-runtime.2.txt +224 -0
- package/llms/agent-runtime.3.txt +1 -1
- package/llms/agent-runtime.txt +18 -225
- package/llms/stitchkit.txt +3 -3
- package/llms/{upgrading-0.37.0-0.56.0.txt → upgrading-0.37.0-0.58.0.txt} +82 -3
- package/llms/{upgrading-0.57.0-0.69.0.txt → upgrading-0.59.0-0.71.0.txt} +108 -82
- package/llms/{upgrading-0.70.0-0.96.0.txt → upgrading-0.72.0-0.97.0.txt} +39 -108
- package/llms/upgrading.txt +3 -3
- package/llms-full.txt +58 -5
- package/llms.txt +4 -4
- package/package.json +1 -1
|
@@ -337,10 +337,25 @@ function migrateAgentRuntimeSqliteV2ToV3(database) {
|
|
|
337
337
|
database.prepare("UPDATE stitchkit_agent_runtime_meta SET value = '3' WHERE key = 'schema_version'").run();
|
|
338
338
|
}
|
|
339
339
|
|
|
340
|
+
// src/agent-runtime/store-migrations/v3-to-v4.ts
|
|
341
|
+
function migrateAgentRuntimeSqliteV3ToV4(database) {
|
|
342
|
+
database.exec(`
|
|
343
|
+
ALTER TABLE stitchkit_agent_runtime_schedules ADD COLUMN retry_at TEXT;
|
|
344
|
+
ALTER TABLE stitchkit_agent_runtime_schedules ADD COLUMN attempts INTEGER NOT NULL DEFAULT 0;
|
|
345
|
+
ALTER TABLE stitchkit_agent_runtime_schedules ADD COLUMN last_error TEXT;
|
|
346
|
+
ALTER TABLE stitchkit_agent_runtime_schedules ADD COLUMN eligible_at TEXT
|
|
347
|
+
GENERATED ALWAYS AS (MAX(next_at, COALESCE(retry_at, next_at), COALESCE(claim_until, next_at))) VIRTUAL;
|
|
348
|
+
DROP INDEX stitchkit_agent_runtime_schedules_due;
|
|
349
|
+
CREATE INDEX stitchkit_agent_runtime_schedules_due
|
|
350
|
+
ON stitchkit_agent_runtime_schedules (eligible_at, id) WHERE state = 'scheduled';
|
|
351
|
+
UPDATE stitchkit_agent_runtime_meta SET value = '4' WHERE key = 'schema_version';
|
|
352
|
+
`);
|
|
353
|
+
}
|
|
354
|
+
|
|
340
355
|
// src/agent-runtime/sqlite-schema.ts
|
|
341
356
|
var MetaRowSchema = z3.object({ value: z3.string() });
|
|
342
357
|
var TableRowSchema = z3.object({ name: z3.string() });
|
|
343
|
-
var SCHEMA_VERSION =
|
|
358
|
+
var SCHEMA_VERSION = 4;
|
|
344
359
|
var TABLES = [
|
|
345
360
|
"stitchkit_agent_runtime_heads",
|
|
346
361
|
"stitchkit_agent_runtime_runs",
|
|
@@ -373,12 +388,20 @@ function initializeAgentRuntimeSqlite(database) {
|
|
|
373
388
|
if (version === 1) {
|
|
374
389
|
migrateAgentRuntimeSqliteV1ToV2(database);
|
|
375
390
|
migrateAgentRuntimeSqliteV2ToV3(database);
|
|
391
|
+
migrateAgentRuntimeSqliteV3ToV4(database);
|
|
376
392
|
initializeSqliteConversationPurge(database);
|
|
377
393
|
database.exec("COMMIT");
|
|
378
394
|
return;
|
|
379
395
|
}
|
|
380
396
|
if (version === 2) {
|
|
381
397
|
migrateAgentRuntimeSqliteV2ToV3(database);
|
|
398
|
+
migrateAgentRuntimeSqliteV3ToV4(database);
|
|
399
|
+
initializeSqliteConversationPurge(database);
|
|
400
|
+
database.exec("COMMIT");
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if (version === 3) {
|
|
404
|
+
migrateAgentRuntimeSqliteV3ToV4(database);
|
|
382
405
|
initializeSqliteConversationPurge(database);
|
|
383
406
|
database.exec("COMMIT");
|
|
384
407
|
return;
|
|
@@ -437,6 +460,7 @@ function initializeAgentRuntimeSqlite(database) {
|
|
|
437
460
|
createAgentRuntimeSqliteV2Tables(database);
|
|
438
461
|
createAgentRuntimeSqliteV3Tables(database);
|
|
439
462
|
database.prepare("INSERT INTO stitchkit_agent_runtime_meta (key, value) VALUES ('schema_version', '3')").run();
|
|
463
|
+
migrateAgentRuntimeSqliteV3ToV4(database);
|
|
440
464
|
initializeSqliteConversationPurge(database);
|
|
441
465
|
database.exec("COMMIT");
|
|
442
466
|
} catch (error) {
|
package/llms/agent-runtime.2.txt
CHANGED
|
@@ -13,6 +13,230 @@ Parts of this slice:
|
|
|
13
13
|
# Guide: Agent application runtime (docs/guide/agent-runtime.md) (continued)
|
|
14
14
|
==============================================================================
|
|
15
15
|
|
|
16
|
+
## What happens to a run when new input arrives
|
|
17
|
+
|
|
18
|
+
`runs.inputPolicy` decides. It takes five values, or a function returning one:
|
|
19
|
+
|
|
20
|
+
| policy | the run in flight | what it already produced |
|
|
21
|
+
|--------|-------------------|--------------------------|
|
|
22
|
+
| `queue` (default) | finishes first | kept |
|
|
23
|
+
| `inject` | continues, and answers the new input too | kept, and built on |
|
|
24
|
+
| `interrupt` | ends | kept, and marked as cut off |
|
|
25
|
+
| `interrupt-next` | ends; this input runs before ordinary queued work | kept, and marked as cut off |
|
|
26
|
+
| `supersede` | ends | discarded from the prompt, kept in the record |
|
|
27
|
+
|
|
28
|
+
`interrupt-next` is the explicit priority path for an urgent input. If A is
|
|
29
|
+
running, ordinary B is queued, and urgent C arrives, the coordinator aborts A,
|
|
30
|
+
waits for A to settle and then runs C before B. B keeps its durable identity and
|
|
31
|
+
eventually runs. Urgent inputs remain FIFO among themselves. They do not
|
|
32
|
+
coalesce into an ordinary pending run: that would erase the priority boundary.
|
|
33
|
+
|
|
34
|
+
`interrupt` and `supersede` differ in exactly one thing, and the question that
|
|
35
|
+
picks between them is **not** "was the run interrupted" but **"did anyone see
|
|
36
|
+
what it produced"**:
|
|
37
|
+
|
|
38
|
+
- The user pressed **stop**. The partial answer was streamed to their screen and
|
|
39
|
+
they read it. It belongs in the conversation — dropping it makes the history
|
|
40
|
+
lie to the model about what the human has seen. That is `interrupt`.
|
|
41
|
+
- A newer message **superseded** the run. Whether the partial reached anyone
|
|
42
|
+
depends on the delivery surface: a token stream shows it as it is produced, a
|
|
43
|
+
surface that sends nothing until the run is done never showed it at all. When
|
|
44
|
+
it reached nobody, it is not part of the conversation. That is `supersede`.
|
|
45
|
+
|
|
46
|
+
**Stitchkit cannot answer that question for you** — delivery belongs to the
|
|
47
|
+
transport, and the runtime sees an abort, not a screen. Hence a declared policy
|
|
48
|
+
(→ ADR 0108), and hence `inputPolicy` accepting a function, so one application
|
|
49
|
+
can hold two surfaces with different rules without the core learning which is
|
|
50
|
+
which:
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
runs: {
|
|
54
|
+
inputPolicy: (input) =>
|
|
55
|
+
protocol.parseContext(input.context).surface === 'operator' ? 'queue' : 'supersede',
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`input.context` is the **raw** context here — admission runs before the runtime
|
|
60
|
+
parses it, so the callback narrows it itself with the protocol it already has.
|
|
61
|
+
|
|
62
|
+
A superseded run ends with `terminalReason: 'superseded'`, run state
|
|
63
|
+
`'superseded'` and an assistant message of status `'superseded'`. **The record
|
|
64
|
+
is kept** — excluded from the projection, not deleted — so an operator can see
|
|
65
|
+
what was thrown away, and run identity, admission receipts and the terminal CAS
|
|
66
|
+
keep the row they depend on. Compaction leaves it alone for the same reason: a
|
|
67
|
+
turn whose answer is never spoken is not a turn that may be summarised into one,
|
|
68
|
+
because that would both feed the discarded text to the summariser and drop the
|
|
69
|
+
record in `replacedMessageIds`.
|
|
70
|
+
|
|
71
|
+
It is also outside the token budget. `selectAgentHistory` removes it with reason
|
|
72
|
+
`'superseded'` and does not count it, so an abandoned fragment cannot push a
|
|
73
|
+
real turn out of a context it never occupies.
|
|
74
|
+
|
|
75
|
+
### How an interrupted turn reaches the model
|
|
76
|
+
|
|
77
|
+
An interrupted turn is projected, and says so:
|
|
78
|
+
|
|
79
|
+
```text
|
|
80
|
+
{ role: 'assistant', content: [
|
|
81
|
+
{ type: 'text', text: 'We are the team, where would you like' },
|
|
82
|
+
{ type: 'text', text: '[interrupted: this turn was cut off before it finished]' },
|
|
83
|
+
]}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`history.interruptedAssistant` chooses the form, and the difference between the
|
|
87
|
+
first two is structural rather than cosmetic. **An assistant turn in provider
|
|
88
|
+
history is a commitment**: the model reads its own previous turn as something it
|
|
89
|
+
said and stays consistent with it. A system line is context.
|
|
90
|
+
|
|
91
|
+
| value | form | right when |
|
|
92
|
+
|-------|------|-----------|
|
|
93
|
+
| `assistant-marked` (default) | assistant turn plus a marker | the human read the text |
|
|
94
|
+
| `system-note` | `[interrupted] partial response: …` as a system line | the fragment reached nobody |
|
|
95
|
+
| `omit` | not projected at all | you want it gone from the request |
|
|
96
|
+
|
|
97
|
+
There is deliberately no value that reproduces what the projection used to do,
|
|
98
|
+
which was to send the partial as an ordinary assistant turn and drop its
|
|
99
|
+
`control` marker on the way. That was the defect, not a behaviour to stay
|
|
100
|
+
compatible with.
|
|
101
|
+
|
|
102
|
+
`projectAgentHistoryDetailed` reports what reached the provider, including part
|
|
103
|
+
types that no projected content stands for:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
const { decisions } = await projectAgentHistoryDetailed(snapshot.messages)
|
|
107
|
+
// → { messageId: 'assistant-2', action: 'projected', reason: 'projected',
|
|
108
|
+
// omittedParts: ['source', 'provider'] }
|
|
109
|
+
// → { messageId: 'assistant-1', action: 'omitted', reason: 'superseded' }
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`runtime.stop(key, 'supersede')` is the same decision taken by hand: the
|
|
113
|
+
process-local escape hatch chooses the reason, so a caller that knows the answer
|
|
114
|
+
was never delivered can discard it without a newer input arriving.
|
|
115
|
+
|
|
116
|
+
### An input that joins a run in flight
|
|
117
|
+
|
|
118
|
+
`inject` is right when the new input **refines** rather than redirects, and the
|
|
119
|
+
steps already taken are still worth something: *"summarise this thread… actually,
|
|
120
|
+
in bullet points"* should not throw away the reading the first message paid for.
|
|
121
|
+
|
|
122
|
+
What happens:
|
|
123
|
+
|
|
124
|
+
1. The input is admitted exactly like any other — a committed user message and a
|
|
125
|
+
**queued run**, durable before anything else happens.
|
|
126
|
+
2. At the running loop's next step boundary, that input joins its prompt. Only
|
|
127
|
+
that input: an unrelated queued submission is never carried in.
|
|
128
|
+
3. When the run finishes, its terminal commit — **one transaction** — records
|
|
129
|
+
the input as one the run answered and settles the queued successor with
|
|
130
|
+
`terminalReason: 'absorbed'` and `absorbedIntoRunId` naming the run that
|
|
131
|
+
answered it. Every ticket for that successor resolves to the same answer.
|
|
132
|
+
|
|
133
|
+
Nothing durable happens between 1 and 3, and that is the design (→ ADR 0113).
|
|
134
|
+
A run that crashes, is closed, or is interrupted after taking an input on
|
|
135
|
+
commits no absorption at all, so what is left behind is an ordinary queued
|
|
136
|
+
successor — the state every other policy already produces and recovery already
|
|
137
|
+
handles. **There is no ordering in which an accepted input becomes
|
|
138
|
+
unanswerable.** Only a run that *completes* may absorb; an interrupted one took
|
|
139
|
+
the input into its prompt and then stopped, and does not get to say it answered
|
|
140
|
+
it.
|
|
141
|
+
|
|
142
|
+
An absorbed run has **no assistant message of its own** — it produced none, and
|
|
143
|
+
writing an empty one would be a record claiming otherwise. Its answer is
|
|
144
|
+
reachable through `absorbedIntoRunId`, and the store follows that pointer itself
|
|
145
|
+
when a submission arrives on the absorbed run's idempotency key, so a retry
|
|
146
|
+
after a restart returns the answer rather than an empty terminal record.
|
|
147
|
+
|
|
148
|
+
It does publish one more `run-state` event, carrying `'superseded'`. It never
|
|
149
|
+
enters the run executor, so that event is the only thing that tells a delivery
|
|
150
|
+
surface following its `runId` that it is no longer queued.
|
|
151
|
+
|
|
152
|
+
With `coalescePending`, one successor can carry several inputs, and an
|
|
153
|
+
absorption covers a successor **whole or not at all** — a partial one would
|
|
154
|
+
leave a terminal run with inputs nobody answered. Inputs that coalesce before
|
|
155
|
+
the absorbing run's last step boundary join the same absorption. One that
|
|
156
|
+
arrives after it cancels the absorption: the successor then answers all of its
|
|
157
|
+
inputs itself, and the absorbing run has answered one of them too. A duplicate
|
|
158
|
+
answer, never a missing one.
|
|
159
|
+
|
|
160
|
+
(0.63.0 shipped a version of this that committed the absorption at the step
|
|
161
|
+
boundary, before the answer existed, and it was withdrawn in 0.65.0. ADR 0113
|
|
162
|
+
records what that ordering broke.)
|
|
163
|
+
|
|
164
|
+
With `runs.coalescePending: true`, an active lane has at most one queued
|
|
165
|
+
successor. Every later accepted input is atomically appended to that successor;
|
|
166
|
+
its `AgentRun.inputMessageIds` records the whole batch and every input ticket
|
|
167
|
+
resolves to the same terminal run. Coalescing never mutates the active run.
|
|
168
|
+
|
|
169
|
+
`AbortSignal.aborted` is only a cooperation request. A successor does not begin
|
|
170
|
+
while the predecessor still owns managed callbacks. A hung predecessor blocks
|
|
171
|
+
the lane in the first version.
|
|
172
|
+
|
|
173
|
+
Shutdown is two-phase, and both budgets carry the names they carry everywhere
|
|
174
|
+
else in Stitchkit:
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
const closed = await runtime.close({ gracePeriodMs: 30_000, forceTimeoutMs: 5_000 })
|
|
178
|
+
if (!closed.settled) {
|
|
179
|
+
console.warn(`exiting with ${closed.remaining} run(s) still in flight`)
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`close` first rejects new process-local admissions and gives active runs
|
|
184
|
+
`gracePeriodMs` to finish on their own. Only after that budget expires does it
|
|
185
|
+
abort them with reason `shutdown`; `forceTimeoutMs` then bounds the settlement
|
|
186
|
+
wait for a non-cooperative model or tool, measured from the abort — the two
|
|
187
|
+
budgets add up rather than overlapping.
|
|
188
|
+
|
|
189
|
+
**`close()` reports what it achieved rather than promising an outcome it cannot
|
|
190
|
+
reach.** The result is `{ settled, timedOut, remaining }`: `settled` when every
|
|
191
|
+
in-flight run finished, `timedOut` with a `remaining` count when the force
|
|
192
|
+
budget expired first. There is no combination of budgets that is both bounded
|
|
193
|
+
and guaranteed to leave nothing in flight — that is the trade the budgets exist
|
|
194
|
+
to make, and the result is where you read which side of it you got:
|
|
195
|
+
|
|
196
|
+
| budgets | behaviour | can return with a run in flight |
|
|
197
|
+
|---------|-----------|---------------------------------|
|
|
198
|
+
| neither | aborts immediately, waits for settlement | no — unbounded wait |
|
|
199
|
+
| `gracePeriodMs` only | waits, aborts, then waits for settlement | no — unbounded wait |
|
|
200
|
+
| `forceTimeoutMs` only | aborts immediately, waits at most that long | **yes** — `timedOut` |
|
|
201
|
+
| both | waits, aborts, waits at most that long | **yes** — `timedOut` |
|
|
202
|
+
|
|
203
|
+
A caller that wants the old shape still writes `await runtime.close(…)` and
|
|
204
|
+
ignores the result. A caller deciding whether to exit the process reads it.
|
|
205
|
+
Durably queued records rejected from the local queue remain recoverable through
|
|
206
|
+
`scanRecoverable`; close never marks them terminal on its own.
|
|
207
|
+
|
|
208
|
+
Use `await runtime.interrupt({ conversationId, runId })` when the interruption
|
|
209
|
+
must be durable: it first commits `interrupt_requested`, then aborts the local
|
|
210
|
+
coordinator signal. If provider completion races that revision change, the terminal path reloads
|
|
211
|
+
the canonical snapshot. An already-terminal winner settles the ticket directly; a still-owned
|
|
212
|
+
`interrupt_requested` run is committed as `interrupted`, and unrelated aggregate-head conflicts
|
|
213
|
+
remain retriable while the run is active with the same owner and fencing token. A stale owner or
|
|
214
|
+
fencing token remains a conflict. Only the execution that applies the terminal mutation publishes the
|
|
215
|
+
**delivery** `terminal` event — a loser settles from canonical state without republishing the turn,
|
|
216
|
+
and its `AgentRuntimeResult.metrics` is `undefined`. The **operator** `run-terminal` event is not
|
|
217
|
+
gated that way: a losing execution still ran, and still spent whatever it spent, so it reports its
|
|
218
|
+
own usage. The two channels answer to different readers — delivering a turn twice is a user's
|
|
219
|
+
problem, and omitting a run's cost is an operator's.
|
|
220
|
+
`runtime.stop(key)` is the process-local signal-only escape hatch.
|
|
221
|
+
|
|
222
|
+
When an operator has positive evidence that another process owning one known
|
|
223
|
+
run is gone, close that run through the runtime boundary rather than updating
|
|
224
|
+
an adapter table or JSON payload directly:
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
await runtime.abandon({
|
|
228
|
+
conversationId,
|
|
229
|
+
runId,
|
|
230
|
+
expectedRevision: run.revision,
|
|
231
|
+
staleOwner: true,
|
|
232
|
+
})
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
The revision protects against stale operator evidence. The mutation updates the
|
|
236
|
+
canonical run and failed assistant atomically, removes the run from recovery
|
|
237
|
+
indexes, and publishes the resulting `run-state`. A run that moved since it was
|
|
238
|
+
read returns a conflict; `staleOwner: true` is mandatory evidence, not a default.
|
|
239
|
+
|
|
16
240
|
## Store operations
|
|
17
241
|
|
|
18
242
|
`AgentRuntimeStore` remains the runtime-facing aggregate. Application adapters
|
package/llms/agent-runtime.3.txt
CHANGED
|
@@ -111,7 +111,7 @@ Server-only optional application runtime. See the
|
|
|
111
111
|
| `AgentSandboxProcess` / `AgentSandboxOutputStream` | _types_ | structural child/output interface for optional lifecycle-owned `AgentProcessSandbox.spawn`, without Node ambient type dependencies |
|
|
112
112
|
| `AgentEventSearchResultSchema` / `AgentEventSearchResult` / `createSqliteAgentEventSearch` / `createAgentEventSearchTools` | schema / _type_ / functions | authorized FTS5 search with exact event addresses and `session_*` tools |
|
|
113
113
|
| `createSqliteAgentSpillStore` | function | durable content-address-checked artifact storage, bounded read/search, retention facts and archive participation |
|
|
114
|
-
| `AgentScheduleSchema` / `AgentSchedule` / `AgentScheduleService` / `createAgentScheduleService` / `createAgentScheduleTools` |
|
|
114
|
+
| `AgentScheduleSchema` / `AgentSchedule` / `AgentScheduleService` / `AgentScheduleDeliveryOutcomeSchema` / `AgentScheduleDeliveryOutcome` / `createAgentScheduleService` / `createAgentScheduleTools` | schemas / _types_ / functions | durable `at`/`after`/timezone-explicit `every`, indexed retry eligibility, typed retry/terminal failure, bounded dispatch and fenced stable occurrence identity (ADR 0202) |
|
|
115
115
|
| `AgentChildBudgetSchema` / `AgentChildBudget` / `AgentChildStateSchema` / `AgentChildState` / `AgentChildRecordSchema` / `AgentChildRecord` | schema / _type_ | durable child graph, bounded seed and measured budget state |
|
|
116
116
|
| `AgentChildBlockingKindSchema` / `AgentChildBlockingKind` / `AgentChildBlockingSourceSchema` / `AgentChildBlockingSource` / `AgentChildBlockingEventSchema` / `AgentChildBlockingEvent` / `AgentChildBlockingDecision` | schemas / _types_ | parent-owned request identities; responses discriminate approval decisions from JSON input values |
|
|
117
117
|
| `AgentChildHandle` / `AgentChildManager` / `SqliteAgentChildManagerConfig` / `createSqliteAgentChildManager` / `createAgentChildTools` / `agentChildBudgetStopPolicy` | _type_ / functions | host execution port, child lifecycle, cascade (given to `createAgentRuntime` as `children`), messaging, Agent tools, and the child runtime's own budget stop policy — `recordStepUsage` at every step boundary, `policy_stop` as `child-budget` when spent |
|
package/llms/agent-runtime.txt
CHANGED
|
@@ -19,7 +19,7 @@ description: Configure Stitchkit's optional durable history, stream loop, run co
|
|
|
19
19
|
type: architecture
|
|
20
20
|
status: active
|
|
21
21
|
created: 2026-08-22
|
|
22
|
-
updated: 2026-
|
|
22
|
+
updated: 2026-09-26 16:00 +07:00
|
|
23
23
|
---
|
|
24
24
|
|
|
25
25
|
# Agent application runtime
|
|
@@ -636,6 +636,23 @@ has invoked a tool is never automatically replayed.
|
|
|
636
636
|
`createAgentScheduleService` persists `at`, `after` and `every` input. Repeating
|
|
637
637
|
schedules require an explicit IANA time zone. Dispatch uses a stable
|
|
638
638
|
`schedule:<id>:<occurrence>` idempotency key, and restart lateness is recorded.
|
|
639
|
+
Resolve `void` after durable admission; return `{ status: 'retry', reason }` for a
|
|
640
|
+
transient refusal or `{ status: 'terminal', reason }` to stop the entire schedule in
|
|
641
|
+
`failed` state. Throws retry too. Backoff is 1, 2, 4, 8, 16, 32, then 60 seconds,
|
|
642
|
+
capped at 60 seconds and persisted across restart. `retryAt`, `attempts` and `lastError`
|
|
643
|
+
show retry diagnostics; success resets them. `nextAt` and `lateByMs` retain the original
|
|
644
|
+
occurrence time. Recurring schedules fire once after an idle stretch and advance to the
|
|
645
|
+
next future slot on their original cadence.
|
|
646
|
+
|
|
647
|
+
Dispatch receives an `AbortSignal`, has a 30-second deadline and holds a unique
|
|
648
|
+
60-second lease. A late or cancelled attempt cannot settle or emit firing events.
|
|
649
|
+
Honor the signal and durably deduplicate the key: a crash after admission but before
|
|
650
|
+
settlement can replay delivery. This is at-least-once, not exactly-once.
|
|
651
|
+
Due selection is indexed, limited to 32 parallel deliveries per tick; one hung batch
|
|
652
|
+
can delay newly due work by at most its deadline. The service keeps one nearest-due
|
|
653
|
+
timer plus per-attempt deadline timers. Storage failures use a bounded recovery timer;
|
|
654
|
+
idle ticks do not write. SQLite v4 migration preserves due times and claims; stop older
|
|
655
|
+
scheduler processes before migration. See [ADR 0202](../decisions/0202-schedule-retries-preserve-occurrence-identity.md).
|
|
639
656
|
`createAgentScheduleTools` exposes the exact `schedule_after`, `schedule_at`,
|
|
640
657
|
`schedule_every`, `schedule_list` and `schedule_cancel` surface.
|
|
641
658
|
|
|
@@ -667,227 +684,3 @@ a successor input into the run already in flight.
|
|
|
667
684
|
```text
|
|
668
685
|
input + queued run → running → execution settled → terminal CAS → successor
|
|
669
686
|
```
|
|
670
|
-
|
|
671
|
-
## What happens to a run when new input arrives
|
|
672
|
-
|
|
673
|
-
`runs.inputPolicy` decides. It takes five values, or a function returning one:
|
|
674
|
-
|
|
675
|
-
| policy | the run in flight | what it already produced |
|
|
676
|
-
|--------|-------------------|--------------------------|
|
|
677
|
-
| `queue` (default) | finishes first | kept |
|
|
678
|
-
| `inject` | continues, and answers the new input too | kept, and built on |
|
|
679
|
-
| `interrupt` | ends | kept, and marked as cut off |
|
|
680
|
-
| `interrupt-next` | ends; this input runs before ordinary queued work | kept, and marked as cut off |
|
|
681
|
-
| `supersede` | ends | discarded from the prompt, kept in the record |
|
|
682
|
-
|
|
683
|
-
`interrupt-next` is the explicit priority path for an urgent input. If A is
|
|
684
|
-
running, ordinary B is queued, and urgent C arrives, the coordinator aborts A,
|
|
685
|
-
waits for A to settle and then runs C before B. B keeps its durable identity and
|
|
686
|
-
eventually runs. Urgent inputs remain FIFO among themselves. They do not
|
|
687
|
-
coalesce into an ordinary pending run: that would erase the priority boundary.
|
|
688
|
-
|
|
689
|
-
`interrupt` and `supersede` differ in exactly one thing, and the question that
|
|
690
|
-
picks between them is **not** "was the run interrupted" but **"did anyone see
|
|
691
|
-
what it produced"**:
|
|
692
|
-
|
|
693
|
-
- The user pressed **stop**. The partial answer was streamed to their screen and
|
|
694
|
-
they read it. It belongs in the conversation — dropping it makes the history
|
|
695
|
-
lie to the model about what the human has seen. That is `interrupt`.
|
|
696
|
-
- A newer message **superseded** the run. Whether the partial reached anyone
|
|
697
|
-
depends on the delivery surface: a token stream shows it as it is produced, a
|
|
698
|
-
surface that sends nothing until the run is done never showed it at all. When
|
|
699
|
-
it reached nobody, it is not part of the conversation. That is `supersede`.
|
|
700
|
-
|
|
701
|
-
**Stitchkit cannot answer that question for you** — delivery belongs to the
|
|
702
|
-
transport, and the runtime sees an abort, not a screen. Hence a declared policy
|
|
703
|
-
(→ ADR 0108), and hence `inputPolicy` accepting a function, so one application
|
|
704
|
-
can hold two surfaces with different rules without the core learning which is
|
|
705
|
-
which:
|
|
706
|
-
|
|
707
|
-
```ts
|
|
708
|
-
runs: {
|
|
709
|
-
inputPolicy: (input) =>
|
|
710
|
-
protocol.parseContext(input.context).surface === 'operator' ? 'queue' : 'supersede',
|
|
711
|
-
}
|
|
712
|
-
```
|
|
713
|
-
|
|
714
|
-
`input.context` is the **raw** context here — admission runs before the runtime
|
|
715
|
-
parses it, so the callback narrows it itself with the protocol it already has.
|
|
716
|
-
|
|
717
|
-
A superseded run ends with `terminalReason: 'superseded'`, run state
|
|
718
|
-
`'superseded'` and an assistant message of status `'superseded'`. **The record
|
|
719
|
-
is kept** — excluded from the projection, not deleted — so an operator can see
|
|
720
|
-
what was thrown away, and run identity, admission receipts and the terminal CAS
|
|
721
|
-
keep the row they depend on. Compaction leaves it alone for the same reason: a
|
|
722
|
-
turn whose answer is never spoken is not a turn that may be summarised into one,
|
|
723
|
-
because that would both feed the discarded text to the summariser and drop the
|
|
724
|
-
record in `replacedMessageIds`.
|
|
725
|
-
|
|
726
|
-
It is also outside the token budget. `selectAgentHistory` removes it with reason
|
|
727
|
-
`'superseded'` and does not count it, so an abandoned fragment cannot push a
|
|
728
|
-
real turn out of a context it never occupies.
|
|
729
|
-
|
|
730
|
-
### How an interrupted turn reaches the model
|
|
731
|
-
|
|
732
|
-
An interrupted turn is projected, and says so:
|
|
733
|
-
|
|
734
|
-
```text
|
|
735
|
-
{ role: 'assistant', content: [
|
|
736
|
-
{ type: 'text', text: 'We are the team, where would you like' },
|
|
737
|
-
{ type: 'text', text: '[interrupted: this turn was cut off before it finished]' },
|
|
738
|
-
]}
|
|
739
|
-
```
|
|
740
|
-
|
|
741
|
-
`history.interruptedAssistant` chooses the form, and the difference between the
|
|
742
|
-
first two is structural rather than cosmetic. **An assistant turn in provider
|
|
743
|
-
history is a commitment**: the model reads its own previous turn as something it
|
|
744
|
-
said and stays consistent with it. A system line is context.
|
|
745
|
-
|
|
746
|
-
| value | form | right when |
|
|
747
|
-
|-------|------|-----------|
|
|
748
|
-
| `assistant-marked` (default) | assistant turn plus a marker | the human read the text |
|
|
749
|
-
| `system-note` | `[interrupted] partial response: …` as a system line | the fragment reached nobody |
|
|
750
|
-
| `omit` | not projected at all | you want it gone from the request |
|
|
751
|
-
|
|
752
|
-
There is deliberately no value that reproduces what the projection used to do,
|
|
753
|
-
which was to send the partial as an ordinary assistant turn and drop its
|
|
754
|
-
`control` marker on the way. That was the defect, not a behaviour to stay
|
|
755
|
-
compatible with.
|
|
756
|
-
|
|
757
|
-
`projectAgentHistoryDetailed` reports what reached the provider, including part
|
|
758
|
-
types that no projected content stands for:
|
|
759
|
-
|
|
760
|
-
```ts
|
|
761
|
-
const { decisions } = await projectAgentHistoryDetailed(snapshot.messages)
|
|
762
|
-
// → { messageId: 'assistant-2', action: 'projected', reason: 'projected',
|
|
763
|
-
// omittedParts: ['source', 'provider'] }
|
|
764
|
-
// → { messageId: 'assistant-1', action: 'omitted', reason: 'superseded' }
|
|
765
|
-
```
|
|
766
|
-
|
|
767
|
-
`runtime.stop(key, 'supersede')` is the same decision taken by hand: the
|
|
768
|
-
process-local escape hatch chooses the reason, so a caller that knows the answer
|
|
769
|
-
was never delivered can discard it without a newer input arriving.
|
|
770
|
-
|
|
771
|
-
### An input that joins a run in flight
|
|
772
|
-
|
|
773
|
-
`inject` is right when the new input **refines** rather than redirects, and the
|
|
774
|
-
steps already taken are still worth something: *"summarise this thread… actually,
|
|
775
|
-
in bullet points"* should not throw away the reading the first message paid for.
|
|
776
|
-
|
|
777
|
-
What happens:
|
|
778
|
-
|
|
779
|
-
1. The input is admitted exactly like any other — a committed user message and a
|
|
780
|
-
**queued run**, durable before anything else happens.
|
|
781
|
-
2. At the running loop's next step boundary, that input joins its prompt. Only
|
|
782
|
-
that input: an unrelated queued submission is never carried in.
|
|
783
|
-
3. When the run finishes, its terminal commit — **one transaction** — records
|
|
784
|
-
the input as one the run answered and settles the queued successor with
|
|
785
|
-
`terminalReason: 'absorbed'` and `absorbedIntoRunId` naming the run that
|
|
786
|
-
answered it. Every ticket for that successor resolves to the same answer.
|
|
787
|
-
|
|
788
|
-
Nothing durable happens between 1 and 3, and that is the design (→ ADR 0113).
|
|
789
|
-
A run that crashes, is closed, or is interrupted after taking an input on
|
|
790
|
-
commits no absorption at all, so what is left behind is an ordinary queued
|
|
791
|
-
successor — the state every other policy already produces and recovery already
|
|
792
|
-
handles. **There is no ordering in which an accepted input becomes
|
|
793
|
-
unanswerable.** Only a run that *completes* may absorb; an interrupted one took
|
|
794
|
-
the input into its prompt and then stopped, and does not get to say it answered
|
|
795
|
-
it.
|
|
796
|
-
|
|
797
|
-
An absorbed run has **no assistant message of its own** — it produced none, and
|
|
798
|
-
writing an empty one would be a record claiming otherwise. Its answer is
|
|
799
|
-
reachable through `absorbedIntoRunId`, and the store follows that pointer itself
|
|
800
|
-
when a submission arrives on the absorbed run's idempotency key, so a retry
|
|
801
|
-
after a restart returns the answer rather than an empty terminal record.
|
|
802
|
-
|
|
803
|
-
It does publish one more `run-state` event, carrying `'superseded'`. It never
|
|
804
|
-
enters the run executor, so that event is the only thing that tells a delivery
|
|
805
|
-
surface following its `runId` that it is no longer queued.
|
|
806
|
-
|
|
807
|
-
With `coalescePending`, one successor can carry several inputs, and an
|
|
808
|
-
absorption covers a successor **whole or not at all** — a partial one would
|
|
809
|
-
leave a terminal run with inputs nobody answered. Inputs that coalesce before
|
|
810
|
-
the absorbing run's last step boundary join the same absorption. One that
|
|
811
|
-
arrives after it cancels the absorption: the successor then answers all of its
|
|
812
|
-
inputs itself, and the absorbing run has answered one of them too. A duplicate
|
|
813
|
-
answer, never a missing one.
|
|
814
|
-
|
|
815
|
-
(0.63.0 shipped a version of this that committed the absorption at the step
|
|
816
|
-
boundary, before the answer existed, and it was withdrawn in 0.65.0. ADR 0113
|
|
817
|
-
records what that ordering broke.)
|
|
818
|
-
|
|
819
|
-
With `runs.coalescePending: true`, an active lane has at most one queued
|
|
820
|
-
successor. Every later accepted input is atomically appended to that successor;
|
|
821
|
-
its `AgentRun.inputMessageIds` records the whole batch and every input ticket
|
|
822
|
-
resolves to the same terminal run. Coalescing never mutates the active run.
|
|
823
|
-
|
|
824
|
-
`AbortSignal.aborted` is only a cooperation request. A successor does not begin
|
|
825
|
-
while the predecessor still owns managed callbacks. A hung predecessor blocks
|
|
826
|
-
the lane in the first version.
|
|
827
|
-
|
|
828
|
-
Shutdown is two-phase, and both budgets carry the names they carry everywhere
|
|
829
|
-
else in Stitchkit:
|
|
830
|
-
|
|
831
|
-
```ts
|
|
832
|
-
const closed = await runtime.close({ gracePeriodMs: 30_000, forceTimeoutMs: 5_000 })
|
|
833
|
-
if (!closed.settled) {
|
|
834
|
-
console.warn(`exiting with ${closed.remaining} run(s) still in flight`)
|
|
835
|
-
}
|
|
836
|
-
```
|
|
837
|
-
|
|
838
|
-
`close` first rejects new process-local admissions and gives active runs
|
|
839
|
-
`gracePeriodMs` to finish on their own. Only after that budget expires does it
|
|
840
|
-
abort them with reason `shutdown`; `forceTimeoutMs` then bounds the settlement
|
|
841
|
-
wait for a non-cooperative model or tool, measured from the abort — the two
|
|
842
|
-
budgets add up rather than overlapping.
|
|
843
|
-
|
|
844
|
-
**`close()` reports what it achieved rather than promising an outcome it cannot
|
|
845
|
-
reach.** The result is `{ settled, timedOut, remaining }`: `settled` when every
|
|
846
|
-
in-flight run finished, `timedOut` with a `remaining` count when the force
|
|
847
|
-
budget expired first. There is no combination of budgets that is both bounded
|
|
848
|
-
and guaranteed to leave nothing in flight — that is the trade the budgets exist
|
|
849
|
-
to make, and the result is where you read which side of it you got:
|
|
850
|
-
|
|
851
|
-
| budgets | behaviour | can return with a run in flight |
|
|
852
|
-
|---------|-----------|---------------------------------|
|
|
853
|
-
| neither | aborts immediately, waits for settlement | no — unbounded wait |
|
|
854
|
-
| `gracePeriodMs` only | waits, aborts, then waits for settlement | no — unbounded wait |
|
|
855
|
-
| `forceTimeoutMs` only | aborts immediately, waits at most that long | **yes** — `timedOut` |
|
|
856
|
-
| both | waits, aborts, waits at most that long | **yes** — `timedOut` |
|
|
857
|
-
|
|
858
|
-
A caller that wants the old shape still writes `await runtime.close(…)` and
|
|
859
|
-
ignores the result. A caller deciding whether to exit the process reads it.
|
|
860
|
-
Durably queued records rejected from the local queue remain recoverable through
|
|
861
|
-
`scanRecoverable`; close never marks them terminal on its own.
|
|
862
|
-
|
|
863
|
-
Use `await runtime.interrupt({ conversationId, runId })` when the interruption
|
|
864
|
-
must be durable: it first commits `interrupt_requested`, then aborts the local
|
|
865
|
-
coordinator signal. If provider completion races that revision change, the terminal path reloads
|
|
866
|
-
the canonical snapshot. An already-terminal winner settles the ticket directly; a still-owned
|
|
867
|
-
`interrupt_requested` run is committed as `interrupted`, and unrelated aggregate-head conflicts
|
|
868
|
-
remain retriable while the run is active with the same owner and fencing token. A stale owner or
|
|
869
|
-
fencing token remains a conflict. Only the execution that applies the terminal mutation publishes the
|
|
870
|
-
**delivery** `terminal` event — a loser settles from canonical state without republishing the turn,
|
|
871
|
-
and its `AgentRuntimeResult.metrics` is `undefined`. The **operator** `run-terminal` event is not
|
|
872
|
-
gated that way: a losing execution still ran, and still spent whatever it spent, so it reports its
|
|
873
|
-
own usage. The two channels answer to different readers — delivering a turn twice is a user's
|
|
874
|
-
problem, and omitting a run's cost is an operator's.
|
|
875
|
-
`runtime.stop(key)` is the process-local signal-only escape hatch.
|
|
876
|
-
|
|
877
|
-
When an operator has positive evidence that another process owning one known
|
|
878
|
-
run is gone, close that run through the runtime boundary rather than updating
|
|
879
|
-
an adapter table or JSON payload directly:
|
|
880
|
-
|
|
881
|
-
```ts
|
|
882
|
-
await runtime.abandon({
|
|
883
|
-
conversationId,
|
|
884
|
-
runId,
|
|
885
|
-
expectedRevision: run.revision,
|
|
886
|
-
staleOwner: true,
|
|
887
|
-
})
|
|
888
|
-
```
|
|
889
|
-
|
|
890
|
-
The revision protects against stale operator evidence. The mutation updates the
|
|
891
|
-
canonical run and failed assistant atomically, removes the run from recovery
|
|
892
|
-
indexes, and publishes the resulting `run-state`. A run that moved since it was
|
|
893
|
-
read returns a conflict; `staleOwner: true` is mandatory evidence, not a default.
|
package/llms/stitchkit.txt
CHANGED
|
@@ -77,11 +77,11 @@ own, recorded as an ADR.
|
|
|
77
77
|
| `stitchkit/geo` | server (Bun or Node) | evolving | managed GeoIP reader generations, last-known-good reload and the optional MaxMind adapter |
|
|
78
78
|
| `stitchkit/oauth` | browser **and** server | evolving | provider-neutral Authorization Code + PKCE request and one-shot callback transaction mechanics |
|
|
79
79
|
| `stitchkit/google` | server (Bun or Node) | evolving | optional Google code exchange and verified OIDC identity adapter |
|
|
80
|
-
| `stitchkit/observability` | server | stable<br>_redefined in 2 of the
|
|
80
|
+
| `stitchkit/observability` | server | stable<br>_redefined in 2 of the 42 minors since 0.56.2, most recently 0.92.0_ | request/tool event projections — `createObservability`, trace context, sanitisation |
|
|
81
81
|
| `stitchkit/testing` | tests on Bun or Node | stable | in-process generated clients over a real Fetch handler, plus the managed-resource conformance kit |
|
|
82
82
|
| `stitchkit/declaration` | browser + build and deployment tooling (Bun or Node) | evolving | `ProjectDeclarationSchema` — the one machine-readable statement a repository makes about itself |
|
|
83
83
|
| `stitchkit/react` | browser + server rendering | stable | `createCursorQuery`, `createCacheBridge`, QueryClient and `ApiError` retry policy |
|
|
84
|
-
| `stitchkit/agent-runtime` | server | evolving<br>_redefined in
|
|
84
|
+
| `stitchkit/agent-runtime` | server | evolving<br>_redefined in 22 of the 42 minors since 0.56.2, most recently 0.97.0_ | optional durable conversation/run loop, history, models, prompts, fencing and events |
|
|
85
85
|
| `stitchkit/agent-runtime/sandbox` | server | evolving | optional Linux Bubblewrap sessions, durable workspaces and a host HTTP credential gateway; see [sandbox guide](sandbox.md) |
|
|
86
86
|
| `stitchkit/agent-runtime/testing` | tests on Bun or Node | evolving | credential-free replay, scripted provider faults, deterministic race controls and the agent-store conformance kit |
|
|
87
87
|
| `stitchkit/agent-runtime/harness` | server | evolving | resource-aware process-local facade over the canonical Agent runtime; supervision stays outside |
|
|
@@ -91,7 +91,7 @@ own, recorded as an ADR.
|
|
|
91
91
|
| `stitchkit/agent-runtime/sqlite/bun` | server (Bun) | evolving | durable built-in SQLite store for the agent runtime |
|
|
92
92
|
| `stitchkit/agent-runtime/sqlite/node` | server (Node ≥ 22.5) | evolving | durable built-in SQLite store for the agent runtime |
|
|
93
93
|
| `stitchkit-tui` | terminal (Bun) | evolving | optional official OpenTUI host over a caller-composed headless runtime |
|
|
94
|
-
| `stitchkit/application` | browser + server | evolving<br>_redefined in 9 of the
|
|
94
|
+
| `stitchkit/application` | browser + server | evolving<br>_redefined in 9 of the 42 minors since 0.56.2, most recently 0.96.0_ | managed resource graph, readiness, admission, schedules, subtree restart and bounded shutdown |
|
|
95
95
|
| `stitchkit/application/grammy` | server | evolving | a grammY bot as application resources — configuration, then polling with updates admitted by batch — and the webhook adapter |
|
|
96
96
|
| `stitchkit/application/opentelemetry` | server | evolving | maps application snapshots onto an injected OpenTelemetry `Meter` |
|
|
97
97
|
| `stitchkit/application/schemas` | browser + server | evolving | the application's snapshot, health and shutdown schemas alone, without the kernel |
|