@namzu/sdk 52.0.0 → 52.1.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 (114) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/compaction/verifier.d.ts.map +1 -1
  3. package/dist/compaction/verifier.js +57 -17
  4. package/dist/compaction/verifier.js.map +1 -1
  5. package/dist/contracts/a2a.d.ts +2 -2
  6. package/dist/provider/capabilities.d.ts +9 -0
  7. package/dist/provider/capabilities.d.ts.map +1 -1
  8. package/dist/provider/capabilities.js +15 -0
  9. package/dist/provider/capabilities.js.map +1 -1
  10. package/dist/provider/errors.d.ts.map +1 -1
  11. package/dist/provider/errors.js +13 -0
  12. package/dist/provider/errors.js.map +1 -1
  13. package/dist/provider/fallback.d.ts.map +1 -1
  14. package/dist/provider/fallback.js +10 -0
  15. package/dist/provider/fallback.js.map +1 -1
  16. package/dist/provider/idle-timeout.d.ts.map +1 -1
  17. package/dist/provider/idle-timeout.js +5 -0
  18. package/dist/provider/idle-timeout.js.map +1 -1
  19. package/dist/provider/image-input-refusal.d.ts +9 -0
  20. package/dist/provider/image-input-refusal.d.ts.map +1 -0
  21. package/dist/provider/image-input-refusal.js +54 -0
  22. package/dist/provider/image-input-refusal.js.map +1 -0
  23. package/dist/provider/index.d.ts +2 -1
  24. package/dist/provider/index.d.ts.map +1 -1
  25. package/dist/provider/index.js +2 -1
  26. package/dist/provider/index.js.map +1 -1
  27. package/dist/provider/retry.d.ts +7 -2
  28. package/dist/provider/retry.d.ts.map +1 -1
  29. package/dist/provider/retry.js +38 -4
  30. package/dist/provider/retry.js.map +1 -1
  31. package/dist/public-runtime.d.ts +1 -1
  32. package/dist/public-runtime.d.ts.map +1 -1
  33. package/dist/public-runtime.js +1 -1
  34. package/dist/public-runtime.js.map +1 -1
  35. package/dist/runtime/query/deferred-sandbox.d.ts +54 -0
  36. package/dist/runtime/query/deferred-sandbox.d.ts.map +1 -0
  37. package/dist/runtime/query/deferred-sandbox.js +75 -0
  38. package/dist/runtime/query/deferred-sandbox.js.map +1 -0
  39. package/dist/runtime/query/executor.d.ts +10 -0
  40. package/dist/runtime/query/executor.d.ts.map +1 -1
  41. package/dist/runtime/query/executor.js +24 -3
  42. package/dist/runtime/query/executor.js.map +1 -1
  43. package/dist/runtime/query/index.d.ts.map +1 -1
  44. package/dist/runtime/query/index.js +93 -25
  45. package/dist/runtime/query/index.js.map +1 -1
  46. package/dist/runtime/query/iteration/index.d.ts +26 -0
  47. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  48. package/dist/runtime/query/iteration/index.js +164 -21
  49. package/dist/runtime/query/iteration/index.js.map +1 -1
  50. package/dist/runtime/query/iteration/phases/compaction-breaker.d.ts +10 -7
  51. package/dist/runtime/query/iteration/phases/compaction-breaker.d.ts.map +1 -1
  52. package/dist/runtime/query/iteration/phases/compaction-breaker.js +10 -7
  53. package/dist/runtime/query/iteration/phases/compaction-breaker.js.map +1 -1
  54. package/dist/runtime/query/iteration/phases/compaction.d.ts +42 -1
  55. package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
  56. package/dist/runtime/query/iteration/phases/compaction.js +119 -21
  57. package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
  58. package/dist/runtime/query/iteration/provider-rejected-image.d.ts +21 -1
  59. package/dist/runtime/query/iteration/provider-rejected-image.d.ts.map +1 -1
  60. package/dist/runtime/query/iteration/provider-rejected-image.js +53 -7
  61. package/dist/runtime/query/iteration/provider-rejected-image.js.map +1 -1
  62. package/dist/runtime/query/iteration/request-repair.d.ts +108 -0
  63. package/dist/runtime/query/iteration/request-repair.d.ts.map +1 -0
  64. package/dist/runtime/query/iteration/request-repair.js +593 -0
  65. package/dist/runtime/query/iteration/request-repair.js.map +1 -0
  66. package/dist/runtime/query/iteration/stream-turn.d.ts +6 -0
  67. package/dist/runtime/query/iteration/stream-turn.d.ts.map +1 -1
  68. package/dist/runtime/query/iteration/stream-turn.js +2 -2
  69. package/dist/runtime/query/iteration/stream-turn.js.map +1 -1
  70. package/dist/runtime/query/request-rich-content.d.ts +5 -1
  71. package/dist/runtime/query/request-rich-content.d.ts.map +1 -1
  72. package/dist/runtime/query/request-rich-content.js +67 -25
  73. package/dist/runtime/query/request-rich-content.js.map +1 -1
  74. package/dist/tools/builtins/computer-use.d.ts +1 -1
  75. package/dist/tools/builtins/computer-use.d.ts.map +1 -1
  76. package/dist/tools/builtins/computer-use.js +12 -5
  77. package/dist/tools/builtins/computer-use.js.map +1 -1
  78. package/dist/types/provider/errors.d.ts.map +1 -1
  79. package/dist/types/provider/errors.js +30 -5
  80. package/dist/types/provider/errors.js.map +1 -1
  81. package/dist/types/provider/interface.d.ts +12 -0
  82. package/dist/types/provider/interface.d.ts.map +1 -1
  83. package/dist/types/provider/stream.d.ts +1 -1
  84. package/dist/types/session/config.d.ts +14 -0
  85. package/dist/types/session/config.d.ts.map +1 -1
  86. package/dist/types/session/events.d.ts +20 -5
  87. package/dist/types/session/events.d.ts.map +1 -1
  88. package/dist/types/session/events.js.map +1 -1
  89. package/package.json +1 -1
  90. package/src/compaction/verifier.ts +57 -20
  91. package/src/provider/capabilities.ts +17 -0
  92. package/src/provider/errors.ts +13 -0
  93. package/src/provider/fallback.ts +10 -0
  94. package/src/provider/idle-timeout.ts +5 -0
  95. package/src/provider/image-input-refusal.ts +54 -0
  96. package/src/provider/index.ts +2 -0
  97. package/src/provider/retry.ts +42 -4
  98. package/src/public-runtime.ts +3 -0
  99. package/src/runtime/query/deferred-sandbox.ts +110 -0
  100. package/src/runtime/query/executor.ts +25 -3
  101. package/src/runtime/query/index.ts +124 -49
  102. package/src/runtime/query/iteration/index.ts +188 -23
  103. package/src/runtime/query/iteration/phases/compaction-breaker.ts +10 -8
  104. package/src/runtime/query/iteration/phases/compaction.ts +151 -35
  105. package/src/runtime/query/iteration/provider-rejected-image.ts +71 -5
  106. package/src/runtime/query/iteration/request-repair.ts +743 -0
  107. package/src/runtime/query/iteration/stream-turn.ts +11 -1
  108. package/src/runtime/query/request-rich-content.ts +77 -27
  109. package/src/tools/builtins/computer-use.ts +13 -4
  110. package/src/types/provider/errors.ts +33 -5
  111. package/src/types/provider/interface.ts +13 -0
  112. package/src/types/provider/stream.ts +1 -1
  113. package/src/types/session/config.ts +14 -0
  114. package/src/types/session/events.ts +25 -5
@@ -104,6 +104,13 @@ import { AwaitedJobs } from '../jobs/awaited-jobs.js'
104
104
  import type { BackgroundJobRegistry } from '../jobs/registry.js'
105
105
  import { catchUpFromCursor, settlePreStartCancellation } from './cancelled-before-start.js'
106
106
  import { CheckpointManager, findPendingCheckpoint } from './checkpoint.js'
107
+ import {
108
+ type DeferredSandbox,
109
+ type SandboxAcquireMode,
110
+ type SandboxAcquisitionStop,
111
+ SandboxAcquisitionStopped,
112
+ createDeferredSandbox,
113
+ } from './deferred-sandbox.js'
107
114
  import type { EventTranslator } from './events.js'
108
115
  import { finalizeTurn } from './finalize-turn.js'
109
116
  import { GuardCoordinator } from './guard.js'
@@ -1825,6 +1832,10 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
1825
1832
  })
1826
1833
 
1827
1834
  let sandbox: Sandbox | undefined
1835
+ /** Set only for `sandbox.acquire: 'concurrent' | 'on-first-use'`. */
1836
+ let deferredSandbox: DeferredSandbox | undefined
1837
+ /** How a stopped acquisition settles the turn; set where the provider is known. */
1838
+ let applyAcquisitionStop: (acquisition: SandboxAcquisitionStop) => void = () => {}
1828
1839
  // Decided during checkpoint restore, executed after the sandbox
1829
1840
  // exists — the approved tools may well need it.
1830
1841
  let pendingResume: PendingResumePlan | null = null
@@ -2300,6 +2311,20 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
2300
2311
  yield* eventTranslator.drainPending()
2301
2312
  }
2302
2313
 
2314
+ // Checked whether or not a provider is present: a host that mistypes the
2315
+ // option should hear about it before it wires a provider in, not after.
2316
+ const acquireMode: SandboxAcquireMode = turnConfig.sandbox?.acquire ?? 'eager'
2317
+ if (
2318
+ acquireMode !== 'eager' &&
2319
+ acquireMode !== 'concurrent' &&
2320
+ acquireMode !== 'on-first-use'
2321
+ ) {
2322
+ throw new NamzuError({
2323
+ code: 'invalid_config',
2324
+ message: `sandbox.acquire must be 'eager', 'concurrent' or 'on-first-use'; received ${String(acquireMode)}`,
2325
+ details: { acquire: String(acquireMode) },
2326
+ })
2327
+ }
2303
2328
  // --- Sandbox lifecycle: create before iteration loop ---
2304
2329
  if (params.sandboxProvider) {
2305
2330
  const rootAtCwd = turnConfig.sandbox?.workspace === 'working-directory'
@@ -2331,40 +2356,60 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
2331
2356
  },
2332
2357
  })
2333
2358
  }
2334
- const acquisition = await acquireSandbox({
2335
- provider: params.sandboxProvider,
2336
- config: {
2337
- ...(rootAtCwd ? { workingDirectory: ctx.cwd } : {}),
2338
- ...(rootAtCwd && params.additionalDirectories?.length
2339
- ? { additionalDirectories: params.additionalDirectories }
2340
- : {}),
2341
- timeoutMs: turnConfig.sandbox?.timeoutMs,
2342
- memoryLimitMb: turnConfig.sandbox?.memoryLimitMb,
2343
- maxProcesses: turnConfig.sandbox?.maxProcesses,
2344
- },
2345
- signal: ctx.abortController.signal,
2346
- timeoutMs: guard.remainingUntilTimeoutMs(),
2347
- teardownTimeoutMs: sandboxTeardownTimeoutMs,
2348
- onLateTeardown: (result) => {
2349
- if (result.kind === 'destroyed') {
2350
- ctx.log.info('Late sandbox allocation was destroyed after the turn stopped', {
2359
+ const sandboxProvider = params.sandboxProvider
2360
+ const startAcquisition = (signal: AbortSignal) =>
2361
+ acquireSandbox({
2362
+ provider: sandboxProvider,
2363
+ config: {
2364
+ ...(rootAtCwd ? { workingDirectory: ctx.cwd } : {}),
2365
+ ...(rootAtCwd && params.additionalDirectories?.length
2366
+ ? { additionalDirectories: params.additionalDirectories }
2367
+ : {}),
2368
+ timeoutMs: turnConfig.sandbox?.timeoutMs,
2369
+ memoryLimitMb: turnConfig.sandbox?.memoryLimitMb,
2370
+ maxProcesses: turnConfig.sandbox?.maxProcesses,
2371
+ },
2372
+ signal,
2373
+ timeoutMs: guard.remainingUntilTimeoutMs(),
2374
+ teardownTimeoutMs: sandboxTeardownTimeoutMs,
2375
+ onLateTeardown: (result) => {
2376
+ if (result.kind === 'destroyed') {
2377
+ ctx.log.info('Late sandbox allocation was destroyed after the turn stopped', {
2378
+ [NAMZU.TURN_ID]: ctx.turnId,
2379
+ })
2380
+ return
2381
+ }
2382
+ ctx.log.error('Late sandbox allocation did not stop cleanly', {
2351
2383
  [NAMZU.TURN_ID]: ctx.turnId,
2384
+ ...errorAttributes(result.error),
2352
2385
  })
2353
- return
2354
- }
2355
- ctx.log.error('Late sandbox allocation did not stop cleanly', {
2356
- [NAMZU.TURN_ID]: ctx.turnId,
2357
- ...errorAttributes(result.error),
2358
- })
2359
- },
2360
- })
2361
- if (acquisition.kind !== 'created') {
2386
+ },
2387
+ })
2388
+ const publishSandbox = async (acquired: Sandbox): Promise<void> => {
2389
+ sandbox = acquired
2390
+ toolExecutor.setSandbox(acquired)
2391
+
2392
+ await eventTranslator.emitEvent({
2393
+ type: 'sandbox_created',
2394
+ turnId: ctx.turnId,
2395
+ sandboxId: acquired.id,
2396
+ environment: acquired.environment,
2397
+ })
2398
+ }
2399
+ const logSandboxCreated = (acquired: Sandbox): void => {
2400
+ ctx.log.info('Sandbox created for run', {
2401
+ 'namzu.sandbox.id': acquired.id,
2402
+ 'namzu.execution.environment': acquired.environment,
2403
+ 'namzu.runtime.root_dir': acquired.rootDir,
2404
+ })
2405
+ }
2406
+ applyAcquisitionStop = (acquisition) => {
2362
2407
  if (acquisition.createPending) {
2363
2408
  ctx.log.warn(
2364
2409
  'Sandbox creation was unsettled when the turn stopped; any returned handle will be released, but a remote allocation hidden behind a lost response requires provider-side reconciliation or a fleet reaper',
2365
2410
  {
2366
2411
  [NAMZU.TURN_ID]: ctx.turnId,
2367
- 'namzu.sandbox.provider_id': params.sandboxProvider.id,
2412
+ 'namzu.sandbox.provider_id': sandboxProvider.id,
2368
2413
  },
2369
2414
  )
2370
2415
  }
@@ -2374,32 +2419,40 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
2374
2419
  ctx.recorder.setStopReason('timeout')
2375
2420
  ctx.log.warn('Sandbox creation exhausted the turn timeout', {
2376
2421
  [NAMZU.TURN_ID]: ctx.turnId,
2377
- 'namzu.sandbox.provider_id': params.sandboxProvider.id,
2422
+ 'namzu.sandbox.provider_id': sandboxProvider.id,
2378
2423
  ...errorAttributes(acquisition.error),
2379
2424
  })
2380
2425
  }
2381
- yield* resultAssembler.completeTurn(rootSpan)
2382
- // The turn HAS settled, so the outer `finally` must not read
2383
- // this as an abandonment — it would persist a second time.
2384
- settled = true
2385
- return await resultAssembler.finalize()
2386
2426
  }
2387
- sandbox = acquisition.sandbox
2388
- toolExecutor.setSandbox(sandbox)
2389
-
2390
- await eventTranslator.emitEvent({
2391
- type: 'sandbox_created',
2392
- turnId: ctx.turnId,
2393
- sandboxId: sandbox.id,
2394
- environment: sandbox.environment,
2395
- })
2396
- yield* eventTranslator.drainPending()
2397
-
2398
- ctx.log.info('Sandbox created for run', {
2399
- 'namzu.sandbox.id': sandbox.id,
2400
- 'namzu.execution.environment': sandbox.environment,
2401
- 'namzu.runtime.root_dir': sandbox.rootDir,
2402
- })
2427
+ if (acquireMode === 'eager') {
2428
+ const acquisition = await startAcquisition(ctx.abortController.signal)
2429
+ if (acquisition.kind !== 'created') {
2430
+ applyAcquisitionStop(acquisition)
2431
+ yield* resultAssembler.completeTurn(rootSpan)
2432
+ // The turn HAS settled, so the outer `finally` must not read
2433
+ // this as an abandonment — it would persist a second time.
2434
+ settled = true
2435
+ return await resultAssembler.finalize()
2436
+ }
2437
+ await publishSandbox(acquisition.sandbox)
2438
+ yield* eventTranslator.drainPending()
2439
+ logSandboxCreated(acquisition.sandbox)
2440
+ } else {
2441
+ // The handle is published, and `sandbox_created` emitted, when
2442
+ // the provider answers. Until then the executor holds tool
2443
+ // admission on the gate, so nothing runs without it.
2444
+ const deferred = createDeferredSandbox({
2445
+ turnSignal: ctx.abortController.signal,
2446
+ acquire: startAcquisition,
2447
+ publish: async (acquired) => {
2448
+ await publishSandbox(acquired)
2449
+ logSandboxCreated(acquired)
2450
+ },
2451
+ })
2452
+ deferredSandbox = deferred
2453
+ toolExecutor.setSandboxGate(() => deferred.ensure())
2454
+ if (acquireMode === 'concurrent') deferred.start()
2455
+ }
2403
2456
  }
2404
2457
 
2405
2458
  // Before the first model call: the cheapest place to refuse, since
@@ -2525,6 +2578,14 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
2525
2578
 
2526
2579
  yield* iterationOrchestrator.runLoop()
2527
2580
 
2581
+ // A turn that never needed the sandbox still settles it: a failure or
2582
+ // a timeout ends the turn the way an eager acquisition would have, and
2583
+ // a handle that arrives after that is released by the lifecycle.
2584
+ if (deferredSandbox) {
2585
+ await deferredSandbox.settle()
2586
+ yield* eventTranslator.drainPending()
2587
+ }
2588
+
2528
2589
  yield* finalizeTurn({
2529
2590
  ctx,
2530
2591
  params,
@@ -2536,6 +2597,16 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
2536
2597
  rootSpan,
2537
2598
  })
2538
2599
  } catch (err) {
2600
+ // A deferred acquisition stopped (cancelled, or out of time) and the
2601
+ // turn found out at its first tool call or at its end. Settled the
2602
+ // way the eager path settles it, with nothing else to report.
2603
+ if (err instanceof SandboxAcquisitionStopped) {
2604
+ applyAcquisitionStop(err.acquisition)
2605
+ yield* eventTranslator.drainPending()
2606
+ yield* resultAssembler.completeTurn(rootSpan)
2607
+ settled = true
2608
+ return await resultAssembler.finalize()
2609
+ }
2539
2610
  // Another turn is active in this session: nothing of this turn was
2540
2611
  // written, so there is nothing to settle. The caller gets the
2541
2612
  // refusal itself.
@@ -2546,6 +2617,10 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
2546
2617
  yield* eventTranslator.drainPending()
2547
2618
  yield* resultAssembler.handleError(err, rootSpan)
2548
2619
  } finally {
2620
+ // A pending acquisition is withdrawn and awaited first, so a handle
2621
+ // that arrives now is either published (and released below) or
2622
+ // handed to the late-teardown path. Nothing is left allocated.
2623
+ await deferredSandbox?.cancel()
2549
2624
  yield* releaseTurnResources({
2550
2625
  ctx,
2551
2626
  eventTranslator,
@@ -15,7 +15,7 @@ import {
15
15
  } from '../../../constants/tools/index.js'
16
16
  import { withStreamedMessageIdentity } from '../../../manager/session/streamed-message-identity.js'
17
17
  import { renderSkillsSection } from '../../../persona/assembler.js'
18
- import { resolveProviderCapabilities } from '../../../provider/capabilities.js'
18
+ import { modelReadsImages, resolveProviderCapabilities } from '../../../provider/capabilities.js'
19
19
  import { collectChatCompletion } from '../../../provider/collect-chat-completion.js'
20
20
  import { renderToolSchema } from '../../../registry/tool/schema.js'
21
21
  import { formatCompletionNotification } from '../../../scheduler/completion-inbox.js'
@@ -54,6 +54,7 @@ import { stableDigest } from '../../../utils/hash.js'
54
54
  import { generateMessageId } from '../../../utils/id.js'
55
55
  import { parseStructuredResultJson } from '../../../utils/structured-result-json.js'
56
56
  import { createCallbackInference } from '../callback-inference.js'
57
+ import { SandboxAcquisitionStopped } from '../deferred-sandbox.js'
57
58
  import { deliverDurableInbound } from '../durable-inbound.js'
58
59
  import type { ToolCallOutcome } from '../executor.js'
59
60
  import { projectObservationContext } from '../observation-context.js'
@@ -80,17 +81,27 @@ import { runAdvisoryPhase } from './phases/advisory.js'
80
81
  import { runIterationCheckpoint } from './phases/checkpoint.js'
81
82
  import { compactionBreaker } from './phases/compaction-breaker.js'
82
83
  import {
84
+ AUTOMATIC_COMPACTION_FAILED,
85
+ OVERFLOW_RELIEF_LIMIT,
83
86
  activeContextWindow,
87
+ clearCompactionErrorNote,
88
+ compactionErrorNote,
84
89
  measureContext,
85
90
  relieveOverflow,
86
91
  runCompactionCheck,
92
+ stopOnRefill,
87
93
  } from './phases/compaction.js'
88
94
  import { runHandoffPause } from './phases/handoff.js'
89
95
  import type { IterationContext } from './phases/index.js'
90
96
  import { runPlanGate } from './phases/plan.js'
91
97
  import { runToolReview } from './phases/tool-review.js'
92
98
  import { refreshWorkingMemory, splitWorkingMemoryForRequest } from './phases/working-memory.js'
93
- import { streamWithProviderRejectedImageRecovery } from './provider-rejected-image.js'
99
+ import {
100
+ type TextOnlyModelRecovery,
101
+ type TextOnlyModelRefusal,
102
+ streamWithProviderRejectedImageRecovery,
103
+ } from './provider-rejected-image.js'
104
+ import { type MediaRemoval, RequestRepair } from './request-repair.js'
94
105
  import {
95
106
  type StepShaping,
96
107
  appendWorkContext,
@@ -141,6 +152,11 @@ const CLOSING_RESPONSE_GUIDANCE =
141
152
 
142
153
  export { awaitedJobGraceMs, settleGraceMs } from './outstanding-work.js'
143
154
 
155
+ /** One provider's model, as {@link IterationOrchestrator} remembers it. */
156
+ function textOnlyKey(providerId: string, model: string): string {
157
+ return `${providerId}\u0000${model}`
158
+ }
159
+
144
160
  export class IterationOrchestrator {
145
161
  private ctx: IterationContext
146
162
  private advisoryTurn:
@@ -188,6 +204,73 @@ export class IterationOrchestrator {
188
204
  }
189
205
  /** Rich tool blocks already reported; durable history is scanned every turn. */
190
206
  private readonly warnedRichToolResults = new Set<string>()
207
+ /** What this run has learned about requests its provider rejects as invalid, and how to repair them. */
208
+ private readonly requestRepair = new RequestRepair({
209
+ debug: (message) => this.ctx.log.debug(message),
210
+ onMediaRemoved: (removal) => this.noteMediaRemoved(removal),
211
+ servedBy: () => this.ctx.servingMember?.(),
212
+ })
213
+ /**
214
+ * Provider and model pairs that refused a request for holding an image; they
215
+ * get none for the rest of the run. Keyed by both: one model id can be served
216
+ * by two members of a fallback chain, and only the one that refused is known.
217
+ */
218
+ private readonly textOnlyModels = new Set<string>()
219
+
220
+ /**
221
+ * The member a request for `model` goes to first. A fallback chain keeps
222
+ * serving from the member it last moved to, so that member, not the head,
223
+ * is the one whose reading of images counts.
224
+ */
225
+ private servingModel(model: string): { readonly providerId: string; readonly model: string } {
226
+ const serving = this.ctx.servingMember?.()
227
+ return {
228
+ providerId: serving?.providerId ?? this.ctx.provider.id,
229
+ model: serving?.model ?? model,
230
+ }
231
+ }
232
+
233
+ /** Whether a request for `model` may carry images: the driver's catalogue and this run's own evidence agree. */
234
+ private modelReadsImages(model: string): boolean {
235
+ const serving = this.servingModel(model)
236
+ return (
237
+ !this.textOnlyModels.has(textOnlyKey(serving.providerId, serving.model)) &&
238
+ modelReadsImages(this.ctx.provider, model)
239
+ )
240
+ }
241
+
242
+ /** What a request for `model` does when its model turns out to read text only. */
243
+ private textOnlyRecovery(model: string): TextOnlyModelRecovery {
244
+ return {
245
+ servedBy: () => this.servingModel(model),
246
+ onAccepted: (refused) => this.noteTextOnlyModel(refused),
247
+ }
248
+ }
249
+
250
+ /**
251
+ * A provider refused a request for holding an image its model cannot read, and
252
+ * the retry without images was accepted. Stop sending images to it, and say so
253
+ * once, so the host can remember the model beyond this run.
254
+ */
255
+ private async noteTextOnlyModel(refused: TextOnlyModelRefusal): Promise<void> {
256
+ const key = textOnlyKey(refused.providerId, refused.model ?? '')
257
+ if (this.textOnlyModels.has(key)) return
258
+ this.textOnlyModels.add(key)
259
+ const { providerId, model } = refused
260
+ this.ctx.log.warn('Capability mismatch: the model cannot read images', {
261
+ [GENAI.SYSTEM]: providerId,
262
+ [GENAI.REQUEST_MODEL]: model ?? '',
263
+ })
264
+ await this.ctx.emitEvent?.({
265
+ type: 'capability_warning',
266
+ turnId: this.ctx.recorder.turnId,
267
+ capability: 'vision',
268
+ contentSource: 'history',
269
+ providerId,
270
+ ...(model !== undefined ? { model } : {}),
271
+ message: `Model '${model ?? 'unknown'}' on provider '${providerId}' refused a request that held an image, so images are left out of its requests. They stay in the conversation and are sent again to a model that reads them.`,
272
+ })
273
+ }
191
274
  /**
192
275
  * The previous iteration held a `stopWhen` decision open for a worker.
193
276
  *
@@ -442,16 +525,19 @@ export class IterationOrchestrator {
442
525
  // checkpoint names the message the turn is answering.
443
526
  await this.ctx.checkpointMgr.markLoopStart?.(recorder)
444
527
 
445
- // One context-overflow relief per *stuck point*, not per turn.
528
+ // Context-overflow relief is a two-rung ladder per *stuck point*, not per
529
+ // turn, as in the reference terminal (at most two reactive compactions per
530
+ // overflow): the first relief, then one deeper pass if the retry still
531
+ // overflows.
446
532
  //
447
- // The latch exists so that a second overflow immediately after a
448
- // successful compaction — meaning the prompt is irreducible — stops
449
- // instead of looping. It was never meant to disarm the mechanism for
450
- // the rest of the turn, which is what a turn-scoped flag did: one
451
- // relief at iteration 3 left iteration 40 to die on an overflow with
452
- // obvious moves left. It is cleared by a turn that actually
453
- // succeeded, which is the evidence that the turn is no longer stuck.
454
- let overflowRelieved = false
533
+ // The limit exists so that an overflow that survives both rungs — an
534
+ // irreducible prompt — stops instead of looping. It was never meant to
535
+ // disarm the mechanism for the rest of the turn, which is what a
536
+ // turn-scoped flag did: one relief at iteration 3 left iteration 40 to die
537
+ // on an overflow with obvious moves left. The count is cleared by a turn
538
+ // that actually succeeded, which is the evidence that the turn is no
539
+ // longer stuck.
540
+ let overflowReliefs = 0
455
541
  // The output cap a provider told us fits beside this prompt. One request's
456
542
  // worth: it is cleared by a call that went through.
457
543
  let outputCapOverride: number | undefined
@@ -814,6 +900,7 @@ export class IterationOrchestrator {
814
900
  this.projectObservations(requestHistory),
815
901
  this.ctx.turnConfig.maxRequestRichContentBytes ??
816
902
  DEFAULT_MAX_REQUEST_RICH_CONTENT_BYTES,
903
+ { imagesReadable: this.modelReadsImages(stepModel) },
817
904
  )
818
905
  appendWorkContext(shaping, messages, iterationNum, step)
819
906
  if (closingDirective) messages.push(closingDirective)
@@ -989,6 +1076,8 @@ export class IterationOrchestrator {
989
1076
  stepMessageId,
990
1077
  {
991
1078
  onAccepted: (identity) => this.acceptProviderRejectedImage(identity),
1079
+ textOnly: this.textOnlyRecovery(stepModel),
1080
+ repair: this.requestRepair,
992
1081
  },
993
1082
  Boolean(
994
1083
  this.ctx.reviewAnswer || this.ctx.structuredOutput?.review || this.ctx.advisoryCtx,
@@ -1042,7 +1131,8 @@ export class IterationOrchestrator {
1042
1131
 
1043
1132
  // The turn went through, so the turn is not sitting on an
1044
1133
  // irreducible prompt any more. Re-arm relief for the next one.
1045
- overflowRelieved = false
1134
+ overflowReliefs = 0
1135
+ clearCompactionErrorNote(this.ctx)
1046
1136
  outputCapOverride = undefined
1047
1137
  if (response.finishReason !== 'length') this.outputContinuations = 0
1048
1138
 
@@ -1895,6 +1985,12 @@ export class IterationOrchestrator {
1895
1985
  yield* this.ctx.drainPending()
1896
1986
  } catch (err) {
1897
1987
  const cancelled = this.ctx.abortController.signal.aborted
1988
+ // A deferred sandbox that was cancelled or ran out of time. The
1989
+ // turn settles it as `cancelled` / `timeout` in `query()`, which is
1990
+ // not a failure of this iteration: it is recorded the way a turn
1991
+ // stopped by its own abort or timeout is (a cancelled step, no
1992
+ // error span), so the ledger and the stop reason agree.
1993
+ const sandboxStopped = err instanceof SandboxAcquisitionStopped
1898
1994
 
1899
1995
  // This iteration gets a step too, and it is the one the
1900
1996
  // argument three hundred lines above was actually about.
@@ -1955,12 +2051,13 @@ export class IterationOrchestrator {
1955
2051
  startedAt: stepStartedAt,
1956
2052
  usageBefore,
1957
2053
  costBefore,
1958
- unfinished: cancelled
1959
- ? { finishReason: 'cancelled' }
1960
- : {
1961
- finishReason: 'error',
1962
- failure: describeStepFailure(err, this.ctx.provider.id),
1963
- },
2054
+ unfinished:
2055
+ cancelled || sandboxStopped
2056
+ ? { finishReason: 'cancelled' }
2057
+ : {
2058
+ finishReason: 'error',
2059
+ failure: describeStepFailure(err, this.ctx.provider.id),
2060
+ },
1964
2061
  })
1965
2062
  }
1966
2063
 
@@ -2020,22 +2117,58 @@ export class IterationOrchestrator {
2020
2117
  }
2021
2118
 
2022
2119
  if (
2023
- !overflowRelieved &&
2024
2120
  !(err instanceof AnswerReviewFailure) &&
2025
2121
  classifyProviderError(err, this.ctx.provider.id).code === 'context_length_exceeded'
2026
2122
  ) {
2027
- overflowRelieved = true
2028
- const shed = await relieveOverflow(this.ctx)
2123
+ // The rapid-refill check comes first, before the first reactive relief
2124
+ // of an overflow, as in the reference terminal. A relief that follows
2125
+ // the previous compaction within the refill window counts toward the
2126
+ // limit, so a tool that re-fills the window every time it is called
2127
+ // stops the turn instead of being cleared and called again without end.
2128
+ // The deeper rung of the same overflow is not counted again.
2129
+ if (overflowReliefs === 0 && (await stopOnRefill(this.ctx))) {
2130
+ if (iterationActivity) {
2131
+ this.ctx.activityStore.fail(iterationActivity.id, toErrorMessage(err))
2132
+ }
2133
+ if (this.stopForCompactionBreaker()) break
2134
+ }
2135
+ // A rung that sheds too little to change the verdict is not worth a
2136
+ // provider call; climb to the next one at once.
2137
+ let shed = false
2138
+ while (!shed && overflowReliefs < OVERFLOW_RELIEF_LIMIT) {
2139
+ overflowReliefs++
2140
+ shed = await relieveOverflow(this.ctx, { deeper: overflowReliefs > 1 })
2141
+ }
2029
2142
  if (shed) {
2143
+ // Clearing results is a compaction too: only summaries and reducers
2144
+ // recorded themselves, so a clear-only relief was invisible to the
2145
+ // breaker.
2146
+ compactionBreaker(this.ctx)?.recordCompaction(iterationNum)
2030
2147
  this.ctx.log.info('Retrying the turn after relieving a context overflow', {
2031
2148
  [NAMZU.TURN_ID]: recorder.turnId,
2032
2149
  [NAMZU.ITERATION]: iterationNum,
2150
+ 'namzu.runtime.relief_rung': overflowReliefs,
2033
2151
  })
2034
2152
  if (iterationActivity) {
2035
2153
  this.ctx.activityStore.complete(iterationActivity.id)
2036
2154
  }
2037
2155
  continue
2038
2156
  }
2157
+ // Out of rungs. The conversation is kept; say why compaction could not
2158
+ // make room when it errored, as the reference does.
2159
+ const cause = compactionErrorNote(this.ctx)
2160
+ if (cause !== undefined && err instanceof Error) {
2161
+ try {
2162
+ err.message = `${err.message}${AUTOMATIC_COMPACTION_FAILED}${cause}`
2163
+ } catch {
2164
+ // A frozen error keeps its own message; the cause stays in the log.
2165
+ }
2166
+ }
2167
+ }
2168
+
2169
+ if (sandboxStopped) {
2170
+ if (iterationActivity) this.ctx.activityStore.complete(iterationActivity.id)
2171
+ throw err
2039
2172
  }
2040
2173
 
2041
2174
  if (iterationActivity) {
@@ -2643,6 +2776,32 @@ export class IterationOrchestrator {
2643
2776
  .some((t) => t.state !== 'completed' && t.state !== 'failed' && t.state !== 'canceled')
2644
2777
  }
2645
2778
 
2779
+ /** Media the provider could not process was left out of a request that then went through. */
2780
+ private async noteMediaRemoved(removal: MediaRemoval): Promise<void> {
2781
+ this.ctx.log.warn('Media the provider rejected was left out of the request', {
2782
+ [GENAI.SYSTEM]: this.ctx.provider.id,
2783
+ 'namzu.request.media_removed': removal.count,
2784
+ })
2785
+ const noun = removal.count === 1 ? 'a file' : `${removal.count} files`
2786
+ const it = removal.count === 1 ? 'it' : 'they'
2787
+ await this.ctx.emitEvent?.({
2788
+ type: 'capability_warning',
2789
+ turnId: this.ctx.recorder.turnId,
2790
+ capability: removal.kind === 'document' ? 'documents' : 'vision',
2791
+ // The person's own latest attachment is named as such; a count limit is
2792
+ // not a fault in any file.
2793
+ contentSource: removal.current
2794
+ ? 'media-removed-current'
2795
+ : removal.limit
2796
+ ? 'media-limit'
2797
+ : 'media-removed',
2798
+ providerId: this.ctx.provider.id,
2799
+ message: removal.limit
2800
+ ? `The conversation holds more files than the provider accepts at once, so ${noun} (the oldest) ${removal.count === 1 ? 'was' : 'were'} left out of the request. The conversation still holds ${it}.`
2801
+ : `The provider could not process ${noun}${removal.current ? ' attached to your latest message' : ' in this conversation'}, so ${it} ${removal.count === 1 ? 'was' : 'were'} left out of the request. The conversation still holds ${it}.`,
2802
+ })
2803
+ }
2804
+
2646
2805
  private async acceptProviderRejectedImage(identity: RequestImageIdentity): Promise<void> {
2647
2806
  const repaired = markProviderRejectedImage(this.ctx.recorder.messages, identity)
2648
2807
  if (repaired.count === 0) {
@@ -2700,6 +2859,7 @@ export class IterationOrchestrator {
2700
2859
  const finalMessages = projectRequestRichContent(
2701
2860
  this.projectObservations(finalHistory),
2702
2861
  this.ctx.turnConfig.maxRequestRichContentBytes ?? DEFAULT_MAX_REQUEST_RICH_CONTENT_BYTES,
2862
+ { imagesReadable: this.modelReadsImages(model) },
2703
2863
  )
2704
2864
  appendWorkContext(this.stepShaping(), finalMessages, this.steps.length + 1, { model })
2705
2865
  // The closing directive is the last thing the model reads: after
@@ -2746,8 +2906,13 @@ export class IterationOrchestrator {
2746
2906
  signal: this.ctx.abortController.signal,
2747
2907
  } satisfies import('../../../types/provider/index.js').ChatCompletionParams
2748
2908
  const response = await collectChatCompletion(
2749
- streamWithProviderRejectedImageRecovery(this.ctx.provider, finalParams, (identity) =>
2750
- this.acceptProviderRejectedImage(identity),
2909
+ streamWithProviderRejectedImageRecovery(
2910
+ this.ctx.provider,
2911
+ finalParams,
2912
+ (identity) => this.acceptProviderRejectedImage(identity),
2913
+ undefined,
2914
+ this.textOnlyRecovery(model),
2915
+ this.requestRepair,
2751
2916
  ),
2752
2917
  )
2753
2918
 
@@ -3,9 +3,10 @@
3
3
  *
4
4
  * - Three failures in a row (a pass that ran and shed nothing): every one of
5
5
  * them paid for a summary or a reducer call and left the history as it was,
6
- * so a fourth would do the same. Compaction is skipped from then on and the
7
- * turn carries on; a pass the provider forces by rejecting the prompt is not
8
- * gated.
6
+ * so a fourth would do the same. Proactive compaction is skipped from then on
7
+ * and the turn carries on, silently as far as the person is concerned (a log
8
+ * line and a `breaker_open` event, no sentence); a pass the provider forces
9
+ * by rejecting the prompt is not gated, so the reactive ladder still runs.
9
10
  * - Three compactions in a row, each followed by the context filling back up
10
11
  * within three iterations: something in the loop (a huge file read, a tool
11
12
  * that prints without limit) refills the window faster than any summary can
@@ -27,12 +28,13 @@ export const COMPACTION_REFILL_ITERATIONS = 3
27
28
  /** Consecutive refills that stop the turn. */
28
29
  export const COMPACTION_REFILL_LIMIT = 3
29
30
 
30
- /** One short line, shown once, when the turn stops on refills. */
31
+ /**
32
+ * One short line, shown once, when the turn stops on refills. It names the
33
+ * cause and a step that keeps the conversation: the reference terminal also
34
+ * offers to start over here, and Namzu never does.
35
+ */
31
36
  export const COMPACTION_REFILL_MESSAGE =
32
- 'The context filled up again right after being compacted, three times in a row. A file or tool output is probably too big for the window: read it in smaller parts, or start fresh with /clear.'
33
- /** One short line, shown once, when compaction stops after failures. */
34
- export const COMPACTION_FAILURE_MESSAGE =
35
- 'Automatic compaction stopped after three failed attempts. Run /compact yourself, or start fresh with /clear.'
37
+ 'The context filled up again right after being compacted, three times in a row. A file being read or a tool output is probably too large for the context window. Try reading it in smaller parts.'
36
38
 
37
39
  export class CompactionBreaker {
38
40
  private failures = 0