@librechat/agents 3.1.70 → 3.1.71-dev.1

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 (66) hide show
  1. package/dist/cjs/graphs/Graph.cjs +52 -0
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/llm/invoke.cjs +13 -2
  4. package/dist/cjs/llm/invoke.cjs.map +1 -1
  5. package/dist/cjs/main.cjs +4 -0
  6. package/dist/cjs/main.cjs.map +1 -1
  7. package/dist/cjs/messages/prune.cjs +9 -2
  8. package/dist/cjs/messages/prune.cjs.map +1 -1
  9. package/dist/cjs/run.cjs +4 -0
  10. package/dist/cjs/run.cjs.map +1 -1
  11. package/dist/cjs/tools/BashExecutor.cjs +43 -0
  12. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  13. package/dist/cjs/tools/ToolNode.cjs +482 -45
  14. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  15. package/dist/cjs/tools/toolOutputReferences.cjs +657 -0
  16. package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -0
  17. package/dist/cjs/utils/truncation.cjs +28 -0
  18. package/dist/cjs/utils/truncation.cjs.map +1 -1
  19. package/dist/esm/graphs/Graph.mjs +52 -0
  20. package/dist/esm/graphs/Graph.mjs.map +1 -1
  21. package/dist/esm/llm/invoke.mjs +13 -2
  22. package/dist/esm/llm/invoke.mjs.map +1 -1
  23. package/dist/esm/main.mjs +2 -2
  24. package/dist/esm/messages/prune.mjs +9 -2
  25. package/dist/esm/messages/prune.mjs.map +1 -1
  26. package/dist/esm/run.mjs +4 -0
  27. package/dist/esm/run.mjs.map +1 -1
  28. package/dist/esm/tools/BashExecutor.mjs +42 -1
  29. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  30. package/dist/esm/tools/ToolNode.mjs +482 -45
  31. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  32. package/dist/esm/tools/toolOutputReferences.mjs +649 -0
  33. package/dist/esm/tools/toolOutputReferences.mjs.map +1 -0
  34. package/dist/esm/utils/truncation.mjs +27 -1
  35. package/dist/esm/utils/truncation.mjs.map +1 -1
  36. package/dist/types/graphs/Graph.d.ts +28 -0
  37. package/dist/types/llm/invoke.d.ts +9 -0
  38. package/dist/types/run.d.ts +1 -0
  39. package/dist/types/tools/BashExecutor.d.ts +31 -0
  40. package/dist/types/tools/ToolNode.d.ts +84 -3
  41. package/dist/types/tools/toolOutputReferences.d.ts +236 -0
  42. package/dist/types/types/index.d.ts +1 -0
  43. package/dist/types/types/messages.d.ts +26 -0
  44. package/dist/types/types/run.d.ts +9 -1
  45. package/dist/types/types/tools.d.ts +70 -0
  46. package/dist/types/utils/truncation.d.ts +21 -0
  47. package/package.json +1 -1
  48. package/src/graphs/Graph.ts +55 -0
  49. package/src/llm/invoke.test.ts +442 -0
  50. package/src/llm/invoke.ts +23 -2
  51. package/src/messages/prune.ts +9 -2
  52. package/src/run.ts +4 -0
  53. package/src/specs/prune.test.ts +413 -0
  54. package/src/tools/BashExecutor.ts +45 -0
  55. package/src/tools/ToolNode.ts +631 -55
  56. package/src/tools/__tests__/BashExecutor.test.ts +36 -0
  57. package/src/tools/__tests__/ToolNode.outputReferences.test.ts +1438 -0
  58. package/src/tools/__tests__/annotateMessagesForLLM.test.ts +419 -0
  59. package/src/tools/__tests__/toolOutputReferences.test.ts +415 -0
  60. package/src/tools/toolOutputReferences.ts +813 -0
  61. package/src/types/index.ts +1 -0
  62. package/src/types/messages.ts +27 -0
  63. package/src/types/run.ts +9 -1
  64. package/src/types/tools.ts +71 -0
  65. package/src/utils/__tests__/truncation.test.ts +66 -0
  66. 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, 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,80 @@ 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 but still stamp the
304
+ * unresolved-refs hint into `additional_kwargs` so the lazy
305
+ * annotation transform surfaces it to the LLM, letting the
306
+ * model self-correct when its reference key caused the
307
+ * failure. Persisted `content` stays clean.
308
+ */
309
+ if (unresolvedRefs.length > 0) {
310
+ toolMsg.additional_kwargs = {
311
+ ...toolMsg.additional_kwargs,
312
+ _unresolvedRefs: unresolvedRefs,
313
+ };
314
+ }
315
+ return toolMsg;
316
+ }
317
+ if (this.toolOutputRegistry != null || unresolvedRefs.length > 0) {
318
+ if (typeof toolMsg.content === 'string') {
319
+ const rawContent = toolMsg.content;
320
+ const llmContent = truncateToolResultContent(rawContent, this.maxToolResultChars);
321
+ toolMsg.content = llmContent;
322
+ const refMeta = this.recordOutputReference(runId, rawContent, refKey, unresolvedRefs);
323
+ if (refMeta != null) {
324
+ toolMsg.additional_kwargs = {
325
+ ...toolMsg.additional_kwargs,
326
+ ...refMeta,
327
+ };
328
+ }
329
+ }
330
+ else {
331
+ /**
332
+ * Non-string content (multi-part content blocks — text +
333
+ * image). Known limitation: we cannot register under a
334
+ * reference key because there's no canonical serialized
335
+ * form. Warn once per tool per run when the caller
336
+ * intended to register. The unresolved-refs hint is still
337
+ * stamped as metadata; the lazy transform prepends a text
338
+ * block at request time so the LLM gets the self-correction
339
+ * signal.
340
+ */
341
+ if (unresolvedRefs.length > 0) {
342
+ toolMsg.additional_kwargs = {
343
+ ...toolMsg.additional_kwargs,
344
+ _unresolvedRefs: unresolvedRefs,
345
+ };
346
+ }
347
+ if (refKey != null &&
348
+ this.toolOutputRegistry.claimWarnOnce(runId, call.name)) {
349
+ // eslint-disable-next-line no-console
350
+ console.warn(`[ToolNode] Skipping tool output reference for "${call.name}": ` +
351
+ 'ToolMessage content is not a string (further occurrences for this tool in the same run are suppressed).');
352
+ }
353
+ }
354
+ }
355
+ return toolMsg;
198
356
  }
357
+ const rawContent = typeof output === 'string' ? output : JSON.stringify(output);
358
+ const truncated = truncateToolResultContent(rawContent, this.maxToolResultChars);
359
+ const refMeta = this.recordOutputReference(runId, rawContent, refKey, unresolvedRefs);
360
+ return new ToolMessage({
361
+ status: 'success',
362
+ name: tool.name,
363
+ content: truncated,
364
+ tool_call_id: call.id,
365
+ ...(refMeta != null && {
366
+ additional_kwargs: refMeta,
367
+ }),
368
+ });
199
369
  }
200
370
  catch (_e) {
201
371
  const e = _e;
@@ -238,14 +408,65 @@ class ToolNode extends RunnableCallable {
238
408
  });
239
409
  }
240
410
  }
411
+ const errorContent = `Error: ${e.message}\n Please fix your mistakes.`;
412
+ const refMeta = unresolvedRefs.length > 0
413
+ ? this.recordOutputReference(runId, errorContent, undefined, unresolvedRefs)
414
+ : undefined;
241
415
  return new ToolMessage({
242
416
  status: 'error',
243
- content: `Error: ${e.message}\n Please fix your mistakes.`,
417
+ content: errorContent,
244
418
  name: call.name,
245
419
  tool_call_id: call.id ?? '',
420
+ ...(refMeta != null && {
421
+ additional_kwargs: refMeta,
422
+ }),
246
423
  });
247
424
  }
248
425
  }
426
+ /**
427
+ * Registers the full, raw output under `refKey` (when provided) and
428
+ * builds the per-message ref metadata stamped onto the resulting
429
+ * `ToolMessage.additional_kwargs`. The metadata is read at LLM-
430
+ * request time by `annotateMessagesForLLM` to produce a transient
431
+ * annotated copy of the message — the persisted `content` itself
432
+ * stays clean.
433
+ *
434
+ * @param registryContent The full, untruncated output to store in
435
+ * the registry so `{{tool<i>turn<n>}}` substitutions deliver the
436
+ * complete payload. Ignored when `refKey` is undefined.
437
+ * @param refKey Precomputed `tool<i>turn<n>` key, or undefined when
438
+ * the output is not to be registered (errors, disabled feature,
439
+ * unavailable batch/turn).
440
+ * @param unresolved Placeholder keys that did not resolve; surfaced
441
+ * to the LLM lazily so it can self-correct.
442
+ * @returns A `ToolMessageRefMetadata` object when there is anything
443
+ * to stamp, otherwise `undefined`.
444
+ */
445
+ recordOutputReference(runId, registryContent, refKey, unresolved) {
446
+ if (this.toolOutputRegistry != null && refKey != null) {
447
+ this.toolOutputRegistry.set(runId, refKey, registryContent);
448
+ }
449
+ if (refKey == null && unresolved.length === 0)
450
+ return undefined;
451
+ const meta = {};
452
+ if (refKey != null) {
453
+ meta._refKey = refKey;
454
+ /**
455
+ * Stamp the registry scope alongside the key so the lazy
456
+ * annotation transform can look up the right bucket. Anonymous
457
+ * invocations get a synthetic per-batch scope (`\0anon-<n>`)
458
+ * that `attemptInvoke` cannot derive from
459
+ * `config.configurable.run_id` — without this, anonymous-run
460
+ * refs would silently fail registry lookup and the LLM would
461
+ * never see `[ref: …]` markers for outputs that were registered.
462
+ */
463
+ if (runId != null)
464
+ meta._refScope = runId;
465
+ }
466
+ if (unresolved.length > 0)
467
+ meta._unresolvedRefs = unresolved;
468
+ return meta;
469
+ }
249
470
  /**
250
471
  * Builds code session context for injection into event-driven tool calls.
251
472
  * Mirrors the session injection logic in runTool() for direct execution.
@@ -304,8 +525,12 @@ class ToolNode extends RunnableCallable {
304
525
  * By handling completions here in graph context (rather than in the
305
526
  * stream consumer via ToolEndHandler), the race between the stream
306
527
  * consumer and graph execution is eliminated.
528
+ *
529
+ * @param resolvedArgsByCallId Per-batch resolved-args sink populated
530
+ * by `runTool`. Threaded as a local map (instead of instance state)
531
+ * so concurrent batches cannot read each other's entries.
307
532
  */
308
- handleRunToolCompletions(calls, outputs, config) {
533
+ handleRunToolCompletions(calls, outputs, config, resolvedArgsByCallId) {
309
534
  for (let i = 0; i < calls.length; i++) {
310
535
  const call = calls[i];
311
536
  const output = outputs[i];
@@ -334,10 +559,17 @@ class ToolNode extends RunnableCallable {
334
559
  const contentString = typeof toolMessage.content === 'string'
335
560
  ? toolMessage.content
336
561
  : JSON.stringify(toolMessage.content);
562
+ /**
563
+ * Prefer the post-substitution args when a `{{…}}` placeholder
564
+ * was resolved in `runTool`. This keeps
565
+ * `ON_RUN_STEP_COMPLETED.tool_call.args` consistent with what
566
+ * the tool actually received rather than leaking the template.
567
+ */
568
+ const effectiveArgs = resolvedArgsByCallId?.get(toolCallId) ?? call.args;
337
569
  const tool_call = {
338
- args: typeof call.args === 'string'
339
- ? call.args
340
- : JSON.stringify(call.args ?? {}),
570
+ args: typeof effectiveArgs === 'string'
571
+ ? effectiveArgs
572
+ : JSON.stringify(effectiveArgs ?? {}),
341
573
  name: call.name,
342
574
  id: toolCallId,
343
575
  output: contentString,
@@ -367,14 +599,52 @@ class ToolNode extends RunnableCallable {
367
599
  * 4. Injected messages from results are collected and returned alongside
368
600
  * ToolMessages (appended AFTER to respect provider ordering).
369
601
  */
370
- async dispatchToolEvents(toolCalls, config) {
602
+ async dispatchToolEvents(toolCalls, config, batchContext = {}) {
603
+ const { batchIndices, turn, batchScopeId, preResolvedArgs, preBatchSnapshot, } = batchContext;
371
604
  const runId = config.configurable?.run_id ?? '';
605
+ /**
606
+ * Registry-facing scope id — prefers the caller-threaded
607
+ * `batchScopeId` so anonymous batches target their own unique
608
+ * bucket and don't step on concurrent anonymous invocations.
609
+ * Hooks and event payloads keep using the empty-string coerced
610
+ * `runId` for backward compat.
611
+ */
612
+ const registryRunId = batchScopeId ?? config.configurable?.run_id;
372
613
  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
- }));
614
+ const registry = this.toolOutputRegistry;
615
+ const unresolvedByCallId = new Map();
616
+ const preToolCalls = toolCalls.map((call, i) => {
617
+ const originalArgs = call.args;
618
+ let resolvedArgs = originalArgs;
619
+ /**
620
+ * When the caller provided a pre-resolved map (the mixed
621
+ * direct+event path snapshots event args synchronously before
622
+ * awaiting directs so they can't accidentally resolve
623
+ * same-turn direct outputs), use those entries verbatim instead
624
+ * of re-resolving against a registry that may have changed
625
+ * since the batch started.
626
+ */
627
+ const pre = call.id != null ? preResolvedArgs?.get(call.id) : undefined;
628
+ if (pre != null) {
629
+ resolvedArgs = pre.resolved;
630
+ if (pre.unresolved.length > 0 && call.id != null) {
631
+ unresolvedByCallId.set(call.id, pre.unresolved);
632
+ }
633
+ }
634
+ else if (registry != null) {
635
+ const { resolved, unresolved } = registry.resolve(registryRunId, originalArgs);
636
+ resolvedArgs = resolved;
637
+ if (unresolved.length > 0 && call.id != null) {
638
+ unresolvedByCallId.set(call.id, unresolved);
639
+ }
640
+ }
641
+ return {
642
+ call,
643
+ stepId: this.toolCallStepIds?.get(call.id) ?? '',
644
+ args: resolvedArgs,
645
+ batchIndex: batchIndices?.[i],
646
+ };
647
+ });
378
648
  const messageByCallId = new Map();
379
649
  const approvedEntries = [];
380
650
  const HOOK_FALLBACK = Object.freeze({
@@ -434,7 +704,40 @@ class ToolNode extends RunnableCallable {
434
704
  continue;
435
705
  }
436
706
  if (hookResult.updatedInput != null) {
437
- entry.args = hookResult.updatedInput;
707
+ /**
708
+ * Re-resolve after PreToolUse replaces the input: a hook may
709
+ * introduce new `{{tool<i>turn<n>}}` placeholders (e.g., by
710
+ * copying user-supplied text) that the pre-hook pass never
711
+ * saw. Re-running the resolver on the hook-rewritten args
712
+ * keeps substitution and the unresolved-refs record in sync
713
+ * with what the tool will actually receive.
714
+ */
715
+ if (registry != null) {
716
+ /**
717
+ * Mixed direct+event batches must use the pre-batch
718
+ * snapshot so a hook-introduced placeholder cannot
719
+ * accidentally resolve to a same-turn direct output that
720
+ * has just registered. Pure event batches don't have a
721
+ * snapshot and resolve against the live registry — safe
722
+ * because no event-side registrations have happened yet.
723
+ */
724
+ const view = preBatchSnapshot ?? {
725
+ resolve: (args) => registry.resolve(registryRunId, args),
726
+ };
727
+ const { resolved, unresolved } = view.resolve(hookResult.updatedInput);
728
+ entry.args = resolved;
729
+ if (entry.call.id != null) {
730
+ if (unresolved.length > 0) {
731
+ unresolvedByCallId.set(entry.call.id, unresolved);
732
+ }
733
+ else {
734
+ unresolvedByCallId.delete(entry.call.id);
735
+ }
736
+ }
737
+ }
738
+ else {
739
+ entry.args = hookResult.updatedInput;
740
+ }
438
741
  }
439
742
  approvedEntries.push(entry);
440
743
  }
@@ -443,10 +746,14 @@ class ToolNode extends RunnableCallable {
443
746
  approvedEntries.push(...preToolCalls);
444
747
  }
445
748
  const injected = [];
749
+ const batchIndexByCallId = new Map();
446
750
  if (approvedEntries.length > 0) {
447
751
  const requests = approvedEntries.map((entry) => {
448
752
  const turn = this.toolUsageCount.get(entry.call.name) ?? 0;
449
753
  this.toolUsageCount.set(entry.call.name, turn + 1);
754
+ if (entry.batchIndex != null && entry.call.id != null) {
755
+ batchIndexByCallId.set(entry.call.id, entry.batchIndex);
756
+ }
450
757
  const request = {
451
758
  id: entry.call.id,
452
759
  name: entry.call.name,
@@ -492,11 +799,25 @@ class ToolNode extends RunnableCallable {
492
799
  let toolMessage;
493
800
  if (result.status === 'error') {
494
801
  contentString = `Error: ${result.errorMessage ?? 'Unknown error'}\n Please fix your mistakes.`;
802
+ /**
803
+ * Error results bypass registration but stamp the
804
+ * unresolved-refs hint into `additional_kwargs` so the lazy
805
+ * annotation transform surfaces it to the LLM at request
806
+ * time, letting the model self-correct when its reference
807
+ * key caused the failure. Persisted `content` stays clean.
808
+ */
809
+ const unresolved = unresolvedByCallId.get(result.toolCallId) ?? [];
810
+ const errorRefMeta = unresolved.length > 0
811
+ ? this.recordOutputReference(registryRunId, contentString, undefined, unresolved)
812
+ : undefined;
495
813
  toolMessage = new ToolMessage({
496
814
  status: 'error',
497
815
  content: contentString,
498
816
  name: toolName,
499
817
  tool_call_id: result.toolCallId,
818
+ ...(errorRefMeta != null && {
819
+ additional_kwargs: errorRefMeta,
820
+ }),
500
821
  });
501
822
  if (hasFailureHook) {
502
823
  await executeHooks({
@@ -521,10 +842,10 @@ class ToolNode extends RunnableCallable {
521
842
  }
522
843
  }
523
844
  else {
524
- const rawContent = typeof result.content === 'string'
845
+ let registryRaw = typeof result.content === 'string'
525
846
  ? result.content
526
847
  : JSON.stringify(result.content);
527
- contentString = truncateToolResultContent(rawContent, this.maxToolResultChars);
848
+ contentString = truncateToolResultContent(registryRaw, this.maxToolResultChars);
528
849
  if (hasPostHook) {
529
850
  const hookResult = await executeHooks({
530
851
  registry: this.hookRegistry,
@@ -547,15 +868,27 @@ class ToolNode extends RunnableCallable {
547
868
  const replaced = typeof hookResult.updatedOutput === 'string'
548
869
  ? hookResult.updatedOutput
549
870
  : JSON.stringify(hookResult.updatedOutput);
871
+ registryRaw = replaced;
550
872
  contentString = truncateToolResultContent(replaced, this.maxToolResultChars);
551
873
  }
552
874
  }
875
+ const batchIndex = batchIndexByCallId.get(result.toolCallId);
876
+ const unresolved = unresolvedByCallId.get(result.toolCallId) ?? [];
877
+ const refKey = this.toolOutputRegistry != null &&
878
+ batchIndex != null &&
879
+ turn != null
880
+ ? buildReferenceKey(batchIndex, turn)
881
+ : undefined;
882
+ const successRefMeta = this.recordOutputReference(registryRunId, registryRaw, refKey, unresolved);
553
883
  toolMessage = new ToolMessage({
554
884
  status: 'success',
555
885
  name: toolName,
556
886
  content: contentString,
557
887
  artifact: result.artifact,
558
888
  tool_call_id: result.toolCallId,
889
+ ...(successRefMeta != null && {
890
+ additional_kwargs: successRefMeta,
891
+ }),
559
892
  });
560
893
  }
561
894
  this.dispatchStepCompleted(result.toolCallId, toolName, request?.args ?? {}, contentString, config, request?.turn);
@@ -617,25 +950,61 @@ class ToolNode extends RunnableCallable {
617
950
  * Injected messages are placed AFTER ToolMessages to respect provider
618
951
  * message ordering (AIMessage tool_calls must be immediately followed
619
952
  * by their ToolMessage results).
953
+ *
954
+ * `batchIndices` mirrors `toolCalls` and carries each call's position
955
+ * within the parent batch. `turn` is the per-`run()` batch index
956
+ * captured locally by the caller. Both are threaded so concurrent
957
+ * invocations cannot race on shared mutable state.
620
958
  */
621
959
  async executeViaEvent(toolCalls, config,
622
960
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
623
- input) {
624
- const { toolMessages, injected } = await this.dispatchToolEvents(toolCalls, config);
961
+ input, batchContext = {}) {
962
+ const { toolMessages, injected } = await this.dispatchToolEvents(toolCalls, config, batchContext);
625
963
  const outputs = [...toolMessages, ...injected];
626
964
  return (Array.isArray(input) ? outputs : { messages: outputs });
627
965
  }
628
966
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
629
967
  async run(input, config) {
630
968
  this.toolCallTurns.clear();
969
+ /**
970
+ * Per-batch local map for resolved (post-substitution) args.
971
+ * Lives on the stack so concurrent `run()` calls on the same
972
+ * ToolNode cannot read or wipe each other's entries.
973
+ */
974
+ const resolvedArgsByCallId = new Map();
975
+ /**
976
+ * Claim this batch's turn synchronously from the registry (or
977
+ * fall back to 0 when the feature is disabled). The registry is
978
+ * partitioned by scope id so overlapping batches cannot
979
+ * overwrite each other's state even under a shared registry.
980
+ *
981
+ * For anonymous callers (no `run_id` in config), mint a unique
982
+ * per-batch scope id so two concurrent anonymous invocations
983
+ * don't target the same bucket. The scope is threaded down to
984
+ * every subsequent registry call on this batch.
985
+ */
986
+ const incomingRunId = config.configurable?.run_id;
987
+ const batchScopeId = incomingRunId ?? `\0anon-${this.anonBatchCounter++}`;
988
+ const turn = this.toolOutputRegistry?.nextTurn(batchScopeId) ?? 0;
631
989
  let outputs;
632
990
  if (this.isSendInput(input)) {
633
991
  const isDirectTool = this.directToolNames?.has(input.lg_tool_call.name);
634
992
  if (this.eventDrivenMode && isDirectTool !== true) {
635
- return this.executeViaEvent([input.lg_tool_call], config, input);
993
+ return this.executeViaEvent([input.lg_tool_call], config, input, {
994
+ batchIndices: [0],
995
+ turn,
996
+ batchScopeId,
997
+ });
636
998
  }
637
- outputs = [await this.runTool(input.lg_tool_call, config)];
638
- this.handleRunToolCompletions([input.lg_tool_call], outputs, config);
999
+ outputs = [
1000
+ await this.runTool(input.lg_tool_call, config, {
1001
+ batchIndex: 0,
1002
+ turn,
1003
+ batchScopeId,
1004
+ resolvedArgsByCallId,
1005
+ }),
1006
+ ];
1007
+ this.handleRunToolCompletions([input.lg_tool_call], outputs, config, resolvedArgsByCallId);
639
1008
  }
640
1009
  else {
641
1010
  let messages;
@@ -680,19 +1049,82 @@ class ToolNode extends RunnableCallable {
680
1049
  false));
681
1050
  }) ?? [];
682
1051
  if (this.eventDrivenMode && filteredCalls.length > 0) {
1052
+ const filteredIndices = filteredCalls.map((_, idx) => idx);
683
1053
  if (!this.directToolNames || this.directToolNames.size === 0) {
684
- return this.executeViaEvent(filteredCalls, config, input);
1054
+ return this.executeViaEvent(filteredCalls, config, input, {
1055
+ batchIndices: filteredIndices,
1056
+ turn,
1057
+ batchScopeId,
1058
+ });
1059
+ }
1060
+ const directEntries = [];
1061
+ const eventEntries = [];
1062
+ for (let i = 0; i < filteredCalls.length; i++) {
1063
+ const call = filteredCalls[i];
1064
+ const entry = { call, batchIndex: i };
1065
+ if (this.directToolNames.has(call.name)) {
1066
+ directEntries.push(entry);
1067
+ }
1068
+ else {
1069
+ eventEntries.push(entry);
1070
+ }
1071
+ }
1072
+ const directCalls = directEntries.map((e) => e.call);
1073
+ const directIndices = directEntries.map((e) => e.batchIndex);
1074
+ const eventCalls = eventEntries.map((e) => e.call);
1075
+ const eventIndices = eventEntries.map((e) => e.batchIndex);
1076
+ /**
1077
+ * Snapshot the event calls' args against the *pre-batch*
1078
+ * registry state synchronously, before any await runs. The
1079
+ * directs are then awaited first (preserving fail-fast
1080
+ * semantics — a thrown error in a direct tool, e.g. with
1081
+ * `handleToolErrors=false` or a `GraphInterrupt`, aborts
1082
+ * before we dispatch any event-driven tools to the host).
1083
+ * Because the event args were captured pre-await, they stay
1084
+ * isolated from same-turn direct outputs that register
1085
+ * during the await.
1086
+ */
1087
+ const preResolvedEventArgs = new Map();
1088
+ /**
1089
+ * Take a frozen snapshot of the registry state before any
1090
+ * direct registrations land. The snapshot resolves
1091
+ * placeholders against this point-in-time view, so a
1092
+ * `PreToolUse` hook later rewriting event args via
1093
+ * `updatedInput` can introduce placeholders that resolve
1094
+ * cross-batch (against prior runs) without ever picking up
1095
+ * same-turn direct outputs.
1096
+ */
1097
+ const preBatchSnapshot = this.toolOutputRegistry?.snapshot(batchScopeId);
1098
+ if (preBatchSnapshot != null) {
1099
+ for (const entry of eventEntries) {
1100
+ if (entry.call.id != null) {
1101
+ const { resolved, unresolved } = preBatchSnapshot.resolve(entry.call.args);
1102
+ preResolvedEventArgs.set(entry.call.id, {
1103
+ resolved: resolved,
1104
+ unresolved,
1105
+ });
1106
+ }
1107
+ }
685
1108
  }
686
- const directCalls = filteredCalls.filter((c) => this.directToolNames.has(c.name));
687
- const eventCalls = filteredCalls.filter((c) => !this.directToolNames.has(c.name));
688
1109
  const directOutputs = directCalls.length > 0
689
- ? await Promise.all(directCalls.map((call) => this.runTool(call, config)))
1110
+ ? await Promise.all(directCalls.map((call, i) => this.runTool(call, config, {
1111
+ batchIndex: directIndices[i],
1112
+ turn,
1113
+ batchScopeId,
1114
+ resolvedArgsByCallId,
1115
+ })))
690
1116
  : [];
691
1117
  if (directCalls.length > 0 && directOutputs.length > 0) {
692
- this.handleRunToolCompletions(directCalls, directOutputs, config);
1118
+ this.handleRunToolCompletions(directCalls, directOutputs, config, resolvedArgsByCallId);
693
1119
  }
694
1120
  const eventResult = eventCalls.length > 0
695
- ? await this.dispatchToolEvents(eventCalls, config)
1121
+ ? await this.dispatchToolEvents(eventCalls, config, {
1122
+ batchIndices: eventIndices,
1123
+ turn,
1124
+ batchScopeId,
1125
+ preResolvedArgs: preResolvedEventArgs,
1126
+ preBatchSnapshot,
1127
+ })
696
1128
  : {
697
1129
  toolMessages: [],
698
1130
  injected: [],
@@ -704,8 +1136,13 @@ class ToolNode extends RunnableCallable {
704
1136
  ];
705
1137
  }
706
1138
  else {
707
- outputs = await Promise.all(filteredCalls.map((call) => this.runTool(call, config)));
708
- this.handleRunToolCompletions(filteredCalls, outputs, config);
1139
+ outputs = await Promise.all(filteredCalls.map((call, i) => this.runTool(call, config, {
1140
+ batchIndex: i,
1141
+ turn,
1142
+ batchScopeId,
1143
+ resolvedArgsByCallId,
1144
+ })));
1145
+ this.handleRunToolCompletions(filteredCalls, outputs, config, resolvedArgsByCallId);
709
1146
  }
710
1147
  }
711
1148
  if (!outputs.some(isCommand)) {