@namzu/sdk 31.1.0 → 33.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 (355) hide show
  1. package/CHANGELOG.md +188 -0
  2. package/dist/agents/ReactiveAgent.d.ts.map +1 -1
  3. package/dist/agents/ReactiveAgent.js +4 -0
  4. package/dist/agents/ReactiveAgent.js.map +1 -1
  5. package/dist/agents/SupervisorAgent.d.ts.map +1 -1
  6. package/dist/agents/SupervisorAgent.js +4 -0
  7. package/dist/agents/SupervisorAgent.js.map +1 -1
  8. package/dist/agents/runAgent.d.ts +4 -0
  9. package/dist/agents/runAgent.d.ts.map +1 -1
  10. package/dist/agents/runAgent.js +4 -0
  11. package/dist/agents/runAgent.js.map +1 -1
  12. package/dist/authorization/gate.d.ts +1 -1
  13. package/dist/authorization/gate.js +1 -1
  14. package/dist/connector/index.d.ts +1 -1
  15. package/dist/connector/index.d.ts.map +1 -1
  16. package/dist/connector/index.js +1 -1
  17. package/dist/connector/index.js.map +1 -1
  18. package/dist/connector/mcp/adapter.d.ts.map +1 -1
  19. package/dist/connector/mcp/adapter.js +71 -16
  20. package/dist/connector/mcp/adapter.js.map +1 -1
  21. package/dist/connector/mcp/client.d.ts.map +1 -1
  22. package/dist/connector/mcp/client.js +3 -2
  23. package/dist/connector/mcp/client.js.map +1 -1
  24. package/dist/connector/mcp/http-redirect.d.ts +17 -0
  25. package/dist/connector/mcp/http-redirect.d.ts.map +1 -0
  26. package/dist/connector/mcp/http-redirect.js +31 -0
  27. package/dist/connector/mcp/http-redirect.js.map +1 -0
  28. package/dist/connector/mcp/http-sse.d.ts.map +1 -1
  29. package/dist/connector/mcp/http-sse.js +5 -0
  30. package/dist/connector/mcp/http-sse.js.map +1 -1
  31. package/dist/connector/mcp/image-admission.d.ts +16 -0
  32. package/dist/connector/mcp/image-admission.d.ts.map +1 -0
  33. package/dist/connector/mcp/image-admission.js +288 -0
  34. package/dist/connector/mcp/image-admission.js.map +1 -0
  35. package/dist/connector/mcp/stdio.d.ts +13 -0
  36. package/dist/connector/mcp/stdio.d.ts.map +1 -1
  37. package/dist/connector/mcp/stdio.js +66 -23
  38. package/dist/connector/mcp/stdio.js.map +1 -1
  39. package/dist/connector/mcp/streamable-http.d.ts.map +1 -1
  40. package/dist/connector/mcp/streamable-http.js +3 -0
  41. package/dist/connector/mcp/streamable-http.js.map +1 -1
  42. package/dist/constants/tools/index.js +1 -1
  43. package/dist/constants/tools/index.js.map +1 -1
  44. package/dist/directory/derive-supervisor.d.ts.map +1 -1
  45. package/dist/directory/derive-supervisor.js +5 -0
  46. package/dist/directory/derive-supervisor.js.map +1 -1
  47. package/dist/directory/derive.d.ts.map +1 -1
  48. package/dist/directory/derive.js +5 -0
  49. package/dist/directory/derive.js.map +1 -1
  50. package/dist/directory/load.d.ts.map +1 -1
  51. package/dist/directory/load.js +1 -0
  52. package/dist/directory/load.js.map +1 -1
  53. package/dist/directory/types.d.ts +2 -0
  54. package/dist/directory/types.d.ts.map +1 -1
  55. package/dist/execution/base.d.ts +13 -0
  56. package/dist/execution/base.d.ts.map +1 -1
  57. package/dist/execution/base.js +103 -8
  58. package/dist/execution/base.js.map +1 -1
  59. package/dist/execution/code-runtime/types.d.ts +11 -1
  60. package/dist/execution/code-runtime/types.d.ts.map +1 -1
  61. package/dist/execution/code-runtime/types.js.map +1 -1
  62. package/dist/execution/code-runtime/worker.d.ts.map +1 -1
  63. package/dist/execution/code-runtime/worker.js +67 -9
  64. package/dist/execution/code-runtime/worker.js.map +1 -1
  65. package/dist/execution/errors.d.ts +12 -0
  66. package/dist/execution/errors.d.ts.map +1 -0
  67. package/dist/execution/errors.js +21 -0
  68. package/dist/execution/errors.js.map +1 -0
  69. package/dist/execution/factory.d.ts.map +1 -1
  70. package/dist/execution/factory.js +1 -0
  71. package/dist/execution/factory.js.map +1 -1
  72. package/dist/execution/hybrid.d.ts +5 -0
  73. package/dist/execution/hybrid.d.ts.map +1 -1
  74. package/dist/execution/hybrid.js +36 -16
  75. package/dist/execution/hybrid.js.map +1 -1
  76. package/dist/execution/index.d.ts +1 -0
  77. package/dist/execution/index.d.ts.map +1 -1
  78. package/dist/execution/index.js +1 -0
  79. package/dist/execution/index.js.map +1 -1
  80. package/dist/execution/local.d.ts +13 -0
  81. package/dist/execution/local.d.ts.map +1 -1
  82. package/dist/execution/local.js +145 -19
  83. package/dist/execution/local.js.map +1 -1
  84. package/dist/execution/output.d.ts +27 -0
  85. package/dist/execution/output.d.ts.map +1 -0
  86. package/dist/execution/output.js +105 -0
  87. package/dist/execution/output.js.map +1 -0
  88. package/dist/execution/remote.d.ts +26 -2
  89. package/dist/execution/remote.d.ts.map +1 -1
  90. package/dist/execution/remote.js +86 -15
  91. package/dist/execution/remote.js.map +1 -1
  92. package/dist/manager/agent/lifecycle.d.ts.map +1 -1
  93. package/dist/manager/agent/lifecycle.js +1 -0
  94. package/dist/manager/agent/lifecycle.js.map +1 -1
  95. package/dist/plugin/lifecycle.d.ts.map +1 -1
  96. package/dist/plugin/lifecycle.js +9 -3
  97. package/dist/plugin/lifecycle.js.map +1 -1
  98. package/dist/process/kill-tree.d.ts +11 -13
  99. package/dist/process/kill-tree.d.ts.map +1 -1
  100. package/dist/process/kill-tree.js +11 -13
  101. package/dist/process/kill-tree.js.map +1 -1
  102. package/dist/provider/errors.d.ts +1 -0
  103. package/dist/provider/errors.d.ts.map +1 -1
  104. package/dist/provider/errors.js +39 -1
  105. package/dist/provider/errors.js.map +1 -1
  106. package/dist/provider/fallback.d.ts.map +1 -1
  107. package/dist/provider/fallback.js +31 -0
  108. package/dist/provider/fallback.js.map +1 -1
  109. package/dist/provider/idle-timeout.d.ts.map +1 -1
  110. package/dist/provider/idle-timeout.js +5 -0
  111. package/dist/provider/idle-timeout.js.map +1 -1
  112. package/dist/provider/retry.d.ts.map +1 -1
  113. package/dist/provider/retry.js +5 -0
  114. package/dist/provider/retry.js.map +1 -1
  115. package/dist/public-runtime.d.ts +7 -5
  116. package/dist/public-runtime.d.ts.map +1 -1
  117. package/dist/public-runtime.js +9 -4
  118. package/dist/public-runtime.js.map +1 -1
  119. package/dist/public-types.d.ts +2 -1
  120. package/dist/public-types.d.ts.map +1 -1
  121. package/dist/registry/tool/execute.d.ts +7 -1
  122. package/dist/registry/tool/execute.d.ts.map +1 -1
  123. package/dist/registry/tool/execute.js +201 -54
  124. package/dist/registry/tool/execute.js.map +1 -1
  125. package/dist/run/command-gate.d.ts.map +1 -1
  126. package/dist/run/command-gate.js +11 -0
  127. package/dist/run/command-gate.js.map +1 -1
  128. package/dist/run/reporter.d.ts.map +1 -1
  129. package/dist/run/reporter.js +6 -1
  130. package/dist/run/reporter.js.map +1 -1
  131. package/dist/run/workspace-fingerprint.d.ts +4 -4
  132. package/dist/run/workspace-fingerprint.d.ts.map +1 -1
  133. package/dist/run/workspace-fingerprint.js +6 -4
  134. package/dist/run/workspace-fingerprint.js.map +1 -1
  135. package/dist/runtime/bidi/index.d.ts +1 -1
  136. package/dist/runtime/bidi/index.d.ts.map +1 -1
  137. package/dist/runtime/bidi/index.js +1 -1
  138. package/dist/runtime/bidi/index.js.map +1 -1
  139. package/dist/runtime/bidi/session.d.ts +19 -0
  140. package/dist/runtime/bidi/session.d.ts.map +1 -1
  141. package/dist/runtime/bidi/session.js +150 -26
  142. package/dist/runtime/bidi/session.js.map +1 -1
  143. package/dist/runtime/query/executor.d.ts +43 -1
  144. package/dist/runtime/query/executor.d.ts.map +1 -1
  145. package/dist/runtime/query/executor.js +824 -174
  146. package/dist/runtime/query/executor.js.map +1 -1
  147. package/dist/runtime/query/index.d.ts +5 -0
  148. package/dist/runtime/query/index.d.ts.map +1 -1
  149. package/dist/runtime/query/index.js +47 -4
  150. package/dist/runtime/query/index.js.map +1 -1
  151. package/dist/runtime/query/iteration/index.d.ts +1 -0
  152. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  153. package/dist/runtime/query/iteration/index.js +34 -14
  154. package/dist/runtime/query/iteration/index.js.map +1 -1
  155. package/dist/runtime/query/iteration/phases/advisory.js +2 -2
  156. package/dist/runtime/query/iteration/phases/advisory.js.map +1 -1
  157. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  158. package/dist/runtime/query/iteration/phases/tool-review.js +84 -22
  159. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  160. package/dist/runtime/query/iteration/provider-rejected-image.d.ts +13 -0
  161. package/dist/runtime/query/iteration/provider-rejected-image.d.ts.map +1 -0
  162. package/dist/runtime/query/iteration/provider-rejected-image.js +91 -0
  163. package/dist/runtime/query/iteration/provider-rejected-image.js.map +1 -0
  164. package/dist/runtime/query/iteration/stream-turn.d.ts +4 -1
  165. package/dist/runtime/query/iteration/stream-turn.d.ts.map +1 -1
  166. package/dist/runtime/query/iteration/stream-turn.js +7 -3
  167. package/dist/runtime/query/iteration/stream-turn.js.map +1 -1
  168. package/dist/runtime/query/request-rich-content.d.ts +13 -0
  169. package/dist/runtime/query/request-rich-content.d.ts.map +1 -1
  170. package/dist/runtime/query/request-rich-content.js +173 -17
  171. package/dist/runtime/query/request-rich-content.js.map +1 -1
  172. package/dist/runtime/query/result.d.ts.map +1 -1
  173. package/dist/runtime/query/result.js +1 -0
  174. package/dist/runtime/query/result.js.map +1 -1
  175. package/dist/runtime/query/resume-pending.d.ts +5 -1
  176. package/dist/runtime/query/resume-pending.d.ts.map +1 -1
  177. package/dist/runtime/query/resume-pending.js +66 -1
  178. package/dist/runtime/query/resume-pending.js.map +1 -1
  179. package/dist/runtime/query/tool-output-budget.d.ts.map +1 -1
  180. package/dist/runtime/query/tool-output-budget.js +45 -16
  181. package/dist/runtime/query/tool-output-budget.js.map +1 -1
  182. package/dist/runtime/query/tooling.d.ts +6 -0
  183. package/dist/runtime/query/tooling.d.ts.map +1 -1
  184. package/dist/runtime/query/tooling.js +4 -0
  185. package/dist/runtime/query/tooling.js.map +1 -1
  186. package/dist/sandbox/provider/local.d.ts.map +1 -1
  187. package/dist/sandbox/provider/local.js +193 -41
  188. package/dist/sandbox/provider/local.js.map +1 -1
  189. package/dist/scheduler/delegating.d.ts +2 -1
  190. package/dist/scheduler/delegating.d.ts.map +1 -1
  191. package/dist/scheduler/delegating.js +6 -5
  192. package/dist/scheduler/delegating.js.map +1 -1
  193. package/dist/scheduler/local.d.ts +2 -1
  194. package/dist/scheduler/local.d.ts.map +1 -1
  195. package/dist/scheduler/local.js +2 -2
  196. package/dist/scheduler/local.js.map +1 -1
  197. package/dist/session/workspace/driver.d.ts +12 -4
  198. package/dist/session/workspace/driver.d.ts.map +1 -1
  199. package/dist/session/workspace/git-worktree.d.ts +17 -3
  200. package/dist/session/workspace/git-worktree.d.ts.map +1 -1
  201. package/dist/session/workspace/git-worktree.js +153 -34
  202. package/dist/session/workspace/git-worktree.js.map +1 -1
  203. package/dist/skills/registry.d.ts +15 -0
  204. package/dist/skills/registry.d.ts.map +1 -1
  205. package/dist/skills/registry.js +33 -0
  206. package/dist/skills/registry.js.map +1 -1
  207. package/dist/store/attachment/index.d.ts +24 -2
  208. package/dist/store/attachment/index.d.ts.map +1 -1
  209. package/dist/store/attachment/index.js +52 -5
  210. package/dist/store/attachment/index.js.map +1 -1
  211. package/dist/tools/builtins/computer-use.d.ts.map +1 -1
  212. package/dist/tools/builtins/computer-use.js +39 -2
  213. package/dist/tools/builtins/computer-use.js.map +1 -1
  214. package/dist/tools/builtins/lsp.d.ts +1 -1
  215. package/dist/tools/builtins/run-code.d.ts.map +1 -1
  216. package/dist/tools/builtins/run-code.js +24 -16
  217. package/dist/tools/builtins/run-code.js.map +1 -1
  218. package/dist/tools/builtins/skill.d.ts +2 -1
  219. package/dist/tools/builtins/skill.d.ts.map +1 -1
  220. package/dist/tools/builtins/skill.js +250 -6
  221. package/dist/tools/builtins/skill.js.map +1 -1
  222. package/dist/tools/coordinator/agent.d.ts.map +1 -1
  223. package/dist/tools/coordinator/agent.js +100 -30
  224. package/dist/tools/coordinator/agent.js.map +1 -1
  225. package/dist/types/agent/base.d.ts +4 -0
  226. package/dist/types/agent/base.d.ts.map +1 -1
  227. package/dist/types/agent/factory.d.ts +1 -0
  228. package/dist/types/agent/factory.d.ts.map +1 -1
  229. package/dist/types/agent/manager.d.ts +1 -1
  230. package/dist/types/agent/manager.d.ts.map +1 -1
  231. package/dist/types/agent/scheduler.d.ts +10 -1
  232. package/dist/types/agent/scheduler.d.ts.map +1 -1
  233. package/dist/types/authorization/index.d.ts +12 -12
  234. package/dist/types/bidi/index.d.ts +19 -2
  235. package/dist/types/bidi/index.d.ts.map +1 -1
  236. package/dist/types/computer-use/index.d.ts +18 -0
  237. package/dist/types/computer-use/index.d.ts.map +1 -1
  238. package/dist/types/connector/execution-context.d.ts +3 -1
  239. package/dist/types/connector/execution-context.d.ts.map +1 -1
  240. package/dist/types/execution/index.d.ts +43 -1
  241. package/dist/types/execution/index.d.ts.map +1 -1
  242. package/dist/types/hitl/index.d.ts +11 -0
  243. package/dist/types/hitl/index.d.ts.map +1 -1
  244. package/dist/types/hitl/index.js.map +1 -1
  245. package/dist/types/message/index.d.ts +31 -1
  246. package/dist/types/message/index.d.ts.map +1 -1
  247. package/dist/types/message/index.js +43 -0
  248. package/dist/types/message/index.js.map +1 -1
  249. package/dist/types/plugin/index.d.ts +14 -1
  250. package/dist/types/plugin/index.d.ts.map +1 -1
  251. package/dist/types/plugin/index.js +1 -0
  252. package/dist/types/plugin/index.js.map +1 -1
  253. package/dist/types/provider/error.d.ts +4 -1
  254. package/dist/types/provider/error.d.ts.map +1 -1
  255. package/dist/types/provider/interface.d.ts +14 -0
  256. package/dist/types/provider/interface.d.ts.map +1 -1
  257. package/dist/types/run/events.d.ts +10 -2
  258. package/dist/types/run/events.d.ts.map +1 -1
  259. package/dist/types/run/events.js.map +1 -1
  260. package/dist/types/sandbox/index.d.ts +15 -10
  261. package/dist/types/sandbox/index.d.ts.map +1 -1
  262. package/dist/types/sandbox/index.js.map +1 -1
  263. package/dist/types/tool/index.d.ts +129 -12
  264. package/dist/types/tool/index.d.ts.map +1 -1
  265. package/dist/types/tool/index.js.map +1 -1
  266. package/dist/types/workspace/ref.d.ts +5 -0
  267. package/dist/types/workspace/ref.d.ts.map +1 -1
  268. package/dist/utils/abort.d.ts +8 -0
  269. package/dist/utils/abort.d.ts.map +1 -1
  270. package/dist/utils/abort.js +16 -0
  271. package/dist/utils/abort.js.map +1 -1
  272. package/package.json +4 -4
  273. package/src/agents/ReactiveAgent.ts +4 -0
  274. package/src/agents/SupervisorAgent.ts +4 -0
  275. package/src/agents/runAgent.ts +8 -0
  276. package/src/authorization/gate.ts +1 -1
  277. package/src/connector/index.ts +2 -0
  278. package/src/connector/mcp/adapter.ts +82 -21
  279. package/src/connector/mcp/client.ts +2 -1
  280. package/src/connector/mcp/http-redirect.ts +32 -0
  281. package/src/connector/mcp/http-sse.ts +5 -0
  282. package/src/connector/mcp/image-admission.ts +272 -0
  283. package/src/connector/mcp/stdio.ts +63 -21
  284. package/src/connector/mcp/streamable-http.ts +3 -0
  285. package/src/constants/tools/index.ts +1 -1
  286. package/src/directory/derive-supervisor.ts +5 -0
  287. package/src/directory/derive.ts +5 -0
  288. package/src/directory/load.ts +1 -0
  289. package/src/directory/types.ts +2 -0
  290. package/src/execution/base.ts +124 -7
  291. package/src/execution/code-runtime/types.ts +15 -1
  292. package/src/execution/code-runtime/worker.ts +72 -9
  293. package/src/execution/errors.ts +24 -0
  294. package/src/execution/factory.ts +1 -0
  295. package/src/execution/hybrid.ts +47 -16
  296. package/src/execution/index.ts +1 -0
  297. package/src/execution/local.ts +164 -19
  298. package/src/execution/output.ts +104 -0
  299. package/src/execution/remote.ts +122 -17
  300. package/src/manager/agent/lifecycle.ts +1 -0
  301. package/src/plugin/lifecycle.ts +9 -3
  302. package/src/process/kill-tree.ts +11 -13
  303. package/src/provider/errors.ts +40 -1
  304. package/src/provider/fallback.ts +39 -0
  305. package/src/provider/idle-timeout.ts +8 -0
  306. package/src/provider/retry.ts +8 -0
  307. package/src/public-runtime.ts +23 -2
  308. package/src/public-types.ts +11 -0
  309. package/src/registry/tool/execute.ts +230 -64
  310. package/src/run/command-gate.ts +12 -0
  311. package/src/run/reporter.ts +7 -1
  312. package/src/run/workspace-fingerprint.ts +5 -4
  313. package/src/runtime/bidi/index.ts +1 -1
  314. package/src/runtime/bidi/session.ts +177 -28
  315. package/src/runtime/query/executor.ts +987 -172
  316. package/src/runtime/query/index.ts +59 -6
  317. package/src/runtime/query/iteration/index.ts +69 -29
  318. package/src/runtime/query/iteration/phases/advisory.ts +2 -2
  319. package/src/runtime/query/iteration/phases/tool-review.ts +87 -23
  320. package/src/runtime/query/iteration/provider-rejected-image.ts +106 -0
  321. package/src/runtime/query/iteration/stream-turn.ts +12 -2
  322. package/src/runtime/query/request-rich-content.ts +213 -15
  323. package/src/runtime/query/result.ts +1 -0
  324. package/src/runtime/query/resume-pending.ts +83 -1
  325. package/src/runtime/query/tool-output-budget.ts +52 -17
  326. package/src/runtime/query/tooling.ts +10 -0
  327. package/src/sandbox/provider/local.ts +213 -45
  328. package/src/scheduler/delegating.ts +6 -5
  329. package/src/scheduler/local.ts +3 -2
  330. package/src/session/workspace/driver.ts +12 -4
  331. package/src/session/workspace/git-worktree.ts +173 -34
  332. package/src/skills/registry.ts +47 -0
  333. package/src/store/attachment/index.ts +100 -15
  334. package/src/tools/builtins/computer-use.ts +45 -2
  335. package/src/tools/builtins/run-code.ts +24 -16
  336. package/src/tools/builtins/skill.ts +325 -7
  337. package/src/tools/coordinator/agent.ts +108 -33
  338. package/src/types/agent/base.ts +4 -0
  339. package/src/types/agent/factory.ts +1 -0
  340. package/src/types/agent/manager.ts +1 -1
  341. package/src/types/agent/scheduler.ts +10 -1
  342. package/src/types/bidi/index.ts +19 -2
  343. package/src/types/computer-use/index.ts +19 -0
  344. package/src/types/connector/execution-context.ts +3 -0
  345. package/src/types/execution/index.ts +46 -1
  346. package/src/types/hitl/index.ts +11 -0
  347. package/src/types/message/index.ts +78 -1
  348. package/src/types/plugin/index.ts +15 -0
  349. package/src/types/provider/error.ts +4 -1
  350. package/src/types/provider/interface.ts +18 -0
  351. package/src/types/run/events.ts +18 -4
  352. package/src/types/sandbox/index.ts +15 -10
  353. package/src/types/tool/index.ts +159 -15
  354. package/src/types/workspace/ref.ts +5 -0
  355. package/src/utils/abort.ts +18 -0
@@ -14,6 +14,15 @@ import { generateToolCallId } from '../../utils/id.js';
14
14
  import { compressShellOutput } from '../../utils/shell-compress.js';
15
15
  import { bindOwner } from '../jobs/registry.js';
16
16
  import { DEFAULT_MAX_TOOL_OUTPUT_CHARS, applyToolOutputBudget, describeDroppedContent, measureContentBytes, } from './tool-output-budget.js';
17
+ function assertUniqueToolCallIds(toolCalls) {
18
+ const seen = new Set();
19
+ for (const [index, toolCall] of toolCalls.entries()) {
20
+ if (seen.has(toolCall.id)) {
21
+ throw new Error(`Provider returned duplicate tool call id "${toolCall.id}" at batch index ${index}; the batch is refused because review, denial and result ownership require one unique id per call.`);
22
+ }
23
+ seen.add(toolCall.id);
24
+ }
25
+ }
17
26
  /**
18
27
  * Default per-tool deadline. Long enough for a real build or test run,
19
28
  * short enough that a wedged tool does not hold a turn open indefinitely.
@@ -29,6 +38,97 @@ export const DEFAULT_TOOL_TIMEOUT_MS = 120_000;
29
38
  * already one-at-a-time by construction.
30
39
  */
31
40
  export const DEFAULT_TOOL_CONCURRENCY = 8;
41
+ /** Maximum UTF-8 size of one ephemeral tool-progress update. */
42
+ const MAX_TOOL_PROGRESS_BYTES = 8 * 1024;
43
+ /** A visible marker: this event is a display projection, not durable output. */
44
+ const TOOL_PROGRESS_OMISSION = '… ';
45
+ function boundedToolProgress(message) {
46
+ if (Buffer.byteLength(message, 'utf8') <= MAX_TOOL_PROGRESS_BYTES)
47
+ return message;
48
+ const tailBudget = MAX_TOOL_PROGRESS_BYTES - Buffer.byteLength(TOOL_PROGRESS_OMISSION, 'utf8');
49
+ let low = 0;
50
+ let high = message.length;
51
+ while (low < high) {
52
+ const middle = Math.floor((low + high) / 2);
53
+ if (Buffer.byteLength(message.slice(middle), 'utf8') > tailBudget)
54
+ low = middle + 1;
55
+ else
56
+ high = middle;
57
+ }
58
+ // Do not turn the low half of a retained surrogate pair into U+FFFD.
59
+ if (low > 0 &&
60
+ low < message.length &&
61
+ message.charCodeAt(low) >= 0xdc00 &&
62
+ message.charCodeAt(low) <= 0xdfff &&
63
+ message.charCodeAt(low - 1) >= 0xd800 &&
64
+ message.charCodeAt(low - 1) <= 0xdbff) {
65
+ low += 1;
66
+ }
67
+ return `${TOOL_PROGRESS_OMISSION}${message.slice(low)}`;
68
+ }
69
+ /**
70
+ * Latest-state publisher for one tool call.
71
+ *
72
+ * `ToolContext.report()` is synchronous by contract, while host event
73
+ * listeners may be arbitrarily slow. Starting one promise per report makes a
74
+ * chatty tool an unbounded allocation source. Progress is state rather than a
75
+ * transcript, so one in-flight update plus the latest pending update is the
76
+ * complete useful working set; intermediate states may be replaced.
77
+ */
78
+ class ToolProgressPublisher {
79
+ emitEvent;
80
+ base;
81
+ pending;
82
+ draining = null;
83
+ accepting = true;
84
+ constructor(emitEvent, base) {
85
+ this.emitEvent = emitEvent;
86
+ this.base = base;
87
+ }
88
+ report(message, fraction) {
89
+ if (!this.accepting)
90
+ return;
91
+ this.pending = {
92
+ message,
93
+ ...(fraction !== undefined ? { fraction: Math.min(1, Math.max(0, fraction)) } : {}),
94
+ };
95
+ this.startDrain();
96
+ }
97
+ async close() {
98
+ this.accepting = false;
99
+ this.startDrain();
100
+ while (this.draining)
101
+ await this.draining;
102
+ }
103
+ startDrain() {
104
+ if (this.draining || !this.pending)
105
+ return;
106
+ const current = this.drain();
107
+ this.draining = current;
108
+ void current.finally(() => {
109
+ if (this.draining !== current)
110
+ return;
111
+ this.draining = null;
112
+ // A report can land after the final loop check but before this
113
+ // settlement callback. It is still an accepted update and must be
114
+ // drained even when close() has since stopped new reports.
115
+ this.startDrain();
116
+ });
117
+ }
118
+ async drain() {
119
+ while (this.pending) {
120
+ const update = this.pending;
121
+ this.pending = undefined;
122
+ // A progress observer is diagnostic. Its failure cannot become a tool
123
+ // failure, and report() never hands a rejection back to the tool.
124
+ await this.emitEvent({
125
+ ...this.base,
126
+ message: boundedToolProgress(update.message),
127
+ ...(update.fraction !== undefined ? { fraction: update.fraction } : {}),
128
+ }).catch(() => { });
129
+ }
130
+ }
131
+ }
32
132
  /**
33
133
  * Re-runs granted to a `post_tool_use` hook that returns `{action:'retry'}`
34
134
  * on a tool which did not opt into {@link ToolDefinition.maxRetries}.
@@ -92,6 +192,7 @@ export class ToolExecutor {
92
192
  workingStateManager;
93
193
  probes;
94
194
  parentSpan;
195
+ preparedBatches = new WeakSet();
95
196
  /** Set per turn by the orchestrator; see {@link setStepAllowedTools}. */
96
197
  stepAllowedTools;
97
198
  readPaths = new Set();
@@ -228,17 +329,72 @@ export class ToolExecutor {
228
329
  const configured = this.config.permissionMode;
229
330
  return typeof configured === 'function' ? configured() : configured;
230
331
  }
231
- async executeBatch(response, denials, prior) {
332
+ /** Evaluate the run's operator policy against one already-prepared value. */
333
+ evaluatePreparedAuthorization(toolName, input) {
334
+ return this.config.authorizationGate?.evaluate({
335
+ toolName,
336
+ toolInput: input,
337
+ toolDef: this.config.tools.get(toolName),
338
+ });
339
+ }
340
+ /**
341
+ * Resolve repairs and pre-tool hooks, then decode each call exactly once.
342
+ * The returned projection is what policy and a human review; execution later
343
+ * consumes the registry-owned preparations rather than parsing again.
344
+ */
345
+ async prepareBatchForReview(response) {
346
+ assertUniqueToolCallIds(response.message.toolCalls ?? []);
347
+ const calls = new Map();
348
+ for (const toolCall of response.message.toolCalls ?? []) {
349
+ calls.set(toolCall.id, await this.prepareDirectCall(toolCall));
350
+ }
351
+ return this.publishPreparedBatch(calls);
352
+ }
353
+ /** Re-prepare only calls whose raw input a reviewer actually changed. */
354
+ async reprepareBatchForReview(response, previous, changedCallIds) {
355
+ assertUniqueToolCallIds(response.message.toolCalls ?? []);
356
+ if (!this.preparedBatches.has(previous)) {
357
+ throw new Error('Prepared tool batch is not owned by this executor.');
358
+ }
359
+ const calls = new Map(previous.calls);
360
+ for (const toolCall of response.message.toolCalls ?? []) {
361
+ if (changedCallIds.has(toolCall.id)) {
362
+ calls.set(toolCall.id, await this.prepareDirectCall(toolCall));
363
+ }
364
+ }
365
+ return this.publishPreparedBatch(calls);
366
+ }
367
+ publishPreparedBatch(calls) {
368
+ const reviewCalls = [...calls.values()]
369
+ .filter((call) => call.kind !== 'synthetic')
370
+ .map((call) => ({
371
+ id: call.toolCall.id,
372
+ name: call.toolName,
373
+ input: call.input,
374
+ }));
375
+ const batch = Object.freeze({
376
+ reviewCalls: Object.freeze(reviewCalls),
377
+ calls: new Map(calls),
378
+ });
379
+ this.preparedBatches.add(batch);
380
+ return batch;
381
+ }
382
+ async executeBatch(response, denials, prior, preparedBatch) {
232
383
  const toolCalls = response.message.toolCalls;
233
384
  if (!toolCalls) {
234
385
  return { messages: [], results: [], observations: [] };
235
386
  }
387
+ assertUniqueToolCallIds(toolCalls);
236
388
  this.batchCounter += 1;
237
389
  // Sampled here, once, and held for every call below. See the note on
238
390
  // `permissionMode` in the config type.
239
391
  this.batchMode = this.resolvePermissionMode();
240
392
  try {
241
- return await this.runBatch(toolCalls, denials, prior);
393
+ const owned = preparedBatch;
394
+ if (owned && !this.preparedBatches.has(owned)) {
395
+ throw new Error('Prepared tool batch is not owned by this executor.');
396
+ }
397
+ return await this.runBatch(toolCalls, denials, prior, owned);
242
398
  }
243
399
  finally {
244
400
  // Cleared so a later single execution outside a batch resolves
@@ -246,7 +402,7 @@ export class ToolExecutor {
246
402
  this.batchMode = undefined;
247
403
  }
248
404
  }
249
- async runBatch(toolCalls, denials, prior) {
405
+ async runBatch(toolCalls, denials, prior, preparedBatch) {
250
406
  this.log.debug('Executing tool batch', {
251
407
  [NAMZU.RUN_ID]: this.config.runId,
252
408
  'namzu.runtime.tool_count': toolCalls.length,
@@ -278,6 +434,7 @@ export class ToolExecutor {
278
434
  // order independence.
279
435
  const gate = new Semaphore(this.config.maxToolConcurrency ?? DEFAULT_TOOL_CONCURRENCY);
280
436
  toolCalls.forEach((toolCall, i) => {
437
+ const preparedCall = preparedBatch?.calls.get(toolCall.id);
281
438
  const recovered = prior?.get(toolCall.id);
282
439
  if (recovered !== undefined) {
283
440
  // This call already ran, in a process that died before the
@@ -298,7 +455,7 @@ export class ToolExecutor {
298
455
  // message so the assistant turn stays fully answered. Run them
299
456
  // on the parallel branch — they perform no side effects, so
300
457
  // serialization would only add latency.
301
- parallel.push(this.recordDenial(toolCall, denialReason).then((r) => {
458
+ parallel.push(this.recordDenial(toolCall, denialReason, preparedCall).then((r) => {
302
459
  results[i] = r;
303
460
  }));
304
461
  return;
@@ -306,33 +463,33 @@ export class ToolExecutor {
306
463
  // Per-call, because the event has to name which call it is about:
307
464
  // a batch can run several tools at once and a host rendering them
308
465
  // side by side needs to know whose progress this is.
466
+ const progress = new ToolProgressPublisher(this.emitEvent, {
467
+ type: 'tool_progress',
468
+ runId: this.config.runId,
469
+ toolUseId: toolCall.id,
470
+ toolName: toolCall.function.name,
471
+ });
309
472
  const ctx = {
310
473
  ...baseContext,
311
474
  toolUseId: toolCall.id,
475
+ source: { kind: 'direct' },
312
476
  // Overridden per call, so a nested dispatch can name the call
313
477
  // that made it. The base context has no `toolUseId`, and a
314
478
  // closure built there would report every nested call as
315
479
  // parentless.
316
- dispatchTool: (name, input) => this.dispatchNested(name, input, ctx, recordObservation),
480
+ dispatchTool: (name, input, options) => this.dispatchNested(name, input, ctx, recordObservation, toolCall.function.name, options),
317
481
  // Per-call for the same reason: a pause has to be routed back
318
482
  // to the call that raised it, and a batch can raise several.
319
483
  ...(this.config.toolPause ? { requestPause: this.config.toolPause(toolCall.id) } : {}),
320
- report: (message, fraction) => {
321
- // Fire-and-forget: a tool reporting progress must never be
322
- // able to fail because the host's listener threw, and must
323
- // never have to await the emit mid-work.
324
- void this.emitEvent({
325
- type: 'tool_progress',
326
- runId: this.config.runId,
327
- toolUseId: toolCall.id,
328
- toolName: toolCall.function.name,
329
- message,
330
- ...(fraction !== undefined ? { fraction: Math.min(1, Math.max(0, fraction)) } : {}),
331
- }).catch(() => { });
332
- },
484
+ report: (message, fraction) => progress.report(message, fraction),
333
485
  };
334
486
  const run = async () => {
335
- results[i] = await this.executeSingle(toolCall, ctx, recordObservation);
487
+ try {
488
+ results[i] = await this.executeSingle(toolCall, ctx, recordObservation, () => progress.close(), preparedCall);
489
+ }
490
+ finally {
491
+ await progress.close();
492
+ }
336
493
  };
337
494
  const gated = async () => {
338
495
  await gate.acquire();
@@ -343,14 +500,19 @@ export class ToolExecutor {
343
500
  gate.release();
344
501
  }
345
502
  };
346
- let input = {};
503
+ let input = preparedCall?.input ?? {};
347
504
  try {
348
- input = JSON.parse(toolCall.function.arguments || '{}');
505
+ if (!preparedBatch?.calls.has(toolCall.id)) {
506
+ input = JSON.parse(toolCall.function.arguments || '{}');
507
+ }
349
508
  }
350
509
  catch {
351
510
  // non-JSON args → treat as unsafe (serialize), the conservative path
352
511
  }
353
- const safe = this.config.tools.get(toolCall.function.name)?.isConcurrencySafe?.(input) === true;
512
+ const preparedToolName = preparedCall?.toolName;
513
+ const safe = this.config.tools
514
+ .get(preparedToolName ?? toolCall.function.name)
515
+ ?.isConcurrencySafe?.(input) === true;
354
516
  if (safe)
355
517
  parallel.push(gated());
356
518
  else
@@ -402,43 +564,219 @@ export class ToolExecutor {
402
564
  * rendering a timeline draws eleven siblings where there is one call with
403
565
  * eleven children.
404
566
  */
405
- async dispatchNested(name, input, context, recordObservation) {
567
+ async dispatchNested(name, input, context, recordObservation, parentToolName, options) {
568
+ const signal = options?.signal
569
+ ? AbortSignal.any([context.abortSignal, options.signal])
570
+ : context.abortSignal;
571
+ // Authority is checked before an id, activity or event is created. A
572
+ // retained closure must be observationally inert after its invocation
573
+ // ends, not merely unable to finish the registry call it already started.
574
+ signal.throwIfAborted();
575
+ const preparedCall = await this.prepareNestedCall(name, input, signal);
576
+ signal.throwIfAborted();
577
+ const preparedInput = preparedCall.input;
406
578
  const parent = context.toolUseId;
407
- const via = parent ? { tool: name, toolUseId: parent } : undefined;
579
+ const via = parent && parentToolName
580
+ ? {
581
+ tool: parentToolName,
582
+ toolUseId: parent,
583
+ ...(options?.runtimeToolCallId ? { runtimeToolCallId: options.runtimeToolCallId } : {}),
584
+ }
585
+ : undefined;
408
586
  // Its own id, minted here. Reusing the parent's would make two
409
587
  // different calls indistinguishable in any log keyed by it, which is
410
588
  // exactly how a nested write gets attributed to the program that ran
411
589
  // it rather than to itself.
412
590
  const nestedId = generateToolCallId();
413
591
  const startedAt = Date.now();
592
+ const source = parent
593
+ ? options?.runtimeToolCallId
594
+ ? {
595
+ kind: 'code',
596
+ parentToolUseId: parent,
597
+ runtimeToolCallId: options.runtimeToolCallId,
598
+ }
599
+ : { kind: 'nested', parentToolUseId: parent }
600
+ : { kind: 'direct' };
601
+ const progress = new ToolProgressPublisher(this.emitEvent, {
602
+ type: 'tool_progress',
603
+ runId: this.config.runId,
604
+ toolUseId: nestedId,
605
+ toolName: name,
606
+ });
607
+ if (preparedCall.kind === 'synthetic') {
608
+ await this.emitEvent({
609
+ type: 'tool_executing',
610
+ runId: this.config.runId,
611
+ toolUseId: nestedId,
612
+ toolName: name,
613
+ input: preparedInput,
614
+ ...(via ? { via } : {}),
615
+ });
616
+ await progress.close();
617
+ await this.emitEvent({
618
+ type: 'tool_completed',
619
+ runId: this.config.runId,
620
+ toolUseId: nestedId,
621
+ toolName: name,
622
+ result: preparedCall.message,
623
+ isError: preparedCall.isError,
624
+ durationMs: Date.now() - startedAt,
625
+ outputLength: preparedCall.message.length,
626
+ ...(via ? { via } : {}),
627
+ });
628
+ return preparedCall.isError
629
+ ? { success: false, output: '', error: preparedCall.message }
630
+ : { success: true, output: preparedCall.message };
631
+ }
632
+ const gateResult = this.config.authorizationGate?.evaluate({
633
+ toolName: name,
634
+ toolInput: preparedInput,
635
+ toolDef: this.config.tools.get(name),
636
+ });
637
+ if (gateResult && gateResult.decision !== 'allow') {
638
+ const reason = gateResult.decision === 'deny'
639
+ ? `Blocked by the authorization gate: ${gateResult.reason}`
640
+ : `Blocked by the authorization gate: this nested call requires an explicit allow rule because an operator review cannot be opened from inside another tool. ${gateResult.reason}`;
641
+ const output = deniedToolOutput(name, reason);
642
+ // Same fail-closed durability rule as a direct gate denial: if the
643
+ // configured run store cannot record the refusal, do not quietly carry
644
+ // on with an unaudited execution.
645
+ if (!this.config.recordAudit) {
646
+ throw new Error(`Nested tool "${name}" was refused, but no durable audit recorder is configured.`);
647
+ }
648
+ await this.config.recordAudit({
649
+ what: { action: 'tool_call', tool: name },
650
+ outcome: 'refused',
651
+ reason,
652
+ });
653
+ await progress.close();
654
+ await this.emitEvent({
655
+ type: 'tool_executing',
656
+ runId: this.config.runId,
657
+ toolUseId: nestedId,
658
+ toolName: name,
659
+ input: preparedInput,
660
+ ...(via ? { via } : {}),
661
+ });
662
+ await this.emitEvent({
663
+ type: 'tool_completed',
664
+ runId: this.config.runId,
665
+ toolUseId: nestedId,
666
+ toolName: name,
667
+ result: output,
668
+ isError: true,
669
+ durationMs: Date.now() - startedAt,
670
+ outputLength: output.length,
671
+ ...(via ? { via } : {}),
672
+ });
673
+ return { success: false, output: '', error: reason };
674
+ }
675
+ const childContext = {
676
+ ...context,
677
+ abortSignal: signal,
678
+ toolUseId: nestedId,
679
+ source,
680
+ dispatchTool: (childName, childInput, childOptions) => this.dispatchNested(childName, childInput, childContext, recordObservation, name, childOptions),
681
+ // A nested execution has its own event/progress identity, but its
682
+ // durable pause belongs to the nearest model-issued ancestor. The
683
+ // checkpoint transcript contains that ancestor call and not this
684
+ // ephemeral child id; minting a pause route for `nestedId` makes the
685
+ // answer impossible to match after process restart. `...context`
686
+ // intentionally preserves the ancestor route here.
687
+ report: (message, fraction) => progress.report(message, fraction),
688
+ };
414
689
  await this.emitEvent({
415
690
  type: 'tool_executing',
416
691
  runId: this.config.runId,
417
692
  toolUseId: nestedId,
418
693
  toolName: name,
419
- input,
694
+ input: preparedInput,
695
+ ...(via ? { via } : {}),
696
+ });
697
+ const vetoOutcome = this.probes.queryVeto({
698
+ type: 'tool_executing',
699
+ runId: this.config.runId,
700
+ toolUseId: nestedId,
701
+ toolName: name,
702
+ input: preparedInput,
420
703
  ...(via ? { via } : {}),
704
+ }, buildProbeContext({ runId: this.config.runId }));
705
+ if (vetoOutcome.action === 'deny') {
706
+ const probeName = vetoOutcome.probeName ?? 'unnamed';
707
+ const reason = vetoOutcome.reason ?? 'no reason provided';
708
+ const message = new ProbeVetoError(probeName, reason, 'tool_executing').message;
709
+ await progress.close();
710
+ await this.emitEvent({
711
+ type: 'tool_completed',
712
+ runId: this.config.runId,
713
+ toolUseId: nestedId,
714
+ toolName: name,
715
+ result: `Error: ${message}`,
716
+ isError: true,
717
+ durationMs: Date.now() - startedAt,
718
+ outputLength: message.length + 7,
719
+ ...(via ? { via } : {}),
720
+ });
721
+ return { success: false, output: '', error: message };
722
+ }
723
+ let result;
724
+ try {
725
+ // Use the same deadline layer as a model-issued call. Calling the
726
+ // registry directly made nested tools the only tools whose own
727
+ // `timeoutMs` declaration was ignored.
728
+ result = await this.runOnce(name, preparedInput, childContext, preparedCall.kind === 'ready' ? preparedCall.prepared : undefined);
729
+ }
730
+ finally {
731
+ await progress.close();
732
+ }
733
+ const rawOutput = result.success
734
+ ? result.output
735
+ : formatFailedToolOutput(result.output, result.error);
736
+ const budgeted = applyToolOutputBudget({
737
+ toolName: name,
738
+ toolUseId: nestedId,
739
+ output: rawOutput,
740
+ maxChars: this.config.maxToolOutputChars ?? DEFAULT_MAX_TOOL_OUTPUT_CHARS,
741
+ spillDir: this.config.toolOutputDir,
742
+ onError: (message) => this.log.warn('Failed to spill oversized nested tool output', {
743
+ [NAMZU.RUN_ID]: this.config.runId,
744
+ [GENAI.TOOL_NAME]: name,
745
+ 'exception.message': message,
746
+ }),
421
747
  });
422
- const result = await this.config.tools.execute(name, input, context);
748
+ const visibleResult = result.success
749
+ ? { ...result, output: budgeted.output }
750
+ : {
751
+ ...result,
752
+ output: '',
753
+ // `run_code` sends `error`, not `output`, across the worker
754
+ // bridge on a failed host call. Leaving the raw error here would
755
+ // make the success path bounded and the failure path unbounded.
756
+ error: budgeted.output,
757
+ };
423
758
  await this.emitEvent({
424
759
  type: 'tool_completed',
425
760
  runId: this.config.runId,
426
761
  toolUseId: nestedId,
427
762
  toolName: name,
428
- result: result.success ? result.output : (result.error ?? 'failed'),
763
+ result: budgeted.output,
429
764
  isError: !result.success,
430
765
  durationMs: Date.now() - startedAt,
766
+ outputLength: budgeted.originalLength,
767
+ ...(budgeted.truncated ? { outputTruncated: true } : {}),
768
+ ...(budgeted.spillPath ? { outputSpillPath: budgeted.spillPath } : {}),
431
769
  ...(via ? { via } : {}),
432
770
  });
433
771
  recordObservation({
434
772
  runId: this.config.runId,
435
773
  toolUseId: nestedId,
436
774
  toolName: name,
437
- input,
775
+ input: preparedInput,
438
776
  result,
439
777
  ...(parent ? { parentToolUseId: parent } : {}),
440
778
  });
441
- return result;
779
+ return visibleResult;
442
780
  }
443
781
  buildToolContext(recordObservation = () => { }) {
444
782
  const context = {
@@ -463,6 +801,7 @@ export class ToolExecutor {
463
801
  adoptSkillScope: (scope) => {
464
802
  this.skillScope = { ...scope, adoptedInBatch: this.batchCounter };
465
803
  },
804
+ maxToolOutputChars: this.config.maxToolOutputChars ?? DEFAULT_MAX_TOOL_OUTPUT_CHARS,
466
805
  ...(this.config.skills ? { skills: this.config.skills } : {}),
467
806
  ...(this.config.web ? { web: this.config.web } : {}),
468
807
  // The SAME registry and the SAME context a model-issued call
@@ -473,7 +812,7 @@ export class ToolExecutor {
473
812
  // dispatching outside a batch has no parent call to name. The
474
813
  // per-call context below overrides it with one that does; see
475
814
  // `dispatchNested`.
476
- dispatchTool: (name, input) => this.dispatchNested(name, input, context, recordObservation),
815
+ dispatchTool: (name, input, options) => this.dispatchNested(name, input, context, recordObservation, undefined, options),
477
816
  sandbox: this.config.sandbox,
478
817
  fileReadTracker: this.fileReadTracker,
479
818
  // Bound to this run, once. Binding here rather than passing the
@@ -498,90 +837,101 @@ export class ToolExecutor {
498
837
  };
499
838
  return context;
500
839
  }
501
- async executeSingle(toolCall, toolContext, recordObservation) {
502
- let toolName = toolCall.function.name;
503
- // A stream that cut off mid-JSON is the case `repairToolCall` exists
504
- // for, and it used to be the one case that never reached it: this
505
- // branch returned before `resolveCall` ran, so the motivating failure
506
- // was answered with a generic hint while the configured repairer sat
507
- // unused. Offer it the partial buffer first.
508
- const truncationRepair = toolCall.metadata?.inputTruncated === true
509
- ? await this.repairTruncatedCall(toolCall, toolName)
510
- : null;
511
- if (toolCall.metadata?.inputTruncated === true && !truncationRepair) {
512
- const message = truncatedToolInputMessage(toolName);
513
- await this.emitEvent({
514
- type: 'tool_executing',
515
- runId: this.config.runId,
516
- toolUseId: toolCall.id,
517
- toolName,
518
- input: {},
519
- });
520
- await this.emitEvent({
521
- type: 'tool_completed',
522
- runId: this.config.runId,
523
- toolUseId: toolCall.id,
524
- toolName,
525
- result: message,
526
- isError: true,
527
- });
528
- return {
529
- toolCallId: toolCall.id,
530
- toolName,
531
- output: message,
532
- isError: true,
533
- };
840
+ async executeSingle(toolCall, toolContext, recordObservation, settleProgress, preparedCall) {
841
+ if (preparedCall?.kind === 'synthetic') {
842
+ return this.recordSyntheticPreparation(preparedCall);
534
843
  }
535
- // A malformed call used to cost a full model round trip to fix: the
536
- // error went back as a `tool_result`, the model re-read the whole
537
- // context and tried again. A host that can repair it locally turns
538
- // that into nothing. No-op when no repairer is configured.
539
- const resolved = await this.resolveCall(truncationRepair
540
- ? {
541
- ...toolCall,
542
- function: {
543
- ...toolCall.function,
544
- name: truncationRepair.toolName ?? toolName,
545
- arguments: truncationRepair.arguments,
546
- },
547
- metadata: {},
548
- }
549
- : toolCall);
550
- toolName = resolved.toolName;
551
- if (!resolved.ok) {
552
- // malformed JSON args used to return without ever
553
- // emitting tool_executing or tool_completed, leaving UI cards
554
- // orphaned in `streaming_input`. Emit the executing→completed
555
- // terminal pair so the card lifecycle closes.
556
- const message = resolved.message;
557
- await this.emitEvent({
558
- type: 'tool_executing',
559
- runId: this.config.runId,
560
- toolUseId: toolCall.id,
561
- toolName,
562
- input: {},
563
- });
564
- await this.emitEvent({
565
- type: 'tool_completed',
566
- runId: this.config.runId,
567
- toolUseId: toolCall.id,
568
- toolName,
569
- result: message,
570
- isError: true,
571
- });
572
- return {
573
- toolCallId: toolCall.id,
574
- toolName,
575
- output: message,
576
- isError: true,
577
- };
844
+ let toolName = preparedCall?.toolName ?? toolCall.function.name;
845
+ let input;
846
+ let prepared;
847
+ if (preparedCall?.kind === 'ready' || preparedCall?.kind === 'legacy') {
848
+ input = preparedCall.input;
849
+ prepared = preparedCall.kind === 'ready' ? preparedCall.prepared : undefined;
578
850
  }
579
- let input = resolved.input;
580
- const preOutcome = await this.runPreToolHook(toolName, input);
581
- if (preOutcome.kind === 'skip' || preOutcome.kind === 'error') {
582
- return this.recordSyntheticHookOutcome(toolCall.id, toolName, preOutcome.input, preOutcome);
851
+ else {
852
+ // A stream that cut off mid-JSON is the case `repairToolCall` exists
853
+ // for, and it used to be the one case that never reached it: this
854
+ // branch returned before `resolveCall` ran, so the motivating failure
855
+ // was answered with a generic hint while the configured repairer sat
856
+ // unused. Offer it the partial buffer first.
857
+ const truncationRepair = toolCall.metadata?.inputTruncated === true
858
+ ? await this.repairTruncatedCall(toolCall, toolName)
859
+ : null;
860
+ if (toolCall.metadata?.inputTruncated === true && !truncationRepair) {
861
+ const message = truncatedToolInputMessage(toolName);
862
+ await this.emitEvent({
863
+ type: 'tool_executing',
864
+ runId: this.config.runId,
865
+ toolUseId: toolCall.id,
866
+ toolName,
867
+ input: {},
868
+ });
869
+ await this.emitEvent({
870
+ type: 'tool_completed',
871
+ runId: this.config.runId,
872
+ toolUseId: toolCall.id,
873
+ toolName,
874
+ result: message,
875
+ isError: true,
876
+ });
877
+ return {
878
+ toolCallId: toolCall.id,
879
+ toolName,
880
+ output: message,
881
+ isError: true,
882
+ };
883
+ }
884
+ // A malformed call used to cost a full model round trip to fix: the
885
+ // error went back as a `tool_result`, the model re-read the whole
886
+ // context and tried again. A host that can repair it locally turns
887
+ // that into nothing. No-op when no repairer is configured.
888
+ const resolved = await this.resolveCall(truncationRepair
889
+ ? {
890
+ ...toolCall,
891
+ function: {
892
+ ...toolCall.function,
893
+ name: truncationRepair.toolName ?? toolName,
894
+ arguments: truncationRepair.arguments,
895
+ },
896
+ metadata: {},
897
+ }
898
+ : toolCall);
899
+ toolName = resolved.toolName;
900
+ if (!resolved.ok) {
901
+ // malformed JSON args used to return without ever
902
+ // emitting tool_executing or tool_completed, leaving UI cards
903
+ // orphaned in `streaming_input`. Emit the executing→completed
904
+ // terminal pair so the card lifecycle closes.
905
+ const message = resolved.message;
906
+ await this.emitEvent({
907
+ type: 'tool_executing',
908
+ runId: this.config.runId,
909
+ toolUseId: toolCall.id,
910
+ toolName,
911
+ input: {},
912
+ });
913
+ await this.emitEvent({
914
+ type: 'tool_completed',
915
+ runId: this.config.runId,
916
+ toolUseId: toolCall.id,
917
+ toolName,
918
+ result: message,
919
+ isError: true,
920
+ });
921
+ return {
922
+ toolCallId: toolCall.id,
923
+ toolName,
924
+ output: message,
925
+ isError: true,
926
+ };
927
+ }
928
+ input = resolved.input;
929
+ const preOutcome = await this.runPreToolHook(toolName, input);
930
+ if (preOutcome.kind === 'skip' || preOutcome.kind === 'error') {
931
+ return this.recordSyntheticHookOutcome(toolCall.id, toolName, preOutcome.input, preOutcome);
932
+ }
933
+ input = preOutcome.input;
583
934
  }
584
- input = preOutcome.input;
585
935
  const activity = this.activityStore.create({
586
936
  type: 'tool_call',
587
937
  description: toolName,
@@ -661,7 +1011,7 @@ export class ToolExecutor {
661
1011
  // version silently DROPPED `content`, so a tool returning an image
662
1012
  // block had it discarded here — before the wire mapper that was
663
1013
  // built to carry it ever saw it.
664
- let result = await this.runOnce(toolName, input, toolContext);
1014
+ let result = await this.runOnce(toolName, input, toolContext, prepared);
665
1015
  let post = await this.runPostToolHook(toolName, input, result);
666
1016
  // In-loop retry. A transient failure used to cost a full model round
667
1017
  // trip: the error went back as a `tool_result`, the model read it and
@@ -728,22 +1078,38 @@ export class ToolExecutor {
728
1078
  // resume to trip over.
729
1079
  break;
730
1080
  }
731
- result = await this.runOnce(toolName, input, toolContext);
1081
+ result = await this.runOnce(toolName, input, toolContext, prepared);
732
1082
  post = await this.runPostToolHook(toolName, input, result);
733
1083
  }
734
1084
  const durationMs = Date.now() - startMs;
735
1085
  const rawOutput = result.success
736
1086
  ? result.output
737
1087
  : formatFailedToolOutput(result.output, result.error);
738
- let output = result.success ? this.maybeCompress(toolName, rawOutput) : rawOutput;
1088
+ const postOverride = post.override;
1089
+ let output = postOverride?.output ?? (result.success ? this.maybeCompress(toolName, rawOutput) : rawOutput);
1090
+ const selectedContent = postOverride?.isError
1091
+ ? undefined
1092
+ : (postOverride?.content ?? result.content);
1093
+ const maxToolOutputChars = this.config.maxToolOutputChars ?? DEFAULT_MAX_TOOL_OUTPUT_CHARS;
1094
+ // If the text preview forces rich content out, say so inside the SAME
1095
+ // budget. Appending this after truncation made the diagnostic itself a
1096
+ // cap bypass; selecting a post-hook override after truncation was a
1097
+ // larger bypass that could replace a bounded result with anything.
1098
+ if (maxToolOutputChars > 0 &&
1099
+ output.length > maxToolOutputChars &&
1100
+ selectedContent !== undefined) {
1101
+ const dropped = describeDroppedContent(selectedContent);
1102
+ if (dropped)
1103
+ output = `${output}\n\n${dropped}`;
1104
+ }
739
1105
  // Compression is opportunistic and shell-only; the budget is the
740
- // hard bound that applies to every tool. Runs after compression so
741
- // a result that shrinks under the cap is never spilled needlessly.
1106
+ // hard bound that applies to every final tool result, including a
1107
+ // post-tool hook's replacement and the rich-content omission notice.
742
1108
  const budgeted = applyToolOutputBudget({
743
1109
  toolName,
744
1110
  toolUseId: toolCall.id,
745
1111
  output,
746
- maxChars: this.config.maxToolOutputChars ?? DEFAULT_MAX_TOOL_OUTPUT_CHARS,
1112
+ maxChars: maxToolOutputChars,
747
1113
  spillDir: this.config.toolOutputDir,
748
1114
  onError: (message) => this.log.warn('Failed to spill oversized tool output', {
749
1115
  [NAMZU.RUN_ID]: this.config.runId,
@@ -760,21 +1126,6 @@ export class ToolExecutor {
760
1126
  });
761
1127
  }
762
1128
  output = budgeted.output;
763
- // A truncated text half drops the rich half with it — the preview is
764
- // no longer the tool's own payload, so an image alongside it would be
765
- // illustrating something the model can no longer read. Dropping is
766
- // right; doing it SILENTLY is not. The model saw a preview and had no
767
- // way to know an image existed at all, so it reasoned as though the
768
- // tool had returned text only.
769
- if (budgeted.truncated && result.content !== undefined) {
770
- const dropped = describeDroppedContent(result.content);
771
- if (dropped)
772
- output = `${output}\n\n${dropped}`;
773
- }
774
- const postOverride = post.override;
775
- if (postOverride !== null) {
776
- output = postOverride.output;
777
- }
778
1129
  // A failed call, or an override that says the call failed. A `replace`
779
1130
  // says the opposite, and reading it as a failure is what made redaction
780
1131
  // unusable: the model was told a successful call had gone wrong, and
@@ -807,6 +1158,11 @@ export class ToolExecutor {
807
1158
  this.activityStore.complete(activity.id, output);
808
1159
  }
809
1160
  }
1161
+ // The terminal event closes the live row. Every accepted progress update
1162
+ // must settle before it, otherwise a slow host can receive an update for a
1163
+ // call it has already removed. Detached work reporting after a timeout is
1164
+ // ignored because close() also revokes the publisher.
1165
+ await settleProgress();
810
1166
  await this.emitEvent({
811
1167
  type: 'tool_completed',
812
1168
  runId: this.config.runId,
@@ -829,14 +1185,9 @@ export class ToolExecutor {
829
1185
  result,
830
1186
  });
831
1187
  const resolveContent = () => {
832
- if (postOverride?.content !== undefined) {
833
- return { content: this.budgetContent(postOverride.content, toolName) };
834
- }
835
- if (postOverride?.isError)
1188
+ if (budgeted.truncated || selectedContent === undefined)
836
1189
  return {};
837
- if (budgeted.truncated || result.content === undefined)
838
- return {};
839
- return { content: this.budgetContent(result.content, toolName) };
1190
+ return { content: this.budgetContent(selectedContent, toolName) };
840
1191
  };
841
1192
  return {
842
1193
  toolCallId: toolCall.id,
@@ -876,30 +1227,31 @@ export class ToolExecutor {
876
1227
  * model sees "this timed out" as a `tool_result` and can route around
877
1228
  * it. A throw would end the run over one slow tool.
878
1229
  */
879
- async executeWithDeadline(toolName, input, toolContext) {
1230
+ async executeWithDeadline(toolName, input, toolContext, prepared) {
880
1231
  const timeoutMs = this.config.tools.get(toolName)?.timeoutMs ??
881
1232
  this.config.toolTimeoutMs ??
882
1233
  DEFAULT_TOOL_TIMEOUT_MS;
883
- if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
884
- return this.config.tools.execute(toolName, input, toolContext);
885
- }
886
1234
  const controller = new AbortController();
887
- const runSignal = this.config.abortSignal;
888
- const onRunAbort = () => controller.abort(runSignal.reason);
889
- if (runSignal.aborted)
890
- controller.abort(runSignal.reason);
1235
+ const parentSignal = toolContext.abortSignal;
1236
+ const onParentAbort = () => controller.abort(parentSignal.reason);
1237
+ if (parentSignal.aborted)
1238
+ controller.abort(parentSignal.reason);
891
1239
  else
892
- runSignal.addEventListener('abort', onRunAbort, { once: true });
1240
+ parentSignal.addEventListener('abort', onParentAbort, { once: true });
893
1241
  let timer;
894
1242
  let timedOut = false;
1243
+ let invocationOpen = true;
1244
+ const nestedDispatches = new Set();
895
1245
  try {
896
- const expired = new Promise((resolve) => {
897
- timer = setTimeout(() => {
898
- timedOut = true;
899
- controller.abort(new Error(`Tool "${toolName}" exceeded ${timeoutMs}ms`));
900
- resolve('timeout');
901
- }, timeoutMs);
902
- });
1246
+ const expired = Number.isFinite(timeoutMs) && timeoutMs > 0
1247
+ ? new Promise((resolve) => {
1248
+ timer = setTimeout(() => {
1249
+ timedOut = true;
1250
+ controller.abort(new Error(`Tool "${toolName}" exceeded ${timeoutMs}ms`));
1251
+ resolve('timeout');
1252
+ }, timeoutMs);
1253
+ })
1254
+ : undefined;
903
1255
  const aborted = new Promise((resolve) => {
904
1256
  if (controller.signal.aborted && !timedOut) {
905
1257
  resolve('aborted');
@@ -910,14 +1262,47 @@ export class ToolExecutor {
910
1262
  resolve('aborted');
911
1263
  }, { once: true });
912
1264
  });
913
- const execution = this.config.tools.execute(toolName, input, {
1265
+ const inheritedDispatch = toolContext.dispatchTool;
1266
+ const scopedDispatch = inheritedDispatch
1267
+ ? (name, nestedInput, options) => {
1268
+ if (!invocationOpen) {
1269
+ return Promise.reject(new Error(`Tool "${toolName}" invocation has settled; nested dispatch is closed.`));
1270
+ }
1271
+ const nested = inheritedDispatch(name, nestedInput, {
1272
+ ...options,
1273
+ signal: options?.signal
1274
+ ? AbortSignal.any([controller.signal, options.signal])
1275
+ : controller.signal,
1276
+ });
1277
+ nestedDispatches.add(nested);
1278
+ // `finally()` creates a second promise. Observe that promise too, or a
1279
+ // rejected nested call which its owner intentionally awaits later would
1280
+ // also create an unhandled cleanup rejection here.
1281
+ void nested.finally(() => nestedDispatches.delete(nested)).catch(() => { });
1282
+ return nested;
1283
+ }
1284
+ : undefined;
1285
+ if (controller.signal.aborted) {
1286
+ return {
1287
+ success: false,
1288
+ output: '',
1289
+ error: abortReasonText(controller.signal.reason)
1290
+ ? `Tool "${toolName}" was cancelled: ${abortReasonText(controller.signal.reason)}`
1291
+ : `Tool "${toolName}" was cancelled.`,
1292
+ };
1293
+ }
1294
+ const context = {
914
1295
  ...toolContext,
915
1296
  abortSignal: controller.signal,
916
- });
1297
+ ...(scopedDispatch ? { dispatchTool: scopedDispatch } : {}),
1298
+ };
1299
+ const execution = prepared
1300
+ ? this.config.tools.executePrepared(prepared, context)
1301
+ : this.config.tools.execute(toolName, input, context);
917
1302
  // The loser of the race may still reject later; neutralize it so
918
1303
  // it is never an unhandled rejection.
919
1304
  execution.catch(() => { });
920
- const outcome = await Promise.race([execution, expired, aborted]);
1305
+ const outcome = await Promise.race(expired ? [execution, expired, aborted] : [execution, aborted]);
921
1306
  if (outcome === 'timeout') {
922
1307
  this.log.warn('Tool timed out', {
923
1308
  [NAMZU.RUN_ID]: this.config.runId,
@@ -949,30 +1334,284 @@ export class ToolExecutor {
949
1334
  return outcome;
950
1335
  }
951
1336
  finally {
1337
+ // Revoke synchronously before awaiting anything. A retained closure
1338
+ // called from another microtask is refused here; the aborted signal is
1339
+ // the structural backstop inside dispatchNested itself.
1340
+ invocationOpen = false;
1341
+ if (!controller.signal.aborted) {
1342
+ controller.abort(new Error(`Tool "${toolName}" invocation has settled.`));
1343
+ }
1344
+ // Calls already admitted before closure own event rows and registry
1345
+ // work. Their executor races observe the abort, emit a terminal result,
1346
+ // and settle before the parent is allowed to report completion.
1347
+ while (nestedDispatches.size > 0) {
1348
+ await Promise.allSettled([...nestedDispatches]);
1349
+ }
952
1350
  if (timer !== undefined)
953
1351
  clearTimeout(timer);
954
- runSignal.removeEventListener('abort', onRunAbort);
1352
+ parentSignal.removeEventListener('abort', onParentAbort);
955
1353
  }
956
1354
  }
957
- async runPreToolHook(toolName, input) {
1355
+ async runPreToolHook(toolName, input, signal = this.config.abortSignal) {
958
1356
  if (!this.config.pluginManager)
959
- return { kind: 'continue', input };
1357
+ return { kind: 'continue', input, modified: false };
960
1358
  const results = await this.config.pluginManager.executeHooks('pre_tool_use', {
961
1359
  runId: this.config.runId,
962
1360
  toolName,
963
1361
  toolInput: input,
964
- signal: this.config.abortSignal,
1362
+ signal,
965
1363
  }, this.emitEvent);
966
1364
  return this.interpretPreToolResults(toolName, input, results);
967
1365
  }
1366
+ async prepareNestedCall(toolName, input, signal) {
1367
+ const prepare = this.config.tools.prepareExecution;
1368
+ const executePrepared = this.config.tools.executePrepared;
1369
+ if (typeof prepare !== 'function' || typeof executePrepared !== 'function') {
1370
+ if (this.config.authorizationGate) {
1371
+ return {
1372
+ kind: 'synthetic',
1373
+ input,
1374
+ message: `Tool "${toolName}" was not executed because its registry cannot bind authorization to one prepared input.`,
1375
+ isError: true,
1376
+ };
1377
+ }
1378
+ const preOutcome = await this.runPreToolHook(toolName, input, signal);
1379
+ if (preOutcome.kind === 'skip' || preOutcome.kind === 'error') {
1380
+ return {
1381
+ kind: 'synthetic',
1382
+ input: preOutcome.input,
1383
+ message: preOutcome.output,
1384
+ isError: preOutcome.kind === 'error',
1385
+ };
1386
+ }
1387
+ return { kind: 'legacy', input: preOutcome.input };
1388
+ }
1389
+ let preparation;
1390
+ try {
1391
+ preparation = prepare.call(this.config.tools, toolName, input);
1392
+ }
1393
+ catch (err) {
1394
+ return {
1395
+ kind: 'synthetic',
1396
+ input,
1397
+ message: `Tool "${toolName}" could not be prepared: ${toErrorMessage(err)}`,
1398
+ isError: true,
1399
+ };
1400
+ }
1401
+ if (!preparation.success) {
1402
+ return {
1403
+ kind: 'synthetic',
1404
+ input,
1405
+ message: formatFailedToolOutput(preparation.result.output, preparation.result.error),
1406
+ isError: true,
1407
+ };
1408
+ }
1409
+ const preOutcome = await this.runPreToolHook(toolName, preparation.prepared.input, signal);
1410
+ if (preOutcome.kind === 'skip' || preOutcome.kind === 'error') {
1411
+ return {
1412
+ kind: 'synthetic',
1413
+ input: preOutcome.input,
1414
+ message: preOutcome.output,
1415
+ isError: preOutcome.kind === 'error',
1416
+ };
1417
+ }
1418
+ if (!preOutcome.modified) {
1419
+ return {
1420
+ kind: 'ready',
1421
+ input: preparation.prepared.input,
1422
+ prepared: preparation.prepared,
1423
+ };
1424
+ }
1425
+ const modified = prepare.call(this.config.tools, toolName, preOutcome.input);
1426
+ if (!modified.success) {
1427
+ return {
1428
+ kind: 'synthetic',
1429
+ input: preOutcome.input,
1430
+ message: formatFailedToolOutput(modified.result.output, modified.result.error),
1431
+ isError: true,
1432
+ };
1433
+ }
1434
+ return { kind: 'ready', input: modified.prepared.input, prepared: modified.prepared };
1435
+ }
1436
+ async prepareDirectCall(toolCall) {
1437
+ let toolName = toolCall.function.name;
1438
+ const truncationRepair = toolCall.metadata?.inputTruncated === true
1439
+ ? await this.repairTruncatedCall(toolCall, toolName)
1440
+ : null;
1441
+ if (toolCall.metadata?.inputTruncated === true && !truncationRepair) {
1442
+ return {
1443
+ kind: 'synthetic',
1444
+ toolCall,
1445
+ toolName,
1446
+ input: {},
1447
+ message: truncatedToolInputMessage(toolName),
1448
+ isError: true,
1449
+ };
1450
+ }
1451
+ const prepare = this.config.tools.prepareExecution;
1452
+ const executePrepared = this.config.tools.executePrepared;
1453
+ if (typeof prepare !== 'function' || typeof executePrepared !== 'function') {
1454
+ const resolved = await this.resolveCall(truncationRepair
1455
+ ? {
1456
+ ...toolCall,
1457
+ function: {
1458
+ ...toolCall.function,
1459
+ name: truncationRepair.toolName ?? toolName,
1460
+ arguments: truncationRepair.arguments,
1461
+ },
1462
+ metadata: {},
1463
+ }
1464
+ : toolCall);
1465
+ toolName = resolved.toolName;
1466
+ if (!resolved.ok) {
1467
+ return {
1468
+ kind: 'synthetic',
1469
+ toolCall,
1470
+ toolName,
1471
+ input: {},
1472
+ message: resolved.message,
1473
+ isError: true,
1474
+ };
1475
+ }
1476
+ const preOutcome = await this.runPreToolHook(toolName, resolved.input);
1477
+ if (preOutcome.kind === 'skip' || preOutcome.kind === 'error') {
1478
+ return {
1479
+ kind: 'synthetic',
1480
+ toolCall,
1481
+ toolName,
1482
+ input: preOutcome.input,
1483
+ message: preOutcome.output,
1484
+ isError: preOutcome.kind === 'error',
1485
+ };
1486
+ }
1487
+ if (!this.config.authorizationGate) {
1488
+ return {
1489
+ kind: 'legacy',
1490
+ toolCall,
1491
+ toolName,
1492
+ input: preOutcome.input,
1493
+ };
1494
+ }
1495
+ return {
1496
+ kind: 'synthetic',
1497
+ toolCall,
1498
+ toolName,
1499
+ input: preOutcome.input,
1500
+ message: `Tool "${toolName}" was not executed because its registry cannot bind authorization to one prepared input.`,
1501
+ isError: true,
1502
+ };
1503
+ }
1504
+ let raw = truncationRepair?.arguments ?? toolCall.function.arguments;
1505
+ toolName = truncationRepair?.toolName ?? toolName;
1506
+ let repairUsed = truncationRepair !== null;
1507
+ let preparation;
1508
+ for (;;) {
1509
+ let parsed;
1510
+ try {
1511
+ parsed = parseArguments(raw);
1512
+ }
1513
+ catch {
1514
+ const message = `Error: Invalid JSON in tool arguments for "${toolName}"`;
1515
+ const repair = !repairUsed && this.config.repairToolCall
1516
+ ? await this.requestRepair(toolCall, toolName, {
1517
+ reason: 'invalid_json',
1518
+ message,
1519
+ })
1520
+ : null;
1521
+ if (repair) {
1522
+ repairUsed = true;
1523
+ toolName = repair.toolName ?? toolName;
1524
+ raw = repair.arguments;
1525
+ continue;
1526
+ }
1527
+ return { kind: 'synthetic', toolCall, toolName, input: {}, message, isError: true };
1528
+ }
1529
+ try {
1530
+ preparation = prepare.call(this.config.tools, toolName, parsed);
1531
+ }
1532
+ catch (err) {
1533
+ const message = `Error: Unknown or unavailable tool "${toolName}": ${toErrorMessage(err)}`;
1534
+ const repair = !repairUsed && this.config.repairToolCall
1535
+ ? await this.requestRepair(toolCall, toolName, {
1536
+ reason: 'unknown_tool',
1537
+ message,
1538
+ })
1539
+ : null;
1540
+ if (repair) {
1541
+ repairUsed = true;
1542
+ toolName = repair.toolName ?? toolName;
1543
+ raw = repair.arguments;
1544
+ continue;
1545
+ }
1546
+ return { kind: 'synthetic', toolCall, toolName, input: parsed, message, isError: true };
1547
+ }
1548
+ if (preparation.success)
1549
+ break;
1550
+ const message = formatFailedToolOutput(preparation.result.output, preparation.result.error);
1551
+ const repair = !repairUsed && this.config.repairToolCall
1552
+ ? await this.requestRepair(toolCall, toolName, {
1553
+ reason: 'schema_validation',
1554
+ message,
1555
+ })
1556
+ : null;
1557
+ if (repair) {
1558
+ repairUsed = true;
1559
+ toolName = repair.toolName ?? toolName;
1560
+ raw = repair.arguments;
1561
+ continue;
1562
+ }
1563
+ return {
1564
+ kind: 'synthetic',
1565
+ toolCall,
1566
+ toolName,
1567
+ input: parsed,
1568
+ message,
1569
+ isError: true,
1570
+ };
1571
+ }
1572
+ const preOutcome = await this.runPreToolHook(toolName, preparation.prepared.input);
1573
+ if (preOutcome.kind === 'skip' || preOutcome.kind === 'error') {
1574
+ return {
1575
+ kind: 'synthetic',
1576
+ toolCall,
1577
+ toolName,
1578
+ input: preOutcome.input,
1579
+ message: preOutcome.output,
1580
+ isError: preOutcome.kind === 'error',
1581
+ };
1582
+ }
1583
+ if (preOutcome.modified) {
1584
+ const modified = prepare.call(this.config.tools, toolName, preOutcome.input);
1585
+ if (!modified.success) {
1586
+ return {
1587
+ kind: 'synthetic',
1588
+ toolCall,
1589
+ toolName,
1590
+ input: preOutcome.input,
1591
+ message: formatFailedToolOutput(modified.result.output, modified.result.error),
1592
+ isError: true,
1593
+ };
1594
+ }
1595
+ preparation = modified;
1596
+ }
1597
+ return {
1598
+ kind: 'ready',
1599
+ toolCall,
1600
+ toolName,
1601
+ input: preparation.prepared.input,
1602
+ prepared: preparation.prepared,
1603
+ };
1604
+ }
968
1605
  interpretPreToolResults(toolName, initialInput, results) {
969
1606
  let currentInput = initialInput;
1607
+ let modified = false;
970
1608
  for (const result of results) {
971
1609
  switch (result.action) {
972
1610
  case 'continue':
973
1611
  continue;
974
1612
  case 'modify':
975
1613
  currentInput = result.input;
1614
+ modified = true;
976
1615
  continue;
977
1616
  case 'skip':
978
1617
  return {
@@ -999,7 +1638,7 @@ export class ToolExecutor {
999
1638
  }
1000
1639
  }
1001
1640
  }
1002
- return { kind: 'continue', input: currentInput };
1641
+ return { kind: 'continue', input: currentInput, modified };
1003
1642
  }
1004
1643
  /**
1005
1644
  * Turn the call the model issued into a name and a parsed input, giving
@@ -1114,7 +1753,10 @@ export class ToolExecutor {
1114
1753
  reason: failure.reason,
1115
1754
  message: failure.message,
1116
1755
  ...(tool
1117
- ? { tool, jsonSchema: tool.modelInputSchema ?? renderToolSchema(tool.inputSchema) }
1756
+ ? {
1757
+ tool,
1758
+ jsonSchema: tool.modelInputSchema ?? renderToolSchema(tool.inputSchema),
1759
+ }
1118
1760
  : {}),
1119
1761
  availableTools: this.config.tools.listNames(),
1120
1762
  });
@@ -1143,9 +1785,9 @@ export class ToolExecutor {
1143
1785
  * image block had it discarded here — before the wire mapper built to
1144
1786
  * carry it ever saw it.
1145
1787
  */
1146
- async runOnce(toolName, input, toolContext) {
1788
+ async runOnce(toolName, input, toolContext, prepared) {
1147
1789
  try {
1148
- return await this.executeWithDeadline(toolName, input, toolContext);
1790
+ return await this.executeWithDeadline(toolName, input, toolContext, prepared);
1149
1791
  }
1150
1792
  catch (err) {
1151
1793
  const message = toErrorMessage(err);
@@ -1216,14 +1858,16 @@ export class ToolExecutor {
1216
1858
  * execution so UI cards reach a terminal state instead of hanging in
1217
1859
  * `executing`, and records a failed activity for the trace.
1218
1860
  */
1219
- async recordDenial(toolCall, reason) {
1220
- const toolName = toolCall.function.name;
1221
- let input = {};
1222
- try {
1223
- input = JSON.parse(toolCall.function.arguments || '{}');
1224
- }
1225
- catch {
1226
- input = toolCall.function.arguments;
1861
+ async recordDenial(toolCall, reason, preparedCall) {
1862
+ const toolName = preparedCall?.toolName ?? toolCall.function.name;
1863
+ let input = preparedCall?.input ?? {};
1864
+ if (!preparedCall) {
1865
+ try {
1866
+ input = JSON.parse(toolCall.function.arguments || '{}');
1867
+ }
1868
+ catch {
1869
+ input = toolCall.function.arguments;
1870
+ }
1227
1871
  }
1228
1872
  const output = deniedToolOutput(toolName, reason);
1229
1873
  this.log.info('Tool call denied — synthesizing tool_result', {
@@ -1299,6 +1943,12 @@ export class ToolExecutor {
1299
1943
  isError: outcome.kind === 'error',
1300
1944
  };
1301
1945
  }
1946
+ recordSyntheticPreparation(call) {
1947
+ return this.recordSyntheticHookOutcome(call.toolCall.id, call.toolName, call.input, {
1948
+ kind: call.isError ? 'error' : 'skip',
1949
+ output: call.message,
1950
+ });
1951
+ }
1302
1952
  /**
1303
1953
  * Bound the rich channel, or leave it alone when no cap is configured.
1304
1954
  *