@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
@@ -12,6 +12,7 @@ import {
12
12
  type SessionLog,
13
13
  type SessionLogEntry,
14
14
  type SessionLogHead,
15
+ type SessionLogWriteHealth,
15
16
  SessionMessageFold,
16
17
  withMessageId,
17
18
  } from '../../store/session-log/index.js'
@@ -130,6 +131,7 @@ const TURN_BOUND_EVENT_TYPES: ReadonlySet<SessionEventType> = new Set<SessionEve
130
131
  'hosted_tool',
131
132
  'provider_retry',
132
133
  'provider_fallback',
134
+ 'model_refusal',
133
135
  'tool_completed',
134
136
  'user_question_asked',
135
137
  'user_question_answered',
@@ -650,6 +652,9 @@ export class TurnRecorder {
650
652
  this.#leaseHeartbeat = setInterval(() => this.#queueLeaseRenewal(), this.#leaseTtlMs / 2)
651
653
  this.#leaseHeartbeat.unref()
652
654
  }
655
+ this.#stopWatchingHealth ??= this.log.onWriteHealth?.((change) => {
656
+ for (const listener of this.#healthListeners) listener(change)
657
+ })
653
658
  const head = await this.log.head()
654
659
  if (head === null) {
655
660
  this.#lastEntry = await this.log.append(lease, {
@@ -680,6 +685,21 @@ export class TurnRecorder {
680
685
  }
681
686
  }
682
687
 
688
+ /**
689
+ * Be told when the session log stops writing (the disk is full) and when
690
+ * it writes again. Sent while the recorder holds the log, from `open` to
691
+ * `release`.
692
+ */
693
+ onLogWriteHealth(listener: (change: SessionLogWriteHealth) => void): () => void {
694
+ this.#healthListeners.add(listener)
695
+ return () => {
696
+ this.#healthListeners.delete(listener)
697
+ }
698
+ }
699
+
700
+ readonly #healthListeners = new Set<(change: SessionLogWriteHealth) => void>()
701
+ #stopWatchingHealth: (() => void) | undefined
702
+
683
703
  /** Serialize timer and write-triggered renewals so release uses the latest token. */
684
704
  #queueLeaseRenewal(): void {
685
705
  this.#leaseRenewal = this.#leaseRenewal
@@ -724,6 +744,8 @@ export class TurnRecorder {
724
744
 
725
745
  /** Give up a lease the recorder claimed. Never throws. */
726
746
  async release(): Promise<void> {
747
+ this.#stopWatchingHealth?.()
748
+ this.#stopWatchingHealth = undefined
727
749
  if (!this.#ownsLease || !this.#lease) return
728
750
  this.#released = true
729
751
  if (this.#leaseHeartbeat !== undefined) clearInterval(this.#leaseHeartbeat)
package/src/peers/dir.ts CHANGED
@@ -123,6 +123,9 @@ function peerRuntimeDirCandidates(options: ResolvePeerRuntimeDirOptions): readon
123
123
  const candidates: string[] = []
124
124
  if (options.env.XDG_RUNTIME_DIR) candidates.push(join(options.env.XDG_RUNTIME_DIR, 'namzu'))
125
125
  candidates.push(join(options.namzuHome, 'run'))
126
+ // The operating system's own temporary directory, not `NAMZU_TMPDIR`: this
127
+ // is the last resort for a socket path, and a relocated base is usually a
128
+ // long path that would not fit the platform's socket-path limit.
126
129
  candidates.push(join(options.tmpDir ?? tmpdir(), `namzu-${options.uid ?? 'user'}`))
127
130
  return candidates
128
131
  }
@@ -72,6 +72,7 @@ export async function collectChatCompletion(
72
72
  let replayState: unknown
73
73
  let finishReason: ChatCompletionResponse['finishReason'] = 'stop'
74
74
  let finishDetail: ChatCompletionResponse['finishDetail']
75
+ let refusal: ChatCompletionResponse['refusal']
75
76
  let usage: ChatCompletionResponse['usage'] = {
76
77
  promptTokens: 0,
77
78
  completionTokens: 0,
@@ -158,6 +159,7 @@ export async function collectChatCompletion(
158
159
  finishReason = chunk.finishReason
159
160
  finishDetail = chunk.finishReason === 'length' ? chunk.finishDetail : undefined
160
161
  }
162
+ if (chunk.refusal) refusal = chunk.refusal
161
163
  // Merge (per-field max), not last-write-wins: a late frame that omits
162
164
  // input/cache tokens must not zero the counts captured earlier in the stream.
163
165
  if (chunk.usage) usage = mergeTokenUsage(usage, chunk.usage)
@@ -207,6 +209,7 @@ export async function collectChatCompletion(
207
209
  },
208
210
  finishReason,
209
211
  ...(finishDetail ? { finishDetail } : {}),
212
+ ...(refusal && finishReason === 'content_filter' ? { refusal } : {}),
210
213
  usage,
211
214
  }
212
215
  }
@@ -572,6 +572,23 @@ export function withProviderFallback(
572
572
  ) => first.provider.effortLevelsFor?.(model, thinking) ?? [],
573
573
  }
574
574
  : {}),
575
+ // Answered by the member the next request goes to first, the one the
576
+ // cursor rests on: it is the member whose model just declined.
577
+ ...(members.some((member) => member.provider.refusalFallbackFor)
578
+ ? {
579
+ refusalFallbackFor: (
580
+ model: string,
581
+ refusal: Parameters<NonNullable<LLMProvider['refusalFallbackFor']>>[1],
582
+ signal?: AbortSignal,
583
+ ) => {
584
+ const serving = members[cursor] ?? first
585
+ return (
586
+ serving.provider.refusalFallbackFor?.(serving.model ?? model, refusal, signal) ??
587
+ Promise.resolve(undefined)
588
+ )
589
+ },
590
+ }
591
+ : {}),
575
592
  ...(first.provider.resolveContextWindow
576
593
  ? {
577
594
  resolveContextWindow: (model: string, signal?: AbortSignal) =>
@@ -247,6 +247,15 @@ export function withStreamIdleTimeout(
247
247
  ) => provider.effortLevelsFor?.(model, thinking) ?? [],
248
248
  }
249
249
  : {}),
250
+ ...(provider.refusalFallbackFor
251
+ ? {
252
+ refusalFallbackFor: (
253
+ model: string,
254
+ refusal: Parameters<NonNullable<LLMProvider['refusalFallbackFor']>>[1],
255
+ signal?: AbortSignal,
256
+ ) => provider.refusalFallbackFor?.(model, refusal, signal) ?? Promise.resolve(undefined),
257
+ }
258
+ : {}),
250
259
  ...(provider.resolveContextWindow
251
260
  ? {
252
261
  resolveContextWindow: (model: string, signal?: AbortSignal) =>
@@ -49,11 +49,19 @@ export class MockLLMProvider implements LLMProvider {
49
49
  /** Every request this provider has received, in order. */
50
50
  readonly requests: ChatCompletionParams[] = []
51
51
 
52
+ /** Present only when the script names a model to retry a refusal on. */
53
+ readonly refusalFallbackFor?: (model: string) => Promise<string | undefined>
54
+
52
55
  constructor(config: MockScript = {}) {
53
56
  this.capabilities = config.capabilities
54
57
  this.model = config.model ?? FALLBACK_MOCK_MODEL
55
58
  this.responseDelayMs = config.responseDelayMs ?? 0
56
59
  this.onRequest = config.onRequest
60
+ const refusalFallbackModel = config.refusalFallbackModel
61
+ if (refusalFallbackModel !== undefined) {
62
+ this.refusalFallbackFor = async (model) =>
63
+ model === refusalFallbackModel ? undefined : refusalFallbackModel
64
+ }
57
65
  this.nextTurn = config.nextTurn
58
66
  this.turns = config.turns ?? [{ text: config.responseText ?? 'Mock provider response' }] // Back-compat: the old single-string config becomes a one-turn script.
59
67
  }
@@ -158,18 +166,20 @@ export class MockLLMProvider implements LLMProvider {
158
166
  yield { id, delta: { toolCallEnd: { index, id: callId } } }
159
167
  }
160
168
 
169
+ const finishReason: NonNullable<StreamChunk['finishReason']> =
170
+ turn.finishReason ??
171
+ // A call cut off mid-JSON is what the output limit does, and the
172
+ // finish reason is how a consumer tells that from malformed JSON.
173
+ (toolCalls.some((call) => call.truncateArguments === true)
174
+ ? 'length'
175
+ : toolCalls.length > 0
176
+ ? 'tool_calls'
177
+ : 'stop')
161
178
  yield {
162
179
  id,
163
180
  delta: {},
164
- finishReason:
165
- turn.finishReason ??
166
- // A call cut off mid-JSON is what the output limit does, and the
167
- // finish reason is how a consumer tells that from malformed JSON.
168
- (toolCalls.some((call) => call.truncateArguments === true)
169
- ? 'length'
170
- : toolCalls.length > 0
171
- ? 'tool_calls'
172
- : 'stop'),
181
+ finishReason,
182
+ ...(finishReason === 'content_filter' ? { refusal: turn.refusal ?? {} } : {}),
173
183
  usage: { ...EMPTY_USAGE, ...turn.usage },
174
184
  }
175
185
  }
@@ -20,6 +20,7 @@ const MODEL_FIRST_METHODS: ReadonlySet<string> = new Set([
20
20
  'reasoningEffortDefaultFor',
21
21
  'effortLevelsFor',
22
22
  'resolveContextWindow',
23
+ 'refusalFallbackFor',
23
24
  'healthCheck',
24
25
  'doctorCheck',
25
26
  ])
@@ -417,6 +417,15 @@ export function withProviderRetry(
417
417
  ) => provider.effortLevelsFor?.(model, thinking) ?? [],
418
418
  }
419
419
  : {}),
420
+ ...(provider.refusalFallbackFor
421
+ ? {
422
+ refusalFallbackFor: (
423
+ model: string,
424
+ refusal: Parameters<NonNullable<LLMProvider['refusalFallbackFor']>>[1],
425
+ signal?: AbortSignal,
426
+ ) => provider.refusalFallbackFor?.(model, refusal, signal) ?? Promise.resolve(undefined),
427
+ }
428
+ : {}),
420
429
  ...(provider.resolveContextWindow
421
430
  ? {
422
431
  resolveContextWindow: (model: string, signal?: AbortSignal) =>
@@ -291,6 +291,19 @@ export {
291
291
  } from './utils/shell-compress.js'
292
292
  export { createChildAbortController } from './utils/abort.js'
293
293
  export { memoizeAsync } from './utils/memoize.js'
294
+ export {
295
+ NAMZU_TMPDIR_ENV,
296
+ type PrivateTempRootOptions,
297
+ TempDirectoryError,
298
+ type TempDirectoryFailure,
299
+ createTempDir,
300
+ createTempDirSync,
301
+ ensureScratchpadDirectory,
302
+ privateTempRoot,
303
+ privateTempRootPath,
304
+ resolveTempBase,
305
+ tempFilePath,
306
+ } from './utils/temp.js'
294
307
  export { extractFinalResponse } from './utils/conversation.js'
295
308
 
296
309
  // ─── router, runtime, turn ───────────────────────────────────────────────
@@ -79,6 +79,25 @@ export class EventTranslator {
79
79
  this.recorder = recorder
80
80
  this.probes = probeRegistry
81
81
  this.log = resolveLogger(log).child({ [SCOPE_ATTRIBUTE]: 'runtime/query/events' })
82
+ recorder.onLogWriteHealth((change) => {
83
+ // Live only: the log is what cannot be written. The listener runs inside
84
+ // a log write, so it must not wait on one.
85
+ void this.emitEvent(
86
+ change.state === 'degraded'
87
+ ? {
88
+ type: 'session_log_degraded',
89
+ cause: change.cause,
90
+ bufferedRecords: change.bufferedRecords,
91
+ bufferedBytes: change.bufferedBytes,
92
+ ...(change.dropping ? { dropping: true } : {}),
93
+ }
94
+ : {
95
+ type: 'session_log_recovered',
96
+ flushedRecords: change.flushedRecords,
97
+ droppedRecords: change.droppedRecords,
98
+ },
99
+ )
100
+ })
82
101
  }
83
102
 
84
103
  /** Serializes appends and reads against each other. */
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto'
2
- import { join, resolve } from 'node:path'
2
+ import { isAbsolute, join, resolve } from 'node:path'
3
3
  import type { Span } from '@opentelemetry/api'
4
4
  import type { AuthorizationGate } from '../../authorization/gate.js'
5
5
  import { unknownProgramInLine } from '../../authorization/program.js'
@@ -24,7 +24,7 @@ import { fingerprintContent } from '../../tools/builtins/content-fingerprint.js'
24
24
  import { sandboxShellSpawn, withoutBashStartup } from '../../tools/command-shell.js'
25
25
  import { isAlwaysDestructive } from '../../tools/defineTool.js'
26
26
  import { createFileReadTracker } from '../../tools/file-read-tracker.js'
27
- import { pathOutsideRoots, toolRoots } from '../../tools/paths.js'
27
+ import { pathOutsideRoots, resolveWithinAnyReal, toolRoots } from '../../tools/paths.js'
28
28
  import { resolveRetainedOutput } from '../../tools/retained-output.js'
29
29
  import type { ToolManager } from '../../toolsets/manager.js'
30
30
  import type { ToolSourceRef } from '../../toolsets/types.js'
@@ -70,6 +70,7 @@ import { generateToolCallId } from '../../utils/id.js'
70
70
  import type { Logger } from '../../utils/logger.js'
71
71
  import { compressShellOutput } from '../../utils/shell-compress.js'
72
72
  import { parseStructuredResultJson } from '../../utils/structured-result-json.js'
73
+ import { ensureScratchpadDirectory } from '../../utils/temp.js'
73
74
  import { type BackgroundJobRegistry, type JobProcess, bindOwner } from '../jobs/registry.js'
74
75
  import { type BatchDetach, stillRunningResult } from './detached-calls.js'
75
76
  import {
@@ -155,12 +156,15 @@ export interface PreparedToolBatch {
155
156
  readonly escalation?: ToolCallEscalation
156
157
  /** What a `pre_tool_use` hook asked for this call's approval; see `ToolCallSummary.hookPermission`. */
157
158
  readonly hookPermission?: PluginHookPermission
159
+ /** The call writes inside the session's scratch folder; see `ToolCallSummary.scratchpadWrite`. */
160
+ readonly scratchpadWrite?: true
158
161
  }[]
159
162
  }
160
163
 
161
164
  interface OwnedPreparedToolBatch extends PreparedToolBatch {
162
165
  readonly calls: ReadonlyMap<string, PreparedDirectCall>
163
166
  readonly escalations: ReadonlyMap<string, ToolCallEscalation>
167
+ readonly scratchpadWrites: ReadonlySet<string>
164
168
  }
165
169
 
166
170
  /**
@@ -363,6 +367,13 @@ export interface ToolExecutorConfig {
363
367
  workingDirectory: string
364
368
  /** See `ToolContext.additionalDirectories`. */
365
369
  additionalDirectories?: readonly string[]
370
+ /**
371
+ * Where this session's own scratch folder is (it need not exist). It becomes
372
+ * a root of the file tools, and a write inside it needs no review, once a call
373
+ * names it and the folder has been made and checked. Unset on a sandboxed
374
+ * turn and on a session with no layout on disk.
375
+ */
376
+ scratchpadDirectory?: string
366
377
  /** See `QueryParams.outsideRootAccess`. Default `'refuse'`. */
367
378
  outsideRootAccess?: 'refuse' | 'review'
368
379
  /** See `QueryParams.sandboxEscape`. Default `'refuse'`. */
@@ -814,6 +825,68 @@ export class ToolExecutor {
814
825
  }
815
826
 
816
827
  /** True once the turn has a sandbox, or is certain to get one before a tool runs. */
828
+ /** The scratch folder, once a call has named it and it has been made and checked. */
829
+ private scratchpadReady: string | undefined
830
+
831
+ /** The tools' roots now: the turn's, plus the scratch folder once it is ready. */
832
+ private rootsNow(): readonly string[] {
833
+ return toolRoots({
834
+ workingDirectory: this.config.workingDirectory,
835
+ additionalDirectories: this.additionalDirectoriesNow(),
836
+ })
837
+ }
838
+
839
+ private additionalDirectoriesNow(): readonly string[] | undefined {
840
+ if (this.scratchpadReady === undefined) return this.config.additionalDirectories
841
+ return [...(this.config.additionalDirectories ?? []), this.scratchpadReady]
842
+ }
843
+
844
+ /**
845
+ * Make the session's scratch folder a root when a call names it. Lazy: a
846
+ * session that never uses it leaves nothing on disk. The folder must be
847
+ * inside the private temporary root with no link on the way; otherwise it
848
+ * stays outside the roots and a path in it is asked about like any other.
849
+ */
850
+ private admitScratchpad(call: PreparedSafetyCall): void {
851
+ const directory = this.config.scratchpadDirectory
852
+ if (directory === undefined || this.scratchpadReady !== undefined || this.sandboxed) return
853
+ if (call.kind === 'synthetic') return
854
+ let named = false
855
+ try {
856
+ named = JSON.stringify(call.input)?.includes(JSON.stringify(directory).slice(1, -1)) === true
857
+ } catch {
858
+ // An input that cannot be serialised names nothing.
859
+ }
860
+ if (!named) return
861
+ try {
862
+ this.scratchpadReady = ensureScratchpadDirectory(directory)
863
+ } catch (error) {
864
+ this.log.warn('The session scratch folder could not be made, so it stays outside the roots', {
865
+ 'exception.message': error instanceof Error ? error.message : String(error),
866
+ })
867
+ }
868
+ }
869
+
870
+ /** Whether `call` writes a file inside the ready scratch folder and nothing else about it is notable. */
871
+ private async writesScratchpad(
872
+ call: PreparedSafetyCall,
873
+ escalation: ToolCallEscalation | undefined,
874
+ ): Promise<boolean> {
875
+ const root = this.scratchpadReady
876
+ if (root === undefined || escalation !== undefined || call.kind === 'synthetic') return false
877
+ if (call.toolName !== 'write' && call.toolName !== 'edit') return false
878
+ const tool = this.config.tools.get(call.toolName)
879
+ if (!tool?.pathArgument || call.input === null || typeof call.input !== 'object') return false
880
+ const value = (call.input as Record<string, unknown>)[tool.pathArgument]
881
+ if (typeof value !== 'string' || !isAbsolute(value)) return false
882
+ try {
883
+ await resolveWithinAnyReal([root], value)
884
+ return true
885
+ } catch {
886
+ return false
887
+ }
888
+ }
889
+
817
890
  private get sandboxed(): boolean {
818
891
  return this.config.sandbox !== undefined || this.sandboxGate !== undefined
819
892
  }
@@ -1126,6 +1199,7 @@ export class ToolExecutor {
1126
1199
  if ((response.message.toolCalls ?? []).length > 0) await this.awaitSandbox()
1127
1200
  const calls = new Map<string, PreparedDirectCall>()
1128
1201
  const escalations = new Map<string, ToolCallEscalation>()
1202
+ const scratchpadWrites = new Set<string>()
1129
1203
  const earlierPaths: string[] = []
1130
1204
  for (const toolCall of response.message.toolCalls ?? []) {
1131
1205
  const prepared = await prepareDirectCall(
@@ -1137,11 +1211,13 @@ export class ToolExecutor {
1137
1211
  const path = inputPathOf(prepared)
1138
1212
  if (path !== undefined) earlierPaths.push(path)
1139
1213
  calls.set(toolCall.id, call)
1214
+ this.admitScratchpad(call)
1140
1215
  const escalation = await this.escalationOrUnfinished(call)
1141
1216
  if (escalation) escalations.set(toolCall.id, escalation)
1217
+ if (await this.writesScratchpad(call, escalation)) scratchpadWrites.add(toolCall.id)
1142
1218
  }
1143
1219
  this.config.abortSignal.throwIfAborted()
1144
- return this.publishPreparedBatch(calls, escalations)
1220
+ return this.publishPreparedBatch(calls, escalations, scratchpadWrites)
1145
1221
  }
1146
1222
 
1147
1223
  /**
@@ -1250,7 +1326,7 @@ export class ToolExecutor {
1250
1326
  // result that was cut told the model to look.
1251
1327
  const retained =
1252
1328
  tool.name === 'read' && (await resolveRetainedOutput(this.outputDirectory(), value))
1253
- const outside = retained ? undefined : await pathOutsideRoots(toolRoots(this.config), value)
1329
+ const outside = retained ? undefined : await pathOutsideRoots(this.rootsNow(), value)
1254
1330
  if (outside !== undefined) outsidePaths = [outside]
1255
1331
  }
1256
1332
  }
@@ -1307,7 +1383,7 @@ export class ToolExecutor {
1307
1383
  protectedPaths: readonly string[]
1308
1384
  dangerousRemoval?: string
1309
1385
  }> {
1310
- const roots = toolRoots(this.config)
1386
+ const roots = this.rootsNow()
1311
1387
  const protectedPaths: string[] = []
1312
1388
  let outsidePaths: readonly string[] = []
1313
1389
  let removal: string | undefined
@@ -1376,6 +1452,7 @@ export class ToolExecutor {
1376
1452
  }
1377
1453
  const calls = new Map((previous as OwnedPreparedToolBatch).calls)
1378
1454
  const escalations = new Map((previous as OwnedPreparedToolBatch).escalations)
1455
+ const scratchpadWrites = new Set((previous as OwnedPreparedToolBatch).scratchpadWrites)
1379
1456
  const earlierPaths: string[] = []
1380
1457
  for (const toolCall of response.message.toolCalls ?? []) {
1381
1458
  if (changedCallIds.has(toolCall.id)) {
@@ -1384,21 +1461,25 @@ export class ToolExecutor {
1384
1461
  earlierPaths,
1385
1462
  )
1386
1463
  calls.set(toolCall.id, call)
1464
+ this.admitScratchpad(call)
1387
1465
  const escalation = await this.escalationOrUnfinished(call)
1388
1466
  if (escalation) escalations.set(toolCall.id, escalation)
1389
1467
  else escalations.delete(toolCall.id)
1468
+ if (await this.writesScratchpad(call, escalation)) scratchpadWrites.add(toolCall.id)
1469
+ else scratchpadWrites.delete(toolCall.id)
1390
1470
  }
1391
1471
  const prepared = calls.get(toolCall.id)
1392
1472
  const path = prepared ? inputPathOf(prepared) : undefined
1393
1473
  if (path !== undefined) earlierPaths.push(path)
1394
1474
  }
1395
1475
  this.config.abortSignal.throwIfAborted()
1396
- return this.publishPreparedBatch(calls, escalations)
1476
+ return this.publishPreparedBatch(calls, escalations, scratchpadWrites)
1397
1477
  }
1398
1478
 
1399
1479
  private publishPreparedBatch(
1400
1480
  calls: ReadonlyMap<string, PreparedDirectCall>,
1401
1481
  escalations: ReadonlyMap<string, ToolCallEscalation>,
1482
+ scratchpadWrites: ReadonlySet<string>,
1402
1483
  ): PreparedToolBatch {
1403
1484
  const reviewCalls = [...calls.values()]
1404
1485
  .filter(
@@ -1414,12 +1495,14 @@ export class ToolExecutor {
1414
1495
  input: call.input,
1415
1496
  ...(escalation ? { escalation } : {}),
1416
1497
  ...(hookPermission ? { hookPermission } : {}),
1498
+ ...(scratchpadWrites.has(call.toolCall.id) ? { scratchpadWrite: true as const } : {}),
1417
1499
  }
1418
1500
  })
1419
1501
  const batch: OwnedPreparedToolBatch = Object.freeze({
1420
1502
  reviewCalls: Object.freeze(reviewCalls),
1421
1503
  calls: new Map(calls),
1422
1504
  escalations: new Map(escalations),
1505
+ scratchpadWrites: new Set(scratchpadWrites),
1423
1506
  })
1424
1507
  this.preparedBatches.add(batch)
1425
1508
  return batch
@@ -2032,6 +2115,7 @@ export class ToolExecutor {
2032
2115
  } catch {
2033
2116
  needsApproval = true
2034
2117
  }
2118
+ this.admitScratchpad(preparedCall)
2035
2119
  const nestedEscalation = await this.escalationOrUnfinished(preparedCall)
2036
2120
  if (nestedEscalation || needsApproval || (gateResult && gateResult.decision !== 'allow')) {
2037
2121
  const reason = nestedEscalation
@@ -2276,8 +2360,8 @@ export class ToolExecutor {
2276
2360
  sessionId: this.config.sessionId,
2277
2361
  turnId: this.config.turnId,
2278
2362
  workingDirectory: this.config.workingDirectory,
2279
- ...(this.config.additionalDirectories?.length
2280
- ? { additionalDirectories: this.config.additionalDirectories }
2363
+ ...(this.additionalDirectoriesNow()?.length
2364
+ ? { additionalDirectories: this.additionalDirectoriesNow() }
2281
2365
  : {}),
2282
2366
  ...(this.config.authorizationGate
2283
2367
  ? {
@@ -377,6 +377,20 @@ export interface QueryParams {
377
377
  * before; `0` stops at the first cut-off.
378
378
  */
379
379
  maxOutputContinuations?: number
380
+ /**
381
+ * What the turn does when the model declines to answer (its provider
382
+ * finished the reply as a refusal or a safety block).
383
+ *
384
+ * `'auto'` (the default) re-runs the refused request once on another model
385
+ * of the same provider (the one the provider recommends, or the one its
386
+ * own fallback order names) and keeps that model for the rest of the turn;
387
+ * a `model_refusal` event names the swap so a host can keep it for later
388
+ * turns. `'off'` never changes the model: the turn tries once more on the
389
+ * same model only when the refused reply had partial output, and otherwise
390
+ * settles with stop reason `'refused'`. A refused reply is never added to
391
+ * the history sent next.
392
+ */
393
+ refusalFallback?: 'auto' | 'off'
380
394
 
381
395
  /**
382
396
  * Model-visible size cap for a single tool result. Over-budget output is
@@ -1528,6 +1542,17 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
1528
1542
  // pre-approves ends with the turn. Shared by the executor, where the
1529
1543
  // `skill` tool records a grant, and the review phase, which reads it.
1530
1544
  const skillGrants = new SkillGrantSet()
1545
+ // The session's own scratch folder, a root of the file tools once a call
1546
+ // names it. Not for a sandboxed turn (it is not mounted there), and not
1547
+ // for a session whose state is not on disk.
1548
+ let scratchpadDirectory: string | undefined
1549
+ if (!params.sandboxProvider) {
1550
+ try {
1551
+ scratchpadDirectory = ctx.storage.paths?.scratchpadPath(ctx.sessionId)
1552
+ } catch {
1553
+ scratchpadDirectory = undefined
1554
+ }
1555
+ }
1531
1556
  const toolExecutor = ToolingBootstrap.init(
1532
1557
  {
1533
1558
  skillGrants,
@@ -1541,6 +1566,7 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
1541
1566
  ...(params.additionalDirectories?.length
1542
1567
  ? { additionalDirectories: params.additionalDirectories }
1543
1568
  : {}),
1569
+ ...(scratchpadDirectory ? { scratchpadDirectory } : {}),
1544
1570
  ...(params.outsideRootAccess ? { outsideRootAccess: params.outsideRootAccess } : {}),
1545
1571
  ...(params.sandboxEscape ? { sandboxEscape: params.sandboxEscape } : {}),
1546
1572
  permissionMode: () => ctx.permissionMode.current,
@@ -1883,6 +1909,7 @@ export async function* query(params: QueryParams): AsyncGenerator<SessionEvent,
1883
1909
  ...(params.maxOutputContinuations !== undefined
1884
1910
  ? { maxOutputContinuations: params.maxOutputContinuations }
1885
1911
  : {}),
1912
+ ...(params.refusalFallback !== undefined ? { refusalFallback: params.refusalFallback } : {}),
1886
1913
  ...(params.inboundMessages ? { inboundMessages: params.inboundMessages } : {}),
1887
1914
  ...(params.reminders ? { reminders: params.reminders } : {}),
1888
1915
  ...(params.durableInbound ? { durableInbound: params.durableInbound } : {}),
@@ -0,0 +1,46 @@
1
+ import type { LLMProvider, ReasoningEffort, ThinkingConfig } from '../../../types/provider/index.js'
2
+
3
+ /** Lowest to highest, the order the effort menus are drawn in. */
4
+ const EFFORT_ORDER: readonly ReasoningEffort[] = [
5
+ 'none',
6
+ 'minimal',
7
+ 'low',
8
+ 'medium',
9
+ 'high',
10
+ 'xhigh',
11
+ 'max',
12
+ 'ultra',
13
+ ]
14
+
15
+ /**
16
+ * The effort to send to `model` when the turn was configured for another one.
17
+ *
18
+ * A level the model does not offer is rejected by some drivers, so a request
19
+ * moved to a different model (a refusal fallback, a live model pick) carries
20
+ * the nearest level that model supports: the next one down when there is one,
21
+ * otherwise the lowest one above. The menu comes from the provider, never from
22
+ * a copy here. A provider that cannot say what the model offers, or a level
23
+ * the model already offers, leaves the effort exactly as configured.
24
+ */
25
+ export function effortForModel(
26
+ provider: LLMProvider,
27
+ model: string,
28
+ effort: ReasoningEffort,
29
+ thinking?: ThinkingConfig,
30
+ ): ReasoningEffort {
31
+ let menu: readonly ReasoningEffort[] | undefined
32
+ try {
33
+ menu = provider.reasoningEffortLevelsFor
34
+ ? provider.reasoningEffortLevelsFor(model, thinking)
35
+ : provider.effortLevelsFor?.(model, thinking)
36
+ } catch {
37
+ return effort
38
+ }
39
+ if (!menu || menu.length === 0 || menu.includes(effort)) return effort
40
+ const rank = (level: ReasoningEffort) => EFFORT_ORDER.indexOf(level)
41
+ const wanted = rank(effort)
42
+ if (wanted < 0) return effort
43
+ const ranked = menu.filter((level) => rank(level) >= 0).sort((a, b) => rank(a) - rank(b))
44
+ const below = ranked.filter((level) => rank(level) < wanted)
45
+ return below[below.length - 1] ?? ranked[0] ?? effort
46
+ }