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