@namzu/sdk 23.0.0 → 26.0.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 (85) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/README.md +1 -1
  3. package/dist/agents/ReactiveAgent.d.ts.map +1 -1
  4. package/dist/agents/ReactiveAgent.js +3 -0
  5. package/dist/agents/ReactiveAgent.js.map +1 -1
  6. package/dist/connector/mcp/stdio.d.ts +11 -0
  7. package/dist/connector/mcp/stdio.d.ts.map +1 -1
  8. package/dist/connector/mcp/stdio.js +106 -1
  9. package/dist/connector/mcp/stdio.js.map +1 -1
  10. package/dist/constants/telemetry/index.d.ts +15 -1
  11. package/dist/constants/telemetry/index.d.ts.map +1 -1
  12. package/dist/constants/telemetry/index.js +15 -1
  13. package/dist/constants/telemetry/index.js.map +1 -1
  14. package/dist/provider/fallback.d.ts.map +1 -1
  15. package/dist/provider/fallback.js +8 -2
  16. package/dist/provider/fallback.js.map +1 -1
  17. package/dist/provider/retry.d.ts +10 -5
  18. package/dist/provider/retry.d.ts.map +1 -1
  19. package/dist/provider/retry.js +11 -31
  20. package/dist/provider/retry.js.map +1 -1
  21. package/dist/public-runtime.d.ts +1 -0
  22. package/dist/public-runtime.d.ts.map +1 -1
  23. package/dist/public-runtime.js.map +1 -1
  24. package/dist/registry/tool/execute.d.ts.map +1 -1
  25. package/dist/registry/tool/execute.js +12 -0
  26. package/dist/registry/tool/execute.js.map +1 -1
  27. package/dist/runtime/query/executor.d.ts +34 -0
  28. package/dist/runtime/query/executor.d.ts.map +1 -1
  29. package/dist/runtime/query/executor.js +61 -0
  30. package/dist/runtime/query/executor.js.map +1 -1
  31. package/dist/runtime/query/index.d.ts +31 -13
  32. package/dist/runtime/query/index.d.ts.map +1 -1
  33. package/dist/runtime/query/index.js +28 -6
  34. package/dist/runtime/query/index.js.map +1 -1
  35. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  36. package/dist/runtime/query/iteration/index.js +178 -20
  37. package/dist/runtime/query/iteration/index.js.map +1 -1
  38. package/dist/runtime/query/iteration/stream-turn.d.ts +14 -1
  39. package/dist/runtime/query/iteration/stream-turn.d.ts.map +1 -1
  40. package/dist/runtime/query/iteration/stream-turn.js +19 -3
  41. package/dist/runtime/query/iteration/stream-turn.js.map +1 -1
  42. package/dist/runtime/query/resume-pending.d.ts.map +1 -1
  43. package/dist/runtime/query/resume-pending.js +10 -1
  44. package/dist/runtime/query/resume-pending.js.map +1 -1
  45. package/dist/runtime/query/tool-pause.d.ts +35 -0
  46. package/dist/runtime/query/tool-pause.d.ts.map +1 -1
  47. package/dist/runtime/query/tool-pause.js +35 -0
  48. package/dist/runtime/query/tool-pause.js.map +1 -1
  49. package/dist/runtime/query/tooling.d.ts +2 -0
  50. package/dist/runtime/query/tooling.d.ts.map +1 -1
  51. package/dist/runtime/query/tooling.js +3 -0
  52. package/dist/runtime/query/tooling.js.map +1 -1
  53. package/dist/types/agent/reactive.d.ts +1 -0
  54. package/dist/types/agent/reactive.d.ts.map +1 -1
  55. package/dist/types/connector/mcp.d.ts +17 -0
  56. package/dist/types/connector/mcp.d.ts.map +1 -1
  57. package/dist/types/provider/interface.d.ts +23 -2
  58. package/dist/types/provider/interface.d.ts.map +1 -1
  59. package/dist/types/run/step.d.ts +75 -3
  60. package/dist/types/run/step.d.ts.map +1 -1
  61. package/dist/types/run/step.js.map +1 -1
  62. package/dist/utils/backoff.d.ts +44 -0
  63. package/dist/utils/backoff.d.ts.map +1 -0
  64. package/dist/utils/backoff.js +58 -0
  65. package/dist/utils/backoff.js.map +1 -0
  66. package/package.json +1 -1
  67. package/src/agents/ReactiveAgent.ts +3 -0
  68. package/src/connector/mcp/stdio.ts +106 -1
  69. package/src/constants/telemetry/index.ts +16 -1
  70. package/src/provider/fallback.ts +8 -2
  71. package/src/provider/retry.ts +20 -37
  72. package/src/public-runtime.ts +5 -0
  73. package/src/registry/tool/execute.ts +12 -0
  74. package/src/runtime/query/executor.ts +74 -0
  75. package/src/runtime/query/index.ts +59 -18
  76. package/src/runtime/query/iteration/index.ts +199 -22
  77. package/src/runtime/query/iteration/stream-turn.ts +24 -3
  78. package/src/runtime/query/resume-pending.ts +10 -1
  79. package/src/runtime/query/tool-pause.ts +37 -0
  80. package/src/runtime/query/tooling.ts +5 -0
  81. package/src/types/agent/reactive.ts +1 -0
  82. package/src/types/connector/mcp.ts +17 -0
  83. package/src/types/provider/interface.ts +23 -2
  84. package/src/types/run/step.ts +76 -3
  85. package/src/utils/backoff.ts +70 -0
@@ -32,6 +32,7 @@ import type { AnswerReview } from '../../../types/run/answer-review.js'
32
32
  import type {
33
33
  PrepareStepResult,
34
34
  RunEvent,
35
+ StepFailure,
35
36
  StepProvenance,
36
37
  StepResult,
37
38
  StopReason,
@@ -258,6 +259,30 @@ export class IterationOrchestrator {
258
259
  {},
259
260
  parentContext(this.ctx.rootSpan),
260
261
  )
262
+
263
+ // Everything the step record needs, hoisted so the `catch` can
264
+ // read whatever the iteration got as far as computing.
265
+ //
266
+ // The failure path is the one the ledger's own argument was
267
+ // written for and the one it never reached: an iteration that
268
+ // threw recorded a span exception and re-threw, so the turn with
269
+ // no record was exactly the turn that went wrong. A reader could
270
+ // not tell that from a turn that never happened.
271
+ //
272
+ // Declared as `let` with real initial values rather than left
273
+ // undefined, because a failure BEFORE the snapshot below is
274
+ // taken has spent nothing, and these are then exact. The success
275
+ // path is untouched: the assignments inside the try still happen
276
+ // where they always did, so compaction and the working-memory
277
+ // refresh stay outside a successful step's window.
278
+ let stepStartedAt = Date.now()
279
+ let usageBefore: TokenUsage = { ...runMgr.tokenUsage }
280
+ let costBefore: CostInfo = { ...runMgr.costInfo }
281
+ let stepModel = model
282
+ let stepMessageId: MessageId | undefined
283
+ let stepResponse: ChatCompletionResponse | undefined
284
+ let stepServedBy: StepProvenance | undefined
285
+
261
286
  try {
262
287
  // Tool spans for this turn belong under this iteration. Inside
263
288
  // the try rather than before it: a throw from any of these left
@@ -303,9 +328,9 @@ export class IterationOrchestrator {
303
328
  // tool_use/tool_result blocks.
304
329
  // Snapshot the cumulative counters so the step can report ITS
305
330
  // own usage rather than the run total.
306
- const stepStartedAt = Date.now()
307
- const usageBefore = { ...runMgr.tokenUsage }
308
- const costBefore = { ...runMgr.costInfo }
331
+ stepStartedAt = Date.now()
332
+ usageBefore = { ...runMgr.tokenUsage }
333
+ costBefore = { ...runMgr.costInfo }
309
334
 
310
335
  // Shape this step before calling the model. `stopWhen` decides
311
336
  // whether to keep going; this decides HOW. No-op when the host
@@ -320,7 +345,7 @@ export class IterationOrchestrator {
320
345
  // still call any of them by name.
321
346
  this.ctx.toolExecutor.setStepAllowedTools(stepAllowedTools)
322
347
  const enforceToolInputSchema = enforcedModelInputToolNames(this.ctx.tools, llmTools)
323
- const stepModel = step.model ?? model
348
+ stepModel = step.model ?? model
324
349
 
325
350
  const baseMessages = forceFinalize
326
351
  ? [
@@ -389,6 +414,16 @@ export class IterationOrchestrator {
389
414
  // aggregated `ChatCompletionResponse` for the legacy
390
415
  // downstream paths (assistantMsg construction, working
391
416
  // state extraction, telemetry attribute stamping).
417
+ //
418
+ // The message id is minted HERE, immediately before the call
419
+ // that announces it,
420
+ // rather than inside that call. The return value never arrives
421
+ // when the stream throws, so a step recorded from the catch
422
+ // could otherwise never name the message — and a stream that
423
+ // died part-way has already emitted both `message_started` and
424
+ // `message_completed` under this id, which is the trail a
425
+ // reader wants most on exactly that turn.
426
+ stepMessageId = generateMessageId()
392
427
  const { response, messageId } = yield* streamProviderTurn(
393
428
  this.ctx.provider,
394
429
  {
@@ -424,7 +459,9 @@ export class IterationOrchestrator {
424
459
  forceFinalize,
425
460
  this.ctx.log,
426
461
  iterSpan,
462
+ stepMessageId,
427
463
  )
464
+ stepResponse = response
428
465
 
429
466
  // Who answered THIS turn.
430
467
  //
@@ -459,6 +496,7 @@ export class IterationOrchestrator {
459
496
  chainIndex: member.index,
460
497
  }
461
498
  })()
499
+ stepServedBy = servedBy
462
500
 
463
501
  // Main-loop turn: also records the prompt size compaction reads.
464
502
  //
@@ -1014,6 +1052,76 @@ export class IterationOrchestrator {
1014
1052
  })
1015
1053
  yield* this.ctx.drainPending()
1016
1054
  } catch (err) {
1055
+ const cancelled = this.ctx.abortController.signal.aborted
1056
+
1057
+ // This iteration gets a step too, and it is the one the
1058
+ // argument three hundred lines above was actually about.
1059
+ //
1060
+ // That docblock makes the case for a rejected tool batch — "a
1061
+ // run that spent a turn getting its tools refused still spent
1062
+ // the tokens" — and every call site it produced sat on a
1063
+ // success path. So the ledger was complete except on the turns
1064
+ // that failed, which is the worst shape it could have: an
1065
+ // evidence record that goes quiet exactly where something went
1066
+ // wrong reads as "nothing went wrong". A reader could not
1067
+ // distinguish iteration N failing from iteration N never
1068
+ // happening, while the events said plainly that it started.
1069
+ //
1070
+ // Recorded HERE, at the top of the catch, rather than at each
1071
+ // of its exits — the same reasoning the success path already
1072
+ // wrote down for itself. All three exits spend a turn: the
1073
+ // cancellation breaks, the overflow-relief retry continues
1074
+ // under a NEW iteration number (so its tokens belong to no
1075
+ // later step), and the re-throw ends the run.
1076
+ //
1077
+ // What it carries is what the iteration got as far as knowing.
1078
+ // `usage` is the same subtraction a successful step makes, so
1079
+ // a turn that failed after the provider answered carries that
1080
+ // answer's tokens, and one that failed before it carries the
1081
+ // zero it actually spent. Nothing is estimated to fill a gap.
1082
+ //
1083
+ // At most ONE step per iteration. Both success paths record
1084
+ // before the work that follows them — the advisory phase, the
1085
+ // structured-output capture, the `iteration_end` hooks, the
1086
+ // terminal `iteration_completed` — and any of those can throw
1087
+ // into here. A second entry numbered N would double-count that
1088
+ // turn's tokens against `run.tokenUsage`, which is the same
1089
+ // class of wrong as dropping them and harder to notice, since
1090
+ // the ledger would look fuller rather than emptier. That turn's
1091
+ // own verdict is already written down; the failure that
1092
+ // followed it reaches the caller as the run's error.
1093
+ if (this.steps.at(-1)?.stepNumber === iterationNum) {
1094
+ this.ctx.log.warn('Iteration failed after its step was already recorded', {
1095
+ runId: runMgr.id,
1096
+ iteration: iterationNum,
1097
+ error: toErrorMessage(err),
1098
+ })
1099
+ } else {
1100
+ this.recordStep({
1101
+ stepNumber: iterationNum,
1102
+ model: stepModel,
1103
+ ...(stepServedBy ? { servedBy: stepServedBy } : {}),
1104
+ ...(stepMessageId ? { messageId: stepMessageId } : {}),
1105
+ ...(stepResponse ? { response: stepResponse } : {}),
1106
+ // Tool outcomes are produced and returned together by
1107
+ // `runToolReview`, so a throw from inside it leaves none
1108
+ // to salvage: an empty list here means "none came back",
1109
+ // which is what the shorter-than-`toolCalls` contract
1110
+ // says.
1111
+ toolResults: [],
1112
+ toolExecutionMs: 0,
1113
+ startedAt: stepStartedAt,
1114
+ usageBefore,
1115
+ costBefore,
1116
+ unfinished: cancelled
1117
+ ? { finishReason: 'cancelled' }
1118
+ : {
1119
+ finishReason: 'error',
1120
+ failure: describeStepFailure(err, this.ctx.provider.id),
1121
+ },
1122
+ })
1123
+ }
1124
+
1017
1125
  // A Stop that aborted the in-flight turn surfaces here as a
1018
1126
  // thrown abort (the provider stream was raced against the run
1019
1127
  // signal). Settle it as a CANCELLATION — mirroring the
@@ -1021,7 +1129,7 @@ export class IterationOrchestrator {
1021
1129
  // recording it as an SDK failure (error span + failed activity)
1022
1130
  // and re-throwing. The run then returns cleanly with a
1023
1131
  // 'cancelled' stop reason instead of propagating an error.
1024
- if (this.ctx.abortController.signal.aborted) {
1132
+ if (cancelled) {
1025
1133
  runMgr.setStopReason('cancelled')
1026
1134
  runMgr.markCancelled()
1027
1135
  break
@@ -1308,39 +1416,69 @@ export class IterationOrchestrator {
1308
1416
  private recordStep(input: {
1309
1417
  stepNumber: number
1310
1418
  model: string
1311
- servedBy: StepProvenance
1312
- messageId: MessageId
1313
- response: ChatCompletionResponse
1419
+ servedBy?: StepProvenance
1420
+ messageId?: MessageId
1421
+ /**
1422
+ * The turn's response. Absent only when the iteration failed before
1423
+ * the provider produced one — see `unfinished`.
1424
+ */
1425
+ response?: ChatCompletionResponse
1314
1426
  toolResults: readonly ToolCallOutcome[]
1315
1427
  toolExecutionMs: number
1316
1428
  startedAt: number
1317
1429
  usageBefore: TokenUsage
1318
1430
  costBefore: CostInfo
1431
+ /**
1432
+ * Set only by the `catch`, for an iteration that did not finish.
1433
+ *
1434
+ * The same writer builds both records on purpose: a failed turn's
1435
+ * step is a `StepResult` like any other, so a caller reconstructing
1436
+ * cost or history sorts them together instead of discovering that
1437
+ * failures live somewhere else.
1438
+ */
1439
+ unfinished?: { finishReason: 'error' | 'cancelled'; failure?: StepFailure }
1319
1440
  }): void {
1320
1441
  const { runMgr } = this.ctx
1321
- const toolCalls = input.response.message.toolCalls ?? []
1442
+ const toolCalls = input.response?.message.toolCalls ?? []
1322
1443
  const byId = new Map(input.toolResults.map((r) => [r.toolCallId, r]))
1323
1444
 
1324
1445
  const step: StepResult = {
1325
1446
  stepNumber: input.stepNumber,
1326
1447
  model: input.model,
1327
- servedBy: input.servedBy,
1328
- messageId: input.messageId,
1329
- content: input.response.message.content,
1448
+ ...(input.servedBy ? { servedBy: input.servedBy } : {}),
1449
+ ...(input.messageId ? { messageId: input.messageId } : {}),
1450
+ content: input.response?.message.content ?? null,
1330
1451
  toolCalls,
1331
1452
  // Ordered by the tool CALLS, not by completion, so the record
1332
1453
  // matches what the model asked for.
1333
- toolResults: toolCalls.map((tc) => {
1454
+ //
1455
+ // On an unfinished step the calls with no outcome are DROPPED
1456
+ // rather than filled with `{output: '', isError: false}`. That
1457
+ // filler is a reading of "the batch was refused" on the success
1458
+ // path, where every call in a batch shares one verdict; under a
1459
+ // step that says `error` it would say a tool ran and returned
1460
+ // nothing successfully, which is the same lie one level down as
1461
+ // the missing step itself.
1462
+ toolResults: toolCalls.flatMap((tc) => {
1334
1463
  const outcome = byId.get(tc.id)
1335
- return {
1336
- toolCallId: tc.id,
1337
- toolName: tc.function.name,
1338
- output: outcome?.output ?? '',
1339
- isError: outcome?.isError ?? false,
1340
- durationMs: 0,
1341
- }
1464
+ if (input.unfinished && !outcome) return []
1465
+ return [
1466
+ {
1467
+ toolCallId: tc.id,
1468
+ toolName: tc.function.name,
1469
+ output: outcome?.output ?? '',
1470
+ isError: outcome?.isError ?? false,
1471
+ durationMs: 0,
1472
+ },
1473
+ ]
1342
1474
  }),
1343
- finishReason: input.response.finishReason,
1475
+ // The turn's own verdict where there is one. A step that ended in
1476
+ // the catch has none — no provider reported `error` or
1477
+ // `cancelled` — so `unfinished` wins even when a response had
1478
+ // already arrived: a turn that answered and then threw during
1479
+ // tool execution did not end in `tool_calls`.
1480
+ finishReason: input.unfinished?.finishReason ?? input.response?.finishReason ?? 'error',
1481
+ ...(input.unfinished?.failure ? { failure: input.unfinished.failure } : {}),
1344
1482
  usage: subtractUsage(runMgr.tokenUsage, input.usageBefore),
1345
1483
  costDelta: {
1346
1484
  ...runMgr.costInfo,
@@ -1352,7 +1490,26 @@ export class IterationOrchestrator {
1352
1490
  }
1353
1491
 
1354
1492
  this.steps.push(step)
1355
- this.ctx.onStepFinish?.(step)
1493
+
1494
+ if (!input.unfinished) {
1495
+ this.ctx.onStepFinish?.(step)
1496
+ return
1497
+ }
1498
+
1499
+ // Nothing here is allowed to throw over the failure that is already
1500
+ // unwinding — the same rule `settleCancelledTurn` states for the
1501
+ // cancellation path. A host callback that throws while being told a
1502
+ // turn failed would REPLACE the reason the turn failed, so the run
1503
+ // would report the observer's bug and lose the original.
1504
+ try {
1505
+ this.ctx.onStepFinish?.(step)
1506
+ } catch (err) {
1507
+ this.ctx.log.warn('onStepFinish threw while recording a failed step', {
1508
+ runId: runMgr.id,
1509
+ step: input.stepNumber,
1510
+ error: toErrorMessage(err),
1511
+ })
1512
+ }
1356
1513
  }
1357
1514
 
1358
1515
  /** Turns spent asking the model again for a valid structured output. */
@@ -1621,6 +1778,26 @@ export class IterationOrchestrator {
1621
1778
  }
1622
1779
  }
1623
1780
 
1781
+ /**
1782
+ * Fold whatever ended an iteration into the record a reader gets.
1783
+ *
1784
+ * Classified through `classifyProviderError` — the same call the catch
1785
+ * already makes to decide whether compaction relief applies — so the step's
1786
+ * verdict and the loop's own decision cannot drift apart. It also handles a
1787
+ * failure that is not a provider failure at all: the code set's `unknown`
1788
+ * means "unclassifiable", which is the true answer for a plugin hook that
1789
+ * threw and is left saying so rather than dressed up as something specific.
1790
+ */
1791
+ function describeStepFailure(err: unknown, providerId: string): StepFailure {
1792
+ const classified = classifyProviderError(err, providerId)
1793
+ return {
1794
+ message: toErrorMessage(err),
1795
+ code: classified.code,
1796
+ ...(classified.status !== undefined ? { status: classified.status } : {}),
1797
+ retryable: classified.retryable,
1798
+ }
1799
+ }
1800
+
1624
1801
  /** Per-step usage: the delta between two cumulative snapshots. */
1625
1802
  function subtractUsage(after: TokenUsage, before: TokenUsage): TokenUsage {
1626
1803
  return {
@@ -134,6 +134,19 @@ export async function* streamProviderTurn(
134
134
  forceFinalize: boolean,
135
135
  log: Logger,
136
136
  parentSpan?: Span,
137
+ /**
138
+ * The id to announce this message under.
139
+ *
140
+ * Supplied by the loop so a turn that THROWS still leaves the caller
141
+ * holding the id it announced. The return value never arrives on a
142
+ * failure, so without this the one case where a failed step most wants
143
+ * to point at the event stream — a stream that died after
144
+ * `message_started`, having already emitted `message_completed` on the
145
+ * way out — is precisely the case that could not.
146
+ *
147
+ * Optional, so a caller with no use for the id is unchanged.
148
+ */
149
+ announceAs?: import('../../../types/ids/index.js').MessageId,
137
150
  ): AsyncGenerator<RunEvent, StreamingTurnResult> {
138
151
  // The `chat {model}` span the GenAI conventions require. There was none:
139
152
  // `chatSpanName` existed with zero call sites, so a trace carried no LLM
@@ -151,7 +164,7 @@ export async function* streamProviderTurn(
151
164
  ...(params.maxTokens !== undefined ? { [GENAI.REQUEST_MAX_TOKENS]: params.maxTokens } : {}),
152
165
  })
153
166
 
154
- const messageId = generateMessageId()
167
+ const messageId = announceAs ?? generateMessageId()
155
168
  await emitEvent({ type: 'message_started', runId, iteration, messageId })
156
169
  yield* drainPending()
157
170
 
@@ -194,7 +207,12 @@ export async function* streamProviderTurn(
194
207
  // verbatim — so the map is drained in index order at the end.
195
208
  const reasoningBuckets = new Map<
196
209
  number,
197
- { type: 'thinking' | 'redacted_thinking'; text: string; signature?: string; encrypted?: string }
210
+ {
211
+ type: 'thinking' | 'redacted_thinking'
212
+ text: string
213
+ signature?: string
214
+ encrypted?: string
215
+ }
198
216
  >()
199
217
 
200
218
  // Citations arrive as their own deltas, in the order the model made
@@ -205,7 +223,10 @@ export async function* streamProviderTurn(
205
223
  let streamError: string | undefined
206
224
  let streamCause: unknown
207
225
 
208
- const stream = provider.chatStream({ ...params, stream: true }) as AsyncIterable<StreamChunk>
226
+ const stream = provider.chatStream({
227
+ ...params,
228
+ stream: true,
229
+ }) as AsyncIterable<StreamChunk>
209
230
 
210
231
  // Drive the stream manually so each `.next()` can be RACED against the run
211
232
  // abort: a Stop tears the in-flight model request down (the provider got
@@ -9,6 +9,7 @@ import type { ChatCompletionResponse } from '../../types/provider/index.js'
9
9
  import type { Logger } from '../../utils/logger.js'
10
10
  import type { PriorToolResults, ToolCallDenials, ToolExecutor } from './executor.js'
11
11
  import { PendingAnswers } from './question-park.js'
12
+ import { isPauseForCall } from './tool-pause.js'
12
13
 
13
14
  /**
14
15
  * Apply a decision collected out-of-band to the tool calls a run parked on.
@@ -154,7 +155,15 @@ function planQuestionResume(
154
155
  // otherwise have its answer delivered to whatever tool now holds that
155
156
  // slot — the misdirection the asking tool's own id guard exists to
156
157
  // prevent, checked here too because by then the tool has been entered.
157
- if (!assistant.toolCalls.some((tc) => tc.id === questionId)) {
158
+ //
159
+ // Through `isPauseForCall` rather than by equality, because a parked
160
+ // question id is not a call id. The general seam appends the tool
161
+ // author's pause name to it, so equality compared
162
+ // `call_1:target_environment` against `call_1`, could never hold, and
163
+ // refused every cross-process resume of a host-authored pause. Only
164
+ // the built-in question tool got through, and only because it parks
165
+ // under the bare tool-use id.
166
+ if (!assistant.toolCalls.some((tc) => isPauseForCall(questionId, tc.id))) {
158
167
  log.error('The parked question does not belong to any unanswered call in this turn', {
159
168
  checkpointId: checkpoint.id,
160
169
  questionId,
@@ -32,6 +32,43 @@ import type { PendingAnswers, QuestionParkRecorder } from './question-park.js'
32
32
  */
33
33
  export const pauseId = (toolUseId: string, name: string): string => `${toolUseId}:${name}`
34
34
 
35
+ /**
36
+ * Does this pause id belong to `callId`?
37
+ *
38
+ * The other half of the scheme above, and it lives beside the mint rather
39
+ * than at the gate that asks it, so the two cannot drift apart again. They
40
+ * already had: the resume gate compared the whole parked id against a raw
41
+ * tool-use id, which a composite can never equal, so a pause raised
42
+ * through {@link createToolPause} was refused by every cross-process
43
+ * resume — while the built-in question tool, whose `questionId` IS the raw
44
+ * id, passed the same gate and worked.
45
+ *
46
+ * Asked against an id actually present in the turn rather than by
47
+ * splitting the composite on `:`. The left half is the provider's tool-use
48
+ * id and nothing forbids a colon in it, so a split takes `call:9:confirm`
49
+ * apart at the wrong place and then compares against `call` — a string no
50
+ * provider minted. Testing a `<callId>:` prefix asks about candidates that
51
+ * exist instead.
52
+ *
53
+ * It is not exact, and the inexactness is bounded rather than absent: a
54
+ * call whose id is literally `call` matches a pause raised on `call:9`.
55
+ * That costs nothing while the real call is also in the turn, because the
56
+ * pause does belong to a call there; and when it is not, the answer is
57
+ * filed under a key no tool asks for, so the tool asks again. A resume
58
+ * that re-asks, never one that misdelivers — routing below is still an
59
+ * exact key match.
60
+ *
61
+ * Membership only — is there a call in this turn that this pause was
62
+ * raised from. The full composite still ROUTES the answer, by exact key in
63
+ * `PendingAnswers`, which is what keeps a call that pauses twice from
64
+ * delivering its second answer against its first question.
65
+ *
66
+ * Every sentence above that names a condition and an outcome is pinned in
67
+ * `__tests__/tool-pause.test.ts`, under "the id a resume gate matches on".
68
+ */
69
+ export const isPauseForCall = (pause: string, callId: string): boolean =>
70
+ pause === callId || pause.startsWith(`${callId}:`)
71
+
35
72
  interface ToolPauseDeps {
36
73
  readonly runId: RunId
37
74
  readonly toolUseId: string
@@ -6,6 +6,7 @@ import type { PermissionMode } from '../../types/permission/index.js'
6
6
  import type { RunEvent } from '../../types/run/index.js'
7
7
  import type { RequestToolPause, ToolRegistryContract } from '../../types/tool/index.js'
8
8
  import type { RepairToolCall } from '../../types/tool/repair.js'
9
+ import type { BackoffPolicy } from '../../utils/backoff.js'
9
10
  import type { Logger } from '../../utils/logger.js'
10
11
  import { ToolExecutor } from './executor.js'
11
12
 
@@ -22,6 +23,7 @@ export interface ToolingBootstrapConfig {
22
23
  invocationState?: InvocationState
23
24
  pluginManager?: PluginLifecycleManager
24
25
  toolTimeoutMs?: number
26
+ toolRetryBackoff?: Partial<BackoffPolicy>
25
27
  maxToolConcurrency?: number
26
28
  maxToolOutputChars?: number
27
29
  maxToolContentBytes?: number
@@ -50,6 +52,9 @@ export class ToolingBootstrap {
50
52
  invocationState: config.invocationState,
51
53
  pluginManager: config.pluginManager,
52
54
  ...(config.toolTimeoutMs !== undefined ? { toolTimeoutMs: config.toolTimeoutMs } : {}),
55
+ ...(config.toolRetryBackoff !== undefined
56
+ ? { toolRetryBackoff: config.toolRetryBackoff }
57
+ : {}),
53
58
  ...(config.maxToolConcurrency !== undefined
54
59
  ? { maxToolConcurrency: config.maxToolConcurrency }
55
60
  : {}),
@@ -86,6 +86,7 @@ export interface ReactiveAgentConfig extends BaseAgentConfig {
86
86
  retry?: QueryParams['retry']
87
87
  emergencySave?: boolean
88
88
  toolTimeoutMs?: number
89
+ toolRetryBackoff?: QueryParams['toolRetryBackoff']
89
90
  maxToolConcurrency?: number
90
91
  maxToolOutputChars?: number
91
92
  /**
@@ -18,7 +18,24 @@ export interface MCPStdioTransportConfig extends MCPTransportConfigBase {
18
18
  type: 'stdio'
19
19
  command: string
20
20
  args?: string[]
21
+ /** Literal values for the child. Highest precedence. */
21
22
  env?: Record<string, string>
23
+ /**
24
+ * Parent variables the child may have, named one at a time.
25
+ *
26
+ * The spawn used to pass the whole parent environment, so a server that
27
+ * needed one token received every credential the host held. It now gets
28
+ * process plumbing plus what is named here and in `env`, which is what
29
+ * makes the grant reviewable: the config says which secrets cross the
30
+ * boundary instead of the answer being "all of them".
31
+ *
32
+ * Use this rather than `env` for a credential — `env` puts the value in the
33
+ * config file, and this keeps it in the environment where it already lives.
34
+ *
35
+ * A name the parent does not hold is absent from the child rather than
36
+ * empty, and does not fail the spawn.
37
+ */
38
+ inheritEnv?: readonly string[]
22
39
  cwd?: string
23
40
  }
24
41
 
@@ -73,7 +73,23 @@ export interface LLMProvider {
73
73
  */
74
74
  probeCredential?(): Promise<void>
75
75
 
76
- healthCheck?(): Promise<boolean>
76
+ /**
77
+ * Is this driver able to serve traffic? A summary bit; `doctorCheck` is
78
+ * the same probe with its reasoning intact.
79
+ *
80
+ * `model` is the model the CALLER intends to run, and it is a parameter
81
+ * rather than something the driver reads off its own config because at
82
+ * least one driver's config does not carry a model at all. That driver
83
+ * hardcoded an id instead, which is how its check came to probe a model
84
+ * nobody used — and, once the id went stale, could not pass at any
85
+ * credential, region or service state. A health check against a model the
86
+ * operator does not run tests the wrong thing even while the id is valid.
87
+ *
88
+ * Optional, and a driver may ignore it: one that probes an endpoint rather
89
+ * than a model has nothing to do with the argument. Passing it is always
90
+ * safe.
91
+ */
92
+ healthCheck?(model?: string): Promise<boolean>
77
93
 
78
94
  /**
79
95
  * Optional structured health probe used by `runDoctor()`.
@@ -82,8 +98,13 @@ export interface LLMProvider {
82
98
  * (latency, model availability, auth status, …). Providers that
83
99
  * cannot be cheaply probed should return `{ status: 'inconclusive' }`
84
100
  * so the doctor doesn't mark them as failing — see ses_007 Q6.4.
101
+ *
102
+ * Takes `model` for the reason `healthCheck` does, and a driver is free to
103
+ * return a SUBTYPE of `DoctorCheckResult` carrying its own machine-readable
104
+ * detail. `status` is what `runDoctor()` reads; a caller holding the
105
+ * concrete driver can read more.
85
106
  */
86
- doctorCheck?(): Promise<DoctorCheckResult>
107
+ doctorCheck?(model?: string): Promise<DoctorCheckResult>
87
108
 
88
109
  /**
89
110
  * Which {@link ChatCompletionParams.effort} levels this model accepts,
@@ -1,6 +1,7 @@
1
1
  import type { CostInfo, TokenUsage } from '../common/index.js'
2
2
  import type { MessageId } from '../ids/index.js'
3
3
  import type { ToolCall } from '../message/index.js'
4
+ import type { ProviderErrorCode } from '../provider/errors.js'
4
5
 
5
6
  /**
6
7
  * What one iteration of the agent loop did.
@@ -55,13 +56,56 @@ export interface StepResult {
55
56
  * it is handed.
56
57
  */
57
58
  servedBy?: StepProvenance
58
- messageId: MessageId
59
+ /**
60
+ * The assistant message this step produced.
61
+ *
62
+ * Absent only on a step whose iteration ended before the model's message
63
+ * was announced — a compaction failure, a lifecycle hook that threw, a
64
+ * transport error raised before the first chunk. Absence is left meaning
65
+ * "there was no message" rather than filled with an id the event stream
66
+ * never carried: a `messageId` a reader cannot find a `message_started`
67
+ * for is worse evidence than no id at all, because it invites the
68
+ * correlation and then loses it.
69
+ *
70
+ * Present, and correlatable, on every step whose iteration got as far as
71
+ * the provider call — including one that failed mid-stream. The loop
72
+ * mints the id before the call that announces it, and a dying stream
73
+ * emits `message_completed` on its way out, so the events carry both
74
+ * ends of the message a failed step points at.
75
+ */
76
+ messageId?: MessageId
59
77
  /** Assistant text for this step, if any. */
60
78
  content: string | null
61
79
  toolCalls: readonly ToolCall[]
62
- /** Tool outcomes, in the same order as `toolCalls`. */
80
+ /**
81
+ * Tool outcomes, in the same order as `toolCalls`.
82
+ *
83
+ * Shorter than `toolCalls` on a step that ended in failure: only the
84
+ * outcomes that exist are recorded, so a call with no entry here reads
85
+ * as "never came back" rather than as an empty success. Pair by
86
+ * `toolCallId`, not by index.
87
+ */
63
88
  toolResults: readonly StepToolResult[]
64
- finishReason: 'stop' | 'tool_calls' | 'length' | 'content_filter'
89
+ /**
90
+ * How the turn ended.
91
+ *
92
+ * `error` and `cancelled` are not provider verdicts — no provider reports
93
+ * them — but a step exists for a failed iteration too, and it has to say
94
+ * how it ended in the same field a reader already sorts by. `error` comes
95
+ * with {@link failure}; `cancelled` means a Stop tore the turn down and
96
+ * there is no failure to report.
97
+ */
98
+ finishReason: 'stop' | 'tool_calls' | 'length' | 'content_filter' | 'error' | 'cancelled'
99
+ /**
100
+ * What went wrong, on a step with `finishReason: 'error'`.
101
+ *
102
+ * Absent everywhere else. A run whose ledger is complete except on the
103
+ * turns that failed reads as "nothing went wrong" precisely when
104
+ * something did, which is worse than an absent record — so the failed
105
+ * turn gets the same record as every other, and this is what makes it
106
+ * legible as a failure.
107
+ */
108
+ failure?: StepFailure
65
109
  /** Usage for THIS step, not the run's cumulative total. */
66
110
  usage: TokenUsage
67
111
  /** Cost delta attributable to this step. Zero without a pricing table. */
@@ -88,6 +132,35 @@ export interface StepProvenance {
88
132
  readonly chainIndex: number
89
133
  }
90
134
 
135
+ /**
136
+ * Why a step ended in `finishReason: 'error'`.
137
+ *
138
+ * The step-level counterpart of the pair a failed run already carries —
139
+ * {@link import('./entity.js').Run.lastError} and
140
+ * {@link import('./entity.js').Run.lastProviderError} — and shaped from the
141
+ * same classification, so the two agree when the failed step is the one that
142
+ * ended the run. What a run records once, a run of twenty iterations records
143
+ * per iteration, which is the difference between "this run failed" and "this
144
+ * turn failed, and the next four succeeded".
145
+ */
146
+ export interface StepFailure {
147
+ /** The failure's message, as the iteration's span and log recorded it. */
148
+ readonly message: string
149
+ /**
150
+ * Where the classifier placed it.
151
+ *
152
+ * `unknown` for a failure that is not a provider failure at all — a
153
+ * plugin hook that threw, a bug in a tool wrapper. That is the honest
154
+ * reading of the code's own contract ("unclassifiable"), and it is left
155
+ * saying so rather than being given a more specific-looking code.
156
+ */
157
+ readonly code: ProviderErrorCode
158
+ /** HTTP status, when the failure carried one. */
159
+ readonly status?: number
160
+ /** Whether sending the same request again could have worked. */
161
+ readonly retryable: boolean
162
+ }
163
+
91
164
  export interface StepToolResult {
92
165
  toolCallId: string
93
166
  toolName: string