@namzu/sdk 32.0.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 (260) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/dist/authorization/gate.d.ts +1 -1
  3. package/dist/authorization/gate.js +1 -1
  4. package/dist/connector/index.d.ts +1 -1
  5. package/dist/connector/index.d.ts.map +1 -1
  6. package/dist/connector/index.js +1 -1
  7. package/dist/connector/index.js.map +1 -1
  8. package/dist/connector/mcp/adapter.d.ts.map +1 -1
  9. package/dist/connector/mcp/adapter.js +71 -16
  10. package/dist/connector/mcp/adapter.js.map +1 -1
  11. package/dist/connector/mcp/client.d.ts.map +1 -1
  12. package/dist/connector/mcp/client.js +3 -2
  13. package/dist/connector/mcp/client.js.map +1 -1
  14. package/dist/connector/mcp/http-redirect.d.ts +17 -0
  15. package/dist/connector/mcp/http-redirect.d.ts.map +1 -0
  16. package/dist/connector/mcp/http-redirect.js +31 -0
  17. package/dist/connector/mcp/http-redirect.js.map +1 -0
  18. package/dist/connector/mcp/http-sse.d.ts.map +1 -1
  19. package/dist/connector/mcp/http-sse.js +5 -0
  20. package/dist/connector/mcp/http-sse.js.map +1 -1
  21. package/dist/connector/mcp/image-admission.d.ts +16 -0
  22. package/dist/connector/mcp/image-admission.d.ts.map +1 -0
  23. package/dist/connector/mcp/image-admission.js +288 -0
  24. package/dist/connector/mcp/image-admission.js.map +1 -0
  25. package/dist/connector/mcp/stdio.d.ts +13 -0
  26. package/dist/connector/mcp/stdio.d.ts.map +1 -1
  27. package/dist/connector/mcp/stdio.js +66 -23
  28. package/dist/connector/mcp/stdio.js.map +1 -1
  29. package/dist/connector/mcp/streamable-http.d.ts.map +1 -1
  30. package/dist/connector/mcp/streamable-http.js +3 -0
  31. package/dist/connector/mcp/streamable-http.js.map +1 -1
  32. package/dist/constants/tools/index.js +1 -1
  33. package/dist/constants/tools/index.js.map +1 -1
  34. package/dist/execution/base.d.ts +13 -0
  35. package/dist/execution/base.d.ts.map +1 -1
  36. package/dist/execution/base.js +103 -8
  37. package/dist/execution/base.js.map +1 -1
  38. package/dist/execution/errors.d.ts +12 -0
  39. package/dist/execution/errors.d.ts.map +1 -0
  40. package/dist/execution/errors.js +21 -0
  41. package/dist/execution/errors.js.map +1 -0
  42. package/dist/execution/factory.d.ts.map +1 -1
  43. package/dist/execution/factory.js +1 -0
  44. package/dist/execution/factory.js.map +1 -1
  45. package/dist/execution/hybrid.d.ts +5 -0
  46. package/dist/execution/hybrid.d.ts.map +1 -1
  47. package/dist/execution/hybrid.js +36 -16
  48. package/dist/execution/hybrid.js.map +1 -1
  49. package/dist/execution/index.d.ts +1 -0
  50. package/dist/execution/index.d.ts.map +1 -1
  51. package/dist/execution/index.js +1 -0
  52. package/dist/execution/index.js.map +1 -1
  53. package/dist/execution/local.d.ts +13 -0
  54. package/dist/execution/local.d.ts.map +1 -1
  55. package/dist/execution/local.js +145 -19
  56. package/dist/execution/local.js.map +1 -1
  57. package/dist/execution/output.d.ts +27 -0
  58. package/dist/execution/output.d.ts.map +1 -0
  59. package/dist/execution/output.js +105 -0
  60. package/dist/execution/output.js.map +1 -0
  61. package/dist/execution/remote.d.ts +26 -2
  62. package/dist/execution/remote.d.ts.map +1 -1
  63. package/dist/execution/remote.js +86 -15
  64. package/dist/execution/remote.js.map +1 -1
  65. package/dist/plugin/lifecycle.d.ts.map +1 -1
  66. package/dist/plugin/lifecycle.js +9 -3
  67. package/dist/plugin/lifecycle.js.map +1 -1
  68. package/dist/process/kill-tree.d.ts +11 -13
  69. package/dist/process/kill-tree.d.ts.map +1 -1
  70. package/dist/process/kill-tree.js +11 -13
  71. package/dist/process/kill-tree.js.map +1 -1
  72. package/dist/provider/fallback.d.ts.map +1 -1
  73. package/dist/provider/fallback.js +31 -0
  74. package/dist/provider/fallback.js.map +1 -1
  75. package/dist/provider/idle-timeout.d.ts.map +1 -1
  76. package/dist/provider/idle-timeout.js +5 -0
  77. package/dist/provider/idle-timeout.js.map +1 -1
  78. package/dist/provider/retry.d.ts.map +1 -1
  79. package/dist/provider/retry.js +5 -0
  80. package/dist/provider/retry.js.map +1 -1
  81. package/dist/public-runtime.d.ts +2 -2
  82. package/dist/public-runtime.d.ts.map +1 -1
  83. package/dist/public-runtime.js +2 -2
  84. package/dist/public-runtime.js.map +1 -1
  85. package/dist/registry/tool/execute.d.ts +7 -1
  86. package/dist/registry/tool/execute.d.ts.map +1 -1
  87. package/dist/registry/tool/execute.js +201 -54
  88. package/dist/registry/tool/execute.js.map +1 -1
  89. package/dist/run/command-gate.d.ts.map +1 -1
  90. package/dist/run/command-gate.js +11 -0
  91. package/dist/run/command-gate.js.map +1 -1
  92. package/dist/run/workspace-fingerprint.d.ts +4 -4
  93. package/dist/run/workspace-fingerprint.d.ts.map +1 -1
  94. package/dist/run/workspace-fingerprint.js +6 -4
  95. package/dist/run/workspace-fingerprint.js.map +1 -1
  96. package/dist/runtime/bidi/index.d.ts +1 -1
  97. package/dist/runtime/bidi/index.d.ts.map +1 -1
  98. package/dist/runtime/bidi/index.js +1 -1
  99. package/dist/runtime/bidi/index.js.map +1 -1
  100. package/dist/runtime/bidi/session.d.ts +19 -0
  101. package/dist/runtime/bidi/session.d.ts.map +1 -1
  102. package/dist/runtime/bidi/session.js +150 -26
  103. package/dist/runtime/bidi/session.js.map +1 -1
  104. package/dist/runtime/query/executor.d.ts +43 -1
  105. package/dist/runtime/query/executor.d.ts.map +1 -1
  106. package/dist/runtime/query/executor.js +626 -161
  107. package/dist/runtime/query/executor.js.map +1 -1
  108. package/dist/runtime/query/index.d.ts.map +1 -1
  109. package/dist/runtime/query/index.js +46 -4
  110. package/dist/runtime/query/index.js.map +1 -1
  111. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  112. package/dist/runtime/query/iteration/phases/tool-review.js +84 -22
  113. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  114. package/dist/runtime/query/request-rich-content.d.ts.map +1 -1
  115. package/dist/runtime/query/request-rich-content.js +33 -13
  116. package/dist/runtime/query/request-rich-content.js.map +1 -1
  117. package/dist/runtime/query/resume-pending.d.ts +5 -1
  118. package/dist/runtime/query/resume-pending.d.ts.map +1 -1
  119. package/dist/runtime/query/resume-pending.js +66 -1
  120. package/dist/runtime/query/resume-pending.js.map +1 -1
  121. package/dist/runtime/query/tool-output-budget.d.ts.map +1 -1
  122. package/dist/runtime/query/tool-output-budget.js +45 -16
  123. package/dist/runtime/query/tool-output-budget.js.map +1 -1
  124. package/dist/runtime/query/tooling.d.ts +6 -0
  125. package/dist/runtime/query/tooling.d.ts.map +1 -1
  126. package/dist/runtime/query/tooling.js +4 -0
  127. package/dist/runtime/query/tooling.js.map +1 -1
  128. package/dist/sandbox/provider/local.d.ts.map +1 -1
  129. package/dist/sandbox/provider/local.js +193 -41
  130. package/dist/sandbox/provider/local.js.map +1 -1
  131. package/dist/scheduler/delegating.d.ts +2 -1
  132. package/dist/scheduler/delegating.d.ts.map +1 -1
  133. package/dist/scheduler/delegating.js +6 -5
  134. package/dist/scheduler/delegating.js.map +1 -1
  135. package/dist/scheduler/local.d.ts +2 -1
  136. package/dist/scheduler/local.d.ts.map +1 -1
  137. package/dist/scheduler/local.js +2 -2
  138. package/dist/scheduler/local.js.map +1 -1
  139. package/dist/session/workspace/driver.d.ts +12 -4
  140. package/dist/session/workspace/driver.d.ts.map +1 -1
  141. package/dist/session/workspace/git-worktree.d.ts +17 -3
  142. package/dist/session/workspace/git-worktree.d.ts.map +1 -1
  143. package/dist/session/workspace/git-worktree.js +153 -34
  144. package/dist/session/workspace/git-worktree.js.map +1 -1
  145. package/dist/skills/registry.d.ts +15 -0
  146. package/dist/skills/registry.d.ts.map +1 -1
  147. package/dist/skills/registry.js +33 -0
  148. package/dist/skills/registry.js.map +1 -1
  149. package/dist/tools/builtins/computer-use.d.ts.map +1 -1
  150. package/dist/tools/builtins/computer-use.js +39 -2
  151. package/dist/tools/builtins/computer-use.js.map +1 -1
  152. package/dist/tools/builtins/run-code.d.ts.map +1 -1
  153. package/dist/tools/builtins/run-code.js +9 -11
  154. package/dist/tools/builtins/run-code.js.map +1 -1
  155. package/dist/tools/builtins/skill.d.ts +2 -1
  156. package/dist/tools/builtins/skill.d.ts.map +1 -1
  157. package/dist/tools/builtins/skill.js +250 -6
  158. package/dist/tools/builtins/skill.js.map +1 -1
  159. package/dist/tools/coordinator/agent.d.ts.map +1 -1
  160. package/dist/tools/coordinator/agent.js +100 -30
  161. package/dist/tools/coordinator/agent.js.map +1 -1
  162. package/dist/types/agent/manager.d.ts +1 -1
  163. package/dist/types/agent/manager.d.ts.map +1 -1
  164. package/dist/types/agent/scheduler.d.ts +10 -1
  165. package/dist/types/agent/scheduler.d.ts.map +1 -1
  166. package/dist/types/authorization/index.d.ts +12 -12
  167. package/dist/types/bidi/index.d.ts +19 -2
  168. package/dist/types/bidi/index.d.ts.map +1 -1
  169. package/dist/types/computer-use/index.d.ts +18 -0
  170. package/dist/types/computer-use/index.d.ts.map +1 -1
  171. package/dist/types/connector/execution-context.d.ts +3 -1
  172. package/dist/types/connector/execution-context.d.ts.map +1 -1
  173. package/dist/types/execution/index.d.ts +43 -1
  174. package/dist/types/execution/index.d.ts.map +1 -1
  175. package/dist/types/hitl/index.d.ts +11 -0
  176. package/dist/types/hitl/index.d.ts.map +1 -1
  177. package/dist/types/hitl/index.js.map +1 -1
  178. package/dist/types/message/index.d.ts +1 -1
  179. package/dist/types/message/index.d.ts.map +1 -1
  180. package/dist/types/message/index.js +2 -1
  181. package/dist/types/message/index.js.map +1 -1
  182. package/dist/types/plugin/index.d.ts +14 -1
  183. package/dist/types/plugin/index.d.ts.map +1 -1
  184. package/dist/types/plugin/index.js +1 -0
  185. package/dist/types/plugin/index.js.map +1 -1
  186. package/dist/types/provider/interface.d.ts +14 -0
  187. package/dist/types/provider/interface.d.ts.map +1 -1
  188. package/dist/types/sandbox/index.d.ts +15 -10
  189. package/dist/types/sandbox/index.d.ts.map +1 -1
  190. package/dist/types/sandbox/index.js.map +1 -1
  191. package/dist/types/tool/index.d.ts +77 -6
  192. package/dist/types/tool/index.d.ts.map +1 -1
  193. package/dist/types/tool/index.js.map +1 -1
  194. package/dist/types/workspace/ref.d.ts +5 -0
  195. package/dist/types/workspace/ref.d.ts.map +1 -1
  196. package/dist/utils/abort.d.ts +8 -0
  197. package/dist/utils/abort.d.ts.map +1 -1
  198. package/dist/utils/abort.js +16 -0
  199. package/dist/utils/abort.js.map +1 -1
  200. package/package.json +4 -4
  201. package/src/authorization/gate.ts +1 -1
  202. package/src/connector/index.ts +2 -0
  203. package/src/connector/mcp/adapter.ts +82 -21
  204. package/src/connector/mcp/client.ts +2 -1
  205. package/src/connector/mcp/http-redirect.ts +32 -0
  206. package/src/connector/mcp/http-sse.ts +5 -0
  207. package/src/connector/mcp/image-admission.ts +272 -0
  208. package/src/connector/mcp/stdio.ts +63 -21
  209. package/src/connector/mcp/streamable-http.ts +3 -0
  210. package/src/constants/tools/index.ts +1 -1
  211. package/src/execution/base.ts +124 -7
  212. package/src/execution/errors.ts +24 -0
  213. package/src/execution/factory.ts +1 -0
  214. package/src/execution/hybrid.ts +47 -16
  215. package/src/execution/index.ts +1 -0
  216. package/src/execution/local.ts +164 -19
  217. package/src/execution/output.ts +104 -0
  218. package/src/execution/remote.ts +122 -17
  219. package/src/plugin/lifecycle.ts +9 -3
  220. package/src/process/kill-tree.ts +11 -13
  221. package/src/provider/fallback.ts +39 -0
  222. package/src/provider/idle-timeout.ts +8 -0
  223. package/src/provider/retry.ts +8 -0
  224. package/src/public-runtime.ts +7 -1
  225. package/src/registry/tool/execute.ts +230 -64
  226. package/src/run/command-gate.ts +12 -0
  227. package/src/run/workspace-fingerprint.ts +5 -4
  228. package/src/runtime/bidi/index.ts +1 -1
  229. package/src/runtime/bidi/session.ts +177 -28
  230. package/src/runtime/query/executor.ts +769 -163
  231. package/src/runtime/query/index.ts +53 -6
  232. package/src/runtime/query/iteration/phases/tool-review.ts +87 -23
  233. package/src/runtime/query/request-rich-content.ts +39 -14
  234. package/src/runtime/query/resume-pending.ts +83 -1
  235. package/src/runtime/query/tool-output-budget.ts +52 -17
  236. package/src/runtime/query/tooling.ts +10 -0
  237. package/src/sandbox/provider/local.ts +213 -45
  238. package/src/scheduler/delegating.ts +6 -5
  239. package/src/scheduler/local.ts +3 -2
  240. package/src/session/workspace/driver.ts +12 -4
  241. package/src/session/workspace/git-worktree.ts +173 -34
  242. package/src/skills/registry.ts +47 -0
  243. package/src/tools/builtins/computer-use.ts +45 -2
  244. package/src/tools/builtins/run-code.ts +9 -11
  245. package/src/tools/builtins/skill.ts +325 -7
  246. package/src/tools/coordinator/agent.ts +108 -33
  247. package/src/types/agent/manager.ts +1 -1
  248. package/src/types/agent/scheduler.ts +10 -1
  249. package/src/types/bidi/index.ts +19 -2
  250. package/src/types/computer-use/index.ts +19 -0
  251. package/src/types/connector/execution-context.ts +3 -0
  252. package/src/types/execution/index.ts +46 -1
  253. package/src/types/hitl/index.ts +11 -0
  254. package/src/types/message/index.ts +3 -2
  255. package/src/types/plugin/index.ts +15 -0
  256. package/src/types/provider/interface.ts +18 -0
  257. package/src/types/sandbox/index.ts +15 -10
  258. package/src/types/tool/index.ts +85 -6
  259. package/src/types/workspace/ref.ts +5 -0
  260. 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,10 +23,12 @@ 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,
@@ -55,6 +58,74 @@ import {
55
58
 
56
59
  export type EmitEvent = (event: RunEvent) => Promise<void>
57
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
+
58
129
  /**
59
130
  * Default per-tool deadline. Long enough for a real build or test run,
60
131
  * short enough that a wedged tool does not hold a turn open indefinitely.
@@ -319,6 +390,17 @@ export interface ToolExecutorConfig {
319
390
  * reaches it. See {@link RepairToolCall}.
320
391
  */
321
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>
322
404
  }
323
405
 
324
406
  /**
@@ -336,7 +418,7 @@ interface PostToolOverride {
336
418
  }
337
419
 
338
420
  type PreToolHookOutcome =
339
- | { kind: 'continue'; input: unknown }
421
+ | { kind: 'continue'; input: unknown; modified: boolean }
340
422
  | { kind: 'skip'; input: unknown; output: string }
341
423
  | { kind: 'error'; input: unknown; output: string }
342
424
 
@@ -399,6 +481,7 @@ export class ToolExecutor {
399
481
  private workingStateManager?: WorkingStateManager
400
482
  private probes: ProbeEnforcement
401
483
  private parentSpan?: Span
484
+ private readonly preparedBatches = new WeakSet<PreparedToolBatch>()
402
485
  /** Set per turn by the orchestrator; see {@link setStepAllowedTools}. */
403
486
  private stepAllowedTools?: readonly string[]
404
487
  private readonly readPaths: Set<string> = new Set()
@@ -553,15 +636,78 @@ export class ToolExecutor {
553
636
  return typeof configured === 'function' ? configured() : configured
554
637
  }
555
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
+
556
700
  async executeBatch(
557
701
  response: ChatCompletionResponse,
558
702
  denials?: ToolCallDenials,
559
703
  prior?: PriorToolResults,
704
+ preparedBatch?: PreparedToolBatch,
560
705
  ): Promise<ToolExecutionBatch> {
561
706
  const toolCalls = response.message.toolCalls
562
707
  if (!toolCalls) {
563
708
  return { messages: [], results: [], observations: [] }
564
709
  }
710
+ assertUniqueToolCallIds(toolCalls)
565
711
 
566
712
  this.batchCounter += 1
567
713
 
@@ -569,7 +715,11 @@ export class ToolExecutor {
569
715
  // `permissionMode` in the config type.
570
716
  this.batchMode = this.resolvePermissionMode()
571
717
  try {
572
- 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)
573
723
  } finally {
574
724
  // Cleared so a later single execution outside a batch resolves
575
725
  // live rather than inheriting the last batch's sample.
@@ -581,6 +731,7 @@ export class ToolExecutor {
581
731
  toolCalls: readonly ToolCall[],
582
732
  denials?: ToolCallDenials,
583
733
  prior?: PriorToolResults,
734
+ preparedBatch?: OwnedPreparedToolBatch,
584
735
  ): Promise<ToolExecutionBatch> {
585
736
  this.log.debug('Executing tool batch', {
586
737
  [NAMZU.RUN_ID]: this.config.runId,
@@ -615,6 +766,7 @@ export class ToolExecutor {
615
766
  // order independence.
616
767
  const gate = new Semaphore(this.config.maxToolConcurrency ?? DEFAULT_TOOL_CONCURRENCY)
617
768
  toolCalls.forEach((toolCall, i) => {
769
+ const preparedCall = preparedBatch?.calls.get(toolCall.id)
618
770
  const recovered = prior?.get(toolCall.id)
619
771
  if (recovered !== undefined) {
620
772
  // This call already ran, in a process that died before the
@@ -637,7 +789,7 @@ export class ToolExecutor {
637
789
  // on the parallel branch — they perform no side effects, so
638
790
  // serialization would only add latency.
639
791
  parallel.push(
640
- this.recordDenial(toolCall, denialReason).then((r) => {
792
+ this.recordDenial(toolCall, denialReason, preparedCall).then((r) => {
641
793
  results[i] = r
642
794
  }),
643
795
  )
@@ -669,8 +821,12 @@ export class ToolExecutor {
669
821
  }
670
822
  const run = async () => {
671
823
  try {
672
- results[i] = await this.executeSingle(toolCall, ctx, recordObservation, () =>
673
- progress.close(),
824
+ results[i] = await this.executeSingle(
825
+ toolCall,
826
+ ctx,
827
+ recordObservation,
828
+ () => progress.close(),
829
+ preparedCall,
674
830
  )
675
831
  } finally {
676
832
  await progress.close()
@@ -684,14 +840,19 @@ export class ToolExecutor {
684
840
  gate.release()
685
841
  }
686
842
  }
687
- let input: unknown = {}
843
+ let input: unknown = preparedCall?.input ?? {}
688
844
  try {
689
- input = JSON.parse(toolCall.function.arguments || '{}')
845
+ if (!preparedBatch?.calls.has(toolCall.id)) {
846
+ input = JSON.parse(toolCall.function.arguments || '{}')
847
+ }
690
848
  } catch {
691
849
  // non-JSON args → treat as unsafe (serialize), the conservative path
692
850
  }
851
+ const preparedToolName = preparedCall?.toolName
693
852
  const safe =
694
- this.config.tools.get(toolCall.function.name)?.isConcurrencySafe?.(input) === true
853
+ this.config.tools
854
+ .get(preparedToolName ?? toolCall.function.name)
855
+ ?.isConcurrencySafe?.(input) === true
695
856
  if (safe) parallel.push(gated())
696
857
  else serial = serial.then(run)
697
858
  })
@@ -754,6 +915,17 @@ export class ToolExecutor {
754
915
  parentToolName?: string,
755
916
  options?: ToolDispatchOptions,
756
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
+
757
929
  const parent = context.toolUseId
758
930
  const via =
759
931
  parent && parentToolName
@@ -769,9 +941,6 @@ export class ToolExecutor {
769
941
  // it rather than to itself.
770
942
  const nestedId = generateToolCallId() as unknown as ToolUseId
771
943
  const startedAt = Date.now()
772
- const signal = options?.signal
773
- ? AbortSignal.any([context.abortSignal, options.signal])
774
- : context.abortSignal
775
944
  const source = parent
776
945
  ? options?.runtimeToolCallId
777
946
  ? {
@@ -787,6 +956,79 @@ export class ToolExecutor {
787
956
  toolUseId: nestedId,
788
957
  toolName: name,
789
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
+
790
1032
  const childContext: ToolContext = {
791
1033
  ...context,
792
1034
  abortSignal: signal,
@@ -815,16 +1057,51 @@ export class ToolExecutor {
815
1057
  runId: this.config.runId,
816
1058
  toolUseId: nestedId,
817
1059
  toolName: name,
818
- input,
1060
+ input: preparedInput,
819
1061
  ...(via ? { via } : {}),
820
1062
  })
821
1063
 
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
+
822
1094
  let result: ToolResult
823
1095
  try {
824
1096
  // Use the same deadline layer as a model-issued call. Calling the
825
1097
  // registry directly made nested tools the only tools whose own
826
1098
  // `timeoutMs` declaration was ignored.
827
- result = await this.runOnce(name, input, childContext)
1099
+ result = await this.runOnce(
1100
+ name,
1101
+ preparedInput,
1102
+ childContext,
1103
+ preparedCall.kind === 'ready' ? preparedCall.prepared : undefined,
1104
+ )
828
1105
  } finally {
829
1106
  await progress.close()
830
1107
  }
@@ -873,7 +1150,7 @@ export class ToolExecutor {
873
1150
  runId: this.config.runId,
874
1151
  toolUseId: nestedId,
875
1152
  toolName: name,
876
- input,
1153
+ input: preparedInput,
877
1154
  result,
878
1155
  ...(parent ? { parentToolUseId: parent } : {}),
879
1156
  })
@@ -906,6 +1183,7 @@ export class ToolExecutor {
906
1183
  adoptSkillScope: (scope) => {
907
1184
  this.skillScope = { ...scope, adoptedInBatch: this.batchCounter }
908
1185
  },
1186
+ maxToolOutputChars: this.config.maxToolOutputChars ?? DEFAULT_MAX_TOOL_OUTPUT_CHARS,
909
1187
  ...(this.config.skills ? { skills: this.config.skills } : {}),
910
1188
  ...(this.config.web ? { web: this.config.web } : {}),
911
1189
  // The SAME registry and the SAME context a model-issued call
@@ -948,99 +1226,111 @@ export class ToolExecutor {
948
1226
  toolContext: ToolContext,
949
1227
  recordObservation: (observation: ToolResultObservation) => void,
950
1228
  settleProgress: () => Promise<void>,
1229
+ preparedCall?: PreparedDirectCall,
951
1230
  ): Promise<ToolCallOutcome> {
952
- let toolName = toolCall.function.name
1231
+ if (preparedCall?.kind === 'synthetic') {
1232
+ return this.recordSyntheticPreparation(preparedCall)
1233
+ }
953
1234
 
954
- // A stream that cut off mid-JSON is the case `repairToolCall` exists
955
- // for, and it used to be the one case that never reached it: this
956
- // branch returned before `resolveCall` ran, so the motivating failure
957
- // was answered with a generic hint while the configured repairer sat
958
- // unused. Offer it the partial buffer first.
959
- const truncationRepair =
960
- toolCall.metadata?.inputTruncated === true
961
- ? await this.repairTruncatedCall(toolCall, toolName)
962
- : null
1235
+ let toolName = preparedCall?.toolName ?? toolCall.function.name
1236
+ let input: unknown
1237
+ let prepared: PreparedToolExecution | undefined
963
1238
 
964
- if (toolCall.metadata?.inputTruncated === true && !truncationRepair) {
965
- const message = truncatedToolInputMessage(toolName)
966
- await this.emitEvent({
967
- type: 'tool_executing',
968
- runId: this.config.runId,
969
- toolUseId: toolCall.id,
970
- toolName,
971
- input: {},
972
- })
973
- await this.emitEvent({
974
- type: 'tool_completed',
975
- runId: this.config.runId,
976
- toolUseId: toolCall.id,
977
- toolName,
978
- result: message,
979
- isError: true,
980
- })
981
- return {
982
- toolCallId: toolCall.id,
983
- toolName,
984
- output: message,
985
- 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
+ }
986
1276
  }
987
- }
988
1277
 
989
- // A malformed call used to cost a full model round trip to fix: the
990
- // error went back as a `tool_result`, the model re-read the whole
991
- // context and tried again. A host that can repair it locally turns
992
- // that into nothing. No-op when no repairer is configured.
993
- const resolved = await this.resolveCall(
994
- truncationRepair
995
- ? {
996
- ...toolCall,
997
- function: {
998
- ...toolCall.function,
999
- name: truncationRepair.toolName ?? toolName,
1000
- arguments: truncationRepair.arguments,
1001
- },
1002
- metadata: {},
1003
- }
1004
- : toolCall,
1005
- )
1006
- toolName = resolved.toolName
1007
-
1008
- if (!resolved.ok) {
1009
- // malformed JSON args used to return without ever
1010
- // emitting tool_executing or tool_completed, leaving UI cards
1011
- // orphaned in `streaming_input`. Emit the executing→completed
1012
- // terminal pair so the card lifecycle closes.
1013
- const message = resolved.message
1014
- await this.emitEvent({
1015
- type: 'tool_executing',
1016
- runId: this.config.runId,
1017
- toolUseId: toolCall.id,
1018
- toolName,
1019
- input: {},
1020
- })
1021
- await this.emitEvent({
1022
- type: 'tool_completed',
1023
- runId: this.config.runId,
1024
- toolUseId: toolCall.id,
1025
- toolName,
1026
- result: message,
1027
- isError: true,
1028
- })
1029
- return {
1030
- toolCallId: toolCall.id,
1031
- toolName,
1032
- output: message,
1033
- 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
+ }
1034
1324
  }
1035
- }
1036
1325
 
1037
- let input: unknown = resolved.input
1326
+ input = resolved.input
1038
1327
 
1039
- const preOutcome = await this.runPreToolHook(toolName, input)
1040
- if (preOutcome.kind === 'skip' || preOutcome.kind === 'error') {
1041
- 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
1042
1333
  }
1043
- input = preOutcome.input
1044
1334
 
1045
1335
  const activity = this.activityStore.create({
1046
1336
  type: 'tool_call',
@@ -1128,7 +1418,7 @@ export class ToolExecutor {
1128
1418
  // version silently DROPPED `content`, so a tool returning an image
1129
1419
  // block had it discarded here — before the wire mapper that was
1130
1420
  // built to carry it ever saw it.
1131
- let result: ToolResult = await this.runOnce(toolName, input, toolContext)
1421
+ let result: ToolResult = await this.runOnce(toolName, input, toolContext, prepared)
1132
1422
  let post = await this.runPostToolHook(toolName, input, result)
1133
1423
 
1134
1424
  // In-loop retry. A transient failure used to cost a full model round
@@ -1197,7 +1487,7 @@ export class ToolExecutor {
1197
1487
  break
1198
1488
  }
1199
1489
 
1200
- result = await this.runOnce(toolName, input, toolContext)
1490
+ result = await this.runOnce(toolName, input, toolContext, prepared)
1201
1491
  post = await this.runPostToolHook(toolName, input, result)
1202
1492
  }
1203
1493
  const durationMs = Date.now() - startMs
@@ -1206,16 +1496,35 @@ export class ToolExecutor {
1206
1496
  ? result.output
1207
1497
  : formatFailedToolOutput(result.output, result.error)
1208
1498
 
1209
- 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
+ }
1210
1519
 
1211
1520
  // Compression is opportunistic and shell-only; the budget is the
1212
- // hard bound that applies to every tool. Runs after compression so
1213
- // 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.
1214
1523
  const budgeted = applyToolOutputBudget({
1215
1524
  toolName,
1216
1525
  toolUseId: toolCall.id,
1217
1526
  output,
1218
- maxChars: this.config.maxToolOutputChars ?? DEFAULT_MAX_TOOL_OUTPUT_CHARS,
1527
+ maxChars: maxToolOutputChars,
1219
1528
  spillDir: this.config.toolOutputDir,
1220
1529
  onError: (message) =>
1221
1530
  this.log.warn('Failed to spill oversized tool output', {
@@ -1234,22 +1543,6 @@ export class ToolExecutor {
1234
1543
  }
1235
1544
  output = budgeted.output
1236
1545
 
1237
- // A truncated text half drops the rich half with it — the preview is
1238
- // no longer the tool's own payload, so an image alongside it would be
1239
- // illustrating something the model can no longer read. Dropping is
1240
- // right; doing it SILENTLY is not. The model saw a preview and had no
1241
- // way to know an image existed at all, so it reasoned as though the
1242
- // tool had returned text only.
1243
- if (budgeted.truncated && result.content !== undefined) {
1244
- const dropped = describeDroppedContent(result.content)
1245
- if (dropped) output = `${output}\n\n${dropped}`
1246
- }
1247
-
1248
- const postOverride = post.override
1249
- if (postOverride !== null) {
1250
- output = postOverride.output
1251
- }
1252
-
1253
1546
  // A failed call, or an override that says the call failed. A `replace`
1254
1547
  // says the opposite, and reading it as a failure is what made redaction
1255
1548
  // unusable: the model was told a successful call had gone wrong, and
@@ -1312,12 +1605,8 @@ export class ToolExecutor {
1312
1605
  })
1313
1606
 
1314
1607
  const resolveContent = (): { content?: ToolResultContent } => {
1315
- if (postOverride?.content !== undefined) {
1316
- return { content: this.budgetContent(postOverride.content, toolName) }
1317
- }
1318
- if (postOverride?.isError) return {}
1319
- if (budgeted.truncated || result.content === undefined) return {}
1320
- return { content: this.budgetContent(result.content, toolName) }
1608
+ if (budgeted.truncated || selectedContent === undefined) return {}
1609
+ return { content: this.budgetContent(selectedContent, toolName) }
1321
1610
  }
1322
1611
 
1323
1612
  return {
@@ -1363,16 +1652,13 @@ export class ToolExecutor {
1363
1652
  toolName: string,
1364
1653
  input: unknown,
1365
1654
  toolContext: ToolContext,
1655
+ prepared?: PreparedToolExecution,
1366
1656
  ): Promise<ToolResult> {
1367
1657
  const timeoutMs =
1368
1658
  this.config.tools.get(toolName)?.timeoutMs ??
1369
1659
  this.config.toolTimeoutMs ??
1370
1660
  DEFAULT_TOOL_TIMEOUT_MS
1371
1661
 
1372
- if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
1373
- return this.config.tools.execute(toolName, input, toolContext)
1374
- }
1375
-
1376
1662
  const controller = new AbortController()
1377
1663
  const parentSignal = toolContext.abortSignal
1378
1664
  const onParentAbort = () => controller.abort(parentSignal.reason)
@@ -1381,15 +1667,20 @@ export class ToolExecutor {
1381
1667
 
1382
1668
  let timer: ReturnType<typeof setTimeout> | undefined
1383
1669
  let timedOut = false
1670
+ let invocationOpen = true
1671
+ const nestedDispatches = new Set<Promise<ToolResult>>()
1384
1672
 
1385
1673
  try {
1386
- const expired = new Promise<'timeout'>((resolve) => {
1387
- timer = setTimeout(() => {
1388
- timedOut = true
1389
- controller.abort(new Error(`Tool "${toolName}" exceeded ${timeoutMs}ms`))
1390
- resolve('timeout')
1391
- }, timeoutMs)
1392
- })
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
1393
1684
 
1394
1685
  const aborted = new Promise<'aborted'>((resolve) => {
1395
1686
  if (controller.signal.aborted && !timedOut) {
@@ -1406,26 +1697,53 @@ export class ToolExecutor {
1406
1697
  })
1407
1698
 
1408
1699
  const inheritedDispatch = toolContext.dispatchTool
1409
- const execution = this.config.tools.execute(toolName, input, {
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 = {
1410
1733
  ...toolContext,
1411
1734
  abortSignal: controller.signal,
1412
- ...(inheritedDispatch
1413
- ? {
1414
- dispatchTool: (name: string, nestedInput: unknown, options?: ToolDispatchOptions) =>
1415
- inheritedDispatch(name, nestedInput, {
1416
- ...options,
1417
- signal: options?.signal
1418
- ? AbortSignal.any([controller.signal, options.signal])
1419
- : controller.signal,
1420
- }),
1421
- }
1422
- : {}),
1423
- })
1735
+ ...(scopedDispatch ? { dispatchTool: scopedDispatch } : {}),
1736
+ }
1737
+ const execution = prepared
1738
+ ? this.config.tools.executePrepared(prepared, context)
1739
+ : this.config.tools.execute(toolName, input, context)
1424
1740
  // The loser of the race may still reject later; neutralize it so
1425
1741
  // it is never an unhandled rejection.
1426
1742
  execution.catch(() => {})
1427
1743
 
1428
- const outcome = await Promise.race([execution, expired, aborted])
1744
+ const outcome = await Promise.race(
1745
+ expired ? [execution, expired, aborted] : [execution, aborted],
1746
+ )
1429
1747
 
1430
1748
  if (outcome === 'timeout') {
1431
1749
  this.log.warn('Tool timed out', {
@@ -1459,38 +1777,312 @@ export class ToolExecutor {
1459
1777
 
1460
1778
  return outcome
1461
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
+ }
1462
1793
  if (timer !== undefined) clearTimeout(timer)
1463
1794
  parentSignal.removeEventListener('abort', onParentAbort)
1464
1795
  }
1465
1796
  }
1466
1797
 
1467
- private async runPreToolHook(toolName: string, input: unknown): Promise<PreToolHookOutcome> {
1468
- 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 }
1469
1804
  const results = await this.config.pluginManager.executeHooks(
1470
1805
  'pre_tool_use',
1471
1806
  {
1472
1807
  runId: this.config.runId,
1473
1808
  toolName,
1474
1809
  toolInput: input,
1475
- signal: this.config.abortSignal,
1810
+ signal,
1476
1811
  },
1477
1812
  this.emitEvent,
1478
1813
  )
1479
1814
  return this.interpretPreToolResults(toolName, input, results)
1480
1815
  }
1481
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
+
1482
2072
  private interpretPreToolResults(
1483
2073
  toolName: string,
1484
2074
  initialInput: unknown,
1485
2075
  results: readonly PluginHookResult[],
1486
2076
  ): PreToolHookOutcome {
1487
2077
  let currentInput = initialInput
2078
+ let modified = false
1488
2079
  for (const result of results) {
1489
2080
  switch (result.action) {
1490
2081
  case 'continue':
1491
2082
  continue
1492
2083
  case 'modify':
1493
2084
  currentInput = result.input
2085
+ modified = true
1494
2086
  continue
1495
2087
  case 'skip':
1496
2088
  return {
@@ -1519,7 +2111,7 @@ export class ToolExecutor {
1519
2111
  }
1520
2112
  }
1521
2113
  }
1522
- return { kind: 'continue', input: currentInput }
2114
+ return { kind: 'continue', input: currentInput, modified }
1523
2115
  }
1524
2116
 
1525
2117
  /**
@@ -1699,9 +2291,10 @@ export class ToolExecutor {
1699
2291
  toolName: string,
1700
2292
  input: unknown,
1701
2293
  toolContext: ToolContext,
2294
+ prepared?: PreparedToolExecution,
1702
2295
  ): Promise<ToolResult> {
1703
2296
  try {
1704
- return await this.executeWithDeadline(toolName, input, toolContext)
2297
+ return await this.executeWithDeadline(toolName, input, toolContext, prepared)
1705
2298
  } catch (err) {
1706
2299
  const message = toErrorMessage(err)
1707
2300
  this.log.warn('Tool execution threw', {
@@ -1782,13 +2375,19 @@ export class ToolExecutor {
1782
2375
  * execution so UI cards reach a terminal state instead of hanging in
1783
2376
  * `executing`, and records a failed activity for the trace.
1784
2377
  */
1785
- private async recordDenial(toolCall: ToolCall, reason: string): Promise<ToolCallOutcome> {
1786
- const toolName = toolCall.function.name
1787
- let input: unknown = {}
1788
- try {
1789
- input = JSON.parse(toolCall.function.arguments || '{}')
1790
- } catch {
1791
- 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
+ }
1792
2391
  }
1793
2392
 
1794
2393
  const output = deniedToolOutput(toolName, reason)
@@ -1875,6 +2474,13 @@ export class ToolExecutor {
1875
2474
  }
1876
2475
  }
1877
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
+
1878
2484
  /**
1879
2485
  * Bound the rich channel, or leave it alone when no cap is configured.
1880
2486
  *