@namzu/sdk 53.2.2 → 55.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 (278) hide show
  1. package/CHANGELOG.md +56 -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/compaction/context-breakdown.d.ts +85 -0
  6. package/dist/compaction/context-breakdown.d.ts.map +1 -0
  7. package/dist/compaction/context-breakdown.js +149 -0
  8. package/dist/compaction/context-breakdown.js.map +1 -0
  9. package/dist/compaction/extractor.d.ts.map +1 -1
  10. package/dist/compaction/extractor.js +5 -1
  11. package/dist/compaction/extractor.js.map +1 -1
  12. package/dist/compaction/managers/structured.d.ts.map +1 -1
  13. package/dist/compaction/managers/structured.js +5 -0
  14. package/dist/compaction/managers/structured.js.map +1 -1
  15. package/dist/compaction/manual.d.ts.map +1 -1
  16. package/dist/compaction/manual.js +6 -0
  17. package/dist/compaction/manual.js.map +1 -1
  18. package/dist/compaction/serializer.d.ts +6 -0
  19. package/dist/compaction/serializer.d.ts.map +1 -1
  20. package/dist/compaction/serializer.js +21 -3
  21. package/dist/compaction/serializer.js.map +1 -1
  22. package/dist/compaction/token-estimate.d.ts +2 -0
  23. package/dist/compaction/token-estimate.d.ts.map +1 -1
  24. package/dist/compaction/token-estimate.js +4 -0
  25. package/dist/compaction/token-estimate.js.map +1 -1
  26. package/dist/compaction/verifier.d.ts.map +1 -1
  27. package/dist/compaction/verifier.js +27 -6
  28. package/dist/compaction/verifier.js.map +1 -1
  29. package/dist/constants/compaction/index.d.ts +1 -0
  30. package/dist/constants/compaction/index.d.ts.map +1 -1
  31. package/dist/constants/compaction/index.js +1 -0
  32. package/dist/constants/compaction/index.js.map +1 -1
  33. package/dist/manager/resident/proposal.d.ts +2 -2
  34. package/dist/manager/resident/store.d.ts +4 -4
  35. package/dist/prompt/coding-agent-doctrine.d.ts +3 -3
  36. package/dist/prompt/coding-agent-doctrine.d.ts.map +1 -1
  37. package/dist/prompt/coding-agent-doctrine.js +18 -20
  38. package/dist/prompt/coding-agent-doctrine.js.map +1 -1
  39. package/dist/public-runtime.d.ts +8 -3
  40. package/dist/public-runtime.d.ts.map +1 -1
  41. package/dist/public-runtime.js +7 -2
  42. package/dist/public-runtime.js.map +1 -1
  43. package/dist/runtime/cache-clock.d.ts +39 -0
  44. package/dist/runtime/cache-clock.d.ts.map +1 -0
  45. package/dist/runtime/cache-clock.js +48 -0
  46. package/dist/runtime/cache-clock.js.map +1 -0
  47. package/dist/runtime/query/checkpoint.d.ts +12 -2
  48. package/dist/runtime/query/checkpoint.d.ts.map +1 -1
  49. package/dist/runtime/query/checkpoint.js +42 -8
  50. package/dist/runtime/query/checkpoint.js.map +1 -1
  51. package/dist/runtime/query/compact-session.d.ts +37 -2
  52. package/dist/runtime/query/compact-session.d.ts.map +1 -1
  53. package/dist/runtime/query/compact-session.js +77 -3
  54. package/dist/runtime/query/compact-session.js.map +1 -1
  55. package/dist/runtime/query/executor/tool-call-admission.d.ts.map +1 -1
  56. package/dist/runtime/query/executor/tool-call-admission.js +3 -0
  57. package/dist/runtime/query/executor/tool-call-admission.js.map +1 -1
  58. package/dist/runtime/query/executor.d.ts +39 -1
  59. package/dist/runtime/query/executor.d.ts.map +1 -1
  60. package/dist/runtime/query/executor.js +115 -7
  61. package/dist/runtime/query/executor.js.map +1 -1
  62. package/dist/runtime/query/index.d.ts +18 -0
  63. package/dist/runtime/query/index.d.ts.map +1 -1
  64. package/dist/runtime/query/index.js +20 -1
  65. package/dist/runtime/query/index.js.map +1 -1
  66. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  67. package/dist/runtime/query/iteration/index.js +25 -8
  68. package/dist/runtime/query/iteration/index.js.map +1 -1
  69. package/dist/runtime/query/iteration/phases/context.d.ts +24 -2
  70. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  71. package/dist/runtime/query/iteration/phases/context.js +22 -22
  72. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  73. package/dist/runtime/query/iteration/phases/per-call-review.d.ts +78 -0
  74. package/dist/runtime/query/iteration/phases/per-call-review.d.ts.map +1 -0
  75. package/dist/runtime/query/iteration/phases/per-call-review.js +494 -0
  76. package/dist/runtime/query/iteration/phases/per-call-review.js.map +1 -0
  77. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  78. package/dist/runtime/query/iteration/phases/tool-review.js +129 -62
  79. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  80. package/dist/runtime/query/iteration/step-context.d.ts +14 -0
  81. package/dist/runtime/query/iteration/step-context.d.ts.map +1 -1
  82. package/dist/runtime/query/iteration/step-context.js +29 -0
  83. package/dist/runtime/query/iteration/step-context.js.map +1 -1
  84. package/dist/runtime/query/iteration/step-shaping.d.ts.map +1 -1
  85. package/dist/runtime/query/iteration/step-shaping.js +6 -2
  86. package/dist/runtime/query/iteration/step-shaping.js.map +1 -1
  87. package/dist/runtime/query/prepare-turn.d.ts.map +1 -1
  88. package/dist/runtime/query/prepare-turn.js +16 -3
  89. package/dist/runtime/query/prepare-turn.js.map +1 -1
  90. package/dist/runtime/query/project-instructions.d.ts +24 -5
  91. package/dist/runtime/query/project-instructions.d.ts.map +1 -1
  92. package/dist/runtime/query/project-instructions.js +107 -35
  93. package/dist/runtime/query/project-instructions.js.map +1 -1
  94. package/dist/runtime/query/prompt.d.ts +6 -0
  95. package/dist/runtime/query/prompt.d.ts.map +1 -1
  96. package/dist/runtime/query/prompt.js +10 -49
  97. package/dist/runtime/query/prompt.js.map +1 -1
  98. package/dist/runtime/query/resume-pending.d.ts +8 -0
  99. package/dist/runtime/query/resume-pending.d.ts.map +1 -1
  100. package/dist/runtime/query/resume-pending.js +101 -0
  101. package/dist/runtime/query/resume-pending.js.map +1 -1
  102. package/dist/runtime/query/review-policy.d.ts +18 -0
  103. package/dist/runtime/query/review-policy.d.ts.map +1 -1
  104. package/dist/runtime/query/review-policy.js +10 -0
  105. package/dist/runtime/query/review-policy.js.map +1 -1
  106. package/dist/runtime/query/steering.d.ts +55 -56
  107. package/dist/runtime/query/steering.d.ts.map +1 -1
  108. package/dist/runtime/query/steering.js +78 -72
  109. package/dist/runtime/query/steering.js.map +1 -1
  110. package/dist/store/budget/disk.js +4 -1
  111. package/dist/store/budget/disk.js.map +1 -1
  112. package/dist/tools/admit-facts.d.ts.map +1 -1
  113. package/dist/tools/admit-facts.js +6 -0
  114. package/dist/tools/admit-facts.js.map +1 -1
  115. package/dist/tools/builtins/bash.d.ts +8 -0
  116. package/dist/tools/builtins/bash.d.ts.map +1 -1
  117. package/dist/tools/builtins/bash.js +36 -27
  118. package/dist/tools/builtins/bash.js.map +1 -1
  119. package/dist/tools/builtins/browser.d.ts.map +1 -1
  120. package/dist/tools/builtins/browser.js +38 -12
  121. package/dist/tools/builtins/browser.js.map +1 -1
  122. package/dist/tools/builtins/edit-apply.d.ts +7 -4
  123. package/dist/tools/builtins/edit-apply.d.ts.map +1 -1
  124. package/dist/tools/builtins/edit-apply.js +3 -0
  125. package/dist/tools/builtins/edit-apply.js.map +1 -1
  126. package/dist/tools/builtins/edit.d.ts.map +1 -1
  127. package/dist/tools/builtins/edit.js +31 -11
  128. package/dist/tools/builtins/edit.js.map +1 -1
  129. package/dist/tools/builtins/exit-semantics.d.ts +31 -0
  130. package/dist/tools/builtins/exit-semantics.d.ts.map +1 -0
  131. package/dist/tools/builtins/exit-semantics.js +198 -0
  132. package/dist/tools/builtins/exit-semantics.js.map +1 -0
  133. package/dist/tools/builtins/job.js +1 -1
  134. package/dist/tools/builtins/job.js.map +1 -1
  135. package/dist/tools/builtins/skill.js +1 -1
  136. package/dist/tools/builtins/skill.js.map +1 -1
  137. package/dist/tools/builtins/structuredOutput.js +1 -1
  138. package/dist/tools/builtins/structuredOutput.js.map +1 -1
  139. package/dist/tools/builtins/verify-outputs.js +1 -1
  140. package/dist/tools/builtins/verify-outputs.js.map +1 -1
  141. package/dist/tools/builtins/write-file.js +11 -6
  142. package/dist/tools/builtins/write-file.js.map +1 -1
  143. package/dist/tools/coordinator/ask-user-question.d.ts +6 -0
  144. package/dist/tools/coordinator/ask-user-question.d.ts.map +1 -1
  145. package/dist/tools/coordinator/ask-user-question.js +6 -1
  146. package/dist/tools/coordinator/ask-user-question.js.map +1 -1
  147. package/dist/tools/coordinator/index.d.ts.map +1 -1
  148. package/dist/tools/coordinator/index.js +10 -3
  149. package/dist/tools/coordinator/index.js.map +1 -1
  150. package/dist/tools/memory/delete.js +1 -1
  151. package/dist/tools/memory/delete.js.map +1 -1
  152. package/dist/tools/memory/read.js +1 -1
  153. package/dist/tools/memory/read.js.map +1 -1
  154. package/dist/tools/memory/save.d.ts.map +1 -1
  155. package/dist/tools/memory/save.js +4 -3
  156. package/dist/tools/memory/save.js.map +1 -1
  157. package/dist/tools/memory/search.js +1 -1
  158. package/dist/tools/memory/search.js.map +1 -1
  159. package/dist/tools/memory/update.js +1 -1
  160. package/dist/tools/memory/update.js.map +1 -1
  161. package/dist/tools/schedules/loop-tool.d.ts.map +1 -1
  162. package/dist/tools/schedules/loop-tool.js +4 -2
  163. package/dist/tools/schedules/loop-tool.js.map +1 -1
  164. package/dist/tools/schedules/schedule-tool.js +2 -2
  165. package/dist/tools/schedules/schedule-tool.js.map +1 -1
  166. package/dist/tools/task/create.js +1 -1
  167. package/dist/tools/task/create.js.map +1 -1
  168. package/dist/tools/untrusted-envelope.js +1 -1
  169. package/dist/tools/untrusted-envelope.js.map +1 -1
  170. package/dist/toolsets/manager.d.ts.map +1 -1
  171. package/dist/toolsets/manager.js +6 -1
  172. package/dist/toolsets/manager.js.map +1 -1
  173. package/dist/turn/evidence-query.d.ts.map +1 -1
  174. package/dist/turn/evidence-query.js +16 -1
  175. package/dist/turn/evidence-query.js.map +1 -1
  176. package/dist/turn/evidence-recall.js +1 -1
  177. package/dist/turn/evidence-recall.js.map +1 -1
  178. package/dist/types/agent/base.d.ts +7 -0
  179. package/dist/types/agent/base.d.ts.map +1 -1
  180. package/dist/types/common/index.d.ts +19 -0
  181. package/dist/types/common/index.d.ts.map +1 -1
  182. package/dist/types/common/index.js +9 -0
  183. package/dist/types/common/index.js.map +1 -1
  184. package/dist/types/hitl/index.d.ts +66 -0
  185. package/dist/types/hitl/index.d.ts.map +1 -1
  186. package/dist/types/hitl/index.js.map +1 -1
  187. package/dist/types/message/index.d.ts +8 -0
  188. package/dist/types/message/index.d.ts.map +1 -1
  189. package/dist/types/message/index.js +12 -2
  190. package/dist/types/message/index.js.map +1 -1
  191. package/dist/types/message/notice-frame.d.ts +41 -0
  192. package/dist/types/message/notice-frame.d.ts.map +1 -0
  193. package/dist/types/message/notice-frame.js +65 -0
  194. package/dist/types/message/notice-frame.js.map +1 -0
  195. package/dist/types/provider/chat.d.ts +8 -0
  196. package/dist/types/provider/chat.d.ts.map +1 -1
  197. package/dist/types/provider/config.d.ts +6 -0
  198. package/dist/types/provider/config.d.ts.map +1 -1
  199. package/dist/types/session/checkpoint.d.ts +52 -0
  200. package/dist/types/session/checkpoint.d.ts.map +1 -1
  201. package/dist/types/session/checkpoint.js +5 -0
  202. package/dist/types/session/checkpoint.js.map +1 -1
  203. package/dist/types/session/events.d.ts +9 -0
  204. package/dist/types/session/events.d.ts.map +1 -1
  205. package/dist/types/session/events.js.map +1 -1
  206. package/dist/types/session/records.d.ts +181 -6
  207. package/dist/types/session/records.d.ts.map +1 -1
  208. package/dist/types/session/records.js +1 -1
  209. package/dist/types/session/records.js.map +1 -1
  210. package/dist/types/tool/presentation.d.ts +17 -1
  211. package/dist/types/tool/presentation.d.ts.map +1 -1
  212. package/package.json +1 -1
  213. package/src/agents/QueryAgent.ts +1 -0
  214. package/src/compaction/context-breakdown.ts +228 -0
  215. package/src/compaction/extractor.ts +8 -1
  216. package/src/compaction/managers/structured.ts +3 -0
  217. package/src/compaction/manual.ts +5 -0
  218. package/src/compaction/serializer.ts +28 -3
  219. package/src/compaction/token-estimate.ts +5 -0
  220. package/src/compaction/verifier.ts +32 -6
  221. package/src/constants/compaction/index.ts +1 -0
  222. package/src/prompt/coding-agent-doctrine.ts +18 -20
  223. package/src/public-runtime.ts +24 -2
  224. package/src/runtime/cache-clock.ts +67 -0
  225. package/src/runtime/query/checkpoint.ts +49 -8
  226. package/src/runtime/query/compact-session.ts +101 -3
  227. package/src/runtime/query/executor/tool-call-admission.ts +3 -0
  228. package/src/runtime/query/executor.ts +165 -15
  229. package/src/runtime/query/index.ts +45 -1
  230. package/src/runtime/query/iteration/index.ts +27 -6
  231. package/src/runtime/query/iteration/phases/context.ts +37 -11
  232. package/src/runtime/query/iteration/phases/per-call-review.ts +598 -0
  233. package/src/runtime/query/iteration/phases/tool-review.ts +168 -83
  234. package/src/runtime/query/iteration/step-context.ts +29 -0
  235. package/src/runtime/query/iteration/step-shaping.ts +6 -2
  236. package/src/runtime/query/prepare-turn.ts +18 -1
  237. package/src/runtime/query/project-instructions.ts +119 -38
  238. package/src/runtime/query/prompt.ts +11 -54
  239. package/src/runtime/query/resume-pending.ts +131 -0
  240. package/src/runtime/query/review-policy.ts +28 -0
  241. package/src/runtime/query/steering.ts +100 -96
  242. package/src/store/budget/disk.ts +4 -1
  243. package/src/tools/admit-facts.ts +6 -0
  244. package/src/tools/builtins/bash.ts +38 -28
  245. package/src/tools/builtins/browser.ts +38 -12
  246. package/src/tools/builtins/edit-apply.ts +12 -2
  247. package/src/tools/builtins/edit.ts +38 -12
  248. package/src/tools/builtins/exit-semantics.ts +198 -0
  249. package/src/tools/builtins/job.ts +1 -1
  250. package/src/tools/builtins/skill.ts +1 -1
  251. package/src/tools/builtins/structuredOutput.ts +1 -1
  252. package/src/tools/builtins/verify-outputs.ts +1 -1
  253. package/src/tools/builtins/write-file.ts +11 -6
  254. package/src/tools/coordinator/ask-user-question.ts +12 -1
  255. package/src/tools/coordinator/index.ts +10 -3
  256. package/src/tools/memory/delete.ts +1 -1
  257. package/src/tools/memory/read.ts +1 -1
  258. package/src/tools/memory/save.ts +4 -3
  259. package/src/tools/memory/search.ts +1 -1
  260. package/src/tools/memory/update.ts +1 -1
  261. package/src/tools/schedules/loop-tool.ts +4 -2
  262. package/src/tools/schedules/schedule-tool.ts +2 -2
  263. package/src/tools/task/create.ts +1 -1
  264. package/src/tools/untrusted-envelope.ts +1 -1
  265. package/src/toolsets/manager.ts +6 -1
  266. package/src/turn/evidence-query.ts +19 -1
  267. package/src/turn/evidence-recall.ts +1 -1
  268. package/src/types/agent/base.ts +8 -0
  269. package/src/types/common/index.ts +27 -0
  270. package/src/types/hitl/index.ts +67 -0
  271. package/src/types/message/index.ts +20 -2
  272. package/src/types/message/notice-frame.ts +88 -0
  273. package/src/types/provider/chat.ts +8 -0
  274. package/src/types/provider/config.ts +6 -0
  275. package/src/types/session/checkpoint.ts +5 -0
  276. package/src/types/session/events.ts +6 -0
  277. package/src/types/session/records.ts +1 -1
  278. package/src/types/tool/presentation.ts +16 -0
@@ -0,0 +1,598 @@
1
+ import type { AuthorizationGate } from '../../../../authorization/index.js'
2
+ import type {
3
+ HITLResumeDecision,
4
+ ToolCallDeclined,
5
+ ToolCallSummary,
6
+ } from '../../../../types/hitl/index.js'
7
+ import type { ToolCall } from '../../../../types/message/index.js'
8
+ import { DECLINED_TOOL_CALL_FEEDBACK } from '../../declined.js'
9
+ import type { CallAdmission, ReviewedExecution } from '../../executor.js'
10
+ import { DECLINED_NOTE_MAX_CHARS } from '../../executor.js'
11
+ import { supersededByRecovery } from '../../resume-pending.js'
12
+ import { type IterationContext, awaitDecisionDurably } from './context.js'
13
+
14
+ /** What a call is told when a refusal elsewhere in its batch ended the turn before it started. */
15
+ export const REFUSED_WITH_THE_TURN =
16
+ 'An earlier call in this batch was refused and the turn was stopped, so this call was not run. Wait for the user.'
17
+
18
+ /** What a call is told when the answer to it could not be taken. */
19
+ export const UNANSWERED_CALL =
20
+ 'No answer was given for this call, so it was not run. Ask again if it is still needed.'
21
+
22
+ const EDIT_NOT_OFFERED =
23
+ 'Editing a call while it is asked about is not offered, so this call was not run. Ask again with the input you want.'
24
+
25
+ const PAUSED_CALL =
26
+ 'The turn was asked to wait before this call could be answered, so it was not run. Ask again if it is still needed.'
27
+
28
+ const NOT_THIS_QUESTION =
29
+ 'That answer was not an answer to a question about a tool call, so this call was not run.'
30
+
31
+ /** The audit trail keeps a person's reason, cut short and without control characters. */
32
+ const AUDIT_NOTE_MAX_CHARS = 500
33
+
34
+ /**
35
+ * A person's words, made safe to print: control characters (an escape
36
+ * sequence among them) become spaces, line breaks and tabs stay, and the
37
+ * length is capped by code point.
38
+ */
39
+ export function cleanPersonNote(note: string | undefined): string {
40
+ if (note === undefined) return ''
41
+ let out = ''
42
+ let inGap = false
43
+ for (const ch of note) {
44
+ const code = ch.codePointAt(0) as number
45
+ const control = code <= 0x08 || (code >= 0x0b && code <= 0x1f) || (code >= 0x7f && code <= 0x9f)
46
+ if (control) {
47
+ if (!inGap) out += ' '
48
+ inGap = true
49
+ continue
50
+ }
51
+ inGap = false
52
+ out += ch
53
+ }
54
+ return Array.from(out.trim()).slice(0, DECLINED_NOTE_MAX_CHARS).join('')
55
+ }
56
+
57
+ interface Refusal {
58
+ readonly run: false
59
+ readonly reason: string
60
+ readonly declined?: ToolCallDeclined
61
+ }
62
+
63
+ type Verdict = { readonly run: true } | Refusal
64
+
65
+ /** What a decision about some calls means for each of them. */
66
+ interface Interpreted {
67
+ readonly verdicts: Map<string, Verdict>
68
+ /** The calls were refused and the refusal ends the turn. */
69
+ readonly endsTurn: boolean
70
+ /** The decision was the host stopping the turn outright. */
71
+ readonly cancelled: boolean
72
+ /** The decision was a request to wait: the park it recorded must not stay open. */
73
+ readonly paused: boolean
74
+ }
75
+
76
+ interface OpenAsk {
77
+ readonly calls: readonly ToolCallSummary[]
78
+ readonly controller: AbortController
79
+ /** A question about one call can be closed by a re-check; a question about several cannot. */
80
+ readonly recheckable: boolean
81
+ /** Why the question was closed from here, when it was. */
82
+ closedBy?: 'covered' | 'ended'
83
+ /** The question's answer was taken from the close itself, not from the host. */
84
+ closedHere?: true
85
+ }
86
+
87
+ /** What the walk needs from the review phase; keeps this file free of that file's closures. */
88
+ export interface PerCallReviewDeps {
89
+ readonly ctx: IterationContext & {
90
+ readonly verificationGate?: AuthorizationGate
91
+ }
92
+ readonly iteration: number
93
+ readonly toolCalls: readonly ToolCall[]
94
+ readonly summaries: () => readonly ToolCallSummary[]
95
+ /** Ids the review may not release: the operator's policy denied them. */
96
+ readonly gateDenied: ReadonlyMap<string, string>
97
+ readonly reviewAllowed: () => boolean
98
+ /**
99
+ * Calls that carry no summary because their preparation failed: they run
100
+ * nothing, the executor answers them with the reason, so they need no yes.
101
+ */
102
+ readonly unreviewedIds: ReadonlySet<string>
103
+ /** The shared checks that follow an approval or a refusal; see `runToolReview`. */
104
+ readonly settleEscalations: (
105
+ denials: Map<string, string>,
106
+ confirmed: readonly string[] | undefined,
107
+ calls: readonly ToolCallSummary[],
108
+ ) => Promise<void>
109
+ readonly recordGrantAudits: (
110
+ decision: HITLResumeDecision,
111
+ calls: readonly ToolCallSummary[],
112
+ ) => Promise<void>
113
+ readonly evaluateGate: (call: ToolCallSummary) => 'allow' | 'deny' | 'review' | undefined
114
+ }
115
+
116
+ /**
117
+ * The per-call review of one batch: it answers the executor's question "may this
118
+ * call start" by asking about that call alone, and keeps every question that is
119
+ * open at once consistent with the others.
120
+ *
121
+ * - An answer that grants something (a remembered approval, a changed rule or
122
+ * mode on the host's side) re-checks the questions still open, and any call
123
+ * the new state covers is let through unasked. A re-check never refuses.
124
+ * - A plain refusal ends the turn: the questions still open close, calls not
125
+ * yet started are refused without being asked, calls in flight are cancelled.
126
+ * - Every refusal and every call that could not be matched to a reviewed
127
+ * summary is a refusal. Nothing is approved by the absence of an answer.
128
+ */
129
+ export class PerCallReview implements ReviewedExecution {
130
+ readonly #deps: PerCallReviewDeps
131
+ readonly #endController = new AbortController()
132
+ readonly #open = new Set<OpenAsk>()
133
+ readonly #approvedTogether = new Set<string>()
134
+ /** Refusals settled before the walk, from the question asked of several calls at once. */
135
+ readonly denials = new Map<string, string>()
136
+ readonly declines = new Map<string, ToolCallDeclined>()
137
+ #endsTurn = false
138
+ #cancelled = false
139
+ #refused = 0
140
+ #checking: Promise<void> | undefined
141
+ #checkAgain = false
142
+ #unsubscribe: (() => void) | undefined
143
+ /**
144
+ * The end of the line of calls waiting to open their question. Questions open
145
+ * one after another in the order the calls were admitted, which is the order
146
+ * the model wrote them, whatever each call's own checks take to finish.
147
+ */
148
+ #line: Promise<void> = Promise.resolve()
149
+
150
+ constructor(deps: PerCallReviewDeps) {
151
+ this.#deps = deps
152
+ this.#unsubscribe = deps.ctx.perCallReview?.onRulesChanged?.(() => {
153
+ void this.recheck()
154
+ })
155
+ }
156
+
157
+ get ended(): AbortSignal {
158
+ return this.#endController.signal
159
+ }
160
+
161
+ /** A refusal (or the host) ended the turn. */
162
+ get endsTurn(): boolean {
163
+ return this.#endsTurn
164
+ }
165
+
166
+ /** How many calls the walk refused, asked or not. */
167
+ get refusedCount(): number {
168
+ return this.#refused
169
+ }
170
+
171
+ /** The host stopped the turn rather than a person refusing a call. */
172
+ get cancelled(): boolean {
173
+ return this.#cancelled
174
+ }
175
+
176
+ /** Stop listening for changes. Called when the walk is over, whichever way it ended. */
177
+ dispose(): void {
178
+ this.#unsubscribe?.()
179
+ this.#unsubscribe = undefined
180
+ }
181
+
182
+ /**
183
+ * The questions that are about the batch rather than about one call, asked
184
+ * first and together. Their answers are settled here, before anything starts.
185
+ */
186
+ async askTogetherFirst(): Promise<void> {
187
+ const config = this.#deps.ctx.perCallReview
188
+ if (!config?.askTogether) return
189
+ const askable = this.#deps
190
+ .summaries()
191
+ .filter((call) => !this.#deps.gateDenied.has(call.id) && !this.#skipsAsk(call))
192
+ if (askable.length === 0) return
193
+ const wanted = new Set(config.askTogether(askable))
194
+ const together = askable.filter((call) => wanted.has(call.id))
195
+ if (together.length === 0) return
196
+ const interpreted = await this.#ask(together)
197
+ for (const call of together) {
198
+ const verdict = interpreted.verdicts.get(call.id) ?? {
199
+ run: false,
200
+ reason: UNANSWERED_CALL,
201
+ }
202
+ if (verdict.run) {
203
+ this.#approvedTogether.add(call.id)
204
+ } else {
205
+ this.denials.set(call.id, verdict.reason)
206
+ if (verdict.declined) this.declines.set(call.id, verdict.declined)
207
+ }
208
+ }
209
+ this.#noteEnd(interpreted)
210
+ }
211
+
212
+ /** Answers the executor: may this call start. */
213
+ async admit(call: ToolCall): Promise<CallAdmission> {
214
+ // Taken synchronously, so the place in line is the order of the asking.
215
+ const before = this.#line
216
+ let leave!: () => void
217
+ this.#line = new Promise<void>((resolve) => {
218
+ leave = resolve
219
+ })
220
+ try {
221
+ const verdict = await this.#decide(call, before, leave)
222
+ if (!verdict.run) this.#refused += 1
223
+ return verdict
224
+ } finally {
225
+ leave()
226
+ }
227
+ }
228
+
229
+ async #decide(call: ToolCall, before: Promise<void>, leave: () => void): Promise<CallAdmission> {
230
+ await before
231
+ if (this.ended.aborted) return { run: false, reason: REFUSED_WITH_THE_TURN }
232
+ if (this.#approvedTogether.has(call.id)) return { run: true }
233
+ const summary = this.#deps.summaries().find((candidate) => candidate.id === call.id)
234
+ if (summary === undefined) {
235
+ // A call whose preparation failed runs nothing; the executor answers it
236
+ // with the reason. Any other call that has no summary was never shown,
237
+ // and a call nobody saw is not approved.
238
+ return this.#deps.unreviewedIds.has(call.id)
239
+ ? { run: true }
240
+ : { run: false, reason: UNANSWERED_CALL }
241
+ }
242
+ if (this.#skipsAsk(summary)) return { run: true }
243
+ // A call that waited for its turn is checked against the rules, grants and
244
+ // mode as they are now: an earlier answer may have remembered a rule that
245
+ // covers it, and nobody is asked about what is already settled.
246
+ if (await this.#coveredNow(summary)) {
247
+ await this.#deps.ctx.recorder.recordAudit({
248
+ what: { action: 'tool_call', tool: summary.name },
249
+ outcome: 'approved',
250
+ reason:
251
+ 'a rule, an earlier approval or the permission mode changed while this call waited for its turn and now covers it; it was approved without being shown',
252
+ })
253
+ return { run: true }
254
+ }
255
+ const interpreted = await this.#ask([summary], leave)
256
+ this.#noteEnd(interpreted)
257
+ const verdict = interpreted.verdicts.get(summary.id) ?? {
258
+ run: false,
259
+ reason: UNANSWERED_CALL,
260
+ }
261
+ if (!verdict.run) {
262
+ this.#deps.ctx.log.info('A tool call was refused', {
263
+ 'namzu.tool.names': [summary.name],
264
+ })
265
+ return verdict
266
+ }
267
+ return { run: true }
268
+ }
269
+
270
+ /**
271
+ * Re-check every question still open against the rules and approvals as they
272
+ * are now. Only ever approves.
273
+ */
274
+ recheck(): Promise<void> {
275
+ if (this.#checking) {
276
+ this.#checkAgain = true
277
+ return this.#checking
278
+ }
279
+ this.#checking = (async () => {
280
+ try {
281
+ do {
282
+ this.#checkAgain = false
283
+ for (const ask of [...this.#open]) {
284
+ if (!ask.recheckable || ask.closedBy !== undefined || this.ended.aborted) continue
285
+ if (await this.#coveredNow(ask.calls[0] as ToolCallSummary)) {
286
+ // The person may have answered while this was being worked out.
287
+ if (!this.#open.has(ask) || ask.closedBy !== undefined) continue
288
+ ask.closedBy = 'covered'
289
+ ask.controller.abort()
290
+ }
291
+ }
292
+ } while (this.#checkAgain)
293
+ } finally {
294
+ this.#checking = undefined
295
+ }
296
+ })()
297
+ return this.#checking
298
+ }
299
+
300
+ /** Calls a re-check must never wave through: they exist to put a question to a person. */
301
+ static #alwaysAsked(call: ToolCallSummary): boolean {
302
+ return (
303
+ call.escalation !== undefined ||
304
+ call.requiresApproval === true ||
305
+ call.authorization?.askRule === true ||
306
+ call.authorization?.explicitReview === true ||
307
+ call.hookPermission?.decision === 'ask'
308
+ )
309
+ }
310
+
311
+ async #coveredNow(call: ToolCallSummary): Promise<boolean> {
312
+ if (PerCallReview.#alwaysAsked(call) || this.#deps.gateDenied.has(call.id)) return false
313
+ const { ctx } = this.#deps
314
+ // The host's veto comes before every approval path, the turn's own included.
315
+ if (await this.#vetoed(ctx.perCallReview?.neverCover, call)) return false
316
+ if (!this.#deps.reviewAllowed()) {
317
+ if (this.#deps.evaluateGate(call) === 'allow') return true
318
+ if (ctx.toolGrants?.covers(call)) return true
319
+ }
320
+ try {
321
+ return (await ctx.perCallReview?.covers?.(call)) === true
322
+ } catch {
323
+ // A host predicate that fails is not a yes.
324
+ return false
325
+ }
326
+ }
327
+
328
+ /** A host predicate that throws vetoes: a failure is never a yes. */
329
+ async #vetoed(
330
+ veto: ((call: ToolCallSummary) => boolean | Promise<boolean>) | undefined,
331
+ call: ToolCallSummary,
332
+ ): Promise<boolean> {
333
+ if (!veto) return false
334
+ try {
335
+ return (await veto(call)) !== false
336
+ } catch {
337
+ return true
338
+ }
339
+ }
340
+
341
+ /** Whether the turn's own rules and earlier approvals let this call run unasked. */
342
+ #skipsAsk(call: ToolCallSummary): boolean {
343
+ if (this.#deps.reviewAllowed()) return false
344
+ if (call.authorization?.decision === 'allow') return !PerCallReview.#alwaysAsked(call)
345
+ return !PerCallReview.#alwaysAsked(call) && this.#deps.ctx.toolGrants?.covers(call) === true
346
+ }
347
+
348
+ #noteEnd(interpreted: Interpreted): void {
349
+ if (!interpreted.endsTurn && !interpreted.cancelled) return
350
+ this.#end(interpreted.cancelled)
351
+ }
352
+
353
+ #end(cancelled: boolean): void {
354
+ if (this.#endsTurn) return
355
+ this.#endsTurn = true
356
+ this.#cancelled = cancelled
357
+ for (const ask of this.#open) {
358
+ ask.closedBy ??= 'ended'
359
+ ask.controller.abort()
360
+ }
361
+ this.#endController.abort(new Error('the user stopped the turn'))
362
+ }
363
+
364
+ /** One request to the handler about `calls`, and what its answer means for each. */
365
+ async #ask(calls: readonly ToolCallSummary[], opened?: () => void): Promise<Interpreted> {
366
+ const { ctx, iteration } = this.#deps
367
+ const checkpoint = await ctx.checkpointMgr.create(ctx.recorder, iteration)
368
+ await ctx.emitEvent({
369
+ type: 'tool_review_requested',
370
+ turnId: ctx.recorder.turnId,
371
+ toolCalls: [...calls],
372
+ iteration,
373
+ })
374
+ const asked: OpenAsk = {
375
+ calls,
376
+ controller: new AbortController(),
377
+ recheckable: calls.length === 1,
378
+ }
379
+ this.#open.add(asked)
380
+ let decision: HITLResumeDecision
381
+ try {
382
+ const answer = awaitDecisionDurably(
383
+ ctx,
384
+ checkpoint,
385
+ {
386
+ type: 'tool_review',
387
+ sessionId: ctx.recorder.sessionId,
388
+ turnId: ctx.recorder.turnId,
389
+ checkpointId: checkpoint.id,
390
+ toolCalls: [...calls],
391
+ perCall: true,
392
+ },
393
+ {
394
+ signal: asked.controller.signal,
395
+ closedAs: () => {
396
+ asked.closedHere = true
397
+ return asked.closedBy === 'covered'
398
+ ? { action: 'approve_tools' }
399
+ : { action: 'reject_tools', feedback: REFUSED_WITH_THE_TURN }
400
+ },
401
+ },
402
+ )
403
+ // The handler has been given the question: the next call may open its own.
404
+ opened?.()
405
+ decision = await answer
406
+ } finally {
407
+ this.#open.delete(asked)
408
+ }
409
+
410
+ // Only an answer the close itself supplied counts as the close: a person
411
+ // who answered first has answered, and a re-check that landed a moment
412
+ // later does not turn their No into a yes.
413
+ if (asked.closedHere && asked.closedBy === 'covered') {
414
+ await ctx.emitEvent({
415
+ type: 'tool_review_completed',
416
+ turnId: ctx.recorder.turnId,
417
+ decision: 'approved',
418
+ })
419
+ for (const call of calls) {
420
+ await ctx.recorder.recordAudit({
421
+ what: { action: 'tool_call', tool: call.name },
422
+ outcome: 'approved',
423
+ reason:
424
+ 'a rule, an earlier approval or the permission mode changed while this call waited and now covers it; it was approved without being shown',
425
+ })
426
+ }
427
+ return this.#settle(
428
+ { action: 'approve_tools' },
429
+ calls,
430
+ new Map(calls.map((call) => [call.id, { run: true } as Verdict])),
431
+ false,
432
+ false,
433
+ false,
434
+ )
435
+ }
436
+ if (asked.closedHere && asked.closedBy === 'ended') {
437
+ await ctx.emitEvent({
438
+ type: 'tool_review_completed',
439
+ turnId: ctx.recorder.turnId,
440
+ decision: 'rejected',
441
+ })
442
+ return {
443
+ verdicts: new Map(
444
+ calls.map((call) => [call.id, { run: false, reason: REFUSED_WITH_THE_TURN } as Verdict]),
445
+ ),
446
+ endsTurn: false,
447
+ cancelled: false,
448
+ paused: false,
449
+ }
450
+ }
451
+
452
+ const interpreted = this.#interpret(decision, calls)
453
+ await ctx.emitEvent({
454
+ type: 'tool_review_completed',
455
+ turnId: ctx.recorder.turnId,
456
+ decision: decisionLabel(decision, interpreted),
457
+ })
458
+ if (interpreted.paused) {
459
+ await ctx.checkpointMgr
460
+ .unpark(checkpoint.id, supersededByRecovery(decision))
461
+ .catch(() => null)
462
+ }
463
+ return this.#settle(
464
+ decision,
465
+ calls,
466
+ interpreted.verdicts,
467
+ interpreted.endsTurn,
468
+ interpreted.cancelled,
469
+ interpreted.paused,
470
+ )
471
+ }
472
+
473
+ /** The checks that follow an answer, then the answer's side effects. */
474
+ async #settle(
475
+ decision: HITLResumeDecision,
476
+ calls: readonly ToolCallSummary[],
477
+ verdicts: Map<string, Verdict>,
478
+ endsTurn: boolean,
479
+ cancelled: boolean,
480
+ paused: boolean,
481
+ ): Promise<Interpreted> {
482
+ const { ctx } = this.#deps
483
+ const denials = new Map<string, string>()
484
+ for (const call of calls) {
485
+ const verdict = verdicts.get(call.id)
486
+ if (verdict && !verdict.run) denials.set(call.id, verdict.reason)
487
+ }
488
+ const confirmed =
489
+ decision.action === 'approve_tools' || decision.action === 'modify_tools'
490
+ ? decision.confirmedEscalations
491
+ : undefined
492
+ await this.#deps.settleEscalations(denials, confirmed, calls)
493
+ const result = new Map<string, Verdict>()
494
+ for (const call of calls) {
495
+ const prior = verdicts.get(call.id) ?? {
496
+ run: false,
497
+ reason: UNANSWERED_CALL,
498
+ }
499
+ const reason = denials.get(call.id)
500
+ // An escape the answer did not confirm turns a yes into a refusal here.
501
+ result.set(call.id, prior.run && reason !== undefined ? { run: false, reason } : prior)
502
+ }
503
+ const approvedCalls = calls.filter((call) => result.get(call.id)?.run === true)
504
+ if (decision.action === 'approve_tools' && approvedCalls.length > 0) {
505
+ if (decision.remember) ctx.toolGrants?.grant(decision.remember)
506
+ await this.#deps.recordGrantAudits(decision, approvedCalls)
507
+ // What was just remembered may cover a question still open.
508
+ if (decision.remember) void this.recheck()
509
+ }
510
+ for (const call of calls) {
511
+ const verdict = result.get(call.id)
512
+ if (verdict && !verdict.run && verdict.declined) {
513
+ const note = cleanPersonNote(verdict.declined.note)
514
+ await ctx.recorder.recordAudit({
515
+ what: { action: 'tool_call', tool: call.name },
516
+ outcome: 'refused',
517
+ reason: `declined by the person${
518
+ note ? `: ${Array.from(note).slice(0, AUDIT_NOTE_MAX_CHARS).join('')}` : ''
519
+ }`,
520
+ })
521
+ }
522
+ }
523
+ return { verdicts: result, endsTurn, cancelled, paused }
524
+ }
525
+
526
+ #interpret(decision: HITLResumeDecision, calls: readonly ToolCallSummary[]): Interpreted {
527
+ const verdicts = new Map<string, Verdict>()
528
+ const refuseAll = (verdict: Refusal) => {
529
+ for (const call of calls) verdicts.set(call.id, verdict)
530
+ }
531
+ let endsTurn = false
532
+ let cancelled = false
533
+ let paused = false
534
+ switch (decision.action) {
535
+ case 'approve_tools':
536
+ case 'continue':
537
+ for (const call of calls) verdicts.set(call.id, { run: true })
538
+ break
539
+ case 'reject_tools': {
540
+ const note = cleanPersonNote(decision.declined?.note)
541
+ refuseAll({
542
+ run: false,
543
+ reason: decision.feedback
544
+ ? Array.from(decision.feedback)
545
+ .slice(0, DECLINED_NOTE_MAX_CHARS * 2)
546
+ .join('')
547
+ : DECLINED_TOOL_CALL_FEEDBACK,
548
+ ...(decision.declined ? { declined: note ? { note } : {} } : {}),
549
+ })
550
+ endsTurn = decision.endTurn === true
551
+ break
552
+ }
553
+ case 'modify_tools': {
554
+ for (const call of calls) {
555
+ const mods = decision.modifications.filter((mod) => mod.toolCallId === call.id)
556
+ if (mods.some((mod) => mod.action === 'deny')) {
557
+ verdicts.set(call.id, {
558
+ run: false,
559
+ reason: DECLINED_TOOL_CALL_FEEDBACK,
560
+ declined: {},
561
+ })
562
+ } else if (mods.some((mod) => mod.action === 'modify')) {
563
+ verdicts.set(call.id, { run: false, reason: EDIT_NOT_OFFERED })
564
+ } else if (mods.some((mod) => mod.action === 'approve')) {
565
+ verdicts.set(call.id, { run: true })
566
+ } else {
567
+ verdicts.set(call.id, { run: false, reason: UNANSWERED_CALL })
568
+ }
569
+ }
570
+ break
571
+ }
572
+ case 'pause':
573
+ refuseAll({ run: false, reason: PAUSED_CALL })
574
+ paused = true
575
+ break
576
+ case 'abort':
577
+ refuseAll({ run: false, reason: REFUSED_WITH_THE_TURN })
578
+ cancelled = true
579
+ break
580
+ default:
581
+ // A plan or question answer to a tool question: not an answer to it.
582
+ this.#deps.ctx.log.warn('Unexpected decision during a per-call tool review', {
583
+ 'namzu.runtime.action': decision.action,
584
+ })
585
+ refuseAll({ run: false, reason: NOT_THIS_QUESTION })
586
+ }
587
+ return { verdicts, endsTurn, cancelled, paused }
588
+ }
589
+ }
590
+
591
+ function decisionLabel(
592
+ decision: HITLResumeDecision,
593
+ interpreted: Interpreted,
594
+ ): 'approved' | 'modified' | 'rejected' {
595
+ if (decision.action === 'modify_tools') return 'modified'
596
+ const refused = [...interpreted.verdicts.values()].some((verdict) => !verdict.run)
597
+ return refused ? 'rejected' : 'approved'
598
+ }