@namzu/sdk 56.2.0 → 57.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 (177) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/agents/QueryAgent.d.ts.map +1 -1
  3. package/dist/agents/QueryAgent.js +1 -0
  4. package/dist/agents/QueryAgent.js.map +1 -1
  5. package/dist/agents/index.d.ts +1 -0
  6. package/dist/agents/index.d.ts.map +1 -1
  7. package/dist/agents/index.js +1 -0
  8. package/dist/agents/index.js.map +1 -1
  9. package/dist/agents/plan.d.ts +20 -0
  10. package/dist/agents/plan.d.ts.map +1 -0
  11. package/dist/agents/plan.js +27 -0
  12. package/dist/agents/plan.js.map +1 -0
  13. package/dist/compaction/dangling.d.ts +12 -1
  14. package/dist/compaction/dangling.d.ts.map +1 -1
  15. package/dist/compaction/dangling.js +17 -3
  16. package/dist/compaction/dangling.js.map +1 -1
  17. package/dist/compaction/index.d.ts +2 -2
  18. package/dist/compaction/index.d.ts.map +1 -1
  19. package/dist/compaction/index.js.map +1 -1
  20. package/dist/compaction/manager.d.ts +16 -0
  21. package/dist/compaction/manager.d.ts.map +1 -1
  22. package/dist/compaction/manager.js +55 -0
  23. package/dist/compaction/manager.js.map +1 -1
  24. package/dist/compaction/manual.d.ts +2 -0
  25. package/dist/compaction/manual.d.ts.map +1 -1
  26. package/dist/compaction/manual.js +33 -4
  27. package/dist/compaction/manual.js.map +1 -1
  28. package/dist/compaction/restore.d.ts +51 -0
  29. package/dist/compaction/restore.d.ts.map +1 -0
  30. package/dist/compaction/restore.js +123 -0
  31. package/dist/compaction/restore.js.map +1 -0
  32. package/dist/compaction/types.d.ts +22 -0
  33. package/dist/compaction/types.d.ts.map +1 -1
  34. package/dist/compaction/wire.d.ts +3 -1
  35. package/dist/compaction/wire.d.ts.map +1 -1
  36. package/dist/compaction/wire.js +16 -0
  37. package/dist/compaction/wire.js.map +1 -1
  38. package/dist/index.d.ts +1 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/plugin/shell-hook.d.ts +19 -1
  41. package/dist/plugin/shell-hook.d.ts.map +1 -1
  42. package/dist/plugin/shell-hook.js +21 -5
  43. package/dist/plugin/shell-hook.js.map +1 -1
  44. package/dist/prompt/coding-agent-doctrine.d.ts +2 -2
  45. package/dist/prompt/coding-agent-doctrine.d.ts.map +1 -1
  46. package/dist/prompt/coding-agent-doctrine.js +3 -2
  47. package/dist/prompt/coding-agent-doctrine.js.map +1 -1
  48. package/dist/provider/errors.d.ts +3 -0
  49. package/dist/provider/errors.d.ts.map +1 -1
  50. package/dist/provider/errors.js +23 -3
  51. package/dist/provider/errors.js.map +1 -1
  52. package/dist/provider/fallback.d.ts.map +1 -1
  53. package/dist/provider/fallback.js +34 -3
  54. package/dist/provider/fallback.js.map +1 -1
  55. package/dist/provider/retry.d.ts +12 -5
  56. package/dist/provider/retry.d.ts.map +1 -1
  57. package/dist/provider/retry.js +22 -8
  58. package/dist/provider/retry.js.map +1 -1
  59. package/dist/public-runtime.d.ts +4 -2
  60. package/dist/public-runtime.d.ts.map +1 -1
  61. package/dist/public-runtime.js +4 -1
  62. package/dist/public-runtime.js.map +1 -1
  63. package/dist/public-types.d.ts +1 -1
  64. package/dist/public-types.d.ts.map +1 -1
  65. package/dist/runtime/jobs/registry.d.ts +42 -0
  66. package/dist/runtime/jobs/registry.d.ts.map +1 -1
  67. package/dist/runtime/jobs/registry.js +77 -0
  68. package/dist/runtime/jobs/registry.js.map +1 -1
  69. package/dist/runtime/query/calls-that-never-started.d.ts +12 -0
  70. package/dist/runtime/query/calls-that-never-started.d.ts.map +1 -0
  71. package/dist/runtime/query/calls-that-never-started.js +41 -0
  72. package/dist/runtime/query/calls-that-never-started.js.map +1 -0
  73. package/dist/runtime/query/executor/tool-call-admission.d.ts +2 -2
  74. package/dist/runtime/query/executor/tool-call-admission.d.ts.map +1 -1
  75. package/dist/runtime/query/executor/tool-call-admission.js +13 -9
  76. package/dist/runtime/query/executor/tool-call-admission.js.map +1 -1
  77. package/dist/runtime/query/executor.d.ts +8 -0
  78. package/dist/runtime/query/executor.d.ts.map +1 -1
  79. package/dist/runtime/query/executor.js +35 -0
  80. package/dist/runtime/query/executor.js.map +1 -1
  81. package/dist/runtime/query/file-evidence-context.js +1 -1
  82. package/dist/runtime/query/file-evidence-context.js.map +1 -1
  83. package/dist/runtime/query/index.d.ts +6 -0
  84. package/dist/runtime/query/index.d.ts.map +1 -1
  85. package/dist/runtime/query/index.js +5 -0
  86. package/dist/runtime/query/index.js.map +1 -1
  87. package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
  88. package/dist/runtime/query/iteration/phases/compaction.js +20 -0
  89. package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
  90. package/dist/runtime/query/iteration/phases/context.d.ts +2 -0
  91. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  92. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  93. package/dist/runtime/query/iteration/stream-turn.d.ts.map +1 -1
  94. package/dist/runtime/query/iteration/stream-turn.js +45 -25
  95. package/dist/runtime/query/iteration/stream-turn.js.map +1 -1
  96. package/dist/runtime/query/prepare-turn.d.ts.map +1 -1
  97. package/dist/runtime/query/prepare-turn.js +10 -1
  98. package/dist/runtime/query/prepare-turn.js.map +1 -1
  99. package/dist/scheduler/completion-inbox.js +3 -3
  100. package/dist/scheduler/completion-inbox.js.map +1 -1
  101. package/dist/tools/admit-facts.d.ts.map +1 -1
  102. package/dist/tools/admit-facts.js +1 -0
  103. package/dist/tools/admit-facts.js.map +1 -1
  104. package/dist/tools/builtins/bash.d.ts +42 -1
  105. package/dist/tools/builtins/bash.d.ts.map +1 -1
  106. package/dist/tools/builtins/bash.js +115 -48
  107. package/dist/tools/builtins/bash.js.map +1 -1
  108. package/dist/tools/builtins/read-file.d.ts.map +1 -1
  109. package/dist/tools/builtins/read-file.js +55 -5
  110. package/dist/tools/builtins/read-file.js.map +1 -1
  111. package/dist/tools/file-read-tracker.d.ts.map +1 -1
  112. package/dist/tools/file-read-tracker.js +28 -0
  113. package/dist/tools/file-read-tracker.js.map +1 -1
  114. package/dist/types/agent/base.d.ts +6 -0
  115. package/dist/types/agent/base.d.ts.map +1 -1
  116. package/dist/types/errors/catalog.d.ts +3 -0
  117. package/dist/types/errors/catalog.d.ts.map +1 -1
  118. package/dist/types/errors/catalog.js +24 -2
  119. package/dist/types/errors/catalog.js.map +1 -1
  120. package/dist/types/provider/error.d.ts +7 -0
  121. package/dist/types/provider/error.d.ts.map +1 -1
  122. package/dist/types/provider/errors.d.ts +24 -0
  123. package/dist/types/provider/errors.d.ts.map +1 -1
  124. package/dist/types/provider/errors.js +101 -1
  125. package/dist/types/provider/errors.js.map +1 -1
  126. package/dist/types/provider/stream.d.ts +6 -0
  127. package/dist/types/provider/stream.d.ts.map +1 -1
  128. package/dist/types/session/events.d.ts +8 -0
  129. package/dist/types/session/events.d.ts.map +1 -1
  130. package/dist/types/session/events.js.map +1 -1
  131. package/dist/types/tool/index.d.ts +39 -0
  132. package/dist/types/tool/index.d.ts.map +1 -1
  133. package/dist/types/tool/index.js.map +1 -1
  134. package/dist/types/tool/presentation.d.ts +4 -0
  135. package/dist/types/tool/presentation.d.ts.map +1 -1
  136. package/package.json +1 -1
  137. package/src/agents/QueryAgent.ts +1 -0
  138. package/src/agents/index.ts +1 -0
  139. package/src/agents/plan.ts +30 -0
  140. package/src/compaction/dangling.ts +35 -3
  141. package/src/compaction/index.ts +4 -0
  142. package/src/compaction/manager.ts +53 -0
  143. package/src/compaction/manual.ts +42 -10
  144. package/src/compaction/restore.ts +159 -0
  145. package/src/compaction/types.ts +25 -0
  146. package/src/compaction/wire.ts +26 -1
  147. package/src/index.ts +2 -0
  148. package/src/plugin/shell-hook.ts +33 -5
  149. package/src/prompt/coding-agent-doctrine.ts +3 -2
  150. package/src/provider/errors.ts +27 -3
  151. package/src/provider/fallback.ts +34 -2
  152. package/src/provider/retry.ts +30 -10
  153. package/src/public-runtime.ts +8 -0
  154. package/src/public-types.ts +3 -0
  155. package/src/runtime/jobs/registry.ts +105 -0
  156. package/src/runtime/query/calls-that-never-started.ts +37 -0
  157. package/src/runtime/query/executor/tool-call-admission.ts +12 -9
  158. package/src/runtime/query/executor.ts +37 -0
  159. package/src/runtime/query/file-evidence-context.ts +1 -1
  160. package/src/runtime/query/index.ts +12 -0
  161. package/src/runtime/query/iteration/phases/compaction.ts +19 -0
  162. package/src/runtime/query/iteration/phases/context.ts +3 -0
  163. package/src/runtime/query/iteration/stream-turn.ts +47 -25
  164. package/src/runtime/query/prepare-turn.ts +10 -1
  165. package/src/scheduler/completion-inbox.ts +3 -3
  166. package/src/tools/admit-facts.ts +1 -0
  167. package/src/tools/builtins/bash.ts +149 -54
  168. package/src/tools/builtins/read-file.ts +66 -4
  169. package/src/tools/file-read-tracker.ts +25 -1
  170. package/src/types/agent/base.ts +7 -0
  171. package/src/types/errors/catalog.ts +31 -2
  172. package/src/types/provider/error.ts +7 -0
  173. package/src/types/provider/errors.ts +120 -1
  174. package/src/types/provider/stream.ts +6 -0
  175. package/src/types/session/events.ts +8 -0
  176. package/src/types/tool/index.ts +40 -0
  177. package/src/types/tool/presentation.ts +4 -0
@@ -6,6 +6,7 @@ import { unknownProgramInLine } from '../../authorization/program.js'
6
6
  import { type SkillGrantSet, compileSkillGrant } from '../../authorization/skill-grant.js'
7
7
  import { extractFromToolCall, extractFromToolResult } from '../../compaction/extractor.js'
8
8
  import type { WorkingStateManager } from '../../compaction/manager.js'
9
+ import { recordRestorable } from '../../compaction/restore.js'
9
10
  import { GENAI, NAMZU } from '../../constants/telemetry/index.js'
10
11
  import type { PluginLifecycleManager } from '../../plugin/lifecycle.js'
11
12
  import { buildProbeContext } from '../../probe/context.js'
@@ -13,6 +14,7 @@ import { ProbeVetoError } from '../../probe/errors.js'
13
14
  import { probe as defaultProbeRegistry } from '../../probe/registry.js'
14
15
  import type { ProbeEnforcement } from '../../probe/registry.js'
15
16
  import type { ActivityStore } from '../../store/activity/memory.js'
17
+ import { fingerprintContent } from '../../tools/builtins/content-fingerprint.js'
16
18
  import { sandboxShellSpawn, withoutBashStartup } from '../../tools/command-shell.js'
17
19
  import { isAlwaysDestructive } from '../../tools/defineTool.js'
18
20
  import { createFileReadTracker } from '../../tools/file-read-tracker.js'
@@ -700,6 +702,7 @@ export class ToolExecutor {
700
702
  private emitEvent: EmitEvent
701
703
  private log: Logger
702
704
  private workingStateManager?: WorkingStateManager
705
+ private historyView?: () => readonly Message[]
703
706
  private probes: ProbeEnforcement
704
707
  private parentSpan?: Span
705
708
  private readonly toolCallBudget?: ToolCallBudget
@@ -749,6 +752,30 @@ export class ToolExecutor {
749
752
  this.workingStateManager = manager
750
753
  }
751
754
 
755
+ /**
756
+ * Read the conversation the model is shown, as it is now. Lets a tool ask
757
+ * whether an earlier receipt is still there (see `ToolContext.receiptVisible`)
758
+ * without being handed the history.
759
+ */
760
+ setHistoryView(view: () => readonly Message[]): void {
761
+ this.historyView = view
762
+ }
763
+
764
+ private receiptVisible = (callId: string, renderedFingerprint: string): boolean => {
765
+ const history = this.historyView?.()
766
+ if (!history) return false
767
+ for (let index = history.length - 1; index >= 0; index -= 1) {
768
+ const message = history[index]
769
+ if (message?.role !== 'tool' || message.toolCallId !== callId) continue
770
+ return (
771
+ typeof message.content === 'string' &&
772
+ message.isError !== true &&
773
+ fingerprintContent(message.content) === renderedFingerprint
774
+ )
775
+ }
776
+ return false
777
+ }
778
+
752
779
  setSandbox(sandbox: Sandbox): void {
753
780
  this.config = { ...this.config, sandbox }
754
781
  this.sandboxGate = undefined
@@ -2185,6 +2212,7 @@ export class ToolExecutor {
2185
2212
  this.dispatchNested(name, input, context, recordObservation, undefined, options),
2186
2213
  sandbox: this.config.sandbox,
2187
2214
  fileReadTracker: this.fileReadTracker,
2215
+ receiptVisible: this.receiptVisible,
2188
2216
  ...(this.config.codeAnalysis ? { codeAnalysis: this.config.codeAnalysis } : {}),
2189
2217
  // Bound to this turn, once. Binding here rather than passing the
2190
2218
  // owner from the tool is what makes the scoping structural: there
@@ -2584,6 +2612,15 @@ export class ToolExecutor {
2584
2612
 
2585
2613
  if (this.workingStateManager) {
2586
2614
  extractFromToolResult(this.workingStateManager, toolName, output, effectiveIsError)
2615
+ // What the model was shown, kept whole so a compaction can put it back.
2616
+ recordRestorable(this.workingStateManager, {
2617
+ toolName,
2618
+ input,
2619
+ output: typeof modelContent === 'string' ? modelContent : output,
2620
+ success: !effectiveIsError,
2621
+ unchanged: result.facts?.kind === 'read' && result.facts.unchanged === true,
2622
+ workingDirectory: this.config.workingDirectory,
2623
+ })
2587
2624
  for (const pin of this.config.abortSignal.aborted ? [] : (result.workingState ?? [])) {
2588
2625
  this.workingStateManager.pin(pin.key, pin.text, toolName)
2589
2626
  }
@@ -149,7 +149,7 @@ export function recordFileEvidence(
149
149
  const lead =
150
150
  recorded.length === 0
151
151
  ? `${FILE_EVIDENCE_HEADING}: ${FILE_EVIDENCE_GUIDANCE}`
152
- : `${FILE_EVIDENCE_HEADING}, changed since the last entry. An entry for a path replaces the earlier entries for that path.`
152
+ : `${FILE_EVIDENCE_HEADING}, changed since the last entry; \`bodyInCall\` and \`observedFingerprint\` read as before. An entry for a path replaces the earlier entries for that path.`
153
153
  return [
154
154
  lead,
155
155
  ...(gone.length > 0 ? [`${NO_LONGER_HOLDS}${JSON.stringify(gone)}`] : []),
@@ -814,6 +814,13 @@ export interface QueryParams {
814
814
  */
815
815
  projectInstructionContext?: ProjectInstructionContext
816
816
 
817
+ /**
818
+ * Text the host wants carried across a compaction, such as where its plan
819
+ * file is. Asked once per pass, after the summary is written; the answer is
820
+ * appended to the restored context. Returning nothing adds nothing.
821
+ */
822
+ postCompactionContext?: () => string | undefined | Promise<string | undefined>
823
+
817
824
  /**
818
825
  * Where this conversation's durable state lives.
819
826
  *
@@ -1656,6 +1663,8 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
1656
1663
  const compactionConfig = params.compactionConfig
1657
1664
  ? CompactionConfigSchema.parse(params.compactionConfig)
1658
1665
  : undefined
1666
+ // The live history, so a tool can ask whether an earlier receipt is still in it.
1667
+ toolExecutor.setHistoryView(() => ctx.recorder.messages)
1659
1668
  if (compactionConfig && compactionConfig.strategy !== 'disabled') {
1660
1669
  workingStateManager = new WorkingStateManager(compactionConfig)
1661
1670
  toolExecutor.setWorkingStateManager(workingStateManager)
@@ -1829,6 +1838,9 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
1829
1838
  ...(params.projectInstructionContext
1830
1839
  ? { projectInstructionContext: params.projectInstructionContext }
1831
1840
  : {}),
1841
+ ...(params.postCompactionContext
1842
+ ? { postCompactionContext: params.postCompactionContext }
1843
+ : {}),
1832
1844
  ...(providerContextWindow !== undefined ? { providerContextWindow } : {}),
1833
1845
  resolveModelContextWindow: async (model) => {
1834
1846
  if (!modelContextWindows.has(model)) {
@@ -11,6 +11,7 @@ import {
11
11
  } from '../../../../compaction/plan.js'
12
12
  import type { ContextReducer, ContextReduction } from '../../../../compaction/reducer.js'
13
13
  import { createSlidingWindowReducer } from '../../../../compaction/reducer.js'
14
+ import { renderRestoredContext } from '../../../../compaction/restore.js'
14
15
  import { findRetainedIndices } from '../../../../compaction/retention.js'
15
16
  import { serializeState } from '../../../../compaction/serializer.js'
16
17
  import {
@@ -1003,6 +1004,24 @@ async function runCompactionCheckInner(
1003
1004
  compactedContent = serializeState(manager.getState())
1004
1005
  }
1005
1006
 
1007
+ // Skills the model loaded and files it read go back verbatim after the
1008
+ // summary, with whatever the host wants carried across (its plan file).
1009
+ let hostNote: string | undefined
1010
+ try {
1011
+ hostNote = await ctx.postCompactionContext?.()
1012
+ } catch (error) {
1013
+ // A host note is an addition; the pass does not depend on it.
1014
+ ctx.log.warn('The host note for a compaction could not be read — leaving it out', {
1015
+ [NAMZU.TURN_ID]: ctx.recorder.turnId,
1016
+ 'exception.message': error instanceof Error ? error.message : String(error),
1017
+ })
1018
+ }
1019
+ const restored = renderRestoredContext(manager.getState(), {
1020
+ contextWindowTokens: budget,
1021
+ ...(hostNote ? { hostNote } : {}),
1022
+ })
1023
+ if (restored) compactedContent = `${compactedContent}\n\n${restored}`
1024
+
1006
1025
  // The pointer is only worth a line when both tools it names are mounted.
1007
1026
  const mounted = new Set(ctx.tools?.listNames?.() ?? [])
1008
1027
  const compactionMessage = buildCompactionMessage(
@@ -256,6 +256,9 @@ export interface IterationContext {
256
256
  /** Live project policy; separate from human inbound continuation. */
257
257
  readonly projectInstructionContext?: ProjectInstructionContext
258
258
 
259
+ /** Host text appended to the restored context after a compaction. */
260
+ readonly postCompactionContext?: () => string | undefined | Promise<string | undefined>
261
+
259
262
  readonly workingStateManager?: WorkingStateManager
260
263
 
261
264
  /**
@@ -30,7 +30,7 @@ import type {
30
30
  ReasoningBlock,
31
31
  ToolInputError,
32
32
  } from '../../../types/message/index.js'
33
- import { ProviderError } from '../../../types/provider/errors.js'
33
+ import { ProviderError, classifyProviderError } from '../../../types/provider/errors.js'
34
34
  import type {
35
35
  ChatCompletionResponse,
36
36
  LLMProvider,
@@ -298,7 +298,7 @@ export async function* streamProviderTurn(
298
298
  let streamedLength = 0
299
299
  const toolBuckets = new Map<number, ToolCallBucket>()
300
300
  // Places a fragment that came without an index; see `ToolCallIndexer`.
301
- const toolIndexer = new ToolCallIndexer()
301
+ let toolIndexer = new ToolCallIndexer()
302
302
  // The call the model's latest output went to, or `undefined` once text,
303
303
  // reasoning or a hosted tool followed it. An output limit, a content
304
304
  // filter or a dropped stream stops the response wherever it is, so this
@@ -397,6 +397,36 @@ export async function* streamProviderTurn(
397
397
  })
398
398
  : undefined
399
399
 
400
+ /**
401
+ * Drop everything the failed attempt wrote, so the request starts over under
402
+ * the same message: after a restarting retry, or a swap that says the same.
403
+ * The failed attempt's text, reasoning and tool calls are void. A call is
404
+ * restarted only while still being written and nothing runs before the
405
+ * response settles, so a half-written call never reaches the model.
406
+ */
407
+ const discardAttempt = () => {
408
+ toolBuckets.clear()
409
+ toolIndexer = new ToolCallIndexer()
410
+ framingViolated = false
411
+ text = new StreamTextAccumulator()
412
+ reasoningBuckets.clear()
413
+ citations.length = 0
414
+ replayState = undefined
415
+ id = ''
416
+ finishReason = 'stop'
417
+ reportedFinishReason = undefined
418
+ finishDetail = undefined
419
+ streamedLength = 0
420
+ lastOutputCall = undefined
421
+ usage = {
422
+ promptTokens: 0,
423
+ completionTokens: 0,
424
+ totalTokens: 0,
425
+ cachedTokens: 0,
426
+ cacheWriteTokens: 0,
427
+ }
428
+ }
429
+
400
430
  try {
401
431
  for (;;) {
402
432
  const next = it.next()
@@ -426,28 +456,7 @@ export async function* streamProviderTurn(
426
456
  ...(chunk.retry.restart ? { restart: true } : {}),
427
457
  })
428
458
  yield* drainPending()
429
- if (chunk.retry.restart) {
430
- // The failed attempt's text and reasoning are void: the request
431
- // starts over under the same message. The decorator never
432
- // restarts once a tool call began, so no bucket is left behind.
433
- text = new StreamTextAccumulator()
434
- reasoningBuckets.clear()
435
- citations.length = 0
436
- replayState = undefined
437
- id = ''
438
- finishReason = 'stop'
439
- reportedFinishReason = undefined
440
- finishDetail = undefined
441
- streamedLength = 0
442
- lastOutputCall = undefined
443
- usage = {
444
- promptTokens: 0,
445
- completionTokens: 0,
446
- totalTokens: 0,
447
- cachedTokens: 0,
448
- cacheWriteTokens: 0,
449
- }
450
- }
459
+ if (chunk.retry.restart) discardAttempt()
451
460
  continue
452
461
  }
453
462
 
@@ -472,8 +481,10 @@ export async function* streamProviderTurn(
472
481
  code: chunk.fallback.code,
473
482
  ...(chunk.fallback.status !== undefined ? { status: chunk.fallback.status } : {}),
474
483
  reason: chunk.fallback.reason,
484
+ ...(chunk.fallback.restart ? { restart: true } : {}),
475
485
  })
476
486
  yield* drainPending()
487
+ if (chunk.fallback.restart) discardAttempt()
477
488
  continue
478
489
  }
479
490
 
@@ -947,10 +958,21 @@ export async function* streamProviderTurn(
947
958
 
948
959
  // Not after a framing violation: the calls it would recover are the ones
949
960
  // the stream was just refused for garbling.
961
+ //
962
+ // Nor when the stream was cut by a connection that may come back and no
963
+ // call came through whole: the retries are spent, the half-written call is
964
+ // not the model's mistake, and telling the model about it would spend a
965
+ // turn on a fault it cannot fix. The failure goes up and the turn pauses.
966
+ const cutByConnection =
967
+ streamError !== undefined &&
968
+ streamCause !== undefined &&
969
+ classifyProviderError(streamCause).retryable
950
970
  const recoveredToolInputFromStreamError =
951
971
  streamError !== undefined &&
952
972
  !framingViolated &&
953
- toolCalls.some((tc) => tc.id && tc.function.name)
973
+ toolCalls.some(
974
+ (tc) => tc.id && tc.function.name && (!cutByConnection || !tc.metadata?.inputError),
975
+ )
954
976
  const effectiveFinishReason: ChatCompletionResponse['finishReason'] =
955
977
  recoveredToolInputFromStreamError ? 'tool_calls' : finishReason
956
978
 
@@ -43,6 +43,7 @@ import { generateCheckpointId, generateTurnId } from '../../utils/id.js'
43
43
  import { errorAttributes } from '../../utils/log/exception.js'
44
44
  import type { Logger } from '../../utils/logger.js'
45
45
  import { AUTO_APPROVE_POLICY_NAME, createSessionApprovalPolicy } from './approval-policy.js'
46
+ import { readCallsThatNeverStarted } from './calls-that-never-started.js'
46
47
  import { type TurnContext, TurnContextFactory } from './context.js'
47
48
  import { EventTranslator } from './events.js'
48
49
  import type { QueryParams } from './index.js'
@@ -681,9 +682,17 @@ export async function prepareTurn(params: QueryParams): Promise<PreparedTurn> {
681
682
  pinCompaction: true,
682
683
  })),
683
684
  ])
684
- const initialRepair = params.resumeFromCheckpoint
685
+ let initialRepair = params.resumeFromCheckpoint
685
686
  ? { messages: projectedInitialMessages, report: undefined }
686
687
  : repairToolMessageHistory(projectedInitialMessages)
688
+ if (initialRepair.report && initialRepair.report.syntheticToolResultsInserted > 0) {
689
+ // A call that was waiting for approval when the session ended never ran;
690
+ // the model is told so rather than that its outcome is unknown.
691
+ const neverRan = await readCallsThatNeverStarted(storage.log)
692
+ if (neverRan.size > 0) {
693
+ initialRepair = repairToolMessageHistory(projectedInitialMessages, { neverRan })
694
+ }
695
+ }
687
696
  const initialMessages = initialRepair.messages
688
697
  if (initialRepair.report && toolHistoryRepairChanged(initialRepair.report)) {
689
698
  pendingHistoryRepairs.push({
@@ -46,7 +46,7 @@ const OWNED_WORK_DISPLAY_LIMIT = 16
46
46
 
47
47
  /** What the model is told about owned work, wherever it is shown. */
48
48
  const OWNED_WORK_GUIDANCE =
49
- 'Each task gives its id (taskId), its state, how its turn ended (turnStatus), why it stopped (stopReason), and whether its result has reached this conversation (resultDelivery: delivered-to-history, or not-delivered). Result delivery is not proof that you answered the original request. Answer the latest operator message and complete the requested synthesis of available results unless the operator cancelled or changed that request. Reuse delivered results when sufficient; do not relaunch completed work. If a delivered result is no longer visible, retrieve it by task ID. A terminal state with a non-end_turn stop reason is not successful task completion. Do not repeat a synthesis already given.'
49
+ 'Each task gives its id (taskId), its state, how its turn ended (turnStatus), why it stopped (stopReason), and whether its result has reached this conversation (resultDelivery: delivered-to-history, or not-delivered). Result delivery is not proof that you answered the original request. Answer the latest operator message and complete the requested synthesis of available results unless the operator cancelled or changed that request. Reuse delivered results when sufficient; do not relaunch completed work. If a delivered result is no longer visible, retrieve it by task ID. A terminal state with a non-end_turn stop reason is not successful task completion. Results reach you and not the user, so before you end a reply check that the result of every finished task has been reported to the user, once. Do not repeat a synthesis already given.'
50
50
 
51
51
  /**
52
52
  * The ids of the tasks that earlier recorded entries name, read from the
@@ -809,8 +809,8 @@ export function formatCompletionNotification(handles: readonly TaskHandle[]): st
809
809
 
810
810
  const preamble =
811
811
  handles.length === 1
812
- ? 'A task you launched has finished. This is its result — you were not waiting on it, so it arrives here rather than as a tool result.'
813
- : `${handles.length} tasks you launched have finished. These are their results — you were not waiting on them, so they arrive here rather than as tool results.`
812
+ ? 'A task you launched has finished. This is its result — you were not waiting on it, so it arrives here rather than as a tool result. The user has not seen it; tell them what it found in your next message.'
813
+ : `${handles.length} tasks you launched have finished. These are their results — you were not waiting on them, so they arrive here rather than as tool results. The user has not seen them; tell them what each found in your next message.`
814
814
 
815
815
  return `${preamble}\n\n${blocks.join('\n\n')}`
816
816
  }
@@ -118,6 +118,7 @@ export function admitToolFacts(value: unknown): ToolFacts | undefined {
118
118
  ? { bytes: count(value.binary.bytes) }
119
119
  : undefined,
120
120
  background: flag(value.background),
121
+ continued: flag(value.continued),
121
122
  })
122
123
  case 'read': {
123
124
  const path = text(value.path)
@@ -4,6 +4,7 @@ import { SANDBOX_KILL_GRACE_MS } from '../../constants/sandbox/index.js'
4
4
  import { DANGEROUS_PATTERNS } from '../../constants/tools/index.js'
5
5
  import { killTree } from '../../process/kill-tree.js'
6
6
  import { genericLabel } from '../../registry/tool/presentation.js'
7
+ import { cancelCauseOf } from '../../types/session/cancel-cause.js'
7
8
  import type { ToolFacts } from '../../types/tool/presentation.js'
8
9
  import { subscribeToAbort } from '../../utils/abort.js'
9
10
  import { readPositiveIntEnv } from '../../utils/env.js'
@@ -93,6 +94,28 @@ export function movedToBackgroundMessage(jobId: string, timeoutMs: number): stri
93
94
  return `Command did not complete within its ${seconds}s timeout and was moved to the background as job ${jobId}; it was not stopped. You will be told when it ends. Its output so far is above.`
94
95
  }
95
96
 
97
+ /**
98
+ * How long a foreground command runs before it is offered for hand-over to
99
+ * the background. A command that ends sooner is never interrupted by a stray
100
+ * key press, and a host that shows a hint shows it from this moment.
101
+ */
102
+ export const FOREGROUND_HANDOFF_AFTER_MS = 2_000
103
+
104
+ /**
105
+ * What the model is told when the person moved a running foreground command to
106
+ * the background instead of stopping it. Exported so the test states the same
107
+ * words.
108
+ */
109
+ export function handedOffMessage(jobId: string, reason: 'user' | 'message' | 'interrupt'): string {
110
+ const why =
111
+ reason === 'user'
112
+ ? "at the person's request"
113
+ : reason === 'message'
114
+ ? 'so a message from the person could reach you'
115
+ : 'because the person interrupted the turn'
116
+ return `Command was moved to the background as job ${jobId} ${why}; it was not stopped. You will be told when it ends. Its output so far is above.`
117
+ }
118
+
96
119
  const inputSchema = z.object({
97
120
  command: z
98
121
  .string()
@@ -240,10 +263,34 @@ export function execHostShell(
240
263
  child: ReturnType<typeof spawnHostShell>
241
264
  initialOutput: readonly { stream: 'stdout' | 'stderr'; text: string }[]
242
265
  }) => string | undefined
266
+ /**
267
+ * Takes the live process over for a hand-over the person asked for
268
+ * (a key press, a message that must reach the model, an interrupt),
269
+ * with the same contract as `onTimeout`. Absent, those hand-overs are
270
+ * not offered and an interrupt kills the command as it always did.
271
+ */
272
+ adopt?: (running: {
273
+ child: ReturnType<typeof spawnHostShell>
274
+ initialOutput: readonly { stream: 'stdout' | 'stderr'; text: string }[]
275
+ }) => string | undefined
276
+ /**
277
+ * An interrupt the person caused (Esc, Ctrl+C) hands the command to
278
+ * `adopt` instead of killing it. Any other abort, a parent stopping a
279
+ * child or a budget stop, still kills it.
280
+ */
281
+ adoptOnInterrupt?: boolean
282
+ /**
283
+ * Called once, right after the process is spawned, with the means to
284
+ * hand it over later. `background` returns whether the process was
285
+ * taken over; it settles the call with `backgroundedJobId` set.
286
+ */
287
+ onRunning?: (control: { background(reason: 'user' | 'message'): boolean }) => void
243
288
  },
244
289
  ): Promise<{
245
- /** Set when `onTimeout` took the process over; the command was not stopped. */
290
+ /** Set when the process was taken over; the command was not stopped. */
246
291
  backgroundedJobId?: string
292
+ /** Why it was taken over. */
293
+ backgroundedBy?: 'timeout' | 'user' | 'message' | 'interrupt'
247
294
  stdout: string
248
295
  stderr: string
249
296
  /** Bytes captured on each stream, before decoding; absent from a runner that does not count them. */
@@ -401,44 +448,64 @@ export function execHostShell(
401
448
  )
402
449
  }
403
450
  child.once('close', onClose)
404
- const deadline = setTimeout(() => {
405
- if (options.onTimeout && !closed && cause === undefined) {
406
- let jobId: string | undefined
407
- try {
408
- jobId = options.onTimeout({
409
- child,
410
- initialOutput: (['stdout', 'stderr'] as const)
411
- .map((stream) => ({ stream, text: decodeCapture(stream) }))
412
- .filter((part) => part.text !== ''),
413
- })
414
- } catch {
415
- // A host that cannot take the process over leaves the kill in place.
416
- }
417
- if (jobId !== undefined) {
418
- // The registry owns the process from here: stop listening to it,
419
- // so a later Stop or its exit is not this call's business.
420
- closed = true
421
- disposeAbort?.()
422
- child.stdout?.off('data', onStdout)
423
- child.stderr?.off('data', onStderr)
424
- child.off('close', onClose)
425
- child.off('error', onError)
426
- child.off('spawn', onSpawn)
427
- resolve({
428
- backgroundedJobId: jobId,
429
- stdout: decodeCapture('stdout'),
430
- stderr: decodeCapture('stderr'),
431
- })
432
- return
433
- }
451
+ // Hands the live process to the host's job registry, once. `onTimeout`
452
+ // answers for the deadline; every other reason goes to `adopt`.
453
+ const takeOver = (reason: 'timeout' | 'user' | 'message' | 'interrupt'): boolean => {
454
+ const adopter =
455
+ reason === 'timeout' ? options.onTimeout : (options.adopt ?? options.onTimeout)
456
+ if (!adopter || closed || cause !== undefined) return false
457
+ let jobId: string | undefined
458
+ try {
459
+ jobId = adopter({
460
+ child,
461
+ initialOutput: (['stdout', 'stderr'] as const)
462
+ .map((stream) => ({ stream, text: decodeCapture(stream) }))
463
+ .filter((part) => part.text !== ''),
464
+ })
465
+ } catch {
466
+ // A host that cannot take the process over leaves the process where it is.
434
467
  }
468
+ if (jobId === undefined) return false
469
+ // The registry owns the process from here: stop listening to it,
470
+ // so a later Stop or its exit is not this call's business.
471
+ closed = true
472
+ disposeAbort?.()
473
+ clearTimeout(deadline)
474
+ child.stdout?.off('data', onStdout)
475
+ child.stderr?.off('data', onStderr)
476
+ child.off('close', onClose)
477
+ child.off('error', onError)
478
+ child.off('spawn', onSpawn)
479
+ resolve({
480
+ backgroundedJobId: jobId,
481
+ backgroundedBy: reason,
482
+ stdout: decodeCapture('stdout'),
483
+ stderr: decodeCapture('stderr'),
484
+ })
485
+ return true
486
+ }
487
+ const deadline = setTimeout(() => {
488
+ if (takeOver('timeout')) return
435
489
  cancel('timeout')
436
490
  }, options.timeout)
437
491
  deadline.unref?.()
438
492
  if (options.signal) {
439
- disposeAbort = subscribeToAbort(options.signal, () => cancel('caller'))
440
- if (options.signal.aborted) cancel('caller')
493
+ const signal = options.signal
494
+ const onAbort = () => {
495
+ // Esc or Ctrl+C keeps what the command was doing; any other stop
496
+ // (a parent cancelling a child, a budget) still ends it.
497
+ if (
498
+ options.adoptOnInterrupt === true &&
499
+ cancelCauseOf(signal.reason) === 'user' &&
500
+ takeOver('interrupt')
501
+ )
502
+ return
503
+ cancel('caller')
504
+ }
505
+ disposeAbort = subscribeToAbort(signal, onAbort)
506
+ if (signal.aborted) onAbort()
441
507
  }
508
+ options.onRunning?.({ background: (reason) => takeOver(reason) })
442
509
  })
443
510
  }
444
511
 
@@ -714,51 +781,76 @@ const BashToolBase = defineTool({
714
781
  // registry, a sandboxed turn, a one-off sandbox escape, a plain wait)
715
782
  // is there anything to hand the process to, so those keep the kill.
716
783
  const adopt = context.backgroundJobs?.adopt?.bind(context.backgroundJobs)
717
- const canMoveToBackground =
718
- adopt !== undefined &&
719
- context.sandbox === undefined &&
720
- !backgroundTasksDisabled() &&
721
- !startsWithSleep(input.command)
784
+ const canHandOff =
785
+ adopt !== undefined && context.sandbox === undefined && !backgroundTasksDisabled()
786
+ const canMoveOnTimeout = canHandOff && !startsWithSleep(input.command)
787
+ const adoptRunning = (running: {
788
+ child: Parameters<NonNullable<typeof adopt>>[0]['child']
789
+ initialOutput: Parameters<NonNullable<typeof adopt>>[0]['initialOutput']
790
+ }) =>
791
+ adopt?.({
792
+ command: input.command,
793
+ child: running.child,
794
+ workingDirectory: context.workingDirectory,
795
+ initialOutput: running.initialOutput,
796
+ }).id
797
+ // Offered for hand-over once it has run long enough to be worth moving.
798
+ let offerTimer: ReturnType<typeof setTimeout> | undefined
799
+ let withdrawOffer: (() => void) | undefined
800
+ const offer = context.backgroundJobs?.registerForeground?.bind(context.backgroundJobs)
722
801
 
723
802
  try {
724
- const { stdout, stderr, stdoutBytes, stderrBytes, backgroundedJobId } = await execHostShell(
725
- input.command,
726
- {
803
+ const { stdout, stderr, stdoutBytes, stderrBytes, backgroundedJobId, backgroundedBy } =
804
+ await execHostShell(input.command, {
727
805
  cwd: context.workingDirectory,
728
806
  timeout: input.timeout,
729
807
  env: { ...inherited.env, ...context.env },
730
808
  maxBuffer: DEFAULT_BASH_MAX_BUFFER_BYTES,
731
809
  signal: context.abortSignal,
732
810
  onOutput,
733
- ...(canMoveToBackground
811
+ ...(canMoveOnTimeout ? { onTimeout: adoptRunning } : {}),
812
+ ...(canHandOff
734
813
  ? {
735
- onTimeout: ({ child, initialOutput }) =>
736
- adopt({
737
- command: input.command,
738
- child,
739
- workingDirectory: context.workingDirectory,
740
- initialOutput,
741
- }).id,
814
+ adopt: adoptRunning,
815
+ adoptOnInterrupt: true,
816
+ ...(offer
817
+ ? {
818
+ onRunning: (control: {
819
+ background(reason: 'user' | 'message'): boolean
820
+ }) => {
821
+ offerTimer = setTimeout(() => {
822
+ withdrawOffer = offer({
823
+ command: input.command,
824
+ background: (reason) => control.background(reason),
825
+ })
826
+ }, FOREGROUND_HANDOFF_AFTER_MS)
827
+ offerTimer.unref?.()
828
+ },
829
+ }
830
+ : {}),
742
831
  }
743
832
  : {}),
744
- },
745
- )
833
+ })
746
834
  if (backgroundedJobId !== undefined) {
835
+ const timedOut = backgroundedBy === 'timeout'
747
836
  return {
748
837
  success: true,
749
838
  output: [
750
839
  formatShellOutput(stdout, stderr),
751
- movedToBackgroundMessage(backgroundedJobId, input.timeout),
840
+ timedOut || backgroundedBy === undefined
841
+ ? movedToBackgroundMessage(backgroundedJobId, input.timeout)
842
+ : handedOffMessage(backgroundedJobId, backgroundedBy),
752
843
  ]
753
844
  .filter(Boolean)
754
845
  .join('\n\n'),
755
846
  data: {
756
847
  jobId: backgroundedJobId,
757
848
  background: true,
758
- timedOut: true,
849
+ ...(timedOut || backgroundedBy === undefined ? { timedOut: true } : {}),
759
850
  movedToBackground: true,
851
+ ...(backgroundedBy ? { backgroundedBy } : {}),
760
852
  },
761
- facts: { kind: 'command', command: input.command, background: true },
853
+ facts: { kind: 'command', command: input.command, background: true, continued: true },
762
854
  }
763
855
  }
764
856
  return {
@@ -856,6 +948,9 @@ const BashToolBase = defineTool({
856
948
  ...(failure.stderrBytes !== undefined ? { stderrBytes: failure.stderrBytes } : {}),
857
949
  }),
858
950
  }
951
+ } finally {
952
+ if (offerTimer !== undefined) clearTimeout(offerTimer)
953
+ withdrawOffer?.()
859
954
  }
860
955
  },
861
956
  })