@librechat/agents 3.1.70 → 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 (54) hide show
  1. package/dist/cjs/graphs/Graph.cjs +45 -0
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/main.cjs +4 -0
  4. package/dist/cjs/main.cjs.map +1 -1
  5. package/dist/cjs/messages/prune.cjs +9 -2
  6. package/dist/cjs/messages/prune.cjs.map +1 -1
  7. package/dist/cjs/run.cjs +4 -0
  8. package/dist/cjs/run.cjs.map +1 -1
  9. package/dist/cjs/tools/BashExecutor.cjs +43 -0
  10. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  11. package/dist/cjs/tools/ToolNode.cjs +453 -45
  12. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  13. package/dist/cjs/tools/toolOutputReferences.cjs +475 -0
  14. package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -0
  15. package/dist/cjs/utils/truncation.cjs +28 -0
  16. package/dist/cjs/utils/truncation.cjs.map +1 -1
  17. package/dist/esm/graphs/Graph.mjs +45 -0
  18. package/dist/esm/graphs/Graph.mjs.map +1 -1
  19. package/dist/esm/main.mjs +2 -2
  20. package/dist/esm/messages/prune.mjs +9 -2
  21. package/dist/esm/messages/prune.mjs.map +1 -1
  22. package/dist/esm/run.mjs +4 -0
  23. package/dist/esm/run.mjs.map +1 -1
  24. package/dist/esm/tools/BashExecutor.mjs +42 -1
  25. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  26. package/dist/esm/tools/ToolNode.mjs +453 -45
  27. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  28. package/dist/esm/tools/toolOutputReferences.mjs +468 -0
  29. package/dist/esm/tools/toolOutputReferences.mjs.map +1 -0
  30. package/dist/esm/utils/truncation.mjs +27 -1
  31. package/dist/esm/utils/truncation.mjs.map +1 -1
  32. package/dist/types/graphs/Graph.d.ts +21 -0
  33. package/dist/types/run.d.ts +1 -0
  34. package/dist/types/tools/BashExecutor.d.ts +31 -0
  35. package/dist/types/tools/ToolNode.d.ts +86 -3
  36. package/dist/types/tools/toolOutputReferences.d.ts +205 -0
  37. package/dist/types/types/run.d.ts +9 -1
  38. package/dist/types/types/tools.d.ts +70 -0
  39. package/dist/types/utils/truncation.d.ts +21 -0
  40. package/package.json +1 -1
  41. package/src/graphs/Graph.ts +48 -0
  42. package/src/messages/prune.ts +9 -2
  43. package/src/run.ts +4 -0
  44. package/src/specs/prune.test.ts +413 -0
  45. package/src/tools/BashExecutor.ts +45 -0
  46. package/src/tools/ToolNode.ts +618 -55
  47. package/src/tools/__tests__/BashExecutor.test.ts +36 -0
  48. package/src/tools/__tests__/ToolNode.outputReferences.test.ts +1395 -0
  49. package/src/tools/__tests__/toolOutputReferences.test.ts +415 -0
  50. package/src/tools/toolOutputReferences.ts +590 -0
  51. package/src/types/run.ts +9 -1
  52. package/src/types/tools.ts +71 -0
  53. package/src/utils/__tests__/truncation.test.ts +66 -0
  54. package/src/utils/truncation.ts +30 -0
@@ -12,6 +12,7 @@ var run = require('../utils/run.cjs');
12
12
  require('ai-tokenizer');
13
13
  require('zod-to-json-schema');
14
14
  var executeHooks = require('../hooks/executeHooks.cjs');
15
+ var toolOutputReferences = require('./toolOutputReferences.cjs');
15
16
 
16
17
  /**
17
18
  * Helper to check if a value is a Send object
@@ -72,7 +73,27 @@ class ToolNode extends run.RunnableCallable {
72
73
  maxToolResultChars;
73
74
  /** Hook registry for PreToolUse/PostToolUse lifecycle hooks */
74
75
  hookRegistry;
75
- constructor({ tools, toolMap, name, tags, errorHandler, toolCallStepIds, handleToolErrors, loadRuntimeTools, toolRegistry, sessions, eventDrivenMode, agentId, directToolNames, maxContextTokens, maxToolResultChars, hookRegistry, }) {
76
+ /**
77
+ * Registry of tool outputs keyed by `tool<idx>turn<turn>`.
78
+ *
79
+ * Populated only when `toolOutputReferences.enabled` is true. The
80
+ * registry owns the run-scoped state (turn counter, last-seen runId,
81
+ * warn-once memo, stored outputs), so sharing a single instance
82
+ * across multiple ToolNodes in a run lets cross-agent `{{…}}`
83
+ * references resolve — which is why multi-agent graphs pass the
84
+ * *same* instance to every ToolNode they compile rather than each
85
+ * ToolNode building its own.
86
+ */
87
+ toolOutputRegistry;
88
+ /**
89
+ * Monotonic counter used to mint a unique scope id for anonymous
90
+ * batches (ones invoked without a `run_id` in
91
+ * `config.configurable`). Each such batch gets its own registry
92
+ * partition so concurrent anonymous invocations can't delete each
93
+ * other's in-flight state.
94
+ */
95
+ anonBatchCounter = 0;
96
+ constructor({ tools, toolMap, name, tags, errorHandler, toolCallStepIds, handleToolErrors, loadRuntimeTools, toolRegistry, sessions, eventDrivenMode, agentId, directToolNames, maxContextTokens, maxToolResultChars, hookRegistry, toolOutputReferences: toolOutputReferences$1, toolOutputRegistry, }) {
76
97
  super({ name, tags, func: (input, config) => this.run(input, config) });
77
98
  this.toolMap = toolMap ?? new Map(tools.map((tool) => [tool.name, tool]));
78
99
  this.toolCallStepIds = toolCallStepIds;
@@ -88,6 +109,37 @@ class ToolNode extends run.RunnableCallable {
88
109
  this.maxToolResultChars =
89
110
  maxToolResultChars ?? truncation.calculateMaxToolResultChars(maxContextTokens);
90
111
  this.hookRegistry = hookRegistry;
112
+ /**
113
+ * Precedence: an explicitly passed `toolOutputRegistry` instance
114
+ * wins over a config object so a host (`Graph`) can share one
115
+ * registry across many ToolNodes. When only the config is
116
+ * provided (direct ToolNode usage), build a local registry so
117
+ * the feature still works without graph-level plumbing. Registry
118
+ * caps are intentionally decoupled from `maxToolResultChars`:
119
+ * the registry stores the raw untruncated output so a later
120
+ * `{{…}}` substitution pipes the full payload into the next
121
+ * tool, even when the LLM saw a truncated preview.
122
+ */
123
+ if (toolOutputRegistry != null) {
124
+ this.toolOutputRegistry = toolOutputRegistry;
125
+ }
126
+ else if (toolOutputReferences$1?.enabled === true) {
127
+ this.toolOutputRegistry = new toolOutputReferences.ToolOutputReferenceRegistry({
128
+ maxOutputSize: toolOutputReferences$1.maxOutputSize,
129
+ maxTotalSize: toolOutputReferences$1.maxTotalSize,
130
+ });
131
+ }
132
+ }
133
+ /**
134
+ * Returns the run-scoped tool output registry, or `undefined` when
135
+ * the feature is disabled.
136
+ *
137
+ * @internal Exposed for test observation only. Host code should rely
138
+ * on `{{tool<i>turn<n>}}` substitution at tool-invocation time and
139
+ * not mutate the registry directly.
140
+ */
141
+ _unsafeGetToolOutputRegistry() {
142
+ return this.toolOutputRegistry;
91
143
  }
92
144
  /**
93
145
  * Returns cached programmatic tools, computing once on first access.
@@ -119,28 +171,85 @@ class ToolNode extends run.RunnableCallable {
119
171
  return new Map(this.toolUsageCount); // Return a copy
120
172
  }
121
173
  /**
122
- * Runs a single tool call with error handling
174
+ * Runs a single tool call with error handling.
175
+ *
176
+ * `batchIndex` is the tool's position within the current ToolNode
177
+ * batch and, together with `this.currentTurn`, forms the key used to
178
+ * register the output for future `{{tool<idx>turn<turn>}}`
179
+ * substitutions. Omit when no registration should occur.
123
180
  */
124
- async runTool(call, config) {
181
+ async runTool(call, config, batchContext = {}) {
182
+ const { batchIndex, turn, batchScopeId, resolvedArgsByCallId } = batchContext;
125
183
  const tool = this.toolMap.get(call.name);
184
+ const registry = this.toolOutputRegistry;
185
+ /**
186
+ * Precompute the reference key once per call — captured locally
187
+ * so concurrent `invoke()` calls on the same ToolNode cannot race
188
+ * on a shared turn field.
189
+ */
190
+ const refKey = registry != null && batchIndex != null && turn != null
191
+ ? toolOutputReferences.buildReferenceKey(batchIndex, turn)
192
+ : undefined;
193
+ /**
194
+ * Hoisted outside the try so the catch branch can append
195
+ * `[unresolved refs: …]` to error messages — otherwise the LLM
196
+ * only sees a generic error when it references a bad key, losing
197
+ * the self-correction signal this feature is meant to provide.
198
+ */
199
+ let unresolvedRefs = [];
200
+ /**
201
+ * Use the caller-provided `batchScopeId` when threaded from
202
+ * `run()` (so anonymous batches get their own unique scope).
203
+ * Fall back to the config's `run_id` when runTool is invoked
204
+ * from a context that doesn't thread it — that still preserves
205
+ * the runId-based partitioning for named runs.
206
+ */
207
+ const runId = batchScopeId ?? config.configurable?.run_id;
126
208
  try {
127
209
  if (tool === undefined) {
128
210
  throw new Error(`Tool "${call.name}" not found.`);
129
211
  }
130
- const turn = this.toolUsageCount.get(call.name) ?? 0;
131
- this.toolUsageCount.set(call.name, turn + 1);
212
+ /**
213
+ * `usageCount` is the per-tool-name invocation index that
214
+ * web-search and other tools observe via `invokeParams.turn`.
215
+ * It is intentionally distinct from the outer `turn` parameter
216
+ * (the batch turn used for ref keys); the latter is captured
217
+ * before the try block when constructing `refKey`.
218
+ */
219
+ const usageCount = this.toolUsageCount.get(call.name) ?? 0;
220
+ this.toolUsageCount.set(call.name, usageCount + 1);
132
221
  if (call.id != null && call.id !== '') {
133
- this.toolCallTurns.set(call.id, turn);
222
+ this.toolCallTurns.set(call.id, usageCount);
223
+ }
224
+ let args = call.args;
225
+ if (registry != null) {
226
+ const { resolved, unresolved } = registry.resolve(runId, args);
227
+ args = resolved;
228
+ unresolvedRefs = unresolved;
229
+ /**
230
+ * Expose the post-substitution args to downstream completion
231
+ * events so audit logs / host-side `ON_RUN_STEP_COMPLETED`
232
+ * handlers observe what actually ran, not the `{{…}}`
233
+ * template. Only string/object args are worth recording.
234
+ */
235
+ if (resolvedArgsByCallId != null &&
236
+ call.id != null &&
237
+ call.id !== '' &&
238
+ resolved !== call.args &&
239
+ typeof resolved === 'object') {
240
+ resolvedArgsByCallId.set(call.id, resolved);
241
+ }
134
242
  }
135
- const args = call.args;
136
243
  const stepId = this.toolCallStepIds?.get(call.id);
137
244
  // Build invoke params - LangChain extracts non-schema fields to config.toolCall
245
+ // `turn` here is the per-tool usage count (matches what tools have
246
+ // observed historically via config.toolCall.turn — e.g. web search).
138
247
  let invokeParams = {
139
248
  ...call,
140
249
  args,
141
250
  type: 'tool_call',
142
251
  stepId,
143
- turn,
252
+ turn: usageCount,
144
253
  };
145
254
  // Inject runtime data for special tools (becomes available at config.toolCall)
146
255
  if (call.name === _enum.Constants.PROGRAMMATIC_TOOL_CALLING ||
@@ -185,19 +294,72 @@ class ToolNode extends run.RunnableCallable {
185
294
  }
186
295
  }
187
296
  const output = await tool.invoke(invokeParams, config);
188
- if ((messages.isBaseMessage(output) && output._getType() === 'tool') ||
189
- langgraph.isCommand(output)) {
297
+ if (langgraph.isCommand(output)) {
190
298
  return output;
191
299
  }
192
- else {
193
- const rawContent = typeof output === 'string' ? output : JSON.stringify(output);
194
- return new messages.ToolMessage({
195
- status: 'success',
196
- name: tool.name,
197
- content: truncation.truncateToolResultContent(rawContent, this.maxToolResultChars),
198
- tool_call_id: call.id,
199
- });
300
+ if (messages.isBaseMessage(output) && output._getType() === 'tool') {
301
+ const toolMsg = output;
302
+ const isError = toolMsg.status === 'error';
303
+ if (isError) {
304
+ /**
305
+ * Error ToolMessages bypass registration/annotation but must
306
+ * still carry the unresolved-refs hint so the LLM can
307
+ * self-correct when its reference key caused the failure.
308
+ */
309
+ if (unresolvedRefs.length > 0 &&
310
+ typeof toolMsg.content === 'string') {
311
+ toolMsg.content = this.applyOutputReference(runId, toolMsg.content, toolMsg.content, undefined, unresolvedRefs);
312
+ }
313
+ return toolMsg;
314
+ }
315
+ if (this.toolOutputRegistry != null || unresolvedRefs.length > 0) {
316
+ if (typeof toolMsg.content === 'string') {
317
+ const rawContent = toolMsg.content;
318
+ const llmContent = truncation.truncateToolResultContent(rawContent, this.maxToolResultChars);
319
+ toolMsg.content = this.applyOutputReference(runId, llmContent, rawContent, refKey, unresolvedRefs);
320
+ }
321
+ else {
322
+ /**
323
+ * Non-string content (multi-part content blocks — text +
324
+ * image). Known limitation: we cannot register under a
325
+ * reference key because there's no canonical serialized
326
+ * form. Warn once per tool per run when the caller
327
+ * intended to register.
328
+ *
329
+ * Still surface unresolved-ref warnings so the LLM gets
330
+ * the self-correction signal that the string and error
331
+ * paths already emit. Prepended as a leading text block
332
+ * to keep the original content ordering intact.
333
+ */
334
+ if (unresolvedRefs.length > 0 && Array.isArray(toolMsg.content)) {
335
+ const warningBlock = {
336
+ type: 'text',
337
+ text: `[unresolved refs: ${unresolvedRefs.join(', ')}]`,
338
+ };
339
+ toolMsg.content = [
340
+ warningBlock,
341
+ ...toolMsg.content,
342
+ ];
343
+ }
344
+ if (refKey != null &&
345
+ this.toolOutputRegistry.claimWarnOnce(runId, call.name)) {
346
+ // eslint-disable-next-line no-console
347
+ console.warn(`[ToolNode] Skipping tool output reference for "${call.name}": ` +
348
+ 'ToolMessage content is not a string (further occurrences for this tool in the same run are suppressed).');
349
+ }
350
+ }
351
+ }
352
+ return toolMsg;
200
353
  }
354
+ const rawContent = typeof output === 'string' ? output : JSON.stringify(output);
355
+ const truncated = truncation.truncateToolResultContent(rawContent, this.maxToolResultChars);
356
+ const content = this.applyOutputReference(runId, truncated, rawContent, refKey, unresolvedRefs);
357
+ return new messages.ToolMessage({
358
+ status: 'success',
359
+ name: tool.name,
360
+ content,
361
+ tool_call_id: call.id,
362
+ });
201
363
  }
202
364
  catch (_e) {
203
365
  const e = _e;
@@ -240,14 +402,52 @@ class ToolNode extends run.RunnableCallable {
240
402
  });
241
403
  }
242
404
  }
405
+ let errorContent = `Error: ${e.message}\n Please fix your mistakes.`;
406
+ if (unresolvedRefs.length > 0) {
407
+ errorContent = this.applyOutputReference(runId, errorContent, errorContent, undefined, unresolvedRefs);
408
+ }
243
409
  return new messages.ToolMessage({
244
410
  status: 'error',
245
- content: `Error: ${e.message}\n Please fix your mistakes.`,
411
+ content: errorContent,
246
412
  name: call.name,
247
413
  tool_call_id: call.id ?? '',
248
414
  });
249
415
  }
250
416
  }
417
+ /**
418
+ * Finalizes the LLM-visible content for a tool call and (when a
419
+ * `refKey` is provided) registers the full, raw output under that
420
+ * key.
421
+ *
422
+ * @param llmContent The content string the LLM will see. This is
423
+ * the already-truncated, post-hook view; the annotation is
424
+ * applied on top of it.
425
+ * @param registryContent The full, untruncated output to store in
426
+ * the registry so `{{tool<i>turn<n>}}` substitutions deliver the
427
+ * complete payload. Ignored when `refKey` is undefined.
428
+ * @param refKey Precomputed `tool<i>turn<n>` key, or undefined when
429
+ * the output is not to be registered (errors, disabled feature,
430
+ * unavailable batch/turn).
431
+ * @param unresolved Placeholder keys that did not resolve; appended
432
+ * as `[unresolved refs: …]` so the LLM can self-correct.
433
+ *
434
+ * `refKey` is passed in (rather than built from `this.currentTurn`)
435
+ * so parallel `invoke()` calls on the same ToolNode cannot race on
436
+ * the shared turn field.
437
+ */
438
+ applyOutputReference(runId, llmContent, registryContent, refKey, unresolved) {
439
+ if (this.toolOutputRegistry != null && refKey != null) {
440
+ this.toolOutputRegistry.set(runId, refKey, registryContent);
441
+ }
442
+ /**
443
+ * `annotateToolOutputWithReference` handles both the ref-key and
444
+ * unresolved-refs cases together so JSON-object outputs stay
445
+ * parseable: unresolved refs land in an `_unresolved_refs` field
446
+ * instead of as a trailing text line that would break
447
+ * `JSON.parse` for downstream consumers.
448
+ */
449
+ return toolOutputReferences.annotateToolOutputWithReference(llmContent, refKey, unresolved);
450
+ }
251
451
  /**
252
452
  * Builds code session context for injection into event-driven tool calls.
253
453
  * Mirrors the session injection logic in runTool() for direct execution.
@@ -306,8 +506,12 @@ class ToolNode extends run.RunnableCallable {
306
506
  * By handling completions here in graph context (rather than in the
307
507
  * stream consumer via ToolEndHandler), the race between the stream
308
508
  * consumer and graph execution is eliminated.
509
+ *
510
+ * @param resolvedArgsByCallId Per-batch resolved-args sink populated
511
+ * by `runTool`. Threaded as a local map (instead of instance state)
512
+ * so concurrent batches cannot read each other's entries.
309
513
  */
310
- handleRunToolCompletions(calls, outputs, config) {
514
+ handleRunToolCompletions(calls, outputs, config, resolvedArgsByCallId) {
311
515
  for (let i = 0; i < calls.length; i++) {
312
516
  const call = calls[i];
313
517
  const output = outputs[i];
@@ -336,10 +540,17 @@ class ToolNode extends run.RunnableCallable {
336
540
  const contentString = typeof toolMessage.content === 'string'
337
541
  ? toolMessage.content
338
542
  : JSON.stringify(toolMessage.content);
543
+ /**
544
+ * Prefer the post-substitution args when a `{{…}}` placeholder
545
+ * was resolved in `runTool`. This keeps
546
+ * `ON_RUN_STEP_COMPLETED.tool_call.args` consistent with what
547
+ * the tool actually received rather than leaking the template.
548
+ */
549
+ const effectiveArgs = resolvedArgsByCallId?.get(toolCallId) ?? call.args;
339
550
  const tool_call = {
340
- args: typeof call.args === 'string'
341
- ? call.args
342
- : JSON.stringify(call.args ?? {}),
551
+ args: typeof effectiveArgs === 'string'
552
+ ? effectiveArgs
553
+ : JSON.stringify(effectiveArgs ?? {}),
343
554
  name: call.name,
344
555
  id: toolCallId,
345
556
  output: contentString,
@@ -369,14 +580,52 @@ class ToolNode extends run.RunnableCallable {
369
580
  * 4. Injected messages from results are collected and returned alongside
370
581
  * ToolMessages (appended AFTER to respect provider ordering).
371
582
  */
372
- async dispatchToolEvents(toolCalls, config) {
583
+ async dispatchToolEvents(toolCalls, config, batchContext = {}) {
584
+ const { batchIndices, turn, batchScopeId, preResolvedArgs, preBatchSnapshot, } = batchContext;
373
585
  const runId = config.configurable?.run_id ?? '';
586
+ /**
587
+ * Registry-facing scope id — prefers the caller-threaded
588
+ * `batchScopeId` so anonymous batches target their own unique
589
+ * bucket and don't step on concurrent anonymous invocations.
590
+ * Hooks and event payloads keep using the empty-string coerced
591
+ * `runId` for backward compat.
592
+ */
593
+ const registryRunId = batchScopeId ?? config.configurable?.run_id;
374
594
  const threadId = config.configurable?.thread_id;
375
- const preToolCalls = toolCalls.map((call) => ({
376
- call,
377
- stepId: this.toolCallStepIds?.get(call.id) ?? '',
378
- args: call.args,
379
- }));
595
+ const registry = this.toolOutputRegistry;
596
+ const unresolvedByCallId = new Map();
597
+ const preToolCalls = toolCalls.map((call, i) => {
598
+ const originalArgs = call.args;
599
+ let resolvedArgs = originalArgs;
600
+ /**
601
+ * When the caller provided a pre-resolved map (the mixed
602
+ * direct+event path snapshots event args synchronously before
603
+ * awaiting directs so they can't accidentally resolve
604
+ * same-turn direct outputs), use those entries verbatim instead
605
+ * of re-resolving against a registry that may have changed
606
+ * since the batch started.
607
+ */
608
+ const pre = call.id != null ? preResolvedArgs?.get(call.id) : undefined;
609
+ if (pre != null) {
610
+ resolvedArgs = pre.resolved;
611
+ if (pre.unresolved.length > 0 && call.id != null) {
612
+ unresolvedByCallId.set(call.id, pre.unresolved);
613
+ }
614
+ }
615
+ else if (registry != null) {
616
+ const { resolved, unresolved } = registry.resolve(registryRunId, originalArgs);
617
+ resolvedArgs = resolved;
618
+ if (unresolved.length > 0 && call.id != null) {
619
+ unresolvedByCallId.set(call.id, unresolved);
620
+ }
621
+ }
622
+ return {
623
+ call,
624
+ stepId: this.toolCallStepIds?.get(call.id) ?? '',
625
+ args: resolvedArgs,
626
+ batchIndex: batchIndices?.[i],
627
+ };
628
+ });
380
629
  const messageByCallId = new Map();
381
630
  const approvedEntries = [];
382
631
  const HOOK_FALLBACK = Object.freeze({
@@ -436,7 +685,40 @@ class ToolNode extends run.RunnableCallable {
436
685
  continue;
437
686
  }
438
687
  if (hookResult.updatedInput != null) {
439
- entry.args = hookResult.updatedInput;
688
+ /**
689
+ * Re-resolve after PreToolUse replaces the input: a hook may
690
+ * introduce new `{{tool<i>turn<n>}}` placeholders (e.g., by
691
+ * copying user-supplied text) that the pre-hook pass never
692
+ * saw. Re-running the resolver on the hook-rewritten args
693
+ * keeps substitution and the unresolved-refs record in sync
694
+ * with what the tool will actually receive.
695
+ */
696
+ if (registry != null) {
697
+ /**
698
+ * Mixed direct+event batches must use the pre-batch
699
+ * snapshot so a hook-introduced placeholder cannot
700
+ * accidentally resolve to a same-turn direct output that
701
+ * has just registered. Pure event batches don't have a
702
+ * snapshot and resolve against the live registry — safe
703
+ * because no event-side registrations have happened yet.
704
+ */
705
+ const view = preBatchSnapshot ?? {
706
+ resolve: (args) => registry.resolve(registryRunId, args),
707
+ };
708
+ const { resolved, unresolved } = view.resolve(hookResult.updatedInput);
709
+ entry.args = resolved;
710
+ if (entry.call.id != null) {
711
+ if (unresolved.length > 0) {
712
+ unresolvedByCallId.set(entry.call.id, unresolved);
713
+ }
714
+ else {
715
+ unresolvedByCallId.delete(entry.call.id);
716
+ }
717
+ }
718
+ }
719
+ else {
720
+ entry.args = hookResult.updatedInput;
721
+ }
440
722
  }
441
723
  approvedEntries.push(entry);
442
724
  }
@@ -445,10 +727,14 @@ class ToolNode extends run.RunnableCallable {
445
727
  approvedEntries.push(...preToolCalls);
446
728
  }
447
729
  const injected = [];
730
+ const batchIndexByCallId = new Map();
448
731
  if (approvedEntries.length > 0) {
449
732
  const requests = approvedEntries.map((entry) => {
450
733
  const turn = this.toolUsageCount.get(entry.call.name) ?? 0;
451
734
  this.toolUsageCount.set(entry.call.name, turn + 1);
735
+ if (entry.batchIndex != null && entry.call.id != null) {
736
+ batchIndexByCallId.set(entry.call.id, entry.batchIndex);
737
+ }
452
738
  const request = {
453
739
  id: entry.call.id,
454
740
  name: entry.call.name,
@@ -494,6 +780,15 @@ class ToolNode extends run.RunnableCallable {
494
780
  let toolMessage;
495
781
  if (result.status === 'error') {
496
782
  contentString = `Error: ${result.errorMessage ?? 'Unknown error'}\n Please fix your mistakes.`;
783
+ /**
784
+ * Error results bypass registration/annotation but must still
785
+ * carry the unresolved-refs hint so the LLM can self-correct
786
+ * when its reference key caused the failure.
787
+ */
788
+ const unresolved = unresolvedByCallId.get(result.toolCallId) ?? [];
789
+ if (unresolved.length > 0) {
790
+ contentString = this.applyOutputReference(registryRunId, contentString, contentString, undefined, unresolved);
791
+ }
497
792
  toolMessage = new messages.ToolMessage({
498
793
  status: 'error',
499
794
  content: contentString,
@@ -523,10 +818,10 @@ class ToolNode extends run.RunnableCallable {
523
818
  }
524
819
  }
525
820
  else {
526
- const rawContent = typeof result.content === 'string'
821
+ let registryRaw = typeof result.content === 'string'
527
822
  ? result.content
528
823
  : JSON.stringify(result.content);
529
- contentString = truncation.truncateToolResultContent(rawContent, this.maxToolResultChars);
824
+ contentString = truncation.truncateToolResultContent(registryRaw, this.maxToolResultChars);
530
825
  if (hasPostHook) {
531
826
  const hookResult = await executeHooks.executeHooks({
532
827
  registry: this.hookRegistry,
@@ -549,9 +844,18 @@ class ToolNode extends run.RunnableCallable {
549
844
  const replaced = typeof hookResult.updatedOutput === 'string'
550
845
  ? hookResult.updatedOutput
551
846
  : JSON.stringify(hookResult.updatedOutput);
847
+ registryRaw = replaced;
552
848
  contentString = truncation.truncateToolResultContent(replaced, this.maxToolResultChars);
553
849
  }
554
850
  }
851
+ const batchIndex = batchIndexByCallId.get(result.toolCallId);
852
+ const unresolved = unresolvedByCallId.get(result.toolCallId) ?? [];
853
+ const refKey = this.toolOutputRegistry != null &&
854
+ batchIndex != null &&
855
+ turn != null
856
+ ? toolOutputReferences.buildReferenceKey(batchIndex, turn)
857
+ : undefined;
858
+ contentString = this.applyOutputReference(registryRunId, contentString, registryRaw, refKey, unresolved);
555
859
  toolMessage = new messages.ToolMessage({
556
860
  status: 'success',
557
861
  name: toolName,
@@ -619,25 +923,61 @@ class ToolNode extends run.RunnableCallable {
619
923
  * Injected messages are placed AFTER ToolMessages to respect provider
620
924
  * message ordering (AIMessage tool_calls must be immediately followed
621
925
  * by their ToolMessage results).
926
+ *
927
+ * `batchIndices` mirrors `toolCalls` and carries each call's position
928
+ * within the parent batch. `turn` is the per-`run()` batch index
929
+ * captured locally by the caller. Both are threaded so concurrent
930
+ * invocations cannot race on shared mutable state.
622
931
  */
623
932
  async executeViaEvent(toolCalls, config,
624
933
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
625
- input) {
626
- const { toolMessages, injected } = await this.dispatchToolEvents(toolCalls, config);
934
+ input, batchContext = {}) {
935
+ const { toolMessages, injected } = await this.dispatchToolEvents(toolCalls, config, batchContext);
627
936
  const outputs = [...toolMessages, ...injected];
628
937
  return (Array.isArray(input) ? outputs : { messages: outputs });
629
938
  }
630
939
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
631
940
  async run(input, config) {
632
941
  this.toolCallTurns.clear();
942
+ /**
943
+ * Per-batch local map for resolved (post-substitution) args.
944
+ * Lives on the stack so concurrent `run()` calls on the same
945
+ * ToolNode cannot read or wipe each other's entries.
946
+ */
947
+ const resolvedArgsByCallId = new Map();
948
+ /**
949
+ * Claim this batch's turn synchronously from the registry (or
950
+ * fall back to 0 when the feature is disabled). The registry is
951
+ * partitioned by scope id so overlapping batches cannot
952
+ * overwrite each other's state even under a shared registry.
953
+ *
954
+ * For anonymous callers (no `run_id` in config), mint a unique
955
+ * per-batch scope id so two concurrent anonymous invocations
956
+ * don't target the same bucket. The scope is threaded down to
957
+ * every subsequent registry call on this batch.
958
+ */
959
+ const incomingRunId = config.configurable?.run_id;
960
+ const batchScopeId = incomingRunId ?? `\0anon-${this.anonBatchCounter++}`;
961
+ const turn = this.toolOutputRegistry?.nextTurn(batchScopeId) ?? 0;
633
962
  let outputs;
634
963
  if (this.isSendInput(input)) {
635
964
  const isDirectTool = this.directToolNames?.has(input.lg_tool_call.name);
636
965
  if (this.eventDrivenMode && isDirectTool !== true) {
637
- return this.executeViaEvent([input.lg_tool_call], config, input);
966
+ return this.executeViaEvent([input.lg_tool_call], config, input, {
967
+ batchIndices: [0],
968
+ turn,
969
+ batchScopeId,
970
+ });
638
971
  }
639
- outputs = [await this.runTool(input.lg_tool_call, config)];
640
- this.handleRunToolCompletions([input.lg_tool_call], outputs, config);
972
+ outputs = [
973
+ await this.runTool(input.lg_tool_call, config, {
974
+ batchIndex: 0,
975
+ turn,
976
+ batchScopeId,
977
+ resolvedArgsByCallId,
978
+ }),
979
+ ];
980
+ this.handleRunToolCompletions([input.lg_tool_call], outputs, config, resolvedArgsByCallId);
641
981
  }
642
982
  else {
643
983
  let messages$1;
@@ -682,19 +1022,82 @@ class ToolNode extends run.RunnableCallable {
682
1022
  false));
683
1023
  }) ?? [];
684
1024
  if (this.eventDrivenMode && filteredCalls.length > 0) {
1025
+ const filteredIndices = filteredCalls.map((_, idx) => idx);
685
1026
  if (!this.directToolNames || this.directToolNames.size === 0) {
686
- return this.executeViaEvent(filteredCalls, config, input);
1027
+ return this.executeViaEvent(filteredCalls, config, input, {
1028
+ batchIndices: filteredIndices,
1029
+ turn,
1030
+ batchScopeId,
1031
+ });
1032
+ }
1033
+ const directEntries = [];
1034
+ const eventEntries = [];
1035
+ for (let i = 0; i < filteredCalls.length; i++) {
1036
+ const call = filteredCalls[i];
1037
+ const entry = { call, batchIndex: i };
1038
+ if (this.directToolNames.has(call.name)) {
1039
+ directEntries.push(entry);
1040
+ }
1041
+ else {
1042
+ eventEntries.push(entry);
1043
+ }
1044
+ }
1045
+ const directCalls = directEntries.map((e) => e.call);
1046
+ const directIndices = directEntries.map((e) => e.batchIndex);
1047
+ const eventCalls = eventEntries.map((e) => e.call);
1048
+ const eventIndices = eventEntries.map((e) => e.batchIndex);
1049
+ /**
1050
+ * Snapshot the event calls' args against the *pre-batch*
1051
+ * registry state synchronously, before any await runs. The
1052
+ * directs are then awaited first (preserving fail-fast
1053
+ * semantics — a thrown error in a direct tool, e.g. with
1054
+ * `handleToolErrors=false` or a `GraphInterrupt`, aborts
1055
+ * before we dispatch any event-driven tools to the host).
1056
+ * Because the event args were captured pre-await, they stay
1057
+ * isolated from same-turn direct outputs that register
1058
+ * during the await.
1059
+ */
1060
+ const preResolvedEventArgs = new Map();
1061
+ /**
1062
+ * Take a frozen snapshot of the registry state before any
1063
+ * direct registrations land. The snapshot resolves
1064
+ * placeholders against this point-in-time view, so a
1065
+ * `PreToolUse` hook later rewriting event args via
1066
+ * `updatedInput` can introduce placeholders that resolve
1067
+ * cross-batch (against prior runs) without ever picking up
1068
+ * same-turn direct outputs.
1069
+ */
1070
+ const preBatchSnapshot = this.toolOutputRegistry?.snapshot(batchScopeId);
1071
+ if (preBatchSnapshot != null) {
1072
+ for (const entry of eventEntries) {
1073
+ if (entry.call.id != null) {
1074
+ const { resolved, unresolved } = preBatchSnapshot.resolve(entry.call.args);
1075
+ preResolvedEventArgs.set(entry.call.id, {
1076
+ resolved: resolved,
1077
+ unresolved,
1078
+ });
1079
+ }
1080
+ }
687
1081
  }
688
- const directCalls = filteredCalls.filter((c) => this.directToolNames.has(c.name));
689
- const eventCalls = filteredCalls.filter((c) => !this.directToolNames.has(c.name));
690
1082
  const directOutputs = directCalls.length > 0
691
- ? await Promise.all(directCalls.map((call) => this.runTool(call, config)))
1083
+ ? await Promise.all(directCalls.map((call, i) => this.runTool(call, config, {
1084
+ batchIndex: directIndices[i],
1085
+ turn,
1086
+ batchScopeId,
1087
+ resolvedArgsByCallId,
1088
+ })))
692
1089
  : [];
693
1090
  if (directCalls.length > 0 && directOutputs.length > 0) {
694
- this.handleRunToolCompletions(directCalls, directOutputs, config);
1091
+ this.handleRunToolCompletions(directCalls, directOutputs, config, resolvedArgsByCallId);
695
1092
  }
696
1093
  const eventResult = eventCalls.length > 0
697
- ? await this.dispatchToolEvents(eventCalls, config)
1094
+ ? await this.dispatchToolEvents(eventCalls, config, {
1095
+ batchIndices: eventIndices,
1096
+ turn,
1097
+ batchScopeId,
1098
+ preResolvedArgs: preResolvedEventArgs,
1099
+ preBatchSnapshot,
1100
+ })
698
1101
  : {
699
1102
  toolMessages: [],
700
1103
  injected: [],
@@ -706,8 +1109,13 @@ class ToolNode extends run.RunnableCallable {
706
1109
  ];
707
1110
  }
708
1111
  else {
709
- outputs = await Promise.all(filteredCalls.map((call) => this.runTool(call, config)));
710
- this.handleRunToolCompletions(filteredCalls, outputs, config);
1112
+ outputs = await Promise.all(filteredCalls.map((call, i) => this.runTool(call, config, {
1113
+ batchIndex: i,
1114
+ turn,
1115
+ batchScopeId,
1116
+ resolvedArgsByCallId,
1117
+ })));
1118
+ this.handleRunToolCompletions(filteredCalls, outputs, config, resolvedArgsByCallId);
711
1119
  }
712
1120
  }
713
1121
  if (!outputs.some(langgraph.isCommand)) {