@namzu/sdk 60.0.0 → 60.2.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 (203) hide show
  1. package/CHANGELOG.md +40 -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/bridge/a2a/mapper.d.ts.map +1 -1
  6. package/dist/bridge/a2a/mapper.js +19 -0
  7. package/dist/bridge/a2a/mapper.js.map +1 -1
  8. package/dist/bridge/acp/update.d.ts.map +1 -1
  9. package/dist/bridge/acp/update.js +1 -0
  10. package/dist/bridge/acp/update.js.map +1 -1
  11. package/dist/bridge/sse/mapper.d.ts.map +1 -1
  12. package/dist/bridge/sse/mapper.js +28 -0
  13. package/dist/bridge/sse/mapper.js.map +1 -1
  14. package/dist/constants/continuation.d.ts +9 -0
  15. package/dist/constants/continuation.d.ts.map +1 -1
  16. package/dist/constants/continuation.js +9 -0
  17. package/dist/constants/continuation.js.map +1 -1
  18. package/dist/contracts/session/api.d.ts +8 -2
  19. package/dist/contracts/session/api.d.ts.map +1 -1
  20. package/dist/manager/session/turn-recorder.d.ts +7 -1
  21. package/dist/manager/session/turn-recorder.d.ts.map +1 -1
  22. package/dist/manager/session/turn-recorder.js +20 -0
  23. package/dist/manager/session/turn-recorder.js.map +1 -1
  24. package/dist/peers/dir.d.ts.map +1 -1
  25. package/dist/peers/dir.js +3 -0
  26. package/dist/peers/dir.js.map +1 -1
  27. package/dist/provider/collect-chat-completion.d.ts.map +1 -1
  28. package/dist/provider/collect-chat-completion.js +4 -0
  29. package/dist/provider/collect-chat-completion.js.map +1 -1
  30. package/dist/provider/fallback.d.ts.map +1 -1
  31. package/dist/provider/fallback.js +11 -0
  32. package/dist/provider/fallback.js.map +1 -1
  33. package/dist/provider/idle-timeout.d.ts.map +1 -1
  34. package/dist/provider/idle-timeout.js +5 -0
  35. package/dist/provider/idle-timeout.js.map +1 -1
  36. package/dist/provider/mock.d.ts +2 -0
  37. package/dist/provider/mock.d.ts.map +1 -1
  38. package/dist/provider/mock.js +16 -8
  39. package/dist/provider/mock.js.map +1 -1
  40. package/dist/provider/model-window-suffix.d.ts.map +1 -1
  41. package/dist/provider/model-window-suffix.js +1 -0
  42. package/dist/provider/model-window-suffix.js.map +1 -1
  43. package/dist/provider/retry.d.ts.map +1 -1
  44. package/dist/provider/retry.js +5 -0
  45. package/dist/provider/retry.js.map +1 -1
  46. package/dist/public-runtime.d.ts +1 -0
  47. package/dist/public-runtime.d.ts.map +1 -1
  48. package/dist/public-runtime.js +1 -0
  49. package/dist/public-runtime.js.map +1 -1
  50. package/dist/runtime/query/events.d.ts.map +1 -1
  51. package/dist/runtime/query/events.js +17 -0
  52. package/dist/runtime/query/events.js.map +1 -1
  53. package/dist/runtime/query/executor.d.ts +23 -0
  54. package/dist/runtime/query/executor.d.ts.map +1 -1
  55. package/dist/runtime/query/executor.js +88 -9
  56. package/dist/runtime/query/executor.js.map +1 -1
  57. package/dist/runtime/query/index.d.ts +14 -0
  58. package/dist/runtime/query/index.d.ts.map +1 -1
  59. package/dist/runtime/query/index.js +14 -0
  60. package/dist/runtime/query/index.js.map +1 -1
  61. package/dist/runtime/query/iteration/effort-clamp.d.ts +13 -0
  62. package/dist/runtime/query/iteration/effort-clamp.d.ts.map +1 -0
  63. package/dist/runtime/query/iteration/effort-clamp.js +42 -0
  64. package/dist/runtime/query/iteration/effort-clamp.js.map +1 -0
  65. package/dist/runtime/query/iteration/index.d.ts +34 -0
  66. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  67. package/dist/runtime/query/iteration/index.js +199 -6
  68. package/dist/runtime/query/iteration/index.js.map +1 -1
  69. package/dist/runtime/query/iteration/phases/context.d.ts +2 -0
  70. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  71. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  72. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  73. package/dist/runtime/query/iteration/phases/tool-review.js +6 -0
  74. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  75. package/dist/runtime/query/iteration/stream-turn.d.ts.map +1 -1
  76. package/dist/runtime/query/iteration/stream-turn.js +6 -0
  77. package/dist/runtime/query/iteration/stream-turn.js.map +1 -1
  78. package/dist/runtime/query/review-policy.d.ts.map +1 -1
  79. package/dist/runtime/query/review-policy.js +12 -2
  80. package/dist/runtime/query/review-policy.js.map +1 -1
  81. package/dist/runtime/query/tooling.d.ts +2 -0
  82. package/dist/runtime/query/tooling.d.ts.map +1 -1
  83. package/dist/runtime/query/tooling.js +1 -0
  84. package/dist/runtime/query/tooling.js.map +1 -1
  85. package/dist/sandbox/provider/local.d.ts.map +1 -1
  86. package/dist/sandbox/provider/local.js +6 -5
  87. package/dist/sandbox/provider/local.js.map +1 -1
  88. package/dist/session/paths.d.ts +15 -22
  89. package/dist/session/paths.d.ts.map +1 -1
  90. package/dist/session/paths.js +19 -46
  91. package/dist/session/paths.js.map +1 -1
  92. package/dist/store/memory/disk.d.ts.map +1 -1
  93. package/dist/store/memory/disk.js +1 -1
  94. package/dist/store/memory/disk.js.map +1 -1
  95. package/dist/store/memory/markdown.d.ts.map +1 -1
  96. package/dist/store/memory/markdown.js +1 -1
  97. package/dist/store/memory/markdown.js.map +1 -1
  98. package/dist/store/memory/operation-lock.d.ts +20 -4
  99. package/dist/store/memory/operation-lock.d.ts.map +1 -1
  100. package/dist/store/memory/operation-lock.js +235 -58
  101. package/dist/store/memory/operation-lock.js.map +1 -1
  102. package/dist/store/session-log/core.d.ts +43 -0
  103. package/dist/store/session-log/core.d.ts.map +1 -1
  104. package/dist/store/session-log/core.js +190 -3
  105. package/dist/store/session-log/core.js.map +1 -1
  106. package/dist/store/session-log/disk.d.ts +1 -1
  107. package/dist/store/session-log/disk.d.ts.map +1 -1
  108. package/dist/store/session-log/disk.js +1 -0
  109. package/dist/store/session-log/disk.js.map +1 -1
  110. package/dist/store/session-log/index.d.ts +1 -1
  111. package/dist/store/session-log/index.d.ts.map +1 -1
  112. package/dist/store/session-log/index.js +1 -1
  113. package/dist/store/session-log/index.js.map +1 -1
  114. package/dist/tools/builtins/bash-cwd.d.ts.map +1 -1
  115. package/dist/tools/builtins/bash-cwd.js +2 -3
  116. package/dist/tools/builtins/bash-cwd.js.map +1 -1
  117. package/dist/tools/builtins/read-media.js +3 -3
  118. package/dist/tools/builtins/read-media.js.map +1 -1
  119. package/dist/turn/reporter.d.ts.map +1 -1
  120. package/dist/turn/reporter.js +13 -0
  121. package/dist/turn/reporter.js.map +1 -1
  122. package/dist/types/agent/query.d.ts +2 -0
  123. package/dist/types/agent/query.d.ts.map +1 -1
  124. package/dist/types/hitl/index.d.ts +8 -0
  125. package/dist/types/hitl/index.d.ts.map +1 -1
  126. package/dist/types/hitl/index.js.map +1 -1
  127. package/dist/types/message/index.d.ts +1 -1
  128. package/dist/types/message/index.d.ts.map +1 -1
  129. package/dist/types/message/index.js +1 -0
  130. package/dist/types/message/index.js.map +1 -1
  131. package/dist/types/provider/chat.d.ts +19 -0
  132. package/dist/types/provider/chat.d.ts.map +1 -1
  133. package/dist/types/provider/config.d.ts +7 -0
  134. package/dist/types/provider/config.d.ts.map +1 -1
  135. package/dist/types/provider/index.d.ts +1 -1
  136. package/dist/types/provider/index.d.ts.map +1 -1
  137. package/dist/types/provider/interface.d.ts +15 -0
  138. package/dist/types/provider/interface.d.ts.map +1 -1
  139. package/dist/types/provider/stream.d.ts +6 -0
  140. package/dist/types/provider/stream.d.ts.map +1 -1
  141. package/dist/types/session/events.d.ts +72 -2
  142. package/dist/types/session/events.d.ts.map +1 -1
  143. package/dist/types/session/events.js +4 -0
  144. package/dist/types/session/events.js.map +1 -1
  145. package/dist/types/session/records.d.ts +34 -34
  146. package/dist/types/session/records.d.ts.map +1 -1
  147. package/dist/types/session/records.js +5 -0
  148. package/dist/types/session/records.js.map +1 -1
  149. package/dist/types/session/stop-reason.d.ts +11 -0
  150. package/dist/types/session/stop-reason.d.ts.map +1 -1
  151. package/dist/utils/temp.d.ts +82 -0
  152. package/dist/utils/temp.d.ts.map +1 -0
  153. package/dist/utils/temp.js +196 -0
  154. package/dist/utils/temp.js.map +1 -0
  155. package/package.json +1 -1
  156. package/src/agents/QueryAgent.ts +1 -0
  157. package/src/bridge/a2a/mapper.ts +22 -0
  158. package/src/bridge/acp/update.ts +6 -1
  159. package/src/bridge/sse/mapper.ts +29 -0
  160. package/src/constants/continuation.ts +13 -0
  161. package/src/contracts/session/api.ts +8 -0
  162. package/src/manager/session/turn-recorder.ts +22 -0
  163. package/src/peers/dir.ts +3 -0
  164. package/src/provider/collect-chat-completion.ts +3 -0
  165. package/src/provider/fallback.ts +17 -0
  166. package/src/provider/idle-timeout.ts +9 -0
  167. package/src/provider/mock.ts +19 -9
  168. package/src/provider/model-window-suffix.ts +1 -0
  169. package/src/provider/retry.ts +9 -0
  170. package/src/public-runtime.ts +13 -0
  171. package/src/runtime/query/events.ts +19 -0
  172. package/src/runtime/query/executor.ts +92 -8
  173. package/src/runtime/query/index.ts +27 -0
  174. package/src/runtime/query/iteration/effort-clamp.ts +46 -0
  175. package/src/runtime/query/iteration/index.ts +260 -6
  176. package/src/runtime/query/iteration/phases/context.ts +2 -0
  177. package/src/runtime/query/iteration/phases/tool-review.ts +6 -0
  178. package/src/runtime/query/iteration/stream-turn.ts +5 -0
  179. package/src/runtime/query/review-policy.ts +13 -2
  180. package/src/runtime/query/tooling.ts +3 -0
  181. package/src/sandbox/provider/local.ts +8 -5
  182. package/src/session/paths.ts +32 -57
  183. package/src/store/memory/disk.ts +1 -0
  184. package/src/store/memory/markdown.ts +1 -0
  185. package/src/store/memory/operation-lock.ts +288 -57
  186. package/src/store/session-log/core.ts +235 -3
  187. package/src/store/session-log/disk.ts +7 -1
  188. package/src/store/session-log/index.ts +4 -0
  189. package/src/tools/builtins/bash-cwd.ts +2 -3
  190. package/src/tools/builtins/read-media.ts +3 -3
  191. package/src/turn/reporter.ts +14 -0
  192. package/src/types/agent/query.ts +2 -0
  193. package/src/types/hitl/index.ts +8 -0
  194. package/src/types/message/index.ts +5 -3
  195. package/src/types/provider/chat.ts +20 -0
  196. package/src/types/provider/config.ts +7 -0
  197. package/src/types/provider/index.ts +1 -0
  198. package/src/types/provider/interface.ts +20 -0
  199. package/src/types/provider/stream.ts +6 -0
  200. package/src/types/session/events.ts +106 -6
  201. package/src/types/session/records.ts +5 -0
  202. package/src/types/session/stop-reason.ts +11 -0
  203. package/src/utils/temp.ts +250 -0
@@ -211,8 +211,59 @@ export interface SessionLog {
211
211
  /** The folded conversation (spec §4.5), spills read back. */
212
212
  messages(options?: { readonly throughSeq?: number }): Promise<Message[]>
213
213
  readSpill(ref: SpillRef, options?: SpillReadOptions): Promise<string>
214
+ /**
215
+ * Be told when the log stops being able to write (the disk is full) and
216
+ * when it can write again. Returns the function that stops the
217
+ * notifications. A log that cannot degrade does not offer this.
218
+ */
219
+ onWriteHealth?(listener: (change: SessionLogWriteHealth) => void): () => void
220
+ }
221
+
222
+ /** Why a log's writes are failing; each is a full or unwritable volume. */
223
+ export type SessionLogDegradedCause = 'disk_full' | 'quota_exceeded' | 'read_only'
224
+
225
+ /**
226
+ * A change in whether a log can write. While `degraded` the session goes on
227
+ * in memory and the records not yet written wait in a bounded buffer.
228
+ */
229
+ export type SessionLogWriteHealth =
230
+ | {
231
+ readonly state: 'degraded'
232
+ readonly cause: SessionLogDegradedCause
233
+ /** Records waiting to be written, including the one that failed. */
234
+ readonly bufferedRecords: number
235
+ readonly bufferedBytes: number
236
+ /**
237
+ * Set on the one notification sent when the buffer first overflowed
238
+ * and the oldest waiting records were discarded.
239
+ */
240
+ readonly dropping?: true
241
+ }
242
+ | {
243
+ readonly state: 'recovered'
244
+ /** Records written from the buffer, in order. */
245
+ readonly flushedRecords: number
246
+ /** Records discarded while the buffer was full; the log has a gap where they were. */
247
+ readonly droppedRecords: number
248
+ }
249
+
250
+ /** The cause when `error` is the volume refusing writes, else `undefined`. */
251
+ export function sessionLogDegradedCause(error: unknown): SessionLogDegradedCause | undefined {
252
+ switch ((error as NodeJS.ErrnoException | undefined)?.code) {
253
+ case 'ENOSPC':
254
+ return 'disk_full'
255
+ case 'EDQUOT':
256
+ return 'quota_exceeded'
257
+ case 'EROFS':
258
+ return 'read_only'
259
+ default:
260
+ return undefined
261
+ }
214
262
  }
215
263
 
264
+ /** Default bound on records held in memory while a log cannot write. */
265
+ export const DEFAULT_DEGRADED_BUFFER_BYTES = 8 * 1024 * 1024
266
+
216
267
  /** A draft that is not a valid session record once enveloped. */
217
268
  export class InvalidSessionRecordError extends Error {
218
269
  override readonly name = 'InvalidSessionRecordError'
@@ -271,6 +322,12 @@ export interface SessionLogCoreOptions {
271
322
  * and decisions. `all` or `none` as named.
272
323
  */
273
324
  readonly sync?: 'boundaries' | 'all' | 'none'
325
+ /**
326
+ * Bytes of records kept in memory while the volume refuses writes (full,
327
+ * over quota, read-only). Past it the oldest are discarded. Default
328
+ * {@link DEFAULT_DEGRADED_BUFFER_BYTES}.
329
+ */
330
+ readonly degradedBufferBytes?: number
274
331
  }
275
332
 
276
333
  const BOUNDARY_TYPES: ReadonlySet<string> = new Set([
@@ -289,6 +346,8 @@ const BOUNDARY_TYPES: ReadonlySet<string> = new Set([
289
346
 
290
347
  /** Characters of a spilled message kept inline as its preview. */
291
348
  const PREVIEW_CHARS = 2000
349
+ /** Added to a preview whose full body could not be written. */
350
+ const NOT_SAVED_NOTE = '[… the rest of this output could not be saved: the disk is full]'
292
351
  /** Default byte bound above which a `compaction` record's summary is spilled. */
293
352
  const COMPACTION_SPILL_ABOVE_BYTES = 256 * 1024
294
353
 
@@ -339,6 +398,17 @@ export class SessionLogCore implements SessionLog {
339
398
  /** Bytes of the log this instance has verified and applied. */
340
399
  #synced = 0
341
400
  #torn = 0
401
+ readonly #degradedBufferBytes: number
402
+ /** Records accepted into the chain that the medium has not taken yet, oldest first. */
403
+ #pending: { readonly bytes: Buffer; readonly sync: boolean }[] = []
404
+ #pendingBytes = 0
405
+ /** Bytes the medium holds while records are pending. */
406
+ #diskBytes = 0
407
+ #degradedCause: SessionLogDegradedCause | undefined
408
+ #droppedRecords = 0
409
+ #droppedNoticeSent = false
410
+ #flushedWhileDegraded = 0
411
+ readonly #healthListeners = new Set<(change: SessionLogWriteHealth) => void>()
342
412
 
343
413
  constructor(options: SessionLogCoreOptions) {
344
414
  this.sessionId = options.sessionId
@@ -362,12 +432,40 @@ export class SessionLogCore implements SessionLog {
362
432
  this.#spillAbove,
363
433
  )
364
434
  this.#sync = options.sync ?? 'boundaries'
435
+ this.#degradedBufferBytes = Math.max(
436
+ 0,
437
+ options.degradedBufferBytes ?? DEFAULT_DEGRADED_BUFFER_BYTES,
438
+ )
439
+ }
440
+
441
+ onWriteHealth(listener: (change: SessionLogWriteHealth) => void): () => void {
442
+ this.#healthListeners.add(listener)
443
+ return () => {
444
+ this.#healthListeners.delete(listener)
445
+ }
446
+ }
447
+
448
+ #notify(change: SessionLogWriteHealth): void {
449
+ for (const listener of this.#healthListeners) {
450
+ try {
451
+ listener(change)
452
+ } catch {
453
+ // A listener that throws must not turn a survived write into a failed one.
454
+ }
455
+ }
456
+ }
457
+
458
+ /** True while records wait in memory because the volume refuses writes. */
459
+ get degraded(): boolean {
460
+ return this.#degradedCause !== undefined
365
461
  }
366
462
 
367
463
  // ── state ──
368
464
 
369
465
  /** Catch up with the medium: verify and apply whatever was appended since the last sync. */
370
466
  async #catchUp(): Promise<void> {
467
+ // Records waiting for the disk are ahead of it; the lease keeps others out.
468
+ if (this.#pending.length > 0) return
371
469
  const size = await this.#medium.size()
372
470
  if (size === this.#synced + this.#torn && size >= this.#synced) return
373
471
  if (size < this.#synced) this.#reset()
@@ -395,6 +493,8 @@ export class SessionLogCore implements SessionLog {
395
493
  this.#turns = new SessionTurnState()
396
494
  this.#synced = 0
397
495
  this.#torn = 0
496
+ this.#pending = []
497
+ this.#pendingBytes = 0
398
498
  }
399
499
 
400
500
  async #requireCurrent(lease: SessionLease): Promise<void> {
@@ -461,6 +561,8 @@ export class SessionLogCore implements SessionLog {
461
561
  */
462
562
  release(lease: SessionLease): Promise<void> {
463
563
  return this.#mutex.run(async () => {
564
+ // One last try to save what the disk refused earlier.
565
+ if (this.#pending.length > 0) await this.#flushPending().catch(() => undefined)
464
566
  if (this.#held?.fence === lease.fence) {
465
567
  this.#held = undefined
466
568
  untrackHeldSessionLease(this.#holding)
@@ -655,13 +757,126 @@ export class SessionLogCore implements SessionLog {
655
757
  }
656
758
  const bytes = Buffer.from(line, 'utf8')
657
759
  const sync = this.#sync === 'all' || (this.#sync === 'boundaries' && BOUNDARY_TYPES.has(type))
658
- await this.#medium.append(bytes, this.#synced, sync)
760
+ await this.#persist(bytes, sync)
659
761
  const entry = this.#chain.accept(bytes, this.#synced)
660
762
  this.#turns.apply(entry.record)
661
763
  this.#synced += bytes.byteLength
662
764
  return entry
663
765
  }
664
766
 
767
+ /**
768
+ * Hand a record's bytes to the medium. A volume that refuses writes (full,
769
+ * over quota, read-only) does not fail the record: it waits in a bounded
770
+ * buffer and goes out, in order, with the next record that can be written.
771
+ * Any other error is thrown as before. Caller holds the mutex.
772
+ */
773
+ async #persist(bytes: Buffer, sync: boolean): Promise<void> {
774
+ if (this.#pending.length === 0) {
775
+ try {
776
+ await this.#medium.append(bytes, this.#synced, sync)
777
+ return
778
+ } catch (error) {
779
+ const cause = sessionLogDegradedCause(error)
780
+ if (cause === undefined) throw error
781
+ this.#diskBytes = this.#synced
782
+ this.#degradedCause = cause
783
+ this.#bufferRecord(bytes, sync)
784
+ this.#notify({
785
+ state: 'degraded',
786
+ cause,
787
+ bufferedRecords: this.#pending.length,
788
+ bufferedBytes: this.#pendingBytes,
789
+ })
790
+ return
791
+ }
792
+ }
793
+ const queued = this.#bufferRecord(bytes, sync)
794
+ try {
795
+ await this.#flushPending()
796
+ } catch (error) {
797
+ // Only the volume refusing writes keeps the session going; anything else
798
+ // fails this record, which must then not stay queued.
799
+ if (sessionLogDegradedCause(error) === undefined) {
800
+ const at = this.#pending.indexOf(queued)
801
+ if (at !== -1) {
802
+ this.#pending.splice(at, 1)
803
+ this.#pendingBytes -= queued.bytes.byteLength
804
+ }
805
+ throw error
806
+ }
807
+ }
808
+ }
809
+
810
+ #bufferRecord(bytes: Buffer, sync: boolean): { readonly bytes: Buffer; readonly sync: boolean } {
811
+ const queued = { bytes, sync }
812
+ this.#pending.push(queued)
813
+ this.#pendingBytes += bytes.byteLength
814
+ let dropped = false
815
+ // The newest record always stays, whatever the bound.
816
+ while (this.#pendingBytes > this.#degradedBufferBytes && this.#pending.length > 1) {
817
+ const oldest = this.#pending.shift()
818
+ if (oldest === undefined) break
819
+ this.#pendingBytes -= oldest.bytes.byteLength
820
+ this.#droppedRecords += 1
821
+ dropped = true
822
+ }
823
+ if (dropped && !this.#droppedNoticeSent && this.#degradedCause !== undefined) {
824
+ this.#droppedNoticeSent = true
825
+ this.#notify({
826
+ state: 'degraded',
827
+ cause: this.#degradedCause,
828
+ bufferedRecords: this.#pending.length,
829
+ bufferedBytes: this.#pendingBytes,
830
+ dropping: true,
831
+ })
832
+ }
833
+ return queued
834
+ }
835
+
836
+ /** Write the buffer in order. Throws the error that stopped it; clears the degraded mark when it empties. */
837
+ async #flushPending(): Promise<void> {
838
+ // A refused write may have left part of a record behind; cut it back.
839
+ const size = await this.#medium.size()
840
+ if (size > this.#diskBytes) await this.#medium.truncate(this.#diskBytes, size)
841
+ while (this.#pending.length > 0) {
842
+ const next = this.#pending[0]
843
+ if (next === undefined) break
844
+ await this.#medium.append(next.bytes, this.#diskBytes, next.sync)
845
+ this.#diskBytes += next.bytes.byteLength
846
+ this.#pendingBytes -= next.bytes.byteLength
847
+ this.#pending.shift()
848
+ this.#flushedWhileDegraded += 1
849
+ }
850
+ const flushed = this.#flushedWhileDegraded
851
+ this.#flushedWhileDegraded = 0
852
+ const dropped = this.#droppedRecords
853
+ this.#degradedCause = undefined
854
+ this.#droppedRecords = 0
855
+ this.#droppedNoticeSent = false
856
+ this.#pendingBytes = 0
857
+ this.#notify({ state: 'recovered', flushedRecords: flushed, droppedRecords: dropped })
858
+ }
859
+
860
+ /**
861
+ * Write a message body beside the log. When the volume refuses it the body
862
+ * is given up (`undefined`) rather than the record,; the record's own write
863
+ * then meets the same refusal and degrades the log.
864
+ */
865
+ async #spillOrLose(
866
+ key: string,
867
+ content: Parameters<SpillStore['write']>[1],
868
+ text: string,
869
+ ): Promise<SpillRef | undefined> {
870
+ try {
871
+ return await this.#spills.write(key, content, text)
872
+ } catch (error) {
873
+ const cause = sessionLogDegradedCause(error)
874
+ if (cause === undefined) throw error
875
+ // The record's own write, next, finds the volume refusing and degrades the log.
876
+ return undefined
877
+ }
878
+ }
879
+
665
880
  async #spillIfLarge(original: Record<string, unknown>): Promise<Record<string, unknown>> {
666
881
  const candidate = this.#structuredResultSpilling
667
882
  ? await this.#spillStructuredResults(original)
@@ -684,11 +899,17 @@ export class SessionLogCore implements SessionLog {
684
899
  if (size <= spillAbove) return candidate
685
900
  if (type === 'turn_completed') {
686
901
  if (typeof candidate.result !== 'string') return candidate
687
- const ref = await this.#spills.write(
902
+ const ref = await this.#spillOrLose(
688
903
  `record:${String(candidate.id)}`,
689
904
  'message',
690
905
  candidate.result,
691
906
  )
907
+ if (ref === undefined) {
908
+ return {
909
+ ...candidate,
910
+ result: `${candidate.result.slice(0, PREVIEW_CHARS)}\n${NOT_SAVED_NOTE}`,
911
+ }
912
+ }
692
913
  const preview = `${candidate.result.slice(0, PREVIEW_CHARS)}\n[… ${ref.bytes} bytes spilled to ${ref.path}]`
693
914
  return { ...candidate, result: preview, resultSpill: ref }
694
915
  }
@@ -708,9 +929,20 @@ export class SessionLogCore implements SessionLog {
708
929
  : type === 'message_replaced'
709
930
  ? `record:${String(candidate.id)}`
710
931
  : `message:${String(candidate.messageId)}`
711
- const ref = await this.#spills.write(key, 'message', JSON.stringify(content))
932
+ const ref = await this.#spillOrLose(key, 'message', JSON.stringify(content))
712
933
  const text =
713
934
  typeof content.content === 'string' ? content.content : JSON.stringify(content.content)
935
+ if (ref === undefined) {
936
+ // The body cannot be kept: the record carries its preview alone.
937
+ return {
938
+ ...candidate,
939
+ content: {
940
+ role: content.role,
941
+ content: `${text.slice(0, PREVIEW_CHARS)}\n${NOT_SAVED_NOTE}`,
942
+ ...(typeof content.toolCallId === 'string' ? { toolCallId: content.toolCallId } : {}),
943
+ },
944
+ }
945
+ }
714
946
  const preview = {
715
947
  role: content.role,
716
948
  content: `${text.slice(0, PREVIEW_CHARS)}\n[… ${ref.bytes} bytes spilled to ${ref.path}]`,
@@ -135,7 +135,12 @@ export class DiskLogMedium implements LogMedium {
135
135
  export interface DiskSessionLogOptions
136
136
  extends Pick<
137
137
  SessionLogCoreOptions,
138
- 'now' | 'spillAboveBytes' | 'compactionSpillAboveBytes' | 'structuredResultSpilling' | 'sync'
138
+ | 'now'
139
+ | 'spillAboveBytes'
140
+ | 'compactionSpillAboveBytes'
141
+ | 'structuredResultSpilling'
142
+ | 'sync'
143
+ | 'degradedBufferBytes'
139
144
  > {
140
145
  readonly sessionId: SessionId
141
146
  /** `<session-id>.jsonl`. */
@@ -174,6 +179,7 @@ export class DiskSessionLog extends SessionLogCore {
174
179
  compactionSpillAboveBytes: options.compactionSpillAboveBytes,
175
180
  structuredResultSpilling: options.structuredResultSpilling,
176
181
  sync: options.sync,
182
+ degradedBufferBytes: options.degradedBufferBytes,
177
183
  })
178
184
  this.file = options.file
179
185
  this.sessionDir = options.sessionDir
@@ -21,6 +21,7 @@ export {
21
21
  type ActiveTurnOptions,
22
22
  type AbandonTurnLogOptions,
23
23
  type BeginTurnOptions,
24
+ DEFAULT_DEGRADED_BUFFER_BYTES,
24
25
  InvalidSessionRecordError,
25
26
  type LogMedium,
26
27
  type ReadSessionLogOptions,
@@ -29,10 +30,13 @@ export {
29
30
  SessionLogConflictError,
30
31
  SessionLogCore,
31
32
  type SessionLogCoreOptions,
33
+ type SessionLogDegradedCause,
32
34
  type SessionLogHead,
33
35
  type SessionLogRead,
34
36
  type SessionLogReadSummary,
37
+ type SessionLogWriteHealth,
35
38
  type SessionRecordDraft,
39
+ sessionLogDegradedCause,
36
40
  type TurnStartedDraft,
37
41
  walkSessionLog,
38
42
  } from './core.js'
@@ -18,9 +18,8 @@
18
18
  import { randomUUID } from 'node:crypto'
19
19
  import { statSync } from 'node:fs'
20
20
  import { rm } from 'node:fs/promises'
21
- import { tmpdir } from 'node:os'
22
- import { join } from 'node:path'
23
21
 
22
+ import { tempFilePath } from '../../utils/temp.js'
24
23
  import { type CommandShell, hostCommandShell } from '../command-shell.js'
25
24
  import { pathOutsideRoots, toolRoots } from '../paths.js'
26
25
  import { readBoundedFile } from './bounded-file-read.js'
@@ -55,7 +54,7 @@ export function withCwdCapture(command: string, file: string): string {
55
54
  }
56
55
 
57
56
  export function newCwdFile(): string {
58
- return join(tmpdir(), `namzu-cwd-${randomUUID()}`)
57
+ return tempFilePath(`cwd-${randomUUID()}`)
59
58
  }
60
59
 
61
60
  /** Where this session's next foreground call starts. Synchronous: the spawn must not wait on it. */
@@ -1,7 +1,7 @@
1
1
  import { spawn } from 'node:child_process'
2
- import { mkdtemp, readdir, rm } from 'node:fs/promises'
3
- import { tmpdir } from 'node:os'
2
+ import { readdir, rm } from 'node:fs/promises'
4
3
  import { join } from 'node:path'
4
+ import { createTempDir } from '../../utils/temp.js'
5
5
  import { readBoundedFile } from './bounded-file-read.js'
6
6
  import { screenshotTargetSize } from './computer-use-image.js'
7
7
  import { describeRaster } from './view-image.js'
@@ -254,7 +254,7 @@ export async function readPdfPages(
254
254
  }
255
255
  last = total === null ? PDF_WHOLE_READ_MAX_PAGES : total
256
256
  }
257
- const directory = await mkdtemp(join(tmpdir(), 'namzu-pdf-'))
257
+ const directory = await createTempDir('pdf-')
258
258
  try {
259
259
  const rendered = await renderPages(bytes, first, last, directory, signal)
260
260
  if (!rendered.ok) return { ok: false, message: rendered.message }
@@ -422,6 +422,18 @@ export function createTurnReporter(parentLogger?: Logger): TurnReporter {
422
422
  })
423
423
  break
424
424
 
425
+ case 'model_refusal':
426
+ log.warn('Model declined to answer', {
427
+ [NAMZU.TURN_ID]: event.turnId,
428
+ [NAMZU.ITERATION]: event.iteration,
429
+ 'namzu.turn.from_model': event.model,
430
+ 'namzu.turn.from_provider_id': event.providerId,
431
+ 'namzu.turn.outcome': event.outcome,
432
+ 'namzu.turn.to_model': event.fallbackModel,
433
+ 'namzu.turn.category': event.category,
434
+ })
435
+ break
436
+
425
437
  case 'provider_fallback':
426
438
  // `warn` for the same reason as a retry, and one stronger: the rest
427
439
  // of this turn is being served by a provider the caller did not pick.
@@ -443,6 +455,8 @@ export function createTurnReporter(parentLogger?: Logger): TurnReporter {
443
455
  case 'tool_calls_admitted':
444
456
  case 'compaction_shed':
445
457
  case 'compaction_started':
458
+ case 'session_log_degraded':
459
+ case 'session_log_recovered':
446
460
  // Deliberately silent. The report is what a human reads about a
447
461
  // run; replaying every shed message into it would bury the
448
462
  // summary line that says the pass happened, in exactly the
@@ -98,6 +98,8 @@ export interface QueryAgentConfig extends BaseAgentConfig {
98
98
  maxToolConcurrency?: number
99
99
  /** See {@link QueryParams.maxOutputContinuations}. */
100
100
  maxOutputContinuations?: QueryParams['maxOutputContinuations']
101
+ /** See {@link QueryParams.refusalFallback}. */
102
+ refusalFallback?: QueryParams['refusalFallback']
101
103
  maxToolOutputChars?: number
102
104
  maxToolOutputCharsPerMessage?: number
103
105
  retainedToolPreviewChars?: QueryParams['retainedToolPreviewChars']
@@ -300,6 +300,14 @@ export interface ToolCallSummary {
300
300
  * and `strict`. A host's own handler may ignore it.
301
301
  */
302
302
  skillGrant?: { readonly skill: string }
303
+ /**
304
+ * Set when this call writes a file inside the session's own scratch folder
305
+ * and nothing else about it needs a person: the folder is the session's, so
306
+ * the review policy does not ask about it (outside `strict`, which asks about
307
+ * everything). A rule that asked for review, a protected path and a tool's
308
+ * own `requiresApproval` all keep the call in review whatever this says.
309
+ */
310
+ scratchpadWrite?: true
303
311
  /**
304
312
  * What a `pre_tool_use` hook answered about approving this call, as the
305
313
  * hooks reported it. `ask` is acted on by the kernel (it makes the call an
@@ -367,6 +367,7 @@ export const RUNTIME_CONTEXT_MESSAGE_KINDS = [
367
367
  'peer-notice',
368
368
  'reminder',
369
369
  'repeat-call',
370
+ 'refusal-retry',
370
371
  'steering',
371
372
  'step-context',
372
373
  'structured-output',
@@ -695,9 +696,10 @@ export function createTurnContextEntry(
695
696
  }
696
697
 
697
698
  /** The key a recorded context entry is filed under, or undefined for any other message. */
698
- export function turnContextKey(message: { readonly role: string; readonly source?: unknown }):
699
- | string
700
- | undefined {
699
+ export function turnContextKey(message: {
700
+ readonly role: string
701
+ readonly source?: unknown
702
+ }): string | undefined {
701
703
  if (message.role !== 'user') return undefined
702
704
  const source = message.source as RuntimeContextMessageSource | undefined
703
705
  return source?.type === 'runtime-context' && source.kind === 'turn-context'
@@ -184,6 +184,24 @@ export type ReasoningEffort =
184
184
  | 'max'
185
185
  | 'ultra'
186
186
 
187
+ /**
188
+ * What a provider says about a reply it declined to give.
189
+ *
190
+ * Carried beside `finishReason: 'content_filter'` and set whenever the finish
191
+ * was a refusal or a safety block, so a host can tell a model that declined
192
+ * from a reply that was merely empty. Every field is optional because
193
+ * providers differ in what they report and several report a refusal with no
194
+ * detail at all: an empty object means "refused, nothing more is known".
195
+ */
196
+ export interface ProviderRefusal {
197
+ /** The provider's own classification of why, when it gives one (for example `cyber`). */
198
+ category?: string
199
+ /** The provider's own sentence about the refusal. Show it; do not parse it. */
200
+ explanation?: string
201
+ /** A model the provider says can usually serve the request instead. */
202
+ recommendedModel?: string
203
+ }
204
+
187
205
  export interface ChatCompletionResponse {
188
206
  id: string
189
207
  model: string
@@ -206,5 +224,7 @@ export interface ChatCompletionResponse {
206
224
  * window rather than the output token limit; see `StreamChunk.finishDetail`.
207
225
  */
208
226
  finishDetail?: 'context_window'
227
+ /** Set with `finishReason: 'content_filter'` when the provider declined to answer. */
228
+ refusal?: ProviderRefusal
209
229
  usage: TokenUsage
210
230
  }
@@ -72,6 +72,8 @@ export interface MockTurn {
72
72
  /** Passages this scripted turn cites, emitted before its text. */
73
73
  citations?: import('../message/index.js').Citation[]
74
74
  finishReason?: 'stop' | 'tool_calls' | 'length' | 'content_filter'
75
+ /** What the provider says about a `'content_filter'` finish. Defaults to `{}`. */
76
+ refusal?: import('./chat.js').ProviderRefusal
75
77
  usage?: Partial<import('../common/index.js').TokenUsage>
76
78
  /** Text fragment size. */
77
79
  chunkSize?: number
@@ -106,6 +108,11 @@ export interface MockScript {
106
108
  * registry-level declaration cannot express "a driver with no vision".
107
109
  */
108
110
  capabilities?: ProviderCapabilities
111
+ /**
112
+ * The model this provider names when asked where to re-run a refused
113
+ * request (`LLMProvider.refusalFallbackFor`). Absent means it names none.
114
+ */
115
+ refusalFallbackModel?: string
109
116
  /** Observe every request (assert on tools, toolChoice, cacheControl…). */
110
117
  onRequest?: (params: import('./chat.js').ChatCompletionParams) => void
111
118
  }
@@ -4,6 +4,7 @@ export type {
4
4
  CacheControl,
5
5
  ChatCompletionParams,
6
6
  ChatCompletionResponse,
7
+ ProviderRefusal,
7
8
  ReasoningEffort,
8
9
  ThinkingConfig,
9
10
  } from './chat.js'
@@ -236,4 +236,24 @@ export interface LLMProvider {
236
236
  * run that would otherwise work.
237
237
  */
238
238
  resolveContextWindow?(model: string, signal?: AbortSignal): Promise<number | undefined>
239
+
240
+ /**
241
+ * The model to re-run a refused request on, when this provider has one.
242
+ *
243
+ * Called after `model` declined to answer (`finishReason: 'content_filter'`),
244
+ * with whatever the provider said about it. Answer with a different model
245
+ * this provider and this credential can actually use, or `undefined` when
246
+ * there is none: the turn then does not change model. A driver whose
247
+ * service names a recommended model in `refusal.recommendedModel` should
248
+ * use it only after checking it is one this account can serve, and falls
249
+ * back to its own declared order otherwise.
250
+ *
251
+ * Optional. A provider without it never has its model swapped on a
252
+ * refusal. A request that failed for any other reason does not call this.
253
+ */
254
+ refusalFallbackFor?(
255
+ model: string,
256
+ refusal: import('./chat.js').ProviderRefusal,
257
+ signal?: AbortSignal,
258
+ ): Promise<string | undefined>
239
259
  }
@@ -132,6 +132,12 @@ export interface StreamChunk {
132
132
  * room to continue into. Set only with `finishReason: 'length'`.
133
133
  */
134
134
  finishDetail?: 'context_window'
135
+ /**
136
+ * The provider declined to answer. Set with `finishReason: 'content_filter'`
137
+ * by every driver whose finish was a refusal or a safety block; an empty
138
+ * object when the provider gave no detail.
139
+ */
140
+ refusal?: import('./chat.js').ProviderRefusal
135
141
  usage?: TokenUsage
136
142
  error?: string
137
143