@namzu/sdk 1.1.0 → 1.3.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 (153) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/dist/bridge/a2a/mapper.d.ts.map +1 -1
  3. package/dist/bridge/a2a/mapper.js +2 -0
  4. package/dist/bridge/a2a/mapper.js.map +1 -1
  5. package/dist/bridge/sse/mapper.d.ts.map +1 -1
  6. package/dist/bridge/sse/mapper.js +4 -0
  7. package/dist/bridge/sse/mapper.js.map +1 -1
  8. package/dist/{runtime/query → constants}/continuation.d.ts +5 -0
  9. package/dist/constants/continuation.d.ts.map +1 -0
  10. package/dist/{runtime/query → constants}/continuation.js +5 -0
  11. package/dist/constants/continuation.js.map +1 -0
  12. package/dist/constants/index.d.ts +1 -0
  13. package/dist/constants/index.d.ts.map +1 -1
  14. package/dist/constants/index.js +1 -0
  15. package/dist/constants/index.js.map +1 -1
  16. package/dist/manager/run/persistence.d.ts +11 -0
  17. package/dist/manager/run/persistence.d.ts.map +1 -1
  18. package/dist/manager/run/persistence.js +30 -1
  19. package/dist/manager/run/persistence.js.map +1 -1
  20. package/dist/provider/__tests__/registry.test.d.ts +5 -0
  21. package/dist/provider/__tests__/registry.test.d.ts.map +1 -1
  22. package/dist/provider/__tests__/registry.test.js +186 -1
  23. package/dist/provider/__tests__/registry.test.js.map +1 -1
  24. package/dist/provider/capabilities.d.ts +27 -0
  25. package/dist/provider/capabilities.d.ts.map +1 -0
  26. package/dist/provider/capabilities.js +29 -0
  27. package/dist/provider/capabilities.js.map +1 -0
  28. package/dist/provider/index.d.ts +3 -1
  29. package/dist/provider/index.d.ts.map +1 -1
  30. package/dist/provider/index.js +2 -1
  31. package/dist/provider/index.js.map +1 -1
  32. package/dist/provider/registry.d.ts +81 -1
  33. package/dist/provider/registry.d.ts.map +1 -1
  34. package/dist/provider/registry.js +173 -7
  35. package/dist/provider/registry.js.map +1 -1
  36. package/dist/public-runtime.d.ts +3 -2
  37. package/dist/public-runtime.d.ts.map +1 -1
  38. package/dist/public-runtime.js +2 -2
  39. package/dist/public-runtime.js.map +1 -1
  40. package/dist/run/reporter.d.ts.map +1 -1
  41. package/dist/run/reporter.js +8 -0
  42. package/dist/run/reporter.js.map +1 -1
  43. package/dist/runtime/query/__tests__/capability-negotiation.test.d.ts +2 -0
  44. package/dist/runtime/query/__tests__/capability-negotiation.test.d.ts.map +1 -0
  45. package/dist/runtime/query/__tests__/capability-negotiation.test.js +184 -0
  46. package/dist/runtime/query/__tests__/capability-negotiation.test.js.map +1 -0
  47. package/dist/runtime/query/__tests__/checkpoint-store.test.d.ts +2 -0
  48. package/dist/runtime/query/__tests__/checkpoint-store.test.d.ts.map +1 -0
  49. package/dist/runtime/query/__tests__/checkpoint-store.test.js +259 -0
  50. package/dist/runtime/query/__tests__/checkpoint-store.test.js.map +1 -0
  51. package/dist/runtime/query/__tests__/checkpoint.test.js +9 -3
  52. package/dist/runtime/query/__tests__/checkpoint.test.js.map +1 -1
  53. package/dist/runtime/query/__tests__/orphan-task-turn-end.test.d.ts +2 -0
  54. package/dist/runtime/query/__tests__/orphan-task-turn-end.test.d.ts.map +1 -0
  55. package/dist/runtime/query/__tests__/orphan-task-turn-end.test.js +110 -0
  56. package/dist/runtime/query/__tests__/orphan-task-turn-end.test.js.map +1 -0
  57. package/dist/runtime/query/checkpoint.d.ts +16 -2
  58. package/dist/runtime/query/checkpoint.d.ts.map +1 -1
  59. package/dist/runtime/query/checkpoint.js +21 -7
  60. package/dist/runtime/query/checkpoint.js.map +1 -1
  61. package/dist/runtime/query/context.d.ts +7 -0
  62. package/dist/runtime/query/context.d.ts.map +1 -1
  63. package/dist/runtime/query/context.js +1 -0
  64. package/dist/runtime/query/context.js.map +1 -1
  65. package/dist/runtime/query/index.d.ts +20 -0
  66. package/dist/runtime/query/index.d.ts.map +1 -1
  67. package/dist/runtime/query/index.js +75 -6
  68. package/dist/runtime/query/index.js.map +1 -1
  69. package/dist/runtime/query/iteration/index.d.ts +3 -57
  70. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  71. package/dist/runtime/query/iteration/index.js +246 -735
  72. package/dist/runtime/query/iteration/index.js.map +1 -1
  73. package/dist/runtime/query/iteration/phases/checkpoint.d.ts.map +1 -1
  74. package/dist/runtime/query/iteration/phases/checkpoint.js +22 -0
  75. package/dist/runtime/query/iteration/phases/checkpoint.js.map +1 -1
  76. package/dist/runtime/query/iteration/phases/context.d.ts +1 -2
  77. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  78. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  79. package/dist/runtime/query/iteration/stream-turn.d.ts +35 -0
  80. package/dist/runtime/query/iteration/stream-turn.d.ts.map +1 -0
  81. package/dist/runtime/query/iteration/stream-turn.js +341 -0
  82. package/dist/runtime/query/iteration/stream-turn.js.map +1 -0
  83. package/dist/runtime/query/replay/list.d.ts +17 -1
  84. package/dist/runtime/query/replay/list.d.ts.map +1 -1
  85. package/dist/runtime/query/replay/list.js +21 -4
  86. package/dist/runtime/query/replay/list.js.map +1 -1
  87. package/dist/runtime/query/replay/prepare.d.ts +10 -0
  88. package/dist/runtime/query/replay/prepare.d.ts.map +1 -1
  89. package/dist/runtime/query/replay/prepare.js +20 -4
  90. package/dist/runtime/query/replay/prepare.js.map +1 -1
  91. package/dist/store/index.d.ts +1 -0
  92. package/dist/store/index.d.ts.map +1 -1
  93. package/dist/store/index.js +1 -0
  94. package/dist/store/index.js.map +1 -1
  95. package/dist/store/run/checkpoint-disk.d.ts +28 -0
  96. package/dist/store/run/checkpoint-disk.d.ts.map +1 -0
  97. package/dist/store/run/checkpoint-disk.js +55 -0
  98. package/dist/store/run/checkpoint-disk.js.map +1 -0
  99. package/dist/types/provider/config.d.ts +51 -0
  100. package/dist/types/provider/config.d.ts.map +1 -1
  101. package/dist/types/provider/index.d.ts +1 -1
  102. package/dist/types/provider/index.d.ts.map +1 -1
  103. package/dist/types/provider/interface.d.ts +10 -0
  104. package/dist/types/provider/interface.d.ts.map +1 -1
  105. package/dist/types/run/checkpoint-store.d.ts +64 -0
  106. package/dist/types/run/checkpoint-store.d.ts.map +1 -0
  107. package/dist/types/run/checkpoint-store.js +12 -0
  108. package/dist/types/run/checkpoint-store.js.map +1 -0
  109. package/dist/types/run/config.d.ts +27 -0
  110. package/dist/types/run/config.d.ts.map +1 -1
  111. package/dist/types/run/events.d.ts +6 -0
  112. package/dist/types/run/events.d.ts.map +1 -1
  113. package/dist/types/run/events.js.map +1 -1
  114. package/dist/types/run/index.d.ts +1 -0
  115. package/dist/types/run/index.d.ts.map +1 -1
  116. package/dist/types/run/index.js +1 -0
  117. package/dist/types/run/index.js.map +1 -1
  118. package/package.json +1 -1
  119. package/src/bridge/a2a/mapper.ts +3 -0
  120. package/src/bridge/sse/mapper.ts +5 -0
  121. package/src/{runtime/query → constants}/continuation.ts +5 -0
  122. package/src/constants/index.ts +1 -0
  123. package/src/manager/run/persistence.ts +34 -1
  124. package/src/provider/__tests__/registry.test.ts +266 -1
  125. package/src/provider/capabilities.ts +47 -0
  126. package/src/provider/index.ts +12 -1
  127. package/src/provider/registry.ts +202 -7
  128. package/src/public-runtime.ts +6 -0
  129. package/src/run/reporter.ts +9 -0
  130. package/src/runtime/query/__tests__/capability-negotiation.test.ts +248 -0
  131. package/src/runtime/query/__tests__/checkpoint-store.test.ts +322 -0
  132. package/src/runtime/query/__tests__/checkpoint.test.ts +15 -6
  133. package/src/runtime/query/__tests__/orphan-task-turn-end.test.ts +127 -0
  134. package/src/runtime/query/checkpoint.ts +23 -9
  135. package/src/runtime/query/context.ts +9 -0
  136. package/src/runtime/query/index.ts +115 -28
  137. package/src/runtime/query/iteration/index.ts +298 -905
  138. package/src/runtime/query/iteration/phases/checkpoint.ts +25 -0
  139. package/src/runtime/query/iteration/phases/context.ts +1 -3
  140. package/src/runtime/query/iteration/stream-turn.ts +396 -0
  141. package/src/runtime/query/replay/list.ts +38 -4
  142. package/src/runtime/query/replay/prepare.ts +37 -4
  143. package/src/store/index.ts +1 -0
  144. package/src/store/run/checkpoint-disk.ts +67 -0
  145. package/src/types/provider/config.ts +54 -0
  146. package/src/types/provider/index.ts +3 -0
  147. package/src/types/provider/interface.ts +11 -0
  148. package/src/types/run/checkpoint-store.ts +72 -0
  149. package/src/types/run/config.ts +30 -0
  150. package/src/types/run/events.ts +13 -0
  151. package/src/types/run/index.ts +1 -0
  152. package/dist/runtime/query/continuation.d.ts.map +0 -1
  153. package/dist/runtime/query/continuation.js.map +0 -1
@@ -7,12 +7,37 @@ import {
7
7
  handleHITLDecision,
8
8
  } from './context.js'
9
9
 
10
+ /**
11
+ * Cadence gate for the per-iteration checkpoint (`runConfig.checkpointEvery`,
12
+ * default 1 = every iteration). Off-cadence iterations skip the whole phase —
13
+ * no checkpoint, no `checkpoint_created` event, and no HITL
14
+ * `iteration_checkpoint` park (there is no checkpoint id to park on).
15
+ * Iterations 1, 1+N, 1+2N, … checkpoint, so the first tool iteration is
16
+ * always covered (a crash before the first cadence hit would otherwise leave
17
+ * nothing to resume from).
18
+ */
19
+ function isOnCheckpointCadence(iterationNum: number, checkpointEvery: number | undefined): boolean {
20
+ const every = Math.max(1, Math.floor(checkpointEvery ?? 1))
21
+ return (iterationNum - 1) % every === 0
22
+ }
23
+
10
24
  export async function* runIterationCheckpoint(
11
25
  ctx: IterationContext,
12
26
  iterationNum: number,
13
27
  ): AsyncGenerator<RunEvent, PhaseSignal> {
28
+ if (!isOnCheckpointCadence(iterationNum, ctx.runConfig.checkpointEvery)) {
29
+ return 'continue'
30
+ }
31
+
14
32
  const iterCheckpoint = await ctx.checkpointMgr.create(ctx.runMgr, iterationNum)
15
33
 
34
+ // Growth control: keep only the newest N checkpoints when the host asked
35
+ // for pruning. Default undefined ⇒ never prune (today's behavior).
36
+ const pruneKeepLast = ctx.runConfig.pruneKeepLast
37
+ if (pruneKeepLast !== undefined && pruneKeepLast >= 1) {
38
+ await ctx.checkpointMgr.prune(Math.floor(pruneKeepLast))
39
+ }
40
+
16
41
  await ctx.emitEvent({
17
42
  type: 'checkpoint_created',
18
43
  runId: ctx.runMgr.id,
@@ -5,7 +5,7 @@ import type { CompactionConfig } from '../../../../config/runtime.js'
5
5
  import type { PlanManager } from '../../../../manager/plan/lifecycle.js'
6
6
  import type { RunPersistence } from '../../../../manager/run/persistence.js'
7
7
  import type { ActivityStore } from '../../../../store/activity/memory.js'
8
- import type { TaskGateway, TaskHandle } from '../../../../types/agent/gateway.js'
8
+ import type { TaskGateway } from '../../../../types/agent/gateway.js'
9
9
  import type { WorkingMemoryProvider } from '../../../../types/agent/working-memory.js'
10
10
  import type { HITLResumeDecision, ResumeHandler } from '../../../../types/hitl/index.js'
11
11
  import type { TaskId } from '../../../../types/ids/index.js'
@@ -56,8 +56,6 @@ export interface IterationContext {
56
56
 
57
57
  readonly taskStore?: TaskStore
58
58
 
59
- readonly pendingNotifications: TaskHandle[]
60
-
61
59
  readonly launchedTasks: Map<TaskId, LaunchedTaskMeta>
62
60
 
63
61
  readonly compactionConfig?: CompactionConfig
@@ -0,0 +1,396 @@
1
+ import { mergeTokenUsage } from '../../../types/common/index.js'
2
+ import type { ToolUseId } from '../../../types/ids/index.js'
3
+ import type {
4
+ ChatCompletionResponse,
5
+ LLMProvider,
6
+ StreamChunk,
7
+ } from '../../../types/provider/index.js'
8
+ import type { RunEvent } from '../../../types/run/index.js'
9
+ import type { MessageStopReason } from '../../../types/run/stop-reason.js'
10
+ import { generateMessageId } from '../../../utils/id.js'
11
+ import type { Logger } from '../../../utils/logger.js'
12
+ import type { EmitEvent } from '../events.js'
13
+
14
+ /**
15
+ * Map a provider's coarse `finishReason` plus the orchestrator's
16
+ * `forceFinalize` flag onto the per-message {@link MessageStopReason}
17
+ * union the v3 `message_completed` event surfaces.
18
+ */
19
+ function synthesizeMessageStopReason(
20
+ finishReason: 'stop' | 'tool_calls' | 'length' | 'content_filter',
21
+ forceFinalize: boolean,
22
+ ): MessageStopReason {
23
+ if (forceFinalize) return 'forced_finalize'
24
+ switch (finishReason) {
25
+ case 'tool_calls':
26
+ return 'tool_use'
27
+ case 'length':
28
+ return 'max_tokens'
29
+ case 'content_filter':
30
+ return 'refusal'
31
+ default:
32
+ return 'end_turn'
33
+ }
34
+ }
35
+
36
+ export interface StreamingTurnResult {
37
+ response: ChatCompletionResponse
38
+ messageId: import('../../../types/ids/index.js').MessageId
39
+ }
40
+
41
+ /**
42
+ * Consume a provider's streaming response and emit the v3 RunEvent
43
+ * lifecycle natively (message_started → text_delta* + tool_input_*
44
+ * → message_completed). Returns the aggregated `ChatCompletionResponse`
45
+ * for downstream code that still expects the legacy shape (assistant
46
+ * message construction, working-state extraction, telemetry attribute
47
+ * stamping).
48
+ *
49
+ * Per-delta `emitEvent` calls are followed by a `drainPending()`
50
+ * yield so SSE consumers see live progress instead of a burst at
51
+ * end-of-message. The bus's ephemeral filter (D1) ensures these
52
+ * deltas never hit transcript.jsonl.
53
+ *
54
+ * Edge cases (codex A3, A4, A5):
55
+ * - Stream ends without `finishReason` (anthropic-sdk-typescript#842
56
+ * dropped message_stop): we still emit `message_completed` from a
57
+ * finally-style fall-through path with `stopReason: 'refusal'`.
58
+ * - `tool_input_delta` with no `toolUseId` registered yet: we drop
59
+ * the fragment and log a warning (proxies seen to misorder events).
60
+ * - `chunk.error`: when no tool input is recoverable, we surface as
61
+ * a thrown error after emitting the message_completed terminator so
62
+ * consumer cards still close. If a tool-use block was already open,
63
+ * we instead synthesize a tool call with runtime truncation metadata
64
+ * so the executor can return a model-readable retry hint.
65
+ */
66
+ export async function* streamProviderTurn(
67
+ provider: LLMProvider,
68
+ params: import('../../../types/provider/index.js').ChatCompletionParams,
69
+ emitEvent: EmitEvent,
70
+ drainPending: () => Generator<RunEvent>,
71
+ runId: import('../../../types/ids/index.js').RunId,
72
+ iteration: number,
73
+ forceFinalize: boolean,
74
+ log: Logger,
75
+ ): AsyncGenerator<RunEvent, StreamingTurnResult> {
76
+ const messageId = generateMessageId()
77
+ await emitEvent({ type: 'message_started', runId, iteration, messageId })
78
+ yield* drainPending()
79
+
80
+ let id = ''
81
+ const model = ''
82
+ let textBuf = ''
83
+ let finishReason: ChatCompletionResponse['finishReason'] = 'stop'
84
+ let usage: ChatCompletionResponse['usage'] = {
85
+ promptTokens: 0,
86
+ completionTokens: 0,
87
+ totalTokens: 0,
88
+ cachedTokens: 0,
89
+ cacheWriteTokens: 0,
90
+ }
91
+ const toolBuckets = new Map<
92
+ number,
93
+ {
94
+ id: string
95
+ name: string
96
+ argsBuf: string
97
+ started: boolean
98
+ completed: boolean
99
+ /**
100
+ * Parsed input. `null` while the bucket is still streaming.
101
+ * The synthesized
102
+ * `ChatCompletionResponse.toolCalls[].function.arguments` is
103
+ * derived from this — never from the raw buffer — so the
104
+ * downstream executor (`runtime/query/executor.ts`) never has
105
+ * to re-parse a truncated string. A truncated tool call is
106
+ * surfaced as `arguments: "{}"` plus `metadata.inputTruncated`
107
+ * so tool args remain clean while the executor can still
108
+ * return a specific retry hint.
109
+ */
110
+ parsed: unknown | null
111
+ inputTruncated: boolean
112
+ }
113
+ >()
114
+ let streamError: string | undefined
115
+
116
+ const stream = provider.chatStream({ ...params, stream: true }) as AsyncIterable<StreamChunk>
117
+
118
+ // Drive the stream manually so each `.next()` can be RACED against the run
119
+ // abort: a Stop tears the in-flight model request down (the provider got
120
+ // `params.signal`), and we ALSO stop pulling within a tick even if a
121
+ // transport buffers or ignores the signal. The abort rejection propagates
122
+ // out of this generator so the run loop settles the turn as cancelled.
123
+ // `{ once: true }` keeps a multi-iteration run from leaking a listener/turn.
124
+ const it = stream[Symbol.asyncIterator]()
125
+ const signal = params.signal
126
+ let onAbort: (() => void) | undefined
127
+ const aborted: Promise<never> | undefined = signal
128
+ ? new Promise<never>((_resolve, reject) => {
129
+ if (signal.aborted) {
130
+ reject(signal.reason)
131
+ return
132
+ }
133
+ onAbort = () => reject(signal.reason)
134
+ signal.addEventListener('abort', onAbort, { once: true })
135
+ })
136
+ : undefined
137
+
138
+ try {
139
+ for (;;) {
140
+ const next = it.next()
141
+ // Neutralize the dangling loser so an eventual rejection of the
142
+ // un-awaited `next` is never an unhandled rejection.
143
+ if (aborted) next.catch(() => {})
144
+ const res = await (aborted ? Promise.race([next, aborted]) : next)
145
+ if (res.done) break
146
+ const chunk = res.value
147
+ if (chunk.error) {
148
+ streamError = chunk.error
149
+ break
150
+ }
151
+ if (!id && chunk.id) id = chunk.id
152
+
153
+ if (chunk.delta.content) {
154
+ textBuf += chunk.delta.content
155
+ await emitEvent({
156
+ type: 'text_delta',
157
+ runId,
158
+ iteration,
159
+ messageId,
160
+ text: chunk.delta.content,
161
+ })
162
+ yield* drainPending()
163
+ }
164
+
165
+ for (const tc of chunk.delta.toolCalls ?? []) {
166
+ let bucket = toolBuckets.get(tc.index)
167
+ if (!bucket) {
168
+ bucket = {
169
+ id: tc.id ?? '',
170
+ name: tc.function?.name ?? '',
171
+ argsBuf: '',
172
+ started: false,
173
+ completed: false,
174
+ parsed: null,
175
+ inputTruncated: false,
176
+ }
177
+ toolBuckets.set(tc.index, bucket)
178
+ }
179
+ if (tc.id && !bucket.id) bucket.id = tc.id
180
+ if (tc.function?.name && !bucket.name) bucket.name = tc.function.name
181
+
182
+ if (!bucket.started && bucket.id && bucket.name) {
183
+ bucket.started = true
184
+ await emitEvent({
185
+ type: 'tool_input_started',
186
+ runId,
187
+ iteration,
188
+ messageId,
189
+ toolUseId: bucket.id as ToolUseId,
190
+ toolName: bucket.name,
191
+ })
192
+ yield* drainPending()
193
+ }
194
+
195
+ const fragment = tc.function?.arguments
196
+ if (fragment) {
197
+ if (!bucket.id) {
198
+ log.warn('tool_input_delta arrived before tool id was known; dropping fragment', {
199
+ runId,
200
+ index: tc.index,
201
+ length: fragment.length,
202
+ })
203
+ } else {
204
+ bucket.argsBuf += fragment
205
+ await emitEvent({
206
+ type: 'tool_input_delta',
207
+ runId,
208
+ toolUseId: bucket.id as ToolUseId,
209
+ partialJson: fragment,
210
+ })
211
+ yield* drainPending()
212
+ }
213
+ }
214
+ }
215
+
216
+ if (chunk.delta.toolCallEnd) {
217
+ const { index, id: endId } = chunk.delta.toolCallEnd
218
+ const bucket = toolBuckets.get(index)
219
+ if (bucket && !bucket.completed) {
220
+ bucket.completed = true
221
+ let parsed: unknown = {}
222
+ try {
223
+ parsed = bucket.argsBuf ? JSON.parse(bucket.argsBuf) : {}
224
+ } catch (err) {
225
+ bucket.inputTruncated = true
226
+ log.warn('tool input JSON parse failed at content_block_stop', {
227
+ runId,
228
+ toolUseId: endId,
229
+ error: err instanceof Error ? err.message : String(err),
230
+ })
231
+ }
232
+ bucket.parsed = parsed
233
+ await emitEvent({
234
+ type: 'tool_input_completed',
235
+ runId,
236
+ toolUseId: endId as ToolUseId,
237
+ input: parsed,
238
+ ...(bucket.inputTruncated ? { inputTruncated: true } : {}),
239
+ })
240
+ yield* drainPending()
241
+ }
242
+ }
243
+
244
+ if (chunk.finishReason) finishReason = chunk.finishReason
245
+ // Merge (per-field max), not last-write-wins: a late usage frame that
246
+ // omits input/cache tokens must not zero the counts seen earlier in the
247
+ // stream, which would under-report this turn's accumulated usage.
248
+ if (chunk.usage) usage = mergeTokenUsage(usage, chunk.usage)
249
+ }
250
+ } catch (err) {
251
+ // An abort tears the turn down: propagate it so the run loop settles the
252
+ // run as cancelled rather than recording a normal (errored) turn. Any
253
+ // other stream error is captured into the synthesized response as before.
254
+ if (signal?.aborted) throw err
255
+ streamError = err instanceof Error ? err.message : String(err)
256
+ } finally {
257
+ if (onAbort) signal?.removeEventListener('abort', onAbort)
258
+ // Release the underlying connection on every exit (natural end, error,
259
+ // or abort). `for await` did this implicitly on natural completion; the
260
+ // manual drive must do it explicitly. `.return()` on an already-finished
261
+ // provider generator is a no-op.
262
+ await it.return?.().catch(() => {})
263
+ }
264
+
265
+ // Flush any tool buckets the provider failed to close (no toolCallEnd
266
+ // arrived — defensive against providers that don't yet emit it, and
267
+ // the load-bearing path when the provider stream ends with
268
+ // `stop_reason: "max_tokens"` mid-`input_json_delta`. In that case
269
+ // Anthropic's SSE never sends `content_block_stop` for the open
270
+ // tool_use block: the upstream model ran out of completion tokens
271
+ // before it could close the JSON literal, so the buffered
272
+ // `argsBuf` ends with something like `"content":"…some prefix` —
273
+ // not parseable.
274
+ //
275
+ // Two cases coalesce here:
276
+ // 1. The buffer parses cleanly (the provider just forgot to emit
277
+ // `content_block_stop` but the args are intact) — keep parsed.
278
+ // 2. The buffer is truncated mid-literal — `parsed = {}` is the
279
+ // safe fallback so the executor's `JSON.parse(arguments)`
280
+ // succeeds and downstream consumers don't crash. The PRICE
281
+ // we used to pay was the model getting back a generic
282
+ // "<field> is required" Zod error and not realising its
283
+ // previous tool call was truncated server-side, so it would
284
+ // retry with the SAME long input and hit the same cutoff in
285
+ // a loop. Detect the truncation case and mark the tool call
286
+ // with runtime metadata; the executor surfaces a specific
287
+ // "your tool call was cut off by max_tokens — retry with
288
+ // shorter input or split into smaller calls" message that the
289
+ // model can act on.
290
+ for (const bucket of toolBuckets.values()) {
291
+ if (bucket.started && !bucket.completed) {
292
+ bucket.completed = true
293
+ let parsed: unknown = {}
294
+ let truncated = false
295
+ if (bucket.argsBuf) {
296
+ try {
297
+ parsed = JSON.parse(bucket.argsBuf)
298
+ } catch {
299
+ // argsBuf had content but didn't parse — almost
300
+ // certainly the max_tokens-mid-literal cutoff. Mark
301
+ // the bucket so the executor can return a model-
302
+ // readable hint instead of a generic Zod error.
303
+ truncated = true
304
+ parsed = {}
305
+ }
306
+ }
307
+ bucket.parsed = parsed
308
+ bucket.inputTruncated = truncated
309
+ if (truncated) {
310
+ log.warn('tool input truncated by upstream cutoff (no toolCallEnd, argsBuf unparsable)', {
311
+ runId,
312
+ toolUseId: bucket.id,
313
+ toolName: bucket.name,
314
+ bufferLength: bucket.argsBuf.length,
315
+ })
316
+ }
317
+ await emitEvent({
318
+ type: 'tool_input_completed',
319
+ runId,
320
+ toolUseId: bucket.id as ToolUseId,
321
+ input: parsed,
322
+ ...(truncated ? { inputTruncated: true } : {}),
323
+ })
324
+ yield* drainPending()
325
+ }
326
+ }
327
+
328
+ // `arguments` MUST be valid JSON for the executor's `JSON.parse`
329
+ // (`runtime/query/executor.ts:executeSingle`) to succeed. We
330
+ // always serialise from the bucket's `parsed` object (filled by
331
+ // either the `toolCallEnd` branch above or the post-stream flush
332
+ // loop) instead of re-emitting `argsBuf`. When the provider
333
+ // stream truncated mid-input, `metadata.inputTruncated` carries that
334
+ // state; the executor parses cleanly and returns a specific
335
+ // model-readable retry hint instead of the generic "Invalid JSON in
336
+ // tool arguments" intercept.
337
+ const toolCalls = [...toolBuckets.entries()]
338
+ .sort(([a], [b]) => a - b)
339
+ .map(([, b]) => ({
340
+ id: b.id,
341
+ type: 'function' as const,
342
+ function: {
343
+ name: b.name,
344
+ arguments: JSON.stringify(b.parsed ?? {}),
345
+ },
346
+ ...(b.inputTruncated ? { metadata: { inputTruncated: true } } : {}),
347
+ }))
348
+
349
+ const recoveredToolInputFromStreamError =
350
+ streamError !== undefined && toolCalls.some((tc) => tc.id && tc.function.name)
351
+ const effectiveFinishReason: ChatCompletionResponse['finishReason'] =
352
+ recoveredToolInputFromStreamError ? 'tool_calls' : finishReason
353
+
354
+ if (recoveredToolInputFromStreamError) {
355
+ log.warn('provider stream failed after tool input; surfacing tool call to executor', {
356
+ runId,
357
+ iteration,
358
+ error: streamError,
359
+ toolCallCount: toolCalls.length,
360
+ })
361
+ }
362
+
363
+ const stopReason: MessageStopReason = streamError
364
+ ? recoveredToolInputFromStreamError
365
+ ? 'tool_use'
366
+ : 'refusal'
367
+ : synthesizeMessageStopReason(effectiveFinishReason, forceFinalize)
368
+
369
+ await emitEvent({
370
+ type: 'message_completed',
371
+ runId,
372
+ iteration,
373
+ messageId,
374
+ stopReason,
375
+ usage,
376
+ content: textBuf || undefined,
377
+ })
378
+ yield* drainPending()
379
+
380
+ if (streamError && !recoveredToolInputFromStreamError) {
381
+ throw new Error(`Provider stream error: ${streamError}`)
382
+ }
383
+
384
+ const response: ChatCompletionResponse = {
385
+ id: id || messageId,
386
+ model: model || params.model,
387
+ message: {
388
+ role: 'assistant',
389
+ content: textBuf.length > 0 ? textBuf : null,
390
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
391
+ },
392
+ finishReason: effectiveFinishReason,
393
+ usage,
394
+ }
395
+ return { response, messageId }
396
+ }
@@ -1,8 +1,9 @@
1
1
  import { RunDiskStore } from '../../../store/run/disk.js'
2
2
  import type { RunId } from '../../../types/ids/index.js'
3
+ import type { CheckpointRunScope, CheckpointStore } from '../../../types/run/checkpoint-store.js'
3
4
  import type { CheckpointListEntry } from '../../../types/run/replay.js'
4
5
  import type { Logger } from '../../../utils/logger.js'
5
- import { CheckpointManager } from '../checkpoint.js'
6
+ import { toCheckpointListEntry } from '../checkpoint.js'
6
7
 
7
8
  export interface ListCheckpointsInput {
8
9
  /** Directory that contains `<runId>/` for the target run. */
@@ -10,6 +11,15 @@ export interface ListCheckpointsInput {
10
11
  /** Run whose checkpoints should be listed. */
11
12
  runId: RunId
12
13
  logger?: Logger
14
+ /**
15
+ * Optional store override (host-injected, e.g. Postgres). When set,
16
+ * `scope` is required — a scope-keyed backend cannot be addressed by
17
+ * `baseDir` — and `baseDir` is ignored. Absent ⇒ the disk layout under
18
+ * `baseDir` is read, exactly as before.
19
+ */
20
+ checkpointStore?: CheckpointStore
21
+ /** Run scope for `checkpointStore`. Required when it is set. */
22
+ scope?: CheckpointRunScope
13
23
  }
14
24
 
15
25
  /**
@@ -19,14 +29,38 @@ export interface ListCheckpointsInput {
19
29
  * pick a fork point — not the full `IterationCheckpoint` payload.
20
30
  *
21
31
  * Entries are returned in the order the underlying store returns them
22
- * (disk store sorts by `createdAt` ascending). Callers that want a
32
+ * (stores sort by `createdAt` ascending). Callers that want a
23
33
  * specific order should sort client-side.
24
34
  *
25
35
  * See ses_005-deterministic-replay/design.md §3.1.
26
36
  */
27
37
  export async function listCheckpoints(input: ListCheckpointsInput): Promise<CheckpointListEntry[]> {
38
+ const checkpoints = await listRawCheckpoints(input)
39
+ return checkpoints.map(toCheckpointListEntry)
40
+ }
41
+
42
+ async function listRawCheckpoints(input: ListCheckpointsInput) {
43
+ if (input.checkpointStore) {
44
+ return input.checkpointStore.listCheckpoints(requireScope(input.scope, 'listCheckpoints'))
45
+ }
28
46
  const store = new RunDiskStore({ baseDir: input.baseDir, logger: input.logger })
29
47
  await store.initRun(input.runId)
30
- const mgr = new CheckpointManager(store)
31
- return mgr.listEntries()
48
+ return store.listCheckpoints()
49
+ }
50
+
51
+ /**
52
+ * Guard shared by the replay entry points: an injected {@link
53
+ * CheckpointStore} is scope-keyed, so the caller must say which run —
54
+ * across the full five-layer attribution — it is asking about.
55
+ */
56
+ export function requireScope(
57
+ scope: CheckpointRunScope | undefined,
58
+ caller: string,
59
+ ): CheckpointRunScope {
60
+ if (!scope) {
61
+ throw new Error(
62
+ `${caller}: \`checkpointStore\` was provided without \`scope\` — an injected store is keyed by tenantId/projectId/sessionId/runId, not by baseDir.`,
63
+ )
64
+ }
65
+ return scope
32
66
  }
@@ -4,9 +4,11 @@ import { RunDiskStore } from '../../../store/run/disk.js'
4
4
  import type { CheckpointId, IterationCheckpoint } from '../../../types/hitl/index.js'
5
5
  import type { RunId } from '../../../types/ids/index.js'
6
6
  import type { Message } from '../../../types/message/index.js'
7
+ import type { CheckpointRunScope, CheckpointStore } from '../../../types/run/checkpoint-store.js'
7
8
  import type { Mutation, ReplayAttribution } from '../../../types/run/replay.js'
8
9
  import type { Logger } from '../../../utils/logger.js'
9
10
  import { projectEmergencyToCheckpoint } from '../checkpoint.js'
11
+ import { requireScope } from './list.js'
10
12
  import { applyMutations } from './mutate.js'
11
13
 
12
14
  export type CheckpointSelector = CheckpointId | 'latest' | 'emergency'
@@ -27,6 +29,15 @@ export interface PrepareReplayInput {
27
29
  */
28
30
  emergencyDir?: string
29
31
  logger?: Logger
32
+ /**
33
+ * Optional store override (host-injected, e.g. Postgres). When set,
34
+ * `scope` is required and `baseDir` is ignored for checkpoint reads.
35
+ * The `'emergency'` selector still reads the on-disk dump under
36
+ * `emergencyDir` — emergency saves are process-local by nature.
37
+ */
38
+ checkpointStore?: CheckpointStore
39
+ /** Run scope for `checkpointStore`. Required when it is set. */
40
+ scope?: CheckpointRunScope
30
41
  }
31
42
 
32
43
  export interface PreparedReplayState {
@@ -80,24 +91,46 @@ async function resolveCheckpoint(input: PrepareReplayInput): Promise<IterationCh
80
91
  if (input.fromCheckpoint === 'emergency') {
81
92
  return resolveEmergency(input)
82
93
  }
83
- const store = new RunDiskStore({ baseDir: input.baseDir, logger: input.logger })
84
- await store.initRun(input.runId)
94
+
95
+ const reader = await bindCheckpointReader(input)
85
96
 
86
97
  if (input.fromCheckpoint === 'latest') {
87
- const all = await store.listCheckpoints()
98
+ const all = await reader.list()
88
99
  if (all.length === 0) {
89
100
  throw new Error(`No checkpoints found for run ${input.runId} in ${input.baseDir}`)
90
101
  }
91
102
  return [...all].sort((a, b) => b.iteration - a.iteration)[0] as IterationCheckpoint
92
103
  }
93
104
 
94
- const checkpoint = await store.readCheckpoint(input.fromCheckpoint)
105
+ const checkpoint = await reader.read(input.fromCheckpoint)
95
106
  if (!checkpoint) {
96
107
  throw new Error(`Checkpoint ${input.fromCheckpoint} not found for run ${input.runId}`)
97
108
  }
98
109
  return checkpoint
99
110
  }
100
111
 
112
+ interface CheckpointReader {
113
+ list(): Promise<IterationCheckpoint[]>
114
+ read(id: CheckpointId): Promise<IterationCheckpoint | null>
115
+ }
116
+
117
+ async function bindCheckpointReader(input: PrepareReplayInput): Promise<CheckpointReader> {
118
+ if (input.checkpointStore) {
119
+ const store = input.checkpointStore
120
+ const scope = requireScope(input.scope, 'prepareReplayState')
121
+ return {
122
+ list: () => store.listCheckpoints(scope),
123
+ read: (id) => store.readCheckpoint(scope, id),
124
+ }
125
+ }
126
+ const store = new RunDiskStore({ baseDir: input.baseDir, logger: input.logger })
127
+ await store.initRun(input.runId)
128
+ return {
129
+ list: () => store.listCheckpoints(),
130
+ read: (id) => store.readCheckpoint(id),
131
+ }
132
+ }
133
+
101
134
  async function resolveEmergency(input: PrepareReplayInput): Promise<IterationCheckpoint> {
102
135
  if (!input.emergencyDir) {
103
136
  throw new Error(
@@ -2,6 +2,7 @@ export { InMemoryStore } from './InMemoryStore.js'
2
2
  export type { Identifiable, Timestamped } from './InMemoryStore.js'
3
3
 
4
4
  export { RunDiskStore } from './run/disk.js'
5
+ export { DiskCheckpointStore } from './run/checkpoint-disk.js'
5
6
 
6
7
  export { ActivityStore } from './activity/memory.js'
7
8
  export type { ActivityEvent, ActivityEventListener } from './activity/memory.js'
@@ -0,0 +1,67 @@
1
+ import type { CheckpointId, IterationCheckpoint } from '../../types/hitl/index.js'
2
+ import type { RunId } from '../../types/ids/index.js'
3
+ import type { CheckpointRunScope, CheckpointStore } from '../../types/run/checkpoint-store.js'
4
+ import type { RunStoreConfig } from '../../types/run/index.js'
5
+ import { RunDiskStore } from './disk.js'
6
+
7
+ /**
8
+ * Disk conformance layer for {@link CheckpointStore}: adapts the existing
9
+ * {@link RunDiskStore} checkpoint methods (which are bound to a single run
10
+ * directory via `initRun`) to the scope-keyed store contract.
11
+ *
12
+ * Path-addressed: `baseDir` already encodes project/session (it is the
13
+ * session's `runs/` directory), so only `scope.runId` / `scope.parentRunId`
14
+ * participate in directory resolution — `tenantId`/`projectId`/`sessionId`
15
+ * exist for backends that key by attribution instead of path.
16
+ *
17
+ * One `RunDiskStore` is bound (and its run directory created) per distinct
18
+ * `runId`, then cached, so repeated checkpoint operations against the same
19
+ * run don't re-run `initRun`.
20
+ */
21
+ export class DiskCheckpointStore implements CheckpointStore {
22
+ private readonly config: RunStoreConfig
23
+ private readonly bound = new Map<RunId, Promise<RunDiskStore>>()
24
+
25
+ constructor(config: RunStoreConfig) {
26
+ this.config = config
27
+ }
28
+
29
+ private bind(scope: CheckpointRunScope): Promise<RunDiskStore> {
30
+ const cached = this.bound.get(scope.runId)
31
+ if (cached) return cached
32
+ const promise = (async () => {
33
+ const store = new RunDiskStore(this.config)
34
+ await store.initRun(scope.runId, scope.parentRunId)
35
+ return store
36
+ })()
37
+ this.bound.set(scope.runId, promise)
38
+ // A failed bind must not poison the cache — the next call retries.
39
+ promise.catch(() => {
40
+ this.bound.delete(scope.runId)
41
+ })
42
+ return promise
43
+ }
44
+
45
+ async writeCheckpoint(scope: CheckpointRunScope, checkpoint: IterationCheckpoint): Promise<void> {
46
+ const store = await this.bind(scope)
47
+ await store.writeCheckpoint(checkpoint)
48
+ }
49
+
50
+ async readCheckpoint(
51
+ scope: CheckpointRunScope,
52
+ checkpointId: CheckpointId,
53
+ ): Promise<IterationCheckpoint | null> {
54
+ const store = await this.bind(scope)
55
+ return store.readCheckpoint(checkpointId)
56
+ }
57
+
58
+ async listCheckpoints(scope: CheckpointRunScope): Promise<IterationCheckpoint[]> {
59
+ const store = await this.bind(scope)
60
+ return store.listCheckpoints()
61
+ }
62
+
63
+ async deleteCheckpoint(scope: CheckpointRunScope, checkpointId: CheckpointId): Promise<void> {
64
+ const store = await this.bind(scope)
65
+ await store.deleteCheckpoint(checkpointId)
66
+ }
67
+ }