stitchkit 0.65.0 → 0.66.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.
Files changed (32) hide show
  1. package/dist/agent-runtime/coordinator.d.ts +8 -5
  2. package/dist/agent-runtime/coordinator.d.ts.map +1 -1
  3. package/dist/agent-runtime/events.d.ts +36 -30
  4. package/dist/agent-runtime/events.d.ts.map +1 -1
  5. package/dist/agent-runtime/injection.d.ts +28 -0
  6. package/dist/agent-runtime/injection.d.ts.map +1 -0
  7. package/dist/agent-runtime/observability.d.ts +22 -20
  8. package/dist/agent-runtime/observability.d.ts.map +1 -1
  9. package/dist/agent-runtime/prompt.d.ts +11 -1
  10. package/dist/agent-runtime/prompt.d.ts.map +1 -1
  11. package/dist/agent-runtime/run-execution.d.ts +11 -0
  12. package/dist/agent-runtime/run-execution.d.ts.map +1 -1
  13. package/dist/agent-runtime/runtime-internals.d.ts.map +1 -1
  14. package/dist/agent-runtime/runtime.d.ts.map +1 -1
  15. package/dist/agent-runtime/schemas.d.ts +75 -21
  16. package/dist/agent-runtime/schemas.d.ts.map +1 -1
  17. package/dist/agent-runtime/store-driver.d.ts +21 -15
  18. package/dist/agent-runtime/store-driver.d.ts.map +1 -1
  19. package/dist/agent-runtime/store.d.ts +280 -46
  20. package/dist/agent-runtime/store.d.ts.map +1 -1
  21. package/dist/agent-runtime/terminal-commit.d.ts +33 -3
  22. package/dist/agent-runtime/terminal-commit.d.ts.map +1 -1
  23. package/dist/agent-runtime/terminal-status.d.ts.map +1 -1
  24. package/dist/agent-runtime-openrouter.js +1 -1
  25. package/dist/agent-runtime.d.ts +2 -2
  26. package/dist/agent-runtime.d.ts.map +1 -1
  27. package/dist/agent-runtime.js +385 -97
  28. package/dist/{index-sbkyvacf.js → index-fhsmrzj7.js} +45 -5
  29. package/dist/testing/agent-store-conformance.d.ts.map +1 -1
  30. package/dist/testing.js +149 -1
  31. package/llms-full.txt +252 -18
  32. package/package.json +1 -1
@@ -114,6 +114,7 @@ var AgentTerminalReasonSchema = z.enum([
114
114
  "shutdown",
115
115
  "provider_failure",
116
116
  "context_overflow",
117
+ "absorbed",
117
118
  "abandoned"
118
119
  ]);
119
120
  function runStateForTerminalReason(reason) {
@@ -122,7 +123,7 @@ function runStateForTerminalReason(reason) {
122
123
  }
123
124
  if (reason === "interrupted")
124
125
  return "interrupted";
125
- if (reason === "superseded")
126
+ if (reason === "superseded" || reason === "absorbed")
126
127
  return "superseded";
127
128
  if (reason === "cancelled" || reason === "shutdown" || reason === "timeout") {
128
129
  return "cancelled";
@@ -131,14 +132,31 @@ function runStateForTerminalReason(reason) {
131
132
  return "abandoned";
132
133
  return "failed";
133
134
  }
135
+ var AgentProvenanceSchema = z.enum([
136
+ "provider-reported",
137
+ "measured",
138
+ "computed",
139
+ "estimated",
140
+ "unavailable"
141
+ ]);
134
142
  var AgentUsageValueSchema = z.object({
135
- value: z.number().nonnegative().optional(),
136
- provenance: z.enum(["provider-reported", "computed", "estimated", "unavailable"])
143
+ value: z.int().nonnegative().optional(),
144
+ provenance: AgentProvenanceSchema.extract([
145
+ "provider-reported",
146
+ "computed",
147
+ "estimated",
148
+ "unavailable"
149
+ ])
137
150
  });
138
151
  var AgentCostValueSchema = z.object({
139
152
  value: z.number().nonnegative().optional(),
140
153
  currency: z.string().length(3).optional(),
141
- provenance: z.enum(["provider-reported", "computed", "estimated", "unavailable"])
154
+ provenance: AgentProvenanceSchema.extract([
155
+ "provider-reported",
156
+ "computed",
157
+ "estimated",
158
+ "unavailable"
159
+ ])
142
160
  });
143
161
  var AgentUsageSchema = z.object({
144
162
  inputTokens: AgentUsageValueSchema,
@@ -160,11 +178,19 @@ var AgentRunFieldsSchema = z.object({
160
178
  fencingToken: AgentRecordVersionSchema.optional(),
161
179
  terminalReason: AgentTerminalReasonSchema.optional(),
162
180
  terminalPolicyName: z.string().min(1).optional(),
181
+ absorbedIntoRunId: AgentRecordIdSchema.optional(),
163
182
  usage: AgentUsageSchema.optional(),
164
183
  createdAt: AgentTimestampSchema,
165
184
  updatedAt: AgentTimestampSchema
166
185
  });
167
186
  var AgentRunSchema = AgentRunFieldsSchema.superRefine((run, ctx) => {
187
+ if (run.terminalReason !== "absorbed" && run.absorbedIntoRunId !== undefined) {
188
+ ctx.addIssue({
189
+ code: "custom",
190
+ path: ["absorbedIntoRunId"],
191
+ message: "Only an absorbed run names the run that answered it"
192
+ });
193
+ }
168
194
  if (run.terminalReason === undefined) {
169
195
  if (TERMINAL_RUN_STATES.has(run.state)) {
170
196
  ctx.addIssue({
@@ -190,6 +216,20 @@ var AgentRunSchema = AgentRunFieldsSchema.superRefine((run, ctx) => {
190
216
  message: "A policy stop names the policy that stopped the run"
191
217
  });
192
218
  }
219
+ if (run.terminalReason === "absorbed" && run.absorbedIntoRunId === undefined) {
220
+ ctx.addIssue({
221
+ code: "custom",
222
+ path: ["absorbedIntoRunId"],
223
+ message: "An absorbed run names the run that answered its input"
224
+ });
225
+ }
226
+ if (run.absorbedIntoRunId === run.id) {
227
+ ctx.addIssue({
228
+ code: "custom",
229
+ path: ["absorbedIntoRunId"],
230
+ message: "A run cannot absorb itself"
231
+ });
232
+ }
193
233
  });
194
234
  var TERMINAL_RUN_STATES = new Set([
195
235
  "completed",
@@ -213,4 +253,4 @@ var AgentRunMetricsSchema = z.object({
213
253
  ttftMs: z.number().nonnegative().optional()
214
254
  });
215
255
 
216
- export { AgentRecordIdSchema, AgentRecordVersionSchema, AgentTimestampSchema, AgentJsonObjectSchema, AgentProviderEnvelopeSchema, AgentTextPartSchema, AgentReasoningPartSchema, AgentFilePartSchema, AgentSourcePartSchema, AgentToolCallPartSchema, AgentToolResultPartSchema, AgentOpaquePartSchema, AgentControlPartSchema, AgentMessagePartSchema, AgentMessageRoleSchema, AgentMessageStatusSchema, AgentMessageSchema, AgentAssistantPlaceholderSchema, AgentRunStateSchema, AgentTerminalReasonSchema, runStateForTerminalReason, AgentUsageValueSchema, AgentCostValueSchema, AgentUsageSchema, AgentRunSchema, AgentSnapshotSchema, AgentRunMetricsSchema };
256
+ export { AgentRecordIdSchema, AgentRecordVersionSchema, AgentTimestampSchema, AgentJsonObjectSchema, AgentProviderEnvelopeSchema, AgentTextPartSchema, AgentReasoningPartSchema, AgentFilePartSchema, AgentSourcePartSchema, AgentToolCallPartSchema, AgentToolResultPartSchema, AgentOpaquePartSchema, AgentControlPartSchema, AgentMessagePartSchema, AgentMessageRoleSchema, AgentMessageStatusSchema, AgentMessageSchema, AgentAssistantPlaceholderSchema, AgentRunStateSchema, AgentTerminalReasonSchema, runStateForTerminalReason, AgentProvenanceSchema, AgentUsageValueSchema, AgentCostValueSchema, AgentUsageSchema, AgentRunSchema, AgentSnapshotSchema, AgentRunMetricsSchema };
@@ -1 +1 @@
1
- {"version":3,"file":"agent-store-conformance.d.ts","sourceRoot":"","sources":["../../src/testing/agent-store-conformance.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAEhE,MAAM,WAAW,2BAA2B;IAC1C,WAAW,IAAI,iBAAiB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAC;CAC/D;AAwCD,gFAAgF;AAChF,wBAAsB,wBAAwB,CAC5C,MAAM,EAAE,2BAA2B,GAClC,OAAO,CAAC,IAAI,CAAC,CAiXf"}
1
+ {"version":3,"file":"agent-store-conformance.d.ts","sourceRoot":"","sources":["../../src/testing/agent-store-conformance.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAEhE,MAAM,WAAW,2BAA2B;IAC1C,WAAW,IAAI,iBAAiB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAC;CAC/D;AAwCD,gFAAgF;AAChF,wBAAsB,wBAAwB,CAC5C,MAAM,EAAE,2BAA2B,GAClC,OAAO,CAAC,IAAI,CAAC,CAobf"}
package/dist/testing.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  AgentMessageSchema,
3
3
  AgentRunSchema
4
- } from "./index-sbkyvacf.js";
4
+ } from "./index-fhsmrzj7.js";
5
5
  import {
6
6
  createApplication
7
7
  } from "./index-eabpd4tb.js";
@@ -352,6 +352,50 @@ async function runAgentStoreConformance(config) {
352
352
  if (foreignOwner.outcome !== "conflict") {
353
353
  throw new Error("A checkpoint from another owner must conflict");
354
354
  }
355
+ const liveView = await store.loadRun({ conversationId, runId: running.id });
356
+ if (!liveView)
357
+ throw new Error("loadRun must find a run of this conversation");
358
+ if (liveView.run.id !== running.id || liveView.run.conversationId !== conversationId) {
359
+ throw new Error("loadRun returned a run it was not asked for");
360
+ }
361
+ if (liveView.run.revision !== checkpointedRun.revision) {
362
+ throw new Error("loadRun must return the run as the last mutation left it");
363
+ }
364
+ if (liveView.run.usage?.cost?.value !== 0.25) {
365
+ throw new Error("loadRun must carry the figure the run has spent so far");
366
+ }
367
+ if (liveView.snapshotVersion !== checkpoint.snapshot.version) {
368
+ throw new Error("loadRun must report the conversation version it read at");
369
+ }
370
+ if (liveView.assistant !== undefined) {
371
+ throw new Error("loadRun must not report a terminal answer for a live run");
372
+ }
373
+ if (await store.loadRun({ conversationId, runId: "no-such-run" })) {
374
+ throw new Error("loadRun must return undefined for an unknown run");
375
+ }
376
+ if (await store.loadRun({ conversationId: "no-such-conversation", runId: running.id })) {
377
+ throw new Error("loadRun must not cross conversation boundaries");
378
+ }
379
+ const activeRuns = await store.listActiveRuns(conversationId);
380
+ if (!activeRuns.some((run) => run.id === running.id)) {
381
+ throw new Error("listActiveRuns must report a run that is in flight");
382
+ }
383
+ if (activeRuns.some((run) => run.terminalReason !== undefined)) {
384
+ throw new Error("listActiveRuns must not report a run that has ended");
385
+ }
386
+ for (let index = 1;index < activeRuns.length; index += 1) {
387
+ const previous = activeRuns[index - 1];
388
+ const current = activeRuns[index];
389
+ if (!previous || !current)
390
+ continue;
391
+ const ordered = previous.createdAt === current.createdAt ? previous.id.localeCompare(current.id) < 0 : previous.createdAt < current.createdAt;
392
+ if (!ordered) {
393
+ throw new Error("listActiveRuns must order by createdAt and then by id");
394
+ }
395
+ }
396
+ if ((await store.listActiveRuns("no-such-conversation")).length !== 0) {
397
+ throw new Error("listActiveRuns must be empty for an unknown conversation");
398
+ }
355
399
  const recoverable = await store.scanRecoverable({ limit: 10 });
356
400
  if (!recoverable.items.some((item) => item.run.id === running.id)) {
357
401
  throw new Error("A running run must appear in a recoverable scan");
@@ -409,6 +453,16 @@ async function runAgentStoreConformance(config) {
409
453
  if (settledRun?.usage?.cost?.value !== 1.5) {
410
454
  throw new Error("Terminal commit did not persist the run usage it was given");
411
455
  }
456
+ const terminalView = await store.loadRun({ conversationId, runId: running.id });
457
+ if (terminalView?.run.terminalReason !== "success") {
458
+ throw new Error("loadRun must report the terminal reason a settled run ended with");
459
+ }
460
+ if (JSON.stringify(terminalView.assistant) !== JSON.stringify(terminalAssistant)) {
461
+ throw new Error("loadRun must retain the answer a settled run produced");
462
+ }
463
+ if ((await store.listActiveRuns(conversationId)).some((run) => run.id === running.id)) {
464
+ throw new Error("listActiveRuns must drop a run once it has ended");
465
+ }
412
466
  const compactedTerminal = await store.replaceCompactedRange({
413
467
  conversationId,
414
468
  expectedVersion: terminalApplied.snapshot.version,
@@ -468,6 +522,100 @@ async function runAgentStoreConformance(config) {
468
522
  if (terminalRun?.state !== "abandoned" || terminalMessage?.status !== "failed") {
469
523
  throw new Error("Abandon recovery did not atomically terminalize its assistant record");
470
524
  }
525
+ await assertAbsorptionIsAtomic(store);
526
+ }
527
+ async function assertAbsorptionIsAtomic(store) {
528
+ const conversationId = `conformance-absorb-${crypto.randomUUID()}`;
529
+ const leadInput = userMessage(conversationId, "absorb-input-1");
530
+ const leadRun = queuedRun(conversationId, leadInput.id, "absorb-run-1");
531
+ const lead = await store.acceptInputAndAssignRun({
532
+ idempotencyKey: "absorb-request-1",
533
+ input: leadInput,
534
+ run: leadRun
535
+ });
536
+ requireOutcome(lead, "applied");
537
+ const successorInput = userMessage(conversationId, "absorb-input-2");
538
+ const successorRun = queuedRun(conversationId, successorInput.id, "absorb-run-2");
539
+ const successor = await store.acceptInputAndAssignRun({
540
+ idempotencyKey: "absorb-request-2",
541
+ input: successorInput,
542
+ run: successorRun
543
+ });
544
+ requireOutcome(successor, "applied");
545
+ const acquired = await store.acquireRun({
546
+ conversationId,
547
+ runId: leadRun.id,
548
+ expectedRevision: 0,
549
+ ownerId: "absorb-owner"
550
+ });
551
+ requireOutcome(acquired, "applied");
552
+ const running = acquired.snapshot.runs.find((run) => run.id === leadRun.id);
553
+ if (!running)
554
+ throw new Error("Absorbing run disappeared after acquisition");
555
+ const answer = AgentMessageSchema.parse({
556
+ schemaVersion: 1,
557
+ id: running.assistantMessageId,
558
+ conversationId,
559
+ runId: running.id,
560
+ role: "assistant",
561
+ status: "completed",
562
+ parts: [{ type: "text", text: "answered both" }],
563
+ createdAt: "2026-08-26T00:00:00.000Z",
564
+ updatedAt: "2026-08-26T00:00:02.000Z"
565
+ });
566
+ await store.commitRunTerminal({
567
+ conversationId,
568
+ runId: running.id,
569
+ expectedRevision: running.revision,
570
+ ownerId: "absorb-owner",
571
+ assistant: AgentMessageSchema.parse({ ...answer, status: "interrupted" }),
572
+ reason: "interrupted",
573
+ absorb: [{ runId: successorRun.id, inputMessageIds: [successorInput.id] }]
574
+ }).then(() => {
575
+ throw new Error("A non-completing run absorbed a queued successor");
576
+ }, (error) => {
577
+ if (!(error instanceof TypeError))
578
+ throw error;
579
+ });
580
+ const committed = await store.commitRunTerminal({
581
+ conversationId,
582
+ runId: running.id,
583
+ expectedRevision: running.revision,
584
+ ownerId: "absorb-owner",
585
+ assistant: answer,
586
+ reason: "success",
587
+ absorb: [{ runId: successorRun.id, inputMessageIds: [successorInput.id] }]
588
+ });
589
+ requireOutcome(committed, "applied");
590
+ const absorbingView = await store.loadRun({ conversationId, runId: leadRun.id });
591
+ if (absorbingView?.run.inputMessageIds.join(",") !== "absorb-input-1,absorb-input-2") {
592
+ throw new Error("An absorbing run must record the inputs it answered");
593
+ }
594
+ const absorbedView = await store.loadRun({ conversationId, runId: successorRun.id });
595
+ if (absorbedView?.run.terminalReason !== "absorbed") {
596
+ throw new Error("An absorbed successor must be terminal in the same transaction");
597
+ }
598
+ if (absorbedView.run.absorbedIntoRunId !== leadRun.id) {
599
+ throw new Error("An absorbed run must name the run that answered its input");
600
+ }
601
+ if (absorbedView.assistant !== undefined) {
602
+ throw new Error("An absorbed run produced no answer and must retain none");
603
+ }
604
+ if ((await store.listActiveRuns(conversationId)).length !== 0) {
605
+ throw new Error("An absorbed successor must leave the active listing");
606
+ }
607
+ const retried = await store.acceptInputAndAssignRun({
608
+ idempotencyKey: "absorb-request-2",
609
+ input: userMessage(conversationId, "absorb-discarded"),
610
+ run: queuedRun(conversationId, "absorb-discarded", "absorb-discarded-run")
611
+ });
612
+ requireOutcome(retried, "duplicate");
613
+ if (retried.runId !== leadRun.id || retried.assistant?.id !== answer.id) {
614
+ throw new Error("A retried absorbed key must resolve to the run that answered it");
615
+ }
616
+ if (retried.inputMessageId !== successorInput.id) {
617
+ throw new Error("A retried absorbed key must still name its own input");
618
+ }
471
619
  }
472
620
  // src/testing/managed-resource-conformance-contract.ts
473
621
  import { z } from "zod";
package/llms-full.txt CHANGED
@@ -58,7 +58,7 @@ own, recorded as an ADR.
58
58
  | `stitchkit/testing` | tests on Bun or Node | stable | in-process generated clients over a real Fetch handler, plus the store and managed-resource conformance kits |
59
59
  | `stitchkit/declaration` | build and deployment tooling (Bun or Node) | evolving | `ProjectDeclarationSchema` — the one machine-readable statement a repository makes about itself |
60
60
  | `stitchkit/react` | browser | stable | `createCursorQuery`, `createCacheBridge` |
61
- | `stitchkit/agent-runtime` | server | evolving<br>_redefined in 8 of the 10 minors since 0.56.2, most recently 0.65.0_ | optional durable conversation/run loop, history, models, prompts, fencing and events |
61
+ | `stitchkit/agent-runtime` | server | evolving<br>_redefined in 9 of the 11 minors since 0.56.2, most recently 0.66.0_ | optional durable conversation/run loop, history, models, prompts, fencing and events |
62
62
  | `stitchkit/agent-runtime/openrouter` | server | evolving | isolated OpenRouter language-model adapter |
63
63
  | `stitchkit/application` | server | evolving | managed resource graph, readiness, admission, schedules and bounded shutdown |
64
64
  | `stitchkit/application/grammy` | server | evolving | isolated grammY polling and webhook lifecycle adapters |
@@ -3683,15 +3683,14 @@ input + queued run → running → execution settled → terminal CAS → succes
3683
3683
 
3684
3684
  ## What happens to a run when new input arrives
3685
3685
 
3686
- `runs.inputPolicy` decides. It takes three values, or a function returning one;
3687
- the fourth row is a behaviour that shipped and was withdrawn:
3686
+ `runs.inputPolicy` decides. It takes four values, or a function returning one:
3688
3687
 
3689
3688
  | policy | the run in flight | what it already produced |
3690
3689
  |--------|-------------------|--------------------------|
3691
3690
  | `queue` (default) | finishes first | kept |
3691
+ | `inject` | continues, and answers the new input too | kept, and built on |
3692
3692
  | `interrupt` | ends | kept, and marked as cut off |
3693
3693
  | `supersede` | ends | discarded from the prompt, kept in the record |
3694
- | _inject_ | continues | withdrawn in 0.65.0 — see below |
3695
3694
 
3696
3695
  `interrupt` and `supersede` differ in exactly one thing, and the question that
3697
3696
  picks between them is **not** "was the run interrupted" but **"did anyone see
@@ -3775,19 +3774,53 @@ const { decisions } = await projectAgentHistoryDetailed(snapshot.messages)
3775
3774
  process-local escape hatch chooses the reason, so a caller that knows the answer
3776
3775
  was never delivered can discard it without a newer input arriving.
3777
3776
 
3778
- ### The one that is not offered
3777
+ ### An input that joins a run in flight
3778
+
3779
+ `inject` is right when the new input **refines** rather than redirects, and the
3780
+ steps already taken are still worth something: *"summarise this thread… actually,
3781
+ in bullet points"* should not throw away the reading the first message paid for.
3782
+
3783
+ What happens:
3784
+
3785
+ 1. The input is admitted exactly like any other — a committed user message and a
3786
+ **queued run**, durable before anything else happens.
3787
+ 2. At the running loop's next step boundary, that input joins its prompt. Only
3788
+ that input: an unrelated queued submission is never carried in.
3789
+ 3. When the run finishes, its terminal commit — **one transaction** — records
3790
+ the input as one the run answered and settles the queued successor with
3791
+ `terminalReason: 'absorbed'` and `absorbedIntoRunId` naming the run that
3792
+ answered it. Every ticket for that successor resolves to the same answer.
3793
+
3794
+ Nothing durable happens between 1 and 3, and that is the design (→ ADR 0113).
3795
+ A run that crashes, is closed, or is interrupted after taking an input on
3796
+ commits no absorption at all, so what is left behind is an ordinary queued
3797
+ successor — the state every other policy already produces and recovery already
3798
+ handles. **There is no ordering in which an accepted input becomes
3799
+ unanswerable.** Only a run that *completes* may absorb; an interrupted one took
3800
+ the input into its prompt and then stopped, and does not get to say it answered
3801
+ it.
3802
+
3803
+ An absorbed run has **no assistant message of its own** — it produced none, and
3804
+ writing an empty one would be a record claiming otherwise. Its answer is
3805
+ reachable through `absorbedIntoRunId`, and the store follows that pointer itself
3806
+ when a submission arrives on the absorbed run's idempotency key, so a retry
3807
+ after a restart returns the answer rather than an empty terminal record.
3779
3808
 
3780
- **inject** hand the input to the loop between tool calls and let the run
3781
- continue shipped in 0.63.0 and was **withdrawn in 0.65.0**. It committed the
3782
- absorption durably at a step boundary, before the answer existed, and everything
3783
- downstream of that ordering was wrong: an accepted input could end up in a state
3784
- that was neither active, recoverable nor terminal, so `close()` could report
3785
- `settled: true` while leaving it permanently unanswerable, and a duplicate
3786
- submission of the same idempotency key was refused forever.
3809
+ It does publish one more `run-state` event, carrying `'superseded'`. It never
3810
+ enters the run executor, so that event is the only thing that tells a delivery
3811
+ surface following its `runId` that it is no longer queued.
3787
3812
 
3788
- The redesign is tracked in the backlog and is not a patch to what shipped: the
3789
- absorption has to commit atomically **with** the terminal record, so that a run
3790
- which ends first simply leaves an ordinary queued successor behind.
3813
+ With `coalescePending`, one successor can carry several inputs, and an
3814
+ absorption covers a successor **whole or not at all** a partial one would
3815
+ leave a terminal run with inputs nobody answered. Inputs that coalesce before
3816
+ the absorbing run's last step boundary join the same absorption. One that
3817
+ arrives after it cancels the absorption: the successor then answers all of its
3818
+ inputs itself, and the absorbing run has answered one of them too. A duplicate
3819
+ answer, never a missing one.
3820
+
3821
+ (0.63.0 shipped a version of this that committed the absorption at the step
3822
+ boundary, before the answer existed, and it was withdrawn in 0.65.0. ADR 0113
3823
+ records what that ordering broke.)
3791
3824
 
3792
3825
  With `runs.coalescePending: true`, an active lane has at most one queued
3793
3826
  successor. Every later accepted input is atomically appended to that successor;
@@ -3850,7 +3883,7 @@ problem, and omitting a run's cost is an operator's.
3850
3883
  ## Store operations
3851
3884
 
3852
3885
  `AgentRuntimeStore` remains the runtime-facing aggregate. Application adapters
3853
- implement the smaller `AgentRuntimeStoreDriver` rather than these nine members:
3886
+ implement the smaller `AgentRuntimeStoreDriver` rather than these eleven members:
3854
3887
 
3855
3888
  - `acceptInputAndAssignRun`
3856
3889
  - `acquireRun`
@@ -3859,11 +3892,37 @@ implement the smaller `AgentRuntimeStoreDriver` rather than these nine members:
3859
3892
  - `recoverRun`
3860
3893
  - `commitRunTerminal`
3861
3894
  - `replaceCompactedRange`
3862
- - `loadSnapshot`
3895
+ - `loadSnapshot` — the whole conversation; see **Reading a conversation** below
3896
+ - `loadRun` — one run by id, with the answer it produced if it has ended
3897
+ - `listActiveRuns` — the runs of one conversation that have not ended
3863
3898
  - `scanRecoverable` — one **bounded page** of recoverable runs; `recover()`
3864
3899
  calls this and nothing else, so an adapter that implements the interface has
3865
3900
  everything recovery needs
3866
3901
 
3902
+ ### Reading a conversation
3903
+
3904
+ Two shapes of read, and the difference matters as a conversation grows.
3905
+
3906
+ **Bounded.** `loadRun` and `listActiveRuns` read run records and the
3907
+ conversation head. Neither touches history, so neither grows with the length of
3908
+ the conversation. `loadRun` is how you resolve the `runId` that
3909
+ `submit().admission` hands back — it returns the run, the version it was read
3910
+ at, and, once the run is terminal, the answer it produced.
3911
+
3912
+ **Unbounded.** `loadSnapshot` returns every message and every run, and so does
3913
+ every mutation result: the store's reducer validates its invariants against the
3914
+ whole conversation, and the runtime builds the next prompt from the snapshot the
3915
+ mutation returns. Ask for it when you need the conversation — composing a
3916
+ prompt, or compacting — and not to look one run up.
3917
+
3918
+ So the cost of a run scales with the length of its conversation, not with the
3919
+ length of the turn. **Configure compaction** (see below) for anything
3920
+ long-running: it is the only thing in the framework that makes a conversation
3921
+ smaller, and without it a year-old assistant thread is read in full on every
3922
+ turn. This is a known limit, held deliberately rather than by omission — paged
3923
+ history would have to change what a snapshot *is*, and the store's invariants
3924
+ with it, and that is a decision on its own (→ ADR 0112).
3925
+
3867
3926
  Every mutation carries an expected run revision or snapshot version. Input
3868
3927
  assignment additionally carries an idempotency identity. A conflict is a
3869
3928
  control outcome; stale data is never silently overwritten.
@@ -7844,6 +7903,149 @@ implement `AgentRuntimeStoreDriver` and compose the aggregate with
7844
7903
  runtime): bootstrap the server, one HTTP request, and any feature you rely on
7845
7904
  (Socket.IO connect, an MCP tool call, a multipart upload, …).
7846
7905
 
7906
+ ## Released migration: 0.66.0
7907
+
7908
+ Three changes, and only one of them is a feature. The other two are shapes that
7909
+ would have had to break later: a vocabulary that described one fact with two
7910
+ words, and a store whose only read was the whole conversation.
7911
+
7912
+ The compiler points at most of it. Two things change with no compile error —
7913
+ what a **total** says about its own provenance, and the fact that token counts
7914
+ are now validated where they were not.
7915
+
7916
+ ### An input that joins a run in flight
7917
+
7918
+ #### If you matched exhaustively on `AgentTerminalReason`
7919
+
7920
+ `'absorbed'` is new. A run ends this way when a run already in flight took its
7921
+ input on and answered it; its state is `'superseded'`, and `absorbedIntoRunId`
7922
+ names the run that has the answer.
7923
+
7924
+ ```ts
7925
+ // after
7926
+ switch (run.terminalReason) {
7927
+ // …
7928
+ case 'absorbed':
7929
+ // no assistant message of its own — follow run.absorbedIntoRunId
7930
+ break
7931
+ }
7932
+ ```
7933
+
7934
+ A run record with `terminalReason: 'absorbed'` and no `absorbedIntoRunId` is
7935
+ refused at parse time, and so is `absorbedIntoRunId` on any other reason.
7936
+
7937
+ #### If you render or export runs
7938
+
7939
+ An absorbed run has **no assistant message**. Anything that assumes "every
7940
+ terminal run has one" needs the `absorbed` case — the answer is on the run
7941
+ `absorbedIntoRunId` names, and `store.loadRun` resolves it.
7942
+
7943
+ #### If you want the policy
7944
+
7945
+ ```ts
7946
+ // after
7947
+ runs: { inputPolicy: 'inject' }
7948
+ ```
7949
+
7950
+ It was withdrawn in 0.65.0 and is back with the ordering corrected: the
7951
+ absorption commits with the terminal record, not at the step boundary. Read
7952
+ *An input that joins a run in flight* in the agent-runtime guide before turning
7953
+ it on — in particular what happens when the absorbing run does not complete, and
7954
+ how it composes with `coalescePending`.
7955
+
7956
+ #### If you implement a store driver
7957
+
7958
+ Nothing to do, but know what changed underneath: one terminal commit can now
7959
+ save **two** run records, and they must land in the same transaction. A driver
7960
+ that persists one of the pair fails `runAgentStoreConformance`.
7961
+
7962
+ ### A run read without its conversation
7963
+
7964
+ #### If you implement `AgentRuntimeStore` by hand
7965
+
7966
+ Two members to add. **If your adapter is an `AgentRuntimeStoreDriver` passed to
7967
+ `createAgentRuntimeStore`, there is nothing to do** — the driver already had
7968
+ everything both need.
7969
+
7970
+ ```ts
7971
+ // after
7972
+ loadRun(input: { conversationId: string; runId: string }): Promise<AgentRunView | undefined>
7973
+ listActiveRuns(conversationId: string): Promise<readonly AgentRun[]>
7974
+ ```
7975
+
7976
+ `AgentRunView` is `{ snapshotVersion, run, assistant? }`. `assistant` is the
7977
+ retained terminal answer, so it is present exactly when the run has ended and
7978
+ absent while it is live — a store that returns a live run's draft here lets the
7979
+ terminal path resolve a run that has not finished. `listActiveRuns` orders by
7980
+ `createdAt` then `id`, and must not report a run that has ended.
7981
+ `runAgentStoreConformance` covers both, including the boundary cases.
7982
+
7983
+ #### If you have a store **double** in your tests
7984
+
7985
+ The runtime now reads `loadRun` where it used to read `loadSnapshot`. A double
7986
+ that simulates a condition — a run drifting to another owner, a stale fencing
7987
+ token — must apply it to both reads, or the code under test will not see it.
7988
+ This is not hypothetical: it turned one of this repository's own fixtures into
7989
+ an infinite retry loop, which is how the bounded retry below was found.
7990
+
7991
+ #### Nothing else changes
7992
+
7993
+ `loadSnapshot` behaves exactly as before, and still returns the whole
7994
+ conversation — as does every mutation result. What it costs, and what bounds it,
7995
+ is now written down in *Reading a conversation* in the agent-runtime guide.
7996
+
7997
+ ### One provenance vocabulary, and integral tokens
7998
+
7999
+ #### If you match on `'measured'`
8000
+
8001
+ A **total** now says `computed`, because it is a sum this code performed rather
8002
+ than a count it took — the same rule `AgentUsage` has always applied to a run's
8003
+ spend. Two values change with no compile error:
8004
+
8005
+ ```ts
8006
+ // before
8007
+ if (result.totalTokens.provenance === 'measured') { /* exact */ }
8008
+ // after
8009
+ if (result.totalTokens.provenance === 'computed') { /* exact, and derived */ }
8010
+ ```
8011
+
8012
+ It affects `AgentHistoryBudgetResult.totalTokens` and
8013
+ `ComposedAgentPrompt.instructionTokens`. A per-message or per-section count is
8014
+ still `measured` — only the totals moved. When any part was estimated the total
8015
+ is still `estimated`: an estimate survives arithmetic, and that is the weaker
8016
+ claim, so it wins.
8017
+
8018
+ #### If you produce token counts
8019
+
8020
+ They are validated now, in the places they were not. A fractional count throws
8021
+ where it used to flow into the context-window arithmetic:
8022
+
8023
+ ```ts
8024
+ // refused from this release on
8025
+ estimateTokens: (text) => ({ value: text.length / 4, provenance: 'estimated' })
8026
+ // after
8027
+ estimateTokens: (text) => ({ value: Math.ceil(text.length / 4), provenance: 'estimated' })
8028
+ ```
8029
+
8030
+ The same applies to `ComposeAgentPromptOptions.estimateFallback` and
8031
+ `.historyTokens`, to `AgentPromptBudget.toolSchemas` / `.attachments` /
8032
+ `.providerOverhead`, and to `AgentPromptBudget.contextWindow` /
8033
+ `.reservedOutput`, which must now be non-negative safe integers.
8034
+
8035
+ `AgentUsageValue.value` is `z.int()` too, which matters if you build usage
8036
+ records by hand or in a store double. A figure arriving from a **provider** is
8037
+ not thrown — `normalizeSdkUsage` and the OpenRouter adapter turn a non-integer
8038
+ into `{ provenance: 'unavailable' }`, so a run that already answered is not
8039
+ failed over its own bookkeeping.
8040
+
8041
+ #### The new export
8042
+
8043
+ `AgentProvenanceSchema` / `AgentProvenance` is the whole vocabulary:
8044
+ `provider-reported`, `measured`, `computed`, `estimated`, `unavailable`. Each
8045
+ surface declares its subset, so nothing widened — `AgentUsageValue` still refuses
8046
+ `measured` and `AgentTokenCount` still refuses `provider-reported`. Use it when
8047
+ you want one switch over the question instead of two.
8048
+
7847
8049
  ## Released migration: 0.65.0
7848
8050
 
7849
8051
  The largest migration of the pre-1.0 line, and most of it is the compiler
@@ -10511,10 +10713,42 @@ Canonical protocol exports are `AgentProtocol`, `AgentProtocolConfig`, `AgentRec
10511
10713
  `AgentUsageValueSchema`, `AgentCostValueSchema`, `AgentUsageSchema`, `AgentUsage` and
10512
10714
  `AgentRunMetrics`.
10513
10715
 
10716
+ `AgentProvenanceSchema` / `AgentProvenance` is the entrypoint's single vocabulary for **how a
10717
+ number came to be known**: `provider-reported` (the provider stated it about a request it served),
10718
+ `measured` (this process counted it exactly, before any request was made), `computed` (arithmetic
10719
+ over other values — a sum of exact numbers is still `computed`), `estimated` (a heuristic) and
10720
+ `unavailable` (not known, so `value` is absent, which is a different fact from a reported zero).
10721
+ Each surface declares the subset it can produce: `AgentUsageValueSchema` and `AgentCostValueSchema`
10722
+ describe a request that has already happened and never say `measured`; `AgentTokenCountSchema`
10723
+ describes a prompt being composed and never says `provider-reported`. Every token count is an
10724
+ integer — `AgentUsageValueSchema` and `AgentTokenCountSchema` refuse a fractional `value`, and a
10725
+ provider figure that is not a whole number is normalised to `unavailable` rather than thrown.
10726
+ `AgentCostValueSchema.value` stays fractional, because money is.
10727
+
10728
+ `runs.inputPolicy` takes `queue` (default), `inject`, `interrupt` or `supersede`, or a function of
10729
+ the raw input returning one. `inject` lets a run in flight take a newly arrived input into its prompt
10730
+ at a step boundary and answer it too; the absorption is committed in the **same transaction** as that
10731
+ run's terminal record, via `CommitRunTerminal.absorb`, so a run that ends any other way leaves an
10732
+ ordinary queued successor. The absorbed run ends with `terminalReason: 'absorbed'`, run state
10733
+ `'superseded'`, `absorbedIntoRunId` naming the run that answered it, and **no assistant message of
10734
+ its own**; a submission on its idempotency key resolves through that pointer to the answer
10735
+ (→ ADR 0113).
10736
+
10737
+ `AgentRuntimeStore` has two **bounded** reads beside `loadSnapshot`:
10738
+ `loadRun({ conversationId, runId })` returns an `AgentRunView` — the run, the conversation version it
10739
+ was read at, and the retained answer once the run is terminal — or `undefined`; `listActiveRuns(conversationId)`
10740
+ returns the runs that have not ended, ordered by `createdAt` then `id`. Neither reads history, so
10741
+ neither grows with the length of the conversation, and neither needs anything new from
10742
+ `AgentRuntimeStoreDriver`. `loadSnapshot` and every mutation result still carry the whole
10743
+ conversation — that is what the store's reducer validates against, and what the runtime builds a
10744
+ prompt from (→ ADR 0112).
10745
+
10514
10746
  Store command/result exports are `AcceptInputAndAssignRun`, `AcceptInputAndAssignRunSchema`,
10515
10747
  `AcquireAgentRun`, `AcquireAgentRunSchema`, `CheckpointRunAssistant`,
10516
10748
  `CheckpointRunAssistantSchema`, `CommitRunTerminal`, `CommitRunTerminalSchema`,
10517
- `RequestRunInterrupt`, `RequestRunInterruptSchema`, `runStateForTerminalReason`, `RecoverAgentRun`, `ReplaceCompactedRange`,
10749
+ `RequestRunInterrupt`, `RequestRunInterruptSchema`, `AgentRunView`, `AgentRunViewSchema`,
10750
+ `runStateForTerminalReason`,
10751
+ `ACTIVE_AGENT_RUN_STATES`, `RecoverAgentRun`, `ReplaceCompactedRange`,
10518
10752
  `ReplaceCompactedRangeSchema`, `AgentStoreMutationResult`, `AgentStoreMutationResultSchema`,
10519
10753
  `AgentStoreAppliedSchema`, `AgentStoreConflictSchema`, `AgentStoreDuplicateSchema`,
10520
10754
  `AgentStoreNotFoundSchema`, `AgentAdmissionReceipt`, `AgentAdmissionReceiptSchema`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stitchkit",
3
- "version": "0.65.0",
3
+ "version": "0.66.0",
4
4
  "description": "Contract-first backend framework — one defineContract() into an HTTP API, MCP tools, AI-agent tools and a typed client. Bun and Node.",
5
5
  "keywords": [
6
6
  "bun",