@librechat/agents 3.1.68 → 3.1.71-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 (192) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +23 -3
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/common/enum.cjs +16 -1
  4. package/dist/cjs/common/enum.cjs.map +1 -1
  5. package/dist/cjs/graphs/Graph.cjs +136 -0
  6. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  7. package/dist/cjs/hooks/HookRegistry.cjs +162 -0
  8. package/dist/cjs/hooks/HookRegistry.cjs.map +1 -0
  9. package/dist/cjs/hooks/executeHooks.cjs +276 -0
  10. package/dist/cjs/hooks/executeHooks.cjs.map +1 -0
  11. package/dist/cjs/hooks/matchers.cjs +256 -0
  12. package/dist/cjs/hooks/matchers.cjs.map +1 -0
  13. package/dist/cjs/hooks/types.cjs +27 -0
  14. package/dist/cjs/hooks/types.cjs.map +1 -0
  15. package/dist/cjs/main.cjs +57 -0
  16. package/dist/cjs/main.cjs.map +1 -1
  17. package/dist/cjs/messages/format.cjs +74 -12
  18. package/dist/cjs/messages/format.cjs.map +1 -1
  19. package/dist/cjs/messages/prune.cjs +9 -2
  20. package/dist/cjs/messages/prune.cjs.map +1 -1
  21. package/dist/cjs/run.cjs +115 -0
  22. package/dist/cjs/run.cjs.map +1 -1
  23. package/dist/cjs/summarization/node.cjs +44 -0
  24. package/dist/cjs/summarization/node.cjs.map +1 -1
  25. package/dist/cjs/tools/BashExecutor.cjs +208 -0
  26. package/dist/cjs/tools/BashExecutor.cjs.map +1 -0
  27. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs +287 -0
  28. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs.map +1 -0
  29. package/dist/cjs/tools/CodeExecutor.cjs +0 -9
  30. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  31. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +7 -23
  32. package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
  33. package/dist/cjs/tools/ReadFile.cjs +43 -0
  34. package/dist/cjs/tools/ReadFile.cjs.map +1 -0
  35. package/dist/cjs/tools/SkillTool.cjs +50 -0
  36. package/dist/cjs/tools/SkillTool.cjs.map +1 -0
  37. package/dist/cjs/tools/SubagentTool.cjs +92 -0
  38. package/dist/cjs/tools/SubagentTool.cjs.map +1 -0
  39. package/dist/cjs/tools/ToolNode.cjs +746 -174
  40. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  41. package/dist/cjs/tools/ToolSearch.cjs +2 -13
  42. package/dist/cjs/tools/ToolSearch.cjs.map +1 -1
  43. package/dist/cjs/tools/skillCatalog.cjs +84 -0
  44. package/dist/cjs/tools/skillCatalog.cjs.map +1 -0
  45. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +511 -0
  46. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -0
  47. package/dist/cjs/tools/toolOutputReferences.cjs +475 -0
  48. package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -0
  49. package/dist/cjs/utils/truncation.cjs +28 -0
  50. package/dist/cjs/utils/truncation.cjs.map +1 -1
  51. package/dist/esm/agents/AgentContext.mjs +23 -3
  52. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  53. package/dist/esm/common/enum.mjs +15 -2
  54. package/dist/esm/common/enum.mjs.map +1 -1
  55. package/dist/esm/graphs/Graph.mjs +136 -0
  56. package/dist/esm/graphs/Graph.mjs.map +1 -1
  57. package/dist/esm/hooks/HookRegistry.mjs +160 -0
  58. package/dist/esm/hooks/HookRegistry.mjs.map +1 -0
  59. package/dist/esm/hooks/executeHooks.mjs +273 -0
  60. package/dist/esm/hooks/executeHooks.mjs.map +1 -0
  61. package/dist/esm/hooks/matchers.mjs +251 -0
  62. package/dist/esm/hooks/matchers.mjs.map +1 -0
  63. package/dist/esm/hooks/types.mjs +25 -0
  64. package/dist/esm/hooks/types.mjs.map +1 -0
  65. package/dist/esm/main.mjs +13 -2
  66. package/dist/esm/main.mjs.map +1 -1
  67. package/dist/esm/messages/format.mjs +66 -4
  68. package/dist/esm/messages/format.mjs.map +1 -1
  69. package/dist/esm/messages/prune.mjs +9 -2
  70. package/dist/esm/messages/prune.mjs.map +1 -1
  71. package/dist/esm/run.mjs +115 -0
  72. package/dist/esm/run.mjs.map +1 -1
  73. package/dist/esm/summarization/node.mjs +44 -0
  74. package/dist/esm/summarization/node.mjs.map +1 -1
  75. package/dist/esm/tools/BashExecutor.mjs +200 -0
  76. package/dist/esm/tools/BashExecutor.mjs.map +1 -0
  77. package/dist/esm/tools/BashProgrammaticToolCalling.mjs +278 -0
  78. package/dist/esm/tools/BashProgrammaticToolCalling.mjs.map +1 -0
  79. package/dist/esm/tools/CodeExecutor.mjs +0 -9
  80. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  81. package/dist/esm/tools/ProgrammaticToolCalling.mjs +8 -24
  82. package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
  83. package/dist/esm/tools/ReadFile.mjs +38 -0
  84. package/dist/esm/tools/ReadFile.mjs.map +1 -0
  85. package/dist/esm/tools/SkillTool.mjs +45 -0
  86. package/dist/esm/tools/SkillTool.mjs.map +1 -0
  87. package/dist/esm/tools/SubagentTool.mjs +85 -0
  88. package/dist/esm/tools/SubagentTool.mjs.map +1 -0
  89. package/dist/esm/tools/ToolNode.mjs +748 -176
  90. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  91. package/dist/esm/tools/ToolSearch.mjs +3 -14
  92. package/dist/esm/tools/ToolSearch.mjs.map +1 -1
  93. package/dist/esm/tools/skillCatalog.mjs +82 -0
  94. package/dist/esm/tools/skillCatalog.mjs.map +1 -0
  95. package/dist/esm/tools/subagent/SubagentExecutor.mjs +505 -0
  96. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -0
  97. package/dist/esm/tools/toolOutputReferences.mjs +468 -0
  98. package/dist/esm/tools/toolOutputReferences.mjs.map +1 -0
  99. package/dist/esm/utils/truncation.mjs +27 -1
  100. package/dist/esm/utils/truncation.mjs.map +1 -1
  101. package/dist/types/agents/AgentContext.d.ts +6 -0
  102. package/dist/types/common/enum.d.ts +10 -2
  103. package/dist/types/graphs/Graph.d.ts +23 -0
  104. package/dist/types/hooks/HookRegistry.d.ts +56 -0
  105. package/dist/types/hooks/executeHooks.d.ts +79 -0
  106. package/dist/types/hooks/index.d.ts +6 -0
  107. package/dist/types/hooks/matchers.d.ts +95 -0
  108. package/dist/types/hooks/types.d.ts +320 -0
  109. package/dist/types/index.d.ts +8 -0
  110. package/dist/types/messages/format.d.ts +2 -1
  111. package/dist/types/run.d.ts +2 -0
  112. package/dist/types/summarization/node.d.ts +2 -0
  113. package/dist/types/tools/BashExecutor.d.ts +76 -0
  114. package/dist/types/tools/BashProgrammaticToolCalling.d.ts +72 -0
  115. package/dist/types/tools/ProgrammaticToolCalling.d.ts +4 -9
  116. package/dist/types/tools/ReadFile.d.ts +28 -0
  117. package/dist/types/tools/SkillTool.d.ts +40 -0
  118. package/dist/types/tools/SubagentTool.d.ts +36 -0
  119. package/dist/types/tools/ToolNode.d.ts +109 -4
  120. package/dist/types/tools/ToolSearch.d.ts +2 -2
  121. package/dist/types/tools/skillCatalog.d.ts +19 -0
  122. package/dist/types/tools/subagent/SubagentExecutor.d.ts +137 -0
  123. package/dist/types/tools/subagent/index.d.ts +2 -0
  124. package/dist/types/tools/toolOutputReferences.d.ts +205 -0
  125. package/dist/types/types/graph.d.ts +61 -2
  126. package/dist/types/types/index.d.ts +1 -0
  127. package/dist/types/types/run.d.ts +28 -0
  128. package/dist/types/types/skill.d.ts +9 -0
  129. package/dist/types/types/tools.d.ts +108 -10
  130. package/dist/types/utils/truncation.d.ts +21 -0
  131. package/package.json +5 -1
  132. package/src/agents/AgentContext.ts +26 -2
  133. package/src/common/enum.ts +15 -1
  134. package/src/graphs/Graph.ts +161 -0
  135. package/src/hooks/HookRegistry.ts +208 -0
  136. package/src/hooks/__tests__/HookRegistry.test.ts +190 -0
  137. package/src/hooks/__tests__/compactHooks.test.ts +214 -0
  138. package/src/hooks/__tests__/executeHooks.test.ts +1013 -0
  139. package/src/hooks/__tests__/integration.test.ts +337 -0
  140. package/src/hooks/__tests__/matchers.test.ts +238 -0
  141. package/src/hooks/__tests__/toolHooks.test.ts +669 -0
  142. package/src/hooks/executeHooks.ts +375 -0
  143. package/src/hooks/index.ts +57 -0
  144. package/src/hooks/matchers.ts +280 -0
  145. package/src/hooks/types.ts +404 -0
  146. package/src/index.ts +10 -0
  147. package/src/messages/format.ts +74 -4
  148. package/src/messages/formatAgentMessages.skills.test.ts +334 -0
  149. package/src/messages/prune.ts +9 -2
  150. package/src/run.ts +130 -0
  151. package/src/scripts/multi-agent-subagent.ts +246 -0
  152. package/src/scripts/programmatic_exec.ts +1 -10
  153. package/src/scripts/subagent-event-driven-debug.ts +190 -0
  154. package/src/scripts/subagent-tools-debug.ts +160 -0
  155. package/src/scripts/test_code_api.ts +0 -7
  156. package/src/scripts/tool_search.ts +1 -10
  157. package/src/specs/prune.test.ts +413 -0
  158. package/src/specs/subagent.test.ts +305 -0
  159. package/src/summarization/node.ts +53 -0
  160. package/src/tools/BashExecutor.ts +238 -0
  161. package/src/tools/BashProgrammaticToolCalling.ts +381 -0
  162. package/src/tools/CodeExecutor.ts +0 -11
  163. package/src/tools/ProgrammaticToolCalling.ts +4 -29
  164. package/src/tools/ReadFile.ts +39 -0
  165. package/src/tools/SkillTool.ts +46 -0
  166. package/src/tools/SubagentTool.ts +100 -0
  167. package/src/tools/ToolNode.ts +999 -214
  168. package/src/tools/ToolSearch.ts +3 -19
  169. package/src/tools/__tests__/BashExecutor.test.ts +36 -0
  170. package/src/tools/__tests__/ProgrammaticToolCalling.integration.test.ts +7 -8
  171. package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +0 -1
  172. package/src/tools/__tests__/ReadFile.test.ts +44 -0
  173. package/src/tools/__tests__/SkillTool.test.ts +442 -0
  174. package/src/tools/__tests__/SubagentExecutor.test.ts +1148 -0
  175. package/src/tools/__tests__/SubagentTool.test.ts +149 -0
  176. package/src/tools/__tests__/ToolNode.outputReferences.test.ts +1395 -0
  177. package/src/tools/__tests__/ToolNode.session.test.ts +12 -12
  178. package/src/tools/__tests__/ToolSearch.integration.test.ts +7 -8
  179. package/src/tools/__tests__/skillCatalog.test.ts +161 -0
  180. package/src/tools/__tests__/subagentHooks.test.ts +215 -0
  181. package/src/tools/__tests__/toolOutputReferences.test.ts +415 -0
  182. package/src/tools/skillCatalog.ts +126 -0
  183. package/src/tools/subagent/SubagentExecutor.ts +676 -0
  184. package/src/tools/subagent/index.ts +13 -0
  185. package/src/tools/toolOutputReferences.ts +590 -0
  186. package/src/types/graph.ts +80 -1
  187. package/src/types/index.ts +1 -0
  188. package/src/types/run.ts +28 -0
  189. package/src/types/skill.ts +11 -0
  190. package/src/types/tools.ts +112 -10
  191. package/src/utils/__tests__/truncation.test.ts +66 -0
  192. package/src/utils/truncation.ts +30 -0
@@ -1,6 +1,7 @@
1
1
  import { ToolCall } from '@langchain/core/messages/tool';
2
2
  import {
3
3
  ToolMessage,
4
+ HumanMessage,
4
5
  isAIMessage,
5
6
  isBaseMessage,
6
7
  } from '@langchain/core/messages';
@@ -18,6 +19,12 @@ import type {
18
19
  } from '@langchain/core/runnables';
19
20
  import type { BaseMessage, AIMessage } from '@langchain/core/messages';
20
21
  import type { StructuredToolInterface } from '@langchain/core/tools';
22
+ import type {
23
+ ToolOutputResolveView,
24
+ PreResolvedArgsMap,
25
+ ResolvedArgsByCallId,
26
+ } from '@/tools/toolOutputReferences';
27
+ import type { HookRegistry, AggregatedHookResult } from '@/hooks';
21
28
  import type * as t from '@/types';
22
29
  import { RunnableCallable } from '@/utils';
23
30
  import {
@@ -25,7 +32,56 @@ import {
25
32
  truncateToolResultContent,
26
33
  } from '@/utils/truncation';
27
34
  import { safeDispatchCustomEvent } from '@/utils/events';
28
- import { Constants, GraphEvents } from '@/common';
35
+ import { executeHooks } from '@/hooks';
36
+ import { Constants, GraphEvents, CODE_EXECUTION_TOOLS } from '@/common';
37
+ import {
38
+ buildReferenceKey,
39
+ annotateToolOutputWithReference,
40
+ ToolOutputReferenceRegistry,
41
+ } from '@/tools/toolOutputReferences';
42
+
43
+ /**
44
+ * Per-call batch context for `runTool`. Bundles every optional
45
+ * batch-scoped value the method needs so the signature stays at
46
+ * three positional parameters even as new context fields are added.
47
+ */
48
+ type RunToolBatchContext = {
49
+ /** Position of this call within the parent ToolNode batch. */
50
+ batchIndex?: number;
51
+ /** Batch turn shared across every call in the batch. */
52
+ turn?: number;
53
+ /** Registry partition scope (run id or anonymous batch id). */
54
+ batchScopeId?: string;
55
+ /** Batch-local sink for post-substitution args. */
56
+ resolvedArgsByCallId?: ResolvedArgsByCallId;
57
+ };
58
+
59
+ /**
60
+ * Per-batch context for `dispatchToolEvents` / `executeViaEvent`.
61
+ * Mirrors {@link RunToolBatchContext} for the event-driven path,
62
+ * with bulk indices and the snapshot/pre-resolved-args carriers
63
+ * used in the mixed direct+event flow.
64
+ */
65
+ type DispatchBatchContext = {
66
+ /** Per-call batch indices, parallel to the `toolCalls` array. */
67
+ batchIndices?: number[];
68
+ /** Batch turn shared across every call in the batch. */
69
+ turn?: number;
70
+ /** Registry partition scope (run id or anonymous batch id). */
71
+ batchScopeId?: string;
72
+ /**
73
+ * Pre-resolved args keyed by `toolCallId`. Populated by the mixed
74
+ * path so event calls don't re-resolve against a registry that
75
+ * already contains same-turn direct outputs.
76
+ */
77
+ preResolvedArgs?: PreResolvedArgsMap;
78
+ /**
79
+ * Frozen pre-batch registry view used to re-resolve placeholders
80
+ * a `PreToolUse` hook injects via `updatedInput` — preserves the
81
+ * same-turn isolation guarantee for hook-rewritten args.
82
+ */
83
+ preBatchSnapshot?: ToolOutputResolveView;
84
+ };
29
85
 
30
86
  /**
31
87
  * Helper to check if a value is a Send object
@@ -34,6 +90,41 @@ function isSend(value: unknown): value is Send {
34
90
  return value instanceof Send;
35
91
  }
36
92
 
93
+ /** Merges code execution session context into the sessions map. */
94
+ function updateCodeSession(
95
+ sessions: t.ToolSessionMap,
96
+ sessionId: string,
97
+ files: t.FileRefs | undefined
98
+ ): void {
99
+ const newFiles = files ?? [];
100
+ const existingSession = sessions.get(Constants.EXECUTE_CODE) as
101
+ | t.CodeSessionContext
102
+ | undefined;
103
+ const existingFiles = existingSession?.files ?? [];
104
+
105
+ if (newFiles.length > 0) {
106
+ const filesWithSession: t.FileRefs = newFiles.map((file) => ({
107
+ ...file,
108
+ session_id: sessionId,
109
+ }));
110
+ const newFileNames = new Set(filesWithSession.map((f) => f.name));
111
+ const filteredExisting = existingFiles.filter(
112
+ (f) => !newFileNames.has(f.name)
113
+ );
114
+ sessions.set(Constants.EXECUTE_CODE, {
115
+ session_id: sessionId,
116
+ files: [...filteredExisting, ...filesWithSession],
117
+ lastUpdated: Date.now(),
118
+ });
119
+ } else {
120
+ sessions.set(Constants.EXECUTE_CODE, {
121
+ session_id: sessionId,
122
+ files: existingFiles,
123
+ lastUpdated: Date.now(),
124
+ });
125
+ }
126
+ }
127
+
37
128
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
38
129
  export class ToolNode<T = any> extends RunnableCallable<T, T> {
39
130
  private toolMap: Map<string, StructuredToolInterface | RunnableToolLike>;
@@ -59,6 +150,28 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
59
150
  private directToolNames?: Set<string>;
60
151
  /** Maximum characters allowed in a single tool result before truncation. */
61
152
  private maxToolResultChars: number;
153
+ /** Hook registry for PreToolUse/PostToolUse lifecycle hooks */
154
+ private hookRegistry?: HookRegistry;
155
+ /**
156
+ * Registry of tool outputs keyed by `tool<idx>turn<turn>`.
157
+ *
158
+ * Populated only when `toolOutputReferences.enabled` is true. The
159
+ * registry owns the run-scoped state (turn counter, last-seen runId,
160
+ * warn-once memo, stored outputs), so sharing a single instance
161
+ * across multiple ToolNodes in a run lets cross-agent `{{…}}`
162
+ * references resolve — which is why multi-agent graphs pass the
163
+ * *same* instance to every ToolNode they compile rather than each
164
+ * ToolNode building its own.
165
+ */
166
+ private toolOutputRegistry?: ToolOutputReferenceRegistry;
167
+ /**
168
+ * Monotonic counter used to mint a unique scope id for anonymous
169
+ * batches (ones invoked without a `run_id` in
170
+ * `config.configurable`). Each such batch gets its own registry
171
+ * partition so concurrent anonymous invocations can't delete each
172
+ * other's in-flight state.
173
+ */
174
+ private anonBatchCounter: number = 0;
62
175
 
63
176
  constructor({
64
177
  tools,
@@ -76,6 +189,9 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
76
189
  directToolNames,
77
190
  maxContextTokens,
78
191
  maxToolResultChars,
192
+ hookRegistry,
193
+ toolOutputReferences,
194
+ toolOutputRegistry,
79
195
  }: t.ToolNodeConstructorParams) {
80
196
  super({ name, tags, func: (input, config) => this.run(input, config) });
81
197
  this.toolMap = toolMap ?? new Map(tools.map((tool) => [tool.name, tool]));
@@ -91,6 +207,40 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
91
207
  this.directToolNames = directToolNames;
92
208
  this.maxToolResultChars =
93
209
  maxToolResultChars ?? calculateMaxToolResultChars(maxContextTokens);
210
+ this.hookRegistry = hookRegistry;
211
+ /**
212
+ * Precedence: an explicitly passed `toolOutputRegistry` instance
213
+ * wins over a config object so a host (`Graph`) can share one
214
+ * registry across many ToolNodes. When only the config is
215
+ * provided (direct ToolNode usage), build a local registry so
216
+ * the feature still works without graph-level plumbing. Registry
217
+ * caps are intentionally decoupled from `maxToolResultChars`:
218
+ * the registry stores the raw untruncated output so a later
219
+ * `{{…}}` substitution pipes the full payload into the next
220
+ * tool, even when the LLM saw a truncated preview.
221
+ */
222
+ if (toolOutputRegistry != null) {
223
+ this.toolOutputRegistry = toolOutputRegistry;
224
+ } else if (toolOutputReferences?.enabled === true) {
225
+ this.toolOutputRegistry = new ToolOutputReferenceRegistry({
226
+ maxOutputSize: toolOutputReferences.maxOutputSize,
227
+ maxTotalSize: toolOutputReferences.maxTotalSize,
228
+ });
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Returns the run-scoped tool output registry, or `undefined` when
234
+ * the feature is disabled.
235
+ *
236
+ * @internal Exposed for test observation only. Host code should rely
237
+ * on `{{tool<i>turn<n>}}` substitution at tool-invocation time and
238
+ * not mutate the registry directly.
239
+ */
240
+ public _unsafeGetToolOutputRegistry():
241
+ | ToolOutputReferenceRegistry
242
+ | undefined {
243
+ return this.toolOutputRegistry;
94
244
  }
95
245
 
96
246
  /**
@@ -128,36 +278,105 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
128
278
  }
129
279
 
130
280
  /**
131
- * Runs a single tool call with error handling
281
+ * Runs a single tool call with error handling.
282
+ *
283
+ * `batchIndex` is the tool's position within the current ToolNode
284
+ * batch and, together with `this.currentTurn`, forms the key used to
285
+ * register the output for future `{{tool<idx>turn<turn>}}`
286
+ * substitutions. Omit when no registration should occur.
132
287
  */
133
288
  protected async runTool(
134
289
  call: ToolCall,
135
- config: RunnableConfig
290
+ config: RunnableConfig,
291
+ batchContext: RunToolBatchContext = {}
136
292
  ): Promise<BaseMessage | Command> {
293
+ const { batchIndex, turn, batchScopeId, resolvedArgsByCallId } =
294
+ batchContext;
137
295
  const tool = this.toolMap.get(call.name);
296
+ const registry = this.toolOutputRegistry;
297
+ /**
298
+ * Precompute the reference key once per call — captured locally
299
+ * so concurrent `invoke()` calls on the same ToolNode cannot race
300
+ * on a shared turn field.
301
+ */
302
+ const refKey =
303
+ registry != null && batchIndex != null && turn != null
304
+ ? buildReferenceKey(batchIndex, turn)
305
+ : undefined;
306
+ /**
307
+ * Hoisted outside the try so the catch branch can append
308
+ * `[unresolved refs: …]` to error messages — otherwise the LLM
309
+ * only sees a generic error when it references a bad key, losing
310
+ * the self-correction signal this feature is meant to provide.
311
+ */
312
+ let unresolvedRefs: string[] = [];
313
+ /**
314
+ * Use the caller-provided `batchScopeId` when threaded from
315
+ * `run()` (so anonymous batches get their own unique scope).
316
+ * Fall back to the config's `run_id` when runTool is invoked
317
+ * from a context that doesn't thread it — that still preserves
318
+ * the runId-based partitioning for named runs.
319
+ */
320
+ const runId =
321
+ batchScopeId ?? (config.configurable?.run_id as string | undefined);
138
322
  try {
139
323
  if (tool === undefined) {
140
324
  throw new Error(`Tool "${call.name}" not found.`);
141
325
  }
142
- const turn = this.toolUsageCount.get(call.name) ?? 0;
143
- this.toolUsageCount.set(call.name, turn + 1);
326
+ /**
327
+ * `usageCount` is the per-tool-name invocation index that
328
+ * web-search and other tools observe via `invokeParams.turn`.
329
+ * It is intentionally distinct from the outer `turn` parameter
330
+ * (the batch turn used for ref keys); the latter is captured
331
+ * before the try block when constructing `refKey`.
332
+ */
333
+ const usageCount = this.toolUsageCount.get(call.name) ?? 0;
334
+ this.toolUsageCount.set(call.name, usageCount + 1);
144
335
  if (call.id != null && call.id !== '') {
145
- this.toolCallTurns.set(call.id, turn);
336
+ this.toolCallTurns.set(call.id, usageCount);
337
+ }
338
+ let args = call.args;
339
+ if (registry != null) {
340
+ const { resolved, unresolved } = registry.resolve(runId, args);
341
+ args = resolved;
342
+ unresolvedRefs = unresolved;
343
+ /**
344
+ * Expose the post-substitution args to downstream completion
345
+ * events so audit logs / host-side `ON_RUN_STEP_COMPLETED`
346
+ * handlers observe what actually ran, not the `{{…}}`
347
+ * template. Only string/object args are worth recording.
348
+ */
349
+ if (
350
+ resolvedArgsByCallId != null &&
351
+ call.id != null &&
352
+ call.id !== '' &&
353
+ resolved !== call.args &&
354
+ typeof resolved === 'object'
355
+ ) {
356
+ resolvedArgsByCallId.set(
357
+ call.id,
358
+ resolved as Record<string, unknown>
359
+ );
360
+ }
146
361
  }
147
- const args = call.args;
148
362
  const stepId = this.toolCallStepIds?.get(call.id!);
149
363
 
150
364
  // Build invoke params - LangChain extracts non-schema fields to config.toolCall
365
+ // `turn` here is the per-tool usage count (matches what tools have
366
+ // observed historically via config.toolCall.turn — e.g. web search).
151
367
  let invokeParams: Record<string, unknown> = {
152
368
  ...call,
153
369
  args,
154
370
  type: 'tool_call',
155
371
  stepId,
156
- turn,
372
+ turn: usageCount,
157
373
  };
158
374
 
159
375
  // Inject runtime data for special tools (becomes available at config.toolCall)
160
- if (call.name === Constants.PROGRAMMATIC_TOOL_CALLING) {
376
+ if (
377
+ call.name === Constants.PROGRAMMATIC_TOOL_CALLING ||
378
+ call.name === Constants.BASH_PROGRAMMATIC_TOOL_CALLING
379
+ ) {
161
380
  const { toolMap, toolDefs } = this.getProgrammaticTools();
162
381
  invokeParams = {
163
382
  ...invokeParams,
@@ -180,10 +399,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
180
399
  * session_id is always injected when available (even without tracked files)
181
400
  * so the CodeExecutor can fall back to the /files endpoint for session continuity.
182
401
  */
183
- if (
184
- call.name === Constants.EXECUTE_CODE ||
185
- call.name === Constants.PROGRAMMATIC_TOOL_CALLING
186
- ) {
402
+ if (CODE_EXECUTION_TOOLS.has(call.name)) {
187
403
  const codeSession = this.sessions?.get(Constants.EXECUTE_CODE) as
188
404
  | t.CodeSessionContext
189
405
  | undefined;
@@ -205,24 +421,102 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
205
421
  }
206
422
 
207
423
  const output = await tool.invoke(invokeParams, config);
208
- if (
209
- (isBaseMessage(output) && output._getType() === 'tool') ||
210
- isCommand(output)
211
- ) {
424
+ if (isCommand(output)) {
212
425
  return output;
213
- } else {
214
- const rawContent =
215
- typeof output === 'string' ? output : JSON.stringify(output);
216
- return new ToolMessage({
217
- status: 'success',
218
- name: tool.name,
219
- content: truncateToolResultContent(
220
- rawContent,
221
- this.maxToolResultChars
222
- ),
223
- tool_call_id: call.id!,
224
- });
225
426
  }
427
+ if (isBaseMessage(output) && output._getType() === 'tool') {
428
+ const toolMsg = output as ToolMessage;
429
+ const isError = toolMsg.status === 'error';
430
+ if (isError) {
431
+ /**
432
+ * Error ToolMessages bypass registration/annotation but must
433
+ * still carry the unresolved-refs hint so the LLM can
434
+ * self-correct when its reference key caused the failure.
435
+ */
436
+ if (
437
+ unresolvedRefs.length > 0 &&
438
+ typeof toolMsg.content === 'string'
439
+ ) {
440
+ toolMsg.content = this.applyOutputReference(
441
+ runId,
442
+ toolMsg.content,
443
+ toolMsg.content,
444
+ undefined,
445
+ unresolvedRefs
446
+ );
447
+ }
448
+ return toolMsg;
449
+ }
450
+ if (this.toolOutputRegistry != null || unresolvedRefs.length > 0) {
451
+ if (typeof toolMsg.content === 'string') {
452
+ const rawContent = toolMsg.content;
453
+ const llmContent = truncateToolResultContent(
454
+ rawContent,
455
+ this.maxToolResultChars
456
+ );
457
+ toolMsg.content = this.applyOutputReference(
458
+ runId,
459
+ llmContent,
460
+ rawContent,
461
+ refKey,
462
+ unresolvedRefs
463
+ );
464
+ } else {
465
+ /**
466
+ * Non-string content (multi-part content blocks — text +
467
+ * image). Known limitation: we cannot register under a
468
+ * reference key because there's no canonical serialized
469
+ * form. Warn once per tool per run when the caller
470
+ * intended to register.
471
+ *
472
+ * Still surface unresolved-ref warnings so the LLM gets
473
+ * the self-correction signal that the string and error
474
+ * paths already emit. Prepended as a leading text block
475
+ * to keep the original content ordering intact.
476
+ */
477
+ if (unresolvedRefs.length > 0 && Array.isArray(toolMsg.content)) {
478
+ const warningBlock = {
479
+ type: 'text',
480
+ text: `[unresolved refs: ${unresolvedRefs.join(', ')}]`,
481
+ };
482
+ toolMsg.content = [
483
+ warningBlock,
484
+ ...toolMsg.content,
485
+ ] as typeof toolMsg.content;
486
+ }
487
+ if (
488
+ refKey != null &&
489
+ this.toolOutputRegistry!.claimWarnOnce(runId, call.name)
490
+ ) {
491
+ // eslint-disable-next-line no-console
492
+ console.warn(
493
+ `[ToolNode] Skipping tool output reference for "${call.name}": ` +
494
+ 'ToolMessage content is not a string (further occurrences for this tool in the same run are suppressed).'
495
+ );
496
+ }
497
+ }
498
+ }
499
+ return toolMsg;
500
+ }
501
+ const rawContent =
502
+ typeof output === 'string' ? output : JSON.stringify(output);
503
+ const truncated = truncateToolResultContent(
504
+ rawContent,
505
+ this.maxToolResultChars
506
+ );
507
+ const content = this.applyOutputReference(
508
+ runId,
509
+ truncated,
510
+ rawContent,
511
+ refKey,
512
+ unresolvedRefs
513
+ );
514
+ return new ToolMessage({
515
+ status: 'success',
516
+ name: tool.name,
517
+ content,
518
+ tool_call_id: call.id!,
519
+ });
226
520
  } catch (_e: unknown) {
227
521
  const e = _e as Error;
228
522
  if (!this.handleToolErrors) {
@@ -267,15 +561,66 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
267
561
  });
268
562
  }
269
563
  }
564
+ let errorContent = `Error: ${e.message}\n Please fix your mistakes.`;
565
+ if (unresolvedRefs.length > 0) {
566
+ errorContent = this.applyOutputReference(
567
+ runId,
568
+ errorContent,
569
+ errorContent,
570
+ undefined,
571
+ unresolvedRefs
572
+ );
573
+ }
270
574
  return new ToolMessage({
271
575
  status: 'error',
272
- content: `Error: ${e.message}\n Please fix your mistakes.`,
576
+ content: errorContent,
273
577
  name: call.name,
274
578
  tool_call_id: call.id ?? '',
275
579
  });
276
580
  }
277
581
  }
278
582
 
583
+ /**
584
+ * Finalizes the LLM-visible content for a tool call and (when a
585
+ * `refKey` is provided) registers the full, raw output under that
586
+ * key.
587
+ *
588
+ * @param llmContent The content string the LLM will see. This is
589
+ * the already-truncated, post-hook view; the annotation is
590
+ * applied on top of it.
591
+ * @param registryContent The full, untruncated output to store in
592
+ * the registry so `{{tool<i>turn<n>}}` substitutions deliver the
593
+ * complete payload. Ignored when `refKey` is undefined.
594
+ * @param refKey Precomputed `tool<i>turn<n>` key, or undefined when
595
+ * the output is not to be registered (errors, disabled feature,
596
+ * unavailable batch/turn).
597
+ * @param unresolved Placeholder keys that did not resolve; appended
598
+ * as `[unresolved refs: …]` so the LLM can self-correct.
599
+ *
600
+ * `refKey` is passed in (rather than built from `this.currentTurn`)
601
+ * so parallel `invoke()` calls on the same ToolNode cannot race on
602
+ * the shared turn field.
603
+ */
604
+ private applyOutputReference(
605
+ runId: string | undefined,
606
+ llmContent: string,
607
+ registryContent: string,
608
+ refKey: string | undefined,
609
+ unresolved: string[]
610
+ ): string {
611
+ if (this.toolOutputRegistry != null && refKey != null) {
612
+ this.toolOutputRegistry.set(runId, refKey, registryContent);
613
+ }
614
+ /**
615
+ * `annotateToolOutputWithReference` handles both the ref-key and
616
+ * unresolved-refs cases together so JSON-object outputs stay
617
+ * parseable: unresolved refs land in an `_unresolved_refs` field
618
+ * instead of as a trailing text line that would break
619
+ * `JSON.parse` for downstream consumers.
620
+ */
621
+ return annotateToolOutputWithReference(llmContent, refKey, unresolved);
622
+ }
623
+
279
624
  /**
280
625
  * Builds code session context for injection into event-driven tool calls.
281
626
  * Mirrors the session injection logic in runTool() for direct execution.
@@ -313,7 +658,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
313
658
  */
314
659
  private storeCodeSessionFromResults(
315
660
  results: t.ToolExecuteResult[],
316
- requests: t.ToolCallRequest[]
661
+ requestMap: Map<string, t.ToolCallRequest>
317
662
  ): void {
318
663
  if (!this.sessions) {
319
664
  return;
@@ -325,10 +670,11 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
325
670
  continue;
326
671
  }
327
672
 
328
- const request = requests.find((r) => r.id === result.toolCallId);
673
+ const request = requestMap.get(result.toolCallId);
329
674
  if (
330
- request?.name !== Constants.EXECUTE_CODE &&
331
- request?.name !== Constants.PROGRAMMATIC_TOOL_CALLING
675
+ !request?.name ||
676
+ (!CODE_EXECUTION_TOOLS.has(request.name) &&
677
+ request.name !== Constants.SKILL_TOOL)
332
678
  ) {
333
679
  continue;
334
680
  }
@@ -338,35 +684,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
338
684
  continue;
339
685
  }
340
686
 
341
- const newFiles = artifact.files ?? [];
342
- const existingSession = this.sessions.get(Constants.EXECUTE_CODE) as
343
- | t.CodeSessionContext
344
- | undefined;
345
- const existingFiles = existingSession?.files ?? [];
346
-
347
- if (newFiles.length > 0) {
348
- const filesWithSession: t.FileRefs = newFiles.map((file) => ({
349
- ...file,
350
- session_id: artifact.session_id,
351
- }));
352
-
353
- const newFileNames = new Set(filesWithSession.map((f) => f.name));
354
- const filteredExisting = existingFiles.filter(
355
- (f) => !newFileNames.has(f.name)
356
- );
357
-
358
- this.sessions.set(Constants.EXECUTE_CODE, {
359
- session_id: artifact.session_id,
360
- files: [...filteredExisting, ...filesWithSession],
361
- lastUpdated: Date.now(),
362
- });
363
- } else {
364
- this.sessions.set(Constants.EXECUTE_CODE, {
365
- session_id: artifact.session_id,
366
- files: existingFiles,
367
- lastUpdated: Date.now(),
368
- });
369
- }
687
+ updateCodeSession(this.sessions, artifact.session_id!, artifact.files);
370
688
  }
371
689
  }
372
690
 
@@ -378,11 +696,16 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
378
696
  * By handling completions here in graph context (rather than in the
379
697
  * stream consumer via ToolEndHandler), the race between the stream
380
698
  * consumer and graph execution is eliminated.
699
+ *
700
+ * @param resolvedArgsByCallId Per-batch resolved-args sink populated
701
+ * by `runTool`. Threaded as a local map (instead of instance state)
702
+ * so concurrent batches cannot read each other's entries.
381
703
  */
382
704
  private handleRunToolCompletions(
383
705
  calls: ToolCall[],
384
706
  outputs: (BaseMessage | Command)[],
385
- config: RunnableConfig
707
+ config: RunnableConfig,
708
+ resolvedArgsByCallId?: ResolvedArgsByCallId
386
709
  ): void {
387
710
  for (let i = 0; i < calls.length; i++) {
388
711
  const call = calls[i];
@@ -402,43 +725,12 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
402
725
  continue;
403
726
  }
404
727
 
405
- // Store code session context from tool results
406
- if (
407
- this.sessions &&
408
- (call.name === Constants.EXECUTE_CODE ||
409
- call.name === Constants.PROGRAMMATIC_TOOL_CALLING)
410
- ) {
728
+ if (this.sessions && CODE_EXECUTION_TOOLS.has(call.name)) {
411
729
  const artifact = toolMessage.artifact as
412
730
  | t.CodeExecutionArtifact
413
731
  | undefined;
414
732
  if (artifact?.session_id != null && artifact.session_id !== '') {
415
- const newFiles = artifact.files ?? [];
416
- const existingSession = this.sessions.get(Constants.EXECUTE_CODE) as
417
- | t.CodeSessionContext
418
- | undefined;
419
- const existingFiles = existingSession?.files ?? [];
420
-
421
- if (newFiles.length > 0) {
422
- const filesWithSession: t.FileRefs = newFiles.map((file) => ({
423
- ...file,
424
- session_id: artifact.session_id,
425
- }));
426
- const newFileNames = new Set(filesWithSession.map((f) => f.name));
427
- const filteredExisting = existingFiles.filter(
428
- (f) => !newFileNames.has(f.name)
429
- );
430
- this.sessions.set(Constants.EXECUTE_CODE, {
431
- session_id: artifact.session_id,
432
- files: [...filteredExisting, ...filesWithSession],
433
- lastUpdated: Date.now(),
434
- });
435
- } else {
436
- this.sessions.set(Constants.EXECUTE_CODE, {
437
- session_id: artifact.session_id,
438
- files: existingFiles,
439
- lastUpdated: Date.now(),
440
- });
441
- }
733
+ updateCodeSession(this.sessions, artifact.session_id, artifact.files);
442
734
  }
443
735
  }
444
736
 
@@ -453,11 +745,18 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
453
745
  ? toolMessage.content
454
746
  : JSON.stringify(toolMessage.content);
455
747
 
748
+ /**
749
+ * Prefer the post-substitution args when a `{{…}}` placeholder
750
+ * was resolved in `runTool`. This keeps
751
+ * `ON_RUN_STEP_COMPLETED.tool_call.args` consistent with what
752
+ * the tool actually received rather than leaking the template.
753
+ */
754
+ const effectiveArgs = resolvedArgsByCallId?.get(toolCallId) ?? call.args;
456
755
  const tool_call: t.ProcessedToolCall = {
457
756
  args:
458
- typeof call.args === 'string'
459
- ? (call.args as string)
460
- : JSON.stringify((call.args as unknown) ?? {}),
757
+ typeof effectiveArgs === 'string'
758
+ ? (effectiveArgs as string)
759
+ : JSON.stringify((effectiveArgs as unknown) ?? {}),
461
760
  name: call.name,
462
761
  id: toolCallId,
463
762
  output: contentString,
@@ -482,151 +781,544 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
482
781
  /**
483
782
  * Dispatches tool calls to the host via ON_TOOL_EXECUTE event and returns raw ToolMessages.
484
783
  * Core logic for event-driven execution, separated from output shaping.
784
+ *
785
+ * Hook lifecycle (when `hookRegistry` is set):
786
+ * 1. **PreToolUse** fires per call in parallel before dispatch. Denied
787
+ * calls produce error ToolMessages and fire **PermissionDenied**;
788
+ * surviving calls proceed with optional `updatedInput`.
789
+ * 2. Surviving calls are dispatched to the host via `ON_TOOL_EXECUTE`.
790
+ * 3. **PostToolUse** / **PostToolUseFailure** fire per result. Post hooks
791
+ * can replace tool output via `updatedOutput`.
792
+ * 4. Injected messages from results are collected and returned alongside
793
+ * ToolMessages (appended AFTER to respect provider ordering).
485
794
  */
486
795
  private async dispatchToolEvents(
487
796
  toolCalls: ToolCall[],
488
- config: RunnableConfig
489
- ): Promise<ToolMessage[]> {
490
- const requests: t.ToolCallRequest[] = toolCalls.map((call) => {
491
- const turn = this.toolUsageCount.get(call.name) ?? 0;
492
- this.toolUsageCount.set(call.name, turn + 1);
493
-
494
- const request: t.ToolCallRequest = {
495
- id: call.id!,
496
- name: call.name,
497
- args: call.args as Record<string, unknown>,
498
- stepId: this.toolCallStepIds?.get(call.id!),
499
- turn,
797
+ config: RunnableConfig,
798
+ batchContext: DispatchBatchContext = {}
799
+ ): Promise<{ toolMessages: ToolMessage[]; injected: BaseMessage[] }> {
800
+ const {
801
+ batchIndices,
802
+ turn,
803
+ batchScopeId,
804
+ preResolvedArgs,
805
+ preBatchSnapshot,
806
+ } = batchContext;
807
+ const runId = (config.configurable?.run_id as string | undefined) ?? '';
808
+ /**
809
+ * Registry-facing scope id — prefers the caller-threaded
810
+ * `batchScopeId` so anonymous batches target their own unique
811
+ * bucket and don't step on concurrent anonymous invocations.
812
+ * Hooks and event payloads keep using the empty-string coerced
813
+ * `runId` for backward compat.
814
+ */
815
+ const registryRunId =
816
+ batchScopeId ?? (config.configurable?.run_id as string | undefined);
817
+ const threadId = config.configurable?.thread_id as string | undefined;
818
+ const registry = this.toolOutputRegistry;
819
+ const unresolvedByCallId = new Map<string, string[]>();
820
+
821
+ const preToolCalls = toolCalls.map((call, i) => {
822
+ const originalArgs = call.args as Record<string, unknown>;
823
+ let resolvedArgs = originalArgs;
824
+ /**
825
+ * When the caller provided a pre-resolved map (the mixed
826
+ * direct+event path snapshots event args synchronously before
827
+ * awaiting directs so they can't accidentally resolve
828
+ * same-turn direct outputs), use those entries verbatim instead
829
+ * of re-resolving against a registry that may have changed
830
+ * since the batch started.
831
+ */
832
+ const pre = call.id != null ? preResolvedArgs?.get(call.id) : undefined;
833
+ if (pre != null) {
834
+ resolvedArgs = pre.resolved;
835
+ if (pre.unresolved.length > 0 && call.id != null) {
836
+ unresolvedByCallId.set(call.id, pre.unresolved);
837
+ }
838
+ } else if (registry != null) {
839
+ const { resolved, unresolved } = registry.resolve(
840
+ registryRunId,
841
+ originalArgs
842
+ );
843
+ resolvedArgs = resolved as Record<string, unknown>;
844
+ if (unresolved.length > 0 && call.id != null) {
845
+ unresolvedByCallId.set(call.id, unresolved);
846
+ }
847
+ }
848
+ return {
849
+ call,
850
+ stepId: this.toolCallStepIds?.get(call.id!) ?? '',
851
+ args: resolvedArgs,
852
+ batchIndex: batchIndices?.[i],
500
853
  };
854
+ });
501
855
 
502
- if (
503
- call.name === Constants.EXECUTE_CODE ||
504
- call.name === Constants.PROGRAMMATIC_TOOL_CALLING
505
- ) {
506
- request.codeSessionContext = this.getCodeSessionContext();
856
+ const messageByCallId = new Map<string, ToolMessage>();
857
+ const approvedEntries: typeof preToolCalls = [];
858
+ const HOOK_FALLBACK: AggregatedHookResult = Object.freeze({
859
+ additionalContexts: [] as string[],
860
+ errors: [] as string[],
861
+ });
862
+
863
+ if (this.hookRegistry?.hasHookFor('PreToolUse', runId) === true) {
864
+ const preResults = await Promise.all(
865
+ preToolCalls.map((entry) =>
866
+ executeHooks({
867
+ registry: this.hookRegistry!,
868
+ input: {
869
+ hook_event_name: 'PreToolUse',
870
+ runId,
871
+ threadId,
872
+ agentId: this.agentId,
873
+ toolName: entry.call.name,
874
+ toolInput: entry.args,
875
+ toolUseId: entry.call.id!,
876
+ stepId: entry.stepId,
877
+ turn: this.toolUsageCount.get(entry.call.name) ?? 0,
878
+ },
879
+ sessionId: runId,
880
+ matchQuery: entry.call.name,
881
+ }).catch((): AggregatedHookResult => HOOK_FALLBACK)
882
+ )
883
+ );
884
+
885
+ for (let i = 0; i < preToolCalls.length; i++) {
886
+ const hookResult = preResults[i];
887
+ const entry = preToolCalls[i];
888
+ const isDenied =
889
+ hookResult.decision === 'deny' || hookResult.decision === 'ask';
890
+ if (isDenied) {
891
+ const reason = hookResult.reason ?? 'Blocked by hook';
892
+ const contentString = `Blocked: ${reason}`;
893
+ messageByCallId.set(
894
+ entry.call.id!,
895
+ new ToolMessage({
896
+ status: 'error',
897
+ content: contentString,
898
+ name: entry.call.name,
899
+ tool_call_id: entry.call.id!,
900
+ })
901
+ );
902
+ this.dispatchStepCompleted(
903
+ entry.call.id!,
904
+ entry.call.name,
905
+ entry.args,
906
+ contentString,
907
+ config
908
+ );
909
+ if (this.hookRegistry.hasHookFor('PermissionDenied', runId)) {
910
+ executeHooks({
911
+ registry: this.hookRegistry,
912
+ input: {
913
+ hook_event_name: 'PermissionDenied',
914
+ runId,
915
+ threadId,
916
+ agentId: this.agentId,
917
+ toolName: entry.call.name,
918
+ toolInput: entry.args,
919
+ toolUseId: entry.call.id!,
920
+ reason,
921
+ },
922
+ sessionId: runId,
923
+ matchQuery: entry.call.name,
924
+ }).catch(() => {
925
+ /* PermissionDenied is observational — swallow errors */
926
+ });
927
+ }
928
+ continue;
929
+ }
930
+ if (hookResult.updatedInput != null) {
931
+ /**
932
+ * Re-resolve after PreToolUse replaces the input: a hook may
933
+ * introduce new `{{tool<i>turn<n>}}` placeholders (e.g., by
934
+ * copying user-supplied text) that the pre-hook pass never
935
+ * saw. Re-running the resolver on the hook-rewritten args
936
+ * keeps substitution and the unresolved-refs record in sync
937
+ * with what the tool will actually receive.
938
+ */
939
+ if (registry != null) {
940
+ /**
941
+ * Mixed direct+event batches must use the pre-batch
942
+ * snapshot so a hook-introduced placeholder cannot
943
+ * accidentally resolve to a same-turn direct output that
944
+ * has just registered. Pure event batches don't have a
945
+ * snapshot and resolve against the live registry — safe
946
+ * because no event-side registrations have happened yet.
947
+ */
948
+ const view: ToolOutputResolveView = preBatchSnapshot ?? {
949
+ resolve: <T>(args: T) => registry.resolve(registryRunId, args),
950
+ };
951
+ const { resolved, unresolved } = view.resolve(
952
+ hookResult.updatedInput
953
+ );
954
+ entry.args = resolved as Record<string, unknown>;
955
+ if (entry.call.id != null) {
956
+ if (unresolved.length > 0) {
957
+ unresolvedByCallId.set(entry.call.id, unresolved);
958
+ } else {
959
+ unresolvedByCallId.delete(entry.call.id);
960
+ }
961
+ }
962
+ } else {
963
+ entry.args = hookResult.updatedInput;
964
+ }
965
+ }
966
+ approvedEntries.push(entry);
507
967
  }
968
+ } else {
969
+ approvedEntries.push(...preToolCalls);
970
+ }
508
971
 
509
- return request;
510
- });
972
+ const injected: BaseMessage[] = [];
973
+
974
+ const batchIndexByCallId = new Map<string, number>();
511
975
 
512
- const results = await new Promise<t.ToolExecuteResult[]>(
513
- (resolve, reject) => {
514
- const request: t.ToolExecuteBatchRequest = {
515
- toolCalls: requests,
516
- userId: config.configurable?.user_id as string | undefined,
517
- agentId: this.agentId,
518
- configurable: config.configurable as
519
- | Record<string, unknown>
520
- | undefined,
521
- metadata: config.metadata as Record<string, unknown> | undefined,
522
- resolve,
523
- reject,
976
+ if (approvedEntries.length > 0) {
977
+ const requests: t.ToolCallRequest[] = approvedEntries.map((entry) => {
978
+ const turn = this.toolUsageCount.get(entry.call.name) ?? 0;
979
+ this.toolUsageCount.set(entry.call.name, turn + 1);
980
+
981
+ if (entry.batchIndex != null && entry.call.id != null) {
982
+ batchIndexByCallId.set(entry.call.id, entry.batchIndex);
983
+ }
984
+
985
+ const request: t.ToolCallRequest = {
986
+ id: entry.call.id!,
987
+ name: entry.call.name,
988
+ args: entry.args,
989
+ stepId: entry.stepId,
990
+ turn,
524
991
  };
525
992
 
526
- safeDispatchCustomEvent(GraphEvents.ON_TOOL_EXECUTE, request, config);
527
- }
528
- );
993
+ if (
994
+ CODE_EXECUTION_TOOLS.has(entry.call.name) ||
995
+ entry.call.name === Constants.SKILL_TOOL
996
+ ) {
997
+ request.codeSessionContext = this.getCodeSessionContext();
998
+ }
529
999
 
530
- this.storeCodeSessionFromResults(results, requests);
1000
+ return request;
1001
+ });
531
1002
 
532
- return results.map((result) => {
533
- const request = requests.find((r) => r.id === result.toolCallId);
534
- const toolName = request?.name ?? 'unknown';
535
- const stepId = this.toolCallStepIds?.get(result.toolCallId) ?? '';
536
- if (!stepId) {
537
- // eslint-disable-next-line no-console
538
- console.warn(
539
- `[ToolNode] toolCallStepIds missing entry for toolCallId=${result.toolCallId} (tool=${toolName}). ` +
540
- 'This indicates a race between the stream consumer and graph execution. ' +
541
- `Map size: ${this.toolCallStepIds?.size ?? 0}`
542
- );
543
- }
1003
+ const requestMap = new Map(requests.map((r) => [r.id, r]));
1004
+
1005
+ const results = await new Promise<t.ToolExecuteResult[]>(
1006
+ (resolve, reject) => {
1007
+ const batchRequest: t.ToolExecuteBatchRequest = {
1008
+ toolCalls: requests,
1009
+ userId: config.configurable?.user_id as string | undefined,
1010
+ agentId: this.agentId,
1011
+ configurable: config.configurable as
1012
+ | Record<string, unknown>
1013
+ | undefined,
1014
+ metadata: config.metadata as Record<string, unknown> | undefined,
1015
+ resolve,
1016
+ reject,
1017
+ };
544
1018
 
545
- let toolMessage: ToolMessage;
546
- let contentString: string;
1019
+ safeDispatchCustomEvent(
1020
+ GraphEvents.ON_TOOL_EXECUTE,
1021
+ batchRequest,
1022
+ config
1023
+ );
1024
+ }
1025
+ );
547
1026
 
548
- if (result.status === 'error') {
549
- contentString = `Error: ${result.errorMessage ?? 'Unknown error'}\n Please fix your mistakes.`;
550
- toolMessage = new ToolMessage({
551
- status: 'error',
552
- content: contentString,
553
- name: toolName,
554
- tool_call_id: result.toolCallId,
555
- });
556
- } else {
557
- const rawContent =
558
- typeof result.content === 'string'
559
- ? result.content
560
- : JSON.stringify(result.content);
561
- contentString = truncateToolResultContent(
562
- rawContent,
563
- this.maxToolResultChars
1027
+ this.storeCodeSessionFromResults(results, requestMap);
1028
+
1029
+ const hasPostHook =
1030
+ this.hookRegistry?.hasHookFor('PostToolUse', runId) === true;
1031
+ const hasFailureHook =
1032
+ this.hookRegistry?.hasHookFor('PostToolUseFailure', runId) === true;
1033
+
1034
+ for (const result of results) {
1035
+ if (result.injectedMessages && result.injectedMessages.length > 0) {
1036
+ try {
1037
+ injected.push(
1038
+ ...this.convertInjectedMessages(result.injectedMessages)
1039
+ );
1040
+ } catch (e) {
1041
+ // eslint-disable-next-line no-console
1042
+ console.warn(
1043
+ `[ToolNode] Failed to convert injectedMessages for toolCallId=${result.toolCallId}:`,
1044
+ e instanceof Error ? e.message : e
1045
+ );
1046
+ }
1047
+ }
1048
+ const request = requestMap.get(result.toolCallId);
1049
+ const toolName = request?.name ?? 'unknown';
1050
+
1051
+ let contentString: string;
1052
+ let toolMessage: ToolMessage;
1053
+
1054
+ if (result.status === 'error') {
1055
+ contentString = `Error: ${result.errorMessage ?? 'Unknown error'}\n Please fix your mistakes.`;
1056
+ /**
1057
+ * Error results bypass registration/annotation but must still
1058
+ * carry the unresolved-refs hint so the LLM can self-correct
1059
+ * when its reference key caused the failure.
1060
+ */
1061
+ const unresolved = unresolvedByCallId.get(result.toolCallId) ?? [];
1062
+ if (unresolved.length > 0) {
1063
+ contentString = this.applyOutputReference(
1064
+ registryRunId,
1065
+ contentString,
1066
+ contentString,
1067
+ undefined,
1068
+ unresolved
1069
+ );
1070
+ }
1071
+ toolMessage = new ToolMessage({
1072
+ status: 'error',
1073
+ content: contentString,
1074
+ name: toolName,
1075
+ tool_call_id: result.toolCallId,
1076
+ });
1077
+
1078
+ if (hasFailureHook) {
1079
+ await executeHooks({
1080
+ registry: this.hookRegistry!,
1081
+ input: {
1082
+ hook_event_name: 'PostToolUseFailure',
1083
+ runId,
1084
+ threadId,
1085
+ agentId: this.agentId,
1086
+ toolName,
1087
+ toolInput: request?.args ?? {},
1088
+ toolUseId: result.toolCallId,
1089
+ error: result.errorMessage ?? 'Unknown error',
1090
+ stepId: request?.stepId,
1091
+ turn: request?.turn,
1092
+ },
1093
+ sessionId: runId,
1094
+ matchQuery: toolName,
1095
+ }).catch(() => {
1096
+ /* PostToolUseFailure is observational — swallow errors */
1097
+ });
1098
+ }
1099
+ } else {
1100
+ let registryRaw =
1101
+ typeof result.content === 'string'
1102
+ ? result.content
1103
+ : JSON.stringify(result.content);
1104
+ contentString = truncateToolResultContent(
1105
+ registryRaw,
1106
+ this.maxToolResultChars
1107
+ );
1108
+
1109
+ if (hasPostHook) {
1110
+ const hookResult = await executeHooks({
1111
+ registry: this.hookRegistry!,
1112
+ input: {
1113
+ hook_event_name: 'PostToolUse',
1114
+ runId,
1115
+ threadId,
1116
+ agentId: this.agentId,
1117
+ toolName,
1118
+ toolInput: request?.args ?? {},
1119
+ toolOutput: result.content,
1120
+ toolUseId: result.toolCallId,
1121
+ stepId: request?.stepId,
1122
+ turn: request?.turn,
1123
+ },
1124
+ sessionId: runId,
1125
+ matchQuery: toolName,
1126
+ }).catch((): undefined => undefined);
1127
+ if (hookResult?.updatedOutput != null) {
1128
+ const replaced =
1129
+ typeof hookResult.updatedOutput === 'string'
1130
+ ? hookResult.updatedOutput
1131
+ : JSON.stringify(hookResult.updatedOutput);
1132
+ registryRaw = replaced;
1133
+ contentString = truncateToolResultContent(
1134
+ replaced,
1135
+ this.maxToolResultChars
1136
+ );
1137
+ }
1138
+ }
1139
+
1140
+ const batchIndex = batchIndexByCallId.get(result.toolCallId);
1141
+ const unresolved = unresolvedByCallId.get(result.toolCallId) ?? [];
1142
+ const refKey =
1143
+ this.toolOutputRegistry != null &&
1144
+ batchIndex != null &&
1145
+ turn != null
1146
+ ? buildReferenceKey(batchIndex, turn)
1147
+ : undefined;
1148
+ contentString = this.applyOutputReference(
1149
+ registryRunId,
1150
+ contentString,
1151
+ registryRaw,
1152
+ refKey,
1153
+ unresolved
1154
+ );
1155
+
1156
+ toolMessage = new ToolMessage({
1157
+ status: 'success',
1158
+ name: toolName,
1159
+ content: contentString,
1160
+ artifact: result.artifact,
1161
+ tool_call_id: result.toolCallId,
1162
+ });
1163
+ }
1164
+
1165
+ this.dispatchStepCompleted(
1166
+ result.toolCallId,
1167
+ toolName,
1168
+ request?.args ?? {},
1169
+ contentString,
1170
+ config,
1171
+ request?.turn
564
1172
  );
565
- toolMessage = new ToolMessage({
566
- status: 'success',
567
- name: toolName,
568
- content: contentString,
569
- artifact: result.artifact,
570
- tool_call_id: result.toolCallId,
571
- });
1173
+
1174
+ messageByCallId.set(result.toolCallId, toolMessage);
572
1175
  }
1176
+ }
573
1177
 
574
- const tool_call: t.ProcessedToolCall = {
575
- args:
576
- typeof request?.args === 'string'
577
- ? request.args
578
- : JSON.stringify(request?.args ?? {}),
579
- name: toolName,
580
- id: result.toolCallId,
581
- output: contentString,
582
- progress: 1,
583
- };
1178
+ const toolMessages = toolCalls
1179
+ .map((call) => messageByCallId.get(call.id!))
1180
+ .filter((m): m is ToolMessage => m != null);
1181
+ return { toolMessages, injected };
1182
+ }
1183
+
1184
+ private dispatchStepCompleted(
1185
+ toolCallId: string,
1186
+ toolName: string,
1187
+ args: Record<string, unknown>,
1188
+ output: string,
1189
+ config: RunnableConfig,
1190
+ turn?: number
1191
+ ): void {
1192
+ const stepId = this.toolCallStepIds?.get(toolCallId) ?? '';
1193
+ if (!stepId) {
1194
+ // eslint-disable-next-line no-console
1195
+ console.warn(
1196
+ `[ToolNode] toolCallStepIds missing entry for toolCallId=${toolCallId} (tool=${toolName}). ` +
1197
+ 'This indicates a race between the stream consumer and graph execution. ' +
1198
+ `Map size: ${this.toolCallStepIds?.size ?? 0}`
1199
+ );
1200
+ }
584
1201
 
585
- const runStepCompletedData = {
1202
+ safeDispatchCustomEvent(
1203
+ GraphEvents.ON_RUN_STEP_COMPLETED,
1204
+ {
586
1205
  result: {
587
1206
  id: stepId,
588
- index: request?.turn ?? 0,
1207
+ index: turn ?? this.toolUsageCount.get(toolName) ?? 0,
589
1208
  type: 'tool_call' as const,
590
- tool_call,
1209
+ tool_call: {
1210
+ args: JSON.stringify(args),
1211
+ name: toolName,
1212
+ id: toolCallId,
1213
+ output,
1214
+ progress: 1,
1215
+ } as t.ProcessedToolCall,
591
1216
  },
1217
+ },
1218
+ config
1219
+ );
1220
+ }
1221
+
1222
+ /**
1223
+ * Converts InjectedMessage instances to LangChain HumanMessage objects.
1224
+ * Both 'user' and 'system' roles become HumanMessage to avoid provider
1225
+ * rejections (Anthropic/Google reject non-leading SystemMessages).
1226
+ * The original role is preserved in additional_kwargs for downstream consumers.
1227
+ */
1228
+ private convertInjectedMessages(
1229
+ messages: t.InjectedMessage[]
1230
+ ): BaseMessage[] {
1231
+ const converted: BaseMessage[] = [];
1232
+ for (const msg of messages) {
1233
+ const additional_kwargs: Record<string, unknown> = {
1234
+ role: msg.role,
592
1235
  };
1236
+ if (msg.isMeta != null) additional_kwargs.isMeta = msg.isMeta;
1237
+ if (msg.source != null) additional_kwargs.source = msg.source;
1238
+ if (msg.skillName != null) additional_kwargs.skillName = msg.skillName;
593
1239
 
594
- safeDispatchCustomEvent(
595
- GraphEvents.ON_RUN_STEP_COMPLETED,
596
- runStepCompletedData,
597
- config
1240
+ converted.push(
1241
+ new HumanMessage({ content: msg.content, additional_kwargs })
598
1242
  );
599
-
600
- return toolMessage;
601
- });
1243
+ }
1244
+ return converted;
602
1245
  }
603
1246
 
604
1247
  /**
605
1248
  * Execute all tool calls via ON_TOOL_EXECUTE event dispatch.
606
- * Used in event-driven mode where the host handles actual tool execution.
1249
+ * Injected messages are placed AFTER ToolMessages to respect provider
1250
+ * message ordering (AIMessage tool_calls must be immediately followed
1251
+ * by their ToolMessage results).
1252
+ *
1253
+ * `batchIndices` mirrors `toolCalls` and carries each call's position
1254
+ * within the parent batch. `turn` is the per-`run()` batch index
1255
+ * captured locally by the caller. Both are threaded so concurrent
1256
+ * invocations cannot race on shared mutable state.
607
1257
  */
608
1258
  private async executeViaEvent(
609
1259
  toolCalls: ToolCall[],
610
1260
  config: RunnableConfig,
611
1261
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
612
- input: any
1262
+ input: any,
1263
+ batchContext: DispatchBatchContext = {}
613
1264
  ): Promise<T> {
614
- const outputs = await this.dispatchToolEvents(toolCalls, config);
1265
+ const { toolMessages, injected } = await this.dispatchToolEvents(
1266
+ toolCalls,
1267
+ config,
1268
+ batchContext
1269
+ );
1270
+ const outputs: BaseMessage[] = [...toolMessages, ...injected];
615
1271
  return (Array.isArray(input) ? outputs : { messages: outputs }) as T;
616
1272
  }
617
1273
 
618
1274
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
619
1275
  protected async run(input: any, config: RunnableConfig): Promise<T> {
620
1276
  this.toolCallTurns.clear();
1277
+ /**
1278
+ * Per-batch local map for resolved (post-substitution) args.
1279
+ * Lives on the stack so concurrent `run()` calls on the same
1280
+ * ToolNode cannot read or wipe each other's entries.
1281
+ */
1282
+ const resolvedArgsByCallId = new Map<string, Record<string, unknown>>();
1283
+ /**
1284
+ * Claim this batch's turn synchronously from the registry (or
1285
+ * fall back to 0 when the feature is disabled). The registry is
1286
+ * partitioned by scope id so overlapping batches cannot
1287
+ * overwrite each other's state even under a shared registry.
1288
+ *
1289
+ * For anonymous callers (no `run_id` in config), mint a unique
1290
+ * per-batch scope id so two concurrent anonymous invocations
1291
+ * don't target the same bucket. The scope is threaded down to
1292
+ * every subsequent registry call on this batch.
1293
+ */
1294
+ const incomingRunId = config.configurable?.run_id as string | undefined;
1295
+ const batchScopeId = incomingRunId ?? `\0anon-${this.anonBatchCounter++}`;
1296
+ const turn = this.toolOutputRegistry?.nextTurn(batchScopeId) ?? 0;
621
1297
  let outputs: (BaseMessage | Command)[];
622
1298
 
623
1299
  if (this.isSendInput(input)) {
624
1300
  const isDirectTool = this.directToolNames?.has(input.lg_tool_call.name);
625
1301
  if (this.eventDrivenMode && isDirectTool !== true) {
626
- return this.executeViaEvent([input.lg_tool_call], config, input);
1302
+ return this.executeViaEvent([input.lg_tool_call], config, input, {
1303
+ batchIndices: [0],
1304
+ turn,
1305
+ batchScopeId,
1306
+ });
627
1307
  }
628
- outputs = [await this.runTool(input.lg_tool_call, config)];
629
- this.handleRunToolCompletions([input.lg_tool_call], outputs, config);
1308
+ outputs = [
1309
+ await this.runTool(input.lg_tool_call, config, {
1310
+ batchIndex: 0,
1311
+ turn,
1312
+ batchScopeId,
1313
+ resolvedArgsByCallId,
1314
+ }),
1315
+ ];
1316
+ this.handleRunToolCompletions(
1317
+ [input.lg_tool_call],
1318
+ outputs,
1319
+ config,
1320
+ resolvedArgsByCallId
1321
+ );
630
1322
  } else {
631
1323
  let messages: BaseMessage[];
632
1324
  if (Array.isArray(input)) {
@@ -685,39 +1377,132 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
685
1377
  }) ?? [];
686
1378
 
687
1379
  if (this.eventDrivenMode && filteredCalls.length > 0) {
1380
+ const filteredIndices = filteredCalls.map((_, idx) => idx);
1381
+
688
1382
  if (!this.directToolNames || this.directToolNames.size === 0) {
689
- return this.executeViaEvent(filteredCalls, config, input);
1383
+ return this.executeViaEvent(filteredCalls, config, input, {
1384
+ batchIndices: filteredIndices,
1385
+ turn,
1386
+ batchScopeId,
1387
+ });
690
1388
  }
691
1389
 
692
- const directCalls = filteredCalls.filter((c) =>
693
- this.directToolNames!.has(c.name)
694
- );
695
- const eventCalls = filteredCalls.filter(
696
- (c) => !this.directToolNames!.has(c.name)
697
- );
1390
+ const directEntries: Array<{ call: ToolCall; batchIndex: number }> = [];
1391
+ const eventEntries: Array<{ call: ToolCall; batchIndex: number }> = [];
1392
+ for (let i = 0; i < filteredCalls.length; i++) {
1393
+ const call = filteredCalls[i];
1394
+ const entry = { call, batchIndex: i };
1395
+ if (this.directToolNames!.has(call.name)) {
1396
+ directEntries.push(entry);
1397
+ } else {
1398
+ eventEntries.push(entry);
1399
+ }
1400
+ }
1401
+
1402
+ const directCalls = directEntries.map((e) => e.call);
1403
+ const directIndices = directEntries.map((e) => e.batchIndex);
1404
+ const eventCalls = eventEntries.map((e) => e.call);
1405
+ const eventIndices = eventEntries.map((e) => e.batchIndex);
1406
+
1407
+ /**
1408
+ * Snapshot the event calls' args against the *pre-batch*
1409
+ * registry state synchronously, before any await runs. The
1410
+ * directs are then awaited first (preserving fail-fast
1411
+ * semantics — a thrown error in a direct tool, e.g. with
1412
+ * `handleToolErrors=false` or a `GraphInterrupt`, aborts
1413
+ * before we dispatch any event-driven tools to the host).
1414
+ * Because the event args were captured pre-await, they stay
1415
+ * isolated from same-turn direct outputs that register
1416
+ * during the await.
1417
+ */
1418
+ const preResolvedEventArgs = new Map<
1419
+ string,
1420
+ { resolved: Record<string, unknown>; unresolved: string[] }
1421
+ >();
1422
+ /**
1423
+ * Take a frozen snapshot of the registry state before any
1424
+ * direct registrations land. The snapshot resolves
1425
+ * placeholders against this point-in-time view, so a
1426
+ * `PreToolUse` hook later rewriting event args via
1427
+ * `updatedInput` can introduce placeholders that resolve
1428
+ * cross-batch (against prior runs) without ever picking up
1429
+ * same-turn direct outputs.
1430
+ */
1431
+ const preBatchSnapshot =
1432
+ this.toolOutputRegistry?.snapshot(batchScopeId);
1433
+ if (preBatchSnapshot != null) {
1434
+ for (const entry of eventEntries) {
1435
+ if (entry.call.id != null) {
1436
+ const { resolved, unresolved } = preBatchSnapshot.resolve(
1437
+ entry.call.args as Record<string, unknown>
1438
+ );
1439
+ preResolvedEventArgs.set(entry.call.id, {
1440
+ resolved: resolved as Record<string, unknown>,
1441
+ unresolved,
1442
+ });
1443
+ }
1444
+ }
1445
+ }
698
1446
 
699
1447
  const directOutputs: (BaseMessage | Command)[] =
700
1448
  directCalls.length > 0
701
1449
  ? await Promise.all(
702
- directCalls.map((call) => this.runTool(call, config))
1450
+ directCalls.map((call, i) =>
1451
+ this.runTool(call, config, {
1452
+ batchIndex: directIndices[i],
1453
+ turn,
1454
+ batchScopeId,
1455
+ resolvedArgsByCallId,
1456
+ })
1457
+ )
703
1458
  )
704
1459
  : [];
705
1460
 
706
1461
  if (directCalls.length > 0 && directOutputs.length > 0) {
707
- this.handleRunToolCompletions(directCalls, directOutputs, config);
1462
+ this.handleRunToolCompletions(
1463
+ directCalls,
1464
+ directOutputs,
1465
+ config,
1466
+ resolvedArgsByCallId
1467
+ );
708
1468
  }
709
1469
 
710
- const eventOutputs: ToolMessage[] =
1470
+ const eventResult =
711
1471
  eventCalls.length > 0
712
- ? await this.dispatchToolEvents(eventCalls, config)
713
- : [];
714
-
715
- outputs = [...directOutputs, ...eventOutputs];
1472
+ ? await this.dispatchToolEvents(eventCalls, config, {
1473
+ batchIndices: eventIndices,
1474
+ turn,
1475
+ batchScopeId,
1476
+ preResolvedArgs: preResolvedEventArgs,
1477
+ preBatchSnapshot,
1478
+ })
1479
+ : {
1480
+ toolMessages: [] as ToolMessage[],
1481
+ injected: [] as BaseMessage[],
1482
+ };
1483
+
1484
+ outputs = [
1485
+ ...directOutputs,
1486
+ ...eventResult.toolMessages,
1487
+ ...eventResult.injected,
1488
+ ];
716
1489
  } else {
717
1490
  outputs = await Promise.all(
718
- filteredCalls.map((call) => this.runTool(call, config))
1491
+ filteredCalls.map((call, i) =>
1492
+ this.runTool(call, config, {
1493
+ batchIndex: i,
1494
+ turn,
1495
+ batchScopeId,
1496
+ resolvedArgsByCallId,
1497
+ })
1498
+ )
1499
+ );
1500
+ this.handleRunToolCompletions(
1501
+ filteredCalls,
1502
+ outputs,
1503
+ config,
1504
+ resolvedArgsByCallId
719
1505
  );
720
- this.handleRunToolCompletions(filteredCalls, outputs, config);
721
1506
  }
722
1507
  }
723
1508