@librechat/agents 3.1.77-dev.1 → 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 (188) 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/llm/openai/index.cjs +317 -1
  8. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  9. package/dist/cjs/main.cjs +90 -0
  10. package/dist/cjs/main.cjs.map +1 -1
  11. package/dist/cjs/messages/anthropicToolCache.cjs +102 -0
  12. package/dist/cjs/messages/anthropicToolCache.cjs.map +1 -0
  13. package/dist/cjs/messages/prune.cjs +27 -0
  14. package/dist/cjs/messages/prune.cjs.map +1 -1
  15. package/dist/cjs/messages/recency.cjs +99 -0
  16. package/dist/cjs/messages/recency.cjs.map +1 -0
  17. package/dist/cjs/run.cjs +30 -0
  18. package/dist/cjs/run.cjs.map +1 -1
  19. package/dist/cjs/summarization/node.cjs +100 -6
  20. package/dist/cjs/summarization/node.cjs.map +1 -1
  21. package/dist/cjs/tools/ToolNode.cjs +635 -23
  22. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  23. package/dist/cjs/tools/local/CompileCheckTool.cjs +227 -0
  24. package/dist/cjs/tools/local/CompileCheckTool.cjs.map +1 -0
  25. package/dist/cjs/tools/local/FileCheckpointer.cjs +90 -0
  26. package/dist/cjs/tools/local/FileCheckpointer.cjs.map +1 -0
  27. package/dist/cjs/tools/local/LocalCodingTools.cjs +1098 -0
  28. package/dist/cjs/tools/local/LocalCodingTools.cjs.map +1 -0
  29. package/dist/cjs/tools/local/LocalExecutionEngine.cjs +1042 -0
  30. package/dist/cjs/tools/local/LocalExecutionEngine.cjs.map +1 -0
  31. package/dist/cjs/tools/local/LocalExecutionTools.cjs +122 -0
  32. package/dist/cjs/tools/local/LocalExecutionTools.cjs.map +1 -0
  33. package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs +453 -0
  34. package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs.map +1 -0
  35. package/dist/cjs/tools/local/attachments.cjs +183 -0
  36. package/dist/cjs/tools/local/attachments.cjs.map +1 -0
  37. package/dist/cjs/tools/local/bashAst.cjs +129 -0
  38. package/dist/cjs/tools/local/bashAst.cjs.map +1 -0
  39. package/dist/cjs/tools/local/editStrategies.cjs +188 -0
  40. package/dist/cjs/tools/local/editStrategies.cjs.map +1 -0
  41. package/dist/cjs/tools/local/resolveLocalExecutionTools.cjs +141 -0
  42. package/dist/cjs/tools/local/resolveLocalExecutionTools.cjs.map +1 -0
  43. package/dist/cjs/tools/local/syntaxCheck.cjs +182 -0
  44. package/dist/cjs/tools/local/syntaxCheck.cjs.map +1 -0
  45. package/dist/cjs/tools/local/textEncoding.cjs +30 -0
  46. package/dist/cjs/tools/local/textEncoding.cjs.map +1 -0
  47. package/dist/cjs/tools/local/workspaceFS.cjs +51 -0
  48. package/dist/cjs/tools/local/workspaceFS.cjs.map +1 -0
  49. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -0
  50. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  51. package/dist/esm/common/enum.mjs +53 -1
  52. package/dist/esm/common/enum.mjs.map +1 -1
  53. package/dist/esm/graphs/Graph.mjs +149 -5
  54. package/dist/esm/graphs/Graph.mjs.map +1 -1
  55. package/dist/esm/hooks/createWorkspacePolicyHook.mjs +289 -0
  56. package/dist/esm/hooks/createWorkspacePolicyHook.mjs.map +1 -0
  57. package/dist/esm/llm/openai/index.mjs +318 -2
  58. package/dist/esm/llm/openai/index.mjs.map +1 -1
  59. package/dist/esm/main.mjs +17 -2
  60. package/dist/esm/main.mjs.map +1 -1
  61. package/dist/esm/messages/anthropicToolCache.mjs +99 -0
  62. package/dist/esm/messages/anthropicToolCache.mjs.map +1 -0
  63. package/dist/esm/messages/prune.mjs +26 -1
  64. package/dist/esm/messages/prune.mjs.map +1 -1
  65. package/dist/esm/messages/recency.mjs +97 -0
  66. package/dist/esm/messages/recency.mjs.map +1 -0
  67. package/dist/esm/run.mjs +30 -0
  68. package/dist/esm/run.mjs.map +1 -1
  69. package/dist/esm/summarization/node.mjs +100 -6
  70. package/dist/esm/summarization/node.mjs.map +1 -1
  71. package/dist/esm/tools/ToolNode.mjs +635 -23
  72. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  73. package/dist/esm/tools/local/CompileCheckTool.mjs +223 -0
  74. package/dist/esm/tools/local/CompileCheckTool.mjs.map +1 -0
  75. package/dist/esm/tools/local/FileCheckpointer.mjs +87 -0
  76. package/dist/esm/tools/local/FileCheckpointer.mjs.map +1 -0
  77. package/dist/esm/tools/local/LocalCodingTools.mjs +1075 -0
  78. package/dist/esm/tools/local/LocalCodingTools.mjs.map +1 -0
  79. package/dist/esm/tools/local/LocalExecutionEngine.mjs +1022 -0
  80. package/dist/esm/tools/local/LocalExecutionEngine.mjs.map +1 -0
  81. package/dist/esm/tools/local/LocalExecutionTools.mjs +117 -0
  82. package/dist/esm/tools/local/LocalExecutionTools.mjs.map +1 -0
  83. package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs +448 -0
  84. package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs.map +1 -0
  85. package/dist/esm/tools/local/attachments.mjs +180 -0
  86. package/dist/esm/tools/local/attachments.mjs.map +1 -0
  87. package/dist/esm/tools/local/bashAst.mjs +126 -0
  88. package/dist/esm/tools/local/bashAst.mjs.map +1 -0
  89. package/dist/esm/tools/local/editStrategies.mjs +185 -0
  90. package/dist/esm/tools/local/editStrategies.mjs.map +1 -0
  91. package/dist/esm/tools/local/resolveLocalExecutionTools.mjs +137 -0
  92. package/dist/esm/tools/local/resolveLocalExecutionTools.mjs.map +1 -0
  93. package/dist/esm/tools/local/syntaxCheck.mjs +179 -0
  94. package/dist/esm/tools/local/syntaxCheck.mjs.map +1 -0
  95. package/dist/esm/tools/local/textEncoding.mjs +27 -0
  96. package/dist/esm/tools/local/textEncoding.mjs.map +1 -0
  97. package/dist/esm/tools/local/workspaceFS.mjs +49 -0
  98. package/dist/esm/tools/local/workspaceFS.mjs.map +1 -0
  99. package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -0
  100. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  101. package/dist/types/common/enum.d.ts +39 -1
  102. package/dist/types/graphs/Graph.d.ts +34 -0
  103. package/dist/types/hooks/createWorkspacePolicyHook.d.ts +95 -0
  104. package/dist/types/hooks/index.d.ts +2 -0
  105. package/dist/types/index.d.ts +1 -0
  106. package/dist/types/llm/openai/index.d.ts +17 -0
  107. package/dist/types/messages/anthropicToolCache.d.ts +51 -0
  108. package/dist/types/messages/index.d.ts +2 -0
  109. package/dist/types/messages/prune.d.ts +11 -0
  110. package/dist/types/messages/recency.d.ts +64 -0
  111. package/dist/types/run.d.ts +21 -0
  112. package/dist/types/tools/ToolNode.d.ts +145 -2
  113. package/dist/types/tools/local/CompileCheckTool.d.ts +31 -0
  114. package/dist/types/tools/local/FileCheckpointer.d.ts +39 -0
  115. package/dist/types/tools/local/LocalCodingTools.d.ts +57 -0
  116. package/dist/types/tools/local/LocalExecutionEngine.d.ts +149 -0
  117. package/dist/types/tools/local/LocalExecutionTools.d.ts +9 -0
  118. package/dist/types/tools/local/LocalProgrammaticToolCalling.d.ts +21 -0
  119. package/dist/types/tools/local/attachments.d.ts +84 -0
  120. package/dist/types/tools/local/bashAst.d.ts +11 -0
  121. package/dist/types/tools/local/editStrategies.d.ts +28 -0
  122. package/dist/types/tools/local/index.d.ts +12 -0
  123. package/dist/types/tools/local/resolveLocalExecutionTools.d.ts +38 -0
  124. package/dist/types/tools/local/syntaxCheck.d.ts +42 -0
  125. package/dist/types/tools/local/textEncoding.d.ts +21 -0
  126. package/dist/types/tools/local/workspaceFS.d.ts +49 -0
  127. package/dist/types/types/hitl.d.ts +56 -27
  128. package/dist/types/types/run.d.ts +8 -1
  129. package/dist/types/types/summarize.d.ts +30 -0
  130. package/dist/types/types/tools.d.ts +341 -6
  131. package/package.json +21 -2
  132. package/src/common/enum.ts +54 -0
  133. package/src/graphs/Graph.ts +164 -6
  134. package/src/hooks/__tests__/compactHooks.test.ts +38 -2
  135. package/src/hooks/__tests__/createWorkspacePolicyHook.test.ts +393 -0
  136. package/src/hooks/createWorkspacePolicyHook.ts +355 -0
  137. package/src/hooks/index.ts +6 -0
  138. package/src/index.ts +1 -0
  139. package/src/llm/openai/deepseek.test.ts +479 -0
  140. package/src/llm/openai/index.ts +484 -1
  141. package/src/messages/__tests__/anthropicToolCache.test.ts +125 -0
  142. package/src/messages/__tests__/recency.test.ts +267 -0
  143. package/src/messages/anthropicToolCache.ts +116 -0
  144. package/src/messages/index.ts +2 -0
  145. package/src/messages/prune.ts +27 -1
  146. package/src/messages/recency.ts +155 -0
  147. package/src/run.ts +31 -0
  148. package/src/scripts/compare_pi_vs_ours.ts +840 -0
  149. package/src/scripts/local_engine.ts +166 -0
  150. package/src/scripts/local_engine_checkpointer.ts +205 -0
  151. package/src/scripts/local_engine_compile.ts +263 -0
  152. package/src/scripts/local_engine_hooks.ts +226 -0
  153. package/src/scripts/local_engine_image.ts +201 -0
  154. package/src/scripts/local_engine_ptc.ts +151 -0
  155. package/src/scripts/local_engine_workspace.ts +258 -0
  156. package/src/scripts/summarization-recency.ts +462 -0
  157. package/src/specs/prune.test.ts +39 -0
  158. package/src/summarization/__tests__/node.test.ts +499 -3
  159. package/src/summarization/node.ts +124 -7
  160. package/src/tools/ToolNode.ts +769 -20
  161. package/src/tools/__tests__/LocalExecutionTools.test.ts +2647 -0
  162. package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +175 -0
  163. package/src/tools/__tests__/ToolNode.outputReferences.test.ts +114 -0
  164. package/src/tools/__tests__/ToolNode.session.test.ts +84 -0
  165. package/src/tools/__tests__/directToolHITLResumeScope.test.ts +467 -0
  166. package/src/tools/__tests__/directToolHooks.test.ts +411 -0
  167. package/src/tools/__tests__/localToolNames.test.ts +73 -0
  168. package/src/tools/__tests__/workspaceSeam.test.ts +134 -0
  169. package/src/tools/local/CompileCheckTool.ts +278 -0
  170. package/src/tools/local/FileCheckpointer.ts +93 -0
  171. package/src/tools/local/LocalCodingTools.ts +1342 -0
  172. package/src/tools/local/LocalExecutionEngine.ts +1329 -0
  173. package/src/tools/local/LocalExecutionTools.ts +167 -0
  174. package/src/tools/local/LocalProgrammaticToolCalling.ts +594 -0
  175. package/src/tools/local/__tests__/FileCheckpointer.test.ts +120 -0
  176. package/src/tools/local/__tests__/editStrategies.test.ts +134 -0
  177. package/src/tools/local/attachments.ts +251 -0
  178. package/src/tools/local/bashAst.ts +151 -0
  179. package/src/tools/local/editStrategies.ts +188 -0
  180. package/src/tools/local/index.ts +12 -0
  181. package/src/tools/local/resolveLocalExecutionTools.ts +208 -0
  182. package/src/tools/local/syntaxCheck.ts +243 -0
  183. package/src/tools/local/textEncoding.ts +37 -0
  184. package/src/tools/local/workspaceFS.ts +89 -0
  185. package/src/types/hitl.ts +56 -27
  186. package/src/types/run.ts +12 -1
  187. package/src/types/summarize.ts +31 -0
  188. package/src/types/tools.ts +359 -7
@@ -25,6 +25,7 @@ import type {
25
25
  ToolOutputResolveView,
26
26
  PreResolvedArgsMap,
27
27
  ResolvedArgsByCallId,
28
+ ResolveResult,
28
29
  } from '@/tools/toolOutputReferences';
29
30
  import type {
30
31
  HookRegistry,
@@ -45,6 +46,10 @@ import {
45
46
  buildReferenceKey,
46
47
  ToolOutputReferenceRegistry,
47
48
  } from '@/tools/toolOutputReferences';
49
+ import {
50
+ resolveLocalToolRegistry,
51
+ resolveLocalExecutionTools,
52
+ } from '@/tools/local';
48
53
 
49
54
  /**
50
55
  * Per-call batch context for `runTool`. Bundles every optional
@@ -60,6 +65,36 @@ type RunToolBatchContext = {
60
65
  batchScopeId?: string;
61
66
  /** Batch-local sink for post-substitution args. */
62
67
  resolvedArgsByCallId?: ResolvedArgsByCallId;
68
+ /**
69
+ * Frozen pre-batch view of the tool-output registry. When supplied,
70
+ * `runTool` resolves `{{tool…turn…}}` placeholders against this
71
+ * snapshot instead of the live registry, so a slow `PreToolUse`
72
+ * hook on one direct call cannot cause a sibling's just-registered
73
+ * output to leak into this call's args mid-batch (Codex P1 #18 —
74
+ * `Promise.all`-induced ordering would otherwise be observable).
75
+ */
76
+ preBatchSnapshot?: ToolOutputResolveView;
77
+ /**
78
+ * Pre-incremented per-tool usage counter. Set by
79
+ * `runDirectToolWithLifecycleHooks` so PreToolUse hooks observe
80
+ * the same `turn` the tool will actually execute under (Codex P2
81
+ * #27 — without this, parallel direct calls of the same tool in
82
+ * one Promise.all batch all read `turn=N` for the hook but
83
+ * actually executed as `turn=N`, `N+1`, `N+2`). When supplied,
84
+ * `runTool` skips its own counter increment.
85
+ */
86
+ usageCount?: number;
87
+ /**
88
+ * Per-batch sink for `additionalContext` strings returned by
89
+ * direct-path PreToolUse / PostToolUse / PostToolUseFailure hooks.
90
+ * The caller in `run()` materializes the accumulated strings as a
91
+ * `HumanMessage` appended to outputs so the next model turn sees
92
+ * them — same shape as the event-driven path's `injected[]`.
93
+ * Codex P2 #39: pre-fix the direct path called `executeHooks` and
94
+ * discarded `additionalContexts`, silently breaking the hook API
95
+ * contract for hosts relying on it for policy / recovery guidance.
96
+ */
97
+ additionalContextsSink?: string[];
63
98
  };
64
99
 
65
100
  /**
@@ -272,6 +307,17 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
272
307
  private toolUsageCount: Map<string, number>;
273
308
  /** Maps toolCallId → turn captured in runTool, used by handleRunToolCompletions */
274
309
  private toolCallTurns: Map<string, number> = new Map();
310
+ /**
311
+ * `call.id → turn` map dedicated to the direct-path lifecycle so the
312
+ * turn assigned on first entry is REUSED on LangGraph resume.
313
+ * Distinct from `toolCallTurns` (which is cleared at the start of
314
+ * every `run()` to keep per-batch event-dispatch metadata fresh) —
315
+ * the direct path needs stability across re-entries triggered by
316
+ * `interrupt()` resumes (Codex P2 #30). Cleared with the rest of
317
+ * the per-Run state in `clearHeavyState`-equivalent flushes when
318
+ * the Run ends.
319
+ */
320
+ private directPathTurns: Map<string, number> = new Map();
275
321
  /** Tool registry for filtering (lazy computation of programmatic maps) */
276
322
  private toolRegistry?: t.LCToolRegistry;
277
323
  /** Cached programmatic tools (computed once on first PTC call) */
@@ -284,6 +330,13 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
284
330
  private agentId?: string;
285
331
  /** Tool names that bypass event dispatch and execute directly (e.g., graph-managed handoff tools) */
286
332
  private directToolNames?: Set<string>;
333
+ /**
334
+ * File checkpointer extracted from the local coding tool bundle when
335
+ * `toolExecution.local.fileCheckpointing === true`. Exposed via
336
+ * {@link getFileCheckpointer}. Undefined when checkpointing is off
337
+ * or the local coding suite isn't bound to this node.
338
+ */
339
+ private fileCheckpointer?: t.LocalFileCheckpointer;
287
340
  /** Maximum characters allowed in a single tool result before truncation. */
288
341
  private maxToolResultChars: number;
289
342
  /** Hook registry for PreToolUse/PostToolUse lifecycle hooks */
@@ -306,6 +359,8 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
306
359
  * ToolNode building its own.
307
360
  */
308
361
  private toolOutputRegistry?: ToolOutputReferenceRegistry;
362
+ /** Run-scoped selection for swapping remote code tools to local executors. */
363
+ private toolExecution?: t.ToolExecutionConfig;
309
364
  /**
310
365
  * Monotonic counter used to mint a unique scope id for anonymous
311
366
  * batches (ones invoked without a `run_id` in
@@ -335,6 +390,8 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
335
390
  humanInTheLoop,
336
391
  toolOutputReferences,
337
392
  toolOutputRegistry,
393
+ toolExecution,
394
+ fileCheckpointer,
338
395
  }: t.ToolNodeConstructorParams) {
339
396
  super({ name, tags, func: (input, config) => this.run(input, config) });
340
397
  this.toolMap = toolMap ?? new Map(tools.map((tool) => [tool.name, tool]));
@@ -343,7 +400,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
343
400
  this.loadRuntimeTools = loadRuntimeTools;
344
401
  this.errorHandler = errorHandler;
345
402
  this.toolUsageCount = new Map<string, number>();
346
- this.toolRegistry = toolRegistry;
403
+ this.toolRegistry = resolveLocalToolRegistry({ toolRegistry, toolExecution });
347
404
  this.sessions = sessions;
348
405
  this.eventDrivenMode = eventDrivenMode ?? false;
349
406
  this.agentId = agentId;
@@ -352,6 +409,14 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
352
409
  maxToolResultChars ?? calculateMaxToolResultChars(maxContextTokens);
353
410
  this.hookRegistry = hookRegistry;
354
411
  this.humanInTheLoop = humanInTheLoop;
412
+ this.toolExecution = toolExecution;
413
+ // Caller-provided checkpointer wins. Graphs use this to share a
414
+ // single per-Run instance across every ToolNode they compile so
415
+ // `Run.rewindFiles()` reaches the same snapshot store regardless
416
+ // of which agent's tool batch ran. Falls through to the bundle's
417
+ // auto-created one when undefined (direct ToolNode construction).
418
+ this.fileCheckpointer = fileCheckpointer;
419
+ this.applyToolExecutionOverrides();
355
420
  /**
356
421
  * Precedence: an explicitly passed `toolOutputRegistry` instance
357
422
  * wins over a config object so a host (`Graph`) can share one
@@ -387,6 +452,62 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
387
452
  return this.toolOutputRegistry;
388
453
  }
389
454
 
455
+ /**
456
+ * Replaces known remote Code API tools with local-process tools when
457
+ * `RunConfig.toolExecution.engine === 'local'`. In event-driven mode those
458
+ * names are also marked direct so the SDK executes them locally instead of
459
+ * dispatching the batch to a host-side remote sandbox handler. When the
460
+ * local coding suite is enabled, this also injects file/search/edit tools.
461
+ */
462
+ private applyToolExecutionOverrides(): void {
463
+ const resolved = resolveLocalExecutionTools({
464
+ toolMap: this.toolMap,
465
+ toolExecution: this.toolExecution,
466
+ fileCheckpointer: this.fileCheckpointer,
467
+ });
468
+
469
+ this.toolMap = resolved.toolMap;
470
+ if (resolved.fileCheckpointer != null) {
471
+ this.fileCheckpointer = resolved.fileCheckpointer;
472
+ }
473
+ if (resolved.directToolNames.size === 0) {
474
+ return;
475
+ }
476
+
477
+ this.directToolNames = new Set([
478
+ ...(this.directToolNames ?? new Set<string>()),
479
+ ...resolved.directToolNames,
480
+ ]);
481
+ this.programmaticCache = undefined;
482
+ }
483
+
484
+ /**
485
+ * Returns the per-Run file checkpointer when
486
+ * `toolExecution.local.fileCheckpointing === true`. Hosts call
487
+ * `rewind()` on the returned object to restore captured pre-write
488
+ * file contents — the standard "undo a tool batch" pattern. Returns
489
+ * undefined when checkpointing is disabled or the local coding suite
490
+ * isn't bound. Manual review (finding E): without this getter, the
491
+ * config flag was a silent no-op outside of direct
492
+ * `createLocalCodingToolBundle()` use.
493
+ */
494
+ getFileCheckpointer(): t.LocalFileCheckpointer | undefined {
495
+ return this.fileCheckpointer;
496
+ }
497
+
498
+ /**
499
+ * Flush the per-Run direct-path turn cache. Called by the Graph at
500
+ * end-of-Run via `clearHeavyState`. The map intentionally survives
501
+ * `run()` re-entry so an interrupt + resume reuses the original
502
+ * slot (Codex P2 #30), but it would otherwise grow linearly with
503
+ * tool calls and could collide across Runs if a provider reused
504
+ * call IDs (Codex P2 #33). Hosts can also call this directly if
505
+ * they reuse a ToolNode across batches outside of a Graph.
506
+ */
507
+ clearDirectPathTurns(): void {
508
+ this.directPathTurns.clear();
509
+ }
510
+
390
511
  /**
391
512
  * Returns cached programmatic tools, computing once on first access.
392
513
  * Single iteration builds both toolMap and toolDefs simultaneously.
@@ -434,10 +555,35 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
434
555
  config: RunnableConfig,
435
556
  batchContext: RunToolBatchContext = {}
436
557
  ): Promise<BaseMessage | Command> {
437
- const { batchIndex, turn, batchScopeId, resolvedArgsByCallId } =
438
- batchContext;
558
+ const {
559
+ batchIndex,
560
+ turn,
561
+ batchScopeId,
562
+ resolvedArgsByCallId,
563
+ preBatchSnapshot,
564
+ } = batchContext;
439
565
  const tool = this.toolMap.get(call.name);
440
566
  const registry = this.toolOutputRegistry;
567
+ /**
568
+ * Prefer the caller-provided snapshot when present — `run()`
569
+ * captures one synchronously per batch so direct-path placeholder
570
+ * resolution stays isolated from same-turn sibling outputs even
571
+ * when a slow `PreToolUse` hook lets siblings finish first.
572
+ * Falls back to the live registry for callers that didn't thread
573
+ * a snapshot (anonymous direct invokes, legacy paths).
574
+ */
575
+ type ResolveFn = <T>(
576
+ runIdArg: string | undefined,
577
+ args: T
578
+ ) => ResolveResult<T>;
579
+ let resolveFn: ResolveFn | undefined;
580
+ if (preBatchSnapshot != null) {
581
+ resolveFn = <T>(_runId: string | undefined, args: T): ResolveResult<T> =>
582
+ preBatchSnapshot.resolve(args);
583
+ } else if (registry != null) {
584
+ resolveFn = <T>(runIdArg: string | undefined, args: T): ResolveResult<T> =>
585
+ registry.resolve(runIdArg, args);
586
+ }
441
587
  /**
442
588
  * Precompute the reference key once per call — captured locally
443
589
  * so concurrent `invoke()` calls on the same ToolNode cannot race
@@ -473,15 +619,26 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
473
619
  * It is intentionally distinct from the outer `turn` parameter
474
620
  * (the batch turn used for ref keys); the latter is captured
475
621
  * before the try block when constructing `refKey`.
622
+ *
623
+ * Prefer the value `runDirectToolWithLifecycleHooks` already
624
+ * incremented (Codex P2 #27) — its hook wants the SAME turn
625
+ * the tool will execute under. When called from a path that
626
+ * doesn't pre-increment (event dispatch, the no-hooks
627
+ * shortcut), do the read+increment here.
476
628
  */
477
- const usageCount = this.toolUsageCount.get(call.name) ?? 0;
478
- this.toolUsageCount.set(call.name, usageCount + 1);
479
- if (call.id != null && call.id !== '') {
480
- this.toolCallTurns.set(call.id, usageCount);
481
- }
629
+ const usageCount =
630
+ batchContext.usageCount ??
631
+ ((): number => {
632
+ const next = this.toolUsageCount.get(call.name) ?? 0;
633
+ this.toolUsageCount.set(call.name, next + 1);
634
+ if (call.id != null && call.id !== '') {
635
+ this.toolCallTurns.set(call.id, next);
636
+ }
637
+ return next;
638
+ })();
482
639
  let args = call.args;
483
- if (registry != null) {
484
- const { resolved, unresolved } = registry.resolve(runId, args);
640
+ if (resolveFn != null) {
641
+ const { resolved, unresolved } = resolveFn(runId, args);
485
642
  args = resolved;
486
643
  unresolvedRefs = unresolved;
487
644
  /**
@@ -526,6 +683,19 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
526
683
  ...invokeParams,
527
684
  toolMap,
528
685
  toolDefs,
686
+ // Plumb the hook context into the programmatic-tool path so
687
+ // inner tool calls made via the in-process bridge can run
688
+ // through `PreToolUse` (deny / updatedInput) before reaching
689
+ // the underlying tool. Without this, `run_tools_with_code`
690
+ // bypassed every PreToolUse hook the host registered for
691
+ // the tools it dispatches — including HITL gates on
692
+ // `write_file` / `edit_file` (manual review finding A).
693
+ hookContext: {
694
+ registry: this.hookRegistry,
695
+ runId: (config.configurable?.run_id as string | undefined) ?? '',
696
+ threadId: config.configurable?.thread_id as string | undefined,
697
+ agentId: this.agentId,
698
+ },
529
699
  };
530
700
  } else if (call.name === Constants.TOOL_SEARCH) {
531
701
  invokeParams = {
@@ -558,6 +728,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
558
728
  session_id: file.session_id ?? codeSession.session_id,
559
729
  id: file.id,
560
730
  name: file.name,
731
+ ...(file.entity_id != null ? { entity_id: file.entity_id } : {}),
561
732
  }));
562
733
  invokeParams._injected_files = fileRefs;
563
734
  }
@@ -725,6 +896,511 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
725
896
  }
726
897
  }
727
898
 
899
+ /**
900
+ * Runs a single in-process tool call with the same lifecycle hooks
901
+ * the event-dispatch path fires (`PreToolUse`, `PermissionDenied`,
902
+ * `PostToolUse`, `PostToolUseFailure`). Used for any tool whose
903
+ * implementation lives in the SDK process — i.e. every entry in
904
+ * `directToolNames` — so host-supplied policy hooks gate
905
+ * direct-invoked tools the same way they gate dispatched ones.
906
+ *
907
+ * Fast path: when the registry has none of the relevant events
908
+ * registered for this run, falls through to `runTool` with zero
909
+ * extra work. The hook list is also checked via
910
+ * `hasHookFor(event, runId)`, which performs the registry's own
911
+ * O(1) shortcut.
912
+ *
913
+ * Hook semantics intentionally mirror `dispatchToolEvents` for the
914
+ * single-call case:
915
+ * - `PreToolUse` returning `decision: 'deny'` synthesizes an error
916
+ * `ToolMessage` and fires `PermissionDenied` (observational).
917
+ * - `PreToolUse` returning `decision: 'ask'`:
918
+ * • When `humanInTheLoop.enabled === true`: raises a real
919
+ * `tool_approval` interrupt for this single tool call (the
920
+ * same payload shape the event path produces). On resume:
921
+ * `approve` runs the tool, `reject` blocks via
922
+ * `blockDirectCall`, `respond` returns the host-supplied
923
+ * `responseText` as a synthetic success ToolMessage,
924
+ * `edit` re-runs with edited args. LangGraph re-enters
925
+ * ToolNode.run from the start on resume; the hook fires
926
+ * again and the resume value distinguishes "first ask" from
927
+ * "second pass with decision".
928
+ * • When HITL is off: collapses to a fail-closed deny (matches
929
+ * the rest of the SDK's HITL-disabled default). One-time
930
+ * warning logged so hosts notice the gap.
931
+ * - `PreToolUse.updatedInput` is applied to the call before
932
+ * `runTool` runs; placeholder resolution inside `runTool` is
933
+ * idempotent on already-resolved args.
934
+ * - `PostToolUse.updatedOutput` replaces the returned
935
+ * `ToolMessage` content (preserving id/name/status).
936
+ * - `PostToolUseFailure` fires when `runTool` returns a
937
+ * `ToolMessage` whose `status === 'error'`. Observational only;
938
+ * the error message stays the source of truth.
939
+ *
940
+ * `PostToolBatch` aggregation across direct + dispatched outcomes is
941
+ * a separate concern: `dispatchToolEvents` accumulates batch entries
942
+ * locally and fires `PostToolBatch` at the end of its scope. Wiring
943
+ * direct-call entries into that aggregation crosses the two paths'
944
+ * scopes and is left to a follow-up.
945
+ */
946
+ private async runDirectToolWithLifecycleHooks(
947
+ call: ToolCall,
948
+ config: RunnableConfig,
949
+ batchContext: RunToolBatchContext = {}
950
+ ): Promise<BaseMessage | Command> {
951
+ const runId = (config.configurable?.run_id as string | undefined) ?? '';
952
+ const hookRegistry = this.hookRegistry;
953
+ const hasPreHook =
954
+ hookRegistry?.hasHookFor('PreToolUse', runId) === true;
955
+ const hasPostHook =
956
+ hookRegistry?.hasHookFor('PostToolUse', runId) === true;
957
+ const hasFailureHook =
958
+ hookRegistry?.hasHookFor('PostToolUseFailure', runId) === true;
959
+
960
+ if (
961
+ hookRegistry == null ||
962
+ (!hasPreHook && !hasPostHook && !hasFailureHook)
963
+ ) {
964
+ return this.runTool(call, config, batchContext);
965
+ }
966
+
967
+ const threadId = config.configurable?.thread_id as string | undefined;
968
+ const registryRunId =
969
+ batchContext.batchScopeId ??
970
+ (config.configurable?.run_id as string | undefined);
971
+ // Slot reservation, synchronous, before any await:
972
+ // 1. If this call.id already has a recorded turn (from a prior
973
+ // entry that asked / interrupted), REUSE it. LangGraph
974
+ // re-runs the entire ToolNode on resume, so the same call
975
+ // can hit this code multiple times — incrementing on each
976
+ // pass would push the eventual approved execution to
977
+ // `turn=N` instead of `turn=0` (Codex P2 #30: the fix from
978
+ // P2 #27 over-incremented across re-entries).
979
+ // 2. Otherwise reserve the next slot from the counter. Done
980
+ // synchronously so concurrent same-tool calls in a single
981
+ // Promise.all batch get distinct turns (the original P2 #27
982
+ // requirement still holds).
983
+ // Net: turns are stable per call.id across interrupt/resume,
984
+ // unique per call within a batch.
985
+ let usageCount: number;
986
+ // Look in the resume-stable map first; fall back to the
987
+ // per-batch one. (`directPathTurns` is set on first entry and
988
+ // survives `run()`'s clear, so a resume sees the original
989
+ // assignment.)
990
+ const cachedTurn =
991
+ call.id != null && call.id !== ''
992
+ ? this.directPathTurns.get(call.id) ??
993
+ this.toolCallTurns.get(call.id)
994
+ : undefined;
995
+ if (cachedTurn != null) {
996
+ usageCount = cachedTurn;
997
+ } else {
998
+ usageCount = this.toolUsageCount.get(call.name) ?? 0;
999
+ this.toolUsageCount.set(call.name, usageCount + 1);
1000
+ if (call.id != null && call.id !== '') {
1001
+ this.toolCallTurns.set(call.id, usageCount);
1002
+ // Dedicated direct-path map that SURVIVES `run()`'s
1003
+ // toolCallTurns.clear() — so a re-entry triggered by
1004
+ // LangGraph interrupt resume reuses this slot instead of
1005
+ // re-incrementing. Codex P2 #30.
1006
+ this.directPathTurns.set(call.id, usageCount);
1007
+ }
1008
+ }
1009
+ const turn = usageCount;
1010
+ const stepId = this.toolCallStepIds?.get(call.id ?? '') ?? '';
1011
+
1012
+ // Use the caller-threaded snapshot when available (P1 #18) so the
1013
+ // value the PreToolUse hook observes matches the value the
1014
+ // (later-awaited) `runTool` will actually run with — both are
1015
+ // anchored to the pre-batch registry state.
1016
+ let resolvedArgs = call.args as Record<string, unknown>;
1017
+ if (batchContext.preBatchSnapshot != null) {
1018
+ const { resolved } = batchContext.preBatchSnapshot.resolve(call.args);
1019
+ resolvedArgs = resolved as Record<string, unknown>;
1020
+ } else if (this.toolOutputRegistry != null) {
1021
+ const { resolved } = this.toolOutputRegistry.resolve(
1022
+ registryRunId,
1023
+ call.args
1024
+ );
1025
+ resolvedArgs = resolved as Record<string, unknown>;
1026
+ }
1027
+
1028
+ let effectiveCall = call;
1029
+ if (hasPreHook) {
1030
+ const preResult = await executeHooks({
1031
+ registry: hookRegistry,
1032
+ input: {
1033
+ hook_event_name: 'PreToolUse',
1034
+ runId,
1035
+ threadId,
1036
+ agentId: this.agentId,
1037
+ toolName: call.name,
1038
+ toolInput: resolvedArgs,
1039
+ toolUseId: call.id ?? '',
1040
+ stepId,
1041
+ turn,
1042
+ },
1043
+ sessionId: runId,
1044
+ matchQuery: call.name,
1045
+ }).catch(() => undefined);
1046
+
1047
+ if (preResult != null) {
1048
+ // Forward any additionalContext strings hooks returned into
1049
+ // the per-batch sink so the caller materializes them as a
1050
+ // HumanMessage for the next model turn — same shape as the
1051
+ // event-driven path's `injected[]`. Codex P2 #39.
1052
+ if (
1053
+ batchContext.additionalContextsSink != null &&
1054
+ preResult.additionalContexts.length > 0
1055
+ ) {
1056
+ batchContext.additionalContextsSink.push(
1057
+ ...preResult.additionalContexts
1058
+ );
1059
+ }
1060
+ // Apply any input rewrite first — `ask`-with-`updatedInput` is
1061
+ // a valid combination (one matcher sanitises args, another asks
1062
+ // for approval); the reviewer should see the sanitised args.
1063
+ if (preResult.updatedInput != null) {
1064
+ effectiveCall = {
1065
+ ...call,
1066
+ args: preResult.updatedInput as Record<string, unknown>,
1067
+ };
1068
+ }
1069
+
1070
+ if (preResult.decision === 'deny') {
1071
+ return this.blockDirectCall({
1072
+ call,
1073
+ resolvedArgs,
1074
+ reason: preResult.reason ?? 'Blocked by hook',
1075
+ hookRegistry,
1076
+ runId,
1077
+ threadId,
1078
+ });
1079
+ }
1080
+
1081
+ if (preResult.decision === 'ask') {
1082
+ if (this.humanInTheLoop?.enabled !== true) {
1083
+ // Fail-closed: no HITL UI configured, so we can't actually
1084
+ // ask. Logged once via the existing helper.
1085
+ const reason = this.resolveAskDecisionForDirectTool(
1086
+ preResult.reason,
1087
+ call.name
1088
+ );
1089
+ return this.blockDirectCall({
1090
+ call,
1091
+ resolvedArgs,
1092
+ reason,
1093
+ hookRegistry,
1094
+ runId,
1095
+ threadId,
1096
+ });
1097
+ }
1098
+
1099
+ // Raise a single-tool tool_approval interrupt. LangGraph
1100
+ // throws on the first execution (host gets the interrupt)
1101
+ // and returns the resume value on re-entry. Because direct
1102
+ // tools re-enter the entire ToolNode.run on resume, the
1103
+ // PreToolUse hook fires AGAIN — which is fine: the hook is
1104
+ // expected to be deterministic, and the resume value is what
1105
+ // distinguishes "first call asking" from "second call after
1106
+ // approve/reject". We anchor `interrupt()` against the
1107
+ // node's RunnableConfig the same way `dispatchToolEvents`
1108
+ // does (ToolNode disables LangSmith tracing, so the
1109
+ // AsyncLocalStorage frame must be re-established here).
1110
+ const askEntry: AskEntry = {
1111
+ entry: {
1112
+ call: effectiveCall,
1113
+ args: effectiveCall.args as Record<string, unknown>,
1114
+ stepId,
1115
+ },
1116
+ reason: preResult.reason,
1117
+ allowedDecisions: preResult.allowedDecisions,
1118
+ };
1119
+ const payload = buildToolApprovalInterruptPayload([askEntry]);
1120
+ const resumeValue = AsyncLocalStorageProviderSingleton.runWithConfig(
1121
+ config,
1122
+ () =>
1123
+ interrupt<
1124
+ t.ToolApprovalInterruptPayload,
1125
+ t.ToolApprovalDecision[] | t.ToolApprovalDecisionMap
1126
+ >(payload)
1127
+ );
1128
+ const decisionByCallId = normalizeApprovalDecisions(
1129
+ [call.id!],
1130
+ resumeValue
1131
+ );
1132
+ const decision = decisionByCallId.get(call.id!) ?? {
1133
+ type: 'reject' as const,
1134
+ reason: 'No decision provided for tool approval',
1135
+ };
1136
+ const declaredType = (decision as { type?: unknown }).type;
1137
+
1138
+ if (
1139
+ preResult.allowedDecisions != null &&
1140
+ (typeof declaredType !== 'string' ||
1141
+ !preResult.allowedDecisions.includes(
1142
+ declaredType as t.ToolApprovalDecisionType
1143
+ ))
1144
+ ) {
1145
+ return this.blockDirectCall({
1146
+ call,
1147
+ resolvedArgs,
1148
+ reason: `Decision "${typeof declaredType === 'string' ? declaredType : '<missing>'}" not in allowedDecisions [${preResult.allowedDecisions.join(', ')}] — failing closed`,
1149
+ hookRegistry,
1150
+ runId,
1151
+ threadId,
1152
+ });
1153
+ }
1154
+
1155
+ if (decision.type === 'reject') {
1156
+ return this.blockDirectCall({
1157
+ call,
1158
+ resolvedArgs,
1159
+ reason:
1160
+ decision.reason ??
1161
+ preResult.reason ??
1162
+ 'Rejected by user',
1163
+ hookRegistry,
1164
+ runId,
1165
+ threadId,
1166
+ });
1167
+ }
1168
+
1169
+ if (decision.type === 'respond') {
1170
+ const responseText = (decision as { responseText?: unknown })
1171
+ .responseText;
1172
+ if (typeof responseText !== 'string') {
1173
+ return this.blockDirectCall({
1174
+ call,
1175
+ resolvedArgs,
1176
+ reason: 'Approval payload `respond` was missing a string `responseText`',
1177
+ hookRegistry,
1178
+ runId,
1179
+ threadId,
1180
+ });
1181
+ }
1182
+ return new ToolMessage({
1183
+ status: 'success',
1184
+ content: responseText,
1185
+ name: call.name,
1186
+ tool_call_id: call.id ?? '',
1187
+ });
1188
+ }
1189
+
1190
+ if (decision.type === 'edit') {
1191
+ // Mirror the event-driven path's validation
1192
+ // (see `dispatchToolEvents`'s edit branch). The wire
1193
+ // field is `updatedInput`, NOT `args` — hosts following
1194
+ // the documented `ToolApprovalDecision` shape were
1195
+ // silently ignored before, so the tool ran with the
1196
+ // original (un-edited) arguments. Fail closed on
1197
+ // malformed payloads instead of falling through with
1198
+ // undefined args.
1199
+ const updatedInput = (decision as { updatedInput?: unknown })
1200
+ .updatedInput;
1201
+ if (
1202
+ updatedInput === null ||
1203
+ typeof updatedInput !== 'object' ||
1204
+ Array.isArray(updatedInput)
1205
+ ) {
1206
+ return new ToolMessage({
1207
+ status: 'error',
1208
+ content:
1209
+ 'Decision "edit" missing object updatedInput — failing closed.',
1210
+ name: call.name,
1211
+ tool_call_id: call.id ?? '',
1212
+ });
1213
+ }
1214
+ effectiveCall = {
1215
+ ...call,
1216
+ args: updatedInput as Record<string, unknown>,
1217
+ };
1218
+ // fall through to executing the edited call
1219
+ }
1220
+ // 'approve' (or 'edit' after applying edits) → fall through
1221
+ }
1222
+ }
1223
+ }
1224
+
1225
+ const output = await this.runTool(effectiveCall, config, {
1226
+ ...batchContext,
1227
+ usageCount,
1228
+ });
1229
+
1230
+ if (!(output instanceof ToolMessage)) {
1231
+ return output;
1232
+ }
1233
+
1234
+ if (output.status === 'error' && hasFailureHook) {
1235
+ // Await the failure hook (instead of fire-and-forget) so we
1236
+ // can capture additionalContexts before returning. The hook is
1237
+ // still observational w.r.t. the tool result itself — we don't
1238
+ // mutate `output`, just plumb the contexts. Codex P2 #39.
1239
+ const failureResult = await executeHooks({
1240
+ registry: hookRegistry,
1241
+ input: {
1242
+ hook_event_name: 'PostToolUseFailure',
1243
+ runId,
1244
+ threadId,
1245
+ agentId: this.agentId,
1246
+ toolName: call.name,
1247
+ toolInput: effectiveCall.args as Record<string, unknown>,
1248
+ toolUseId: call.id ?? '',
1249
+ error:
1250
+ typeof output.content === 'string'
1251
+ ? output.content
1252
+ : JSON.stringify(output.content),
1253
+ stepId,
1254
+ turn,
1255
+ },
1256
+ sessionId: runId,
1257
+ matchQuery: call.name,
1258
+ }).catch(() => undefined);
1259
+ if (
1260
+ failureResult != null &&
1261
+ batchContext.additionalContextsSink != null &&
1262
+ failureResult.additionalContexts.length > 0
1263
+ ) {
1264
+ batchContext.additionalContextsSink.push(
1265
+ ...failureResult.additionalContexts
1266
+ );
1267
+ }
1268
+ return output;
1269
+ }
1270
+
1271
+ if (output.status !== 'error' && hasPostHook) {
1272
+ const postResult = await executeHooks({
1273
+ registry: hookRegistry,
1274
+ input: {
1275
+ hook_event_name: 'PostToolUse',
1276
+ runId,
1277
+ threadId,
1278
+ agentId: this.agentId,
1279
+ toolName: call.name,
1280
+ toolInput: effectiveCall.args as Record<string, unknown>,
1281
+ toolOutput: output.content,
1282
+ toolUseId: call.id ?? '',
1283
+ stepId,
1284
+ turn,
1285
+ },
1286
+ sessionId: runId,
1287
+ matchQuery: call.name,
1288
+ }).catch(() => undefined);
1289
+
1290
+ // Forward additionalContexts from the PostToolUse hook into
1291
+ // the per-batch sink (Codex P2 #39).
1292
+ if (
1293
+ postResult != null &&
1294
+ batchContext.additionalContextsSink != null &&
1295
+ postResult.additionalContexts.length > 0
1296
+ ) {
1297
+ batchContext.additionalContextsSink.push(
1298
+ ...postResult.additionalContexts
1299
+ );
1300
+ }
1301
+
1302
+ if (postResult?.updatedOutput != null) {
1303
+ const replaced =
1304
+ typeof postResult.updatedOutput === 'string'
1305
+ ? postResult.updatedOutput
1306
+ : JSON.stringify(postResult.updatedOutput);
1307
+ // Keep the tool-output registry in sync with what the model
1308
+ // actually sees. Without this, `runTool` already registered
1309
+ // the PRE-hook content under `_refKey`, and a later
1310
+ // `{{tool<i>turn<n>}}` substitution would deliver the stale
1311
+ // pre-hook bytes while the model (and downstream tools)
1312
+ // observed the post-hook replacement. Read `_refKey` /
1313
+ // `_refScope` straight off the message metadata that
1314
+ // `recordOutputReference` stamped — no need to re-derive
1315
+ // (and we couldn't, for anonymous-batch synthetic scopes).
1316
+ const refMeta = output.additional_kwargs as
1317
+ | t.ToolMessageRefMetadata
1318
+ | undefined;
1319
+ const refKey = refMeta?._refKey;
1320
+ const refScope = refMeta?._refScope;
1321
+ if (this.toolOutputRegistry != null && refKey != null) {
1322
+ this.toolOutputRegistry.set(refScope, refKey, replaced);
1323
+ }
1324
+ return new ToolMessage({
1325
+ status: output.status,
1326
+ name: output.name,
1327
+ content: replaced,
1328
+ artifact: output.artifact,
1329
+ tool_call_id: output.tool_call_id,
1330
+ additional_kwargs: output.additional_kwargs,
1331
+ });
1332
+ }
1333
+ }
1334
+
1335
+ return output;
1336
+ }
1337
+
1338
+ /**
1339
+ * `ask` decisions on direct-path tools collapse to fail-closed deny
1340
+ * only when `humanInTheLoop.enabled !== true` (i.e. there's no host
1341
+ * UI configured to actually prompt the user). Logged once per process
1342
+ * so the gap is visible. When HITL IS enabled, `ask` raises a real
1343
+ * LangGraph `interrupt()` instead — see `runDirectToolWithLifecycleHooks`.
1344
+ */
1345
+ private askDirectWarningEmitted = false;
1346
+ private resolveAskDecisionForDirectTool(
1347
+ reason: string | undefined,
1348
+ toolName: string
1349
+ ): string {
1350
+ if (!this.askDirectWarningEmitted) {
1351
+ this.askDirectWarningEmitted = true;
1352
+ // eslint-disable-next-line no-console
1353
+ console.warn(
1354
+ `[ToolNode] PreToolUse returned 'ask' for direct-path tool "${toolName}" but ` +
1355
+ 'humanInTheLoop is not enabled — failing closed. Set humanInTheLoop.enabled=true ' +
1356
+ 'to raise a tool_approval interrupt the host can resolve.'
1357
+ );
1358
+ }
1359
+ return reason ?? 'Blocked by hook';
1360
+ }
1361
+
1362
+ /**
1363
+ * Synthesize a Blocked ToolMessage AND fire `PermissionDenied`
1364
+ * (observational) for a direct-path tool call. Centralised so the
1365
+ * deny path looks identical whether the block came from `'deny'` or
1366
+ * from a fail-closed/`'reject'`/policy-violation path.
1367
+ */
1368
+ private blockDirectCall(args: {
1369
+ call: ToolCall;
1370
+ resolvedArgs: Record<string, unknown>;
1371
+ reason: string;
1372
+ hookRegistry: HookRegistry;
1373
+ runId: string;
1374
+ threadId: string | undefined;
1375
+ }): ToolMessage {
1376
+ const { call, resolvedArgs, reason, hookRegistry, runId, threadId } = args;
1377
+ if (hookRegistry.hasHookFor('PermissionDenied', runId) === true) {
1378
+ executeHooks({
1379
+ registry: hookRegistry,
1380
+ input: {
1381
+ hook_event_name: 'PermissionDenied',
1382
+ runId,
1383
+ threadId,
1384
+ agentId: this.agentId,
1385
+ toolName: call.name,
1386
+ toolInput: resolvedArgs,
1387
+ toolUseId: call.id ?? '',
1388
+ reason,
1389
+ },
1390
+ sessionId: runId,
1391
+ matchQuery: call.name,
1392
+ }).catch(() => {
1393
+ /* observational */
1394
+ });
1395
+ }
1396
+ return new ToolMessage({
1397
+ status: 'error',
1398
+ content: `Blocked: ${reason}`,
1399
+ name: call.name,
1400
+ tool_call_id: call.id ?? '',
1401
+ });
1402
+ }
1403
+
728
1404
  /**
729
1405
  * Registers the full, raw output under `refKey` (when provided) and
730
1406
  * builds the per-message ref metadata stamped onto the resulting
@@ -797,6 +1473,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
797
1473
  session_id: file.session_id ?? codeSession.session_id,
798
1474
  id: file.id,
799
1475
  name: file.name,
1476
+ ...(file.entity_id != null ? { entity_id: file.entity_id } : {}),
800
1477
  }));
801
1478
  }
802
1479
 
@@ -823,7 +1500,8 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
823
1500
 
824
1501
  const request = requestMap.get(result.toolCallId);
825
1502
  if (
826
- !request?.name ||
1503
+ request?.name == null ||
1504
+ request.name === '' ||
827
1505
  (!CODE_EXECUTION_TOOLS.has(request.name) &&
828
1506
  request.name !== Constants.SKILL_TOOL)
829
1507
  ) {
@@ -1992,17 +2670,42 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1992
2670
  batchScopeId,
1993
2671
  });
1994
2672
  }
1995
- outputs = [
1996
- await this.runTool(input.lg_tool_call, config, {
2673
+ // Same per-batch sink the message-state branches use so
2674
+ // direct-path PreToolUse/PostToolUse/Failure additionalContexts
2675
+ // surface here too. Codex P2 [44] — round 14 added the sink to
2676
+ // both message-state branches but missed this Send-input
2677
+ // branch, so direct tools dispatched via Send (a supported
2678
+ // input shape) still silently dropped hook context.
2679
+ const directAdditionalContexts: string[] = [];
2680
+ const sendOutput = await this.runDirectToolWithLifecycleHooks(
2681
+ input.lg_tool_call,
2682
+ config,
2683
+ {
1997
2684
  batchIndex: 0,
1998
2685
  turn,
1999
2686
  batchScopeId,
2000
2687
  resolvedArgsByCallId,
2001
- }),
2002
- ];
2688
+ additionalContextsSink: directAdditionalContexts,
2689
+ }
2690
+ );
2691
+ outputs =
2692
+ directAdditionalContexts.length > 0
2693
+ ? [
2694
+ sendOutput,
2695
+ new HumanMessage({
2696
+ content: directAdditionalContexts.join('\n\n'),
2697
+ // Match the event-driven path's marker so hosts /
2698
+ // model-side annotators treat this as system intent
2699
+ // rather than ordinary user text. Codex P2 [46].
2700
+ additional_kwargs: { role: 'system', source: 'hook' },
2701
+ }),
2702
+ ]
2703
+ : [sendOutput];
2003
2704
  this.handleRunToolCompletions(
2004
2705
  [input.lg_tool_call],
2005
- outputs,
2706
+ // Pass only the tool output to completion handling; the
2707
+ // HumanMessage isn't a tool result.
2708
+ [sendOutput],
2006
2709
  config,
2007
2710
  resolvedArgsByCallId
2008
2711
  );
@@ -2043,6 +2746,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2043
2746
  );
2044
2747
  this.toolMap =
2045
2748
  toolMap ?? new Map(tools.map((tool) => [tool.name, tool]));
2749
+ this.applyToolExecutionOverrides();
2046
2750
  this.programmaticCache = undefined; // Invalidate cache on toolMap change
2047
2751
  }
2048
2752
 
@@ -2131,15 +2835,22 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2131
2835
  }
2132
2836
  }
2133
2837
 
2838
+ // Per-batch sink for direct-path hook additionalContexts
2839
+ // (Codex P2 #39). Materialized as a HumanMessage at end-of-
2840
+ // batch so the next model turn sees the injected context,
2841
+ // matching the event path's `injected[]` shape.
2842
+ const directAdditionalContexts: string[] = [];
2134
2843
  const directOutputs: (BaseMessage | Command)[] =
2135
2844
  directCalls.length > 0
2136
2845
  ? await Promise.all(
2137
2846
  directCalls.map((call, i) =>
2138
- this.runTool(call, config, {
2847
+ this.runDirectToolWithLifecycleHooks(call, config, {
2139
2848
  batchIndex: directIndices[i],
2140
2849
  turn,
2141
2850
  batchScopeId,
2142
2851
  resolvedArgsByCallId,
2852
+ preBatchSnapshot,
2853
+ additionalContextsSink: directAdditionalContexts,
2143
2854
  })
2144
2855
  )
2145
2856
  )
@@ -2168,28 +2879,66 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2168
2879
  injected: [] as BaseMessage[],
2169
2880
  };
2170
2881
 
2882
+ const directInjected: BaseMessage[] =
2883
+ directAdditionalContexts.length > 0
2884
+ ? [
2885
+ new HumanMessage({
2886
+ content: directAdditionalContexts.join('\n\n'),
2887
+ // System-role metadata to match the event-driven
2888
+ // path so policy/recovery guidance is treated
2889
+ // consistently regardless of whether the tool ran
2890
+ // direct or dispatched. Codex P2 [46].
2891
+ additional_kwargs: { role: 'system', source: 'hook' },
2892
+ }),
2893
+ ]
2894
+ : [];
2171
2895
  outputs = [
2172
2896
  ...directOutputs,
2173
2897
  ...eventResult.toolMessages,
2898
+ ...directInjected,
2174
2899
  ...eventResult.injected,
2175
2900
  ];
2176
2901
  } else {
2177
- outputs = await Promise.all(
2902
+ // Same per-batch pre-snapshot as the mixed path, applied to
2903
+ // the all-direct case so `Promise.all`-induced ordering can't
2904
+ // leak a sibling's just-registered output into a sister
2905
+ // call's args mid-await (Codex P1 #18).
2906
+ const preBatchSnapshot =
2907
+ this.toolOutputRegistry?.snapshot(batchScopeId);
2908
+ const directAdditionalContexts: string[] = [];
2909
+ const toolOutputs = await Promise.all(
2178
2910
  filteredCalls.map((call, i) =>
2179
- this.runTool(call, config, {
2911
+ this.runDirectToolWithLifecycleHooks(call, config, {
2180
2912
  batchIndex: i,
2181
2913
  turn,
2182
2914
  batchScopeId,
2183
2915
  resolvedArgsByCallId,
2916
+ preBatchSnapshot,
2917
+ additionalContextsSink: directAdditionalContexts,
2184
2918
  })
2185
2919
  )
2186
2920
  );
2187
2921
  this.handleRunToolCompletions(
2188
2922
  filteredCalls,
2189
- outputs,
2923
+ toolOutputs,
2190
2924
  config,
2191
2925
  resolvedArgsByCallId
2192
2926
  );
2927
+ // Append accumulated additionalContexts as a single
2928
+ // HumanMessage so the next model turn sees them. Codex P2 #39.
2929
+ outputs =
2930
+ directAdditionalContexts.length > 0
2931
+ ? [
2932
+ ...toolOutputs,
2933
+ new HumanMessage({
2934
+ content: directAdditionalContexts.join('\n\n'),
2935
+ // Same system-role marker the event-driven path
2936
+ // uses so direct vs dispatched is invisible to
2937
+ // downstream consumers. Codex P2 [46].
2938
+ additional_kwargs: { role: 'system', source: 'hook' },
2939
+ }),
2940
+ ]
2941
+ : toolOutputs;
2193
2942
  }
2194
2943
  }
2195
2944