@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,6 @@
1
- import { isBaseMessage, ToolMessage, isAIMessage } from '@langchain/core/messages';
1
+ import { isBaseMessage, ToolMessage, HumanMessage, isAIMessage } from '@langchain/core/messages';
2
2
  import { isCommand, isGraphInterrupt, Command, Send, END } from '@langchain/langgraph';
3
- import { Constants, GraphEvents } from '../common/enum.mjs';
3
+ import { Constants, CODE_EXECUTION_TOOLS, GraphEvents } from '../common/enum.mjs';
4
4
  import 'nanoid';
5
5
  import '../messages/core.mjs';
6
6
  import { calculateMaxToolResultChars, truncateToolResultContent } from '../utils/truncation.mjs';
@@ -9,6 +9,8 @@ import 'uuid';
9
9
  import { RunnableCallable } from '../utils/run.mjs';
10
10
  import 'ai-tokenizer';
11
11
  import 'zod-to-json-schema';
12
+ import { executeHooks } from '../hooks/executeHooks.mjs';
13
+ import { ToolOutputReferenceRegistry, annotateToolOutputWithReference, buildReferenceKey } from './toolOutputReferences.mjs';
12
14
 
13
15
  /**
14
16
  * Helper to check if a value is a Send object
@@ -16,6 +18,32 @@ import 'zod-to-json-schema';
16
18
  function isSend(value) {
17
19
  return value instanceof Send;
18
20
  }
21
+ /** Merges code execution session context into the sessions map. */
22
+ function updateCodeSession(sessions, sessionId, files) {
23
+ const newFiles = files ?? [];
24
+ const existingSession = sessions.get(Constants.EXECUTE_CODE);
25
+ const existingFiles = existingSession?.files ?? [];
26
+ if (newFiles.length > 0) {
27
+ const filesWithSession = newFiles.map((file) => ({
28
+ ...file,
29
+ session_id: sessionId,
30
+ }));
31
+ const newFileNames = new Set(filesWithSession.map((f) => f.name));
32
+ const filteredExisting = existingFiles.filter((f) => !newFileNames.has(f.name));
33
+ sessions.set(Constants.EXECUTE_CODE, {
34
+ session_id: sessionId,
35
+ files: [...filteredExisting, ...filesWithSession],
36
+ lastUpdated: Date.now(),
37
+ });
38
+ }
39
+ else {
40
+ sessions.set(Constants.EXECUTE_CODE, {
41
+ session_id: sessionId,
42
+ files: existingFiles,
43
+ lastUpdated: Date.now(),
44
+ });
45
+ }
46
+ }
19
47
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
20
48
  class ToolNode extends RunnableCallable {
21
49
  toolMap;
@@ -41,7 +69,29 @@ class ToolNode extends RunnableCallable {
41
69
  directToolNames;
42
70
  /** Maximum characters allowed in a single tool result before truncation. */
43
71
  maxToolResultChars;
44
- constructor({ tools, toolMap, name, tags, errorHandler, toolCallStepIds, handleToolErrors, loadRuntimeTools, toolRegistry, sessions, eventDrivenMode, agentId, directToolNames, maxContextTokens, maxToolResultChars, }) {
72
+ /** Hook registry for PreToolUse/PostToolUse lifecycle hooks */
73
+ hookRegistry;
74
+ /**
75
+ * Registry of tool outputs keyed by `tool<idx>turn<turn>`.
76
+ *
77
+ * Populated only when `toolOutputReferences.enabled` is true. The
78
+ * registry owns the run-scoped state (turn counter, last-seen runId,
79
+ * warn-once memo, stored outputs), so sharing a single instance
80
+ * across multiple ToolNodes in a run lets cross-agent `{{…}}`
81
+ * references resolve — which is why multi-agent graphs pass the
82
+ * *same* instance to every ToolNode they compile rather than each
83
+ * ToolNode building its own.
84
+ */
85
+ toolOutputRegistry;
86
+ /**
87
+ * Monotonic counter used to mint a unique scope id for anonymous
88
+ * batches (ones invoked without a `run_id` in
89
+ * `config.configurable`). Each such batch gets its own registry
90
+ * partition so concurrent anonymous invocations can't delete each
91
+ * other's in-flight state.
92
+ */
93
+ anonBatchCounter = 0;
94
+ constructor({ tools, toolMap, name, tags, errorHandler, toolCallStepIds, handleToolErrors, loadRuntimeTools, toolRegistry, sessions, eventDrivenMode, agentId, directToolNames, maxContextTokens, maxToolResultChars, hookRegistry, toolOutputReferences, toolOutputRegistry, }) {
45
95
  super({ name, tags, func: (input, config) => this.run(input, config) });
46
96
  this.toolMap = toolMap ?? new Map(tools.map((tool) => [tool.name, tool]));
47
97
  this.toolCallStepIds = toolCallStepIds;
@@ -56,6 +106,38 @@ class ToolNode extends RunnableCallable {
56
106
  this.directToolNames = directToolNames;
57
107
  this.maxToolResultChars =
58
108
  maxToolResultChars ?? calculateMaxToolResultChars(maxContextTokens);
109
+ this.hookRegistry = hookRegistry;
110
+ /**
111
+ * Precedence: an explicitly passed `toolOutputRegistry` instance
112
+ * wins over a config object so a host (`Graph`) can share one
113
+ * registry across many ToolNodes. When only the config is
114
+ * provided (direct ToolNode usage), build a local registry so
115
+ * the feature still works without graph-level plumbing. Registry
116
+ * caps are intentionally decoupled from `maxToolResultChars`:
117
+ * the registry stores the raw untruncated output so a later
118
+ * `{{…}}` substitution pipes the full payload into the next
119
+ * tool, even when the LLM saw a truncated preview.
120
+ */
121
+ if (toolOutputRegistry != null) {
122
+ this.toolOutputRegistry = toolOutputRegistry;
123
+ }
124
+ else if (toolOutputReferences?.enabled === true) {
125
+ this.toolOutputRegistry = new ToolOutputReferenceRegistry({
126
+ maxOutputSize: toolOutputReferences.maxOutputSize,
127
+ maxTotalSize: toolOutputReferences.maxTotalSize,
128
+ });
129
+ }
130
+ }
131
+ /**
132
+ * Returns the run-scoped tool output registry, or `undefined` when
133
+ * the feature is disabled.
134
+ *
135
+ * @internal Exposed for test observation only. Host code should rely
136
+ * on `{{tool<i>turn<n>}}` substitution at tool-invocation time and
137
+ * not mutate the registry directly.
138
+ */
139
+ _unsafeGetToolOutputRegistry() {
140
+ return this.toolOutputRegistry;
59
141
  }
60
142
  /**
61
143
  * Returns cached programmatic tools, computing once on first access.
@@ -87,31 +169,89 @@ class ToolNode extends RunnableCallable {
87
169
  return new Map(this.toolUsageCount); // Return a copy
88
170
  }
89
171
  /**
90
- * Runs a single tool call with error handling
172
+ * Runs a single tool call with error handling.
173
+ *
174
+ * `batchIndex` is the tool's position within the current ToolNode
175
+ * batch and, together with `this.currentTurn`, forms the key used to
176
+ * register the output for future `{{tool<idx>turn<turn>}}`
177
+ * substitutions. Omit when no registration should occur.
91
178
  */
92
- async runTool(call, config) {
179
+ async runTool(call, config, batchContext = {}) {
180
+ const { batchIndex, turn, batchScopeId, resolvedArgsByCallId } = batchContext;
93
181
  const tool = this.toolMap.get(call.name);
182
+ const registry = this.toolOutputRegistry;
183
+ /**
184
+ * Precompute the reference key once per call — captured locally
185
+ * so concurrent `invoke()` calls on the same ToolNode cannot race
186
+ * on a shared turn field.
187
+ */
188
+ const refKey = registry != null && batchIndex != null && turn != null
189
+ ? buildReferenceKey(batchIndex, turn)
190
+ : undefined;
191
+ /**
192
+ * Hoisted outside the try so the catch branch can append
193
+ * `[unresolved refs: …]` to error messages — otherwise the LLM
194
+ * only sees a generic error when it references a bad key, losing
195
+ * the self-correction signal this feature is meant to provide.
196
+ */
197
+ let unresolvedRefs = [];
198
+ /**
199
+ * Use the caller-provided `batchScopeId` when threaded from
200
+ * `run()` (so anonymous batches get their own unique scope).
201
+ * Fall back to the config's `run_id` when runTool is invoked
202
+ * from a context that doesn't thread it — that still preserves
203
+ * the runId-based partitioning for named runs.
204
+ */
205
+ const runId = batchScopeId ?? config.configurable?.run_id;
94
206
  try {
95
207
  if (tool === undefined) {
96
208
  throw new Error(`Tool "${call.name}" not found.`);
97
209
  }
98
- const turn = this.toolUsageCount.get(call.name) ?? 0;
99
- this.toolUsageCount.set(call.name, turn + 1);
210
+ /**
211
+ * `usageCount` is the per-tool-name invocation index that
212
+ * web-search and other tools observe via `invokeParams.turn`.
213
+ * It is intentionally distinct from the outer `turn` parameter
214
+ * (the batch turn used for ref keys); the latter is captured
215
+ * before the try block when constructing `refKey`.
216
+ */
217
+ const usageCount = this.toolUsageCount.get(call.name) ?? 0;
218
+ this.toolUsageCount.set(call.name, usageCount + 1);
100
219
  if (call.id != null && call.id !== '') {
101
- this.toolCallTurns.set(call.id, turn);
220
+ this.toolCallTurns.set(call.id, usageCount);
221
+ }
222
+ let args = call.args;
223
+ if (registry != null) {
224
+ const { resolved, unresolved } = registry.resolve(runId, args);
225
+ args = resolved;
226
+ unresolvedRefs = unresolved;
227
+ /**
228
+ * Expose the post-substitution args to downstream completion
229
+ * events so audit logs / host-side `ON_RUN_STEP_COMPLETED`
230
+ * handlers observe what actually ran, not the `{{…}}`
231
+ * template. Only string/object args are worth recording.
232
+ */
233
+ if (resolvedArgsByCallId != null &&
234
+ call.id != null &&
235
+ call.id !== '' &&
236
+ resolved !== call.args &&
237
+ typeof resolved === 'object') {
238
+ resolvedArgsByCallId.set(call.id, resolved);
239
+ }
102
240
  }
103
- const args = call.args;
104
241
  const stepId = this.toolCallStepIds?.get(call.id);
105
242
  // Build invoke params - LangChain extracts non-schema fields to config.toolCall
243
+ // `turn` here is the per-tool usage count (matches what tools have
244
+ // observed historically via config.toolCall.turn — e.g. web search).
106
245
  let invokeParams = {
107
246
  ...call,
108
247
  args,
109
248
  type: 'tool_call',
110
249
  stepId,
111
- turn,
250
+ turn: usageCount,
112
251
  };
113
252
  // Inject runtime data for special tools (becomes available at config.toolCall)
114
- if (call.name === Constants.PROGRAMMATIC_TOOL_CALLING) {
253
+ if (call.name === Constants.PROGRAMMATIC_TOOL_CALLING ||
254
+ call.name === Constants.BASH_PROGRAMMATIC_TOOL_CALLING) {
115
255
  const { toolMap, toolDefs } = this.getProgrammaticTools();
116
256
  invokeParams = {
117
257
  ...invokeParams,
@@ -134,8 +274,7 @@ class ToolNode extends RunnableCallable {
134
274
  * session_id is always injected when available (even without tracked files)
135
275
  * so the CodeExecutor can fall back to the /files endpoint for session continuity.
136
276
  */
137
- if (call.name === Constants.EXECUTE_CODE ||
138
- call.name === Constants.PROGRAMMATIC_TOOL_CALLING) {
277
+ if (CODE_EXECUTION_TOOLS.has(call.name)) {
139
278
  const codeSession = this.sessions?.get(Constants.EXECUTE_CODE);
140
279
  if (codeSession?.session_id != null && codeSession.session_id !== '') {
141
280
  invokeParams = {
@@ -153,19 +292,72 @@ class ToolNode extends RunnableCallable {
153
292
  }
154
293
  }
155
294
  const output = await tool.invoke(invokeParams, config);
156
- if ((isBaseMessage(output) && output._getType() === 'tool') ||
157
- isCommand(output)) {
295
+ if (isCommand(output)) {
158
296
  return output;
159
297
  }
160
- else {
161
- const rawContent = typeof output === 'string' ? output : JSON.stringify(output);
162
- return new ToolMessage({
163
- status: 'success',
164
- name: tool.name,
165
- content: truncateToolResultContent(rawContent, this.maxToolResultChars),
166
- tool_call_id: call.id,
167
- });
298
+ if (isBaseMessage(output) && output._getType() === 'tool') {
299
+ const toolMsg = output;
300
+ const isError = toolMsg.status === 'error';
301
+ if (isError) {
302
+ /**
303
+ * Error ToolMessages bypass registration/annotation but must
304
+ * still carry the unresolved-refs hint so the LLM can
305
+ * self-correct when its reference key caused the failure.
306
+ */
307
+ if (unresolvedRefs.length > 0 &&
308
+ typeof toolMsg.content === 'string') {
309
+ toolMsg.content = this.applyOutputReference(runId, toolMsg.content, toolMsg.content, undefined, unresolvedRefs);
310
+ }
311
+ return toolMsg;
312
+ }
313
+ if (this.toolOutputRegistry != null || unresolvedRefs.length > 0) {
314
+ if (typeof toolMsg.content === 'string') {
315
+ const rawContent = toolMsg.content;
316
+ const llmContent = truncateToolResultContent(rawContent, this.maxToolResultChars);
317
+ toolMsg.content = this.applyOutputReference(runId, llmContent, rawContent, refKey, unresolvedRefs);
318
+ }
319
+ else {
320
+ /**
321
+ * Non-string content (multi-part content blocks — text +
322
+ * image). Known limitation: we cannot register under a
323
+ * reference key because there's no canonical serialized
324
+ * form. Warn once per tool per run when the caller
325
+ * intended to register.
326
+ *
327
+ * Still surface unresolved-ref warnings so the LLM gets
328
+ * the self-correction signal that the string and error
329
+ * paths already emit. Prepended as a leading text block
330
+ * to keep the original content ordering intact.
331
+ */
332
+ if (unresolvedRefs.length > 0 && Array.isArray(toolMsg.content)) {
333
+ const warningBlock = {
334
+ type: 'text',
335
+ text: `[unresolved refs: ${unresolvedRefs.join(', ')}]`,
336
+ };
337
+ toolMsg.content = [
338
+ warningBlock,
339
+ ...toolMsg.content,
340
+ ];
341
+ }
342
+ if (refKey != null &&
343
+ this.toolOutputRegistry.claimWarnOnce(runId, call.name)) {
344
+ // eslint-disable-next-line no-console
345
+ console.warn(`[ToolNode] Skipping tool output reference for "${call.name}": ` +
346
+ 'ToolMessage content is not a string (further occurrences for this tool in the same run are suppressed).');
347
+ }
348
+ }
349
+ }
350
+ return toolMsg;
168
351
  }
352
+ const rawContent = typeof output === 'string' ? output : JSON.stringify(output);
353
+ const truncated = truncateToolResultContent(rawContent, this.maxToolResultChars);
354
+ const content = this.applyOutputReference(runId, truncated, rawContent, refKey, unresolvedRefs);
355
+ return new ToolMessage({
356
+ status: 'success',
357
+ name: tool.name,
358
+ content,
359
+ tool_call_id: call.id,
360
+ });
169
361
  }
170
362
  catch (_e) {
171
363
  const e = _e;
@@ -208,14 +400,52 @@ class ToolNode extends RunnableCallable {
208
400
  });
209
401
  }
210
402
  }
403
+ let errorContent = `Error: ${e.message}\n Please fix your mistakes.`;
404
+ if (unresolvedRefs.length > 0) {
405
+ errorContent = this.applyOutputReference(runId, errorContent, errorContent, undefined, unresolvedRefs);
406
+ }
211
407
  return new ToolMessage({
212
408
  status: 'error',
213
- content: `Error: ${e.message}\n Please fix your mistakes.`,
409
+ content: errorContent,
214
410
  name: call.name,
215
411
  tool_call_id: call.id ?? '',
216
412
  });
217
413
  }
218
414
  }
415
+ /**
416
+ * Finalizes the LLM-visible content for a tool call and (when a
417
+ * `refKey` is provided) registers the full, raw output under that
418
+ * key.
419
+ *
420
+ * @param llmContent The content string the LLM will see. This is
421
+ * the already-truncated, post-hook view; the annotation is
422
+ * applied on top of it.
423
+ * @param registryContent The full, untruncated output to store in
424
+ * the registry so `{{tool<i>turn<n>}}` substitutions deliver the
425
+ * complete payload. Ignored when `refKey` is undefined.
426
+ * @param refKey Precomputed `tool<i>turn<n>` key, or undefined when
427
+ * the output is not to be registered (errors, disabled feature,
428
+ * unavailable batch/turn).
429
+ * @param unresolved Placeholder keys that did not resolve; appended
430
+ * as `[unresolved refs: …]` so the LLM can self-correct.
431
+ *
432
+ * `refKey` is passed in (rather than built from `this.currentTurn`)
433
+ * so parallel `invoke()` calls on the same ToolNode cannot race on
434
+ * the shared turn field.
435
+ */
436
+ applyOutputReference(runId, llmContent, registryContent, refKey, unresolved) {
437
+ if (this.toolOutputRegistry != null && refKey != null) {
438
+ this.toolOutputRegistry.set(runId, refKey, registryContent);
439
+ }
440
+ /**
441
+ * `annotateToolOutputWithReference` handles both the ref-key and
442
+ * unresolved-refs cases together so JSON-object outputs stay
443
+ * parseable: unresolved refs land in an `_unresolved_refs` field
444
+ * instead of as a trailing text line that would break
445
+ * `JSON.parse` for downstream consumers.
446
+ */
447
+ return annotateToolOutputWithReference(llmContent, refKey, unresolved);
448
+ }
219
449
  /**
220
450
  * Builds code session context for injection into event-driven tool calls.
221
451
  * Mirrors the session injection logic in runTool() for direct execution.
@@ -244,7 +474,7 @@ class ToolNode extends RunnableCallable {
244
474
  * Extracts code execution session context from tool results and stores in Graph.sessions.
245
475
  * Mirrors the session storage logic in handleRunToolCompletions for direct execution.
246
476
  */
247
- storeCodeSessionFromResults(results, requests) {
477
+ storeCodeSessionFromResults(results, requestMap) {
248
478
  if (!this.sessions) {
249
479
  return;
250
480
  }
@@ -253,38 +483,17 @@ class ToolNode extends RunnableCallable {
253
483
  if (result.status !== 'success' || result.artifact == null) {
254
484
  continue;
255
485
  }
256
- const request = requests.find((r) => r.id === result.toolCallId);
257
- if (request?.name !== Constants.EXECUTE_CODE &&
258
- request?.name !== Constants.PROGRAMMATIC_TOOL_CALLING) {
486
+ const request = requestMap.get(result.toolCallId);
487
+ if (!request?.name ||
488
+ (!CODE_EXECUTION_TOOLS.has(request.name) &&
489
+ request.name !== Constants.SKILL_TOOL)) {
259
490
  continue;
260
491
  }
261
492
  const artifact = result.artifact;
262
493
  if (artifact?.session_id == null || artifact.session_id === '') {
263
494
  continue;
264
495
  }
265
- const newFiles = artifact.files ?? [];
266
- const existingSession = this.sessions.get(Constants.EXECUTE_CODE);
267
- const existingFiles = existingSession?.files ?? [];
268
- if (newFiles.length > 0) {
269
- const filesWithSession = newFiles.map((file) => ({
270
- ...file,
271
- session_id: artifact.session_id,
272
- }));
273
- const newFileNames = new Set(filesWithSession.map((f) => f.name));
274
- const filteredExisting = existingFiles.filter((f) => !newFileNames.has(f.name));
275
- this.sessions.set(Constants.EXECUTE_CODE, {
276
- session_id: artifact.session_id,
277
- files: [...filteredExisting, ...filesWithSession],
278
- lastUpdated: Date.now(),
279
- });
280
- }
281
- else {
282
- this.sessions.set(Constants.EXECUTE_CODE, {
283
- session_id: artifact.session_id,
284
- files: existingFiles,
285
- lastUpdated: Date.now(),
286
- });
287
- }
496
+ updateCodeSession(this.sessions, artifact.session_id, artifact.files);
288
497
  }
289
498
  }
290
499
  /**
@@ -295,8 +504,12 @@ class ToolNode extends RunnableCallable {
295
504
  * By handling completions here in graph context (rather than in the
296
505
  * stream consumer via ToolEndHandler), the race between the stream
297
506
  * consumer and graph execution is eliminated.
507
+ *
508
+ * @param resolvedArgsByCallId Per-batch resolved-args sink populated
509
+ * by `runTool`. Threaded as a local map (instead of instance state)
510
+ * so concurrent batches cannot read each other's entries.
298
511
  */
299
- handleRunToolCompletions(calls, outputs, config) {
512
+ handleRunToolCompletions(calls, outputs, config, resolvedArgsByCallId) {
300
513
  for (let i = 0; i < calls.length; i++) {
301
514
  const call = calls[i];
302
515
  const output = outputs[i];
@@ -311,35 +524,10 @@ class ToolNode extends RunnableCallable {
311
524
  if (toolMessage.status === 'error' && this.errorHandler != null) {
312
525
  continue;
313
526
  }
314
- // Store code session context from tool results
315
- if (this.sessions &&
316
- (call.name === Constants.EXECUTE_CODE ||
317
- call.name === Constants.PROGRAMMATIC_TOOL_CALLING)) {
527
+ if (this.sessions && CODE_EXECUTION_TOOLS.has(call.name)) {
318
528
  const artifact = toolMessage.artifact;
319
529
  if (artifact?.session_id != null && artifact.session_id !== '') {
320
- const newFiles = artifact.files ?? [];
321
- const existingSession = this.sessions.get(Constants.EXECUTE_CODE);
322
- const existingFiles = existingSession?.files ?? [];
323
- if (newFiles.length > 0) {
324
- const filesWithSession = newFiles.map((file) => ({
325
- ...file,
326
- session_id: artifact.session_id,
327
- }));
328
- const newFileNames = new Set(filesWithSession.map((f) => f.name));
329
- const filteredExisting = existingFiles.filter((f) => !newFileNames.has(f.name));
330
- this.sessions.set(Constants.EXECUTE_CODE, {
331
- session_id: artifact.session_id,
332
- files: [...filteredExisting, ...filesWithSession],
333
- lastUpdated: Date.now(),
334
- });
335
- }
336
- else {
337
- this.sessions.set(Constants.EXECUTE_CODE, {
338
- session_id: artifact.session_id,
339
- files: existingFiles,
340
- lastUpdated: Date.now(),
341
- });
342
- }
530
+ updateCodeSession(this.sessions, artifact.session_id, artifact.files);
343
531
  }
344
532
  }
345
533
  // Dispatch ON_RUN_STEP_COMPLETED via custom event (same path as dispatchToolEvents)
@@ -350,10 +538,17 @@ class ToolNode extends RunnableCallable {
350
538
  const contentString = typeof toolMessage.content === 'string'
351
539
  ? toolMessage.content
352
540
  : JSON.stringify(toolMessage.content);
541
+ /**
542
+ * Prefer the post-substitution args when a `{{…}}` placeholder
543
+ * was resolved in `runTool`. This keeps
544
+ * `ON_RUN_STEP_COMPLETED.tool_call.args` consistent with what
545
+ * the tool actually received rather than leaking the template.
546
+ */
547
+ const effectiveArgs = resolvedArgsByCallId?.get(toolCallId) ?? call.args;
353
548
  const tool_call = {
354
- args: typeof call.args === 'string'
355
- ? call.args
356
- : JSON.stringify(call.args ?? {}),
549
+ args: typeof effectiveArgs === 'string'
550
+ ? effectiveArgs
551
+ : JSON.stringify(effectiveArgs ?? {}),
357
552
  name: call.name,
358
553
  id: toolCallId,
359
554
  output: contentString,
@@ -372,113 +567,415 @@ class ToolNode extends RunnableCallable {
372
567
  /**
373
568
  * Dispatches tool calls to the host via ON_TOOL_EXECUTE event and returns raw ToolMessages.
374
569
  * Core logic for event-driven execution, separated from output shaping.
570
+ *
571
+ * Hook lifecycle (when `hookRegistry` is set):
572
+ * 1. **PreToolUse** fires per call in parallel before dispatch. Denied
573
+ * calls produce error ToolMessages and fire **PermissionDenied**;
574
+ * surviving calls proceed with optional `updatedInput`.
575
+ * 2. Surviving calls are dispatched to the host via `ON_TOOL_EXECUTE`.
576
+ * 3. **PostToolUse** / **PostToolUseFailure** fire per result. Post hooks
577
+ * can replace tool output via `updatedOutput`.
578
+ * 4. Injected messages from results are collected and returned alongside
579
+ * ToolMessages (appended AFTER to respect provider ordering).
375
580
  */
376
- async dispatchToolEvents(toolCalls, config) {
377
- const requests = toolCalls.map((call) => {
378
- const turn = this.toolUsageCount.get(call.name) ?? 0;
379
- this.toolUsageCount.set(call.name, turn + 1);
380
- const request = {
381
- id: call.id,
382
- name: call.name,
383
- args: call.args,
384
- stepId: this.toolCallStepIds?.get(call.id),
385
- turn,
386
- };
387
- if (call.name === Constants.EXECUTE_CODE ||
388
- call.name === Constants.PROGRAMMATIC_TOOL_CALLING) {
389
- request.codeSessionContext = this.getCodeSessionContext();
581
+ async dispatchToolEvents(toolCalls, config, batchContext = {}) {
582
+ const { batchIndices, turn, batchScopeId, preResolvedArgs, preBatchSnapshot, } = batchContext;
583
+ const runId = config.configurable?.run_id ?? '';
584
+ /**
585
+ * Registry-facing scope id — prefers the caller-threaded
586
+ * `batchScopeId` so anonymous batches target their own unique
587
+ * bucket and don't step on concurrent anonymous invocations.
588
+ * Hooks and event payloads keep using the empty-string coerced
589
+ * `runId` for backward compat.
590
+ */
591
+ const registryRunId = batchScopeId ?? config.configurable?.run_id;
592
+ const threadId = config.configurable?.thread_id;
593
+ const registry = this.toolOutputRegistry;
594
+ const unresolvedByCallId = new Map();
595
+ const preToolCalls = toolCalls.map((call, i) => {
596
+ const originalArgs = call.args;
597
+ let resolvedArgs = originalArgs;
598
+ /**
599
+ * When the caller provided a pre-resolved map (the mixed
600
+ * direct+event path snapshots event args synchronously before
601
+ * awaiting directs so they can't accidentally resolve
602
+ * same-turn direct outputs), use those entries verbatim instead
603
+ * of re-resolving against a registry that may have changed
604
+ * since the batch started.
605
+ */
606
+ const pre = call.id != null ? preResolvedArgs?.get(call.id) : undefined;
607
+ if (pre != null) {
608
+ resolvedArgs = pre.resolved;
609
+ if (pre.unresolved.length > 0 && call.id != null) {
610
+ unresolvedByCallId.set(call.id, pre.unresolved);
611
+ }
390
612
  }
391
- return request;
392
- });
393
- const results = await new Promise((resolve, reject) => {
394
- const request = {
395
- toolCalls: requests,
396
- userId: config.configurable?.user_id,
397
- agentId: this.agentId,
398
- configurable: config.configurable,
399
- metadata: config.metadata,
400
- resolve,
401
- reject,
613
+ else if (registry != null) {
614
+ const { resolved, unresolved } = registry.resolve(registryRunId, originalArgs);
615
+ resolvedArgs = resolved;
616
+ if (unresolved.length > 0 && call.id != null) {
617
+ unresolvedByCallId.set(call.id, unresolved);
618
+ }
619
+ }
620
+ return {
621
+ call,
622
+ stepId: this.toolCallStepIds?.get(call.id) ?? '',
623
+ args: resolvedArgs,
624
+ batchIndex: batchIndices?.[i],
402
625
  };
403
- safeDispatchCustomEvent(GraphEvents.ON_TOOL_EXECUTE, request, config);
404
626
  });
405
- this.storeCodeSessionFromResults(results, requests);
406
- return results.map((result) => {
407
- const request = requests.find((r) => r.id === result.toolCallId);
408
- const toolName = request?.name ?? 'unknown';
409
- const stepId = this.toolCallStepIds?.get(result.toolCallId) ?? '';
410
- if (!stepId) {
411
- // eslint-disable-next-line no-console
412
- console.warn(`[ToolNode] toolCallStepIds missing entry for toolCallId=${result.toolCallId} (tool=${toolName}). ` +
413
- 'This indicates a race between the stream consumer and graph execution. ' +
414
- `Map size: ${this.toolCallStepIds?.size ?? 0}`);
415
- }
416
- let toolMessage;
417
- let contentString;
418
- if (result.status === 'error') {
419
- contentString = `Error: ${result.errorMessage ?? 'Unknown error'}\n Please fix your mistakes.`;
420
- toolMessage = new ToolMessage({
421
- status: 'error',
422
- content: contentString,
423
- name: toolName,
424
- tool_call_id: result.toolCallId,
425
- });
627
+ const messageByCallId = new Map();
628
+ const approvedEntries = [];
629
+ const HOOK_FALLBACK = Object.freeze({
630
+ additionalContexts: [],
631
+ errors: [],
632
+ });
633
+ if (this.hookRegistry?.hasHookFor('PreToolUse', runId) === true) {
634
+ const preResults = await Promise.all(preToolCalls.map((entry) => executeHooks({
635
+ registry: this.hookRegistry,
636
+ input: {
637
+ hook_event_name: 'PreToolUse',
638
+ runId,
639
+ threadId,
640
+ agentId: this.agentId,
641
+ toolName: entry.call.name,
642
+ toolInput: entry.args,
643
+ toolUseId: entry.call.id,
644
+ stepId: entry.stepId,
645
+ turn: this.toolUsageCount.get(entry.call.name) ?? 0,
646
+ },
647
+ sessionId: runId,
648
+ matchQuery: entry.call.name,
649
+ }).catch(() => HOOK_FALLBACK)));
650
+ for (let i = 0; i < preToolCalls.length; i++) {
651
+ const hookResult = preResults[i];
652
+ const entry = preToolCalls[i];
653
+ const isDenied = hookResult.decision === 'deny' || hookResult.decision === 'ask';
654
+ if (isDenied) {
655
+ const reason = hookResult.reason ?? 'Blocked by hook';
656
+ const contentString = `Blocked: ${reason}`;
657
+ messageByCallId.set(entry.call.id, new ToolMessage({
658
+ status: 'error',
659
+ content: contentString,
660
+ name: entry.call.name,
661
+ tool_call_id: entry.call.id,
662
+ }));
663
+ this.dispatchStepCompleted(entry.call.id, entry.call.name, entry.args, contentString, config);
664
+ if (this.hookRegistry.hasHookFor('PermissionDenied', runId)) {
665
+ executeHooks({
666
+ registry: this.hookRegistry,
667
+ input: {
668
+ hook_event_name: 'PermissionDenied',
669
+ runId,
670
+ threadId,
671
+ agentId: this.agentId,
672
+ toolName: entry.call.name,
673
+ toolInput: entry.args,
674
+ toolUseId: entry.call.id,
675
+ reason,
676
+ },
677
+ sessionId: runId,
678
+ matchQuery: entry.call.name,
679
+ }).catch(() => {
680
+ /* PermissionDenied is observational — swallow errors */
681
+ });
682
+ }
683
+ continue;
684
+ }
685
+ if (hookResult.updatedInput != null) {
686
+ /**
687
+ * Re-resolve after PreToolUse replaces the input: a hook may
688
+ * introduce new `{{tool<i>turn<n>}}` placeholders (e.g., by
689
+ * copying user-supplied text) that the pre-hook pass never
690
+ * saw. Re-running the resolver on the hook-rewritten args
691
+ * keeps substitution and the unresolved-refs record in sync
692
+ * with what the tool will actually receive.
693
+ */
694
+ if (registry != null) {
695
+ /**
696
+ * Mixed direct+event batches must use the pre-batch
697
+ * snapshot so a hook-introduced placeholder cannot
698
+ * accidentally resolve to a same-turn direct output that
699
+ * has just registered. Pure event batches don't have a
700
+ * snapshot and resolve against the live registry — safe
701
+ * because no event-side registrations have happened yet.
702
+ */
703
+ const view = preBatchSnapshot ?? {
704
+ resolve: (args) => registry.resolve(registryRunId, args),
705
+ };
706
+ const { resolved, unresolved } = view.resolve(hookResult.updatedInput);
707
+ entry.args = resolved;
708
+ if (entry.call.id != null) {
709
+ if (unresolved.length > 0) {
710
+ unresolvedByCallId.set(entry.call.id, unresolved);
711
+ }
712
+ else {
713
+ unresolvedByCallId.delete(entry.call.id);
714
+ }
715
+ }
716
+ }
717
+ else {
718
+ entry.args = hookResult.updatedInput;
719
+ }
720
+ }
721
+ approvedEntries.push(entry);
426
722
  }
427
- else {
428
- const rawContent = typeof result.content === 'string'
429
- ? result.content
430
- : JSON.stringify(result.content);
431
- contentString = truncateToolResultContent(rawContent, this.maxToolResultChars);
432
- toolMessage = new ToolMessage({
433
- status: 'success',
434
- name: toolName,
435
- content: contentString,
436
- artifact: result.artifact,
437
- tool_call_id: result.toolCallId,
438
- });
723
+ }
724
+ else {
725
+ approvedEntries.push(...preToolCalls);
726
+ }
727
+ const injected = [];
728
+ const batchIndexByCallId = new Map();
729
+ if (approvedEntries.length > 0) {
730
+ const requests = approvedEntries.map((entry) => {
731
+ const turn = this.toolUsageCount.get(entry.call.name) ?? 0;
732
+ this.toolUsageCount.set(entry.call.name, turn + 1);
733
+ if (entry.batchIndex != null && entry.call.id != null) {
734
+ batchIndexByCallId.set(entry.call.id, entry.batchIndex);
735
+ }
736
+ const request = {
737
+ id: entry.call.id,
738
+ name: entry.call.name,
739
+ args: entry.args,
740
+ stepId: entry.stepId,
741
+ turn,
742
+ };
743
+ if (CODE_EXECUTION_TOOLS.has(entry.call.name) ||
744
+ entry.call.name === Constants.SKILL_TOOL) {
745
+ request.codeSessionContext = this.getCodeSessionContext();
746
+ }
747
+ return request;
748
+ });
749
+ const requestMap = new Map(requests.map((r) => [r.id, r]));
750
+ const results = await new Promise((resolve, reject) => {
751
+ const batchRequest = {
752
+ toolCalls: requests,
753
+ userId: config.configurable?.user_id,
754
+ agentId: this.agentId,
755
+ configurable: config.configurable,
756
+ metadata: config.metadata,
757
+ resolve,
758
+ reject,
759
+ };
760
+ safeDispatchCustomEvent(GraphEvents.ON_TOOL_EXECUTE, batchRequest, config);
761
+ });
762
+ this.storeCodeSessionFromResults(results, requestMap);
763
+ const hasPostHook = this.hookRegistry?.hasHookFor('PostToolUse', runId) === true;
764
+ const hasFailureHook = this.hookRegistry?.hasHookFor('PostToolUseFailure', runId) === true;
765
+ for (const result of results) {
766
+ if (result.injectedMessages && result.injectedMessages.length > 0) {
767
+ try {
768
+ injected.push(...this.convertInjectedMessages(result.injectedMessages));
769
+ }
770
+ catch (e) {
771
+ // eslint-disable-next-line no-console
772
+ console.warn(`[ToolNode] Failed to convert injectedMessages for toolCallId=${result.toolCallId}:`, e instanceof Error ? e.message : e);
773
+ }
774
+ }
775
+ const request = requestMap.get(result.toolCallId);
776
+ const toolName = request?.name ?? 'unknown';
777
+ let contentString;
778
+ let toolMessage;
779
+ if (result.status === 'error') {
780
+ contentString = `Error: ${result.errorMessage ?? 'Unknown error'}\n Please fix your mistakes.`;
781
+ /**
782
+ * Error results bypass registration/annotation but must still
783
+ * carry the unresolved-refs hint so the LLM can self-correct
784
+ * when its reference key caused the failure.
785
+ */
786
+ const unresolved = unresolvedByCallId.get(result.toolCallId) ?? [];
787
+ if (unresolved.length > 0) {
788
+ contentString = this.applyOutputReference(registryRunId, contentString, contentString, undefined, unresolved);
789
+ }
790
+ toolMessage = new ToolMessage({
791
+ status: 'error',
792
+ content: contentString,
793
+ name: toolName,
794
+ tool_call_id: result.toolCallId,
795
+ });
796
+ if (hasFailureHook) {
797
+ await executeHooks({
798
+ registry: this.hookRegistry,
799
+ input: {
800
+ hook_event_name: 'PostToolUseFailure',
801
+ runId,
802
+ threadId,
803
+ agentId: this.agentId,
804
+ toolName,
805
+ toolInput: request?.args ?? {},
806
+ toolUseId: result.toolCallId,
807
+ error: result.errorMessage ?? 'Unknown error',
808
+ stepId: request?.stepId,
809
+ turn: request?.turn,
810
+ },
811
+ sessionId: runId,
812
+ matchQuery: toolName,
813
+ }).catch(() => {
814
+ /* PostToolUseFailure is observational — swallow errors */
815
+ });
816
+ }
817
+ }
818
+ else {
819
+ let registryRaw = typeof result.content === 'string'
820
+ ? result.content
821
+ : JSON.stringify(result.content);
822
+ contentString = truncateToolResultContent(registryRaw, this.maxToolResultChars);
823
+ if (hasPostHook) {
824
+ const hookResult = await executeHooks({
825
+ registry: this.hookRegistry,
826
+ input: {
827
+ hook_event_name: 'PostToolUse',
828
+ runId,
829
+ threadId,
830
+ agentId: this.agentId,
831
+ toolName,
832
+ toolInput: request?.args ?? {},
833
+ toolOutput: result.content,
834
+ toolUseId: result.toolCallId,
835
+ stepId: request?.stepId,
836
+ turn: request?.turn,
837
+ },
838
+ sessionId: runId,
839
+ matchQuery: toolName,
840
+ }).catch(() => undefined);
841
+ if (hookResult?.updatedOutput != null) {
842
+ const replaced = typeof hookResult.updatedOutput === 'string'
843
+ ? hookResult.updatedOutput
844
+ : JSON.stringify(hookResult.updatedOutput);
845
+ registryRaw = replaced;
846
+ contentString = truncateToolResultContent(replaced, this.maxToolResultChars);
847
+ }
848
+ }
849
+ const batchIndex = batchIndexByCallId.get(result.toolCallId);
850
+ const unresolved = unresolvedByCallId.get(result.toolCallId) ?? [];
851
+ const refKey = this.toolOutputRegistry != null &&
852
+ batchIndex != null &&
853
+ turn != null
854
+ ? buildReferenceKey(batchIndex, turn)
855
+ : undefined;
856
+ contentString = this.applyOutputReference(registryRunId, contentString, registryRaw, refKey, unresolved);
857
+ toolMessage = new ToolMessage({
858
+ status: 'success',
859
+ name: toolName,
860
+ content: contentString,
861
+ artifact: result.artifact,
862
+ tool_call_id: result.toolCallId,
863
+ });
864
+ }
865
+ this.dispatchStepCompleted(result.toolCallId, toolName, request?.args ?? {}, contentString, config, request?.turn);
866
+ messageByCallId.set(result.toolCallId, toolMessage);
439
867
  }
440
- const tool_call = {
441
- args: typeof request?.args === 'string'
442
- ? request.args
443
- : JSON.stringify(request?.args ?? {}),
444
- name: toolName,
445
- id: result.toolCallId,
446
- output: contentString,
447
- progress: 1,
448
- };
449
- const runStepCompletedData = {
450
- result: {
451
- id: stepId,
452
- index: request?.turn ?? 0,
453
- type: 'tool_call',
454
- tool_call,
868
+ }
869
+ const toolMessages = toolCalls
870
+ .map((call) => messageByCallId.get(call.id))
871
+ .filter((m) => m != null);
872
+ return { toolMessages, injected };
873
+ }
874
+ dispatchStepCompleted(toolCallId, toolName, args, output, config, turn) {
875
+ const stepId = this.toolCallStepIds?.get(toolCallId) ?? '';
876
+ if (!stepId) {
877
+ // eslint-disable-next-line no-console
878
+ console.warn(`[ToolNode] toolCallStepIds missing entry for toolCallId=${toolCallId} (tool=${toolName}). ` +
879
+ 'This indicates a race between the stream consumer and graph execution. ' +
880
+ `Map size: ${this.toolCallStepIds?.size ?? 0}`);
881
+ }
882
+ safeDispatchCustomEvent(GraphEvents.ON_RUN_STEP_COMPLETED, {
883
+ result: {
884
+ id: stepId,
885
+ index: turn ?? this.toolUsageCount.get(toolName) ?? 0,
886
+ type: 'tool_call',
887
+ tool_call: {
888
+ args: JSON.stringify(args),
889
+ name: toolName,
890
+ id: toolCallId,
891
+ output,
892
+ progress: 1,
455
893
  },
894
+ },
895
+ }, config);
896
+ }
897
+ /**
898
+ * Converts InjectedMessage instances to LangChain HumanMessage objects.
899
+ * Both 'user' and 'system' roles become HumanMessage to avoid provider
900
+ * rejections (Anthropic/Google reject non-leading SystemMessages).
901
+ * The original role is preserved in additional_kwargs for downstream consumers.
902
+ */
903
+ convertInjectedMessages(messages) {
904
+ const converted = [];
905
+ for (const msg of messages) {
906
+ const additional_kwargs = {
907
+ role: msg.role,
456
908
  };
457
- safeDispatchCustomEvent(GraphEvents.ON_RUN_STEP_COMPLETED, runStepCompletedData, config);
458
- return toolMessage;
459
- });
909
+ if (msg.isMeta != null)
910
+ additional_kwargs.isMeta = msg.isMeta;
911
+ if (msg.source != null)
912
+ additional_kwargs.source = msg.source;
913
+ if (msg.skillName != null)
914
+ additional_kwargs.skillName = msg.skillName;
915
+ converted.push(new HumanMessage({ content: msg.content, additional_kwargs }));
916
+ }
917
+ return converted;
460
918
  }
461
919
  /**
462
920
  * Execute all tool calls via ON_TOOL_EXECUTE event dispatch.
463
- * Used in event-driven mode where the host handles actual tool execution.
921
+ * Injected messages are placed AFTER ToolMessages to respect provider
922
+ * message ordering (AIMessage tool_calls must be immediately followed
923
+ * by their ToolMessage results).
924
+ *
925
+ * `batchIndices` mirrors `toolCalls` and carries each call's position
926
+ * within the parent batch. `turn` is the per-`run()` batch index
927
+ * captured locally by the caller. Both are threaded so concurrent
928
+ * invocations cannot race on shared mutable state.
464
929
  */
465
930
  async executeViaEvent(toolCalls, config,
466
931
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
467
- input) {
468
- const outputs = await this.dispatchToolEvents(toolCalls, config);
932
+ input, batchContext = {}) {
933
+ const { toolMessages, injected } = await this.dispatchToolEvents(toolCalls, config, batchContext);
934
+ const outputs = [...toolMessages, ...injected];
469
935
  return (Array.isArray(input) ? outputs : { messages: outputs });
470
936
  }
471
937
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
472
938
  async run(input, config) {
473
939
  this.toolCallTurns.clear();
940
+ /**
941
+ * Per-batch local map for resolved (post-substitution) args.
942
+ * Lives on the stack so concurrent `run()` calls on the same
943
+ * ToolNode cannot read or wipe each other's entries.
944
+ */
945
+ const resolvedArgsByCallId = new Map();
946
+ /**
947
+ * Claim this batch's turn synchronously from the registry (or
948
+ * fall back to 0 when the feature is disabled). The registry is
949
+ * partitioned by scope id so overlapping batches cannot
950
+ * overwrite each other's state even under a shared registry.
951
+ *
952
+ * For anonymous callers (no `run_id` in config), mint a unique
953
+ * per-batch scope id so two concurrent anonymous invocations
954
+ * don't target the same bucket. The scope is threaded down to
955
+ * every subsequent registry call on this batch.
956
+ */
957
+ const incomingRunId = config.configurable?.run_id;
958
+ const batchScopeId = incomingRunId ?? `\0anon-${this.anonBatchCounter++}`;
959
+ const turn = this.toolOutputRegistry?.nextTurn(batchScopeId) ?? 0;
474
960
  let outputs;
475
961
  if (this.isSendInput(input)) {
476
962
  const isDirectTool = this.directToolNames?.has(input.lg_tool_call.name);
477
963
  if (this.eventDrivenMode && isDirectTool !== true) {
478
- return this.executeViaEvent([input.lg_tool_call], config, input);
964
+ return this.executeViaEvent([input.lg_tool_call], config, input, {
965
+ batchIndices: [0],
966
+ turn,
967
+ batchScopeId,
968
+ });
479
969
  }
480
- outputs = [await this.runTool(input.lg_tool_call, config)];
481
- this.handleRunToolCompletions([input.lg_tool_call], outputs, config);
970
+ outputs = [
971
+ await this.runTool(input.lg_tool_call, config, {
972
+ batchIndex: 0,
973
+ turn,
974
+ batchScopeId,
975
+ resolvedArgsByCallId,
976
+ }),
977
+ ];
978
+ this.handleRunToolCompletions([input.lg_tool_call], outputs, config, resolvedArgsByCallId);
482
979
  }
483
980
  else {
484
981
  let messages;
@@ -523,25 +1020,100 @@ class ToolNode extends RunnableCallable {
523
1020
  false));
524
1021
  }) ?? [];
525
1022
  if (this.eventDrivenMode && filteredCalls.length > 0) {
1023
+ const filteredIndices = filteredCalls.map((_, idx) => idx);
526
1024
  if (!this.directToolNames || this.directToolNames.size === 0) {
527
- return this.executeViaEvent(filteredCalls, config, input);
1025
+ return this.executeViaEvent(filteredCalls, config, input, {
1026
+ batchIndices: filteredIndices,
1027
+ turn,
1028
+ batchScopeId,
1029
+ });
1030
+ }
1031
+ const directEntries = [];
1032
+ const eventEntries = [];
1033
+ for (let i = 0; i < filteredCalls.length; i++) {
1034
+ const call = filteredCalls[i];
1035
+ const entry = { call, batchIndex: i };
1036
+ if (this.directToolNames.has(call.name)) {
1037
+ directEntries.push(entry);
1038
+ }
1039
+ else {
1040
+ eventEntries.push(entry);
1041
+ }
1042
+ }
1043
+ const directCalls = directEntries.map((e) => e.call);
1044
+ const directIndices = directEntries.map((e) => e.batchIndex);
1045
+ const eventCalls = eventEntries.map((e) => e.call);
1046
+ const eventIndices = eventEntries.map((e) => e.batchIndex);
1047
+ /**
1048
+ * Snapshot the event calls' args against the *pre-batch*
1049
+ * registry state synchronously, before any await runs. The
1050
+ * directs are then awaited first (preserving fail-fast
1051
+ * semantics — a thrown error in a direct tool, e.g. with
1052
+ * `handleToolErrors=false` or a `GraphInterrupt`, aborts
1053
+ * before we dispatch any event-driven tools to the host).
1054
+ * Because the event args were captured pre-await, they stay
1055
+ * isolated from same-turn direct outputs that register
1056
+ * during the await.
1057
+ */
1058
+ const preResolvedEventArgs = new Map();
1059
+ /**
1060
+ * Take a frozen snapshot of the registry state before any
1061
+ * direct registrations land. The snapshot resolves
1062
+ * placeholders against this point-in-time view, so a
1063
+ * `PreToolUse` hook later rewriting event args via
1064
+ * `updatedInput` can introduce placeholders that resolve
1065
+ * cross-batch (against prior runs) without ever picking up
1066
+ * same-turn direct outputs.
1067
+ */
1068
+ const preBatchSnapshot = this.toolOutputRegistry?.snapshot(batchScopeId);
1069
+ if (preBatchSnapshot != null) {
1070
+ for (const entry of eventEntries) {
1071
+ if (entry.call.id != null) {
1072
+ const { resolved, unresolved } = preBatchSnapshot.resolve(entry.call.args);
1073
+ preResolvedEventArgs.set(entry.call.id, {
1074
+ resolved: resolved,
1075
+ unresolved,
1076
+ });
1077
+ }
1078
+ }
528
1079
  }
529
- const directCalls = filteredCalls.filter((c) => this.directToolNames.has(c.name));
530
- const eventCalls = filteredCalls.filter((c) => !this.directToolNames.has(c.name));
531
1080
  const directOutputs = directCalls.length > 0
532
- ? await Promise.all(directCalls.map((call) => this.runTool(call, config)))
1081
+ ? await Promise.all(directCalls.map((call, i) => this.runTool(call, config, {
1082
+ batchIndex: directIndices[i],
1083
+ turn,
1084
+ batchScopeId,
1085
+ resolvedArgsByCallId,
1086
+ })))
533
1087
  : [];
534
1088
  if (directCalls.length > 0 && directOutputs.length > 0) {
535
- this.handleRunToolCompletions(directCalls, directOutputs, config);
1089
+ this.handleRunToolCompletions(directCalls, directOutputs, config, resolvedArgsByCallId);
536
1090
  }
537
- const eventOutputs = eventCalls.length > 0
538
- ? await this.dispatchToolEvents(eventCalls, config)
539
- : [];
540
- outputs = [...directOutputs, ...eventOutputs];
1091
+ const eventResult = eventCalls.length > 0
1092
+ ? await this.dispatchToolEvents(eventCalls, config, {
1093
+ batchIndices: eventIndices,
1094
+ turn,
1095
+ batchScopeId,
1096
+ preResolvedArgs: preResolvedEventArgs,
1097
+ preBatchSnapshot,
1098
+ })
1099
+ : {
1100
+ toolMessages: [],
1101
+ injected: [],
1102
+ };
1103
+ outputs = [
1104
+ ...directOutputs,
1105
+ ...eventResult.toolMessages,
1106
+ ...eventResult.injected,
1107
+ ];
541
1108
  }
542
1109
  else {
543
- outputs = await Promise.all(filteredCalls.map((call) => this.runTool(call, config)));
544
- this.handleRunToolCompletions(filteredCalls, outputs, config);
1110
+ outputs = await Promise.all(filteredCalls.map((call, i) => this.runTool(call, config, {
1111
+ batchIndex: i,
1112
+ turn,
1113
+ batchScopeId,
1114
+ resolvedArgsByCallId,
1115
+ })));
1116
+ this.handleRunToolCompletions(filteredCalls, outputs, config, resolvedArgsByCallId);
545
1117
  }
546
1118
  }
547
1119
  if (!outputs.some(isCommand)) {