@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
@@ -12,6 +12,7 @@ var run = require('../utils/run.cjs');
12
12
  require('ai-tokenizer');
13
13
  require('zod-to-json-schema');
14
14
  var executeHooks = require('../hooks/executeHooks.cjs');
15
+ var toolOutputReferences = require('./toolOutputReferences.cjs');
15
16
 
16
17
  /**
17
18
  * Helper to check if a value is a Send object
@@ -72,7 +73,27 @@ class ToolNode extends run.RunnableCallable {
72
73
  maxToolResultChars;
73
74
  /** Hook registry for PreToolUse/PostToolUse lifecycle hooks */
74
75
  hookRegistry;
75
- constructor({ tools, toolMap, name, tags, errorHandler, toolCallStepIds, handleToolErrors, loadRuntimeTools, toolRegistry, sessions, eventDrivenMode, agentId, directToolNames, maxContextTokens, maxToolResultChars, hookRegistry, }) {
76
+ /**
77
+ * Registry of tool outputs keyed by `tool<idx>turn<turn>`.
78
+ *
79
+ * Populated only when `toolOutputReferences.enabled` is true. The
80
+ * registry owns the run-scoped state (turn counter, last-seen runId,
81
+ * warn-once memo, stored outputs), so sharing a single instance
82
+ * across multiple ToolNodes in a run lets cross-agent `{{…}}`
83
+ * references resolve — which is why multi-agent graphs pass the
84
+ * *same* instance to every ToolNode they compile rather than each
85
+ * ToolNode building its own.
86
+ */
87
+ toolOutputRegistry;
88
+ /**
89
+ * Monotonic counter used to mint a unique scope id for anonymous
90
+ * batches (ones invoked without a `run_id` in
91
+ * `config.configurable`). Each such batch gets its own registry
92
+ * partition so concurrent anonymous invocations can't delete each
93
+ * other's in-flight state.
94
+ */
95
+ anonBatchCounter = 0;
96
+ constructor({ tools, toolMap, name, tags, errorHandler, toolCallStepIds, handleToolErrors, loadRuntimeTools, toolRegistry, sessions, eventDrivenMode, agentId, directToolNames, maxContextTokens, maxToolResultChars, hookRegistry, toolOutputReferences: toolOutputReferences$1, toolOutputRegistry, }) {
76
97
  super({ name, tags, func: (input, config) => this.run(input, config) });
77
98
  this.toolMap = toolMap ?? new Map(tools.map((tool) => [tool.name, tool]));
78
99
  this.toolCallStepIds = toolCallStepIds;
@@ -88,6 +109,37 @@ class ToolNode extends run.RunnableCallable {
88
109
  this.maxToolResultChars =
89
110
  maxToolResultChars ?? truncation.calculateMaxToolResultChars(maxContextTokens);
90
111
  this.hookRegistry = hookRegistry;
112
+ /**
113
+ * Precedence: an explicitly passed `toolOutputRegistry` instance
114
+ * wins over a config object so a host (`Graph`) can share one
115
+ * registry across many ToolNodes. When only the config is
116
+ * provided (direct ToolNode usage), build a local registry so
117
+ * the feature still works without graph-level plumbing. Registry
118
+ * caps are intentionally decoupled from `maxToolResultChars`:
119
+ * the registry stores the raw untruncated output so a later
120
+ * `{{…}}` substitution pipes the full payload into the next
121
+ * tool, even when the LLM saw a truncated preview.
122
+ */
123
+ if (toolOutputRegistry != null) {
124
+ this.toolOutputRegistry = toolOutputRegistry;
125
+ }
126
+ else if (toolOutputReferences$1?.enabled === true) {
127
+ this.toolOutputRegistry = new toolOutputReferences.ToolOutputReferenceRegistry({
128
+ maxOutputSize: toolOutputReferences$1.maxOutputSize,
129
+ maxTotalSize: toolOutputReferences$1.maxTotalSize,
130
+ });
131
+ }
132
+ }
133
+ /**
134
+ * Returns the run-scoped tool output registry, or `undefined` when
135
+ * the feature is disabled.
136
+ *
137
+ * @internal Exposed for test observation only. Host code should rely
138
+ * on `{{tool<i>turn<n>}}` substitution at tool-invocation time and
139
+ * not mutate the registry directly.
140
+ */
141
+ _unsafeGetToolOutputRegistry() {
142
+ return this.toolOutputRegistry;
91
143
  }
92
144
  /**
93
145
  * Returns cached programmatic tools, computing once on first access.
@@ -119,28 +171,85 @@ class ToolNode extends run.RunnableCallable {
119
171
  return new Map(this.toolUsageCount); // Return a copy
120
172
  }
121
173
  /**
122
- * Runs a single tool call with error handling
174
+ * Runs a single tool call with error handling.
175
+ *
176
+ * `batchIndex` is the tool's position within the current ToolNode
177
+ * batch and, together with `this.currentTurn`, forms the key used to
178
+ * register the output for future `{{tool<idx>turn<turn>}}`
179
+ * substitutions. Omit when no registration should occur.
123
180
  */
124
- async runTool(call, config) {
181
+ async runTool(call, config, batchContext = {}) {
182
+ const { batchIndex, turn, batchScopeId, resolvedArgsByCallId } = batchContext;
125
183
  const tool = this.toolMap.get(call.name);
184
+ const registry = this.toolOutputRegistry;
185
+ /**
186
+ * Precompute the reference key once per call — captured locally
187
+ * so concurrent `invoke()` calls on the same ToolNode cannot race
188
+ * on a shared turn field.
189
+ */
190
+ const refKey = registry != null && batchIndex != null && turn != null
191
+ ? toolOutputReferences.buildReferenceKey(batchIndex, turn)
192
+ : undefined;
193
+ /**
194
+ * Hoisted outside the try so the catch branch can append
195
+ * `[unresolved refs: …]` to error messages — otherwise the LLM
196
+ * only sees a generic error when it references a bad key, losing
197
+ * the self-correction signal this feature is meant to provide.
198
+ */
199
+ let unresolvedRefs = [];
200
+ /**
201
+ * Use the caller-provided `batchScopeId` when threaded from
202
+ * `run()` (so anonymous batches get their own unique scope).
203
+ * Fall back to the config's `run_id` when runTool is invoked
204
+ * from a context that doesn't thread it — that still preserves
205
+ * the runId-based partitioning for named runs.
206
+ */
207
+ const runId = batchScopeId ?? config.configurable?.run_id;
126
208
  try {
127
209
  if (tool === undefined) {
128
210
  throw new Error(`Tool "${call.name}" not found.`);
129
211
  }
130
- const turn = this.toolUsageCount.get(call.name) ?? 0;
131
- this.toolUsageCount.set(call.name, turn + 1);
212
+ /**
213
+ * `usageCount` is the per-tool-name invocation index that
214
+ * web-search and other tools observe via `invokeParams.turn`.
215
+ * It is intentionally distinct from the outer `turn` parameter
216
+ * (the batch turn used for ref keys); the latter is captured
217
+ * before the try block when constructing `refKey`.
218
+ */
219
+ const usageCount = this.toolUsageCount.get(call.name) ?? 0;
220
+ this.toolUsageCount.set(call.name, usageCount + 1);
132
221
  if (call.id != null && call.id !== '') {
133
- this.toolCallTurns.set(call.id, turn);
222
+ this.toolCallTurns.set(call.id, usageCount);
223
+ }
224
+ let args = call.args;
225
+ if (registry != null) {
226
+ const { resolved, unresolved } = registry.resolve(runId, args);
227
+ args = resolved;
228
+ unresolvedRefs = unresolved;
229
+ /**
230
+ * Expose the post-substitution args to downstream completion
231
+ * events so audit logs / host-side `ON_RUN_STEP_COMPLETED`
232
+ * handlers observe what actually ran, not the `{{…}}`
233
+ * template. Only string/object args are worth recording.
234
+ */
235
+ if (resolvedArgsByCallId != null &&
236
+ call.id != null &&
237
+ call.id !== '' &&
238
+ resolved !== call.args &&
239
+ typeof resolved === 'object') {
240
+ resolvedArgsByCallId.set(call.id, resolved);
241
+ }
134
242
  }
135
- const args = call.args;
136
243
  const stepId = this.toolCallStepIds?.get(call.id);
137
244
  // Build invoke params - LangChain extracts non-schema fields to config.toolCall
245
+ // `turn` here is the per-tool usage count (matches what tools have
246
+ // observed historically via config.toolCall.turn — e.g. web search).
138
247
  let invokeParams = {
139
248
  ...call,
140
249
  args,
141
250
  type: 'tool_call',
142
251
  stepId,
143
- turn,
252
+ turn: usageCount,
144
253
  };
145
254
  // Inject runtime data for special tools (becomes available at config.toolCall)
146
255
  if (call.name === _enum.Constants.PROGRAMMATIC_TOOL_CALLING ||
@@ -185,19 +294,80 @@ class ToolNode extends run.RunnableCallable {
185
294
  }
186
295
  }
187
296
  const output = await tool.invoke(invokeParams, config);
188
- if ((messages.isBaseMessage(output) && output._getType() === 'tool') ||
189
- langgraph.isCommand(output)) {
297
+ if (langgraph.isCommand(output)) {
190
298
  return output;
191
299
  }
192
- else {
193
- const rawContent = typeof output === 'string' ? output : JSON.stringify(output);
194
- return new messages.ToolMessage({
195
- status: 'success',
196
- name: tool.name,
197
- content: truncation.truncateToolResultContent(rawContent, this.maxToolResultChars),
198
- tool_call_id: call.id,
199
- });
300
+ if (messages.isBaseMessage(output) && output._getType() === 'tool') {
301
+ const toolMsg = output;
302
+ const isError = toolMsg.status === 'error';
303
+ if (isError) {
304
+ /**
305
+ * Error ToolMessages bypass registration but still stamp the
306
+ * unresolved-refs hint into `additional_kwargs` so the lazy
307
+ * annotation transform surfaces it to the LLM, letting the
308
+ * model self-correct when its reference key caused the
309
+ * failure. Persisted `content` stays clean.
310
+ */
311
+ if (unresolvedRefs.length > 0) {
312
+ toolMsg.additional_kwargs = {
313
+ ...toolMsg.additional_kwargs,
314
+ _unresolvedRefs: unresolvedRefs,
315
+ };
316
+ }
317
+ return toolMsg;
318
+ }
319
+ if (this.toolOutputRegistry != null || unresolvedRefs.length > 0) {
320
+ if (typeof toolMsg.content === 'string') {
321
+ const rawContent = toolMsg.content;
322
+ const llmContent = truncation.truncateToolResultContent(rawContent, this.maxToolResultChars);
323
+ toolMsg.content = llmContent;
324
+ const refMeta = this.recordOutputReference(runId, rawContent, refKey, unresolvedRefs);
325
+ if (refMeta != null) {
326
+ toolMsg.additional_kwargs = {
327
+ ...toolMsg.additional_kwargs,
328
+ ...refMeta,
329
+ };
330
+ }
331
+ }
332
+ else {
333
+ /**
334
+ * Non-string content (multi-part content blocks — text +
335
+ * image). Known limitation: we cannot register under a
336
+ * reference key because there's no canonical serialized
337
+ * form. Warn once per tool per run when the caller
338
+ * intended to register. The unresolved-refs hint is still
339
+ * stamped as metadata; the lazy transform prepends a text
340
+ * block at request time so the LLM gets the self-correction
341
+ * signal.
342
+ */
343
+ if (unresolvedRefs.length > 0) {
344
+ toolMsg.additional_kwargs = {
345
+ ...toolMsg.additional_kwargs,
346
+ _unresolvedRefs: unresolvedRefs,
347
+ };
348
+ }
349
+ if (refKey != null &&
350
+ this.toolOutputRegistry.claimWarnOnce(runId, call.name)) {
351
+ // eslint-disable-next-line no-console
352
+ console.warn(`[ToolNode] Skipping tool output reference for "${call.name}": ` +
353
+ 'ToolMessage content is not a string (further occurrences for this tool in the same run are suppressed).');
354
+ }
355
+ }
356
+ }
357
+ return toolMsg;
200
358
  }
359
+ const rawContent = typeof output === 'string' ? output : JSON.stringify(output);
360
+ const truncated = truncation.truncateToolResultContent(rawContent, this.maxToolResultChars);
361
+ const refMeta = this.recordOutputReference(runId, rawContent, refKey, unresolvedRefs);
362
+ return new messages.ToolMessage({
363
+ status: 'success',
364
+ name: tool.name,
365
+ content: truncated,
366
+ tool_call_id: call.id,
367
+ ...(refMeta != null && {
368
+ additional_kwargs: refMeta,
369
+ }),
370
+ });
201
371
  }
202
372
  catch (_e) {
203
373
  const e = _e;
@@ -240,14 +410,65 @@ class ToolNode extends run.RunnableCallable {
240
410
  });
241
411
  }
242
412
  }
413
+ const errorContent = `Error: ${e.message}\n Please fix your mistakes.`;
414
+ const refMeta = unresolvedRefs.length > 0
415
+ ? this.recordOutputReference(runId, errorContent, undefined, unresolvedRefs)
416
+ : undefined;
243
417
  return new messages.ToolMessage({
244
418
  status: 'error',
245
- content: `Error: ${e.message}\n Please fix your mistakes.`,
419
+ content: errorContent,
246
420
  name: call.name,
247
421
  tool_call_id: call.id ?? '',
422
+ ...(refMeta != null && {
423
+ additional_kwargs: refMeta,
424
+ }),
248
425
  });
249
426
  }
250
427
  }
428
+ /**
429
+ * Registers the full, raw output under `refKey` (when provided) and
430
+ * builds the per-message ref metadata stamped onto the resulting
431
+ * `ToolMessage.additional_kwargs`. The metadata is read at LLM-
432
+ * request time by `annotateMessagesForLLM` to produce a transient
433
+ * annotated copy of the message — the persisted `content` itself
434
+ * stays clean.
435
+ *
436
+ * @param registryContent The full, untruncated output to store in
437
+ * the registry so `{{tool<i>turn<n>}}` substitutions deliver the
438
+ * complete payload. Ignored when `refKey` is undefined.
439
+ * @param refKey Precomputed `tool<i>turn<n>` key, or undefined when
440
+ * the output is not to be registered (errors, disabled feature,
441
+ * unavailable batch/turn).
442
+ * @param unresolved Placeholder keys that did not resolve; surfaced
443
+ * to the LLM lazily so it can self-correct.
444
+ * @returns A `ToolMessageRefMetadata` object when there is anything
445
+ * to stamp, otherwise `undefined`.
446
+ */
447
+ recordOutputReference(runId, registryContent, refKey, unresolved) {
448
+ if (this.toolOutputRegistry != null && refKey != null) {
449
+ this.toolOutputRegistry.set(runId, refKey, registryContent);
450
+ }
451
+ if (refKey == null && unresolved.length === 0)
452
+ return undefined;
453
+ const meta = {};
454
+ if (refKey != null) {
455
+ meta._refKey = refKey;
456
+ /**
457
+ * Stamp the registry scope alongside the key so the lazy
458
+ * annotation transform can look up the right bucket. Anonymous
459
+ * invocations get a synthetic per-batch scope (`\0anon-<n>`)
460
+ * that `attemptInvoke` cannot derive from
461
+ * `config.configurable.run_id` — without this, anonymous-run
462
+ * refs would silently fail registry lookup and the LLM would
463
+ * never see `[ref: …]` markers for outputs that were registered.
464
+ */
465
+ if (runId != null)
466
+ meta._refScope = runId;
467
+ }
468
+ if (unresolved.length > 0)
469
+ meta._unresolvedRefs = unresolved;
470
+ return meta;
471
+ }
251
472
  /**
252
473
  * Builds code session context for injection into event-driven tool calls.
253
474
  * Mirrors the session injection logic in runTool() for direct execution.
@@ -306,8 +527,12 @@ class ToolNode extends run.RunnableCallable {
306
527
  * By handling completions here in graph context (rather than in the
307
528
  * stream consumer via ToolEndHandler), the race between the stream
308
529
  * consumer and graph execution is eliminated.
530
+ *
531
+ * @param resolvedArgsByCallId Per-batch resolved-args sink populated
532
+ * by `runTool`. Threaded as a local map (instead of instance state)
533
+ * so concurrent batches cannot read each other's entries.
309
534
  */
310
- handleRunToolCompletions(calls, outputs, config) {
535
+ handleRunToolCompletions(calls, outputs, config, resolvedArgsByCallId) {
311
536
  for (let i = 0; i < calls.length; i++) {
312
537
  const call = calls[i];
313
538
  const output = outputs[i];
@@ -336,10 +561,17 @@ class ToolNode extends run.RunnableCallable {
336
561
  const contentString = typeof toolMessage.content === 'string'
337
562
  ? toolMessage.content
338
563
  : JSON.stringify(toolMessage.content);
564
+ /**
565
+ * Prefer the post-substitution args when a `{{…}}` placeholder
566
+ * was resolved in `runTool`. This keeps
567
+ * `ON_RUN_STEP_COMPLETED.tool_call.args` consistent with what
568
+ * the tool actually received rather than leaking the template.
569
+ */
570
+ const effectiveArgs = resolvedArgsByCallId?.get(toolCallId) ?? call.args;
339
571
  const tool_call = {
340
- args: typeof call.args === 'string'
341
- ? call.args
342
- : JSON.stringify(call.args ?? {}),
572
+ args: typeof effectiveArgs === 'string'
573
+ ? effectiveArgs
574
+ : JSON.stringify(effectiveArgs ?? {}),
343
575
  name: call.name,
344
576
  id: toolCallId,
345
577
  output: contentString,
@@ -369,14 +601,52 @@ class ToolNode extends run.RunnableCallable {
369
601
  * 4. Injected messages from results are collected and returned alongside
370
602
  * ToolMessages (appended AFTER to respect provider ordering).
371
603
  */
372
- async dispatchToolEvents(toolCalls, config) {
604
+ async dispatchToolEvents(toolCalls, config, batchContext = {}) {
605
+ const { batchIndices, turn, batchScopeId, preResolvedArgs, preBatchSnapshot, } = batchContext;
373
606
  const runId = config.configurable?.run_id ?? '';
607
+ /**
608
+ * Registry-facing scope id — prefers the caller-threaded
609
+ * `batchScopeId` so anonymous batches target their own unique
610
+ * bucket and don't step on concurrent anonymous invocations.
611
+ * Hooks and event payloads keep using the empty-string coerced
612
+ * `runId` for backward compat.
613
+ */
614
+ const registryRunId = batchScopeId ?? config.configurable?.run_id;
374
615
  const threadId = config.configurable?.thread_id;
375
- const preToolCalls = toolCalls.map((call) => ({
376
- call,
377
- stepId: this.toolCallStepIds?.get(call.id) ?? '',
378
- args: call.args,
379
- }));
616
+ const registry = this.toolOutputRegistry;
617
+ const unresolvedByCallId = new Map();
618
+ const preToolCalls = toolCalls.map((call, i) => {
619
+ const originalArgs = call.args;
620
+ let resolvedArgs = originalArgs;
621
+ /**
622
+ * When the caller provided a pre-resolved map (the mixed
623
+ * direct+event path snapshots event args synchronously before
624
+ * awaiting directs so they can't accidentally resolve
625
+ * same-turn direct outputs), use those entries verbatim instead
626
+ * of re-resolving against a registry that may have changed
627
+ * since the batch started.
628
+ */
629
+ const pre = call.id != null ? preResolvedArgs?.get(call.id) : undefined;
630
+ if (pre != null) {
631
+ resolvedArgs = pre.resolved;
632
+ if (pre.unresolved.length > 0 && call.id != null) {
633
+ unresolvedByCallId.set(call.id, pre.unresolved);
634
+ }
635
+ }
636
+ else if (registry != null) {
637
+ const { resolved, unresolved } = registry.resolve(registryRunId, originalArgs);
638
+ resolvedArgs = resolved;
639
+ if (unresolved.length > 0 && call.id != null) {
640
+ unresolvedByCallId.set(call.id, unresolved);
641
+ }
642
+ }
643
+ return {
644
+ call,
645
+ stepId: this.toolCallStepIds?.get(call.id) ?? '',
646
+ args: resolvedArgs,
647
+ batchIndex: batchIndices?.[i],
648
+ };
649
+ });
380
650
  const messageByCallId = new Map();
381
651
  const approvedEntries = [];
382
652
  const HOOK_FALLBACK = Object.freeze({
@@ -436,7 +706,40 @@ class ToolNode extends run.RunnableCallable {
436
706
  continue;
437
707
  }
438
708
  if (hookResult.updatedInput != null) {
439
- entry.args = hookResult.updatedInput;
709
+ /**
710
+ * Re-resolve after PreToolUse replaces the input: a hook may
711
+ * introduce new `{{tool<i>turn<n>}}` placeholders (e.g., by
712
+ * copying user-supplied text) that the pre-hook pass never
713
+ * saw. Re-running the resolver on the hook-rewritten args
714
+ * keeps substitution and the unresolved-refs record in sync
715
+ * with what the tool will actually receive.
716
+ */
717
+ if (registry != null) {
718
+ /**
719
+ * Mixed direct+event batches must use the pre-batch
720
+ * snapshot so a hook-introduced placeholder cannot
721
+ * accidentally resolve to a same-turn direct output that
722
+ * has just registered. Pure event batches don't have a
723
+ * snapshot and resolve against the live registry — safe
724
+ * because no event-side registrations have happened yet.
725
+ */
726
+ const view = preBatchSnapshot ?? {
727
+ resolve: (args) => registry.resolve(registryRunId, args),
728
+ };
729
+ const { resolved, unresolved } = view.resolve(hookResult.updatedInput);
730
+ entry.args = resolved;
731
+ if (entry.call.id != null) {
732
+ if (unresolved.length > 0) {
733
+ unresolvedByCallId.set(entry.call.id, unresolved);
734
+ }
735
+ else {
736
+ unresolvedByCallId.delete(entry.call.id);
737
+ }
738
+ }
739
+ }
740
+ else {
741
+ entry.args = hookResult.updatedInput;
742
+ }
440
743
  }
441
744
  approvedEntries.push(entry);
442
745
  }
@@ -445,10 +748,14 @@ class ToolNode extends run.RunnableCallable {
445
748
  approvedEntries.push(...preToolCalls);
446
749
  }
447
750
  const injected = [];
751
+ const batchIndexByCallId = new Map();
448
752
  if (approvedEntries.length > 0) {
449
753
  const requests = approvedEntries.map((entry) => {
450
754
  const turn = this.toolUsageCount.get(entry.call.name) ?? 0;
451
755
  this.toolUsageCount.set(entry.call.name, turn + 1);
756
+ if (entry.batchIndex != null && entry.call.id != null) {
757
+ batchIndexByCallId.set(entry.call.id, entry.batchIndex);
758
+ }
452
759
  const request = {
453
760
  id: entry.call.id,
454
761
  name: entry.call.name,
@@ -494,11 +801,25 @@ class ToolNode extends run.RunnableCallable {
494
801
  let toolMessage;
495
802
  if (result.status === 'error') {
496
803
  contentString = `Error: ${result.errorMessage ?? 'Unknown error'}\n Please fix your mistakes.`;
804
+ /**
805
+ * Error results bypass registration but stamp the
806
+ * unresolved-refs hint into `additional_kwargs` so the lazy
807
+ * annotation transform surfaces it to the LLM at request
808
+ * time, letting the model self-correct when its reference
809
+ * key caused the failure. Persisted `content` stays clean.
810
+ */
811
+ const unresolved = unresolvedByCallId.get(result.toolCallId) ?? [];
812
+ const errorRefMeta = unresolved.length > 0
813
+ ? this.recordOutputReference(registryRunId, contentString, undefined, unresolved)
814
+ : undefined;
497
815
  toolMessage = new messages.ToolMessage({
498
816
  status: 'error',
499
817
  content: contentString,
500
818
  name: toolName,
501
819
  tool_call_id: result.toolCallId,
820
+ ...(errorRefMeta != null && {
821
+ additional_kwargs: errorRefMeta,
822
+ }),
502
823
  });
503
824
  if (hasFailureHook) {
504
825
  await executeHooks.executeHooks({
@@ -523,10 +844,10 @@ class ToolNode extends run.RunnableCallable {
523
844
  }
524
845
  }
525
846
  else {
526
- const rawContent = typeof result.content === 'string'
847
+ let registryRaw = typeof result.content === 'string'
527
848
  ? result.content
528
849
  : JSON.stringify(result.content);
529
- contentString = truncation.truncateToolResultContent(rawContent, this.maxToolResultChars);
850
+ contentString = truncation.truncateToolResultContent(registryRaw, this.maxToolResultChars);
530
851
  if (hasPostHook) {
531
852
  const hookResult = await executeHooks.executeHooks({
532
853
  registry: this.hookRegistry,
@@ -549,15 +870,27 @@ class ToolNode extends run.RunnableCallable {
549
870
  const replaced = typeof hookResult.updatedOutput === 'string'
550
871
  ? hookResult.updatedOutput
551
872
  : JSON.stringify(hookResult.updatedOutput);
873
+ registryRaw = replaced;
552
874
  contentString = truncation.truncateToolResultContent(replaced, this.maxToolResultChars);
553
875
  }
554
876
  }
877
+ const batchIndex = batchIndexByCallId.get(result.toolCallId);
878
+ const unresolved = unresolvedByCallId.get(result.toolCallId) ?? [];
879
+ const refKey = this.toolOutputRegistry != null &&
880
+ batchIndex != null &&
881
+ turn != null
882
+ ? toolOutputReferences.buildReferenceKey(batchIndex, turn)
883
+ : undefined;
884
+ const successRefMeta = this.recordOutputReference(registryRunId, registryRaw, refKey, unresolved);
555
885
  toolMessage = new messages.ToolMessage({
556
886
  status: 'success',
557
887
  name: toolName,
558
888
  content: contentString,
559
889
  artifact: result.artifact,
560
890
  tool_call_id: result.toolCallId,
891
+ ...(successRefMeta != null && {
892
+ additional_kwargs: successRefMeta,
893
+ }),
561
894
  });
562
895
  }
563
896
  this.dispatchStepCompleted(result.toolCallId, toolName, request?.args ?? {}, contentString, config, request?.turn);
@@ -619,25 +952,61 @@ class ToolNode extends run.RunnableCallable {
619
952
  * Injected messages are placed AFTER ToolMessages to respect provider
620
953
  * message ordering (AIMessage tool_calls must be immediately followed
621
954
  * by their ToolMessage results).
955
+ *
956
+ * `batchIndices` mirrors `toolCalls` and carries each call's position
957
+ * within the parent batch. `turn` is the per-`run()` batch index
958
+ * captured locally by the caller. Both are threaded so concurrent
959
+ * invocations cannot race on shared mutable state.
622
960
  */
623
961
  async executeViaEvent(toolCalls, config,
624
962
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
625
- input) {
626
- const { toolMessages, injected } = await this.dispatchToolEvents(toolCalls, config);
963
+ input, batchContext = {}) {
964
+ const { toolMessages, injected } = await this.dispatchToolEvents(toolCalls, config, batchContext);
627
965
  const outputs = [...toolMessages, ...injected];
628
966
  return (Array.isArray(input) ? outputs : { messages: outputs });
629
967
  }
630
968
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
631
969
  async run(input, config) {
632
970
  this.toolCallTurns.clear();
971
+ /**
972
+ * Per-batch local map for resolved (post-substitution) args.
973
+ * Lives on the stack so concurrent `run()` calls on the same
974
+ * ToolNode cannot read or wipe each other's entries.
975
+ */
976
+ const resolvedArgsByCallId = new Map();
977
+ /**
978
+ * Claim this batch's turn synchronously from the registry (or
979
+ * fall back to 0 when the feature is disabled). The registry is
980
+ * partitioned by scope id so overlapping batches cannot
981
+ * overwrite each other's state even under a shared registry.
982
+ *
983
+ * For anonymous callers (no `run_id` in config), mint a unique
984
+ * per-batch scope id so two concurrent anonymous invocations
985
+ * don't target the same bucket. The scope is threaded down to
986
+ * every subsequent registry call on this batch.
987
+ */
988
+ const incomingRunId = config.configurable?.run_id;
989
+ const batchScopeId = incomingRunId ?? `\0anon-${this.anonBatchCounter++}`;
990
+ const turn = this.toolOutputRegistry?.nextTurn(batchScopeId) ?? 0;
633
991
  let outputs;
634
992
  if (this.isSendInput(input)) {
635
993
  const isDirectTool = this.directToolNames?.has(input.lg_tool_call.name);
636
994
  if (this.eventDrivenMode && isDirectTool !== true) {
637
- return this.executeViaEvent([input.lg_tool_call], config, input);
995
+ return this.executeViaEvent([input.lg_tool_call], config, input, {
996
+ batchIndices: [0],
997
+ turn,
998
+ batchScopeId,
999
+ });
638
1000
  }
639
- outputs = [await this.runTool(input.lg_tool_call, config)];
640
- this.handleRunToolCompletions([input.lg_tool_call], outputs, config);
1001
+ outputs = [
1002
+ await this.runTool(input.lg_tool_call, config, {
1003
+ batchIndex: 0,
1004
+ turn,
1005
+ batchScopeId,
1006
+ resolvedArgsByCallId,
1007
+ }),
1008
+ ];
1009
+ this.handleRunToolCompletions([input.lg_tool_call], outputs, config, resolvedArgsByCallId);
641
1010
  }
642
1011
  else {
643
1012
  let messages$1;
@@ -682,19 +1051,82 @@ class ToolNode extends run.RunnableCallable {
682
1051
  false));
683
1052
  }) ?? [];
684
1053
  if (this.eventDrivenMode && filteredCalls.length > 0) {
1054
+ const filteredIndices = filteredCalls.map((_, idx) => idx);
685
1055
  if (!this.directToolNames || this.directToolNames.size === 0) {
686
- return this.executeViaEvent(filteredCalls, config, input);
1056
+ return this.executeViaEvent(filteredCalls, config, input, {
1057
+ batchIndices: filteredIndices,
1058
+ turn,
1059
+ batchScopeId,
1060
+ });
1061
+ }
1062
+ const directEntries = [];
1063
+ const eventEntries = [];
1064
+ for (let i = 0; i < filteredCalls.length; i++) {
1065
+ const call = filteredCalls[i];
1066
+ const entry = { call, batchIndex: i };
1067
+ if (this.directToolNames.has(call.name)) {
1068
+ directEntries.push(entry);
1069
+ }
1070
+ else {
1071
+ eventEntries.push(entry);
1072
+ }
1073
+ }
1074
+ const directCalls = directEntries.map((e) => e.call);
1075
+ const directIndices = directEntries.map((e) => e.batchIndex);
1076
+ const eventCalls = eventEntries.map((e) => e.call);
1077
+ const eventIndices = eventEntries.map((e) => e.batchIndex);
1078
+ /**
1079
+ * Snapshot the event calls' args against the *pre-batch*
1080
+ * registry state synchronously, before any await runs. The
1081
+ * directs are then awaited first (preserving fail-fast
1082
+ * semantics — a thrown error in a direct tool, e.g. with
1083
+ * `handleToolErrors=false` or a `GraphInterrupt`, aborts
1084
+ * before we dispatch any event-driven tools to the host).
1085
+ * Because the event args were captured pre-await, they stay
1086
+ * isolated from same-turn direct outputs that register
1087
+ * during the await.
1088
+ */
1089
+ const preResolvedEventArgs = new Map();
1090
+ /**
1091
+ * Take a frozen snapshot of the registry state before any
1092
+ * direct registrations land. The snapshot resolves
1093
+ * placeholders against this point-in-time view, so a
1094
+ * `PreToolUse` hook later rewriting event args via
1095
+ * `updatedInput` can introduce placeholders that resolve
1096
+ * cross-batch (against prior runs) without ever picking up
1097
+ * same-turn direct outputs.
1098
+ */
1099
+ const preBatchSnapshot = this.toolOutputRegistry?.snapshot(batchScopeId);
1100
+ if (preBatchSnapshot != null) {
1101
+ for (const entry of eventEntries) {
1102
+ if (entry.call.id != null) {
1103
+ const { resolved, unresolved } = preBatchSnapshot.resolve(entry.call.args);
1104
+ preResolvedEventArgs.set(entry.call.id, {
1105
+ resolved: resolved,
1106
+ unresolved,
1107
+ });
1108
+ }
1109
+ }
687
1110
  }
688
- const directCalls = filteredCalls.filter((c) => this.directToolNames.has(c.name));
689
- const eventCalls = filteredCalls.filter((c) => !this.directToolNames.has(c.name));
690
1111
  const directOutputs = directCalls.length > 0
691
- ? await Promise.all(directCalls.map((call) => this.runTool(call, config)))
1112
+ ? await Promise.all(directCalls.map((call, i) => this.runTool(call, config, {
1113
+ batchIndex: directIndices[i],
1114
+ turn,
1115
+ batchScopeId,
1116
+ resolvedArgsByCallId,
1117
+ })))
692
1118
  : [];
693
1119
  if (directCalls.length > 0 && directOutputs.length > 0) {
694
- this.handleRunToolCompletions(directCalls, directOutputs, config);
1120
+ this.handleRunToolCompletions(directCalls, directOutputs, config, resolvedArgsByCallId);
695
1121
  }
696
1122
  const eventResult = eventCalls.length > 0
697
- ? await this.dispatchToolEvents(eventCalls, config)
1123
+ ? await this.dispatchToolEvents(eventCalls, config, {
1124
+ batchIndices: eventIndices,
1125
+ turn,
1126
+ batchScopeId,
1127
+ preResolvedArgs: preResolvedEventArgs,
1128
+ preBatchSnapshot,
1129
+ })
698
1130
  : {
699
1131
  toolMessages: [],
700
1132
  injected: [],
@@ -706,8 +1138,13 @@ class ToolNode extends run.RunnableCallable {
706
1138
  ];
707
1139
  }
708
1140
  else {
709
- outputs = await Promise.all(filteredCalls.map((call) => this.runTool(call, config)));
710
- this.handleRunToolCompletions(filteredCalls, outputs, config);
1141
+ outputs = await Promise.all(filteredCalls.map((call, i) => this.runTool(call, config, {
1142
+ batchIndex: i,
1143
+ turn,
1144
+ batchScopeId,
1145
+ resolvedArgsByCallId,
1146
+ })));
1147
+ this.handleRunToolCompletions(filteredCalls, outputs, config, resolvedArgsByCallId);
711
1148
  }
712
1149
  }
713
1150
  if (!outputs.some(langgraph.isCommand)) {