@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
@@ -0,0 +1,813 @@
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 { ToolMessage } from '@langchain/core/messages';
26
+ import type { BaseMessage } from '@langchain/core/messages';
27
+ import {
28
+ calculateMaxTotalToolOutputSize,
29
+ HARD_MAX_TOOL_RESULT_CHARS,
30
+ HARD_MAX_TOTAL_TOOL_OUTPUT_SIZE,
31
+ } from '@/utils/truncation';
32
+
33
+ /**
34
+ * Non-global matcher for a single `{{tool<i>turn<n>}}` placeholder.
35
+ * Exported for consumers that want to detect references (e.g., syntax
36
+ * highlighting, docs). The stateful `g` variant lives inside the
37
+ * registry so nobody trips on `lastIndex`.
38
+ */
39
+ export const TOOL_OUTPUT_REF_PATTERN = /\{\{(tool\d+turn\d+)\}\}/;
40
+
41
+ /** Object key used when a parsed-object output has `_ref` injected. */
42
+ export const TOOL_OUTPUT_REF_KEY = '_ref';
43
+
44
+ /**
45
+ * Object key used to carry unresolved reference warnings on a parsed-
46
+ * object output. Using a dedicated field instead of a trailing text
47
+ * line keeps the annotated `ToolMessage.content` parseable as JSON for
48
+ * downstream consumers that rely on the object shape.
49
+ */
50
+ export const TOOL_OUTPUT_UNRESOLVED_KEY = '_unresolved_refs';
51
+
52
+ /** Single-line prefix prepended to non-object tool outputs so the LLM sees the reference key. */
53
+ export function buildReferencePrefix(key: string): string {
54
+ return `[ref: ${key}]`;
55
+ }
56
+
57
+ /** Stable registry key for a tool output. */
58
+ export function buildReferenceKey(toolIndex: number, turn: number): string {
59
+ return `tool${toolIndex}turn${turn}`;
60
+ }
61
+
62
+ export type ToolOutputReferenceRegistryOptions = {
63
+ /** Maximum characters stored per registered output. */
64
+ maxOutputSize?: number;
65
+ /** Maximum total characters retained across all registered outputs. */
66
+ maxTotalSize?: number;
67
+ /**
68
+ * Upper bound on the number of concurrently-tracked runs. When
69
+ * exceeded, the oldest run bucket is evicted (FIFO). Defaults to 32.
70
+ */
71
+ maxActiveRuns?: number;
72
+ };
73
+
74
+ /**
75
+ * Result of resolving placeholders in tool args.
76
+ */
77
+ export type ResolveResult<T> = {
78
+ /** Arguments with placeholders replaced. Same shape as the input. */
79
+ resolved: T;
80
+ /** Reference keys that were referenced but had no stored value. */
81
+ unresolved: string[];
82
+ };
83
+
84
+ /**
85
+ * Read-only view over a frozen registry snapshot. Returned by
86
+ * {@link ToolOutputReferenceRegistry.snapshot} for callers that need
87
+ * to resolve placeholders against the registry state at a specific
88
+ * point in time, ignoring any subsequent registrations.
89
+ */
90
+ export interface ToolOutputResolveView {
91
+ resolve<T>(args: T): ResolveResult<T>;
92
+ }
93
+
94
+ /**
95
+ * Pre-resolved arg map keyed by `toolCallId`. Used by the mixed
96
+ * direct+event dispatch path to feed event calls' resolved args
97
+ * (captured pre-batch) into the dispatcher without re-resolving
98
+ * against the now-stale live registry.
99
+ */
100
+ export type PreResolvedArgsMap = Map<
101
+ string,
102
+ { resolved: Record<string, unknown>; unresolved: string[] }
103
+ >;
104
+
105
+ /**
106
+ * Per-call sink for resolved args, keyed by `toolCallId`. Threaded
107
+ * as a per-batch local map so concurrent `ToolNode.run()` calls do
108
+ * not race on shared sink state.
109
+ */
110
+ export type ResolvedArgsByCallId = Map<string, Record<string, unknown>>;
111
+
112
+ const EMPTY_ENTRIES: ReadonlyMap<string, string> = new Map<string, string>();
113
+
114
+ /**
115
+ * Per-run state bucket held inside the registry. Each distinct
116
+ * `run_id` gets its own bucket so overlapping concurrent runs on a
117
+ * shared registry cannot leak outputs, turn counters, or warn-memos
118
+ * into one another.
119
+ */
120
+ class RunStateBucket {
121
+ entries: Map<string, string> = new Map();
122
+ totalSize: number = 0;
123
+ turnCounter: number = 0;
124
+ warnedNonStringTools: Set<string> = new Set();
125
+ }
126
+
127
+ /**
128
+ * Anonymous (`run_id` absent) bucket key. Anonymous batches are
129
+ * treated as fresh runs on every invocation — see `nextTurn`.
130
+ */
131
+ const ANON_RUN_KEY = '\0anon';
132
+
133
+ /**
134
+ * Default upper bound on the number of concurrently-tracked runs per
135
+ * registry. When exceeded, the oldest run's bucket (by insertion
136
+ * order) is evicted. Keeps memory bounded when a ToolNode is reused
137
+ * across many runs without explicit `releaseRun` calls.
138
+ */
139
+ const DEFAULT_MAX_ACTIVE_RUNS = 32;
140
+
141
+ /**
142
+ * Ordered map of reference-key → stored output, partitioned by run so
143
+ * concurrent / interleaved runs sharing one registry cannot leak
144
+ * outputs between each other.
145
+ *
146
+ * Each public method takes a `runId` which selects the run's bucket.
147
+ * Hosts typically get one registry per run via `Graph`, in which
148
+ * case only a single bucket is ever populated; the partitioning
149
+ * exists so the registry also behaves correctly when a single
150
+ * instance is reused directly.
151
+ */
152
+ export class ToolOutputReferenceRegistry {
153
+ private runStates: Map<string, RunStateBucket> = new Map();
154
+ private readonly maxOutputSize: number;
155
+ private readonly maxTotalSize: number;
156
+ private readonly maxActiveRuns: number;
157
+ /**
158
+ * Local stateful matcher used only by `replaceInString`. Kept
159
+ * off-module so callers of the exported `TOOL_OUTPUT_REF_PATTERN`
160
+ * never see a stale `lastIndex`.
161
+ */
162
+ private static readonly PLACEHOLDER_MATCHER = /\{\{(tool\d+turn\d+)\}\}/g;
163
+
164
+ constructor(options: ToolOutputReferenceRegistryOptions = {}) {
165
+ /**
166
+ * Per-output default is the same ~400 KB budget as the standard
167
+ * tool-result truncation (`HARD_MAX_TOOL_RESULT_CHARS`). This
168
+ * keeps a single `{{…}}` substitution at a size that is safe to
169
+ * pass through typical shell `ARG_MAX` limits and matches what
170
+ * the LLM would otherwise have seen. Hosts that want larger per-
171
+ * output payloads (API consumers, long JSON streams) can raise
172
+ * the cap explicitly up to the 5 MB total budget.
173
+ */
174
+ const perOutput =
175
+ options.maxOutputSize != null && options.maxOutputSize > 0
176
+ ? options.maxOutputSize
177
+ : HARD_MAX_TOOL_RESULT_CHARS;
178
+ /**
179
+ * Clamp a caller-supplied `maxTotalSize` to
180
+ * `HARD_MAX_TOTAL_TOOL_OUTPUT_SIZE` (5 MB) so the documented
181
+ * absolute cap is enforced regardless of host config —
182
+ * `calculateMaxTotalToolOutputSize` already applies the same
183
+ * upper bound on its computed default, but the user-provided
184
+ * branch was bypassing it.
185
+ */
186
+ const totalRaw =
187
+ options.maxTotalSize != null && options.maxTotalSize > 0
188
+ ? Math.min(options.maxTotalSize, HARD_MAX_TOTAL_TOOL_OUTPUT_SIZE)
189
+ : calculateMaxTotalToolOutputSize(perOutput);
190
+ this.maxTotalSize = totalRaw;
191
+ /**
192
+ * The per-output cap can never exceed the per-run aggregate cap:
193
+ * if a single entry were allowed to be larger than `maxTotalSize`,
194
+ * the eviction loop would either blow the cap (to keep the entry)
195
+ * or self-evict a just-stored value. Clamping here turns
196
+ * `maxTotalSize` into a hard upper bound on *any* state the
197
+ * registry retains per run.
198
+ */
199
+ this.maxOutputSize = Math.min(perOutput, totalRaw);
200
+ this.maxActiveRuns =
201
+ options.maxActiveRuns != null && options.maxActiveRuns > 0
202
+ ? options.maxActiveRuns
203
+ : DEFAULT_MAX_ACTIVE_RUNS;
204
+ }
205
+
206
+ private keyFor(runId: string | undefined): string {
207
+ return runId ?? ANON_RUN_KEY;
208
+ }
209
+
210
+ private getOrCreate(runId: string | undefined): RunStateBucket {
211
+ const key = this.keyFor(runId);
212
+ let state = this.runStates.get(key);
213
+ if (state == null) {
214
+ state = new RunStateBucket();
215
+ this.runStates.set(key, state);
216
+ if (this.runStates.size > this.maxActiveRuns) {
217
+ const oldest = this.runStates.keys().next().value;
218
+ if (oldest != null && oldest !== key) {
219
+ this.runStates.delete(oldest);
220
+ }
221
+ }
222
+ }
223
+ return state;
224
+ }
225
+
226
+ /** Registers (or replaces) the output stored under `key` for `runId`. */
227
+ set(runId: string | undefined, key: string, value: string): void {
228
+ const bucket = this.getOrCreate(runId);
229
+ const clipped =
230
+ value.length > this.maxOutputSize
231
+ ? value.slice(0, this.maxOutputSize)
232
+ : value;
233
+ const existing = bucket.entries.get(key);
234
+ if (existing != null) {
235
+ bucket.totalSize -= existing.length;
236
+ bucket.entries.delete(key);
237
+ }
238
+ bucket.entries.set(key, clipped);
239
+ bucket.totalSize += clipped.length;
240
+ this.evictWithinBucket(bucket);
241
+ }
242
+
243
+ /** Returns the stored value for `key` in `runId`'s bucket, or `undefined`. */
244
+ get(runId: string | undefined, key: string): string | undefined {
245
+ return this.runStates.get(this.keyFor(runId))?.entries.get(key);
246
+ }
247
+
248
+ /**
249
+ * Returns `true` when `key` is currently stored in `runId`'s bucket.
250
+ * Used by {@link annotateMessagesForLLM} to gate transient annotation
251
+ * on whether the registry still owns the referenced output (a stale
252
+ * `_refKey` from a prior run silently no-ops here).
253
+ */
254
+ has(runId: string | undefined, key: string): boolean {
255
+ return this.runStates.get(this.keyFor(runId))?.entries.has(key) ?? false;
256
+ }
257
+
258
+ /** Total number of registered outputs across every run bucket. */
259
+ get size(): number {
260
+ let n = 0;
261
+ for (const bucket of this.runStates.values()) {
262
+ n += bucket.entries.size;
263
+ }
264
+ return n;
265
+ }
266
+
267
+ /** Maximum characters retained per output (post-clip). */
268
+ get perOutputLimit(): number {
269
+ return this.maxOutputSize;
270
+ }
271
+
272
+ /** Maximum total characters retained *per run*. */
273
+ get totalLimit(): number {
274
+ return this.maxTotalSize;
275
+ }
276
+
277
+ /** Drops every run's state. */
278
+ clear(): void {
279
+ this.runStates.clear();
280
+ }
281
+
282
+ /**
283
+ * Explicitly release `runId`'s state. Safe to call when a run has
284
+ * finished. Hosts sharing one registry across runs should call this
285
+ * to reclaim memory deterministically; otherwise LRU eviction kicks
286
+ * in when `maxActiveRuns` runs accumulate.
287
+ */
288
+ releaseRun(runId: string | undefined): void {
289
+ this.runStates.delete(this.keyFor(runId));
290
+ }
291
+
292
+ /**
293
+ * Claims the next batch turn synchronously from `runId`'s bucket.
294
+ *
295
+ * Must be called once at the start of each ToolNode batch before
296
+ * any `await`, so concurrent invocations within the same run see
297
+ * distinct turn values (reads are effectively atomic by JS's
298
+ * single-threaded execution of the sync prefix).
299
+ *
300
+ * If `runId` is missing the anonymous bucket is dropped and a
301
+ * fresh one created so each anonymous call behaves as its own run.
302
+ */
303
+ nextTurn(runId: string | undefined): number {
304
+ if (runId == null) {
305
+ this.runStates.delete(ANON_RUN_KEY);
306
+ }
307
+ const bucket = this.getOrCreate(runId);
308
+ return bucket.turnCounter++;
309
+ }
310
+
311
+ /**
312
+ * Records that `toolName` has been warned about in `runId` (returns
313
+ * `true` on the first call per run, `false` after). Used by
314
+ * ToolNode to emit one log line per offending tool per run when a
315
+ * `ToolMessage.content` isn't a string.
316
+ */
317
+ claimWarnOnce(runId: string | undefined, toolName: string): boolean {
318
+ const bucket = this.getOrCreate(runId);
319
+ if (bucket.warnedNonStringTools.has(toolName)) {
320
+ return false;
321
+ }
322
+ bucket.warnedNonStringTools.add(toolName);
323
+ return true;
324
+ }
325
+
326
+ /**
327
+ * Walks `args` and replaces every `{{tool<i>turn<n>}}` placeholder in
328
+ * string values with the stored output *from `runId`'s bucket*. Non-
329
+ * string values and object keys are left untouched. Unresolved
330
+ * references are left in-place and reported so the caller can
331
+ * surface them to the LLM. When no placeholder appears anywhere in
332
+ * the serialized args, the original input is returned without
333
+ * walking the tree.
334
+ */
335
+ resolve<T>(runId: string | undefined, args: T): ResolveResult<T> {
336
+ if (!hasAnyPlaceholder(args)) {
337
+ return { resolved: args, unresolved: [] };
338
+ }
339
+ const bucket = this.runStates.get(this.keyFor(runId));
340
+ return this.resolveAgainst(bucket?.entries ?? EMPTY_ENTRIES, args);
341
+ }
342
+
343
+ /**
344
+ * Captures a frozen snapshot of `runId`'s current entries and
345
+ * returns a view that resolves placeholders against *only* that
346
+ * snapshot. The snapshot is decoupled from the live registry, so
347
+ * subsequent `set()` calls (for example, same-turn direct outputs
348
+ * registering while an event branch is still in flight) are
349
+ * invisible to the snapshot's `resolve`. Used by the mixed
350
+ * direct+event dispatch path to preserve same-turn isolation when
351
+ * a `PreToolUse` hook rewrites event args after directs have
352
+ * completed.
353
+ */
354
+ snapshot(runId: string | undefined): ToolOutputResolveView {
355
+ const bucket = this.runStates.get(this.keyFor(runId));
356
+ const entries: ReadonlyMap<string, string> = bucket
357
+ ? new Map(bucket.entries)
358
+ : EMPTY_ENTRIES;
359
+ return {
360
+ resolve: <T>(args: T): ResolveResult<T> =>
361
+ this.resolveAgainst(entries, args),
362
+ };
363
+ }
364
+
365
+ private resolveAgainst<T>(
366
+ entries: ReadonlyMap<string, string>,
367
+ args: T
368
+ ): ResolveResult<T> {
369
+ if (!hasAnyPlaceholder(args)) {
370
+ return { resolved: args, unresolved: [] };
371
+ }
372
+ const unresolved = new Set<string>();
373
+ const resolved = this.transform(entries, args, unresolved) as T;
374
+ return { resolved, unresolved: Array.from(unresolved) };
375
+ }
376
+
377
+ private transform(
378
+ entries: ReadonlyMap<string, string>,
379
+ value: unknown,
380
+ unresolved: Set<string>
381
+ ): unknown {
382
+ if (typeof value === 'string') {
383
+ return this.replaceInString(entries, value, unresolved);
384
+ }
385
+ if (Array.isArray(value)) {
386
+ return value.map((item) => this.transform(entries, item, unresolved));
387
+ }
388
+ if (value !== null && typeof value === 'object') {
389
+ const source = value as Record<string, unknown>;
390
+ const next: Record<string, unknown> = {};
391
+ for (const [key, item] of Object.entries(source)) {
392
+ next[key] = this.transform(entries, item, unresolved);
393
+ }
394
+ return next;
395
+ }
396
+ return value;
397
+ }
398
+
399
+ private replaceInString(
400
+ entries: ReadonlyMap<string, string>,
401
+ input: string,
402
+ unresolved: Set<string>
403
+ ): string {
404
+ if (input.indexOf('{{tool') === -1) {
405
+ return input;
406
+ }
407
+ return input.replace(
408
+ ToolOutputReferenceRegistry.PLACEHOLDER_MATCHER,
409
+ (match, key: string) => {
410
+ const stored = entries.get(key);
411
+ if (stored == null) {
412
+ unresolved.add(key);
413
+ return match;
414
+ }
415
+ return stored;
416
+ }
417
+ );
418
+ }
419
+
420
+ private evictWithinBucket(bucket: RunStateBucket): void {
421
+ if (bucket.totalSize <= this.maxTotalSize) {
422
+ return;
423
+ }
424
+ for (const key of bucket.entries.keys()) {
425
+ if (bucket.totalSize <= this.maxTotalSize) {
426
+ return;
427
+ }
428
+ const entry = bucket.entries.get(key);
429
+ if (entry == null) {
430
+ continue;
431
+ }
432
+ bucket.totalSize -= entry.length;
433
+ bucket.entries.delete(key);
434
+ }
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Cheap pre-check: returns true if any string value in `args` contains
440
+ * the `{{tool` substring. Lets `resolve()` skip the deep tree walk (and
441
+ * its object allocations) for the common case of plain args.
442
+ */
443
+ function hasAnyPlaceholder(value: unknown): boolean {
444
+ if (typeof value === 'string') {
445
+ return value.indexOf('{{tool') !== -1;
446
+ }
447
+ if (Array.isArray(value)) {
448
+ for (const item of value) {
449
+ if (hasAnyPlaceholder(item)) {
450
+ return true;
451
+ }
452
+ }
453
+ return false;
454
+ }
455
+ if (value !== null && typeof value === 'object') {
456
+ for (const item of Object.values(value as Record<string, unknown>)) {
457
+ if (hasAnyPlaceholder(item)) {
458
+ return true;
459
+ }
460
+ }
461
+ return false;
462
+ }
463
+ return false;
464
+ }
465
+
466
+ /**
467
+ * Annotates `content` with a reference key and/or unresolved-ref
468
+ * warnings so the LLM sees both alongside the tool output.
469
+ *
470
+ * Behavior:
471
+ * - If `content` parses as a plain (non-array, non-null) JSON object
472
+ * and the object does not already have a conflicting `_ref` key,
473
+ * the reference key and (when present) `_unresolved_refs` array
474
+ * are injected as object fields, preserving JSON validity for
475
+ * downstream consumers that parse the output.
476
+ * - Otherwise (string output, JSON array/primitive, parse failure,
477
+ * or `_ref` collision), a `[ref: <key>]\n` prefix line is
478
+ * prepended and unresolved refs are appended as a trailing
479
+ * `[unresolved refs: …]` line.
480
+ *
481
+ * The annotated string is what the LLM sees as `ToolMessage.content`.
482
+ * The *original* (un-annotated) value is what gets stored in the
483
+ * registry, so downstream piping remains pristine.
484
+ *
485
+ * @param content Raw (post-truncation) tool output.
486
+ * @param key Reference key for this output, or undefined when
487
+ * there is nothing to register (errors etc.).
488
+ * @param unresolved Reference keys that failed to resolve during
489
+ * argument substitution. Surfaced so the LLM can
490
+ * self-correct its next tool call.
491
+ */
492
+ export function annotateToolOutputWithReference(
493
+ content: string,
494
+ key: string | undefined,
495
+ unresolved: string[] = []
496
+ ): string {
497
+ const hasRefKey = key != null;
498
+ const hasUnresolved = unresolved.length > 0;
499
+ if (!hasRefKey && !hasUnresolved) {
500
+ return content;
501
+ }
502
+ const trimmed = content.trimStart();
503
+ if (trimmed.startsWith('{')) {
504
+ const annotated = tryInjectRefIntoJsonObject(content, key, unresolved);
505
+ if (annotated != null) {
506
+ return annotated;
507
+ }
508
+ }
509
+ const prefix = hasRefKey ? `${buildReferencePrefix(key!)}\n` : '';
510
+ const trailer = hasUnresolved
511
+ ? `\n[unresolved refs: ${unresolved.join(', ')}]`
512
+ : '';
513
+ return `${prefix}${content}${trailer}`;
514
+ }
515
+
516
+ function tryInjectRefIntoJsonObject(
517
+ content: string,
518
+ key: string | undefined,
519
+ unresolved: string[]
520
+ ): string | null {
521
+ let parsed: unknown;
522
+ try {
523
+ parsed = JSON.parse(content);
524
+ } catch {
525
+ return null;
526
+ }
527
+
528
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
529
+ return null;
530
+ }
531
+
532
+ const obj = parsed as Record<string, unknown>;
533
+ const injectingRef = key != null;
534
+ const injectingUnresolved = unresolved.length > 0;
535
+
536
+ /**
537
+ * Reject the JSON-injection path (fall back to prefix form) when
538
+ * either of our keys collides with real payload data:
539
+ * - `_ref` collision: existing value is non-null and differs from
540
+ * the key we're about to inject.
541
+ * - `_unresolved_refs` collision: existing value is non-null and
542
+ * is not a deep-equal match for the array we'd inject.
543
+ * This keeps us from silently overwriting legitimate tool output.
544
+ */
545
+ if (
546
+ injectingRef &&
547
+ TOOL_OUTPUT_REF_KEY in obj &&
548
+ obj[TOOL_OUTPUT_REF_KEY] !== key &&
549
+ obj[TOOL_OUTPUT_REF_KEY] != null
550
+ ) {
551
+ return null;
552
+ }
553
+ if (
554
+ injectingUnresolved &&
555
+ TOOL_OUTPUT_UNRESOLVED_KEY in obj &&
556
+ obj[TOOL_OUTPUT_UNRESOLVED_KEY] != null &&
557
+ !arraysShallowEqual(obj[TOOL_OUTPUT_UNRESOLVED_KEY], unresolved)
558
+ ) {
559
+ return null;
560
+ }
561
+
562
+ /**
563
+ * Only strip the framework-owned key we're actually injecting —
564
+ * leave everything else (including a pre-existing `_ref` on the
565
+ * unresolved-only path, or a pre-existing `_unresolved_refs` on a
566
+ * plain-annotation path) untouched so we annotate rather than
567
+ * mutate downstream payload data. Our injected keys land first in
568
+ * the serialized JSON so the LLM sees them before the body.
569
+ */
570
+ const omitKeys = new Set<string>();
571
+ if (injectingRef) omitKeys.add(TOOL_OUTPUT_REF_KEY);
572
+ if (injectingUnresolved) omitKeys.add(TOOL_OUTPUT_UNRESOLVED_KEY);
573
+ const rest: Record<string, unknown> = {};
574
+ for (const [k, v] of Object.entries(obj)) {
575
+ if (!omitKeys.has(k)) {
576
+ rest[k] = v;
577
+ }
578
+ }
579
+ const injected: Record<string, unknown> = {};
580
+ if (injectingRef) {
581
+ injected[TOOL_OUTPUT_REF_KEY] = key;
582
+ }
583
+ if (injectingUnresolved) {
584
+ injected[TOOL_OUTPUT_UNRESOLVED_KEY] = unresolved;
585
+ }
586
+ Object.assign(injected, rest);
587
+
588
+ const pretty = /^\{\s*\n/.test(content);
589
+ return pretty ? JSON.stringify(injected, null, 2) : JSON.stringify(injected);
590
+ }
591
+
592
+ function arraysShallowEqual(a: unknown, b: readonly string[]): boolean {
593
+ if (!Array.isArray(a) || a.length !== b.length) {
594
+ return false;
595
+ }
596
+ for (let i = 0; i < a.length; i++) {
597
+ if (a[i] !== b[i]) {
598
+ return false;
599
+ }
600
+ }
601
+ return true;
602
+ }
603
+
604
+ /**
605
+ * Lazy projection that, given a registry and a runId, returns a new
606
+ * `messages` array where each `ToolMessage` carrying ref metadata is
607
+ * projected into a transient copy with annotated content (when the ref
608
+ * is live in the registry) and with the framework-owned `additional_
609
+ * kwargs` keys (`_refKey`, `_refScope`, `_unresolvedRefs`) stripped
610
+ * regardless of whether annotation applied. The original input array
611
+ * and its messages are never mutated.
612
+ *
613
+ * Annotation is gated on registry presence: a stale `_refKey` from a
614
+ * prior run (e.g. one that survived in persisted history) silently
615
+ * no-ops on the *content* side. The strip-metadata side still runs so
616
+ * stale framework keys never leak onto the wire under any custom or
617
+ * future provider serializer that might transmit `additional_kwargs`.
618
+ * `_unresolvedRefs` is always meaningful and is not gated.
619
+ *
620
+ * **Feature-disabled fast path:** when the host hasn't enabled the
621
+ * tool-output-reference feature, the registry is `undefined` and this
622
+ * function returns the input array reference-equal *without iterating
623
+ * a single message*. The loop is exclusive to the feature-enabled
624
+ * code path.
625
+ */
626
+ export function annotateMessagesForLLM(
627
+ messages: BaseMessage[],
628
+ registry: ToolOutputReferenceRegistry | undefined,
629
+ runId: string | undefined
630
+ ): BaseMessage[] {
631
+ if (registry == null) return messages;
632
+
633
+ /**
634
+ * Lazy-allocate the output array so the common case (no ToolMessage
635
+ * carries framework metadata) returns the input reference-equal with
636
+ * zero allocations beyond the per-message predicate checks.
637
+ */
638
+ let out: BaseMessage[] | undefined;
639
+ for (let i = 0; i < messages.length; i++) {
640
+ const m = messages[i];
641
+ if (m._getType() !== 'tool') continue;
642
+ /**
643
+ * `additional_kwargs` is untyped at the LangChain layer
644
+ * (`Record<string, unknown>`), so persisted or client-supplied
645
+ * ToolMessages can carry arbitrary shapes under our framework
646
+ * keys. Treat them as untrusted input and coerce defensively
647
+ * before any array operation — a malformed field on a single
648
+ * hydrated message must not crash `attemptInvoke` before the
649
+ * provider call.
650
+ */
651
+ const meta = m.additional_kwargs as Record<string, unknown> | undefined;
652
+ const hasRefKey = meta != null && '_refKey' in meta;
653
+ const hasRefScope = meta != null && '_refScope' in meta;
654
+ const hasUnresolvedField = meta != null && '_unresolvedRefs' in meta;
655
+ if (!hasRefKey && !hasRefScope && !hasUnresolvedField) continue;
656
+
657
+ const refKey = readRefKey(meta);
658
+ const unresolved = readUnresolvedRefs(meta);
659
+
660
+ /**
661
+ * Prefer the message-stamped `_refScope` for the registry lookup.
662
+ * For named runs it equals the current `runId`; for anonymous
663
+ * invocations it carries the per-batch synthetic scope minted by
664
+ * ToolNode (`\0anon-<n>`), which `runId` from config cannot
665
+ * recover. Falling back to `runId` keeps backward compatibility
666
+ * with messages stamped before this field existed.
667
+ */
668
+ const lookupScope = readRefScope(meta) ?? runId;
669
+ const liveRef =
670
+ refKey != null && registry.has(lookupScope, refKey) ? refKey : undefined;
671
+ const annotates = liveRef != null || unresolved.length > 0;
672
+
673
+ const tm = m as ToolMessage;
674
+ let nextContent: ToolMessage['content'] = tm.content;
675
+
676
+ if (annotates && typeof tm.content === 'string') {
677
+ nextContent = annotateToolOutputWithReference(
678
+ tm.content,
679
+ liveRef,
680
+ unresolved
681
+ );
682
+ } else if (
683
+ annotates &&
684
+ Array.isArray(tm.content) &&
685
+ unresolved.length > 0
686
+ ) {
687
+ const warningBlock = {
688
+ type: 'text' as const,
689
+ text: `[unresolved refs: ${unresolved.join(', ')}]`,
690
+ };
691
+ nextContent = [
692
+ warningBlock,
693
+ ...tm.content,
694
+ ] as unknown as ToolMessage['content'];
695
+ }
696
+
697
+ /**
698
+ * Project unconditionally: even when no annotation applies (stale
699
+ * `_refKey` or non-annotatable content), `cloneToolMessageWithContent`
700
+ * runs `stripFrameworkRefMetadata` on `additional_kwargs` so the
701
+ * framework-owned keys never reach the wire.
702
+ */
703
+ out ??= messages.slice();
704
+ out[i] = cloneToolMessageWithContent(tm, nextContent);
705
+ }
706
+
707
+ return out ?? messages;
708
+ }
709
+
710
+ /**
711
+ * Reads `_refKey` defensively from untyped `additional_kwargs`. Returns
712
+ * undefined for non-string values so a malformed field cannot poison
713
+ * the registry lookup or downstream string operations.
714
+ */
715
+ function readRefKey(
716
+ meta: Record<string, unknown> | undefined
717
+ ): string | undefined {
718
+ const v = meta?._refKey;
719
+ return typeof v === 'string' ? v : undefined;
720
+ }
721
+
722
+ /**
723
+ * Reads `_refScope` defensively from untyped `additional_kwargs`.
724
+ * Mirrors {@link readRefKey} — non-string scopes are dropped (the
725
+ * caller falls back to the run-derived scope) rather than passed into
726
+ * the registry as a malformed key.
727
+ */
728
+ function readRefScope(
729
+ meta: Record<string, unknown> | undefined
730
+ ): string | undefined {
731
+ const v = meta?._refScope;
732
+ return typeof v === 'string' ? v : undefined;
733
+ }
734
+
735
+ /**
736
+ * Reads `_unresolvedRefs` defensively from untyped `additional_kwargs`.
737
+ * Returns an empty array for any non-array value, and filters out
738
+ * non-string entries from a real array. Without this guard, a hydrated
739
+ * ToolMessage carrying e.g. `_unresolvedRefs: 'tool0turn0'` would crash
740
+ * `attemptInvoke` on the eventual `.length` / `.join(...)` call.
741
+ */
742
+ function readUnresolvedRefs(
743
+ meta: Record<string, unknown> | undefined
744
+ ): string[] {
745
+ const v = meta?._unresolvedRefs;
746
+ if (!Array.isArray(v)) return [];
747
+ const out: string[] = [];
748
+ for (const item of v) {
749
+ if (typeof item === 'string') out.push(item);
750
+ }
751
+ return out;
752
+ }
753
+
754
+ /**
755
+ * Builds a fresh `ToolMessage` that mirrors `tm`'s identity fields with
756
+ * the supplied `content`. Every `ToolMessage` field but `content` is
757
+ * carried over so the projection is structurally identical to the
758
+ * original from a LangChain serializer's perspective.
759
+ *
760
+ * `additional_kwargs` is rebuilt with the framework-owned ref keys
761
+ * stripped. Defensive: LangChain's standard provider serializers do not
762
+ * transmit `additional_kwargs` to provider HTTP APIs, but a custom
763
+ * adapter or future LangChain change could. Stripping keeps the
764
+ * implementation correct under any serializer behavior at the cost of a
765
+ * shallow object spread per annotated message.
766
+ */
767
+ function cloneToolMessageWithContent(
768
+ tm: ToolMessage,
769
+ content: ToolMessage['content']
770
+ ): ToolMessage {
771
+ return new ToolMessage({
772
+ id: tm.id,
773
+ name: tm.name,
774
+ status: tm.status,
775
+ artifact: tm.artifact,
776
+ tool_call_id: tm.tool_call_id,
777
+ response_metadata: tm.response_metadata,
778
+ additional_kwargs: stripFrameworkRefMetadata(tm.additional_kwargs),
779
+ content,
780
+ });
781
+ }
782
+
783
+ /**
784
+ * Returns a copy of `kwargs` with `_refKey`, `_refScope`, and
785
+ * `_unresolvedRefs` removed. Returns the input reference-equal when
786
+ * none of those keys are present so the no-strip path stays cheap;
787
+ * returns `undefined` when stripping leaves the object empty so the
788
+ * caller can drop the field entirely.
789
+ */
790
+ function stripFrameworkRefMetadata(
791
+ kwargs: Record<string, unknown> | undefined
792
+ ): Record<string, unknown> | undefined {
793
+ if (kwargs == null) return undefined;
794
+ if (
795
+ !('_refKey' in kwargs) &&
796
+ !('_refScope' in kwargs) &&
797
+ !('_unresolvedRefs' in kwargs)
798
+ ) {
799
+ return kwargs;
800
+ }
801
+ const { _refKey, _refScope, _unresolvedRefs, ...rest } = kwargs as Record<
802
+ string,
803
+ unknown
804
+ > & {
805
+ _refKey?: unknown;
806
+ _refScope?: unknown;
807
+ _unresolvedRefs?: unknown;
808
+ };
809
+ void _refKey;
810
+ void _refScope;
811
+ void _unresolvedRefs;
812
+ return Object.keys(rest).length === 0 ? undefined : rest;
813
+ }