@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
@@ -10,6 +10,7 @@ import { RunnableCallable } from '../utils/run.mjs';
10
10
  import 'ai-tokenizer';
11
11
  import 'zod-to-json-schema';
12
12
  import { executeHooks } from '../hooks/executeHooks.mjs';
13
+ import { ToolOutputReferenceRegistry, annotateToolOutputWithReference, buildReferenceKey } from './toolOutputReferences.mjs';
13
14
 
14
15
  /**
15
16
  * Helper to check if a value is a Send object
@@ -70,7 +71,27 @@ class ToolNode extends RunnableCallable {
70
71
  maxToolResultChars;
71
72
  /** Hook registry for PreToolUse/PostToolUse lifecycle hooks */
72
73
  hookRegistry;
73
- constructor({ tools, toolMap, name, tags, errorHandler, toolCallStepIds, handleToolErrors, loadRuntimeTools, toolRegistry, sessions, eventDrivenMode, agentId, directToolNames, maxContextTokens, maxToolResultChars, 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, }) {
74
95
  super({ name, tags, func: (input, config) => this.run(input, config) });
75
96
  this.toolMap = toolMap ?? new Map(tools.map((tool) => [tool.name, tool]));
76
97
  this.toolCallStepIds = toolCallStepIds;
@@ -86,6 +107,37 @@ class ToolNode extends RunnableCallable {
86
107
  this.maxToolResultChars =
87
108
  maxToolResultChars ?? calculateMaxToolResultChars(maxContextTokens);
88
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;
89
141
  }
90
142
  /**
91
143
  * Returns cached programmatic tools, computing once on first access.
@@ -117,28 +169,85 @@ class ToolNode extends RunnableCallable {
117
169
  return new Map(this.toolUsageCount); // Return a copy
118
170
  }
119
171
  /**
120
- * 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.
121
178
  */
122
- async runTool(call, config) {
179
+ async runTool(call, config, batchContext = {}) {
180
+ const { batchIndex, turn, batchScopeId, resolvedArgsByCallId } = batchContext;
123
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;
124
206
  try {
125
207
  if (tool === undefined) {
126
208
  throw new Error(`Tool "${call.name}" not found.`);
127
209
  }
128
- const turn = this.toolUsageCount.get(call.name) ?? 0;
129
- 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);
130
219
  if (call.id != null && call.id !== '') {
131
- 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
+ }
132
240
  }
133
- const args = call.args;
134
241
  const stepId = this.toolCallStepIds?.get(call.id);
135
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).
136
245
  let invokeParams = {
137
246
  ...call,
138
247
  args,
139
248
  type: 'tool_call',
140
249
  stepId,
141
- turn,
250
+ turn: usageCount,
142
251
  };
143
252
  // Inject runtime data for special tools (becomes available at config.toolCall)
144
253
  if (call.name === Constants.PROGRAMMATIC_TOOL_CALLING ||
@@ -183,19 +292,72 @@ class ToolNode extends RunnableCallable {
183
292
  }
184
293
  }
185
294
  const output = await tool.invoke(invokeParams, config);
186
- if ((isBaseMessage(output) && output._getType() === 'tool') ||
187
- isCommand(output)) {
295
+ if (isCommand(output)) {
188
296
  return output;
189
297
  }
190
- else {
191
- const rawContent = typeof output === 'string' ? output : JSON.stringify(output);
192
- return new ToolMessage({
193
- status: 'success',
194
- name: tool.name,
195
- content: truncateToolResultContent(rawContent, this.maxToolResultChars),
196
- tool_call_id: call.id,
197
- });
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;
198
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
+ });
199
361
  }
200
362
  catch (_e) {
201
363
  const e = _e;
@@ -238,14 +400,52 @@ class ToolNode extends RunnableCallable {
238
400
  });
239
401
  }
240
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
+ }
241
407
  return new ToolMessage({
242
408
  status: 'error',
243
- content: `Error: ${e.message}\n Please fix your mistakes.`,
409
+ content: errorContent,
244
410
  name: call.name,
245
411
  tool_call_id: call.id ?? '',
246
412
  });
247
413
  }
248
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
+ }
249
449
  /**
250
450
  * Builds code session context for injection into event-driven tool calls.
251
451
  * Mirrors the session injection logic in runTool() for direct execution.
@@ -304,8 +504,12 @@ class ToolNode extends RunnableCallable {
304
504
  * By handling completions here in graph context (rather than in the
305
505
  * stream consumer via ToolEndHandler), the race between the stream
306
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.
307
511
  */
308
- handleRunToolCompletions(calls, outputs, config) {
512
+ handleRunToolCompletions(calls, outputs, config, resolvedArgsByCallId) {
309
513
  for (let i = 0; i < calls.length; i++) {
310
514
  const call = calls[i];
311
515
  const output = outputs[i];
@@ -334,10 +538,17 @@ class ToolNode extends RunnableCallable {
334
538
  const contentString = typeof toolMessage.content === 'string'
335
539
  ? toolMessage.content
336
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;
337
548
  const tool_call = {
338
- args: typeof call.args === 'string'
339
- ? call.args
340
- : JSON.stringify(call.args ?? {}),
549
+ args: typeof effectiveArgs === 'string'
550
+ ? effectiveArgs
551
+ : JSON.stringify(effectiveArgs ?? {}),
341
552
  name: call.name,
342
553
  id: toolCallId,
343
554
  output: contentString,
@@ -367,14 +578,52 @@ class ToolNode extends RunnableCallable {
367
578
  * 4. Injected messages from results are collected and returned alongside
368
579
  * ToolMessages (appended AFTER to respect provider ordering).
369
580
  */
370
- async dispatchToolEvents(toolCalls, config) {
581
+ async dispatchToolEvents(toolCalls, config, batchContext = {}) {
582
+ const { batchIndices, turn, batchScopeId, preResolvedArgs, preBatchSnapshot, } = batchContext;
371
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;
372
592
  const threadId = config.configurable?.thread_id;
373
- const preToolCalls = toolCalls.map((call) => ({
374
- call,
375
- stepId: this.toolCallStepIds?.get(call.id) ?? '',
376
- args: call.args,
377
- }));
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
+ }
612
+ }
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],
625
+ };
626
+ });
378
627
  const messageByCallId = new Map();
379
628
  const approvedEntries = [];
380
629
  const HOOK_FALLBACK = Object.freeze({
@@ -434,7 +683,40 @@ class ToolNode extends RunnableCallable {
434
683
  continue;
435
684
  }
436
685
  if (hookResult.updatedInput != null) {
437
- entry.args = hookResult.updatedInput;
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
+ }
438
720
  }
439
721
  approvedEntries.push(entry);
440
722
  }
@@ -443,10 +725,14 @@ class ToolNode extends RunnableCallable {
443
725
  approvedEntries.push(...preToolCalls);
444
726
  }
445
727
  const injected = [];
728
+ const batchIndexByCallId = new Map();
446
729
  if (approvedEntries.length > 0) {
447
730
  const requests = approvedEntries.map((entry) => {
448
731
  const turn = this.toolUsageCount.get(entry.call.name) ?? 0;
449
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
+ }
450
736
  const request = {
451
737
  id: entry.call.id,
452
738
  name: entry.call.name,
@@ -492,6 +778,15 @@ class ToolNode extends RunnableCallable {
492
778
  let toolMessage;
493
779
  if (result.status === 'error') {
494
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
+ }
495
790
  toolMessage = new ToolMessage({
496
791
  status: 'error',
497
792
  content: contentString,
@@ -521,10 +816,10 @@ class ToolNode extends RunnableCallable {
521
816
  }
522
817
  }
523
818
  else {
524
- const rawContent = typeof result.content === 'string'
819
+ let registryRaw = typeof result.content === 'string'
525
820
  ? result.content
526
821
  : JSON.stringify(result.content);
527
- contentString = truncateToolResultContent(rawContent, this.maxToolResultChars);
822
+ contentString = truncateToolResultContent(registryRaw, this.maxToolResultChars);
528
823
  if (hasPostHook) {
529
824
  const hookResult = await executeHooks({
530
825
  registry: this.hookRegistry,
@@ -547,9 +842,18 @@ class ToolNode extends RunnableCallable {
547
842
  const replaced = typeof hookResult.updatedOutput === 'string'
548
843
  ? hookResult.updatedOutput
549
844
  : JSON.stringify(hookResult.updatedOutput);
845
+ registryRaw = replaced;
550
846
  contentString = truncateToolResultContent(replaced, this.maxToolResultChars);
551
847
  }
552
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);
553
857
  toolMessage = new ToolMessage({
554
858
  status: 'success',
555
859
  name: toolName,
@@ -617,25 +921,61 @@ class ToolNode extends RunnableCallable {
617
921
  * Injected messages are placed AFTER ToolMessages to respect provider
618
922
  * message ordering (AIMessage tool_calls must be immediately followed
619
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.
620
929
  */
621
930
  async executeViaEvent(toolCalls, config,
622
931
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
623
- input) {
624
- const { toolMessages, injected } = await this.dispatchToolEvents(toolCalls, config);
932
+ input, batchContext = {}) {
933
+ const { toolMessages, injected } = await this.dispatchToolEvents(toolCalls, config, batchContext);
625
934
  const outputs = [...toolMessages, ...injected];
626
935
  return (Array.isArray(input) ? outputs : { messages: outputs });
627
936
  }
628
937
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
629
938
  async run(input, config) {
630
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;
631
960
  let outputs;
632
961
  if (this.isSendInput(input)) {
633
962
  const isDirectTool = this.directToolNames?.has(input.lg_tool_call.name);
634
963
  if (this.eventDrivenMode && isDirectTool !== true) {
635
- 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
+ });
636
969
  }
637
- outputs = [await this.runTool(input.lg_tool_call, config)];
638
- 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);
639
979
  }
640
980
  else {
641
981
  let messages;
@@ -680,19 +1020,82 @@ class ToolNode extends RunnableCallable {
680
1020
  false));
681
1021
  }) ?? [];
682
1022
  if (this.eventDrivenMode && filteredCalls.length > 0) {
1023
+ const filteredIndices = filteredCalls.map((_, idx) => idx);
683
1024
  if (!this.directToolNames || this.directToolNames.size === 0) {
684
- 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
+ }
685
1079
  }
686
- const directCalls = filteredCalls.filter((c) => this.directToolNames.has(c.name));
687
- const eventCalls = filteredCalls.filter((c) => !this.directToolNames.has(c.name));
688
1080
  const directOutputs = directCalls.length > 0
689
- ? 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
+ })))
690
1087
  : [];
691
1088
  if (directCalls.length > 0 && directOutputs.length > 0) {
692
- this.handleRunToolCompletions(directCalls, directOutputs, config);
1089
+ this.handleRunToolCompletions(directCalls, directOutputs, config, resolvedArgsByCallId);
693
1090
  }
694
1091
  const eventResult = eventCalls.length > 0
695
- ? await this.dispatchToolEvents(eventCalls, config)
1092
+ ? await this.dispatchToolEvents(eventCalls, config, {
1093
+ batchIndices: eventIndices,
1094
+ turn,
1095
+ batchScopeId,
1096
+ preResolvedArgs: preResolvedEventArgs,
1097
+ preBatchSnapshot,
1098
+ })
696
1099
  : {
697
1100
  toolMessages: [],
698
1101
  injected: [],
@@ -704,8 +1107,13 @@ class ToolNode extends RunnableCallable {
704
1107
  ];
705
1108
  }
706
1109
  else {
707
- outputs = await Promise.all(filteredCalls.map((call) => this.runTool(call, config)));
708
- 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);
709
1117
  }
710
1118
  }
711
1119
  if (!outputs.some(isCommand)) {