@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
@@ -1,4 +1,5 @@
1
1
  import type { Span } from '@opentelemetry/api'
2
+ import type { AuthorizationGate } from '../../authorization/gate.js'
2
3
  import { extractFromToolCall, extractFromToolResult } from '../../compaction/extractor.js'
3
4
  import type { WorkingStateManager } from '../../compaction/manager.js'
4
5
  import { GENAI, NAMZU } from '../../constants/telemetry/index.js'
@@ -22,13 +23,16 @@ import {
22
23
  import type { PermissionMode } from '../../types/permission/index.js'
23
24
  import type { PluginHookResult } from '../../types/plugin/index.js'
24
25
  import type { ChatCompletionResponse } from '../../types/provider/index.js'
26
+ import type { AuditEventInput } from '../../types/run/audit.js'
25
27
  import type { RunEvent } from '../../types/run/index.js'
26
28
  import type { Sandbox } from '../../types/sandbox/index.js'
27
29
  import type {
28
30
  FileReadTracker,
31
+ PreparedToolExecution,
29
32
  RequestToolPause,
30
33
  SkillRegistryRef,
31
34
  ToolContext,
35
+ ToolDispatchOptions,
32
36
  ToolRegistryContract,
33
37
  ToolResult,
34
38
  } from '../../types/tool/index.js'
@@ -54,6 +58,74 @@ import {
54
58
 
55
59
  export type EmitEvent = (event: RunEvent) => Promise<void>
56
60
 
61
+ type PreparedDirectCall =
62
+ | {
63
+ readonly kind: 'ready'
64
+ readonly toolCall: ToolCall
65
+ readonly toolName: string
66
+ readonly input: unknown
67
+ readonly prepared: PreparedToolExecution
68
+ }
69
+ | {
70
+ readonly kind: 'legacy'
71
+ readonly toolCall: ToolCall
72
+ readonly toolName: string
73
+ readonly input: unknown
74
+ }
75
+ | {
76
+ readonly kind: 'synthetic'
77
+ readonly toolCall: ToolCall
78
+ readonly toolName: string
79
+ readonly input: unknown
80
+ readonly message: string
81
+ readonly isError: boolean
82
+ }
83
+
84
+ /**
85
+ * Executor-owned, single-use preparation of one provider tool-call batch.
86
+ *
87
+ * Consumers may inspect `reviewCalls`; only the creating executor can consume
88
+ * the opaque call preparations. This keeps schema transforms, plugin rewrites,
89
+ * authorization and execution on one value instead of reparsing between them.
90
+ */
91
+ export interface PreparedToolBatch {
92
+ readonly reviewCalls: readonly {
93
+ readonly id: string
94
+ readonly name: string
95
+ readonly input: unknown
96
+ }[]
97
+ }
98
+
99
+ interface OwnedPreparedToolBatch extends PreparedToolBatch {
100
+ readonly calls: ReadonlyMap<string, PreparedDirectCall>
101
+ }
102
+
103
+ function assertUniqueToolCallIds(toolCalls: readonly ToolCall[]): void {
104
+ const seen = new Set<string>()
105
+ for (const [index, toolCall] of toolCalls.entries()) {
106
+ if (seen.has(toolCall.id)) {
107
+ throw new Error(
108
+ `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.`,
109
+ )
110
+ }
111
+ seen.add(toolCall.id)
112
+ }
113
+ }
114
+
115
+ type PreparedNestedCall =
116
+ | {
117
+ readonly kind: 'ready'
118
+ readonly input: unknown
119
+ readonly prepared: PreparedToolExecution
120
+ }
121
+ | { readonly kind: 'legacy'; readonly input: unknown }
122
+ | {
123
+ readonly kind: 'synthetic'
124
+ readonly input: unknown
125
+ readonly message: string
126
+ readonly isError: boolean
127
+ }
128
+
57
129
  /**
58
130
  * Default per-tool deadline. Long enough for a real build or test run,
59
131
  * short enough that a wedged tool does not hold a turn open indefinitely.
@@ -71,6 +143,103 @@ export const DEFAULT_TOOL_TIMEOUT_MS = 120_000
71
143
  */
72
144
  export const DEFAULT_TOOL_CONCURRENCY = 8
73
145
 
146
+ /** Maximum UTF-8 size of one ephemeral tool-progress update. */
147
+ const MAX_TOOL_PROGRESS_BYTES = 8 * 1024
148
+
149
+ /** A visible marker: this event is a display projection, not durable output. */
150
+ const TOOL_PROGRESS_OMISSION = '… '
151
+
152
+ function boundedToolProgress(message: string): string {
153
+ if (Buffer.byteLength(message, 'utf8') <= MAX_TOOL_PROGRESS_BYTES) return message
154
+
155
+ const tailBudget = MAX_TOOL_PROGRESS_BYTES - Buffer.byteLength(TOOL_PROGRESS_OMISSION, 'utf8')
156
+ let low = 0
157
+ let high = message.length
158
+ while (low < high) {
159
+ const middle = Math.floor((low + high) / 2)
160
+ if (Buffer.byteLength(message.slice(middle), 'utf8') > tailBudget) low = middle + 1
161
+ else high = middle
162
+ }
163
+ // Do not turn the low half of a retained surrogate pair into U+FFFD.
164
+ if (
165
+ low > 0 &&
166
+ low < message.length &&
167
+ message.charCodeAt(low) >= 0xdc00 &&
168
+ message.charCodeAt(low) <= 0xdfff &&
169
+ message.charCodeAt(low - 1) >= 0xd800 &&
170
+ message.charCodeAt(low - 1) <= 0xdbff
171
+ ) {
172
+ low += 1
173
+ }
174
+ return `${TOOL_PROGRESS_OMISSION}${message.slice(low)}`
175
+ }
176
+
177
+ /**
178
+ * Latest-state publisher for one tool call.
179
+ *
180
+ * `ToolContext.report()` is synchronous by contract, while host event
181
+ * listeners may be arbitrarily slow. Starting one promise per report makes a
182
+ * chatty tool an unbounded allocation source. Progress is state rather than a
183
+ * transcript, so one in-flight update plus the latest pending update is the
184
+ * complete useful working set; intermediate states may be replaced.
185
+ */
186
+ class ToolProgressPublisher {
187
+ private pending: { readonly message: string; readonly fraction?: number } | undefined
188
+ private draining: Promise<void> | null = null
189
+ private accepting = true
190
+
191
+ constructor(
192
+ private readonly emitEvent: EmitEvent,
193
+ private readonly base: Omit<
194
+ Extract<RunEvent, { type: 'tool_progress' }>,
195
+ 'message' | 'fraction'
196
+ >,
197
+ ) {}
198
+
199
+ report(message: string, fraction?: number): void {
200
+ if (!this.accepting) return
201
+ this.pending = {
202
+ message,
203
+ ...(fraction !== undefined ? { fraction: Math.min(1, Math.max(0, fraction)) } : {}),
204
+ }
205
+ this.startDrain()
206
+ }
207
+
208
+ async close(): Promise<void> {
209
+ this.accepting = false
210
+ this.startDrain()
211
+ while (this.draining) await this.draining
212
+ }
213
+
214
+ private startDrain(): void {
215
+ if (this.draining || !this.pending) return
216
+ const current = this.drain()
217
+ this.draining = current
218
+ void current.finally(() => {
219
+ if (this.draining !== current) return
220
+ this.draining = null
221
+ // A report can land after the final loop check but before this
222
+ // settlement callback. It is still an accepted update and must be
223
+ // drained even when close() has since stopped new reports.
224
+ this.startDrain()
225
+ })
226
+ }
227
+
228
+ private async drain(): Promise<void> {
229
+ while (this.pending) {
230
+ const update = this.pending
231
+ this.pending = undefined
232
+ // A progress observer is diagnostic. Its failure cannot become a tool
233
+ // failure, and report() never hands a rejection back to the tool.
234
+ await this.emitEvent({
235
+ ...this.base,
236
+ message: boundedToolProgress(update.message),
237
+ ...(update.fraction !== undefined ? { fraction: update.fraction } : {}),
238
+ }).catch(() => {})
239
+ }
240
+ }
241
+ }
242
+
74
243
  /**
75
244
  * Re-runs granted to a `post_tool_use` hook that returns `{action:'retry'}`
76
245
  * on a tool which did not opt into {@link ToolDefinition.maxRetries}.
@@ -221,6 +390,17 @@ export interface ToolExecutorConfig {
221
390
  * reaches it. See {@link RepairToolCall}.
222
391
  */
223
392
  repairToolCall?: RepairToolCall
393
+ /**
394
+ * Operator policy applied to calls dispatched by another tool.
395
+ *
396
+ * Model-issued calls are reviewed by the iteration orchestrator. Nested
397
+ * calls cannot open a second durable review while their parent is already
398
+ * executing, so only an explicit `allow` may proceed; `deny` and `review`
399
+ * both fail closed before the registry is touched.
400
+ */
401
+ authorizationGate?: AuthorizationGate
402
+ /** Durable refusal sink paired with {@link authorizationGate}. */
403
+ recordAudit?: (input: AuditEventInput) => Promise<unknown>
224
404
  }
225
405
 
226
406
  /**
@@ -238,7 +418,7 @@ interface PostToolOverride {
238
418
  }
239
419
 
240
420
  type PreToolHookOutcome =
241
- | { kind: 'continue'; input: unknown }
421
+ | { kind: 'continue'; input: unknown; modified: boolean }
242
422
  | { kind: 'skip'; input: unknown; output: string }
243
423
  | { kind: 'error'; input: unknown; output: string }
244
424
 
@@ -301,6 +481,7 @@ export class ToolExecutor {
301
481
  private workingStateManager?: WorkingStateManager
302
482
  private probes: ProbeEnforcement
303
483
  private parentSpan?: Span
484
+ private readonly preparedBatches = new WeakSet<PreparedToolBatch>()
304
485
  /** Set per turn by the orchestrator; see {@link setStepAllowedTools}. */
305
486
  private stepAllowedTools?: readonly string[]
306
487
  private readonly readPaths: Set<string> = new Set()
@@ -455,15 +636,78 @@ export class ToolExecutor {
455
636
  return typeof configured === 'function' ? configured() : configured
456
637
  }
457
638
 
639
+ /** Evaluate the run's operator policy against one already-prepared value. */
640
+ evaluatePreparedAuthorization(toolName: string, input: unknown) {
641
+ return this.config.authorizationGate?.evaluate({
642
+ toolName,
643
+ toolInput: input,
644
+ toolDef: this.config.tools.get(toolName),
645
+ })
646
+ }
647
+
648
+ /**
649
+ * Resolve repairs and pre-tool hooks, then decode each call exactly once.
650
+ * The returned projection is what policy and a human review; execution later
651
+ * consumes the registry-owned preparations rather than parsing again.
652
+ */
653
+ async prepareBatchForReview(response: ChatCompletionResponse): Promise<PreparedToolBatch> {
654
+ assertUniqueToolCallIds(response.message.toolCalls ?? [])
655
+ const calls = new Map<string, PreparedDirectCall>()
656
+ for (const toolCall of response.message.toolCalls ?? []) {
657
+ calls.set(toolCall.id, await this.prepareDirectCall(toolCall))
658
+ }
659
+ return this.publishPreparedBatch(calls)
660
+ }
661
+
662
+ /** Re-prepare only calls whose raw input a reviewer actually changed. */
663
+ async reprepareBatchForReview(
664
+ response: ChatCompletionResponse,
665
+ previous: PreparedToolBatch,
666
+ changedCallIds: ReadonlySet<string>,
667
+ ): Promise<PreparedToolBatch> {
668
+ assertUniqueToolCallIds(response.message.toolCalls ?? [])
669
+ if (!this.preparedBatches.has(previous)) {
670
+ throw new Error('Prepared tool batch is not owned by this executor.')
671
+ }
672
+ const calls = new Map((previous as OwnedPreparedToolBatch).calls)
673
+ for (const toolCall of response.message.toolCalls ?? []) {
674
+ if (changedCallIds.has(toolCall.id)) {
675
+ calls.set(toolCall.id, await this.prepareDirectCall(toolCall))
676
+ }
677
+ }
678
+ return this.publishPreparedBatch(calls)
679
+ }
680
+
681
+ private publishPreparedBatch(calls: ReadonlyMap<string, PreparedDirectCall>): PreparedToolBatch {
682
+ const reviewCalls = [...calls.values()]
683
+ .filter(
684
+ (call): call is Exclude<PreparedDirectCall, { kind: 'synthetic' }> =>
685
+ call.kind !== 'synthetic',
686
+ )
687
+ .map((call) => ({
688
+ id: call.toolCall.id,
689
+ name: call.toolName,
690
+ input: call.input,
691
+ }))
692
+ const batch: OwnedPreparedToolBatch = Object.freeze({
693
+ reviewCalls: Object.freeze(reviewCalls),
694
+ calls: new Map(calls),
695
+ })
696
+ this.preparedBatches.add(batch)
697
+ return batch
698
+ }
699
+
458
700
  async executeBatch(
459
701
  response: ChatCompletionResponse,
460
702
  denials?: ToolCallDenials,
461
703
  prior?: PriorToolResults,
704
+ preparedBatch?: PreparedToolBatch,
462
705
  ): Promise<ToolExecutionBatch> {
463
706
  const toolCalls = response.message.toolCalls
464
707
  if (!toolCalls) {
465
708
  return { messages: [], results: [], observations: [] }
466
709
  }
710
+ assertUniqueToolCallIds(toolCalls)
467
711
 
468
712
  this.batchCounter += 1
469
713
 
@@ -471,7 +715,11 @@ export class ToolExecutor {
471
715
  // `permissionMode` in the config type.
472
716
  this.batchMode = this.resolvePermissionMode()
473
717
  try {
474
- return await this.runBatch(toolCalls, denials, prior)
718
+ const owned = preparedBatch as OwnedPreparedToolBatch | undefined
719
+ if (owned && !this.preparedBatches.has(owned)) {
720
+ throw new Error('Prepared tool batch is not owned by this executor.')
721
+ }
722
+ return await this.runBatch(toolCalls, denials, prior, owned)
475
723
  } finally {
476
724
  // Cleared so a later single execution outside a batch resolves
477
725
  // live rather than inheriting the last batch's sample.
@@ -483,6 +731,7 @@ export class ToolExecutor {
483
731
  toolCalls: readonly ToolCall[],
484
732
  denials?: ToolCallDenials,
485
733
  prior?: PriorToolResults,
734
+ preparedBatch?: OwnedPreparedToolBatch,
486
735
  ): Promise<ToolExecutionBatch> {
487
736
  this.log.debug('Executing tool batch', {
488
737
  [NAMZU.RUN_ID]: this.config.runId,
@@ -517,6 +766,7 @@ export class ToolExecutor {
517
766
  // order independence.
518
767
  const gate = new Semaphore(this.config.maxToolConcurrency ?? DEFAULT_TOOL_CONCURRENCY)
519
768
  toolCalls.forEach((toolCall, i) => {
769
+ const preparedCall = preparedBatch?.calls.get(toolCall.id)
520
770
  const recovered = prior?.get(toolCall.id)
521
771
  if (recovered !== undefined) {
522
772
  // This call already ran, in a process that died before the
@@ -539,7 +789,7 @@ export class ToolExecutor {
539
789
  // on the parallel branch — they perform no side effects, so
540
790
  // serialization would only add latency.
541
791
  parallel.push(
542
- this.recordDenial(toolCall, denialReason).then((r) => {
792
+ this.recordDenial(toolCall, denialReason, preparedCall).then((r) => {
543
793
  results[i] = r
544
794
  }),
545
795
  )
@@ -548,33 +798,39 @@ export class ToolExecutor {
548
798
  // Per-call, because the event has to name which call it is about:
549
799
  // a batch can run several tools at once and a host rendering them
550
800
  // side by side needs to know whose progress this is.
801
+ const progress = new ToolProgressPublisher(this.emitEvent, {
802
+ type: 'tool_progress',
803
+ runId: this.config.runId,
804
+ toolUseId: toolCall.id as ToolUseId,
805
+ toolName: toolCall.function.name,
806
+ })
551
807
  const ctx: ToolContext = {
552
808
  ...baseContext,
553
809
  toolUseId: toolCall.id,
810
+ source: { kind: 'direct' },
554
811
  // Overridden per call, so a nested dispatch can name the call
555
812
  // that made it. The base context has no `toolUseId`, and a
556
813
  // closure built there would report every nested call as
557
814
  // parentless.
558
- dispatchTool: (name, input) => this.dispatchNested(name, input, ctx, recordObservation),
815
+ dispatchTool: (name, input, options) =>
816
+ this.dispatchNested(name, input, ctx, recordObservation, toolCall.function.name, options),
559
817
  // Per-call for the same reason: a pause has to be routed back
560
818
  // to the call that raised it, and a batch can raise several.
561
819
  ...(this.config.toolPause ? { requestPause: this.config.toolPause(toolCall.id) } : {}),
562
- report: (message: string, fraction?: number) => {
563
- // Fire-and-forget: a tool reporting progress must never be
564
- // able to fail because the host's listener threw, and must
565
- // never have to await the emit mid-work.
566
- void this.emitEvent({
567
- type: 'tool_progress',
568
- runId: this.config.runId,
569
- toolUseId: toolCall.id as ToolUseId,
570
- toolName: toolCall.function.name,
571
- message,
572
- ...(fraction !== undefined ? { fraction: Math.min(1, Math.max(0, fraction)) } : {}),
573
- }).catch(() => {})
574
- },
820
+ report: (message: string, fraction?: number) => progress.report(message, fraction),
575
821
  }
576
822
  const run = async () => {
577
- results[i] = await this.executeSingle(toolCall, ctx, recordObservation)
823
+ try {
824
+ results[i] = await this.executeSingle(
825
+ toolCall,
826
+ ctx,
827
+ recordObservation,
828
+ () => progress.close(),
829
+ preparedCall,
830
+ )
831
+ } finally {
832
+ await progress.close()
833
+ }
578
834
  }
579
835
  const gated = async () => {
580
836
  await gate.acquire()
@@ -584,14 +840,19 @@ export class ToolExecutor {
584
840
  gate.release()
585
841
  }
586
842
  }
587
- let input: unknown = {}
843
+ let input: unknown = preparedCall?.input ?? {}
588
844
  try {
589
- input = JSON.parse(toolCall.function.arguments || '{}')
845
+ if (!preparedBatch?.calls.has(toolCall.id)) {
846
+ input = JSON.parse(toolCall.function.arguments || '{}')
847
+ }
590
848
  } catch {
591
849
  // non-JSON args → treat as unsafe (serialize), the conservative path
592
850
  }
851
+ const preparedToolName = preparedCall?.toolName
593
852
  const safe =
594
- this.config.tools.get(toolCall.function.name)?.isConcurrencySafe?.(input) === true
853
+ this.config.tools
854
+ .get(preparedToolName ?? toolCall.function.name)
855
+ ?.isConcurrencySafe?.(input) === true
595
856
  if (safe) parallel.push(gated())
596
857
  else serial = serial.then(run)
597
858
  })
@@ -651,47 +912,250 @@ export class ToolExecutor {
651
912
  input: unknown,
652
913
  context: ToolContext,
653
914
  recordObservation: (observation: ToolResultObservation) => void,
915
+ parentToolName?: string,
916
+ options?: ToolDispatchOptions,
654
917
  ): Promise<ToolResult> {
918
+ const signal = options?.signal
919
+ ? AbortSignal.any([context.abortSignal, options.signal])
920
+ : context.abortSignal
921
+ // Authority is checked before an id, activity or event is created. A
922
+ // retained closure must be observationally inert after its invocation
923
+ // ends, not merely unable to finish the registry call it already started.
924
+ signal.throwIfAborted()
925
+ const preparedCall = await this.prepareNestedCall(name, input, signal)
926
+ signal.throwIfAborted()
927
+ const preparedInput = preparedCall.input
928
+
655
929
  const parent = context.toolUseId
656
- const via = parent ? { tool: name, toolUseId: parent as ToolUseId } : undefined
930
+ const via =
931
+ parent && parentToolName
932
+ ? {
933
+ tool: parentToolName,
934
+ toolUseId: parent as ToolUseId,
935
+ ...(options?.runtimeToolCallId ? { runtimeToolCallId: options.runtimeToolCallId } : {}),
936
+ }
937
+ : undefined
657
938
  // Its own id, minted here. Reusing the parent's would make two
658
939
  // different calls indistinguishable in any log keyed by it, which is
659
940
  // exactly how a nested write gets attributed to the program that ran
660
941
  // it rather than to itself.
661
942
  const nestedId = generateToolCallId() as unknown as ToolUseId
662
943
  const startedAt = Date.now()
944
+ const source = parent
945
+ ? options?.runtimeToolCallId
946
+ ? {
947
+ kind: 'code' as const,
948
+ parentToolUseId: parent,
949
+ runtimeToolCallId: options.runtimeToolCallId,
950
+ }
951
+ : { kind: 'nested' as const, parentToolUseId: parent }
952
+ : { kind: 'direct' as const }
953
+ const progress = new ToolProgressPublisher(this.emitEvent, {
954
+ type: 'tool_progress',
955
+ runId: this.config.runId,
956
+ toolUseId: nestedId,
957
+ toolName: name,
958
+ })
959
+ if (preparedCall.kind === 'synthetic') {
960
+ await this.emitEvent({
961
+ type: 'tool_executing',
962
+ runId: this.config.runId,
963
+ toolUseId: nestedId,
964
+ toolName: name,
965
+ input: preparedInput,
966
+ ...(via ? { via } : {}),
967
+ })
968
+ await progress.close()
969
+ await this.emitEvent({
970
+ type: 'tool_completed',
971
+ runId: this.config.runId,
972
+ toolUseId: nestedId,
973
+ toolName: name,
974
+ result: preparedCall.message,
975
+ isError: preparedCall.isError,
976
+ durationMs: Date.now() - startedAt,
977
+ outputLength: preparedCall.message.length,
978
+ ...(via ? { via } : {}),
979
+ })
980
+ return preparedCall.isError
981
+ ? { success: false, output: '', error: preparedCall.message }
982
+ : { success: true, output: preparedCall.message }
983
+ }
984
+
985
+ const gateResult = this.config.authorizationGate?.evaluate({
986
+ toolName: name,
987
+ toolInput: preparedInput,
988
+ toolDef: this.config.tools.get(name),
989
+ })
990
+ if (gateResult && gateResult.decision !== 'allow') {
991
+ const reason =
992
+ gateResult.decision === 'deny'
993
+ ? `Blocked by the authorization gate: ${gateResult.reason}`
994
+ : `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}`
995
+ const output = deniedToolOutput(name, reason)
996
+ // Same fail-closed durability rule as a direct gate denial: if the
997
+ // configured run store cannot record the refusal, do not quietly carry
998
+ // on with an unaudited execution.
999
+ if (!this.config.recordAudit) {
1000
+ throw new Error(
1001
+ `Nested tool "${name}" was refused, but no durable audit recorder is configured.`,
1002
+ )
1003
+ }
1004
+ await this.config.recordAudit({
1005
+ what: { action: 'tool_call', tool: name },
1006
+ outcome: 'refused',
1007
+ reason,
1008
+ })
1009
+ await progress.close()
1010
+ await this.emitEvent({
1011
+ type: 'tool_executing',
1012
+ runId: this.config.runId,
1013
+ toolUseId: nestedId,
1014
+ toolName: name,
1015
+ input: preparedInput,
1016
+ ...(via ? { via } : {}),
1017
+ })
1018
+ await this.emitEvent({
1019
+ type: 'tool_completed',
1020
+ runId: this.config.runId,
1021
+ toolUseId: nestedId,
1022
+ toolName: name,
1023
+ result: output,
1024
+ isError: true,
1025
+ durationMs: Date.now() - startedAt,
1026
+ outputLength: output.length,
1027
+ ...(via ? { via } : {}),
1028
+ })
1029
+ return { success: false, output: '', error: reason }
1030
+ }
1031
+
1032
+ const childContext: ToolContext = {
1033
+ ...context,
1034
+ abortSignal: signal,
1035
+ toolUseId: nestedId,
1036
+ source,
1037
+ dispatchTool: (childName, childInput, childOptions) =>
1038
+ this.dispatchNested(
1039
+ childName,
1040
+ childInput,
1041
+ childContext,
1042
+ recordObservation,
1043
+ name,
1044
+ childOptions,
1045
+ ),
1046
+ // A nested execution has its own event/progress identity, but its
1047
+ // durable pause belongs to the nearest model-issued ancestor. The
1048
+ // checkpoint transcript contains that ancestor call and not this
1049
+ // ephemeral child id; minting a pause route for `nestedId` makes the
1050
+ // answer impossible to match after process restart. `...context`
1051
+ // intentionally preserves the ancestor route here.
1052
+ report: (message: string, fraction?: number) => progress.report(message, fraction),
1053
+ }
663
1054
 
664
1055
  await this.emitEvent({
665
1056
  type: 'tool_executing',
666
1057
  runId: this.config.runId,
667
1058
  toolUseId: nestedId,
668
1059
  toolName: name,
669
- input,
1060
+ input: preparedInput,
670
1061
  ...(via ? { via } : {}),
671
1062
  })
672
1063
 
673
- const result = await this.config.tools.execute(name, input, context)
1064
+ const vetoOutcome = this.probes.queryVeto(
1065
+ {
1066
+ type: 'tool_executing',
1067
+ runId: this.config.runId,
1068
+ toolUseId: nestedId,
1069
+ toolName: name,
1070
+ input: preparedInput,
1071
+ ...(via ? { via } : {}),
1072
+ },
1073
+ buildProbeContext({ runId: this.config.runId }),
1074
+ )
1075
+ if (vetoOutcome.action === 'deny') {
1076
+ const probeName = vetoOutcome.probeName ?? 'unnamed'
1077
+ const reason = vetoOutcome.reason ?? 'no reason provided'
1078
+ const message = new ProbeVetoError(probeName, reason, 'tool_executing').message
1079
+ await progress.close()
1080
+ await this.emitEvent({
1081
+ type: 'tool_completed',
1082
+ runId: this.config.runId,
1083
+ toolUseId: nestedId,
1084
+ toolName: name,
1085
+ result: `Error: ${message}`,
1086
+ isError: true,
1087
+ durationMs: Date.now() - startedAt,
1088
+ outputLength: message.length + 7,
1089
+ ...(via ? { via } : {}),
1090
+ })
1091
+ return { success: false, output: '', error: message }
1092
+ }
1093
+
1094
+ let result: ToolResult
1095
+ try {
1096
+ // Use the same deadline layer as a model-issued call. Calling the
1097
+ // registry directly made nested tools the only tools whose own
1098
+ // `timeoutMs` declaration was ignored.
1099
+ result = await this.runOnce(
1100
+ name,
1101
+ preparedInput,
1102
+ childContext,
1103
+ preparedCall.kind === 'ready' ? preparedCall.prepared : undefined,
1104
+ )
1105
+ } finally {
1106
+ await progress.close()
1107
+ }
1108
+
1109
+ const rawOutput = result.success
1110
+ ? result.output
1111
+ : formatFailedToolOutput(result.output, result.error)
1112
+ const budgeted = applyToolOutputBudget({
1113
+ toolName: name,
1114
+ toolUseId: nestedId,
1115
+ output: rawOutput,
1116
+ maxChars: this.config.maxToolOutputChars ?? DEFAULT_MAX_TOOL_OUTPUT_CHARS,
1117
+ spillDir: this.config.toolOutputDir,
1118
+ onError: (message) =>
1119
+ this.log.warn('Failed to spill oversized nested tool output', {
1120
+ [NAMZU.RUN_ID]: this.config.runId,
1121
+ [GENAI.TOOL_NAME]: name,
1122
+ 'exception.message': message,
1123
+ }),
1124
+ })
1125
+ const visibleResult: ToolResult = result.success
1126
+ ? { ...result, output: budgeted.output }
1127
+ : {
1128
+ ...result,
1129
+ output: '',
1130
+ // `run_code` sends `error`, not `output`, across the worker
1131
+ // bridge on a failed host call. Leaving the raw error here would
1132
+ // make the success path bounded and the failure path unbounded.
1133
+ error: budgeted.output,
1134
+ }
674
1135
 
675
1136
  await this.emitEvent({
676
1137
  type: 'tool_completed',
677
1138
  runId: this.config.runId,
678
1139
  toolUseId: nestedId,
679
1140
  toolName: name,
680
- result: result.success ? result.output : (result.error ?? 'failed'),
1141
+ result: budgeted.output,
681
1142
  isError: !result.success,
682
1143
  durationMs: Date.now() - startedAt,
1144
+ outputLength: budgeted.originalLength,
1145
+ ...(budgeted.truncated ? { outputTruncated: true } : {}),
1146
+ ...(budgeted.spillPath ? { outputSpillPath: budgeted.spillPath } : {}),
683
1147
  ...(via ? { via } : {}),
684
1148
  })
685
1149
  recordObservation({
686
1150
  runId: this.config.runId,
687
1151
  toolUseId: nestedId,
688
1152
  toolName: name,
689
- input,
1153
+ input: preparedInput,
690
1154
  result,
691
1155
  ...(parent ? { parentToolUseId: parent } : {}),
692
1156
  })
693
1157
 
694
- return result
1158
+ return visibleResult
695
1159
  }
696
1160
 
697
1161
  private buildToolContext(
@@ -719,6 +1183,7 @@ export class ToolExecutor {
719
1183
  adoptSkillScope: (scope) => {
720
1184
  this.skillScope = { ...scope, adoptedInBatch: this.batchCounter }
721
1185
  },
1186
+ maxToolOutputChars: this.config.maxToolOutputChars ?? DEFAULT_MAX_TOOL_OUTPUT_CHARS,
722
1187
  ...(this.config.skills ? { skills: this.config.skills } : {}),
723
1188
  ...(this.config.web ? { web: this.config.web } : {}),
724
1189
  // The SAME registry and the SAME context a model-issued call
@@ -729,7 +1194,8 @@ export class ToolExecutor {
729
1194
  // dispatching outside a batch has no parent call to name. The
730
1195
  // per-call context below overrides it with one that does; see
731
1196
  // `dispatchNested`.
732
- dispatchTool: (name, input) => this.dispatchNested(name, input, context, recordObservation),
1197
+ dispatchTool: (name, input, options) =>
1198
+ this.dispatchNested(name, input, context, recordObservation, undefined, options),
733
1199
  sandbox: this.config.sandbox,
734
1200
  fileReadTracker: this.fileReadTracker,
735
1201
  // Bound to this run, once. Binding here rather than passing the
@@ -759,99 +1225,112 @@ export class ToolExecutor {
759
1225
  toolCall: ToolCall,
760
1226
  toolContext: ToolContext,
761
1227
  recordObservation: (observation: ToolResultObservation) => void,
1228
+ settleProgress: () => Promise<void>,
1229
+ preparedCall?: PreparedDirectCall,
762
1230
  ): Promise<ToolCallOutcome> {
763
- let toolName = toolCall.function.name
1231
+ if (preparedCall?.kind === 'synthetic') {
1232
+ return this.recordSyntheticPreparation(preparedCall)
1233
+ }
764
1234
 
765
- // A stream that cut off mid-JSON is the case `repairToolCall` exists
766
- // for, and it used to be the one case that never reached it: this
767
- // branch returned before `resolveCall` ran, so the motivating failure
768
- // was answered with a generic hint while the configured repairer sat
769
- // unused. Offer it the partial buffer first.
770
- const truncationRepair =
771
- toolCall.metadata?.inputTruncated === true
772
- ? await this.repairTruncatedCall(toolCall, toolName)
773
- : null
1235
+ let toolName = preparedCall?.toolName ?? toolCall.function.name
1236
+ let input: unknown
1237
+ let prepared: PreparedToolExecution | undefined
774
1238
 
775
- if (toolCall.metadata?.inputTruncated === true && !truncationRepair) {
776
- const message = truncatedToolInputMessage(toolName)
777
- await this.emitEvent({
778
- type: 'tool_executing',
779
- runId: this.config.runId,
780
- toolUseId: toolCall.id,
781
- toolName,
782
- input: {},
783
- })
784
- await this.emitEvent({
785
- type: 'tool_completed',
786
- runId: this.config.runId,
787
- toolUseId: toolCall.id,
788
- toolName,
789
- result: message,
790
- isError: true,
791
- })
792
- return {
793
- toolCallId: toolCall.id,
794
- toolName,
795
- output: message,
796
- isError: true,
1239
+ if (preparedCall?.kind === 'ready' || preparedCall?.kind === 'legacy') {
1240
+ input = preparedCall.input
1241
+ prepared = preparedCall.kind === 'ready' ? preparedCall.prepared : undefined
1242
+ } else {
1243
+ // A stream that cut off mid-JSON is the case `repairToolCall` exists
1244
+ // for, and it used to be the one case that never reached it: this
1245
+ // branch returned before `resolveCall` ran, so the motivating failure
1246
+ // was answered with a generic hint while the configured repairer sat
1247
+ // unused. Offer it the partial buffer first.
1248
+ const truncationRepair =
1249
+ toolCall.metadata?.inputTruncated === true
1250
+ ? await this.repairTruncatedCall(toolCall, toolName)
1251
+ : null
1252
+
1253
+ if (toolCall.metadata?.inputTruncated === true && !truncationRepair) {
1254
+ const message = truncatedToolInputMessage(toolName)
1255
+ await this.emitEvent({
1256
+ type: 'tool_executing',
1257
+ runId: this.config.runId,
1258
+ toolUseId: toolCall.id,
1259
+ toolName,
1260
+ input: {},
1261
+ })
1262
+ await this.emitEvent({
1263
+ type: 'tool_completed',
1264
+ runId: this.config.runId,
1265
+ toolUseId: toolCall.id,
1266
+ toolName,
1267
+ result: message,
1268
+ isError: true,
1269
+ })
1270
+ return {
1271
+ toolCallId: toolCall.id,
1272
+ toolName,
1273
+ output: message,
1274
+ isError: true,
1275
+ }
797
1276
  }
798
- }
799
1277
 
800
- // A malformed call used to cost a full model round trip to fix: the
801
- // error went back as a `tool_result`, the model re-read the whole
802
- // context and tried again. A host that can repair it locally turns
803
- // that into nothing. No-op when no repairer is configured.
804
- const resolved = await this.resolveCall(
805
- truncationRepair
806
- ? {
807
- ...toolCall,
808
- function: {
809
- ...toolCall.function,
810
- name: truncationRepair.toolName ?? toolName,
811
- arguments: truncationRepair.arguments,
812
- },
813
- metadata: {},
814
- }
815
- : toolCall,
816
- )
817
- toolName = resolved.toolName
818
-
819
- if (!resolved.ok) {
820
- // malformed JSON args used to return without ever
821
- // emitting tool_executing or tool_completed, leaving UI cards
822
- // orphaned in `streaming_input`. Emit the executing→completed
823
- // terminal pair so the card lifecycle closes.
824
- const message = resolved.message
825
- await this.emitEvent({
826
- type: 'tool_executing',
827
- runId: this.config.runId,
828
- toolUseId: toolCall.id,
829
- toolName,
830
- input: {},
831
- })
832
- await this.emitEvent({
833
- type: 'tool_completed',
834
- runId: this.config.runId,
835
- toolUseId: toolCall.id,
836
- toolName,
837
- result: message,
838
- isError: true,
839
- })
840
- return {
841
- toolCallId: toolCall.id,
842
- toolName,
843
- output: message,
844
- isError: true,
1278
+ // A malformed call used to cost a full model round trip to fix: the
1279
+ // error went back as a `tool_result`, the model re-read the whole
1280
+ // context and tried again. A host that can repair it locally turns
1281
+ // that into nothing. No-op when no repairer is configured.
1282
+ const resolved = await this.resolveCall(
1283
+ truncationRepair
1284
+ ? {
1285
+ ...toolCall,
1286
+ function: {
1287
+ ...toolCall.function,
1288
+ name: truncationRepair.toolName ?? toolName,
1289
+ arguments: truncationRepair.arguments,
1290
+ },
1291
+ metadata: {},
1292
+ }
1293
+ : toolCall,
1294
+ )
1295
+ toolName = resolved.toolName
1296
+
1297
+ if (!resolved.ok) {
1298
+ // malformed JSON args used to return without ever
1299
+ // emitting tool_executing or tool_completed, leaving UI cards
1300
+ // orphaned in `streaming_input`. Emit the executing→completed
1301
+ // terminal pair so the card lifecycle closes.
1302
+ const message = resolved.message
1303
+ await this.emitEvent({
1304
+ type: 'tool_executing',
1305
+ runId: this.config.runId,
1306
+ toolUseId: toolCall.id,
1307
+ toolName,
1308
+ input: {},
1309
+ })
1310
+ await this.emitEvent({
1311
+ type: 'tool_completed',
1312
+ runId: this.config.runId,
1313
+ toolUseId: toolCall.id,
1314
+ toolName,
1315
+ result: message,
1316
+ isError: true,
1317
+ })
1318
+ return {
1319
+ toolCallId: toolCall.id,
1320
+ toolName,
1321
+ output: message,
1322
+ isError: true,
1323
+ }
845
1324
  }
846
- }
847
1325
 
848
- let input: unknown = resolved.input
1326
+ input = resolved.input
849
1327
 
850
- const preOutcome = await this.runPreToolHook(toolName, input)
851
- if (preOutcome.kind === 'skip' || preOutcome.kind === 'error') {
852
- return this.recordSyntheticHookOutcome(toolCall.id, toolName, preOutcome.input, preOutcome)
1328
+ const preOutcome = await this.runPreToolHook(toolName, input)
1329
+ if (preOutcome.kind === 'skip' || preOutcome.kind === 'error') {
1330
+ return this.recordSyntheticHookOutcome(toolCall.id, toolName, preOutcome.input, preOutcome)
1331
+ }
1332
+ input = preOutcome.input
853
1333
  }
854
- input = preOutcome.input
855
1334
 
856
1335
  const activity = this.activityStore.create({
857
1336
  type: 'tool_call',
@@ -939,7 +1418,7 @@ export class ToolExecutor {
939
1418
  // version silently DROPPED `content`, so a tool returning an image
940
1419
  // block had it discarded here — before the wire mapper that was
941
1420
  // built to carry it ever saw it.
942
- let result: ToolResult = await this.runOnce(toolName, input, toolContext)
1421
+ let result: ToolResult = await this.runOnce(toolName, input, toolContext, prepared)
943
1422
  let post = await this.runPostToolHook(toolName, input, result)
944
1423
 
945
1424
  // In-loop retry. A transient failure used to cost a full model round
@@ -1008,7 +1487,7 @@ export class ToolExecutor {
1008
1487
  break
1009
1488
  }
1010
1489
 
1011
- result = await this.runOnce(toolName, input, toolContext)
1490
+ result = await this.runOnce(toolName, input, toolContext, prepared)
1012
1491
  post = await this.runPostToolHook(toolName, input, result)
1013
1492
  }
1014
1493
  const durationMs = Date.now() - startMs
@@ -1017,16 +1496,35 @@ export class ToolExecutor {
1017
1496
  ? result.output
1018
1497
  : formatFailedToolOutput(result.output, result.error)
1019
1498
 
1020
- let output = result.success ? this.maybeCompress(toolName, rawOutput) : rawOutput
1499
+ const postOverride = post.override
1500
+ let output =
1501
+ postOverride?.output ?? (result.success ? this.maybeCompress(toolName, rawOutput) : rawOutput)
1502
+ const selectedContent = postOverride?.isError
1503
+ ? undefined
1504
+ : (postOverride?.content ?? result.content)
1505
+ const maxToolOutputChars = this.config.maxToolOutputChars ?? DEFAULT_MAX_TOOL_OUTPUT_CHARS
1506
+
1507
+ // If the text preview forces rich content out, say so inside the SAME
1508
+ // budget. Appending this after truncation made the diagnostic itself a
1509
+ // cap bypass; selecting a post-hook override after truncation was a
1510
+ // larger bypass that could replace a bounded result with anything.
1511
+ if (
1512
+ maxToolOutputChars > 0 &&
1513
+ output.length > maxToolOutputChars &&
1514
+ selectedContent !== undefined
1515
+ ) {
1516
+ const dropped = describeDroppedContent(selectedContent)
1517
+ if (dropped) output = `${output}\n\n${dropped}`
1518
+ }
1021
1519
 
1022
1520
  // Compression is opportunistic and shell-only; the budget is the
1023
- // hard bound that applies to every tool. Runs after compression so
1024
- // a result that shrinks under the cap is never spilled needlessly.
1521
+ // hard bound that applies to every final tool result, including a
1522
+ // post-tool hook's replacement and the rich-content omission notice.
1025
1523
  const budgeted = applyToolOutputBudget({
1026
1524
  toolName,
1027
1525
  toolUseId: toolCall.id,
1028
1526
  output,
1029
- maxChars: this.config.maxToolOutputChars ?? DEFAULT_MAX_TOOL_OUTPUT_CHARS,
1527
+ maxChars: maxToolOutputChars,
1030
1528
  spillDir: this.config.toolOutputDir,
1031
1529
  onError: (message) =>
1032
1530
  this.log.warn('Failed to spill oversized tool output', {
@@ -1045,22 +1543,6 @@ export class ToolExecutor {
1045
1543
  }
1046
1544
  output = budgeted.output
1047
1545
 
1048
- // A truncated text half drops the rich half with it — the preview is
1049
- // no longer the tool's own payload, so an image alongside it would be
1050
- // illustrating something the model can no longer read. Dropping is
1051
- // right; doing it SILENTLY is not. The model saw a preview and had no
1052
- // way to know an image existed at all, so it reasoned as though the
1053
- // tool had returned text only.
1054
- if (budgeted.truncated && result.content !== undefined) {
1055
- const dropped = describeDroppedContent(result.content)
1056
- if (dropped) output = `${output}\n\n${dropped}`
1057
- }
1058
-
1059
- const postOverride = post.override
1060
- if (postOverride !== null) {
1061
- output = postOverride.output
1062
- }
1063
-
1064
1546
  // A failed call, or an override that says the call failed. A `replace`
1065
1547
  // says the opposite, and reading it as a failure is what made redaction
1066
1548
  // unusable: the model was told a successful call had gone wrong, and
@@ -1095,6 +1577,11 @@ export class ToolExecutor {
1095
1577
  }
1096
1578
  }
1097
1579
 
1580
+ // The terminal event closes the live row. Every accepted progress update
1581
+ // must settle before it, otherwise a slow host can receive an update for a
1582
+ // call it has already removed. Detached work reporting after a timeout is
1583
+ // ignored because close() also revokes the publisher.
1584
+ await settleProgress()
1098
1585
  await this.emitEvent({
1099
1586
  type: 'tool_completed',
1100
1587
  runId: this.config.runId,
@@ -1118,12 +1605,8 @@ export class ToolExecutor {
1118
1605
  })
1119
1606
 
1120
1607
  const resolveContent = (): { content?: ToolResultContent } => {
1121
- if (postOverride?.content !== undefined) {
1122
- return { content: this.budgetContent(postOverride.content, toolName) }
1123
- }
1124
- if (postOverride?.isError) return {}
1125
- if (budgeted.truncated || result.content === undefined) return {}
1126
- return { content: this.budgetContent(result.content, toolName) }
1608
+ if (budgeted.truncated || selectedContent === undefined) return {}
1609
+ return { content: this.budgetContent(selectedContent, toolName) }
1127
1610
  }
1128
1611
 
1129
1612
  return {
@@ -1169,33 +1652,35 @@ export class ToolExecutor {
1169
1652
  toolName: string,
1170
1653
  input: unknown,
1171
1654
  toolContext: ToolContext,
1655
+ prepared?: PreparedToolExecution,
1172
1656
  ): Promise<ToolResult> {
1173
1657
  const timeoutMs =
1174
1658
  this.config.tools.get(toolName)?.timeoutMs ??
1175
1659
  this.config.toolTimeoutMs ??
1176
1660
  DEFAULT_TOOL_TIMEOUT_MS
1177
1661
 
1178
- if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
1179
- return this.config.tools.execute(toolName, input, toolContext)
1180
- }
1181
-
1182
1662
  const controller = new AbortController()
1183
- const runSignal = this.config.abortSignal
1184
- const onRunAbort = () => controller.abort(runSignal.reason)
1185
- if (runSignal.aborted) controller.abort(runSignal.reason)
1186
- else runSignal.addEventListener('abort', onRunAbort, { once: true })
1663
+ const parentSignal = toolContext.abortSignal
1664
+ const onParentAbort = () => controller.abort(parentSignal.reason)
1665
+ if (parentSignal.aborted) controller.abort(parentSignal.reason)
1666
+ else parentSignal.addEventListener('abort', onParentAbort, { once: true })
1187
1667
 
1188
1668
  let timer: ReturnType<typeof setTimeout> | undefined
1189
1669
  let timedOut = false
1670
+ let invocationOpen = true
1671
+ const nestedDispatches = new Set<Promise<ToolResult>>()
1190
1672
 
1191
1673
  try {
1192
- const expired = new Promise<'timeout'>((resolve) => {
1193
- timer = setTimeout(() => {
1194
- timedOut = true
1195
- controller.abort(new Error(`Tool "${toolName}" exceeded ${timeoutMs}ms`))
1196
- resolve('timeout')
1197
- }, timeoutMs)
1198
- })
1674
+ const expired =
1675
+ Number.isFinite(timeoutMs) && timeoutMs > 0
1676
+ ? new Promise<'timeout'>((resolve) => {
1677
+ timer = setTimeout(() => {
1678
+ timedOut = true
1679
+ controller.abort(new Error(`Tool "${toolName}" exceeded ${timeoutMs}ms`))
1680
+ resolve('timeout')
1681
+ }, timeoutMs)
1682
+ })
1683
+ : undefined
1199
1684
 
1200
1685
  const aborted = new Promise<'aborted'>((resolve) => {
1201
1686
  if (controller.signal.aborted && !timedOut) {
@@ -1211,15 +1696,54 @@ export class ToolExecutor {
1211
1696
  )
1212
1697
  })
1213
1698
 
1214
- const execution = this.config.tools.execute(toolName, input, {
1699
+ const inheritedDispatch = toolContext.dispatchTool
1700
+ const scopedDispatch = inheritedDispatch
1701
+ ? (name: string, nestedInput: unknown, options?: ToolDispatchOptions) => {
1702
+ if (!invocationOpen) {
1703
+ return Promise.reject(
1704
+ new Error(`Tool "${toolName}" invocation has settled; nested dispatch is closed.`),
1705
+ )
1706
+ }
1707
+ const nested = inheritedDispatch(name, nestedInput, {
1708
+ ...options,
1709
+ signal: options?.signal
1710
+ ? AbortSignal.any([controller.signal, options.signal])
1711
+ : controller.signal,
1712
+ })
1713
+ nestedDispatches.add(nested)
1714
+ // `finally()` creates a second promise. Observe that promise too, or a
1715
+ // rejected nested call which its owner intentionally awaits later would
1716
+ // also create an unhandled cleanup rejection here.
1717
+ void nested.finally(() => nestedDispatches.delete(nested)).catch(() => {})
1718
+ return nested
1719
+ }
1720
+ : undefined
1721
+
1722
+ if (controller.signal.aborted) {
1723
+ return {
1724
+ success: false,
1725
+ output: '',
1726
+ error: abortReasonText(controller.signal.reason)
1727
+ ? `Tool "${toolName}" was cancelled: ${abortReasonText(controller.signal.reason)}`
1728
+ : `Tool "${toolName}" was cancelled.`,
1729
+ }
1730
+ }
1731
+
1732
+ const context = {
1215
1733
  ...toolContext,
1216
1734
  abortSignal: controller.signal,
1217
- })
1735
+ ...(scopedDispatch ? { dispatchTool: scopedDispatch } : {}),
1736
+ }
1737
+ const execution = prepared
1738
+ ? this.config.tools.executePrepared(prepared, context)
1739
+ : this.config.tools.execute(toolName, input, context)
1218
1740
  // The loser of the race may still reject later; neutralize it so
1219
1741
  // it is never an unhandled rejection.
1220
1742
  execution.catch(() => {})
1221
1743
 
1222
- const outcome = await Promise.race([execution, expired, aborted])
1744
+ const outcome = await Promise.race(
1745
+ expired ? [execution, expired, aborted] : [execution, aborted],
1746
+ )
1223
1747
 
1224
1748
  if (outcome === 'timeout') {
1225
1749
  this.log.warn('Tool timed out', {
@@ -1253,38 +1777,312 @@ export class ToolExecutor {
1253
1777
 
1254
1778
  return outcome
1255
1779
  } finally {
1780
+ // Revoke synchronously before awaiting anything. A retained closure
1781
+ // called from another microtask is refused here; the aborted signal is
1782
+ // the structural backstop inside dispatchNested itself.
1783
+ invocationOpen = false
1784
+ if (!controller.signal.aborted) {
1785
+ controller.abort(new Error(`Tool "${toolName}" invocation has settled.`))
1786
+ }
1787
+ // Calls already admitted before closure own event rows and registry
1788
+ // work. Their executor races observe the abort, emit a terminal result,
1789
+ // and settle before the parent is allowed to report completion.
1790
+ while (nestedDispatches.size > 0) {
1791
+ await Promise.allSettled([...nestedDispatches])
1792
+ }
1256
1793
  if (timer !== undefined) clearTimeout(timer)
1257
- runSignal.removeEventListener('abort', onRunAbort)
1794
+ parentSignal.removeEventListener('abort', onParentAbort)
1258
1795
  }
1259
1796
  }
1260
1797
 
1261
- private async runPreToolHook(toolName: string, input: unknown): Promise<PreToolHookOutcome> {
1262
- if (!this.config.pluginManager) return { kind: 'continue', input }
1798
+ private async runPreToolHook(
1799
+ toolName: string,
1800
+ input: unknown,
1801
+ signal: AbortSignal = this.config.abortSignal,
1802
+ ): Promise<PreToolHookOutcome> {
1803
+ if (!this.config.pluginManager) return { kind: 'continue', input, modified: false }
1263
1804
  const results = await this.config.pluginManager.executeHooks(
1264
1805
  'pre_tool_use',
1265
1806
  {
1266
1807
  runId: this.config.runId,
1267
1808
  toolName,
1268
1809
  toolInput: input,
1269
- signal: this.config.abortSignal,
1810
+ signal,
1270
1811
  },
1271
1812
  this.emitEvent,
1272
1813
  )
1273
1814
  return this.interpretPreToolResults(toolName, input, results)
1274
1815
  }
1275
1816
 
1817
+ private async prepareNestedCall(
1818
+ toolName: string,
1819
+ input: unknown,
1820
+ signal: AbortSignal,
1821
+ ): Promise<PreparedNestedCall> {
1822
+ const prepare = this.config.tools.prepareExecution
1823
+ const executePrepared = this.config.tools.executePrepared
1824
+ if (typeof prepare !== 'function' || typeof executePrepared !== 'function') {
1825
+ if (this.config.authorizationGate) {
1826
+ return {
1827
+ kind: 'synthetic',
1828
+ input,
1829
+ message: `Tool "${toolName}" was not executed because its registry cannot bind authorization to one prepared input.`,
1830
+ isError: true,
1831
+ }
1832
+ }
1833
+ const preOutcome = await this.runPreToolHook(toolName, input, signal)
1834
+ if (preOutcome.kind === 'skip' || preOutcome.kind === 'error') {
1835
+ return {
1836
+ kind: 'synthetic',
1837
+ input: preOutcome.input,
1838
+ message: preOutcome.output,
1839
+ isError: preOutcome.kind === 'error',
1840
+ }
1841
+ }
1842
+ return { kind: 'legacy', input: preOutcome.input }
1843
+ }
1844
+
1845
+ let preparation: ReturnType<typeof prepare>
1846
+ try {
1847
+ preparation = prepare.call(this.config.tools, toolName, input)
1848
+ } catch (err) {
1849
+ return {
1850
+ kind: 'synthetic',
1851
+ input,
1852
+ message: `Tool "${toolName}" could not be prepared: ${toErrorMessage(err)}`,
1853
+ isError: true,
1854
+ }
1855
+ }
1856
+ if (!preparation.success) {
1857
+ return {
1858
+ kind: 'synthetic',
1859
+ input,
1860
+ message: formatFailedToolOutput(preparation.result.output, preparation.result.error),
1861
+ isError: true,
1862
+ }
1863
+ }
1864
+ const preOutcome = await this.runPreToolHook(toolName, preparation.prepared.input, signal)
1865
+ if (preOutcome.kind === 'skip' || preOutcome.kind === 'error') {
1866
+ return {
1867
+ kind: 'synthetic',
1868
+ input: preOutcome.input,
1869
+ message: preOutcome.output,
1870
+ isError: preOutcome.kind === 'error',
1871
+ }
1872
+ }
1873
+ if (!preOutcome.modified) {
1874
+ return {
1875
+ kind: 'ready',
1876
+ input: preparation.prepared.input,
1877
+ prepared: preparation.prepared,
1878
+ }
1879
+ }
1880
+ const modified = prepare.call(this.config.tools, toolName, preOutcome.input)
1881
+ if (!modified.success) {
1882
+ return {
1883
+ kind: 'synthetic',
1884
+ input: preOutcome.input,
1885
+ message: formatFailedToolOutput(modified.result.output, modified.result.error),
1886
+ isError: true,
1887
+ }
1888
+ }
1889
+ return { kind: 'ready', input: modified.prepared.input, prepared: modified.prepared }
1890
+ }
1891
+
1892
+ private async prepareDirectCall(toolCall: ToolCall): Promise<PreparedDirectCall> {
1893
+ let toolName = toolCall.function.name
1894
+ const truncationRepair =
1895
+ toolCall.metadata?.inputTruncated === true
1896
+ ? await this.repairTruncatedCall(toolCall, toolName)
1897
+ : null
1898
+ if (toolCall.metadata?.inputTruncated === true && !truncationRepair) {
1899
+ return {
1900
+ kind: 'synthetic',
1901
+ toolCall,
1902
+ toolName,
1903
+ input: {},
1904
+ message: truncatedToolInputMessage(toolName),
1905
+ isError: true,
1906
+ }
1907
+ }
1908
+
1909
+ const prepare = this.config.tools.prepareExecution
1910
+ const executePrepared = this.config.tools.executePrepared
1911
+ if (typeof prepare !== 'function' || typeof executePrepared !== 'function') {
1912
+ const resolved = await this.resolveCall(
1913
+ truncationRepair
1914
+ ? {
1915
+ ...toolCall,
1916
+ function: {
1917
+ ...toolCall.function,
1918
+ name: truncationRepair.toolName ?? toolName,
1919
+ arguments: truncationRepair.arguments,
1920
+ },
1921
+ metadata: {},
1922
+ }
1923
+ : toolCall,
1924
+ )
1925
+ toolName = resolved.toolName
1926
+ if (!resolved.ok) {
1927
+ return {
1928
+ kind: 'synthetic',
1929
+ toolCall,
1930
+ toolName,
1931
+ input: {},
1932
+ message: resolved.message,
1933
+ isError: true,
1934
+ }
1935
+ }
1936
+ const preOutcome = await this.runPreToolHook(toolName, resolved.input)
1937
+ if (preOutcome.kind === 'skip' || preOutcome.kind === 'error') {
1938
+ return {
1939
+ kind: 'synthetic',
1940
+ toolCall,
1941
+ toolName,
1942
+ input: preOutcome.input,
1943
+ message: preOutcome.output,
1944
+ isError: preOutcome.kind === 'error',
1945
+ }
1946
+ }
1947
+ if (!this.config.authorizationGate) {
1948
+ return {
1949
+ kind: 'legacy',
1950
+ toolCall,
1951
+ toolName,
1952
+ input: preOutcome.input,
1953
+ }
1954
+ }
1955
+ return {
1956
+ kind: 'synthetic',
1957
+ toolCall,
1958
+ toolName,
1959
+ input: preOutcome.input,
1960
+ message: `Tool "${toolName}" was not executed because its registry cannot bind authorization to one prepared input.`,
1961
+ isError: true,
1962
+ }
1963
+ }
1964
+
1965
+ let raw = truncationRepair?.arguments ?? toolCall.function.arguments
1966
+ toolName = truncationRepair?.toolName ?? toolName
1967
+ let repairUsed = truncationRepair !== null
1968
+ let preparation: ReturnType<typeof prepare>
1969
+ for (;;) {
1970
+ let parsed: unknown
1971
+ try {
1972
+ parsed = parseArguments(raw)
1973
+ } catch {
1974
+ const message = `Error: Invalid JSON in tool arguments for "${toolName}"`
1975
+ const repair =
1976
+ !repairUsed && this.config.repairToolCall
1977
+ ? await this.requestRepair(toolCall, toolName, {
1978
+ reason: 'invalid_json',
1979
+ message,
1980
+ })
1981
+ : null
1982
+ if (repair) {
1983
+ repairUsed = true
1984
+ toolName = repair.toolName ?? toolName
1985
+ raw = repair.arguments
1986
+ continue
1987
+ }
1988
+ return { kind: 'synthetic', toolCall, toolName, input: {}, message, isError: true }
1989
+ }
1990
+
1991
+ try {
1992
+ preparation = prepare.call(this.config.tools, toolName, parsed)
1993
+ } catch (err) {
1994
+ const message = `Error: Unknown or unavailable tool "${toolName}": ${toErrorMessage(err)}`
1995
+ const repair =
1996
+ !repairUsed && this.config.repairToolCall
1997
+ ? await this.requestRepair(toolCall, toolName, {
1998
+ reason: 'unknown_tool',
1999
+ message,
2000
+ })
2001
+ : null
2002
+ if (repair) {
2003
+ repairUsed = true
2004
+ toolName = repair.toolName ?? toolName
2005
+ raw = repair.arguments
2006
+ continue
2007
+ }
2008
+ return { kind: 'synthetic', toolCall, toolName, input: parsed, message, isError: true }
2009
+ }
2010
+
2011
+ if (preparation.success) break
2012
+ const message = formatFailedToolOutput(preparation.result.output, preparation.result.error)
2013
+ const repair =
2014
+ !repairUsed && this.config.repairToolCall
2015
+ ? await this.requestRepair(toolCall, toolName, {
2016
+ reason: 'schema_validation',
2017
+ message,
2018
+ })
2019
+ : null
2020
+ if (repair) {
2021
+ repairUsed = true
2022
+ toolName = repair.toolName ?? toolName
2023
+ raw = repair.arguments
2024
+ continue
2025
+ }
2026
+ return {
2027
+ kind: 'synthetic',
2028
+ toolCall,
2029
+ toolName,
2030
+ input: parsed,
2031
+ message,
2032
+ isError: true,
2033
+ }
2034
+ }
2035
+
2036
+ const preOutcome = await this.runPreToolHook(toolName, preparation.prepared.input)
2037
+ if (preOutcome.kind === 'skip' || preOutcome.kind === 'error') {
2038
+ return {
2039
+ kind: 'synthetic',
2040
+ toolCall,
2041
+ toolName,
2042
+ input: preOutcome.input,
2043
+ message: preOutcome.output,
2044
+ isError: preOutcome.kind === 'error',
2045
+ }
2046
+ }
2047
+
2048
+ if (preOutcome.modified) {
2049
+ const modified = prepare.call(this.config.tools, toolName, preOutcome.input)
2050
+ if (!modified.success) {
2051
+ return {
2052
+ kind: 'synthetic',
2053
+ toolCall,
2054
+ toolName,
2055
+ input: preOutcome.input,
2056
+ message: formatFailedToolOutput(modified.result.output, modified.result.error),
2057
+ isError: true,
2058
+ }
2059
+ }
2060
+ preparation = modified
2061
+ }
2062
+
2063
+ return {
2064
+ kind: 'ready',
2065
+ toolCall,
2066
+ toolName,
2067
+ input: preparation.prepared.input,
2068
+ prepared: preparation.prepared,
2069
+ }
2070
+ }
2071
+
1276
2072
  private interpretPreToolResults(
1277
2073
  toolName: string,
1278
2074
  initialInput: unknown,
1279
2075
  results: readonly PluginHookResult[],
1280
2076
  ): PreToolHookOutcome {
1281
2077
  let currentInput = initialInput
2078
+ let modified = false
1282
2079
  for (const result of results) {
1283
2080
  switch (result.action) {
1284
2081
  case 'continue':
1285
2082
  continue
1286
2083
  case 'modify':
1287
2084
  currentInput = result.input
2085
+ modified = true
1288
2086
  continue
1289
2087
  case 'skip':
1290
2088
  return {
@@ -1313,7 +2111,7 @@ export class ToolExecutor {
1313
2111
  }
1314
2112
  }
1315
2113
  }
1316
- return { kind: 'continue', input: currentInput }
2114
+ return { kind: 'continue', input: currentInput, modified }
1317
2115
  }
1318
2116
 
1319
2117
  /**
@@ -1457,7 +2255,10 @@ export class ToolExecutor {
1457
2255
  reason: failure.reason,
1458
2256
  message: failure.message,
1459
2257
  ...(tool
1460
- ? { tool, jsonSchema: tool.modelInputSchema ?? renderToolSchema(tool.inputSchema) }
2258
+ ? {
2259
+ tool,
2260
+ jsonSchema: tool.modelInputSchema ?? renderToolSchema(tool.inputSchema),
2261
+ }
1461
2262
  : {}),
1462
2263
  availableTools: this.config.tools.listNames(),
1463
2264
  })
@@ -1490,9 +2291,10 @@ export class ToolExecutor {
1490
2291
  toolName: string,
1491
2292
  input: unknown,
1492
2293
  toolContext: ToolContext,
2294
+ prepared?: PreparedToolExecution,
1493
2295
  ): Promise<ToolResult> {
1494
2296
  try {
1495
- return await this.executeWithDeadline(toolName, input, toolContext)
2297
+ return await this.executeWithDeadline(toolName, input, toolContext, prepared)
1496
2298
  } catch (err) {
1497
2299
  const message = toErrorMessage(err)
1498
2300
  this.log.warn('Tool execution threw', {
@@ -1573,13 +2375,19 @@ export class ToolExecutor {
1573
2375
  * execution so UI cards reach a terminal state instead of hanging in
1574
2376
  * `executing`, and records a failed activity for the trace.
1575
2377
  */
1576
- private async recordDenial(toolCall: ToolCall, reason: string): Promise<ToolCallOutcome> {
1577
- const toolName = toolCall.function.name
1578
- let input: unknown = {}
1579
- try {
1580
- input = JSON.parse(toolCall.function.arguments || '{}')
1581
- } catch {
1582
- input = toolCall.function.arguments
2378
+ private async recordDenial(
2379
+ toolCall: ToolCall,
2380
+ reason: string,
2381
+ preparedCall?: PreparedDirectCall,
2382
+ ): Promise<ToolCallOutcome> {
2383
+ const toolName = preparedCall?.toolName ?? toolCall.function.name
2384
+ let input: unknown = preparedCall?.input ?? {}
2385
+ if (!preparedCall) {
2386
+ try {
2387
+ input = JSON.parse(toolCall.function.arguments || '{}')
2388
+ } catch {
2389
+ input = toolCall.function.arguments
2390
+ }
1583
2391
  }
1584
2392
 
1585
2393
  const output = deniedToolOutput(toolName, reason)
@@ -1666,6 +2474,13 @@ export class ToolExecutor {
1666
2474
  }
1667
2475
  }
1668
2476
 
2477
+ private recordSyntheticPreparation(call: Extract<PreparedDirectCall, { kind: 'synthetic' }>) {
2478
+ return this.recordSyntheticHookOutcome(call.toolCall.id, call.toolName, call.input, {
2479
+ kind: call.isError ? 'error' : 'skip',
2480
+ output: call.message,
2481
+ })
2482
+ }
2483
+
1669
2484
  /**
1670
2485
  * Bound the rich channel, or leave it alone when no cap is configured.
1671
2486
  *