@namzu/sdk 61.4.0 → 62.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (302) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/dist/agents/QueryAgent.d.ts.map +1 -1
  3. package/dist/agents/QueryAgent.js +2 -0
  4. package/dist/agents/QueryAgent.js.map +1 -1
  5. package/dist/bridge/acp/permission.d.ts +8 -0
  6. package/dist/bridge/acp/permission.d.ts.map +1 -1
  7. package/dist/bridge/acp/permission.js.map +1 -1
  8. package/dist/bridge/acp/server.d.ts.map +1 -1
  9. package/dist/bridge/acp/server.js +8 -1
  10. package/dist/bridge/acp/server.js.map +1 -1
  11. package/dist/constants/tools/index.d.ts +2 -0
  12. package/dist/constants/tools/index.d.ts.map +1 -1
  13. package/dist/constants/tools/index.js +23 -0
  14. package/dist/constants/tools/index.js.map +1 -1
  15. package/dist/eval/harness-protection.js +1 -1
  16. package/dist/eval/harness-protection.js.map +1 -1
  17. package/dist/learning/cycle.d.ts +5 -3
  18. package/dist/learning/cycle.d.ts.map +1 -1
  19. package/dist/learning/cycle.js +11 -13
  20. package/dist/learning/cycle.js.map +1 -1
  21. package/dist/learning/guidance.d.ts +74 -0
  22. package/dist/learning/guidance.d.ts.map +1 -0
  23. package/dist/learning/guidance.js +259 -0
  24. package/dist/learning/guidance.js.map +1 -0
  25. package/dist/learning/skill.d.ts +2 -2
  26. package/dist/learning/skill.d.ts.map +1 -1
  27. package/dist/learning/skill.js +6 -6
  28. package/dist/learning/skill.js.map +1 -1
  29. package/dist/learning/store.d.ts.map +1 -1
  30. package/dist/learning/store.js +6 -6
  31. package/dist/learning/store.js.map +1 -1
  32. package/dist/pals/communication/ingress-schema.d.ts +8 -8
  33. package/dist/pals/communication/schema.d.ts +2 -2
  34. package/dist/peers/client.d.ts +2 -3
  35. package/dist/peers/client.d.ts.map +1 -1
  36. package/dist/peers/client.js +2 -3
  37. package/dist/peers/client.js.map +1 -1
  38. package/dist/peers/endpoint.d.ts +1 -2
  39. package/dist/peers/endpoint.d.ts.map +1 -1
  40. package/dist/peers/endpoint.js +1 -2
  41. package/dist/peers/endpoint.js.map +1 -1
  42. package/dist/peers/protocol.d.ts.map +1 -1
  43. package/dist/peers/protocol.js +1 -0
  44. package/dist/peers/protocol.js.map +1 -1
  45. package/dist/prompt/coding-agent-doctrine.d.ts +4 -4
  46. package/dist/prompt/coding-agent-doctrine.d.ts.map +1 -1
  47. package/dist/prompt/coding-agent-doctrine.js +5 -3
  48. package/dist/prompt/coding-agent-doctrine.js.map +1 -1
  49. package/dist/prompt/index.d.ts +0 -4
  50. package/dist/prompt/index.d.ts.map +1 -1
  51. package/dist/prompt/index.js +0 -2
  52. package/dist/prompt/index.js.map +1 -1
  53. package/dist/provider/fallback.d.ts.map +1 -1
  54. package/dist/provider/fallback.js +3 -0
  55. package/dist/provider/fallback.js.map +1 -1
  56. package/dist/provider/idle-timeout.d.ts.map +1 -1
  57. package/dist/provider/idle-timeout.js +5 -0
  58. package/dist/provider/idle-timeout.js.map +1 -1
  59. package/dist/provider/model-window-suffix.d.ts.map +1 -1
  60. package/dist/provider/model-window-suffix.js +1 -0
  61. package/dist/provider/model-window-suffix.js.map +1 -1
  62. package/dist/provider/retry.d.ts.map +1 -1
  63. package/dist/provider/retry.js +5 -0
  64. package/dist/provider/retry.js.map +1 -1
  65. package/dist/provider/step-stopped.d.ts +19 -0
  66. package/dist/provider/step-stopped.d.ts.map +1 -0
  67. package/dist/provider/step-stopped.js +25 -0
  68. package/dist/provider/step-stopped.js.map +1 -0
  69. package/dist/provider/token-budget.d.ts.map +1 -1
  70. package/dist/provider/token-budget.js +6 -1
  71. package/dist/provider/token-budget.js.map +1 -1
  72. package/dist/public-runtime.d.ts +3 -14
  73. package/dist/public-runtime.d.ts.map +1 -1
  74. package/dist/public-runtime.js +3 -14
  75. package/dist/public-runtime.js.map +1 -1
  76. package/dist/public-tools.d.ts +0 -2
  77. package/dist/public-tools.d.ts.map +1 -1
  78. package/dist/public-tools.js +0 -2
  79. package/dist/public-tools.js.map +1 -1
  80. package/dist/public-types.d.ts +3 -21
  81. package/dist/public-types.d.ts.map +1 -1
  82. package/dist/runtime/query/iteration/index.d.ts +17 -0
  83. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  84. package/dist/runtime/query/iteration/index.js +168 -5
  85. package/dist/runtime/query/iteration/index.js.map +1 -1
  86. package/dist/runtime/query/iteration/phases/per-call-review.d.ts.map +1 -1
  87. package/dist/runtime/query/iteration/phases/per-call-review.js +4 -5
  88. package/dist/runtime/query/iteration/phases/per-call-review.js.map +1 -1
  89. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  90. package/dist/runtime/query/iteration/phases/tool-review.js +6 -3
  91. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  92. package/dist/runtime/query/iteration/stream-turn.d.ts +9 -1
  93. package/dist/runtime/query/iteration/stream-turn.d.ts.map +1 -1
  94. package/dist/runtime/query/iteration/stream-turn.js +48 -2
  95. package/dist/runtime/query/iteration/stream-turn.js.map +1 -1
  96. package/dist/runtime/query/resume-pending.d.ts.map +1 -1
  97. package/dist/runtime/query/resume-pending.js +7 -4
  98. package/dist/runtime/query/resume-pending.js.map +1 -1
  99. package/dist/runtime/query/review-policy.d.ts +40 -8
  100. package/dist/runtime/query/review-policy.d.ts.map +1 -1
  101. package/dist/runtime/query/review-policy.js +139 -24
  102. package/dist/runtime/query/review-policy.js.map +1 -1
  103. package/dist/runtime/query/turn/completion-audit.d.ts +32 -0
  104. package/dist/runtime/query/turn/completion-audit.d.ts.map +1 -0
  105. package/dist/runtime/query/turn/completion-audit.js +56 -0
  106. package/dist/runtime/query/turn/completion-audit.js.map +1 -0
  107. package/dist/runtime/query/turn/stall-monitor.d.ts +78 -0
  108. package/dist/runtime/query/turn/stall-monitor.d.ts.map +1 -0
  109. package/dist/runtime/query/turn/stall-monitor.js +101 -0
  110. package/dist/runtime/query/turn/stall-monitor.js.map +1 -0
  111. package/dist/session/paths.d.ts +1 -2
  112. package/dist/session/paths.d.ts.map +1 -1
  113. package/dist/session/paths.js +1 -4
  114. package/dist/session/paths.js.map +1 -1
  115. package/dist/tools/builtins/bash.d.ts +2 -1
  116. package/dist/tools/builtins/bash.d.ts.map +1 -1
  117. package/dist/tools/builtins/bash.js +5 -2
  118. package/dist/tools/builtins/bash.js.map +1 -1
  119. package/dist/tools/builtins/content-fingerprint.d.ts +13 -0
  120. package/dist/tools/builtins/content-fingerprint.d.ts.map +1 -1
  121. package/dist/tools/builtins/content-fingerprint.js +25 -0
  122. package/dist/tools/builtins/content-fingerprint.js.map +1 -1
  123. package/dist/tools/builtins/edit.d.ts.map +1 -1
  124. package/dist/tools/builtins/edit.js +7 -2
  125. package/dist/tools/builtins/edit.js.map +1 -1
  126. package/dist/tools/builtins/shell-read-credit.d.ts +15 -0
  127. package/dist/tools/builtins/shell-read-credit.d.ts.map +1 -0
  128. package/dist/tools/builtins/shell-read-credit.js +161 -0
  129. package/dist/tools/builtins/shell-read-credit.js.map +1 -0
  130. package/dist/tools/paths.d.ts +15 -2
  131. package/dist/tools/paths.d.ts.map +1 -1
  132. package/dist/tools/paths.js +17 -2
  133. package/dist/tools/paths.js.map +1 -1
  134. package/dist/turn/evidence-recall.d.ts +1 -1
  135. package/dist/turn/evidence-recall.d.ts.map +1 -1
  136. package/dist/turn/evidence-recall.js +5 -14
  137. package/dist/turn/evidence-recall.js.map +1 -1
  138. package/dist/types/acp/index.d.ts +6 -0
  139. package/dist/types/acp/index.d.ts.map +1 -1
  140. package/dist/types/agent/base.d.ts +4 -0
  141. package/dist/types/agent/base.d.ts.map +1 -1
  142. package/dist/types/hitl/index.d.ts +8 -0
  143. package/dist/types/hitl/index.d.ts.map +1 -1
  144. package/dist/types/hitl/index.js.map +1 -1
  145. package/dist/types/ids/index.d.ts +1 -1
  146. package/dist/types/message/index.d.ts +1 -1
  147. package/dist/types/message/index.d.ts.map +1 -1
  148. package/dist/types/message/index.js +1 -0
  149. package/dist/types/message/index.js.map +1 -1
  150. package/dist/types/provider/interface.d.ts +12 -0
  151. package/dist/types/provider/interface.d.ts.map +1 -1
  152. package/dist/types/session/config.d.ts +25 -0
  153. package/dist/types/session/config.d.ts.map +1 -1
  154. package/dist/types/session/records.d.ts.map +1 -1
  155. package/dist/types/session/records.js +2 -0
  156. package/dist/types/session/records.js.map +1 -1
  157. package/dist/types/session/turn.d.ts +1 -1
  158. package/dist/types/session/turn.d.ts.map +1 -1
  159. package/dist/types/session/turn.js.map +1 -1
  160. package/package.json +1 -1
  161. package/src/agents/QueryAgent.ts +2 -0
  162. package/src/bridge/acp/permission.ts +8 -0
  163. package/src/bridge/acp/server.ts +7 -1
  164. package/src/constants/tools/index.ts +27 -0
  165. package/src/eval/harness-protection.ts +1 -1
  166. package/src/learning/cycle.ts +15 -16
  167. package/src/learning/guidance.ts +339 -0
  168. package/src/learning/skill.ts +7 -7
  169. package/src/learning/store.ts +5 -6
  170. package/src/peers/client.ts +2 -3
  171. package/src/peers/endpoint.ts +1 -2
  172. package/src/peers/protocol.ts +1 -0
  173. package/src/prompt/coding-agent-doctrine.ts +5 -3
  174. package/src/prompt/index.ts +0 -4
  175. package/src/provider/fallback.ts +7 -0
  176. package/src/provider/idle-timeout.ts +8 -0
  177. package/src/provider/model-window-suffix.ts +1 -0
  178. package/src/provider/retry.ts +8 -0
  179. package/src/provider/step-stopped.ts +27 -0
  180. package/src/provider/token-budget.ts +6 -1
  181. package/src/public-runtime.ts +6 -28
  182. package/src/public-tools.ts +0 -3
  183. package/src/public-types.ts +5 -137
  184. package/src/runtime/query/iteration/index.ts +207 -5
  185. package/src/runtime/query/iteration/phases/per-call-review.ts +7 -5
  186. package/src/runtime/query/iteration/phases/tool-review.ts +5 -3
  187. package/src/runtime/query/iteration/stream-turn.ts +51 -1
  188. package/src/runtime/query/resume-pending.ts +11 -4
  189. package/src/runtime/query/review-policy.ts +157 -24
  190. package/src/runtime/query/turn/completion-audit.ts +71 -0
  191. package/src/runtime/query/turn/stall-monitor.ts +158 -0
  192. package/src/session/paths.ts +2 -6
  193. package/src/tools/builtins/bash.ts +6 -2
  194. package/src/tools/builtins/content-fingerprint.ts +25 -0
  195. package/src/tools/builtins/edit.ts +11 -2
  196. package/src/tools/builtins/shell-read-credit.ts +159 -0
  197. package/src/tools/paths.ts +18 -2
  198. package/src/turn/evidence-recall.ts +7 -15
  199. package/src/types/acp/index.ts +6 -0
  200. package/src/types/agent/base.ts +4 -0
  201. package/src/types/hitl/index.ts +8 -0
  202. package/src/types/ids/index.ts +1 -1
  203. package/src/types/message/index.ts +1 -0
  204. package/src/types/provider/interface.ts +13 -0
  205. package/src/types/session/config.ts +27 -0
  206. package/src/types/session/records.ts +2 -0
  207. package/src/types/session/turn.ts +2 -1
  208. package/dist/learning/deprecated.d.ts +0 -86
  209. package/dist/learning/deprecated.d.ts.map +0 -1
  210. package/dist/learning/deprecated.js +0 -22
  211. package/dist/learning/deprecated.js.map +0 -1
  212. package/dist/manager/resident/activity.d.ts +0 -74
  213. package/dist/manager/resident/activity.d.ts.map +0 -1
  214. package/dist/manager/resident/activity.js +0 -125
  215. package/dist/manager/resident/activity.js.map +0 -1
  216. package/dist/manager/resident/agenda.d.ts +0 -167
  217. package/dist/manager/resident/agenda.d.ts.map +0 -1
  218. package/dist/manager/resident/agenda.js +0 -588
  219. package/dist/manager/resident/agenda.js.map +0 -1
  220. package/dist/manager/resident/consumption.d.ts +0 -98
  221. package/dist/manager/resident/consumption.d.ts.map +0 -1
  222. package/dist/manager/resident/consumption.js +0 -237
  223. package/dist/manager/resident/consumption.js.map +0 -1
  224. package/dist/manager/resident/delivery-window.d.ts +0 -28
  225. package/dist/manager/resident/delivery-window.d.ts.map +0 -1
  226. package/dist/manager/resident/delivery-window.js +0 -80
  227. package/dist/manager/resident/delivery-window.js.map +0 -1
  228. package/dist/manager/resident/evidence-recall.d.ts +0 -30
  229. package/dist/manager/resident/evidence-recall.d.ts.map +0 -1
  230. package/dist/manager/resident/evidence-recall.js +0 -297
  231. package/dist/manager/resident/evidence-recall.js.map +0 -1
  232. package/dist/manager/resident/history-disk.d.ts +0 -10
  233. package/dist/manager/resident/history-disk.d.ts.map +0 -1
  234. package/dist/manager/resident/history-disk.js +0 -50
  235. package/dist/manager/resident/history-disk.js.map +0 -1
  236. package/dist/manager/resident/history.d.ts +0 -115
  237. package/dist/manager/resident/history.d.ts.map +0 -1
  238. package/dist/manager/resident/history.js +0 -203
  239. package/dist/manager/resident/history.js.map +0 -1
  240. package/dist/manager/resident/host.d.ts +0 -112
  241. package/dist/manager/resident/host.d.ts.map +0 -1
  242. package/dist/manager/resident/host.js +0 -254
  243. package/dist/manager/resident/host.js.map +0 -1
  244. package/dist/manager/resident/initiative.d.ts +0 -86
  245. package/dist/manager/resident/initiative.d.ts.map +0 -1
  246. package/dist/manager/resident/initiative.js +0 -131
  247. package/dist/manager/resident/initiative.js.map +0 -1
  248. package/dist/manager/resident/loop.d.ts +0 -49
  249. package/dist/manager/resident/loop.d.ts.map +0 -1
  250. package/dist/manager/resident/loop.js +0 -75
  251. package/dist/manager/resident/loop.js.map +0 -1
  252. package/dist/manager/resident/outbox.d.ts +0 -124
  253. package/dist/manager/resident/outbox.d.ts.map +0 -1
  254. package/dist/manager/resident/outbox.js +0 -216
  255. package/dist/manager/resident/outbox.js.map +0 -1
  256. package/dist/manager/resident/proposal.d.ts +0 -65
  257. package/dist/manager/resident/proposal.d.ts.map +0 -1
  258. package/dist/manager/resident/proposal.js +0 -77
  259. package/dist/manager/resident/proposal.js.map +0 -1
  260. package/dist/manager/resident/store.d.ts +0 -108
  261. package/dist/manager/resident/store.d.ts.map +0 -1
  262. package/dist/manager/resident/store.js +0 -205
  263. package/dist/manager/resident/store.js.map +0 -1
  264. package/dist/manager/resident/tool-evidence.d.ts +0 -112
  265. package/dist/manager/resident/tool-evidence.d.ts.map +0 -1
  266. package/dist/manager/resident/tool-evidence.js +0 -289
  267. package/dist/manager/resident/tool-evidence.js.map +0 -1
  268. package/dist/prompt/resident-learning.d.ts +0 -29
  269. package/dist/prompt/resident-learning.d.ts.map +0 -1
  270. package/dist/prompt/resident-learning.js +0 -126
  271. package/dist/prompt/resident-learning.js.map +0 -1
  272. package/dist/prompt/resident-step.d.ts +0 -42
  273. package/dist/prompt/resident-step.d.ts.map +0 -1
  274. package/dist/prompt/resident-step.js +0 -137
  275. package/dist/prompt/resident-step.js.map +0 -1
  276. package/dist/tools/resident-history.d.ts +0 -12
  277. package/dist/tools/resident-history.d.ts.map +0 -1
  278. package/dist/tools/resident-history.js +0 -82
  279. package/dist/tools/resident-history.js.map +0 -1
  280. package/dist/tools/resident-tool-evidence.d.ts +0 -9
  281. package/dist/tools/resident-tool-evidence.d.ts.map +0 -1
  282. package/dist/tools/resident-tool-evidence.js +0 -61
  283. package/dist/tools/resident-tool-evidence.js.map +0 -1
  284. package/src/learning/deprecated.ts +0 -124
  285. package/src/manager/resident/activity.ts +0 -211
  286. package/src/manager/resident/agenda.ts +0 -937
  287. package/src/manager/resident/consumption.ts +0 -335
  288. package/src/manager/resident/delivery-window.ts +0 -99
  289. package/src/manager/resident/evidence-recall.ts +0 -369
  290. package/src/manager/resident/history-disk.ts +0 -63
  291. package/src/manager/resident/history.ts +0 -348
  292. package/src/manager/resident/host.ts +0 -390
  293. package/src/manager/resident/initiative.ts +0 -231
  294. package/src/manager/resident/loop.ts +0 -119
  295. package/src/manager/resident/outbox.ts +0 -341
  296. package/src/manager/resident/proposal.ts +0 -119
  297. package/src/manager/resident/store.ts +0 -279
  298. package/src/manager/resident/tool-evidence.ts +0 -455
  299. package/src/prompt/resident-learning.ts +0 -152
  300. package/src/prompt/resident-step.ts +0 -182
  301. package/src/tools/resident-history.ts +0 -88
  302. package/src/tools/resident-tool-evidence.ts +0 -72
@@ -4,6 +4,7 @@ import {
4
4
  assertNativeStructuredOutputSupported,
5
5
  } from '../../../provider/capabilities.js'
6
6
  import { ProviderRequestError, isProviderRequestError } from '../../../provider/errors.js'
7
+ import { StepStopped } from '../../../provider/step-stopped.js'
7
8
  import { StreamTextAccumulator } from '../../../provider/stream-text.js'
8
9
  import {
9
10
  INTERLEAVED_TOOL_INPUT_PARSE_ERROR,
@@ -120,6 +121,8 @@ export interface StreamingTurnResult {
120
121
  messageId: import('../../../types/ids/index.js').MessageId
121
122
  /** Captured only for host review, at the provider-chain dispatch boundary. */
122
123
  requestMessages?: readonly Message[]
124
+ /** The step was cut short because its reasoning reached the cap before it acted. */
125
+ reasoningCapped?: boolean
123
126
  }
124
127
 
125
128
  /**
@@ -249,6 +252,12 @@ export async function* streamProviderTurn(
249
252
  captureRequest = false,
250
253
  /** The session the turn belongs to, named on the chat span as `gen_ai.conversation.id`. */
251
254
  sessionId?: import('../../../types/ids/index.js').SessionId,
255
+ /**
256
+ * The most characters of reasoning this step may stream before it has made a
257
+ * call. When it is reached the request is stopped on purpose (the abort carries
258
+ * a {@link StepStopped} reason) and the step ends with what it produced so far.
259
+ */
260
+ reasoningCapChars?: number,
252
261
  ): AsyncGenerator<SessionEvent, StreamingTurnResult> {
253
262
  assertNativeStructuredOutputSupported(provider, params)
254
263
  assertHostedWebSearchSupported(provider, params)
@@ -355,8 +364,20 @@ export async function* streamProviderTurn(
355
364
  /** The stream broke tool-call framing; nothing it sent becomes a tool call. */
356
365
  let framingViolated = false
357
366
 
367
+ // The step's own stop: aborting it ends the request without being a caller's
368
+ // Stop, so the turn is not cancelled by it.
369
+ const stepStop = new AbortController()
370
+ let reasoningCapped = false
371
+ let reasoningStreamed = 0
358
372
  const streamParams = {
359
373
  ...params,
374
+ ...(reasoningCapChars !== undefined
375
+ ? {
376
+ signal: params.signal
377
+ ? AbortSignal.any([params.signal, stepStop.signal])
378
+ : stepStop.signal,
379
+ }
380
+ : {}),
360
381
  stream: true,
361
382
  } satisfies import('../../../types/provider/index.js').ChatCompletionParams
362
383
  let requestMessages: readonly Message[] | undefined
@@ -420,6 +441,7 @@ export async function* streamProviderTurn(
420
441
  finishDetail = undefined
421
442
  refusal = undefined
422
443
  streamedLength = 0
444
+ reasoningStreamed = 0
423
445
  lastOutputCall = undefined
424
446
  usage = {
425
447
  promptTokens: 0,
@@ -554,6 +576,7 @@ export async function* streamProviderTurn(
554
576
  if (reasoning.text) {
555
577
  bucket.text += reasoning.text
556
578
  streamedLength += reasoning.text.length
579
+ reasoningStreamed += reasoning.text.length
557
580
  await emitEvent({
558
581
  type: 'reasoning_delta',
559
582
  turnId,
@@ -576,6 +599,28 @@ export async function* streamProviderTurn(
576
599
  })
577
600
  yield* drainPending()
578
601
  }
602
+ // Reasoning that reaches the cap before any call has begun ends the step.
603
+ if (
604
+ reasoningCapChars !== undefined &&
605
+ reasoningStreamed >= reasoningCapChars &&
606
+ toolBuckets.size === 0
607
+ ) {
608
+ reasoningCapped = true
609
+ if (!reasoning.done) {
610
+ await emitEvent({
611
+ type: 'reasoning_completed',
612
+ turnId,
613
+ iteration,
614
+ messageId,
615
+ blockIndex: reasoning.index,
616
+ ...(bucket.text ? { text: bucket.text } : {}),
617
+ signed: bucket.signature !== undefined,
618
+ })
619
+ yield* drainPending()
620
+ }
621
+ stepStop.abort(new StepStopped())
622
+ break
623
+ }
579
624
  }
580
625
 
581
626
  if (chunk.delta.content) {
@@ -1091,5 +1136,10 @@ export async function* streamProviderTurn(
1091
1136
  chatSpan.setStatus({ code: SpanStatusCode.OK })
1092
1137
  chatSpan.end()
1093
1138
 
1094
- return { response, messageId, ...(requestMessages ? { requestMessages } : {}) }
1139
+ return {
1140
+ response,
1141
+ messageId,
1142
+ ...(requestMessages ? { requestMessages } : {}),
1143
+ ...(reasoningCapped ? { reasoningCapped } : {}),
1144
+ }
1095
1145
  }
@@ -248,7 +248,7 @@ function derivePartialDenials(
248
248
  for (const call of asked) {
249
249
  const mods = decision.modifications.filter((mod) => mod.toolCallId === call.id)
250
250
  if (mods.some((mod) => mod.action === 'deny'))
251
- denials.set(call.id, DECLINED_TOOL_CALL_FEEDBACK)
251
+ denials.set(call.id, decision.policyRefusals?.[call.id] ?? DECLINED_TOOL_CALL_FEEDBACK)
252
252
  else if (!mods.some((mod) => mod.action === 'approve')) {
253
253
  denials.set(
254
254
  call.id,
@@ -280,7 +280,8 @@ function derivePartialDeclines(
280
280
  const denied = asked.filter(
281
281
  (call) =>
282
282
  !gateDenied.has(call.id) &&
283
- decision.modifications.some((mod) => mod.toolCallId === call.id && mod.action === 'deny'),
283
+ decision.modifications.some((mod) => mod.toolCallId === call.id && mod.action === 'deny') &&
284
+ decision.policyRefusals?.[call.id] === undefined,
284
285
  )
285
286
  if (denied.length > 0) return new Map(denied.map((call) => [call.id, {}]))
286
287
  }
@@ -833,7 +834,10 @@ function derriveDeclines(
833
834
  }
834
835
  if (decision.action === 'modify_tools') {
835
836
  const denied = decision.modifications.filter(
836
- (mod) => mod.action === 'deny' && !gateDenied.has(mod.toolCallId),
837
+ (mod) =>
838
+ mod.action === 'deny' &&
839
+ !gateDenied.has(mod.toolCallId) &&
840
+ decision.policyRefusals?.[mod.toolCallId] === undefined,
837
841
  )
838
842
  if (denied.length > 0) return new Map(denied.map((mod) => [mod.toolCallId, {}]))
839
843
  }
@@ -868,7 +872,10 @@ function derriveDenials(
868
872
  const denials = new Map<string, string>()
869
873
  for (const mod of decision.modifications) {
870
874
  if (mod.action === 'deny') {
871
- denials.set(mod.toolCallId, DECLINED_TOOL_CALL_FEEDBACK)
875
+ denials.set(
876
+ mod.toolCallId,
877
+ decision.policyRefusals?.[mod.toolCallId] ?? DECLINED_TOOL_CALL_FEEDBACK,
878
+ )
872
879
  }
873
880
  }
874
881
  for (const mod of decision.modifications) {
@@ -21,6 +21,9 @@
21
21
  * person (`prompt`).
22
22
  */
23
23
 
24
+ import { homedir } from 'node:os'
25
+ import { dirname, parse } from 'node:path'
26
+
24
27
  import { unknownProgramInLine } from '../../authorization/program.js'
25
28
  import type { ShellWord } from '../../authorization/shell-lexer.js'
26
29
  import {
@@ -38,6 +41,7 @@ import type {
38
41
  ResumeHandler,
39
42
  ToolCallDeclined,
40
43
  ToolCallSummary,
44
+ ToolModification,
41
45
  } from '../../types/hitl/index.js'
42
46
  import type { ApprovalPolicy } from '../../types/hitl/policy.js'
43
47
  import type { SessionId, TurnId } from '../../types/ids/index.js'
@@ -242,7 +246,7 @@ function onlyReadsOutsideRoots(
242
246
  * one that needed a person is dropped would run a plan with a hole in it.
243
247
  */
244
248
  export const SANDBOX_ESCAPE_UNATTENDED_REFUSAL =
245
- 'Refused: a call in this batch asks to run outside the sandbox, which needs a person to confirm it each time, and nobody can be asked in this session. Nothing in this batch ran. Run the command inside the sandbox, or resend the other calls without the escape.'
249
+ 'Refused: this call asks to run outside the sandbox, which needs a person to confirm it each time, and nobody can be asked in this session. This call did not run; the other calls in the batch are not affected. Run the command inside the sandbox instead.'
246
250
 
247
251
  /**
248
252
  * What the model is told when a batch reaches a path outside the turn's roots
@@ -254,7 +258,7 @@ export const SANDBOX_ESCAPE_UNATTENDED_REFUSAL =
254
258
  * the CLI's `--add-dir`), where an operator wrote the directory down.
255
259
  */
256
260
  export const OUTSIDE_ROOTS_UNATTENDED_REFUSAL =
257
- "Refused: a call in this batch reaches a path outside the working directory and the added directories, which needs a person to approve it each time, and nobody can be asked in this session. Nothing in this batch ran. Stay inside the working directory, or tell the user which directory you need so they can add it to the session (the CLI's --add-dir)."
261
+ "Refused: this call reaches a path outside the working directory and the directories added to this session, which needs a person to approve it each time, and nobody can be asked in this session. This call did not run; the other calls in the batch are not affected. Use a path inside the working directory, or tell the user which directory you need so they can start the session with it added (the CLI's --add-dir)."
258
262
 
259
263
  /**
260
264
  * What the model is told when a batch carries a call the tool itself
@@ -265,7 +269,7 @@ export const OUTSIDE_ROOTS_UNATTENDED_REFUSAL =
265
269
  * remembered grant can stand in for the person it names.
266
270
  */
267
271
  export const REQUIRES_APPROVAL_UNATTENDED_REFUSAL =
268
- "Refused: a call in this batch is one the tool itself declared always needs a person's approval, and nobody can be asked in this session. Nothing in this batch ran. This cannot be granted by a rule, an automation mode, a remembered approval or a skill; run it in a session where a person can be asked."
272
+ "Refused: this call is one the tool itself declared always needs a person's approval, and nobody can be asked in this session. This call did not run; the other calls in the batch are not affected. This cannot be granted by a rule, an automation mode, a remembered approval or a skill; run it in a session where a person can be asked."
269
273
 
270
274
  /**
271
275
  * What the model is told when a batch runs a command whose own program name
@@ -277,7 +281,7 @@ export const REQUIRES_APPROVAL_UNATTENDED_REFUSAL =
277
281
  * "unattended allow" for this.
278
282
  */
279
283
  export const UNKNOWN_PROGRAM_UNATTENDED_REFUSAL =
280
- 'Refused: a call in this batch runs a command whose own program name is decided at runtime (a substitution or a variable stands for it), which needs a person to approve it each time, and nobody can be asked in this session. Nothing in this batch ran. Write the program name literally; compute what needs computing in the arguments instead.'
284
+ 'Refused: this call runs a command whose own program name is decided at runtime (a substitution or a variable stands for it), which needs a person to approve it each time, and nobody can be asked in this session. This call did not run; the other calls in the batch are not affected. Write the program name literally; compute what needs computing in the arguments instead.'
281
285
 
282
286
  /**
283
287
  * What the model is told when a batch writes a protected path (a git hook, a
@@ -289,7 +293,7 @@ export const UNKNOWN_PROGRAM_UNATTENDED_REFUSAL =
289
293
  * existed (a mode, a rule, "approve all") is not an answer about it.
290
294
  */
291
295
  export const PROTECTED_PATH_UNATTENDED_REFUSAL =
292
- "Refused: a call in this batch writes a protected path (a git hook or config, a shell startup file, editor or tool configuration, or the project's .namzu directory), which needs a person to approve it each time, and nobody can be asked in this session. Nothing in this batch ran. Leave those files alone, or tell the user what change you need so they can make it."
296
+ "Refused: this call writes a protected path (a git hook or config, a shell startup file, editor or tool configuration, or the project's .namzu directory), which needs a person to approve it each time, and nobody can be asked in this session. This call did not run; the other calls in the batch are not affected. Leave those files alone, or tell the user what change you need so they can make it."
293
297
 
294
298
  /**
295
299
  * What the model is told when a batch removes something catastrophic (the
@@ -297,21 +301,21 @@ export const PROTECTED_PATH_UNATTENDED_REFUSAL =
297
301
  * across directories, a target a variable may empty) and nobody can be asked.
298
302
  */
299
303
  export const DANGEROUS_REMOVAL_UNATTENDED_REFUSAL =
300
- 'Refused: a call in this batch removes something this check treats as catastrophic (the file system root, a top-level or home directory, the working directory or a parent of it, a glob across directories, or a target a variable or substitution may leave empty), which needs a person to approve it each time, and nobody can be asked in this session. Nothing in this batch ran. Name the exact directory to remove with a literal path.'
304
+ 'Refused: this call removes something this check treats as catastrophic (the file system root, a top-level or home directory, the working directory or a parent of it, a glob across directories, or a target a variable or substitution may leave empty), which needs a person to approve it each time, and nobody can be asked in this session. This call did not run; the other calls in the batch are not affected. Name the exact directory to remove with a literal path.'
301
305
 
302
306
  /**
303
307
  * What the model is told when a batch carries a call a rule or a hook asked a
304
308
  * person to approve and nobody can be asked.
305
309
  */
306
310
  export const ASK_RULE_UNATTENDED_REFUSAL =
307
- 'Refused: a call in this batch is one a rule or a hook says a person must approve, and nobody can be asked in this session. Nothing in this batch ran. Run it in a session where a person can be asked, or have the operator change the rule.'
311
+ 'Refused: this call is one a rule or a hook says a person must approve, and nobody can be asked in this session. This call did not run; the other calls in the batch are not affected. Run it in a session where a person can be asked, or have the operator change the rule.'
308
312
 
309
313
  /**
310
314
  * What the model is told when the checks that decide whether a call reaches
311
315
  * past its boundary did not finish and nobody can be asked.
312
316
  */
313
317
  export const UNFINISHED_CHECK_UNATTENDED_REFUSAL =
314
- 'Refused: the safety checks on a call in this batch did not finish, so it cannot be shown to be safe, and a person has to approve it and nobody can be asked in this session. Nothing in this batch ran. Try a simpler form of the call.'
318
+ 'Refused: the safety checks on this call did not finish, so it cannot be shown to be safe, and a person has to approve it and nobody can be asked in this session. This call did not run; the other calls in the batch are not affected. Try a simpler form of the call.'
315
319
 
316
320
  /** What the model is told when the conversation is too long for the classifier to read. */
317
321
  export const CLASSIFIER_TRANSCRIPT_TOO_LONG_FEEDBACK =
@@ -534,6 +538,37 @@ export interface ScreenConsentRecord {
534
538
  readonly sessions: Set<string>
535
539
  }
536
540
 
541
+ /** The directory to name for a path the guard stopped: the path itself unless it looks like a file. */
542
+ function directoryToAllow(path: string): string {
543
+ const last = path.split(/[\\/]/).pop() ?? ''
544
+ return last.includes('.') ? dirname(path) : path
545
+ }
546
+
547
+ /** The outside-the-roots refusal, naming the directory and the way to allow it. */
548
+ export function outsideRootsRefusal(toolCalls: readonly ToolCallSummary[]): string {
549
+ const paths = [...new Set(toolCalls.flatMap((tc) => tc.escalation?.outsidePaths ?? []))]
550
+ if (paths.length === 0) return OUTSIDE_ROOTS_UNATTENDED_REFUSAL
551
+ const blocked = `${OUTSIDE_ROOTS_UNATTENDED_REFUSAL} Blocked: ${paths.slice(0, 3).join(', ')}.`
552
+ const directories = [...new Set(paths.map(directoryToAllow))]
553
+ // A directory that is the file system root or the home directory is too wide to
554
+ // suggest adding; a path directly under the root is more often a mistyped name.
555
+ const wide = new Set([parse(directories[0] ?? '/').root, homedir()])
556
+ const addable = directories.filter((d) => !wide.has(d)).slice(0, 3)
557
+ if (addable.length === 0) {
558
+ return `${blocked} It sits directly under a directory too wide to add; if the name is a typo, use a path inside the working directory or the system temp directory.`
559
+ }
560
+ return `${blocked} To allow it, the user starts the session with ${addable.map((d) => `--add-dir ${d}`).join(' ')}.`
561
+ }
562
+
563
+ /** The catastrophic-removal refusal with the one-line reason the check found. */
564
+ export function dangerousRemovalRefusal(toolCalls: readonly ToolCallSummary[]): string {
565
+ const why = toolCalls.find((tc) => tc.escalation?.dangerousRemoval !== undefined)?.escalation
566
+ ?.dangerousRemoval
567
+ return why === undefined
568
+ ? DANGEROUS_REMOVAL_UNATTENDED_REFUSAL
569
+ : `${DANGEROUS_REMOVAL_UNATTENDED_REFUSAL} Why: ${why}.`
570
+ }
571
+
537
572
  /**
538
573
  * Whether the call writes a protected path, removes something catastrophic, or
539
574
  * could not be checked.
@@ -552,7 +587,7 @@ function floorRefusal(toolCalls: readonly ToolCallSummary[]): string {
552
587
  return PROTECTED_PATH_UNATTENDED_REFUSAL
553
588
  }
554
589
  return toolCalls.some((tc) => tc.escalation?.dangerousRemoval !== undefined)
555
- ? DANGEROUS_REMOVAL_UNATTENDED_REFUSAL
590
+ ? dangerousRemovalRefusal(toolCalls)
556
591
  : UNFINISHED_CHECK_UNATTENDED_REFUSAL
557
592
  }
558
593
 
@@ -589,6 +624,39 @@ function isBypassImmune(tc: ToolCallSummary): boolean {
589
624
  )
590
625
  }
591
626
 
627
+ /** The slice of a reviewed call {@link mustAskPerson} reads; a `ToolCallSummary` fits it. */
628
+ export interface MustAskCall {
629
+ readonly escalation?: unknown
630
+ readonly requiresApproval?: boolean
631
+ readonly authorization?: { readonly explicitReview?: boolean; readonly askRule?: boolean }
632
+ readonly hookPermission?: { readonly decision?: string }
633
+ }
634
+
635
+ /**
636
+ * Whether a standing "yes" (a remembered `approve-all`, or a host's own
637
+ * "allow for this conversation") must not cover this call: it exists to put a
638
+ * question to a person.
639
+ *
640
+ * It crosses the turn's boundary in any way (a sandbox escape, a path outside
641
+ * the roots, a protected path, a catastrophic removal, a program nobody can
642
+ * name, a check that did not finish), its tool needs a yes on every call, or a
643
+ * rule or a hook asked for a person. `escalation` is tested as a whole rather
644
+ * than field by field, so a kind of escalation added later is asked about
645
+ * before anyone remembers to list it. A call that reaches the first look at
646
+ * the screen is asked about too; that is a property of the request
647
+ * (`ToolReviewRequest.screenConsent`), so a host adds it where it builds the
648
+ * ask.
649
+ */
650
+ export function mustAskPerson(call: MustAskCall): boolean {
651
+ return (
652
+ call.escalation !== undefined ||
653
+ call.requiresApproval === true ||
654
+ call.authorization?.explicitReview === true ||
655
+ call.authorization?.askRule === true ||
656
+ call.hookPermission?.decision === 'ask'
657
+ )
658
+ }
659
+
592
660
  /** The refusal for the first immune thing in a batch nobody can be asked about, or undefined. */
593
661
  function immuneRefusal(
594
662
  toolCalls: readonly ToolCallSummary[],
@@ -601,7 +669,7 @@ function immuneRefusal(
601
669
  return PROTECTED_PATH_UNATTENDED_REFUSAL
602
670
  }
603
671
  if (toolCalls.some((tc) => tc.escalation?.dangerousRemoval !== undefined)) {
604
- return DANGEROUS_REMOVAL_UNATTENDED_REFUSAL
672
+ return dangerousRemovalRefusal(toolCalls)
605
673
  }
606
674
  if (toolCalls.some((tc) => tc.escalation?.unfinishedCheck !== undefined)) {
607
675
  return UNFINISHED_CHECK_UNATTENDED_REFUSAL
@@ -994,7 +1062,20 @@ export function createReviewHandler(options: ReviewPolicyOptions = {}): ResumeHa
994
1062
  return acceptEditsAllows(tc, sessionId)
995
1063
  }
996
1064
 
997
- return async (request, context?: ResumeContext): Promise<HITLResumeDecision> => {
1065
+ // Decisions that refuse one call and say so. The wrapper below splits a batch on
1066
+ // this mark, never on the wording of the refusal.
1067
+ const perCallRefusals = new WeakSet<object>()
1068
+ const callRefusal = (feedback: string): HITLResumeDecision => {
1069
+ const decision: HITLResumeDecision = { action: 'reject_tools', feedback }
1070
+ perCallRefusals.add(decision)
1071
+ return decision
1072
+ }
1073
+
1074
+ const decide = async (
1075
+ request: Parameters<ResumeHandler>[0],
1076
+ context?: ResumeContext,
1077
+ classified = false,
1078
+ ): Promise<HITLResumeDecision> => {
998
1079
  if (request.type !== 'tool_review') {
999
1080
  return request.type === 'plan_approval' ? { action: 'approve_plan' } : { action: 'continue' }
1000
1081
  }
@@ -1128,7 +1209,7 @@ export function createReviewHandler(options: ReviewPolicyOptions = {}): ResumeHa
1128
1209
  }
1129
1210
  const live = request.toolCalls.filter((tc) => tc.authorization?.decision !== 'deny')
1130
1211
  const escapes = live.filter((tc) => tc.escalation?.sandboxEscape === true).map((tc) => tc.id)
1131
- if (mode === 'classify') {
1212
+ if (mode === 'classify' && !classified) {
1132
1213
  const classifier = options.classifier as AutoReviewClassifier
1133
1214
  const transcript = context?.transcript() ?? []
1134
1215
  for (const tc of live) {
@@ -1180,7 +1261,7 @@ export function createReviewHandler(options: ReviewPolicyOptions = {}): ResumeHa
1180
1261
  if (!live.some(isBypassImmune)) return { action: 'approve_tools' }
1181
1262
  if (!prompt) {
1182
1263
  const refusal = immuneRefusal(live, options.unattendedSandboxEscape === 'allow')
1183
- if (refusal !== undefined) return { action: 'reject_tools', feedback: refusal }
1264
+ if (refusal !== undefined) return callRefusal(refusal)
1184
1265
  return {
1185
1266
  action: 'approve_tools',
1186
1267
  ...(escapes.length > 0 ? { confirmedEscalations: escapes } : {}),
@@ -1200,22 +1281,22 @@ export function createReviewHandler(options: ReviewPolicyOptions = {}): ResumeHa
1200
1281
  if (escapes.length > 0) {
1201
1282
  if (!prompt) {
1202
1283
  if (options.unattendedSandboxEscape !== 'allow') {
1203
- return { action: 'reject_tools', feedback: SANDBOX_ESCAPE_UNATTENDED_REFUSAL }
1284
+ return callRefusal(SANDBOX_ESCAPE_UNATTENDED_REFUSAL)
1204
1285
  }
1205
1286
  // This opt-in confirms only the sandbox escape. A different call in
1206
1287
  // the batch, or another boundary on this same call, still needs a
1207
1288
  // person; the early return must not approve that boundary too.
1208
1289
  if (request.toolCalls.some(reachesAFloor)) {
1209
- return { action: 'reject_tools', feedback: floorRefusal(request.toolCalls) }
1290
+ return callRefusal(floorRefusal(request.toolCalls))
1210
1291
  }
1211
1292
  if (request.toolCalls.some((tc) => (tc.escalation?.outsidePaths?.length ?? 0) > 0)) {
1212
- return { action: 'reject_tools', feedback: OUTSIDE_ROOTS_UNATTENDED_REFUSAL }
1293
+ return callRefusal(outsideRootsRefusal(request.toolCalls))
1213
1294
  }
1214
1295
  if (request.toolCalls.some((tc) => tc.requiresApproval === true)) {
1215
- return { action: 'reject_tools', feedback: REQUIRES_APPROVAL_UNATTENDED_REFUSAL }
1296
+ return callRefusal(REQUIRES_APPROVAL_UNATTENDED_REFUSAL)
1216
1297
  }
1217
1298
  if (request.toolCalls.some((tc) => tc.escalation?.unknownProgram !== undefined)) {
1218
- return { action: 'reject_tools', feedback: UNKNOWN_PROGRAM_UNATTENDED_REFUSAL }
1299
+ return callRefusal(UNKNOWN_PROGRAM_UNATTENDED_REFUSAL)
1219
1300
  }
1220
1301
  return { action: 'approve_tools', confirmedEscalations: escapes }
1221
1302
  }
@@ -1238,7 +1319,7 @@ export function createReviewHandler(options: ReviewPolicyOptions = {}): ResumeHa
1238
1319
  // far: `auto` and a remembered "approve all" were answers given before
1239
1320
  // the call existed. With nobody to ask it is refused, not approved.
1240
1321
  if (request.toolCalls.some(reachesAFloor)) {
1241
- if (!prompt) return { action: 'reject_tools', feedback: floorRefusal(request.toolCalls) }
1322
+ if (!prompt) return callRefusal(floorRefusal(request.toolCalls))
1242
1323
  const answer = await ask()
1243
1324
  if (answer.kind === 'reject') {
1244
1325
  return {
@@ -1257,7 +1338,7 @@ export function createReviewHandler(options: ReviewPolicyOptions = {}): ResumeHa
1257
1338
  // given about tools, before this path was named, and neither saw it.
1258
1339
  // With nobody to ask it is refused, not approved.
1259
1340
  if (request.toolCalls.some((tc) => (tc.escalation?.outsidePaths?.length ?? 0) > 0)) {
1260
- if (!prompt) return { action: 'reject_tools', feedback: OUTSIDE_ROOTS_UNATTENDED_REFUSAL }
1341
+ if (!prompt) return callRefusal(outsideRootsRefusal(request.toolCalls))
1261
1342
  const answer = await ask()
1262
1343
  if (answer.kind === 'reject') {
1263
1344
  return {
@@ -1278,7 +1359,7 @@ export function createReviewHandler(options: ReviewPolicyOptions = {}): ResumeHa
1278
1359
  // declaration travels with the tool, and no rule, mode or remembered
1279
1360
  // grant was ever an answer to IT specifically.
1280
1361
  if (request.toolCalls.some((tc) => tc.requiresApproval === true)) {
1281
- if (!prompt) return { action: 'reject_tools', feedback: REQUIRES_APPROVAL_UNATTENDED_REFUSAL }
1362
+ if (!prompt) return callRefusal(REQUIRES_APPROVAL_UNATTENDED_REFUSAL)
1282
1363
  const answer = await ask()
1283
1364
  if (answer.kind === 'reject') {
1284
1365
  return {
@@ -1299,7 +1380,7 @@ export function createReviewHandler(options: ReviewPolicyOptions = {}): ResumeHa
1299
1380
  // escape — an operator who wants this can only write the program
1300
1381
  // name literally.
1301
1382
  if (request.toolCalls.some((tc) => tc.escalation?.unknownProgram !== undefined)) {
1302
- if (!prompt) return { action: 'reject_tools', feedback: UNKNOWN_PROGRAM_UNATTENDED_REFUSAL }
1383
+ if (!prompt) return callRefusal(UNKNOWN_PROGRAM_UNATTENDED_REFUSAL)
1303
1384
  const answer = await ask()
1304
1385
  if (answer.kind === 'reject') {
1305
1386
  return {
@@ -1312,7 +1393,10 @@ export function createReviewHandler(options: ReviewPolicyOptions = {}): ResumeHa
1312
1393
  if (answer.kind === 'approve-all') remembered.all = true
1313
1394
  return { action: 'approve_tools' }
1314
1395
  }
1315
- if (mode === 'auto' || !prompt || remembered.all) {
1396
+ // A remembered "approve all" answers ordinary calls. A call a rule or a
1397
+ // hook asked a person about exists to put its own question, which the
1398
+ // latch was never an answer to (`mustAskPerson`): it goes on to ask.
1399
+ if (mode === 'auto' || !prompt || (remembered.all && !request.toolCalls.some(mustAskPerson))) {
1316
1400
  return { action: 'approve_tools' }
1317
1401
  }
1318
1402
  // Every call a person would be asked about is one a skill loaded in
@@ -1350,7 +1434,10 @@ export function createReviewHandler(options: ReviewPolicyOptions = {}): ResumeHa
1350
1434
  case 'approve':
1351
1435
  return { action: 'approve_tools' }
1352
1436
  case 'approve-all':
1353
- remembered.all = true
1437
+ // A rule's or a hook's question was put to this person once; an
1438
+ // "approve all" to it approves it and leaves the latch as it was,
1439
+ // as the ACP bridge does.
1440
+ if (!request.toolCalls.some(mustAskPerson)) remembered.all = true
1354
1441
  return { action: 'approve_tools' }
1355
1442
  case 'reject':
1356
1443
  return {
@@ -1361,6 +1448,52 @@ export function createReviewHandler(options: ReviewPolicyOptions = {}): ResumeHa
1361
1448
  }
1362
1449
  }
1363
1450
  }
1451
+ // With nobody to ask, a refusal that belongs to one call is that call's
1452
+ // alone. The batch is not refused for it: each call is decided on its own,
1453
+ // the ones that pass run, and the one that does not gets its own reason.
1454
+ // Without this a single stray path in a parallel batch costs the turn every
1455
+ // other call in it.
1456
+ return async (request, context?: ResumeContext): Promise<HITLResumeDecision> => {
1457
+ const whole = await decide(request, context)
1458
+ if (
1459
+ whole.action !== 'reject_tools' ||
1460
+ prompt ||
1461
+ request.type !== 'tool_review' ||
1462
+ request.toolCalls.length < 2 ||
1463
+ !perCallRefusals.has(whole)
1464
+ ) {
1465
+ return whole
1466
+ }
1467
+ // The whole-batch pass has already put every call to the classifier (it
1468
+ // refuses only after all of them were allowed), so the per-call passes do
1469
+ // not ask it again. One at a time: no pass races another on shared state.
1470
+ const alone: HITLResumeDecision[] = []
1471
+ for (const tc of request.toolCalls) {
1472
+ alone.push(await decide({ ...request, toolCalls: [tc] }, context, true))
1473
+ }
1474
+ const modifications: ToolModification[] = []
1475
+ const policyRefusals: Record<string, string> = {}
1476
+ const confirmed: string[] = []
1477
+ for (const [i, decision] of alone.entries()) {
1478
+ const id = (request.toolCalls[i] as ToolCallSummary).id
1479
+ if (decision.action === 'approve_tools') {
1480
+ modifications.push({ toolCallId: id, action: 'approve' })
1481
+ confirmed.push(...(decision.confirmedEscalations ?? []))
1482
+ } else if (decision.action === 'reject_tools' && !decision.endTurn && !decision.declined) {
1483
+ modifications.push({ toolCallId: id, action: 'deny' })
1484
+ policyRefusals[id] = decision.feedback
1485
+ } else {
1486
+ return whole
1487
+ }
1488
+ }
1489
+ if (Object.keys(policyRefusals).length === 0) return whole
1490
+ return {
1491
+ action: 'modify_tools',
1492
+ modifications,
1493
+ policyRefusals,
1494
+ ...(confirmed.length > 0 ? { confirmedEscalations: confirmed } : {}),
1495
+ }
1496
+ }
1364
1497
  }
1365
1498
 
1366
1499
  /**
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The completion audit: before a turn that edited files is allowed to end,
3
+ * check from the turn's own record whether anything was run to check the
4
+ * edits. It judges what happened, not what the model says happened.
5
+ *
6
+ * It blocks at most once per turn. After the one reminder the turn may end
7
+ * however it likes, so the audit can never trap a run.
8
+ */
9
+
10
+ /** Tools that write to the project regardless of their input. */
11
+ const WRITING_TOOLS = new Set(['edit', 'write', 'apply_patch', 'notebook_edit'])
12
+
13
+ /**
14
+ * A command that checks work: a project runner invoked with a test, lint,
15
+ * typecheck, format-check or build target, or a checker invoked directly.
16
+ */
17
+ const RUNNER =
18
+ /\b(?:npm|pnpm|yarn|bun|npx|make|cargo|go|dotnet|gradle|gradlew|mvn|deno|just|task|uv|poetry|tox|nox|rake|bundle)\b[^|;&\n]*\b(?:test|tests|lint|check|typecheck|type-check|verify|build|vet|clippy|fmt)\b/i
19
+ const DIRECT =
20
+ /(?:^|[\s;&|(])(?:vitest|jest|pytest|mocha|tsc|eslint|biome|prettier|ruff|mypy|pyright|flake8|golangci-lint|rubocop|phpunit|node\s+--test)(?=$|[\s;&|)])/i
21
+
22
+ /** Whether a shell command is one that checks work. */
23
+ export function isVerificationCommand(command: string): boolean {
24
+ return RUNNER.test(command) || DIRECT.test(command)
25
+ }
26
+
27
+ /** Said to the model once, when it tries to end a turn that changed files and checked nothing. */
28
+ export const COMPLETION_AUDIT_MESSAGE =
29
+ "Files were changed in this turn and nothing was run afterwards to check them. Before you finish: rerun the reproduction of the defect, if there was one; run the project's tests and its lint or format check; confirm the diff holds only what was asked; name any throwaway file you made. Then give your answer, stating what you ran and what you did not run. If a check cannot be run here, say which and why."
30
+
31
+ /** A finished tool call as the audit sees it. */
32
+ export interface AuditedCall {
33
+ readonly toolName: string
34
+ /** The call's parsed input, when it had one. */
35
+ readonly input?: unknown
36
+ readonly isError?: boolean
37
+ }
38
+
39
+ export class CompletionAudit {
40
+ private edited = false
41
+ private checkedSinceEdit = false
42
+ private blocked = false
43
+
44
+ /** Feed the calls of one finished step, in order. */
45
+ observe(calls: readonly AuditedCall[]): void {
46
+ for (const call of calls) {
47
+ if (call.isError) continue
48
+ if (WRITING_TOOLS.has(call.toolName)) {
49
+ this.edited = true
50
+ this.checkedSinceEdit = false
51
+ continue
52
+ }
53
+ if (call.toolName === 'bash' && this.edited) {
54
+ const command = (call.input as { command?: unknown } | undefined)?.command
55
+ if (typeof command === 'string' && isVerificationCommand(command)) {
56
+ this.checkedSinceEdit = true
57
+ }
58
+ }
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Whether the turn must be held back from ending. True at most once: asking
64
+ * records the block.
65
+ */
66
+ takeBlock(): boolean {
67
+ if (this.blocked || !this.edited || this.checkedSinceEdit) return false
68
+ this.blocked = true
69
+ return true
70
+ }
71
+ }