@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
@@ -7,6 +7,8 @@ import { estimateMessageTokens } from '../../../compaction/token-estimate.js'
7
7
  import {
8
8
  AUTO_CONTINUATION_USER_MESSAGE,
9
9
  REASONING_CUT_USER_MESSAGE,
10
+ REFUSED_REPLY_CALL_RESULT,
11
+ REFUSED_REPLY_RETRY_MESSAGE,
10
12
  } from '../../../constants/continuation.js'
11
13
  import { CHARS_PER_TOKEN } from '../../../constants/limits.js'
12
14
  import {
@@ -37,6 +39,7 @@ import {
37
39
  createAssistantMessage,
38
40
  createRuntimeContextMessage,
39
41
  createSystemMessage,
42
+ createToolMessage,
40
43
  } from '../../../types/message/index.js'
41
44
  import { classifyProviderError, readOutputOverflow } from '../../../types/provider/errors.js'
42
45
  import type { ChatCompletionResponse } from '../../../types/provider/index.js'
@@ -71,6 +74,7 @@ import {
71
74
  projectRequestRichContent,
72
75
  } from '../request-rich-content.js'
73
76
  import { formatSteeringNote, isOperatorUserMessage } from '../steering.js'
77
+ import { effortForModel } from './effort-clamp.js'
74
78
  import { parseNativeCandidate } from './native-output.js'
75
79
  import {
76
80
  drainArrivedTaskCompletions,
@@ -225,7 +229,10 @@ export class IterationOrchestrator {
225
229
  * serving from the member it last moved to, so that member, not the head,
226
230
  * is the one whose reading of images counts.
227
231
  */
228
- private servingModel(model: string): { readonly providerId: string; readonly model: string } {
232
+ private servingModel(model: string): {
233
+ readonly providerId: string
234
+ readonly model: string
235
+ } {
229
236
  const serving = this.ctx.servingMember?.()
230
237
  return {
231
238
  providerId: serving?.providerId ?? this.ctx.provider.id,
@@ -291,6 +298,16 @@ export class IterationOrchestrator {
291
298
  private stopDeferredForOutstandingWork = false
292
299
  /** Nudges sent since the model last finished a call without hitting its output cap. */
293
300
  private outputContinuations = 0
301
+ /**
302
+ * The model a refused request moved to. Once set it replaces the model the
303
+ * step asked for, for the rest of the turn: the request that was refused
304
+ * would only be refused again.
305
+ */
306
+ private refusalPinnedModel: string | undefined
307
+ /** The request has already been re-run on another model once this turn. */
308
+ private refusalFallbackUsed = false
309
+ /** The request has already been re-run on the same model once this turn. */
310
+ private refusalRetryUsed = false
294
311
  /**
295
312
  * `turn_end` (and, for a child, `subagent_stop`) fired when the model
296
313
  * finished and no hook asked for more. `finalizeTurn` then does not fire
@@ -319,7 +336,11 @@ export class IterationOrchestrator {
319
336
  const feedback: string[] = []
320
337
  const ask = async (
321
338
  event: 'turn_end' | 'subagent_stop',
322
- extra: { parentSessionId?: SessionId; parentTurnId?: TurnId; agentType?: string },
339
+ extra: {
340
+ parentSessionId?: SessionId
341
+ parentTurnId?: TurnId
342
+ agentType?: string
343
+ },
323
344
  ): Promise<void> => {
324
345
  const results = await manager.executeHooks(
325
346
  event,
@@ -795,7 +816,7 @@ export class IterationOrchestrator {
795
816
  // Preparation inference belongs to the turn, not the main-model step.
796
817
  usageBefore = { ...recorder.tokenUsage }
797
818
  costBefore = { ...recorder.costInfo }
798
- stepModel = step.model ?? model
819
+ stepModel = this.refusalPinnedModel ?? step.model ?? model
799
820
  await selectContextModel(shaping, stepModel)
800
821
  // Preserve post-compaction preparation/recall semantics. A changed
801
822
  // model needs a second check against its own window; never replay
@@ -824,7 +845,7 @@ export class IterationOrchestrator {
824
845
  // still call any of them by name.
825
846
  this.ctx.toolExecutor.setStepAllowedTools(stepAllowedTools)
826
847
  const enforceToolInputSchema = enforcedModelInputToolNames(this.ctx.tools, llmTools)
827
- stepModel = step.model ?? model
848
+ stepModel = this.refusalPinnedModel ?? step.model ?? model
828
849
 
829
850
  // The closing directive is appended LAST, after every piece of
830
851
  // request-only context below, so it is the final thing the model
@@ -1051,6 +1072,25 @@ export class IterationOrchestrator {
1051
1072
  model: requestedMember.model ?? stepModel,
1052
1073
  chainIndex: requestedMember.index,
1053
1074
  }
1075
+ // The turn's effort was chosen for the turn's own model. A request
1076
+ // moved to another model (a refusal fallback, a live pick) carries
1077
+ // the nearest level that model offers, for this request only.
1078
+ let requestEffort = turnConfig.effort
1079
+ if (requestEffort && stepModel !== model) {
1080
+ requestEffort = effortForModel(
1081
+ this.ctx.provider,
1082
+ stepModel,
1083
+ requestEffort,
1084
+ turnConfig.thinking,
1085
+ )
1086
+ if (requestEffort !== turnConfig.effort) {
1087
+ this.ctx.log.debug('Effort lowered to a level this model offers', {
1088
+ 'namzu.runtime.model': stepModel,
1089
+ 'namzu.runtime.effort_requested': turnConfig.effort,
1090
+ 'namzu.runtime.effort_used': requestEffort,
1091
+ })
1092
+ }
1093
+ }
1054
1094
  const operatorInputAtDispatch = this.latestUserMessage
1055
1095
  const latestReviewUserMessage =
1056
1096
  (this.ctx.reviewAnswer || this.ctx.structuredOutput?.review) && operatorInputAtDispatch
@@ -1092,7 +1132,7 @@ export class IterationOrchestrator {
1092
1132
  outputCapOverride ?? step.maxResponseTokens ?? turnConfig.maxResponseTokens,
1093
1133
  cacheControl: { type: 'auto' },
1094
1134
  ...(turnConfig.thinking ? { thinking: turnConfig.thinking } : {}),
1095
- ...(turnConfig.effort ? { effort: turnConfig.effort } : {}),
1135
+ ...(requestEffort ? { effort: requestEffort } : {}),
1096
1136
  ...(!forceFinalize && turnConfig.webSearch
1097
1137
  ? { webSearch: turnConfig.webSearch }
1098
1138
  : {}),
@@ -1247,6 +1287,34 @@ export class IterationOrchestrator {
1247
1287
  ...contextFigures,
1248
1288
  })
1249
1289
 
1290
+ // A reply the provider declined to give is its own case. It is not
1291
+ // an empty completion: asking again with the same history on the
1292
+ // same model is refused again, at the full price of the prompt.
1293
+ // Recovered here, before the reply can enter the history.
1294
+ if (response.finishReason === 'content_filter') {
1295
+ iterSpan.setStatus({ code: SpanStatusCode.OK })
1296
+ if (iterationActivity) {
1297
+ this.ctx.activityStore.complete(iterationActivity.id, {
1298
+ content: response.message.content,
1299
+ hasToolCalls: false,
1300
+ })
1301
+ }
1302
+ const outcome = yield* this.recoverFromRefusal({
1303
+ response,
1304
+ messageId,
1305
+ stepModel,
1306
+ servedBy,
1307
+ forceFinalize,
1308
+ limitStopReason: forceFinalize ? guardResult.stopReason : undefined,
1309
+ iterationNum,
1310
+ stepStartedAt,
1311
+ usageBefore,
1312
+ costBefore,
1313
+ })
1314
+ if (outcome === 'retry') continue
1315
+ break
1316
+ }
1317
+
1250
1318
  // Durable reasoning and its adapter-private replay envelope ride
1251
1319
  // with the turn they belong to. Trimming therefore removes both;
1252
1320
  // retaining them gives the target adapter enough evidence to
@@ -1681,7 +1749,10 @@ export class IterationOrchestrator {
1681
1749
  [NAMZU.ITERATION]: iterationNum,
1682
1750
  'namzu.runtime.finish_reason': response.finishReason,
1683
1751
  })
1684
- closingStopReason = await this.requestFinalResponse(model, 'end_turn')
1752
+ closingStopReason = await this.requestFinalResponse(
1753
+ this.refusalPinnedModel ?? model,
1754
+ 'end_turn',
1755
+ )
1685
1756
  yield* this.ctx.drainPending()
1686
1757
  }
1687
1758
 
@@ -2873,6 +2944,179 @@ export class IterationOrchestrator {
2873
2944
  })
2874
2945
  }
2875
2946
 
2947
+ /**
2948
+ * A reply the provider declined to give.
2949
+ *
2950
+ * The refused reply never enters the history sent next, except on the
2951
+ * same-model retry, where the stopped partial reply stays so the model can
2952
+ * see what to avoid. At most one automatic retry of each kind per turn:
2953
+ *
2954
+ * 1. Another model of the same provider, when the provider names one and
2955
+ * the host has not turned `refusalFallback` off. The model is kept for
2956
+ * the rest of the turn and announced in a `model_refusal` event.
2957
+ * 2. The same model once more, only when the refused reply had partial
2958
+ * text or tool calls: unfinished calls get an error result and the
2959
+ * model is told the reply was stopped.
2960
+ * 3. Otherwise the turn settles `refused`.
2961
+ */
2962
+ private async *recoverFromRefusal(input: {
2963
+ response: ChatCompletionResponse
2964
+ messageId: MessageId
2965
+ stepModel: string
2966
+ servedBy: StepProvenance
2967
+ forceFinalize: boolean
2968
+ /** The limit that asked for this closing step; it stays the reason the turn ended. */
2969
+ limitStopReason?: StopReason | undefined
2970
+ iterationNum: number
2971
+ stepStartedAt: number
2972
+ usageBefore: TokenUsage
2973
+ costBefore: CostInfo
2974
+ }): AsyncGenerator<SessionEvent, 'retry' | 'settled'> {
2975
+ const { recorder } = this.ctx
2976
+ const { response, servedBy, iterationNum } = input
2977
+ const refusal = response.refusal ?? {}
2978
+ const toolCalls = response.message.toolCalls ?? []
2979
+ const hasPartial =
2980
+ (response.message.content !== null && response.message.content.length > 0) ||
2981
+ toolCalls.length > 0
2982
+
2983
+ this.ctx.log.warn('The model declined to answer', {
2984
+ [NAMZU.TURN_ID]: recorder.turnId,
2985
+ [NAMZU.ITERATION]: iterationNum,
2986
+ 'namzu.runtime.model': servedBy.model,
2987
+ 'namzu.runtime.refusal_category': refusal.category,
2988
+ 'namzu.runtime.has_partial_output': hasPartial,
2989
+ })
2990
+
2991
+ const announce = async (outcome: 'fallback' | 'retry' | 'declined', fallbackModel?: string) => {
2992
+ await this.ctx.emitEvent({
2993
+ type: 'model_refusal',
2994
+ turnId: recorder.turnId,
2995
+ iteration: iterationNum,
2996
+ model: servedBy.model,
2997
+ providerId: servedBy.providerId,
2998
+ outcome,
2999
+ ...(fallbackModel !== undefined ? { fallbackModel } : {}),
3000
+ ...(refusal.category ? { category: refusal.category } : {}),
3001
+ ...(refusal.explanation ? { explanation: refusal.explanation } : {}),
3002
+ })
3003
+ }
3004
+ const closeIteration = async () => {
3005
+ this.recordStep({
3006
+ stepNumber: iterationNum,
3007
+ model: input.stepModel,
3008
+ servedBy,
3009
+ messageId: input.messageId,
3010
+ response,
3011
+ toolResults: [],
3012
+ toolExecutionMs: 0,
3013
+ startedAt: input.stepStartedAt,
3014
+ usageBefore: input.usageBefore,
3015
+ costBefore: input.costBefore,
3016
+ })
3017
+ await this.ctx.emitEvent({
3018
+ type: 'iteration_completed',
3019
+ turnId: recorder.turnId,
3020
+ iteration: iterationNum,
3021
+ hasToolCalls: false,
3022
+ })
3023
+ }
3024
+
3025
+ if (this.ctx.abortController.signal.aborted) {
3026
+ recorder.setStopReason('cancelled')
3027
+ recorder.markCancelled()
3028
+ await closeIteration()
3029
+ yield* this.ctx.drainPending()
3030
+ return 'settled'
3031
+ }
3032
+
3033
+ if (!input.forceFinalize && !this.refusalFallbackUsed && this.ctx.refusalFallback !== 'off') {
3034
+ const next = await this.refusalFallbackModel(input.stepModel, refusal)
3035
+ if (next !== undefined) {
3036
+ this.refusalFallbackUsed = true
3037
+ this.refusalPinnedModel = next
3038
+ await announce('fallback', next)
3039
+ await closeIteration()
3040
+ yield* this.ctx.drainPending()
3041
+ return 'retry'
3042
+ }
3043
+ }
3044
+
3045
+ if (!input.forceFinalize && !this.refusalRetryUsed && hasPartial) {
3046
+ this.refusalRetryUsed = true
3047
+ recorder.pushMessage(
3048
+ withStreamedMessageIdentity(
3049
+ createAssistantMessage(
3050
+ response.message.content,
3051
+ toolCalls.length > 0 ? toolCalls : undefined,
3052
+ response.message.reasoning,
3053
+ response.message.citations,
3054
+ {
3055
+ type: 'model',
3056
+ ...servedBy,
3057
+ ...(response.message.replayState !== undefined
3058
+ ? { replayState: response.message.replayState }
3059
+ : {}),
3060
+ },
3061
+ response.message.textParts,
3062
+ ),
3063
+ input.messageId,
3064
+ ),
3065
+ )
3066
+ for (const call of toolCalls) {
3067
+ recorder.pushMessage(createToolMessage(REFUSED_REPLY_CALL_RESULT, call.id, true))
3068
+ }
3069
+ recorder.pushMessage(
3070
+ createRuntimeContextMessage(REFUSED_REPLY_RETRY_MESSAGE, 'refusal-retry'),
3071
+ )
3072
+ await announce('retry')
3073
+ await closeIteration()
3074
+ yield* this.ctx.drainPending()
3075
+ return 'retry'
3076
+ }
3077
+
3078
+ recorder.setLastError(
3079
+ `${servedBy.model} declined to answer this message${refusal.explanation ? `: ${refusal.explanation}` : '.'}`,
3080
+ )
3081
+ // A closing step that a limit asked for keeps that limit as the reason
3082
+ // the turn ended; the refusal is still announced.
3083
+ recorder.setStopReason(input.limitStopReason ?? 'refused')
3084
+ await announce('declined')
3085
+ await closeIteration()
3086
+ yield* this.ctx.drainPending()
3087
+ return 'settled'
3088
+ }
3089
+
3090
+ /**
3091
+ * Another model to try after `model` declined, or `undefined` when there is
3092
+ * none. Only the provider can say: it names its own recommendation or its
3093
+ * own declared order, checked against what this credential can serve. A
3094
+ * chain member declared with a model of its own cannot be swapped, because
3095
+ * the chain would put its own model back on the request.
3096
+ */
3097
+ private async refusalFallbackModel(
3098
+ model: string,
3099
+ refusal: NonNullable<ChatCompletionResponse['refusal']>,
3100
+ ): Promise<string | undefined> {
3101
+ if (this.ctx.servingMember?.()?.model !== undefined) return undefined
3102
+ const pick = this.ctx.provider.refusalFallbackFor
3103
+ if (!pick) return undefined
3104
+ try {
3105
+ const next = await pick.call(
3106
+ this.ctx.provider,
3107
+ model,
3108
+ refusal,
3109
+ this.ctx.abortController.signal,
3110
+ )
3111
+ return next !== undefined && next.length > 0 && next !== model ? next : undefined
3112
+ } catch (err) {
3113
+ this.ctx.log.warn('The provider could not name a model to retry a refusal on', {
3114
+ 'exception.message': toErrorMessage(err),
3115
+ })
3116
+ return undefined
3117
+ }
3118
+ }
3119
+
2876
3120
  private async requestFinalResponse(
2877
3121
  model: string,
2878
3122
  reason: StopReason,
@@ -2980,6 +3224,16 @@ export class IterationOrchestrator {
2980
3224
  model: servedBy.model,
2981
3225
  })
2982
3226
 
3227
+ // A closing request the provider declined has nothing to add, and its
3228
+ // empty reply must not be stored or sent again.
3229
+ if (response.finishReason === 'content_filter') {
3230
+ this.ctx.log.warn('The closing response was declined by the model', {
3231
+ [NAMZU.TURN_ID]: this.ctx.recorder.turnId,
3232
+ 'namzu.runtime.model': servedBy.model,
3233
+ })
3234
+ return undefined
3235
+ }
3236
+
2983
3237
  const finalMessageId = generateMessageId()
2984
3238
  const assistantMsg = withStreamedMessageIdentity(
2985
3239
  createAssistantMessage(
@@ -224,6 +224,8 @@ export interface IterationContext {
224
224
  readonly compactionConfig?: CompactionConfig
225
225
  /** See `QueryParams.maxOutputContinuations`. */
226
226
  readonly maxOutputContinuations?: number
227
+ /** See `QueryParams.refusalFallback`. */
228
+ readonly refusalFallback?: 'auto' | 'off'
227
229
 
228
230
  /**
229
231
  * What the driver said this model's context window is, resolved once.
@@ -238,6 +238,12 @@ export async function* runToolReview(
238
238
  ...(requiresApproval ? { requiresApproval: true as const } : {}),
239
239
  ...(escalation ? { escalation } : {}),
240
240
  ...(hookPermission ? { hookPermission } : {}),
241
+ ...('scratchpadWrite' in tc && tc.scratchpadWrite
242
+ ? { scratchpadWrite: true as const }
243
+ : {}),
244
+ ...('scratchpadWrite' in tc && tc.scratchpadWrite
245
+ ? { scratchpadWrite: true as const }
246
+ : {}),
241
247
  }
242
248
  })
243
249
  }
@@ -284,6 +284,8 @@ export async function* streamProviderTurn(
284
284
  let reportedFinishReason: ChatCompletionResponse['finishReason'] | undefined
285
285
  // Which limit a 'length' finish reached, when it was the context window.
286
286
  let finishDetail: ChatCompletionResponse['finishDetail']
287
+ // What the provider said about a refusal, kept with the finish that carried it.
288
+ let refusal: ChatCompletionResponse['refusal']
287
289
  let usage: ChatCompletionResponse['usage'] = {
288
290
  promptTokens: 0,
289
291
  completionTokens: 0,
@@ -416,6 +418,7 @@ export async function* streamProviderTurn(
416
418
  finishReason = 'stop'
417
419
  reportedFinishReason = undefined
418
420
  finishDetail = undefined
421
+ refusal = undefined
419
422
  streamedLength = 0
420
423
  lastOutputCall = undefined
421
424
  usage = {
@@ -766,6 +769,7 @@ export async function* streamProviderTurn(
766
769
  reportedFinishReason = chunk.finishReason
767
770
  finishDetail = chunk.finishReason === 'length' ? chunk.finishDetail : undefined
768
771
  }
772
+ if (chunk.refusal) refusal = chunk.refusal
769
773
  // Merge (per-field max), not last-write-wins: a late usage frame that
770
774
  // omits input/cache tokens must not zero the counts seen earlier in the
771
775
  // stream, which would under-report this turn's accumulated usage.
@@ -1061,6 +1065,7 @@ export async function* streamProviderTurn(
1061
1065
  },
1062
1066
  finishReason: effectiveFinishReason,
1063
1067
  ...(effectiveFinishReason === 'length' && finishDetail ? { finishDetail } : {}),
1068
+ ...(effectiveFinishReason === 'content_filter' && refusal ? { refusal } : {}),
1064
1069
  usage,
1065
1070
  }
1066
1071
 
@@ -1051,8 +1051,19 @@ export function createReviewHandler(options: ReviewPolicyOptions = {}): ResumeHa
1051
1051
  mode === 'plan' &&
1052
1052
  options.planModeWrite?.(tc) === true &&
1053
1053
  tc.authorization?.decision !== 'deny'
1054
+ // A file written inside the session's own scratch folder, in any mode but
1055
+ // `strict`: the folder is the session's, so it is not a change anyone
1056
+ // reviews, and not "outside the roots". A rule that asked for review keeps
1057
+ // the call in the batch; a deny still refuses it.
1058
+ const isScratchpadWrite = (tc: ToolCallSummary): boolean =>
1059
+ mode !== 'strict' &&
1060
+ tc.scratchpadWrite === true &&
1061
+ tc.escalation === undefined &&
1062
+ tc.requiresApproval !== true &&
1063
+ !tc.authorization?.explicitReview &&
1064
+ tc.authorization?.decision !== 'deny'
1054
1065
  const reviewable = request.toolCalls.filter(
1055
- (tc) => !(isPlanFileWrite(tc) && !tc.authorization?.explicitReview),
1066
+ (tc) => !(isPlanFileWrite(tc) && !tc.authorization?.explicitReview) && !isScratchpadWrite(tc),
1056
1067
  )
1057
1068
  if (!batchNeedsReview(reviewable, exempt)) {
1058
1069
  return { action: 'approve_tools' }
@@ -1089,7 +1100,7 @@ export function createReviewHandler(options: ReviewPolicyOptions = {}): ResumeHa
1089
1100
  if (
1090
1101
  mode === 'plan' &&
1091
1102
  !onlyReadsOutsideRoots(
1092
- reviewable.filter((tc) => !isPlanFileWrite(tc)),
1103
+ reviewable.filter((tc) => !isPlanFileWrite(tc) && !isScratchpadWrite(tc)),
1093
1104
  exempt,
1094
1105
  )
1095
1106
  ) {
@@ -28,6 +28,8 @@ export interface ToolingBootstrapConfig {
28
28
  workingDirectory: string
29
29
  /** See `QueryParams.additionalDirectories`. */
30
30
  additionalDirectories?: readonly string[]
31
+ /** See `ToolExecutorConfig.scratchpadDirectory`. */
32
+ scratchpadDirectory?: string
31
33
  /** See `QueryParams.outsideRootAccess`. */
32
34
  outsideRootAccess?: 'refuse' | 'review'
33
35
  /** See `QueryParams.sandboxEscape`. */
@@ -99,6 +101,7 @@ export class ToolingBootstrap {
99
101
  ...(config.additionalDirectories?.length
100
102
  ? { additionalDirectories: config.additionalDirectories }
101
103
  : {}),
104
+ ...(config.scratchpadDirectory ? { scratchpadDirectory: config.scratchpadDirectory } : {}),
102
105
  ...(config.outsideRootAccess ? { outsideRootAccess: config.outsideRootAccess } : {}),
103
106
  ...(config.sandboxEscape ? { sandboxEscape: config.sandboxEscape } : {}),
104
107
  permissionMode: config.permissionMode,
@@ -53,6 +53,7 @@ import {
53
53
  applyEnvironmentOverrides,
54
54
  pickEnvironmentEntries,
55
55
  } from '../../utils/process-environment.js'
56
+ import { createTempDir, resolveTempBase } from '../../utils/temp.js'
56
57
  import { walkFilesLocally } from '../file-walk.js'
57
58
  import { assertIsolation, describeIsolation } from '../isolation.js'
58
59
  import type { PtyLoader } from '../terminal.js'
@@ -317,7 +318,7 @@ function detectEnvironment(): DetectedEnvironment {
317
318
  // hosts that mediate namespace helpers and can claim a tier production
318
319
  // cannot actually drive.
319
320
  const bwrap = resolveTrustedWrapper('bwrap')
320
- if (bwrap !== undefined && probeBwrapSpawn(bwrap, buildBwrapArgs(tmpdir()))) {
321
+ if (bwrap !== undefined && probeBwrapSpawn(bwrap, buildBwrapArgs(resolveTempBase()))) {
321
322
  return { environment: 'linux-bwrap', wrapperCommand: bwrap }
322
323
  }
323
324
 
@@ -329,7 +330,10 @@ function detectEnvironment(): DetectedEnvironment {
329
330
 
330
331
  if (platform === 'darwin') {
331
332
  const seatbelt = resolveTrustedWrapper('sandbox-exec')
332
- if (seatbelt !== undefined && probeSandboxSpawn(seatbelt, buildSeatbeltArgs(tmpdir()))) {
333
+ if (
334
+ seatbelt !== undefined &&
335
+ probeSandboxSpawn(seatbelt, buildSeatbeltArgs(resolveTempBase()))
336
+ ) {
333
337
  return { environment: 'macos-seatbelt', wrapperCommand: seatbelt }
334
338
  }
335
339
  }
@@ -1091,7 +1095,7 @@ class LocalSandbox implements Sandbox {
1091
1095
  exitCode,
1092
1096
  stderr: stderr.text,
1093
1097
  timedOut,
1094
- allowedRoots: [...this.roots, tmpdir(), '/tmp', '/var/tmp', '/dev'],
1098
+ allowedRoots: [...this.roots, tmpdir(), resolveTempBase(), '/tmp', '/var/tmp', '/dev'],
1095
1099
  })
1096
1100
  resolvePromise({
1097
1101
  exitCode,
@@ -1229,8 +1233,7 @@ export class LocalSandboxProvider implements SandboxProvider {
1229
1233
  }
1230
1234
  } else {
1231
1235
  // No workspace was named: this allocation owns a fresh temporary root.
1232
- const { mkdtemp } = await import('node:fs/promises')
1233
- const rawDir = await mkdtemp(join(tmpdir(), SANDBOX_TEMP_DIR_PREFIX))
1236
+ const rawDir = await createTempDir(SANDBOX_TEMP_DIR_PREFIX)
1234
1237
  rootDir = canonicalizePath(rawDir)
1235
1238
  removeRootOnDestroy = true
1236
1239
  }
@@ -1,7 +1,5 @@
1
1
  import { createHash } from 'node:crypto'
2
- import { lstatSync, mkdirSync } from 'node:fs'
3
2
  import { link, mkdir, open, readFile, realpath, unlink } from 'node:fs/promises'
4
- import { tmpdir, userInfo } from 'node:os'
5
3
  import { basename, dirname, join } from 'node:path'
6
4
  import type {
7
5
  CheckpointId,
@@ -14,6 +12,12 @@ import type {
14
12
  } from '../types/ids/index.js'
15
13
  import { type ProjectDocument, ProjectDocumentSchema } from '../types/session/records.js'
16
14
  import { type EntityIdKind, asSessionId, generateProjectId, isEntityId } from '../utils/id.js'
15
+ import {
16
+ type PrivateTempRootOptions,
17
+ TempDirectoryError,
18
+ privateTempRoot,
19
+ privateTempRootPath,
20
+ } from '../utils/temp.js'
17
21
  import { uuidv7 } from '../utils/uuidv7.js'
18
22
 
19
23
  /**
@@ -243,68 +247,26 @@ export async function ensureProject(options: EnsureProjectOptions): Promise<Ensu
243
247
  )
244
248
  }
245
249
 
246
- export interface TempRootOptions {
247
- /** Base directory. Default `os.tmpdir()`. */
248
- readonly tmpdir?: string
249
- /** Default `process.platform`. */
250
- readonly platform?: NodeJS.Platform
251
- /**
252
- * Default `process.getuid`. Pass `undefined` explicitly to model a
253
- * platform without it (win32).
254
- */
255
- readonly getuid?: (() => number) | undefined
256
- /** Default `os.userInfo().username`. */
257
- readonly username?: () => string
258
- }
250
+ /** See {@link PrivateTempRootOptions}; `tmpdir` is the base. */
251
+ export type TempRootOptions = PrivateTempRootOptions
259
252
 
260
253
  /**
261
- * The per-user temporary root, `$TMPDIR/namzu-<user>`, created if missing.
262
- *
263
- * `<user>` is the numeric uid where the platform has one; otherwise the first
264
- * 12 hex digits of the SHA-256 of the user name; otherwise `user`. On POSIX
265
- * the directory is created with mode 0700 and refused if it is a symlink, not
266
- * a directory, owned by another uid, or open to group or others. On win32
267
- * `%TEMP%` is already per user: a symlink or junction is refused and the
268
- * owner check is skipped.
254
+ * The per-user temporary root, `<base>/namzu-<user>`, created if missing,
255
+ * where the base is `NAMZU_TMPDIR` or `os.tmpdir()`. A root that is a symlink,
256
+ * not a directory, owned by another uid or open to others is refused with a
257
+ * {@link SessionPathError}; one that cannot be created raises the
258
+ * {@link TempDirectoryError} that names the directory and `NAMZU_TMPDIR`.
259
+ * See {@link privateTempRoot}.
269
260
  */
270
261
  export function tempRoot(options: TempRootOptions = {}): string {
271
- const platform = options.platform ?? process.platform
272
- const getuid = 'getuid' in options ? options.getuid : process.getuid?.bind(process)
273
- let user = 'user'
274
- if (typeof getuid === 'function') {
275
- user = String(getuid())
276
- } else {
277
- try {
278
- const name = (options.username ?? (() => userInfo().username))()
279
- if (name.length > 0) user = sha256Hex(name).slice(0, 12)
280
- } catch {
281
- // No user database entry: fall back to the fixed segment.
282
- }
283
- }
284
- const root = join(options.tmpdir ?? tmpdir(), `namzu-${user}`)
285
262
  try {
286
- mkdirSync(root, { mode: 0o700 })
263
+ return privateTempRoot(options)
287
264
  } catch (error) {
288
- if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
289
- }
290
- const entry = lstatSync(root)
291
- if (entry.isSymbolicLink()) {
292
- throw new SessionPathError(`Refusing temporary root ${root}: it is a symbolic link.`)
293
- }
294
- if (!entry.isDirectory()) {
295
- throw new SessionPathError(`Refusing temporary root ${root}: it is not a directory.`)
296
- }
297
- if (platform !== 'win32' && typeof getuid === 'function') {
298
- if (entry.uid !== getuid()) {
299
- throw new SessionPathError(`Refusing temporary root ${root}: it belongs to uid ${entry.uid}.`)
300
- }
301
- if ((entry.mode & 0o077) !== 0) {
302
- throw new SessionPathError(
303
- `Refusing temporary root ${root}: mode ${(entry.mode & 0o777).toString(8)} lets other users in; it must be 700.`,
304
- )
265
+ if (error instanceof TempDirectoryError && error.reason === 'refused') {
266
+ throw new SessionPathError(error.message)
305
267
  }
268
+ throw error
306
269
  }
307
- return root
308
270
  }
309
271
 
310
272
  /** Every path of one project's layout. Pure: nothing is created or read. */
@@ -451,9 +413,22 @@ export class SessionPaths {
451
413
  return join(this.sessionDir(locator), 'lease.json')
452
414
  }
453
415
 
454
- /** `$TMPDIR/namzu-<user>/<slug>/<session-id>/scratchpad`. Not created; the per-user root is. */
416
+ /** `<temp base>/namzu-<user>/<slug>/<session-id>/scratchpad`. Not created; the per-user root is. */
455
417
  tempDir(sessionId: SessionId): string {
456
418
  this.#tempRoot ??= tempRoot()
457
419
  return join(this.#tempRoot, this.slug, checkId(sessionId, 'session'), 'scratchpad')
458
420
  }
421
+
422
+ /**
423
+ * Where {@link tempDir} is, creating and checking nothing, not even the
424
+ * per-user root: for a caller that makes the folder only when it is first used.
425
+ */
426
+ scratchpadPath(sessionId: SessionId): string {
427
+ return join(
428
+ this.#tempRoot ?? privateTempRootPath(),
429
+ this.slug,
430
+ checkId(sessionId, 'session'),
431
+ 'scratchpad',
432
+ )
433
+ }
459
434
  }
@@ -261,6 +261,7 @@ export class DiskMemoryStore implements ConditionalMemoryStore {
261
261
  const release = await acquireMemoryOperationLock(
262
262
  join(dirname(location.indexPath), 'operation.lock'),
263
263
  this.lockTimeoutMs,
264
+ { logger: this.log },
264
265
  )
265
266
  try {
266
267
  await this.reloadIndex(location)
@@ -418,6 +418,7 @@ export class MarkdownMemoryStore implements ConditionalMemoryStore {
418
418
  const release = await acquireMemoryOperationLock(
419
419
  join(dir, 'operation.lock'),
420
420
  this.lockTimeoutMs,
421
+ { logger: this.log },
421
422
  )
422
423
  try {
423
424
  const { loaded, superseded } = await this.load(dir, mode.importing ?? false)