@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
@@ -0,0 +1,590 @@
1
+ /**
2
+ * Tool output reference registry.
3
+ *
4
+ * When enabled via `RunConfig.toolOutputReferences.enabled`, ToolNode
5
+ * stores each successful tool output under a stable key
6
+ * (`tool<idx>turn<turn>`) where `idx` is the tool's position within a
7
+ * ToolNode batch and `turn` is the batch index within the run
8
+ * (incremented once per ToolNode invocation).
9
+ *
10
+ * Subsequent tool calls can pipe a previous output into their args by
11
+ * embedding `{{tool<idx>turn<turn>}}` inside any string argument;
12
+ * {@link ToolOutputReferenceRegistry.resolve} walks the args and
13
+ * substitutes the placeholders immediately before invocation.
14
+ *
15
+ * The registry stores the *raw, untruncated* tool output so a later
16
+ * `{{…}}` substitution pipes the full payload into the next tool —
17
+ * even when the LLM only saw a head+tail-truncated preview in
18
+ * `ToolMessage.content`. Outputs are stored without any annotation
19
+ * (the `_ref` key or the `[ref: ...]` prefix seen by the LLM is
20
+ * strictly a UX signal attached to `ToolMessage.content`). Keeping the
21
+ * registry pristine means downstream bash/jq piping receives the
22
+ * complete, verbatim output with no injected fields.
23
+ */
24
+
25
+ import {
26
+ calculateMaxTotalToolOutputSize,
27
+ HARD_MAX_TOOL_RESULT_CHARS,
28
+ HARD_MAX_TOTAL_TOOL_OUTPUT_SIZE,
29
+ } from '@/utils/truncation';
30
+
31
+ /**
32
+ * Non-global matcher for a single `{{tool<i>turn<n>}}` placeholder.
33
+ * Exported for consumers that want to detect references (e.g., syntax
34
+ * highlighting, docs). The stateful `g` variant lives inside the
35
+ * registry so nobody trips on `lastIndex`.
36
+ */
37
+ export const TOOL_OUTPUT_REF_PATTERN = /\{\{(tool\d+turn\d+)\}\}/;
38
+
39
+ /** Object key used when a parsed-object output has `_ref` injected. */
40
+ export const TOOL_OUTPUT_REF_KEY = '_ref';
41
+
42
+ /**
43
+ * Object key used to carry unresolved reference warnings on a parsed-
44
+ * object output. Using a dedicated field instead of a trailing text
45
+ * line keeps the annotated `ToolMessage.content` parseable as JSON for
46
+ * downstream consumers that rely on the object shape.
47
+ */
48
+ export const TOOL_OUTPUT_UNRESOLVED_KEY = '_unresolved_refs';
49
+
50
+ /** Single-line prefix prepended to non-object tool outputs so the LLM sees the reference key. */
51
+ export function buildReferencePrefix(key: string): string {
52
+ return `[ref: ${key}]`;
53
+ }
54
+
55
+ /** Stable registry key for a tool output. */
56
+ export function buildReferenceKey(toolIndex: number, turn: number): string {
57
+ return `tool${toolIndex}turn${turn}`;
58
+ }
59
+
60
+ export type ToolOutputReferenceRegistryOptions = {
61
+ /** Maximum characters stored per registered output. */
62
+ maxOutputSize?: number;
63
+ /** Maximum total characters retained across all registered outputs. */
64
+ maxTotalSize?: number;
65
+ /**
66
+ * Upper bound on the number of concurrently-tracked runs. When
67
+ * exceeded, the oldest run bucket is evicted (FIFO). Defaults to 32.
68
+ */
69
+ maxActiveRuns?: number;
70
+ };
71
+
72
+ /**
73
+ * Result of resolving placeholders in tool args.
74
+ */
75
+ export type ResolveResult<T> = {
76
+ /** Arguments with placeholders replaced. Same shape as the input. */
77
+ resolved: T;
78
+ /** Reference keys that were referenced but had no stored value. */
79
+ unresolved: string[];
80
+ };
81
+
82
+ /**
83
+ * Read-only view over a frozen registry snapshot. Returned by
84
+ * {@link ToolOutputReferenceRegistry.snapshot} for callers that need
85
+ * to resolve placeholders against the registry state at a specific
86
+ * point in time, ignoring any subsequent registrations.
87
+ */
88
+ export interface ToolOutputResolveView {
89
+ resolve<T>(args: T): ResolveResult<T>;
90
+ }
91
+
92
+ /**
93
+ * Pre-resolved arg map keyed by `toolCallId`. Used by the mixed
94
+ * direct+event dispatch path to feed event calls' resolved args
95
+ * (captured pre-batch) into the dispatcher without re-resolving
96
+ * against the now-stale live registry.
97
+ */
98
+ export type PreResolvedArgsMap = Map<
99
+ string,
100
+ { resolved: Record<string, unknown>; unresolved: string[] }
101
+ >;
102
+
103
+ /**
104
+ * Per-call sink for resolved args, keyed by `toolCallId`. Threaded
105
+ * as a per-batch local map so concurrent `ToolNode.run()` calls do
106
+ * not race on shared sink state.
107
+ */
108
+ export type ResolvedArgsByCallId = Map<string, Record<string, unknown>>;
109
+
110
+ const EMPTY_ENTRIES: ReadonlyMap<string, string> = new Map<string, string>();
111
+
112
+ /**
113
+ * Per-run state bucket held inside the registry. Each distinct
114
+ * `run_id` gets its own bucket so overlapping concurrent runs on a
115
+ * shared registry cannot leak outputs, turn counters, or warn-memos
116
+ * into one another.
117
+ */
118
+ class RunStateBucket {
119
+ entries: Map<string, string> = new Map();
120
+ totalSize: number = 0;
121
+ turnCounter: number = 0;
122
+ warnedNonStringTools: Set<string> = new Set();
123
+ }
124
+
125
+ /**
126
+ * Anonymous (`run_id` absent) bucket key. Anonymous batches are
127
+ * treated as fresh runs on every invocation — see `nextTurn`.
128
+ */
129
+ const ANON_RUN_KEY = '\0anon';
130
+
131
+ /**
132
+ * Default upper bound on the number of concurrently-tracked runs per
133
+ * registry. When exceeded, the oldest run's bucket (by insertion
134
+ * order) is evicted. Keeps memory bounded when a ToolNode is reused
135
+ * across many runs without explicit `releaseRun` calls.
136
+ */
137
+ const DEFAULT_MAX_ACTIVE_RUNS = 32;
138
+
139
+ /**
140
+ * Ordered map of reference-key → stored output, partitioned by run so
141
+ * concurrent / interleaved runs sharing one registry cannot leak
142
+ * outputs between each other.
143
+ *
144
+ * Each public method takes a `runId` which selects the run's bucket.
145
+ * Hosts typically get one registry per run via `Graph`, in which
146
+ * case only a single bucket is ever populated; the partitioning
147
+ * exists so the registry also behaves correctly when a single
148
+ * instance is reused directly.
149
+ */
150
+ export class ToolOutputReferenceRegistry {
151
+ private runStates: Map<string, RunStateBucket> = new Map();
152
+ private readonly maxOutputSize: number;
153
+ private readonly maxTotalSize: number;
154
+ private readonly maxActiveRuns: number;
155
+ /**
156
+ * Local stateful matcher used only by `replaceInString`. Kept
157
+ * off-module so callers of the exported `TOOL_OUTPUT_REF_PATTERN`
158
+ * never see a stale `lastIndex`.
159
+ */
160
+ private static readonly PLACEHOLDER_MATCHER = /\{\{(tool\d+turn\d+)\}\}/g;
161
+
162
+ constructor(options: ToolOutputReferenceRegistryOptions = {}) {
163
+ /**
164
+ * Per-output default is the same ~400 KB budget as the standard
165
+ * tool-result truncation (`HARD_MAX_TOOL_RESULT_CHARS`). This
166
+ * keeps a single `{{…}}` substitution at a size that is safe to
167
+ * pass through typical shell `ARG_MAX` limits and matches what
168
+ * the LLM would otherwise have seen. Hosts that want larger per-
169
+ * output payloads (API consumers, long JSON streams) can raise
170
+ * the cap explicitly up to the 5 MB total budget.
171
+ */
172
+ const perOutput =
173
+ options.maxOutputSize != null && options.maxOutputSize > 0
174
+ ? options.maxOutputSize
175
+ : HARD_MAX_TOOL_RESULT_CHARS;
176
+ /**
177
+ * Clamp a caller-supplied `maxTotalSize` to
178
+ * `HARD_MAX_TOTAL_TOOL_OUTPUT_SIZE` (5 MB) so the documented
179
+ * absolute cap is enforced regardless of host config —
180
+ * `calculateMaxTotalToolOutputSize` already applies the same
181
+ * upper bound on its computed default, but the user-provided
182
+ * branch was bypassing it.
183
+ */
184
+ const totalRaw =
185
+ options.maxTotalSize != null && options.maxTotalSize > 0
186
+ ? Math.min(options.maxTotalSize, HARD_MAX_TOTAL_TOOL_OUTPUT_SIZE)
187
+ : calculateMaxTotalToolOutputSize(perOutput);
188
+ this.maxTotalSize = totalRaw;
189
+ /**
190
+ * The per-output cap can never exceed the per-run aggregate cap:
191
+ * if a single entry were allowed to be larger than `maxTotalSize`,
192
+ * the eviction loop would either blow the cap (to keep the entry)
193
+ * or self-evict a just-stored value. Clamping here turns
194
+ * `maxTotalSize` into a hard upper bound on *any* state the
195
+ * registry retains per run.
196
+ */
197
+ this.maxOutputSize = Math.min(perOutput, totalRaw);
198
+ this.maxActiveRuns =
199
+ options.maxActiveRuns != null && options.maxActiveRuns > 0
200
+ ? options.maxActiveRuns
201
+ : DEFAULT_MAX_ACTIVE_RUNS;
202
+ }
203
+
204
+ private keyFor(runId: string | undefined): string {
205
+ return runId ?? ANON_RUN_KEY;
206
+ }
207
+
208
+ private getOrCreate(runId: string | undefined): RunStateBucket {
209
+ const key = this.keyFor(runId);
210
+ let state = this.runStates.get(key);
211
+ if (state == null) {
212
+ state = new RunStateBucket();
213
+ this.runStates.set(key, state);
214
+ if (this.runStates.size > this.maxActiveRuns) {
215
+ const oldest = this.runStates.keys().next().value;
216
+ if (oldest != null && oldest !== key) {
217
+ this.runStates.delete(oldest);
218
+ }
219
+ }
220
+ }
221
+ return state;
222
+ }
223
+
224
+ /** Registers (or replaces) the output stored under `key` for `runId`. */
225
+ set(runId: string | undefined, key: string, value: string): void {
226
+ const bucket = this.getOrCreate(runId);
227
+ const clipped =
228
+ value.length > this.maxOutputSize
229
+ ? value.slice(0, this.maxOutputSize)
230
+ : value;
231
+ const existing = bucket.entries.get(key);
232
+ if (existing != null) {
233
+ bucket.totalSize -= existing.length;
234
+ bucket.entries.delete(key);
235
+ }
236
+ bucket.entries.set(key, clipped);
237
+ bucket.totalSize += clipped.length;
238
+ this.evictWithinBucket(bucket);
239
+ }
240
+
241
+ /** Returns the stored value for `key` in `runId`'s bucket, or `undefined`. */
242
+ get(runId: string | undefined, key: string): string | undefined {
243
+ return this.runStates.get(this.keyFor(runId))?.entries.get(key);
244
+ }
245
+
246
+ /** Total number of registered outputs across every run bucket. */
247
+ get size(): number {
248
+ let n = 0;
249
+ for (const bucket of this.runStates.values()) {
250
+ n += bucket.entries.size;
251
+ }
252
+ return n;
253
+ }
254
+
255
+ /** Maximum characters retained per output (post-clip). */
256
+ get perOutputLimit(): number {
257
+ return this.maxOutputSize;
258
+ }
259
+
260
+ /** Maximum total characters retained *per run*. */
261
+ get totalLimit(): number {
262
+ return this.maxTotalSize;
263
+ }
264
+
265
+ /** Drops every run's state. */
266
+ clear(): void {
267
+ this.runStates.clear();
268
+ }
269
+
270
+ /**
271
+ * Explicitly release `runId`'s state. Safe to call when a run has
272
+ * finished. Hosts sharing one registry across runs should call this
273
+ * to reclaim memory deterministically; otherwise LRU eviction kicks
274
+ * in when `maxActiveRuns` runs accumulate.
275
+ */
276
+ releaseRun(runId: string | undefined): void {
277
+ this.runStates.delete(this.keyFor(runId));
278
+ }
279
+
280
+ /**
281
+ * Claims the next batch turn synchronously from `runId`'s bucket.
282
+ *
283
+ * Must be called once at the start of each ToolNode batch before
284
+ * any `await`, so concurrent invocations within the same run see
285
+ * distinct turn values (reads are effectively atomic by JS's
286
+ * single-threaded execution of the sync prefix).
287
+ *
288
+ * If `runId` is missing the anonymous bucket is dropped and a
289
+ * fresh one created so each anonymous call behaves as its own run.
290
+ */
291
+ nextTurn(runId: string | undefined): number {
292
+ if (runId == null) {
293
+ this.runStates.delete(ANON_RUN_KEY);
294
+ }
295
+ const bucket = this.getOrCreate(runId);
296
+ return bucket.turnCounter++;
297
+ }
298
+
299
+ /**
300
+ * Records that `toolName` has been warned about in `runId` (returns
301
+ * `true` on the first call per run, `false` after). Used by
302
+ * ToolNode to emit one log line per offending tool per run when a
303
+ * `ToolMessage.content` isn't a string.
304
+ */
305
+ claimWarnOnce(runId: string | undefined, toolName: string): boolean {
306
+ const bucket = this.getOrCreate(runId);
307
+ if (bucket.warnedNonStringTools.has(toolName)) {
308
+ return false;
309
+ }
310
+ bucket.warnedNonStringTools.add(toolName);
311
+ return true;
312
+ }
313
+
314
+ /**
315
+ * Walks `args` and replaces every `{{tool<i>turn<n>}}` placeholder in
316
+ * string values with the stored output *from `runId`'s bucket*. Non-
317
+ * string values and object keys are left untouched. Unresolved
318
+ * references are left in-place and reported so the caller can
319
+ * surface them to the LLM. When no placeholder appears anywhere in
320
+ * the serialized args, the original input is returned without
321
+ * walking the tree.
322
+ */
323
+ resolve<T>(runId: string | undefined, args: T): ResolveResult<T> {
324
+ if (!hasAnyPlaceholder(args)) {
325
+ return { resolved: args, unresolved: [] };
326
+ }
327
+ const bucket = this.runStates.get(this.keyFor(runId));
328
+ return this.resolveAgainst(bucket?.entries ?? EMPTY_ENTRIES, args);
329
+ }
330
+
331
+ /**
332
+ * Captures a frozen snapshot of `runId`'s current entries and
333
+ * returns a view that resolves placeholders against *only* that
334
+ * snapshot. The snapshot is decoupled from the live registry, so
335
+ * subsequent `set()` calls (for example, same-turn direct outputs
336
+ * registering while an event branch is still in flight) are
337
+ * invisible to the snapshot's `resolve`. Used by the mixed
338
+ * direct+event dispatch path to preserve same-turn isolation when
339
+ * a `PreToolUse` hook rewrites event args after directs have
340
+ * completed.
341
+ */
342
+ snapshot(runId: string | undefined): ToolOutputResolveView {
343
+ const bucket = this.runStates.get(this.keyFor(runId));
344
+ const entries: ReadonlyMap<string, string> = bucket
345
+ ? new Map(bucket.entries)
346
+ : EMPTY_ENTRIES;
347
+ return {
348
+ resolve: <T>(args: T): ResolveResult<T> =>
349
+ this.resolveAgainst(entries, args),
350
+ };
351
+ }
352
+
353
+ private resolveAgainst<T>(
354
+ entries: ReadonlyMap<string, string>,
355
+ args: T
356
+ ): ResolveResult<T> {
357
+ if (!hasAnyPlaceholder(args)) {
358
+ return { resolved: args, unresolved: [] };
359
+ }
360
+ const unresolved = new Set<string>();
361
+ const resolved = this.transform(entries, args, unresolved) as T;
362
+ return { resolved, unresolved: Array.from(unresolved) };
363
+ }
364
+
365
+ private transform(
366
+ entries: ReadonlyMap<string, string>,
367
+ value: unknown,
368
+ unresolved: Set<string>
369
+ ): unknown {
370
+ if (typeof value === 'string') {
371
+ return this.replaceInString(entries, value, unresolved);
372
+ }
373
+ if (Array.isArray(value)) {
374
+ return value.map((item) => this.transform(entries, item, unresolved));
375
+ }
376
+ if (value !== null && typeof value === 'object') {
377
+ const source = value as Record<string, unknown>;
378
+ const next: Record<string, unknown> = {};
379
+ for (const [key, item] of Object.entries(source)) {
380
+ next[key] = this.transform(entries, item, unresolved);
381
+ }
382
+ return next;
383
+ }
384
+ return value;
385
+ }
386
+
387
+ private replaceInString(
388
+ entries: ReadonlyMap<string, string>,
389
+ input: string,
390
+ unresolved: Set<string>
391
+ ): string {
392
+ if (input.indexOf('{{tool') === -1) {
393
+ return input;
394
+ }
395
+ return input.replace(
396
+ ToolOutputReferenceRegistry.PLACEHOLDER_MATCHER,
397
+ (match, key: string) => {
398
+ const stored = entries.get(key);
399
+ if (stored == null) {
400
+ unresolved.add(key);
401
+ return match;
402
+ }
403
+ return stored;
404
+ }
405
+ );
406
+ }
407
+
408
+ private evictWithinBucket(bucket: RunStateBucket): void {
409
+ if (bucket.totalSize <= this.maxTotalSize) {
410
+ return;
411
+ }
412
+ for (const key of bucket.entries.keys()) {
413
+ if (bucket.totalSize <= this.maxTotalSize) {
414
+ return;
415
+ }
416
+ const entry = bucket.entries.get(key);
417
+ if (entry == null) {
418
+ continue;
419
+ }
420
+ bucket.totalSize -= entry.length;
421
+ bucket.entries.delete(key);
422
+ }
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Cheap pre-check: returns true if any string value in `args` contains
428
+ * the `{{tool` substring. Lets `resolve()` skip the deep tree walk (and
429
+ * its object allocations) for the common case of plain args.
430
+ */
431
+ function hasAnyPlaceholder(value: unknown): boolean {
432
+ if (typeof value === 'string') {
433
+ return value.indexOf('{{tool') !== -1;
434
+ }
435
+ if (Array.isArray(value)) {
436
+ for (const item of value) {
437
+ if (hasAnyPlaceholder(item)) {
438
+ return true;
439
+ }
440
+ }
441
+ return false;
442
+ }
443
+ if (value !== null && typeof value === 'object') {
444
+ for (const item of Object.values(value as Record<string, unknown>)) {
445
+ if (hasAnyPlaceholder(item)) {
446
+ return true;
447
+ }
448
+ }
449
+ return false;
450
+ }
451
+ return false;
452
+ }
453
+
454
+ /**
455
+ * Annotates `content` with a reference key and/or unresolved-ref
456
+ * warnings so the LLM sees both alongside the tool output.
457
+ *
458
+ * Behavior:
459
+ * - If `content` parses as a plain (non-array, non-null) JSON object
460
+ * and the object does not already have a conflicting `_ref` key,
461
+ * the reference key and (when present) `_unresolved_refs` array
462
+ * are injected as object fields, preserving JSON validity for
463
+ * downstream consumers that parse the output.
464
+ * - Otherwise (string output, JSON array/primitive, parse failure,
465
+ * or `_ref` collision), a `[ref: <key>]\n` prefix line is
466
+ * prepended and unresolved refs are appended as a trailing
467
+ * `[unresolved refs: …]` line.
468
+ *
469
+ * The annotated string is what the LLM sees as `ToolMessage.content`.
470
+ * The *original* (un-annotated) value is what gets stored in the
471
+ * registry, so downstream piping remains pristine.
472
+ *
473
+ * @param content Raw (post-truncation) tool output.
474
+ * @param key Reference key for this output, or undefined when
475
+ * there is nothing to register (errors etc.).
476
+ * @param unresolved Reference keys that failed to resolve during
477
+ * argument substitution. Surfaced so the LLM can
478
+ * self-correct its next tool call.
479
+ */
480
+ export function annotateToolOutputWithReference(
481
+ content: string,
482
+ key: string | undefined,
483
+ unresolved: string[] = []
484
+ ): string {
485
+ const hasRefKey = key != null;
486
+ const hasUnresolved = unresolved.length > 0;
487
+ if (!hasRefKey && !hasUnresolved) {
488
+ return content;
489
+ }
490
+ const trimmed = content.trimStart();
491
+ if (trimmed.startsWith('{')) {
492
+ const annotated = tryInjectRefIntoJsonObject(content, key, unresolved);
493
+ if (annotated != null) {
494
+ return annotated;
495
+ }
496
+ }
497
+ const prefix = hasRefKey ? `${buildReferencePrefix(key!)}\n` : '';
498
+ const trailer = hasUnresolved
499
+ ? `\n[unresolved refs: ${unresolved.join(', ')}]`
500
+ : '';
501
+ return `${prefix}${content}${trailer}`;
502
+ }
503
+
504
+ function tryInjectRefIntoJsonObject(
505
+ content: string,
506
+ key: string | undefined,
507
+ unresolved: string[]
508
+ ): string | null {
509
+ let parsed: unknown;
510
+ try {
511
+ parsed = JSON.parse(content);
512
+ } catch {
513
+ return null;
514
+ }
515
+
516
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
517
+ return null;
518
+ }
519
+
520
+ const obj = parsed as Record<string, unknown>;
521
+ const injectingRef = key != null;
522
+ const injectingUnresolved = unresolved.length > 0;
523
+
524
+ /**
525
+ * Reject the JSON-injection path (fall back to prefix form) when
526
+ * either of our keys collides with real payload data:
527
+ * - `_ref` collision: existing value is non-null and differs from
528
+ * the key we're about to inject.
529
+ * - `_unresolved_refs` collision: existing value is non-null and
530
+ * is not a deep-equal match for the array we'd inject.
531
+ * This keeps us from silently overwriting legitimate tool output.
532
+ */
533
+ if (
534
+ injectingRef &&
535
+ TOOL_OUTPUT_REF_KEY in obj &&
536
+ obj[TOOL_OUTPUT_REF_KEY] !== key &&
537
+ obj[TOOL_OUTPUT_REF_KEY] != null
538
+ ) {
539
+ return null;
540
+ }
541
+ if (
542
+ injectingUnresolved &&
543
+ TOOL_OUTPUT_UNRESOLVED_KEY in obj &&
544
+ obj[TOOL_OUTPUT_UNRESOLVED_KEY] != null &&
545
+ !arraysShallowEqual(obj[TOOL_OUTPUT_UNRESOLVED_KEY], unresolved)
546
+ ) {
547
+ return null;
548
+ }
549
+
550
+ /**
551
+ * Only strip the framework-owned key we're actually injecting —
552
+ * leave everything else (including a pre-existing `_ref` on the
553
+ * unresolved-only path, or a pre-existing `_unresolved_refs` on a
554
+ * plain-annotation path) untouched so we annotate rather than
555
+ * mutate downstream payload data. Our injected keys land first in
556
+ * the serialized JSON so the LLM sees them before the body.
557
+ */
558
+ const omitKeys = new Set<string>();
559
+ if (injectingRef) omitKeys.add(TOOL_OUTPUT_REF_KEY);
560
+ if (injectingUnresolved) omitKeys.add(TOOL_OUTPUT_UNRESOLVED_KEY);
561
+ const rest: Record<string, unknown> = {};
562
+ for (const [k, v] of Object.entries(obj)) {
563
+ if (!omitKeys.has(k)) {
564
+ rest[k] = v;
565
+ }
566
+ }
567
+ const injected: Record<string, unknown> = {};
568
+ if (injectingRef) {
569
+ injected[TOOL_OUTPUT_REF_KEY] = key;
570
+ }
571
+ if (injectingUnresolved) {
572
+ injected[TOOL_OUTPUT_UNRESOLVED_KEY] = unresolved;
573
+ }
574
+ Object.assign(injected, rest);
575
+
576
+ const pretty = /^\{\s*\n/.test(content);
577
+ return pretty ? JSON.stringify(injected, null, 2) : JSON.stringify(injected);
578
+ }
579
+
580
+ function arraysShallowEqual(a: unknown, b: readonly string[]): boolean {
581
+ if (!Array.isArray(a) || a.length !== b.length) {
582
+ return false;
583
+ }
584
+ for (let i = 0; i < a.length; i++) {
585
+ if (a[i] !== b[i]) {
586
+ return false;
587
+ }
588
+ }
589
+ return true;
590
+ }
package/src/types/run.ts CHANGED
@@ -11,7 +11,7 @@ import type * as s from '@/types/stream';
11
11
  import type * as e from '@/common/enum';
12
12
  import type * as g from '@/types/graph';
13
13
  import type * as l from '@/types/llm';
14
- import type { ToolSessionMap } from '@/types/tools';
14
+ import type { ToolSessionMap, ToolOutputReferencesConfig } from '@/types/tools';
15
15
  import type { HookRegistry } from '@/hooks';
16
16
 
17
17
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -146,6 +146,14 @@ export type RunConfig = {
146
146
  * at run start, so ToolNode can inject session_id + files into tool calls.
147
147
  */
148
148
  initialSessions?: ToolSessionMap;
149
+ /**
150
+ * Run-scoped tool output reference configuration. When `enabled` is
151
+ * `true`, tool outputs are registered under stable keys
152
+ * (`tool<idx>turn<turn>`) and subsequent tool calls can pipe previous
153
+ * outputs into their arguments via `{{tool<idx>turn<turn>}}`
154
+ * placeholders. Disabled by default so existing runs are unaffected.
155
+ */
156
+ toolOutputReferences?: ToolOutputReferencesConfig;
149
157
  };
150
158
 
151
159
  export type ProvidedCallbacks =
@@ -3,6 +3,7 @@ import type { StructuredToolInterface } from '@langchain/core/tools';
3
3
  import type { RunnableToolLike } from '@langchain/core/runnables';
4
4
  import type { ToolCall } from '@langchain/core/messages/tool';
5
5
  import type { HookRegistry } from '@/hooks';
6
+ import type { ToolOutputReferenceRegistry } from '@/tools/toolOutputReferences';
6
7
  import type { MessageContentComplex, ToolErrorData } from './stream';
7
8
 
8
9
  /** Replacement type for `import type { ToolCall } from '@langchain/core/messages/tool'` in order to have stringified args typed */
@@ -62,6 +63,22 @@ export type ToolNodeOptions = {
62
63
  * When provided, takes precedence over the value computed from maxContextTokens.
63
64
  */
64
65
  maxToolResultChars?: number;
66
+ /**
67
+ * Run-scoped tool output reference configuration. When `enabled` is
68
+ * `true`, ToolNode registers successful outputs and substitutes
69
+ * `{{tool<idx>turn<turn>}}` placeholders found in string args.
70
+ *
71
+ * Ignored when `toolOutputRegistry` is also provided (host-supplied
72
+ * registry wins).
73
+ */
74
+ toolOutputReferences?: ToolOutputReferencesConfig;
75
+ /**
76
+ * Pre-constructed registry instance shared across ToolNodes for the
77
+ * run. Graphs pass the same registry to every ToolNode they compile
78
+ * so cross-agent `{{tool<i>turn<n>}}` substitutions resolve. Takes
79
+ * precedence over `toolOutputReferences` when both are set.
80
+ */
81
+ toolOutputRegistry?: ToolOutputReferenceRegistry;
65
82
  };
66
83
 
67
84
  export type ToolNodeConstructorParams = ToolRefs & ToolNodeOptions;
@@ -234,6 +251,60 @@ export type ToolExecuteResult = {
234
251
  /** Map of tool names to tool definitions */
235
252
  export type LCToolRegistry = Map<string, LCTool>;
236
253
 
254
+ /**
255
+ * Run-scoped configuration for tool output references.
256
+ *
257
+ * When enabled, each successful tool result is registered under a stable
258
+ * key (`tool<idx>turn<turn>`). Later tool calls can pipe a previous
259
+ * output into their arguments by including the literal placeholder
260
+ * `{{tool<idx>turn<turn>}}` anywhere in a string argument; ToolNode
261
+ * substitutes it with the stored output immediately before invoking
262
+ * the tool.
263
+ *
264
+ * The registry stores the *raw, untruncated* tool output (subject to
265
+ * its own size caps) so a later substitution can pipe the full payload
266
+ * into the next tool even when the LLM only saw a head+tail-truncated
267
+ * preview in `ToolMessage.content`. Size limits are decoupled from the
268
+ * LLM-visible truncation budget and default to 5 MB total.
269
+ *
270
+ * Known limitations:
271
+ * - Tools that return a `ToolMessage` with array-type content
272
+ * (multi-part content blocks such as text + image) are not
273
+ * registered and cannot be cited via `{{tool<i>turn<n>}}`. A
274
+ * warning is logged so the missing reference is visible.
275
+ * - When a `PostToolUse` hook replaces `ToolMessage.content`, the
276
+ * *post-hook* content is what gets stored in the registry (and
277
+ * what the model sees), so `{{…}}` substitutions deliver the
278
+ * hooked output rather than the raw tool return. This matches the
279
+ * hook's "authoritative" role for output shaping.
280
+ */
281
+ export type ToolOutputReferencesConfig = {
282
+ /** Enable the registry and placeholder substitution. Defaults to `false`. */
283
+ enabled?: boolean;
284
+ /**
285
+ * Maximum characters stored (and substituted) per registered output.
286
+ * Applied to the *raw* output before storage. Defaults to
287
+ * `HARD_MAX_TOOL_RESULT_CHARS` (~400 KB) — matching the
288
+ * LLM-visible tool-result truncation budget, which is also a safe
289
+ * payload size for shell `ARG_MAX` limits when a `{{…}}` expansion
290
+ * gets piped into a bash `command`. Hosts that want to preserve
291
+ * fuller fidelity (for example for non-bash API consumers) can
292
+ * raise this up to `maxTotalSize` (defaults to 5 MB) — be aware
293
+ * that large single-output substitutions may exceed shell
294
+ * argument-size limits on typical Linux/macOS.
295
+ */
296
+ maxOutputSize?: number;
297
+ /**
298
+ * Hard cap on total characters retained across all registered outputs
299
+ * for the run. When exceeded, the oldest entries are evicted FIFO
300
+ * until the total fits. The effective per-output cap is
301
+ * `min(maxOutputSize, maxTotalSize)` so a single stored output can
302
+ * never exceed the aggregate bound. Defaults to
303
+ * `calculateMaxTotalToolOutputSize(maxOutputSize)` (5 MB).
304
+ */
305
+ maxTotalSize?: number;
306
+ };
307
+
237
308
  export type ProgrammaticCache = { toolMap: ToolMap; toolDefs: LCTool[] };
238
309
 
239
310
  /** Search mode: code_interpreter uses external sandbox, local uses safe substring matching */