@librechat/agents 3.1.77 → 3.1.78-dev.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 (181) hide show
  1. package/dist/cjs/common/enum.cjs +54 -0
  2. package/dist/cjs/common/enum.cjs.map +1 -1
  3. package/dist/cjs/graphs/Graph.cjs +148 -4
  4. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  5. package/dist/cjs/hooks/createWorkspacePolicyHook.cjs +291 -0
  6. package/dist/cjs/hooks/createWorkspacePolicyHook.cjs.map +1 -0
  7. package/dist/cjs/main.cjs +90 -0
  8. package/dist/cjs/main.cjs.map +1 -1
  9. package/dist/cjs/messages/anthropicToolCache.cjs +102 -0
  10. package/dist/cjs/messages/anthropicToolCache.cjs.map +1 -0
  11. package/dist/cjs/messages/prune.cjs +27 -0
  12. package/dist/cjs/messages/prune.cjs.map +1 -1
  13. package/dist/cjs/messages/recency.cjs +99 -0
  14. package/dist/cjs/messages/recency.cjs.map +1 -0
  15. package/dist/cjs/run.cjs +30 -0
  16. package/dist/cjs/run.cjs.map +1 -1
  17. package/dist/cjs/summarization/node.cjs +100 -6
  18. package/dist/cjs/summarization/node.cjs.map +1 -1
  19. package/dist/cjs/tools/ToolNode.cjs +635 -23
  20. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  21. package/dist/cjs/tools/local/CompileCheckTool.cjs +227 -0
  22. package/dist/cjs/tools/local/CompileCheckTool.cjs.map +1 -0
  23. package/dist/cjs/tools/local/FileCheckpointer.cjs +90 -0
  24. package/dist/cjs/tools/local/FileCheckpointer.cjs.map +1 -0
  25. package/dist/cjs/tools/local/LocalCodingTools.cjs +1098 -0
  26. package/dist/cjs/tools/local/LocalCodingTools.cjs.map +1 -0
  27. package/dist/cjs/tools/local/LocalExecutionEngine.cjs +1042 -0
  28. package/dist/cjs/tools/local/LocalExecutionEngine.cjs.map +1 -0
  29. package/dist/cjs/tools/local/LocalExecutionTools.cjs +122 -0
  30. package/dist/cjs/tools/local/LocalExecutionTools.cjs.map +1 -0
  31. package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs +453 -0
  32. package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs.map +1 -0
  33. package/dist/cjs/tools/local/attachments.cjs +183 -0
  34. package/dist/cjs/tools/local/attachments.cjs.map +1 -0
  35. package/dist/cjs/tools/local/bashAst.cjs +129 -0
  36. package/dist/cjs/tools/local/bashAst.cjs.map +1 -0
  37. package/dist/cjs/tools/local/editStrategies.cjs +188 -0
  38. package/dist/cjs/tools/local/editStrategies.cjs.map +1 -0
  39. package/dist/cjs/tools/local/resolveLocalExecutionTools.cjs +141 -0
  40. package/dist/cjs/tools/local/resolveLocalExecutionTools.cjs.map +1 -0
  41. package/dist/cjs/tools/local/syntaxCheck.cjs +182 -0
  42. package/dist/cjs/tools/local/syntaxCheck.cjs.map +1 -0
  43. package/dist/cjs/tools/local/textEncoding.cjs +30 -0
  44. package/dist/cjs/tools/local/textEncoding.cjs.map +1 -0
  45. package/dist/cjs/tools/local/workspaceFS.cjs +51 -0
  46. package/dist/cjs/tools/local/workspaceFS.cjs.map +1 -0
  47. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -0
  48. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  49. package/dist/esm/common/enum.mjs +53 -1
  50. package/dist/esm/common/enum.mjs.map +1 -1
  51. package/dist/esm/graphs/Graph.mjs +149 -5
  52. package/dist/esm/graphs/Graph.mjs.map +1 -1
  53. package/dist/esm/hooks/createWorkspacePolicyHook.mjs +289 -0
  54. package/dist/esm/hooks/createWorkspacePolicyHook.mjs.map +1 -0
  55. package/dist/esm/main.mjs +17 -2
  56. package/dist/esm/main.mjs.map +1 -1
  57. package/dist/esm/messages/anthropicToolCache.mjs +99 -0
  58. package/dist/esm/messages/anthropicToolCache.mjs.map +1 -0
  59. package/dist/esm/messages/prune.mjs +26 -1
  60. package/dist/esm/messages/prune.mjs.map +1 -1
  61. package/dist/esm/messages/recency.mjs +97 -0
  62. package/dist/esm/messages/recency.mjs.map +1 -0
  63. package/dist/esm/run.mjs +30 -0
  64. package/dist/esm/run.mjs.map +1 -1
  65. package/dist/esm/summarization/node.mjs +100 -6
  66. package/dist/esm/summarization/node.mjs.map +1 -1
  67. package/dist/esm/tools/ToolNode.mjs +635 -23
  68. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  69. package/dist/esm/tools/local/CompileCheckTool.mjs +223 -0
  70. package/dist/esm/tools/local/CompileCheckTool.mjs.map +1 -0
  71. package/dist/esm/tools/local/FileCheckpointer.mjs +87 -0
  72. package/dist/esm/tools/local/FileCheckpointer.mjs.map +1 -0
  73. package/dist/esm/tools/local/LocalCodingTools.mjs +1075 -0
  74. package/dist/esm/tools/local/LocalCodingTools.mjs.map +1 -0
  75. package/dist/esm/tools/local/LocalExecutionEngine.mjs +1022 -0
  76. package/dist/esm/tools/local/LocalExecutionEngine.mjs.map +1 -0
  77. package/dist/esm/tools/local/LocalExecutionTools.mjs +117 -0
  78. package/dist/esm/tools/local/LocalExecutionTools.mjs.map +1 -0
  79. package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs +448 -0
  80. package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs.map +1 -0
  81. package/dist/esm/tools/local/attachments.mjs +180 -0
  82. package/dist/esm/tools/local/attachments.mjs.map +1 -0
  83. package/dist/esm/tools/local/bashAst.mjs +126 -0
  84. package/dist/esm/tools/local/bashAst.mjs.map +1 -0
  85. package/dist/esm/tools/local/editStrategies.mjs +185 -0
  86. package/dist/esm/tools/local/editStrategies.mjs.map +1 -0
  87. package/dist/esm/tools/local/resolveLocalExecutionTools.mjs +137 -0
  88. package/dist/esm/tools/local/resolveLocalExecutionTools.mjs.map +1 -0
  89. package/dist/esm/tools/local/syntaxCheck.mjs +179 -0
  90. package/dist/esm/tools/local/syntaxCheck.mjs.map +1 -0
  91. package/dist/esm/tools/local/textEncoding.mjs +27 -0
  92. package/dist/esm/tools/local/textEncoding.mjs.map +1 -0
  93. package/dist/esm/tools/local/workspaceFS.mjs +49 -0
  94. package/dist/esm/tools/local/workspaceFS.mjs.map +1 -0
  95. package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -0
  96. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  97. package/dist/types/common/enum.d.ts +39 -1
  98. package/dist/types/graphs/Graph.d.ts +34 -0
  99. package/dist/types/hooks/createWorkspacePolicyHook.d.ts +95 -0
  100. package/dist/types/hooks/index.d.ts +2 -0
  101. package/dist/types/index.d.ts +1 -0
  102. package/dist/types/messages/anthropicToolCache.d.ts +51 -0
  103. package/dist/types/messages/index.d.ts +2 -0
  104. package/dist/types/messages/prune.d.ts +11 -0
  105. package/dist/types/messages/recency.d.ts +64 -0
  106. package/dist/types/run.d.ts +21 -0
  107. package/dist/types/tools/ToolNode.d.ts +145 -2
  108. package/dist/types/tools/local/CompileCheckTool.d.ts +31 -0
  109. package/dist/types/tools/local/FileCheckpointer.d.ts +39 -0
  110. package/dist/types/tools/local/LocalCodingTools.d.ts +57 -0
  111. package/dist/types/tools/local/LocalExecutionEngine.d.ts +149 -0
  112. package/dist/types/tools/local/LocalExecutionTools.d.ts +9 -0
  113. package/dist/types/tools/local/LocalProgrammaticToolCalling.d.ts +21 -0
  114. package/dist/types/tools/local/attachments.d.ts +84 -0
  115. package/dist/types/tools/local/bashAst.d.ts +11 -0
  116. package/dist/types/tools/local/editStrategies.d.ts +28 -0
  117. package/dist/types/tools/local/index.d.ts +12 -0
  118. package/dist/types/tools/local/resolveLocalExecutionTools.d.ts +38 -0
  119. package/dist/types/tools/local/syntaxCheck.d.ts +42 -0
  120. package/dist/types/tools/local/textEncoding.d.ts +21 -0
  121. package/dist/types/tools/local/workspaceFS.d.ts +49 -0
  122. package/dist/types/types/hitl.d.ts +56 -27
  123. package/dist/types/types/run.d.ts +8 -1
  124. package/dist/types/types/summarize.d.ts +30 -0
  125. package/dist/types/types/tools.d.ts +341 -6
  126. package/package.json +21 -2
  127. package/src/common/enum.ts +54 -0
  128. package/src/graphs/Graph.ts +164 -6
  129. package/src/hooks/__tests__/compactHooks.test.ts +38 -2
  130. package/src/hooks/__tests__/createWorkspacePolicyHook.test.ts +393 -0
  131. package/src/hooks/createWorkspacePolicyHook.ts +355 -0
  132. package/src/hooks/index.ts +6 -0
  133. package/src/index.ts +1 -0
  134. package/src/messages/__tests__/anthropicToolCache.test.ts +125 -0
  135. package/src/messages/__tests__/recency.test.ts +267 -0
  136. package/src/messages/anthropicToolCache.ts +116 -0
  137. package/src/messages/index.ts +2 -0
  138. package/src/messages/prune.ts +27 -1
  139. package/src/messages/recency.ts +155 -0
  140. package/src/run.ts +31 -0
  141. package/src/scripts/compare_pi_vs_ours.ts +840 -0
  142. package/src/scripts/local_engine.ts +166 -0
  143. package/src/scripts/local_engine_checkpointer.ts +205 -0
  144. package/src/scripts/local_engine_compile.ts +263 -0
  145. package/src/scripts/local_engine_hooks.ts +226 -0
  146. package/src/scripts/local_engine_image.ts +201 -0
  147. package/src/scripts/local_engine_ptc.ts +151 -0
  148. package/src/scripts/local_engine_workspace.ts +258 -0
  149. package/src/scripts/summarization-recency.ts +462 -0
  150. package/src/specs/prune.test.ts +39 -0
  151. package/src/summarization/__tests__/node.test.ts +499 -3
  152. package/src/summarization/node.ts +124 -7
  153. package/src/tools/ToolNode.ts +769 -20
  154. package/src/tools/__tests__/LocalExecutionTools.test.ts +2647 -0
  155. package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +175 -0
  156. package/src/tools/__tests__/ToolNode.outputReferences.test.ts +114 -0
  157. package/src/tools/__tests__/ToolNode.session.test.ts +84 -0
  158. package/src/tools/__tests__/directToolHITLResumeScope.test.ts +467 -0
  159. package/src/tools/__tests__/directToolHooks.test.ts +411 -0
  160. package/src/tools/__tests__/localToolNames.test.ts +73 -0
  161. package/src/tools/__tests__/workspaceSeam.test.ts +134 -0
  162. package/src/tools/local/CompileCheckTool.ts +278 -0
  163. package/src/tools/local/FileCheckpointer.ts +93 -0
  164. package/src/tools/local/LocalCodingTools.ts +1342 -0
  165. package/src/tools/local/LocalExecutionEngine.ts +1329 -0
  166. package/src/tools/local/LocalExecutionTools.ts +167 -0
  167. package/src/tools/local/LocalProgrammaticToolCalling.ts +594 -0
  168. package/src/tools/local/__tests__/FileCheckpointer.test.ts +120 -0
  169. package/src/tools/local/__tests__/editStrategies.test.ts +134 -0
  170. package/src/tools/local/attachments.ts +251 -0
  171. package/src/tools/local/bashAst.ts +151 -0
  172. package/src/tools/local/editStrategies.ts +188 -0
  173. package/src/tools/local/index.ts +12 -0
  174. package/src/tools/local/resolveLocalExecutionTools.ts +208 -0
  175. package/src/tools/local/syntaxCheck.ts +243 -0
  176. package/src/tools/local/textEncoding.ts +37 -0
  177. package/src/tools/local/workspaceFS.ts +89 -0
  178. package/src/types/hitl.ts +56 -27
  179. package/src/types/run.ts +12 -1
  180. package/src/types/summarize.ts +31 -0
  181. package/src/types/tools.ts +359 -7
@@ -12,7 +12,19 @@ import { RunnableCallable } from '../utils/run.mjs';
12
12
  import 'ai-tokenizer';
13
13
  import 'zod-to-json-schema';
14
14
  import { executeHooks } from '../hooks/executeHooks.mjs';
15
+ import '../hooks/createWorkspacePolicyHook.mjs';
15
16
  import { ToolOutputReferenceRegistry, buildReferenceKey } from './toolOutputReferences.mjs';
17
+ import './local/CompileCheckTool.mjs';
18
+ import 'path';
19
+ import 'fs/promises';
20
+ import './local/LocalCodingTools.mjs';
21
+ import './local/LocalExecutionEngine.mjs';
22
+ import '@langchain/core/tools';
23
+ import './CodeExecutor.mjs';
24
+ import './BashExecutor.mjs';
25
+ import './local/LocalProgrammaticToolCalling.mjs';
26
+ import { resolveLocalToolRegistry, resolveLocalExecutionTools } from './local/resolveLocalExecutionTools.mjs';
27
+ import './local/attachments.mjs';
16
28
 
17
29
  /**
18
30
  * Helper to check if a value is a Send object
@@ -158,6 +170,17 @@ class ToolNode extends RunnableCallable {
158
170
  toolUsageCount;
159
171
  /** Maps toolCallId → turn captured in runTool, used by handleRunToolCompletions */
160
172
  toolCallTurns = new Map();
173
+ /**
174
+ * `call.id → turn` map dedicated to the direct-path lifecycle so the
175
+ * turn assigned on first entry is REUSED on LangGraph resume.
176
+ * Distinct from `toolCallTurns` (which is cleared at the start of
177
+ * every `run()` to keep per-batch event-dispatch metadata fresh) —
178
+ * the direct path needs stability across re-entries triggered by
179
+ * `interrupt()` resumes (Codex P2 #30). Cleared with the rest of
180
+ * the per-Run state in `clearHeavyState`-equivalent flushes when
181
+ * the Run ends.
182
+ */
183
+ directPathTurns = new Map();
161
184
  /** Tool registry for filtering (lazy computation of programmatic maps) */
162
185
  toolRegistry;
163
186
  /** Cached programmatic tools (computed once on first PTC call) */
@@ -170,6 +193,13 @@ class ToolNode extends RunnableCallable {
170
193
  agentId;
171
194
  /** Tool names that bypass event dispatch and execute directly (e.g., graph-managed handoff tools) */
172
195
  directToolNames;
196
+ /**
197
+ * File checkpointer extracted from the local coding tool bundle when
198
+ * `toolExecution.local.fileCheckpointing === true`. Exposed via
199
+ * {@link getFileCheckpointer}. Undefined when checkpointing is off
200
+ * or the local coding suite isn't bound to this node.
201
+ */
202
+ fileCheckpointer;
173
203
  /** Maximum characters allowed in a single tool result before truncation. */
174
204
  maxToolResultChars;
175
205
  /** Hook registry for PreToolUse/PostToolUse lifecycle hooks */
@@ -192,6 +222,8 @@ class ToolNode extends RunnableCallable {
192
222
  * ToolNode building its own.
193
223
  */
194
224
  toolOutputRegistry;
225
+ /** Run-scoped selection for swapping remote code tools to local executors. */
226
+ toolExecution;
195
227
  /**
196
228
  * Monotonic counter used to mint a unique scope id for anonymous
197
229
  * batches (ones invoked without a `run_id` in
@@ -200,7 +232,7 @@ class ToolNode extends RunnableCallable {
200
232
  * other's in-flight state.
201
233
  */
202
234
  anonBatchCounter = 0;
203
- constructor({ tools, toolMap, name, tags, errorHandler, toolCallStepIds, handleToolErrors, loadRuntimeTools, toolRegistry, sessions, eventDrivenMode, agentId, directToolNames, maxContextTokens, maxToolResultChars, hookRegistry, humanInTheLoop, toolOutputReferences, toolOutputRegistry, }) {
235
+ constructor({ tools, toolMap, name, tags, errorHandler, toolCallStepIds, handleToolErrors, loadRuntimeTools, toolRegistry, sessions, eventDrivenMode, agentId, directToolNames, maxContextTokens, maxToolResultChars, hookRegistry, humanInTheLoop, toolOutputReferences, toolOutputRegistry, toolExecution, fileCheckpointer, }) {
204
236
  super({ name, tags, func: (input, config) => this.run(input, config) });
205
237
  this.toolMap = toolMap ?? new Map(tools.map((tool) => [tool.name, tool]));
206
238
  this.toolCallStepIds = toolCallStepIds;
@@ -208,7 +240,7 @@ class ToolNode extends RunnableCallable {
208
240
  this.loadRuntimeTools = loadRuntimeTools;
209
241
  this.errorHandler = errorHandler;
210
242
  this.toolUsageCount = new Map();
211
- this.toolRegistry = toolRegistry;
243
+ this.toolRegistry = resolveLocalToolRegistry({ toolRegistry, toolExecution });
212
244
  this.sessions = sessions;
213
245
  this.eventDrivenMode = eventDrivenMode ?? false;
214
246
  this.agentId = agentId;
@@ -217,6 +249,14 @@ class ToolNode extends RunnableCallable {
217
249
  maxToolResultChars ?? calculateMaxToolResultChars(maxContextTokens);
218
250
  this.hookRegistry = hookRegistry;
219
251
  this.humanInTheLoop = humanInTheLoop;
252
+ this.toolExecution = toolExecution;
253
+ // Caller-provided checkpointer wins. Graphs use this to share a
254
+ // single per-Run instance across every ToolNode they compile so
255
+ // `Run.rewindFiles()` reaches the same snapshot store regardless
256
+ // of which agent's tool batch ran. Falls through to the bundle's
257
+ // auto-created one when undefined (direct ToolNode construction).
258
+ this.fileCheckpointer = fileCheckpointer;
259
+ this.applyToolExecutionOverrides();
220
260
  /**
221
261
  * Precedence: an explicitly passed `toolOutputRegistry` instance
222
262
  * wins over a config object so a host (`Graph`) can share one
@@ -249,6 +289,57 @@ class ToolNode extends RunnableCallable {
249
289
  _unsafeGetToolOutputRegistry() {
250
290
  return this.toolOutputRegistry;
251
291
  }
292
+ /**
293
+ * Replaces known remote Code API tools with local-process tools when
294
+ * `RunConfig.toolExecution.engine === 'local'`. In event-driven mode those
295
+ * names are also marked direct so the SDK executes them locally instead of
296
+ * dispatching the batch to a host-side remote sandbox handler. When the
297
+ * local coding suite is enabled, this also injects file/search/edit tools.
298
+ */
299
+ applyToolExecutionOverrides() {
300
+ const resolved = resolveLocalExecutionTools({
301
+ toolMap: this.toolMap,
302
+ toolExecution: this.toolExecution,
303
+ fileCheckpointer: this.fileCheckpointer,
304
+ });
305
+ this.toolMap = resolved.toolMap;
306
+ if (resolved.fileCheckpointer != null) {
307
+ this.fileCheckpointer = resolved.fileCheckpointer;
308
+ }
309
+ if (resolved.directToolNames.size === 0) {
310
+ return;
311
+ }
312
+ this.directToolNames = new Set([
313
+ ...(this.directToolNames ?? new Set()),
314
+ ...resolved.directToolNames,
315
+ ]);
316
+ this.programmaticCache = undefined;
317
+ }
318
+ /**
319
+ * Returns the per-Run file checkpointer when
320
+ * `toolExecution.local.fileCheckpointing === true`. Hosts call
321
+ * `rewind()` on the returned object to restore captured pre-write
322
+ * file contents — the standard "undo a tool batch" pattern. Returns
323
+ * undefined when checkpointing is disabled or the local coding suite
324
+ * isn't bound. Manual review (finding E): without this getter, the
325
+ * config flag was a silent no-op outside of direct
326
+ * `createLocalCodingToolBundle()` use.
327
+ */
328
+ getFileCheckpointer() {
329
+ return this.fileCheckpointer;
330
+ }
331
+ /**
332
+ * Flush the per-Run direct-path turn cache. Called by the Graph at
333
+ * end-of-Run via `clearHeavyState`. The map intentionally survives
334
+ * `run()` re-entry so an interrupt + resume reuses the original
335
+ * slot (Codex P2 #30), but it would otherwise grow linearly with
336
+ * tool calls and could collide across Runs if a provider reused
337
+ * call IDs (Codex P2 #33). Hosts can also call this directly if
338
+ * they reuse a ToolNode across batches outside of a Graph.
339
+ */
340
+ clearDirectPathTurns() {
341
+ this.directPathTurns.clear();
342
+ }
252
343
  /**
253
344
  * Returns cached programmatic tools, computing once on first access.
254
345
  * Single iteration builds both toolMap and toolDefs simultaneously.
@@ -287,9 +378,16 @@ class ToolNode extends RunnableCallable {
287
378
  * substitutions. Omit when no registration should occur.
288
379
  */
289
380
  async runTool(call, config, batchContext = {}) {
290
- const { batchIndex, turn, batchScopeId, resolvedArgsByCallId } = batchContext;
381
+ const { batchIndex, turn, batchScopeId, resolvedArgsByCallId, preBatchSnapshot, } = batchContext;
291
382
  const tool = this.toolMap.get(call.name);
292
383
  const registry = this.toolOutputRegistry;
384
+ let resolveFn;
385
+ if (preBatchSnapshot != null) {
386
+ resolveFn = (_runId, args) => preBatchSnapshot.resolve(args);
387
+ }
388
+ else if (registry != null) {
389
+ resolveFn = (runIdArg, args) => registry.resolve(runIdArg, args);
390
+ }
293
391
  /**
294
392
  * Precompute the reference key once per call — captured locally
295
393
  * so concurrent `invoke()` calls on the same ToolNode cannot race
@@ -323,15 +421,25 @@ class ToolNode extends RunnableCallable {
323
421
  * It is intentionally distinct from the outer `turn` parameter
324
422
  * (the batch turn used for ref keys); the latter is captured
325
423
  * before the try block when constructing `refKey`.
424
+ *
425
+ * Prefer the value `runDirectToolWithLifecycleHooks` already
426
+ * incremented (Codex P2 #27) — its hook wants the SAME turn
427
+ * the tool will execute under. When called from a path that
428
+ * doesn't pre-increment (event dispatch, the no-hooks
429
+ * shortcut), do the read+increment here.
326
430
  */
327
- const usageCount = this.toolUsageCount.get(call.name) ?? 0;
328
- this.toolUsageCount.set(call.name, usageCount + 1);
329
- if (call.id != null && call.id !== '') {
330
- this.toolCallTurns.set(call.id, usageCount);
331
- }
431
+ const usageCount = batchContext.usageCount ??
432
+ (() => {
433
+ const next = this.toolUsageCount.get(call.name) ?? 0;
434
+ this.toolUsageCount.set(call.name, next + 1);
435
+ if (call.id != null && call.id !== '') {
436
+ this.toolCallTurns.set(call.id, next);
437
+ }
438
+ return next;
439
+ })();
332
440
  let args = call.args;
333
- if (registry != null) {
334
- const { resolved, unresolved } = registry.resolve(runId, args);
441
+ if (resolveFn != null) {
442
+ const { resolved, unresolved } = resolveFn(runId, args);
335
443
  args = resolved;
336
444
  unresolvedRefs = unresolved;
337
445
  /**
@@ -367,6 +475,19 @@ class ToolNode extends RunnableCallable {
367
475
  ...invokeParams,
368
476
  toolMap,
369
477
  toolDefs,
478
+ // Plumb the hook context into the programmatic-tool path so
479
+ // inner tool calls made via the in-process bridge can run
480
+ // through `PreToolUse` (deny / updatedInput) before reaching
481
+ // the underlying tool. Without this, `run_tools_with_code`
482
+ // bypassed every PreToolUse hook the host registered for
483
+ // the tools it dispatches — including HITL gates on
484
+ // `write_file` / `edit_file` (manual review finding A).
485
+ hookContext: {
486
+ registry: this.hookRegistry,
487
+ runId: config.configurable?.run_id ?? '',
488
+ threadId: config.configurable?.thread_id,
489
+ agentId: this.agentId,
490
+ },
370
491
  };
371
492
  }
372
493
  else if (call.name === Constants.TOOL_SEARCH) {
@@ -396,6 +517,7 @@ class ToolNode extends RunnableCallable {
396
517
  session_id: file.session_id ?? codeSession.session_id,
397
518
  id: file.id,
398
519
  name: file.name,
520
+ ...(file.entity_id != null ? { entity_id: file.entity_id } : {}),
399
521
  }));
400
522
  invokeParams._injected_files = fileRefs;
401
523
  }
@@ -533,6 +655,428 @@ class ToolNode extends RunnableCallable {
533
655
  });
534
656
  }
535
657
  }
658
+ /**
659
+ * Runs a single in-process tool call with the same lifecycle hooks
660
+ * the event-dispatch path fires (`PreToolUse`, `PermissionDenied`,
661
+ * `PostToolUse`, `PostToolUseFailure`). Used for any tool whose
662
+ * implementation lives in the SDK process — i.e. every entry in
663
+ * `directToolNames` — so host-supplied policy hooks gate
664
+ * direct-invoked tools the same way they gate dispatched ones.
665
+ *
666
+ * Fast path: when the registry has none of the relevant events
667
+ * registered for this run, falls through to `runTool` with zero
668
+ * extra work. The hook list is also checked via
669
+ * `hasHookFor(event, runId)`, which performs the registry's own
670
+ * O(1) shortcut.
671
+ *
672
+ * Hook semantics intentionally mirror `dispatchToolEvents` for the
673
+ * single-call case:
674
+ * - `PreToolUse` returning `decision: 'deny'` synthesizes an error
675
+ * `ToolMessage` and fires `PermissionDenied` (observational).
676
+ * - `PreToolUse` returning `decision: 'ask'`:
677
+ * • When `humanInTheLoop.enabled === true`: raises a real
678
+ * `tool_approval` interrupt for this single tool call (the
679
+ * same payload shape the event path produces). On resume:
680
+ * `approve` runs the tool, `reject` blocks via
681
+ * `blockDirectCall`, `respond` returns the host-supplied
682
+ * `responseText` as a synthetic success ToolMessage,
683
+ * `edit` re-runs with edited args. LangGraph re-enters
684
+ * ToolNode.run from the start on resume; the hook fires
685
+ * again and the resume value distinguishes "first ask" from
686
+ * "second pass with decision".
687
+ * • When HITL is off: collapses to a fail-closed deny (matches
688
+ * the rest of the SDK's HITL-disabled default). One-time
689
+ * warning logged so hosts notice the gap.
690
+ * - `PreToolUse.updatedInput` is applied to the call before
691
+ * `runTool` runs; placeholder resolution inside `runTool` is
692
+ * idempotent on already-resolved args.
693
+ * - `PostToolUse.updatedOutput` replaces the returned
694
+ * `ToolMessage` content (preserving id/name/status).
695
+ * - `PostToolUseFailure` fires when `runTool` returns a
696
+ * `ToolMessage` whose `status === 'error'`. Observational only;
697
+ * the error message stays the source of truth.
698
+ *
699
+ * `PostToolBatch` aggregation across direct + dispatched outcomes is
700
+ * a separate concern: `dispatchToolEvents` accumulates batch entries
701
+ * locally and fires `PostToolBatch` at the end of its scope. Wiring
702
+ * direct-call entries into that aggregation crosses the two paths'
703
+ * scopes and is left to a follow-up.
704
+ */
705
+ async runDirectToolWithLifecycleHooks(call, config, batchContext = {}) {
706
+ const runId = config.configurable?.run_id ?? '';
707
+ const hookRegistry = this.hookRegistry;
708
+ const hasPreHook = hookRegistry?.hasHookFor('PreToolUse', runId) === true;
709
+ const hasPostHook = hookRegistry?.hasHookFor('PostToolUse', runId) === true;
710
+ const hasFailureHook = hookRegistry?.hasHookFor('PostToolUseFailure', runId) === true;
711
+ if (hookRegistry == null ||
712
+ (!hasPreHook && !hasPostHook && !hasFailureHook)) {
713
+ return this.runTool(call, config, batchContext);
714
+ }
715
+ const threadId = config.configurable?.thread_id;
716
+ const registryRunId = batchContext.batchScopeId ??
717
+ config.configurable?.run_id;
718
+ // Slot reservation, synchronous, before any await:
719
+ // 1. If this call.id already has a recorded turn (from a prior
720
+ // entry that asked / interrupted), REUSE it. LangGraph
721
+ // re-runs the entire ToolNode on resume, so the same call
722
+ // can hit this code multiple times — incrementing on each
723
+ // pass would push the eventual approved execution to
724
+ // `turn=N` instead of `turn=0` (Codex P2 #30: the fix from
725
+ // P2 #27 over-incremented across re-entries).
726
+ // 2. Otherwise reserve the next slot from the counter. Done
727
+ // synchronously so concurrent same-tool calls in a single
728
+ // Promise.all batch get distinct turns (the original P2 #27
729
+ // requirement still holds).
730
+ // Net: turns are stable per call.id across interrupt/resume,
731
+ // unique per call within a batch.
732
+ let usageCount;
733
+ // Look in the resume-stable map first; fall back to the
734
+ // per-batch one. (`directPathTurns` is set on first entry and
735
+ // survives `run()`'s clear, so a resume sees the original
736
+ // assignment.)
737
+ const cachedTurn = call.id != null && call.id !== ''
738
+ ? this.directPathTurns.get(call.id) ??
739
+ this.toolCallTurns.get(call.id)
740
+ : undefined;
741
+ if (cachedTurn != null) {
742
+ usageCount = cachedTurn;
743
+ }
744
+ else {
745
+ usageCount = this.toolUsageCount.get(call.name) ?? 0;
746
+ this.toolUsageCount.set(call.name, usageCount + 1);
747
+ if (call.id != null && call.id !== '') {
748
+ this.toolCallTurns.set(call.id, usageCount);
749
+ // Dedicated direct-path map that SURVIVES `run()`'s
750
+ // toolCallTurns.clear() — so a re-entry triggered by
751
+ // LangGraph interrupt resume reuses this slot instead of
752
+ // re-incrementing. Codex P2 #30.
753
+ this.directPathTurns.set(call.id, usageCount);
754
+ }
755
+ }
756
+ const turn = usageCount;
757
+ const stepId = this.toolCallStepIds?.get(call.id ?? '') ?? '';
758
+ // Use the caller-threaded snapshot when available (P1 #18) so the
759
+ // value the PreToolUse hook observes matches the value the
760
+ // (later-awaited) `runTool` will actually run with — both are
761
+ // anchored to the pre-batch registry state.
762
+ let resolvedArgs = call.args;
763
+ if (batchContext.preBatchSnapshot != null) {
764
+ const { resolved } = batchContext.preBatchSnapshot.resolve(call.args);
765
+ resolvedArgs = resolved;
766
+ }
767
+ else if (this.toolOutputRegistry != null) {
768
+ const { resolved } = this.toolOutputRegistry.resolve(registryRunId, call.args);
769
+ resolvedArgs = resolved;
770
+ }
771
+ let effectiveCall = call;
772
+ if (hasPreHook) {
773
+ const preResult = await executeHooks({
774
+ registry: hookRegistry,
775
+ input: {
776
+ hook_event_name: 'PreToolUse',
777
+ runId,
778
+ threadId,
779
+ agentId: this.agentId,
780
+ toolName: call.name,
781
+ toolInput: resolvedArgs,
782
+ toolUseId: call.id ?? '',
783
+ stepId,
784
+ turn,
785
+ },
786
+ sessionId: runId,
787
+ matchQuery: call.name,
788
+ }).catch(() => undefined);
789
+ if (preResult != null) {
790
+ // Forward any additionalContext strings hooks returned into
791
+ // the per-batch sink so the caller materializes them as a
792
+ // HumanMessage for the next model turn — same shape as the
793
+ // event-driven path's `injected[]`. Codex P2 #39.
794
+ if (batchContext.additionalContextsSink != null &&
795
+ preResult.additionalContexts.length > 0) {
796
+ batchContext.additionalContextsSink.push(...preResult.additionalContexts);
797
+ }
798
+ // Apply any input rewrite first — `ask`-with-`updatedInput` is
799
+ // a valid combination (one matcher sanitises args, another asks
800
+ // for approval); the reviewer should see the sanitised args.
801
+ if (preResult.updatedInput != null) {
802
+ effectiveCall = {
803
+ ...call,
804
+ args: preResult.updatedInput,
805
+ };
806
+ }
807
+ if (preResult.decision === 'deny') {
808
+ return this.blockDirectCall({
809
+ call,
810
+ resolvedArgs,
811
+ reason: preResult.reason ?? 'Blocked by hook',
812
+ hookRegistry,
813
+ runId,
814
+ threadId,
815
+ });
816
+ }
817
+ if (preResult.decision === 'ask') {
818
+ if (this.humanInTheLoop?.enabled !== true) {
819
+ // Fail-closed: no HITL UI configured, so we can't actually
820
+ // ask. Logged once via the existing helper.
821
+ const reason = this.resolveAskDecisionForDirectTool(preResult.reason, call.name);
822
+ return this.blockDirectCall({
823
+ call,
824
+ resolvedArgs,
825
+ reason,
826
+ hookRegistry,
827
+ runId,
828
+ threadId,
829
+ });
830
+ }
831
+ // Raise a single-tool tool_approval interrupt. LangGraph
832
+ // throws on the first execution (host gets the interrupt)
833
+ // and returns the resume value on re-entry. Because direct
834
+ // tools re-enter the entire ToolNode.run on resume, the
835
+ // PreToolUse hook fires AGAIN — which is fine: the hook is
836
+ // expected to be deterministic, and the resume value is what
837
+ // distinguishes "first call asking" from "second call after
838
+ // approve/reject". We anchor `interrupt()` against the
839
+ // node's RunnableConfig the same way `dispatchToolEvents`
840
+ // does (ToolNode disables LangSmith tracing, so the
841
+ // AsyncLocalStorage frame must be re-established here).
842
+ const askEntry = {
843
+ entry: {
844
+ call: effectiveCall,
845
+ args: effectiveCall.args,
846
+ stepId,
847
+ },
848
+ reason: preResult.reason,
849
+ allowedDecisions: preResult.allowedDecisions,
850
+ };
851
+ const payload = buildToolApprovalInterruptPayload([askEntry]);
852
+ const resumeValue = AsyncLocalStorageProviderSingleton.runWithConfig(config, () => interrupt(payload));
853
+ const decisionByCallId = normalizeApprovalDecisions([call.id], resumeValue);
854
+ const decision = decisionByCallId.get(call.id) ?? {
855
+ type: 'reject',
856
+ reason: 'No decision provided for tool approval',
857
+ };
858
+ const declaredType = decision.type;
859
+ if (preResult.allowedDecisions != null &&
860
+ (typeof declaredType !== 'string' ||
861
+ !preResult.allowedDecisions.includes(declaredType))) {
862
+ return this.blockDirectCall({
863
+ call,
864
+ resolvedArgs,
865
+ reason: `Decision "${typeof declaredType === 'string' ? declaredType : '<missing>'}" not in allowedDecisions [${preResult.allowedDecisions.join(', ')}] — failing closed`,
866
+ hookRegistry,
867
+ runId,
868
+ threadId,
869
+ });
870
+ }
871
+ if (decision.type === 'reject') {
872
+ return this.blockDirectCall({
873
+ call,
874
+ resolvedArgs,
875
+ reason: decision.reason ??
876
+ preResult.reason ??
877
+ 'Rejected by user',
878
+ hookRegistry,
879
+ runId,
880
+ threadId,
881
+ });
882
+ }
883
+ if (decision.type === 'respond') {
884
+ const responseText = decision
885
+ .responseText;
886
+ if (typeof responseText !== 'string') {
887
+ return this.blockDirectCall({
888
+ call,
889
+ resolvedArgs,
890
+ reason: 'Approval payload `respond` was missing a string `responseText`',
891
+ hookRegistry,
892
+ runId,
893
+ threadId,
894
+ });
895
+ }
896
+ return new ToolMessage({
897
+ status: 'success',
898
+ content: responseText,
899
+ name: call.name,
900
+ tool_call_id: call.id ?? '',
901
+ });
902
+ }
903
+ if (decision.type === 'edit') {
904
+ // Mirror the event-driven path's validation
905
+ // (see `dispatchToolEvents`'s edit branch). The wire
906
+ // field is `updatedInput`, NOT `args` — hosts following
907
+ // the documented `ToolApprovalDecision` shape were
908
+ // silently ignored before, so the tool ran with the
909
+ // original (un-edited) arguments. Fail closed on
910
+ // malformed payloads instead of falling through with
911
+ // undefined args.
912
+ const updatedInput = decision
913
+ .updatedInput;
914
+ if (updatedInput === null ||
915
+ typeof updatedInput !== 'object' ||
916
+ Array.isArray(updatedInput)) {
917
+ return new ToolMessage({
918
+ status: 'error',
919
+ content: 'Decision "edit" missing object updatedInput — failing closed.',
920
+ name: call.name,
921
+ tool_call_id: call.id ?? '',
922
+ });
923
+ }
924
+ effectiveCall = {
925
+ ...call,
926
+ args: updatedInput,
927
+ };
928
+ // fall through to executing the edited call
929
+ }
930
+ // 'approve' (or 'edit' after applying edits) → fall through
931
+ }
932
+ }
933
+ }
934
+ const output = await this.runTool(effectiveCall, config, {
935
+ ...batchContext,
936
+ usageCount,
937
+ });
938
+ if (!(output instanceof ToolMessage)) {
939
+ return output;
940
+ }
941
+ if (output.status === 'error' && hasFailureHook) {
942
+ // Await the failure hook (instead of fire-and-forget) so we
943
+ // can capture additionalContexts before returning. The hook is
944
+ // still observational w.r.t. the tool result itself — we don't
945
+ // mutate `output`, just plumb the contexts. Codex P2 #39.
946
+ const failureResult = await executeHooks({
947
+ registry: hookRegistry,
948
+ input: {
949
+ hook_event_name: 'PostToolUseFailure',
950
+ runId,
951
+ threadId,
952
+ agentId: this.agentId,
953
+ toolName: call.name,
954
+ toolInput: effectiveCall.args,
955
+ toolUseId: call.id ?? '',
956
+ error: typeof output.content === 'string'
957
+ ? output.content
958
+ : JSON.stringify(output.content),
959
+ stepId,
960
+ turn,
961
+ },
962
+ sessionId: runId,
963
+ matchQuery: call.name,
964
+ }).catch(() => undefined);
965
+ if (failureResult != null &&
966
+ batchContext.additionalContextsSink != null &&
967
+ failureResult.additionalContexts.length > 0) {
968
+ batchContext.additionalContextsSink.push(...failureResult.additionalContexts);
969
+ }
970
+ return output;
971
+ }
972
+ if (output.status !== 'error' && hasPostHook) {
973
+ const postResult = await executeHooks({
974
+ registry: hookRegistry,
975
+ input: {
976
+ hook_event_name: 'PostToolUse',
977
+ runId,
978
+ threadId,
979
+ agentId: this.agentId,
980
+ toolName: call.name,
981
+ toolInput: effectiveCall.args,
982
+ toolOutput: output.content,
983
+ toolUseId: call.id ?? '',
984
+ stepId,
985
+ turn,
986
+ },
987
+ sessionId: runId,
988
+ matchQuery: call.name,
989
+ }).catch(() => undefined);
990
+ // Forward additionalContexts from the PostToolUse hook into
991
+ // the per-batch sink (Codex P2 #39).
992
+ if (postResult != null &&
993
+ batchContext.additionalContextsSink != null &&
994
+ postResult.additionalContexts.length > 0) {
995
+ batchContext.additionalContextsSink.push(...postResult.additionalContexts);
996
+ }
997
+ if (postResult?.updatedOutput != null) {
998
+ const replaced = typeof postResult.updatedOutput === 'string'
999
+ ? postResult.updatedOutput
1000
+ : JSON.stringify(postResult.updatedOutput);
1001
+ // Keep the tool-output registry in sync with what the model
1002
+ // actually sees. Without this, `runTool` already registered
1003
+ // the PRE-hook content under `_refKey`, and a later
1004
+ // `{{tool<i>turn<n>}}` substitution would deliver the stale
1005
+ // pre-hook bytes while the model (and downstream tools)
1006
+ // observed the post-hook replacement. Read `_refKey` /
1007
+ // `_refScope` straight off the message metadata that
1008
+ // `recordOutputReference` stamped — no need to re-derive
1009
+ // (and we couldn't, for anonymous-batch synthetic scopes).
1010
+ const refMeta = output.additional_kwargs;
1011
+ const refKey = refMeta?._refKey;
1012
+ const refScope = refMeta?._refScope;
1013
+ if (this.toolOutputRegistry != null && refKey != null) {
1014
+ this.toolOutputRegistry.set(refScope, refKey, replaced);
1015
+ }
1016
+ return new ToolMessage({
1017
+ status: output.status,
1018
+ name: output.name,
1019
+ content: replaced,
1020
+ artifact: output.artifact,
1021
+ tool_call_id: output.tool_call_id,
1022
+ additional_kwargs: output.additional_kwargs,
1023
+ });
1024
+ }
1025
+ }
1026
+ return output;
1027
+ }
1028
+ /**
1029
+ * `ask` decisions on direct-path tools collapse to fail-closed deny
1030
+ * only when `humanInTheLoop.enabled !== true` (i.e. there's no host
1031
+ * UI configured to actually prompt the user). Logged once per process
1032
+ * so the gap is visible. When HITL IS enabled, `ask` raises a real
1033
+ * LangGraph `interrupt()` instead — see `runDirectToolWithLifecycleHooks`.
1034
+ */
1035
+ askDirectWarningEmitted = false;
1036
+ resolveAskDecisionForDirectTool(reason, toolName) {
1037
+ if (!this.askDirectWarningEmitted) {
1038
+ this.askDirectWarningEmitted = true;
1039
+ // eslint-disable-next-line no-console
1040
+ console.warn(`[ToolNode] PreToolUse returned 'ask' for direct-path tool "${toolName}" but ` +
1041
+ 'humanInTheLoop is not enabled — failing closed. Set humanInTheLoop.enabled=true ' +
1042
+ 'to raise a tool_approval interrupt the host can resolve.');
1043
+ }
1044
+ return reason ?? 'Blocked by hook';
1045
+ }
1046
+ /**
1047
+ * Synthesize a Blocked ToolMessage AND fire `PermissionDenied`
1048
+ * (observational) for a direct-path tool call. Centralised so the
1049
+ * deny path looks identical whether the block came from `'deny'` or
1050
+ * from a fail-closed/`'reject'`/policy-violation path.
1051
+ */
1052
+ blockDirectCall(args) {
1053
+ const { call, resolvedArgs, reason, hookRegistry, runId, threadId } = args;
1054
+ if (hookRegistry.hasHookFor('PermissionDenied', runId) === true) {
1055
+ executeHooks({
1056
+ registry: hookRegistry,
1057
+ input: {
1058
+ hook_event_name: 'PermissionDenied',
1059
+ runId,
1060
+ threadId,
1061
+ agentId: this.agentId,
1062
+ toolName: call.name,
1063
+ toolInput: resolvedArgs,
1064
+ toolUseId: call.id ?? '',
1065
+ reason,
1066
+ },
1067
+ sessionId: runId,
1068
+ matchQuery: call.name,
1069
+ }).catch(() => {
1070
+ /* observational */
1071
+ });
1072
+ }
1073
+ return new ToolMessage({
1074
+ status: 'error',
1075
+ content: `Blocked: ${reason}`,
1076
+ name: call.name,
1077
+ tool_call_id: call.id ?? '',
1078
+ });
1079
+ }
536
1080
  /**
537
1081
  * Registers the full, raw output under `refKey` (when provided) and
538
1082
  * builds the per-message ref metadata stamped onto the resulting
@@ -597,6 +1141,7 @@ class ToolNode extends RunnableCallable {
597
1141
  session_id: file.session_id ?? codeSession.session_id,
598
1142
  id: file.id,
599
1143
  name: file.name,
1144
+ ...(file.entity_id != null ? { entity_id: file.entity_id } : {}),
600
1145
  }));
601
1146
  }
602
1147
  return context;
@@ -615,7 +1160,8 @@ class ToolNode extends RunnableCallable {
615
1160
  continue;
616
1161
  }
617
1162
  const request = requestMap.get(result.toolCallId);
618
- if (!request?.name ||
1163
+ if (request?.name == null ||
1164
+ request.name === '' ||
619
1165
  (!CODE_EXECUTION_TOOLS.has(request.name) &&
620
1166
  request.name !== Constants.SKILL_TOOL)) {
621
1167
  continue;
@@ -1527,15 +2073,37 @@ class ToolNode extends RunnableCallable {
1527
2073
  batchScopeId,
1528
2074
  });
1529
2075
  }
1530
- outputs = [
1531
- await this.runTool(input.lg_tool_call, config, {
1532
- batchIndex: 0,
1533
- turn,
1534
- batchScopeId,
1535
- resolvedArgsByCallId,
1536
- }),
1537
- ];
1538
- this.handleRunToolCompletions([input.lg_tool_call], outputs, config, resolvedArgsByCallId);
2076
+ // Same per-batch sink the message-state branches use so
2077
+ // direct-path PreToolUse/PostToolUse/Failure additionalContexts
2078
+ // surface here too. Codex P2 [44] — round 14 added the sink to
2079
+ // both message-state branches but missed this Send-input
2080
+ // branch, so direct tools dispatched via Send (a supported
2081
+ // input shape) still silently dropped hook context.
2082
+ const directAdditionalContexts = [];
2083
+ const sendOutput = await this.runDirectToolWithLifecycleHooks(input.lg_tool_call, config, {
2084
+ batchIndex: 0,
2085
+ turn,
2086
+ batchScopeId,
2087
+ resolvedArgsByCallId,
2088
+ additionalContextsSink: directAdditionalContexts,
2089
+ });
2090
+ outputs =
2091
+ directAdditionalContexts.length > 0
2092
+ ? [
2093
+ sendOutput,
2094
+ new HumanMessage({
2095
+ content: directAdditionalContexts.join('\n\n'),
2096
+ // Match the event-driven path's marker so hosts /
2097
+ // model-side annotators treat this as system intent
2098
+ // rather than ordinary user text. Codex P2 [46].
2099
+ additional_kwargs: { role: 'system', source: 'hook' },
2100
+ }),
2101
+ ]
2102
+ : [sendOutput];
2103
+ this.handleRunToolCompletions([input.lg_tool_call],
2104
+ // Pass only the tool output to completion handling; the
2105
+ // HumanMessage isn't a tool result.
2106
+ [sendOutput], config, resolvedArgsByCallId);
1539
2107
  }
1540
2108
  else {
1541
2109
  let messages;
@@ -1566,6 +2134,7 @@ class ToolNode extends RunnableCallable {
1566
2134
  const { tools, toolMap } = this.loadRuntimeTools(aiMessage.tool_calls ?? []);
1567
2135
  this.toolMap =
1568
2136
  toolMap ?? new Map(tools.map((tool) => [tool.name, tool]));
2137
+ this.applyToolExecutionOverrides();
1569
2138
  this.programmaticCache = undefined; // Invalidate cache on toolMap change
1570
2139
  }
1571
2140
  const filteredCalls = aiMessage.tool_calls?.filter((call) => {
@@ -1637,12 +2206,19 @@ class ToolNode extends RunnableCallable {
1637
2206
  }
1638
2207
  }
1639
2208
  }
2209
+ // Per-batch sink for direct-path hook additionalContexts
2210
+ // (Codex P2 #39). Materialized as a HumanMessage at end-of-
2211
+ // batch so the next model turn sees the injected context,
2212
+ // matching the event path's `injected[]` shape.
2213
+ const directAdditionalContexts = [];
1640
2214
  const directOutputs = directCalls.length > 0
1641
- ? await Promise.all(directCalls.map((call, i) => this.runTool(call, config, {
2215
+ ? await Promise.all(directCalls.map((call, i) => this.runDirectToolWithLifecycleHooks(call, config, {
1642
2216
  batchIndex: directIndices[i],
1643
2217
  turn,
1644
2218
  batchScopeId,
1645
2219
  resolvedArgsByCallId,
2220
+ preBatchSnapshot,
2221
+ additionalContextsSink: directAdditionalContexts,
1646
2222
  })))
1647
2223
  : [];
1648
2224
  if (directCalls.length > 0 && directOutputs.length > 0) {
@@ -1660,20 +2236,56 @@ class ToolNode extends RunnableCallable {
1660
2236
  toolMessages: [],
1661
2237
  injected: [],
1662
2238
  };
2239
+ const directInjected = directAdditionalContexts.length > 0
2240
+ ? [
2241
+ new HumanMessage({
2242
+ content: directAdditionalContexts.join('\n\n'),
2243
+ // System-role metadata to match the event-driven
2244
+ // path so policy/recovery guidance is treated
2245
+ // consistently regardless of whether the tool ran
2246
+ // direct or dispatched. Codex P2 [46].
2247
+ additional_kwargs: { role: 'system', source: 'hook' },
2248
+ }),
2249
+ ]
2250
+ : [];
1663
2251
  outputs = [
1664
2252
  ...directOutputs,
1665
2253
  ...eventResult.toolMessages,
2254
+ ...directInjected,
1666
2255
  ...eventResult.injected,
1667
2256
  ];
1668
2257
  }
1669
2258
  else {
1670
- outputs = await Promise.all(filteredCalls.map((call, i) => this.runTool(call, config, {
2259
+ // Same per-batch pre-snapshot as the mixed path, applied to
2260
+ // the all-direct case so `Promise.all`-induced ordering can't
2261
+ // leak a sibling's just-registered output into a sister
2262
+ // call's args mid-await (Codex P1 #18).
2263
+ const preBatchSnapshot = this.toolOutputRegistry?.snapshot(batchScopeId);
2264
+ const directAdditionalContexts = [];
2265
+ const toolOutputs = await Promise.all(filteredCalls.map((call, i) => this.runDirectToolWithLifecycleHooks(call, config, {
1671
2266
  batchIndex: i,
1672
2267
  turn,
1673
2268
  batchScopeId,
1674
2269
  resolvedArgsByCallId,
2270
+ preBatchSnapshot,
2271
+ additionalContextsSink: directAdditionalContexts,
1675
2272
  })));
1676
- this.handleRunToolCompletions(filteredCalls, outputs, config, resolvedArgsByCallId);
2273
+ this.handleRunToolCompletions(filteredCalls, toolOutputs, config, resolvedArgsByCallId);
2274
+ // Append accumulated additionalContexts as a single
2275
+ // HumanMessage so the next model turn sees them. Codex P2 #39.
2276
+ outputs =
2277
+ directAdditionalContexts.length > 0
2278
+ ? [
2279
+ ...toolOutputs,
2280
+ new HumanMessage({
2281
+ content: directAdditionalContexts.join('\n\n'),
2282
+ // Same system-role marker the event-driven path
2283
+ // uses so direct vs dispatched is invisible to
2284
+ // downstream consumers. Codex P2 [46].
2285
+ additional_kwargs: { role: 'system', source: 'hook' },
2286
+ }),
2287
+ ]
2288
+ : toolOutputs;
1677
2289
  }
1678
2290
  }
1679
2291
  if (!outputs.some(isCommand)) {