@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
@@ -19,8 +19,13 @@ import type {
19
19
  } from '@langchain/core/runnables';
20
20
  import type { BaseMessage, AIMessage } from '@langchain/core/messages';
21
21
  import type { StructuredToolInterface } from '@langchain/core/tools';
22
- import type * as t from '@/types';
22
+ import type {
23
+ ToolOutputResolveView,
24
+ PreResolvedArgsMap,
25
+ ResolvedArgsByCallId,
26
+ } from '@/tools/toolOutputReferences';
23
27
  import type { HookRegistry, AggregatedHookResult } from '@/hooks';
28
+ import type * as t from '@/types';
24
29
  import { RunnableCallable } from '@/utils';
25
30
  import {
26
31
  calculateMaxToolResultChars,
@@ -29,6 +34,53 @@ import {
29
34
  import { safeDispatchCustomEvent } from '@/utils/events';
30
35
  import { executeHooks } from '@/hooks';
31
36
  import { Constants, GraphEvents, CODE_EXECUTION_TOOLS } from '@/common';
37
+ import {
38
+ buildReferenceKey,
39
+ ToolOutputReferenceRegistry,
40
+ } from '@/tools/toolOutputReferences';
41
+
42
+ /**
43
+ * Per-call batch context for `runTool`. Bundles every optional
44
+ * batch-scoped value the method needs so the signature stays at
45
+ * three positional parameters even as new context fields are added.
46
+ */
47
+ type RunToolBatchContext = {
48
+ /** Position of this call within the parent ToolNode batch. */
49
+ batchIndex?: number;
50
+ /** Batch turn shared across every call in the batch. */
51
+ turn?: number;
52
+ /** Registry partition scope (run id or anonymous batch id). */
53
+ batchScopeId?: string;
54
+ /** Batch-local sink for post-substitution args. */
55
+ resolvedArgsByCallId?: ResolvedArgsByCallId;
56
+ };
57
+
58
+ /**
59
+ * Per-batch context for `dispatchToolEvents` / `executeViaEvent`.
60
+ * Mirrors {@link RunToolBatchContext} for the event-driven path,
61
+ * with bulk indices and the snapshot/pre-resolved-args carriers
62
+ * used in the mixed direct+event flow.
63
+ */
64
+ type DispatchBatchContext = {
65
+ /** Per-call batch indices, parallel to the `toolCalls` array. */
66
+ batchIndices?: number[];
67
+ /** Batch turn shared across every call in the batch. */
68
+ turn?: number;
69
+ /** Registry partition scope (run id or anonymous batch id). */
70
+ batchScopeId?: string;
71
+ /**
72
+ * Pre-resolved args keyed by `toolCallId`. Populated by the mixed
73
+ * path so event calls don't re-resolve against a registry that
74
+ * already contains same-turn direct outputs.
75
+ */
76
+ preResolvedArgs?: PreResolvedArgsMap;
77
+ /**
78
+ * Frozen pre-batch registry view used to re-resolve placeholders
79
+ * a `PreToolUse` hook injects via `updatedInput` — preserves the
80
+ * same-turn isolation guarantee for hook-rewritten args.
81
+ */
82
+ preBatchSnapshot?: ToolOutputResolveView;
83
+ };
32
84
 
33
85
  /**
34
86
  * Helper to check if a value is a Send object
@@ -99,6 +151,26 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
99
151
  private maxToolResultChars: number;
100
152
  /** Hook registry for PreToolUse/PostToolUse lifecycle hooks */
101
153
  private hookRegistry?: HookRegistry;
154
+ /**
155
+ * Registry of tool outputs keyed by `tool<idx>turn<turn>`.
156
+ *
157
+ * Populated only when `toolOutputReferences.enabled` is true. The
158
+ * registry owns the run-scoped state (turn counter, last-seen runId,
159
+ * warn-once memo, stored outputs), so sharing a single instance
160
+ * across multiple ToolNodes in a run lets cross-agent `{{…}}`
161
+ * references resolve — which is why multi-agent graphs pass the
162
+ * *same* instance to every ToolNode they compile rather than each
163
+ * ToolNode building its own.
164
+ */
165
+ private toolOutputRegistry?: ToolOutputReferenceRegistry;
166
+ /**
167
+ * Monotonic counter used to mint a unique scope id for anonymous
168
+ * batches (ones invoked without a `run_id` in
169
+ * `config.configurable`). Each such batch gets its own registry
170
+ * partition so concurrent anonymous invocations can't delete each
171
+ * other's in-flight state.
172
+ */
173
+ private anonBatchCounter: number = 0;
102
174
 
103
175
  constructor({
104
176
  tools,
@@ -117,6 +189,8 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
117
189
  maxContextTokens,
118
190
  maxToolResultChars,
119
191
  hookRegistry,
192
+ toolOutputReferences,
193
+ toolOutputRegistry,
120
194
  }: t.ToolNodeConstructorParams) {
121
195
  super({ name, tags, func: (input, config) => this.run(input, config) });
122
196
  this.toolMap = toolMap ?? new Map(tools.map((tool) => [tool.name, tool]));
@@ -133,6 +207,39 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
133
207
  this.maxToolResultChars =
134
208
  maxToolResultChars ?? calculateMaxToolResultChars(maxContextTokens);
135
209
  this.hookRegistry = hookRegistry;
210
+ /**
211
+ * Precedence: an explicitly passed `toolOutputRegistry` instance
212
+ * wins over a config object so a host (`Graph`) can share one
213
+ * registry across many ToolNodes. When only the config is
214
+ * provided (direct ToolNode usage), build a local registry so
215
+ * the feature still works without graph-level plumbing. Registry
216
+ * caps are intentionally decoupled from `maxToolResultChars`:
217
+ * the registry stores the raw untruncated output so a later
218
+ * `{{…}}` substitution pipes the full payload into the next
219
+ * tool, even when the LLM saw a truncated preview.
220
+ */
221
+ if (toolOutputRegistry != null) {
222
+ this.toolOutputRegistry = toolOutputRegistry;
223
+ } else if (toolOutputReferences?.enabled === true) {
224
+ this.toolOutputRegistry = new ToolOutputReferenceRegistry({
225
+ maxOutputSize: toolOutputReferences.maxOutputSize,
226
+ maxTotalSize: toolOutputReferences.maxTotalSize,
227
+ });
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Returns the run-scoped tool output registry, or `undefined` when
233
+ * the feature is disabled.
234
+ *
235
+ * @internal Exposed for test observation only. Host code should rely
236
+ * on `{{tool<i>turn<n>}}` substitution at tool-invocation time and
237
+ * not mutate the registry directly.
238
+ */
239
+ public _unsafeGetToolOutputRegistry():
240
+ | ToolOutputReferenceRegistry
241
+ | undefined {
242
+ return this.toolOutputRegistry;
136
243
  }
137
244
 
138
245
  /**
@@ -170,32 +277,98 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
170
277
  }
171
278
 
172
279
  /**
173
- * Runs a single tool call with error handling
280
+ * Runs a single tool call with error handling.
281
+ *
282
+ * `batchIndex` is the tool's position within the current ToolNode
283
+ * batch and, together with `this.currentTurn`, forms the key used to
284
+ * register the output for future `{{tool<idx>turn<turn>}}`
285
+ * substitutions. Omit when no registration should occur.
174
286
  */
175
287
  protected async runTool(
176
288
  call: ToolCall,
177
- config: RunnableConfig
289
+ config: RunnableConfig,
290
+ batchContext: RunToolBatchContext = {}
178
291
  ): Promise<BaseMessage | Command> {
292
+ const { batchIndex, turn, batchScopeId, resolvedArgsByCallId } =
293
+ batchContext;
179
294
  const tool = this.toolMap.get(call.name);
295
+ const registry = this.toolOutputRegistry;
296
+ /**
297
+ * Precompute the reference key once per call — captured locally
298
+ * so concurrent `invoke()` calls on the same ToolNode cannot race
299
+ * on a shared turn field.
300
+ */
301
+ const refKey =
302
+ registry != null && batchIndex != null && turn != null
303
+ ? buildReferenceKey(batchIndex, turn)
304
+ : undefined;
305
+ /**
306
+ * Hoisted outside the try so the catch branch can append
307
+ * `[unresolved refs: …]` to error messages — otherwise the LLM
308
+ * only sees a generic error when it references a bad key, losing
309
+ * the self-correction signal this feature is meant to provide.
310
+ */
311
+ let unresolvedRefs: string[] = [];
312
+ /**
313
+ * Use the caller-provided `batchScopeId` when threaded from
314
+ * `run()` (so anonymous batches get their own unique scope).
315
+ * Fall back to the config's `run_id` when runTool is invoked
316
+ * from a context that doesn't thread it — that still preserves
317
+ * the runId-based partitioning for named runs.
318
+ */
319
+ const runId =
320
+ batchScopeId ?? (config.configurable?.run_id as string | undefined);
180
321
  try {
181
322
  if (tool === undefined) {
182
323
  throw new Error(`Tool "${call.name}" not found.`);
183
324
  }
184
- const turn = this.toolUsageCount.get(call.name) ?? 0;
185
- this.toolUsageCount.set(call.name, turn + 1);
325
+ /**
326
+ * `usageCount` is the per-tool-name invocation index that
327
+ * web-search and other tools observe via `invokeParams.turn`.
328
+ * It is intentionally distinct from the outer `turn` parameter
329
+ * (the batch turn used for ref keys); the latter is captured
330
+ * before the try block when constructing `refKey`.
331
+ */
332
+ const usageCount = this.toolUsageCount.get(call.name) ?? 0;
333
+ this.toolUsageCount.set(call.name, usageCount + 1);
186
334
  if (call.id != null && call.id !== '') {
187
- this.toolCallTurns.set(call.id, turn);
335
+ this.toolCallTurns.set(call.id, usageCount);
336
+ }
337
+ let args = call.args;
338
+ if (registry != null) {
339
+ const { resolved, unresolved } = registry.resolve(runId, args);
340
+ args = resolved;
341
+ unresolvedRefs = unresolved;
342
+ /**
343
+ * Expose the post-substitution args to downstream completion
344
+ * events so audit logs / host-side `ON_RUN_STEP_COMPLETED`
345
+ * handlers observe what actually ran, not the `{{…}}`
346
+ * template. Only string/object args are worth recording.
347
+ */
348
+ if (
349
+ resolvedArgsByCallId != null &&
350
+ call.id != null &&
351
+ call.id !== '' &&
352
+ resolved !== call.args &&
353
+ typeof resolved === 'object'
354
+ ) {
355
+ resolvedArgsByCallId.set(
356
+ call.id,
357
+ resolved as Record<string, unknown>
358
+ );
359
+ }
188
360
  }
189
- const args = call.args;
190
361
  const stepId = this.toolCallStepIds?.get(call.id!);
191
362
 
192
363
  // Build invoke params - LangChain extracts non-schema fields to config.toolCall
364
+ // `turn` here is the per-tool usage count (matches what tools have
365
+ // observed historically via config.toolCall.turn — e.g. web search).
193
366
  let invokeParams: Record<string, unknown> = {
194
367
  ...call,
195
368
  args,
196
369
  type: 'tool_call',
197
370
  stepId,
198
- turn,
371
+ turn: usageCount,
199
372
  };
200
373
 
201
374
  // Inject runtime data for special tools (becomes available at config.toolCall)
@@ -247,24 +420,100 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
247
420
  }
248
421
 
249
422
  const output = await tool.invoke(invokeParams, config);
250
- if (
251
- (isBaseMessage(output) && output._getType() === 'tool') ||
252
- isCommand(output)
253
- ) {
423
+ if (isCommand(output)) {
254
424
  return output;
255
- } else {
256
- const rawContent =
257
- typeof output === 'string' ? output : JSON.stringify(output);
258
- return new ToolMessage({
259
- status: 'success',
260
- name: tool.name,
261
- content: truncateToolResultContent(
262
- rawContent,
263
- this.maxToolResultChars
264
- ),
265
- tool_call_id: call.id!,
266
- });
267
425
  }
426
+ if (isBaseMessage(output) && output._getType() === 'tool') {
427
+ const toolMsg = output as ToolMessage;
428
+ const isError = toolMsg.status === 'error';
429
+ if (isError) {
430
+ /**
431
+ * Error ToolMessages bypass registration but still stamp the
432
+ * unresolved-refs hint into `additional_kwargs` so the lazy
433
+ * annotation transform surfaces it to the LLM, letting the
434
+ * model self-correct when its reference key caused the
435
+ * failure. Persisted `content` stays clean.
436
+ */
437
+ if (unresolvedRefs.length > 0) {
438
+ toolMsg.additional_kwargs = {
439
+ ...toolMsg.additional_kwargs,
440
+ _unresolvedRefs: unresolvedRefs,
441
+ };
442
+ }
443
+ return toolMsg;
444
+ }
445
+ if (this.toolOutputRegistry != null || unresolvedRefs.length > 0) {
446
+ if (typeof toolMsg.content === 'string') {
447
+ const rawContent = toolMsg.content;
448
+ const llmContent = truncateToolResultContent(
449
+ rawContent,
450
+ this.maxToolResultChars
451
+ );
452
+ toolMsg.content = llmContent;
453
+ const refMeta = this.recordOutputReference(
454
+ runId,
455
+ rawContent,
456
+ refKey,
457
+ unresolvedRefs
458
+ );
459
+ if (refMeta != null) {
460
+ toolMsg.additional_kwargs = {
461
+ ...toolMsg.additional_kwargs,
462
+ ...refMeta,
463
+ };
464
+ }
465
+ } else {
466
+ /**
467
+ * Non-string content (multi-part content blocks — text +
468
+ * image). Known limitation: we cannot register under a
469
+ * reference key because there's no canonical serialized
470
+ * form. Warn once per tool per run when the caller
471
+ * intended to register. The unresolved-refs hint is still
472
+ * stamped as metadata; the lazy transform prepends a text
473
+ * block at request time so the LLM gets the self-correction
474
+ * signal.
475
+ */
476
+ if (unresolvedRefs.length > 0) {
477
+ toolMsg.additional_kwargs = {
478
+ ...toolMsg.additional_kwargs,
479
+ _unresolvedRefs: unresolvedRefs,
480
+ };
481
+ }
482
+ if (
483
+ refKey != null &&
484
+ this.toolOutputRegistry!.claimWarnOnce(runId, call.name)
485
+ ) {
486
+ // eslint-disable-next-line no-console
487
+ console.warn(
488
+ `[ToolNode] Skipping tool output reference for "${call.name}": ` +
489
+ 'ToolMessage content is not a string (further occurrences for this tool in the same run are suppressed).'
490
+ );
491
+ }
492
+ }
493
+ }
494
+ return toolMsg;
495
+ }
496
+ const rawContent =
497
+ typeof output === 'string' ? output : JSON.stringify(output);
498
+ const truncated = truncateToolResultContent(
499
+ rawContent,
500
+ this.maxToolResultChars
501
+ );
502
+ const refMeta = this.recordOutputReference(
503
+ runId,
504
+ rawContent,
505
+ refKey,
506
+ unresolvedRefs
507
+ );
508
+ return new ToolMessage({
509
+ status: 'success',
510
+ name: tool.name,
511
+ content: truncated,
512
+ tool_call_id: call.id!,
513
+ ...(refMeta != null && {
514
+ additional_kwargs: refMeta as Record<string, unknown>,
515
+ }),
516
+ });
268
517
  } catch (_e: unknown) {
269
518
  const e = _e as Error;
270
519
  if (!this.handleToolErrors) {
@@ -309,15 +558,75 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
309
558
  });
310
559
  }
311
560
  }
561
+ const errorContent = `Error: ${e.message}\n Please fix your mistakes.`;
562
+ const refMeta =
563
+ unresolvedRefs.length > 0
564
+ ? this.recordOutputReference(
565
+ runId,
566
+ errorContent,
567
+ undefined,
568
+ unresolvedRefs
569
+ )
570
+ : undefined;
312
571
  return new ToolMessage({
313
572
  status: 'error',
314
- content: `Error: ${e.message}\n Please fix your mistakes.`,
573
+ content: errorContent,
315
574
  name: call.name,
316
575
  tool_call_id: call.id ?? '',
576
+ ...(refMeta != null && {
577
+ additional_kwargs: refMeta as Record<string, unknown>,
578
+ }),
317
579
  });
318
580
  }
319
581
  }
320
582
 
583
+ /**
584
+ * Registers the full, raw output under `refKey` (when provided) and
585
+ * builds the per-message ref metadata stamped onto the resulting
586
+ * `ToolMessage.additional_kwargs`. The metadata is read at LLM-
587
+ * request time by `annotateMessagesForLLM` to produce a transient
588
+ * annotated copy of the message — the persisted `content` itself
589
+ * stays clean.
590
+ *
591
+ * @param registryContent The full, untruncated output to store in
592
+ * the registry so `{{tool<i>turn<n>}}` substitutions deliver the
593
+ * complete payload. Ignored when `refKey` is undefined.
594
+ * @param refKey Precomputed `tool<i>turn<n>` key, or undefined when
595
+ * the output is not to be registered (errors, disabled feature,
596
+ * unavailable batch/turn).
597
+ * @param unresolved Placeholder keys that did not resolve; surfaced
598
+ * to the LLM lazily so it can self-correct.
599
+ * @returns A `ToolMessageRefMetadata` object when there is anything
600
+ * to stamp, otherwise `undefined`.
601
+ */
602
+ private recordOutputReference(
603
+ runId: string | undefined,
604
+ registryContent: string,
605
+ refKey: string | undefined,
606
+ unresolved: string[]
607
+ ): t.ToolMessageRefMetadata | undefined {
608
+ if (this.toolOutputRegistry != null && refKey != null) {
609
+ this.toolOutputRegistry.set(runId, refKey, registryContent);
610
+ }
611
+ if (refKey == null && unresolved.length === 0) return undefined;
612
+ const meta: t.ToolMessageRefMetadata = {};
613
+ if (refKey != null) {
614
+ meta._refKey = refKey;
615
+ /**
616
+ * Stamp the registry scope alongside the key so the lazy
617
+ * annotation transform can look up the right bucket. Anonymous
618
+ * invocations get a synthetic per-batch scope (`\0anon-<n>`)
619
+ * that `attemptInvoke` cannot derive from
620
+ * `config.configurable.run_id` — without this, anonymous-run
621
+ * refs would silently fail registry lookup and the LLM would
622
+ * never see `[ref: …]` markers for outputs that were registered.
623
+ */
624
+ if (runId != null) meta._refScope = runId;
625
+ }
626
+ if (unresolved.length > 0) meta._unresolvedRefs = unresolved;
627
+ return meta;
628
+ }
629
+
321
630
  /**
322
631
  * Builds code session context for injection into event-driven tool calls.
323
632
  * Mirrors the session injection logic in runTool() for direct execution.
@@ -393,11 +702,16 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
393
702
  * By handling completions here in graph context (rather than in the
394
703
  * stream consumer via ToolEndHandler), the race between the stream
395
704
  * consumer and graph execution is eliminated.
705
+ *
706
+ * @param resolvedArgsByCallId Per-batch resolved-args sink populated
707
+ * by `runTool`. Threaded as a local map (instead of instance state)
708
+ * so concurrent batches cannot read each other's entries.
396
709
  */
397
710
  private handleRunToolCompletions(
398
711
  calls: ToolCall[],
399
712
  outputs: (BaseMessage | Command)[],
400
- config: RunnableConfig
713
+ config: RunnableConfig,
714
+ resolvedArgsByCallId?: ResolvedArgsByCallId
401
715
  ): void {
402
716
  for (let i = 0; i < calls.length; i++) {
403
717
  const call = calls[i];
@@ -437,11 +751,18 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
437
751
  ? toolMessage.content
438
752
  : JSON.stringify(toolMessage.content);
439
753
 
754
+ /**
755
+ * Prefer the post-substitution args when a `{{…}}` placeholder
756
+ * was resolved in `runTool`. This keeps
757
+ * `ON_RUN_STEP_COMPLETED.tool_call.args` consistent with what
758
+ * the tool actually received rather than leaking the template.
759
+ */
760
+ const effectiveArgs = resolvedArgsByCallId?.get(toolCallId) ?? call.args;
440
761
  const tool_call: t.ProcessedToolCall = {
441
762
  args:
442
- typeof call.args === 'string'
443
- ? (call.args as string)
444
- : JSON.stringify((call.args as unknown) ?? {}),
763
+ typeof effectiveArgs === 'string'
764
+ ? (effectiveArgs as string)
765
+ : JSON.stringify((effectiveArgs as unknown) ?? {}),
445
766
  name: call.name,
446
767
  id: toolCallId,
447
768
  output: contentString,
@@ -479,16 +800,64 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
479
800
  */
480
801
  private async dispatchToolEvents(
481
802
  toolCalls: ToolCall[],
482
- config: RunnableConfig
803
+ config: RunnableConfig,
804
+ batchContext: DispatchBatchContext = {}
483
805
  ): Promise<{ toolMessages: ToolMessage[]; injected: BaseMessage[] }> {
806
+ const {
807
+ batchIndices,
808
+ turn,
809
+ batchScopeId,
810
+ preResolvedArgs,
811
+ preBatchSnapshot,
812
+ } = batchContext;
484
813
  const runId = (config.configurable?.run_id as string | undefined) ?? '';
814
+ /**
815
+ * Registry-facing scope id — prefers the caller-threaded
816
+ * `batchScopeId` so anonymous batches target their own unique
817
+ * bucket and don't step on concurrent anonymous invocations.
818
+ * Hooks and event payloads keep using the empty-string coerced
819
+ * `runId` for backward compat.
820
+ */
821
+ const registryRunId =
822
+ batchScopeId ?? (config.configurable?.run_id as string | undefined);
485
823
  const threadId = config.configurable?.thread_id as string | undefined;
824
+ const registry = this.toolOutputRegistry;
825
+ const unresolvedByCallId = new Map<string, string[]>();
486
826
 
487
- const preToolCalls = toolCalls.map((call) => ({
488
- call,
489
- stepId: this.toolCallStepIds?.get(call.id!) ?? '',
490
- args: call.args as Record<string, unknown>,
491
- }));
827
+ const preToolCalls = toolCalls.map((call, i) => {
828
+ const originalArgs = call.args as Record<string, unknown>;
829
+ let resolvedArgs = originalArgs;
830
+ /**
831
+ * When the caller provided a pre-resolved map (the mixed
832
+ * direct+event path snapshots event args synchronously before
833
+ * awaiting directs so they can't accidentally resolve
834
+ * same-turn direct outputs), use those entries verbatim instead
835
+ * of re-resolving against a registry that may have changed
836
+ * since the batch started.
837
+ */
838
+ const pre = call.id != null ? preResolvedArgs?.get(call.id) : undefined;
839
+ if (pre != null) {
840
+ resolvedArgs = pre.resolved;
841
+ if (pre.unresolved.length > 0 && call.id != null) {
842
+ unresolvedByCallId.set(call.id, pre.unresolved);
843
+ }
844
+ } else if (registry != null) {
845
+ const { resolved, unresolved } = registry.resolve(
846
+ registryRunId,
847
+ originalArgs
848
+ );
849
+ resolvedArgs = resolved as Record<string, unknown>;
850
+ if (unresolved.length > 0 && call.id != null) {
851
+ unresolvedByCallId.set(call.id, unresolved);
852
+ }
853
+ }
854
+ return {
855
+ call,
856
+ stepId: this.toolCallStepIds?.get(call.id!) ?? '',
857
+ args: resolvedArgs,
858
+ batchIndex: batchIndices?.[i],
859
+ };
860
+ });
492
861
 
493
862
  const messageByCallId = new Map<string, ToolMessage>();
494
863
  const approvedEntries: typeof preToolCalls = [];
@@ -565,7 +934,40 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
565
934
  continue;
566
935
  }
567
936
  if (hookResult.updatedInput != null) {
568
- entry.args = hookResult.updatedInput;
937
+ /**
938
+ * Re-resolve after PreToolUse replaces the input: a hook may
939
+ * introduce new `{{tool<i>turn<n>}}` placeholders (e.g., by
940
+ * copying user-supplied text) that the pre-hook pass never
941
+ * saw. Re-running the resolver on the hook-rewritten args
942
+ * keeps substitution and the unresolved-refs record in sync
943
+ * with what the tool will actually receive.
944
+ */
945
+ if (registry != null) {
946
+ /**
947
+ * Mixed direct+event batches must use the pre-batch
948
+ * snapshot so a hook-introduced placeholder cannot
949
+ * accidentally resolve to a same-turn direct output that
950
+ * has just registered. Pure event batches don't have a
951
+ * snapshot and resolve against the live registry — safe
952
+ * because no event-side registrations have happened yet.
953
+ */
954
+ const view: ToolOutputResolveView = preBatchSnapshot ?? {
955
+ resolve: <T>(args: T) => registry.resolve(registryRunId, args),
956
+ };
957
+ const { resolved, unresolved } = view.resolve(
958
+ hookResult.updatedInput
959
+ );
960
+ entry.args = resolved as Record<string, unknown>;
961
+ if (entry.call.id != null) {
962
+ if (unresolved.length > 0) {
963
+ unresolvedByCallId.set(entry.call.id, unresolved);
964
+ } else {
965
+ unresolvedByCallId.delete(entry.call.id);
966
+ }
967
+ }
968
+ } else {
969
+ entry.args = hookResult.updatedInput;
970
+ }
569
971
  }
570
972
  approvedEntries.push(entry);
571
973
  }
@@ -575,11 +977,17 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
575
977
 
576
978
  const injected: BaseMessage[] = [];
577
979
 
980
+ const batchIndexByCallId = new Map<string, number>();
981
+
578
982
  if (approvedEntries.length > 0) {
579
983
  const requests: t.ToolCallRequest[] = approvedEntries.map((entry) => {
580
984
  const turn = this.toolUsageCount.get(entry.call.name) ?? 0;
581
985
  this.toolUsageCount.set(entry.call.name, turn + 1);
582
986
 
987
+ if (entry.batchIndex != null && entry.call.id != null) {
988
+ batchIndexByCallId.set(entry.call.id, entry.batchIndex);
989
+ }
990
+
583
991
  const request: t.ToolCallRequest = {
584
992
  id: entry.call.id!,
585
993
  name: entry.call.name,
@@ -651,11 +1059,31 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
651
1059
 
652
1060
  if (result.status === 'error') {
653
1061
  contentString = `Error: ${result.errorMessage ?? 'Unknown error'}\n Please fix your mistakes.`;
1062
+ /**
1063
+ * Error results bypass registration but stamp the
1064
+ * unresolved-refs hint into `additional_kwargs` so the lazy
1065
+ * annotation transform surfaces it to the LLM at request
1066
+ * time, letting the model self-correct when its reference
1067
+ * key caused the failure. Persisted `content` stays clean.
1068
+ */
1069
+ const unresolved = unresolvedByCallId.get(result.toolCallId) ?? [];
1070
+ const errorRefMeta =
1071
+ unresolved.length > 0
1072
+ ? this.recordOutputReference(
1073
+ registryRunId,
1074
+ contentString,
1075
+ undefined,
1076
+ unresolved
1077
+ )
1078
+ : undefined;
654
1079
  toolMessage = new ToolMessage({
655
1080
  status: 'error',
656
1081
  content: contentString,
657
1082
  name: toolName,
658
1083
  tool_call_id: result.toolCallId,
1084
+ ...(errorRefMeta != null && {
1085
+ additional_kwargs: errorRefMeta as Record<string, unknown>,
1086
+ }),
659
1087
  });
660
1088
 
661
1089
  if (hasFailureHook) {
@@ -680,12 +1108,12 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
680
1108
  });
681
1109
  }
682
1110
  } else {
683
- const rawContent =
1111
+ let registryRaw =
684
1112
  typeof result.content === 'string'
685
1113
  ? result.content
686
1114
  : JSON.stringify(result.content);
687
1115
  contentString = truncateToolResultContent(
688
- rawContent,
1116
+ registryRaw,
689
1117
  this.maxToolResultChars
690
1118
  );
691
1119
 
@@ -712,6 +1140,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
712
1140
  typeof hookResult.updatedOutput === 'string'
713
1141
  ? hookResult.updatedOutput
714
1142
  : JSON.stringify(hookResult.updatedOutput);
1143
+ registryRaw = replaced;
715
1144
  contentString = truncateToolResultContent(
716
1145
  replaced,
717
1146
  this.maxToolResultChars
@@ -719,12 +1148,30 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
719
1148
  }
720
1149
  }
721
1150
 
1151
+ const batchIndex = batchIndexByCallId.get(result.toolCallId);
1152
+ const unresolved = unresolvedByCallId.get(result.toolCallId) ?? [];
1153
+ const refKey =
1154
+ this.toolOutputRegistry != null &&
1155
+ batchIndex != null &&
1156
+ turn != null
1157
+ ? buildReferenceKey(batchIndex, turn)
1158
+ : undefined;
1159
+ const successRefMeta = this.recordOutputReference(
1160
+ registryRunId,
1161
+ registryRaw,
1162
+ refKey,
1163
+ unresolved
1164
+ );
1165
+
722
1166
  toolMessage = new ToolMessage({
723
1167
  status: 'success',
724
1168
  name: toolName,
725
1169
  content: contentString,
726
1170
  artifact: result.artifact,
727
1171
  tool_call_id: result.toolCallId,
1172
+ ...(successRefMeta != null && {
1173
+ additional_kwargs: successRefMeta as Record<string, unknown>,
1174
+ }),
728
1175
  });
729
1176
  }
730
1177
 
@@ -815,16 +1262,23 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
815
1262
  * Injected messages are placed AFTER ToolMessages to respect provider
816
1263
  * message ordering (AIMessage tool_calls must be immediately followed
817
1264
  * by their ToolMessage results).
1265
+ *
1266
+ * `batchIndices` mirrors `toolCalls` and carries each call's position
1267
+ * within the parent batch. `turn` is the per-`run()` batch index
1268
+ * captured locally by the caller. Both are threaded so concurrent
1269
+ * invocations cannot race on shared mutable state.
818
1270
  */
819
1271
  private async executeViaEvent(
820
1272
  toolCalls: ToolCall[],
821
1273
  config: RunnableConfig,
822
1274
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
823
- input: any
1275
+ input: any,
1276
+ batchContext: DispatchBatchContext = {}
824
1277
  ): Promise<T> {
825
1278
  const { toolMessages, injected } = await this.dispatchToolEvents(
826
1279
  toolCalls,
827
- config
1280
+ config,
1281
+ batchContext
828
1282
  );
829
1283
  const outputs: BaseMessage[] = [...toolMessages, ...injected];
830
1284
  return (Array.isArray(input) ? outputs : { messages: outputs }) as T;
@@ -833,15 +1287,51 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
833
1287
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
834
1288
  protected async run(input: any, config: RunnableConfig): Promise<T> {
835
1289
  this.toolCallTurns.clear();
1290
+ /**
1291
+ * Per-batch local map for resolved (post-substitution) args.
1292
+ * Lives on the stack so concurrent `run()` calls on the same
1293
+ * ToolNode cannot read or wipe each other's entries.
1294
+ */
1295
+ const resolvedArgsByCallId = new Map<string, Record<string, unknown>>();
1296
+ /**
1297
+ * Claim this batch's turn synchronously from the registry (or
1298
+ * fall back to 0 when the feature is disabled). The registry is
1299
+ * partitioned by scope id so overlapping batches cannot
1300
+ * overwrite each other's state even under a shared registry.
1301
+ *
1302
+ * For anonymous callers (no `run_id` in config), mint a unique
1303
+ * per-batch scope id so two concurrent anonymous invocations
1304
+ * don't target the same bucket. The scope is threaded down to
1305
+ * every subsequent registry call on this batch.
1306
+ */
1307
+ const incomingRunId = config.configurable?.run_id as string | undefined;
1308
+ const batchScopeId = incomingRunId ?? `\0anon-${this.anonBatchCounter++}`;
1309
+ const turn = this.toolOutputRegistry?.nextTurn(batchScopeId) ?? 0;
836
1310
  let outputs: (BaseMessage | Command)[];
837
1311
 
838
1312
  if (this.isSendInput(input)) {
839
1313
  const isDirectTool = this.directToolNames?.has(input.lg_tool_call.name);
840
1314
  if (this.eventDrivenMode && isDirectTool !== true) {
841
- return this.executeViaEvent([input.lg_tool_call], config, input);
1315
+ return this.executeViaEvent([input.lg_tool_call], config, input, {
1316
+ batchIndices: [0],
1317
+ turn,
1318
+ batchScopeId,
1319
+ });
842
1320
  }
843
- outputs = [await this.runTool(input.lg_tool_call, config)];
844
- this.handleRunToolCompletions([input.lg_tool_call], outputs, config);
1321
+ outputs = [
1322
+ await this.runTool(input.lg_tool_call, config, {
1323
+ batchIndex: 0,
1324
+ turn,
1325
+ batchScopeId,
1326
+ resolvedArgsByCallId,
1327
+ }),
1328
+ ];
1329
+ this.handleRunToolCompletions(
1330
+ [input.lg_tool_call],
1331
+ outputs,
1332
+ config,
1333
+ resolvedArgsByCallId
1334
+ );
845
1335
  } else {
846
1336
  let messages: BaseMessage[];
847
1337
  if (Array.isArray(input)) {
@@ -900,31 +1390,105 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
900
1390
  }) ?? [];
901
1391
 
902
1392
  if (this.eventDrivenMode && filteredCalls.length > 0) {
1393
+ const filteredIndices = filteredCalls.map((_, idx) => idx);
1394
+
903
1395
  if (!this.directToolNames || this.directToolNames.size === 0) {
904
- return this.executeViaEvent(filteredCalls, config, input);
1396
+ return this.executeViaEvent(filteredCalls, config, input, {
1397
+ batchIndices: filteredIndices,
1398
+ turn,
1399
+ batchScopeId,
1400
+ });
905
1401
  }
906
1402
 
907
- const directCalls = filteredCalls.filter((c) =>
908
- this.directToolNames!.has(c.name)
909
- );
910
- const eventCalls = filteredCalls.filter(
911
- (c) => !this.directToolNames!.has(c.name)
912
- );
1403
+ const directEntries: Array<{ call: ToolCall; batchIndex: number }> = [];
1404
+ const eventEntries: Array<{ call: ToolCall; batchIndex: number }> = [];
1405
+ for (let i = 0; i < filteredCalls.length; i++) {
1406
+ const call = filteredCalls[i];
1407
+ const entry = { call, batchIndex: i };
1408
+ if (this.directToolNames!.has(call.name)) {
1409
+ directEntries.push(entry);
1410
+ } else {
1411
+ eventEntries.push(entry);
1412
+ }
1413
+ }
1414
+
1415
+ const directCalls = directEntries.map((e) => e.call);
1416
+ const directIndices = directEntries.map((e) => e.batchIndex);
1417
+ const eventCalls = eventEntries.map((e) => e.call);
1418
+ const eventIndices = eventEntries.map((e) => e.batchIndex);
1419
+
1420
+ /**
1421
+ * Snapshot the event calls' args against the *pre-batch*
1422
+ * registry state synchronously, before any await runs. The
1423
+ * directs are then awaited first (preserving fail-fast
1424
+ * semantics — a thrown error in a direct tool, e.g. with
1425
+ * `handleToolErrors=false` or a `GraphInterrupt`, aborts
1426
+ * before we dispatch any event-driven tools to the host).
1427
+ * Because the event args were captured pre-await, they stay
1428
+ * isolated from same-turn direct outputs that register
1429
+ * during the await.
1430
+ */
1431
+ const preResolvedEventArgs = new Map<
1432
+ string,
1433
+ { resolved: Record<string, unknown>; unresolved: string[] }
1434
+ >();
1435
+ /**
1436
+ * Take a frozen snapshot of the registry state before any
1437
+ * direct registrations land. The snapshot resolves
1438
+ * placeholders against this point-in-time view, so a
1439
+ * `PreToolUse` hook later rewriting event args via
1440
+ * `updatedInput` can introduce placeholders that resolve
1441
+ * cross-batch (against prior runs) without ever picking up
1442
+ * same-turn direct outputs.
1443
+ */
1444
+ const preBatchSnapshot =
1445
+ this.toolOutputRegistry?.snapshot(batchScopeId);
1446
+ if (preBatchSnapshot != null) {
1447
+ for (const entry of eventEntries) {
1448
+ if (entry.call.id != null) {
1449
+ const { resolved, unresolved } = preBatchSnapshot.resolve(
1450
+ entry.call.args as Record<string, unknown>
1451
+ );
1452
+ preResolvedEventArgs.set(entry.call.id, {
1453
+ resolved: resolved as Record<string, unknown>,
1454
+ unresolved,
1455
+ });
1456
+ }
1457
+ }
1458
+ }
913
1459
 
914
1460
  const directOutputs: (BaseMessage | Command)[] =
915
1461
  directCalls.length > 0
916
1462
  ? await Promise.all(
917
- directCalls.map((call) => this.runTool(call, config))
1463
+ directCalls.map((call, i) =>
1464
+ this.runTool(call, config, {
1465
+ batchIndex: directIndices[i],
1466
+ turn,
1467
+ batchScopeId,
1468
+ resolvedArgsByCallId,
1469
+ })
1470
+ )
918
1471
  )
919
1472
  : [];
920
1473
 
921
1474
  if (directCalls.length > 0 && directOutputs.length > 0) {
922
- this.handleRunToolCompletions(directCalls, directOutputs, config);
1475
+ this.handleRunToolCompletions(
1476
+ directCalls,
1477
+ directOutputs,
1478
+ config,
1479
+ resolvedArgsByCallId
1480
+ );
923
1481
  }
924
1482
 
925
1483
  const eventResult =
926
1484
  eventCalls.length > 0
927
- ? await this.dispatchToolEvents(eventCalls, config)
1485
+ ? await this.dispatchToolEvents(eventCalls, config, {
1486
+ batchIndices: eventIndices,
1487
+ turn,
1488
+ batchScopeId,
1489
+ preResolvedArgs: preResolvedEventArgs,
1490
+ preBatchSnapshot,
1491
+ })
928
1492
  : {
929
1493
  toolMessages: [] as ToolMessage[],
930
1494
  injected: [] as BaseMessage[],
@@ -937,9 +1501,21 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
937
1501
  ];
938
1502
  } else {
939
1503
  outputs = await Promise.all(
940
- filteredCalls.map((call) => this.runTool(call, config))
1504
+ filteredCalls.map((call, i) =>
1505
+ this.runTool(call, config, {
1506
+ batchIndex: i,
1507
+ turn,
1508
+ batchScopeId,
1509
+ resolvedArgsByCallId,
1510
+ })
1511
+ )
1512
+ );
1513
+ this.handleRunToolCompletions(
1514
+ filteredCalls,
1515
+ outputs,
1516
+ config,
1517
+ resolvedArgsByCallId
941
1518
  );
942
- this.handleRunToolCompletions(filteredCalls, outputs, config);
943
1519
  }
944
1520
  }
945
1521