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