@librechat/agents 3.1.71-dev.0 → 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.
@@ -0,0 +1,419 @@
1
+ import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
2
+ import { describe, it, expect } from '@jest/globals';
3
+ import {
4
+ annotateMessagesForLLM,
5
+ ToolOutputReferenceRegistry,
6
+ TOOL_OUTPUT_REF_KEY,
7
+ TOOL_OUTPUT_UNRESOLVED_KEY,
8
+ } from '../toolOutputReferences';
9
+
10
+ function makeToolMessage(fields: {
11
+ content: ToolMessage['content'];
12
+ name?: string;
13
+ tool_call_id?: string;
14
+ status?: 'success' | 'error';
15
+ additional_kwargs?: Record<string, unknown>;
16
+ }): ToolMessage {
17
+ return new ToolMessage({
18
+ name: fields.name ?? 'echo',
19
+ tool_call_id: fields.tool_call_id ?? 'tc1',
20
+ status: fields.status ?? 'success',
21
+ additional_kwargs: fields.additional_kwargs,
22
+ content: fields.content,
23
+ });
24
+ }
25
+
26
+ describe('annotateMessagesForLLM', () => {
27
+ it('returns the input array reference when registry is undefined', () => {
28
+ const messages = [
29
+ new HumanMessage('hi'),
30
+ makeToolMessage({
31
+ content: 'data',
32
+ additional_kwargs: { _refKey: 'tool0turn0' },
33
+ }),
34
+ ];
35
+ const out = annotateMessagesForLLM(messages, undefined, 'r1');
36
+ expect(out).toBe(messages);
37
+ });
38
+
39
+ it('does not iterate any message when the feature is disabled (registry undefined)', () => {
40
+ /**
41
+ * Hard guarantee: when the host hasn't enabled
42
+ * `RunConfig.toolOutputReferences`, calling
43
+ * `annotateMessagesForLLM` must short-circuit at O(1) without
44
+ * touching a single ToolMessage. We assert by spying on
45
+ * `_getType` — the first per-message call inside the loop — and
46
+ * confirming it was never invoked.
47
+ */
48
+ const messages = [
49
+ makeToolMessage({ content: 'a' }),
50
+ makeToolMessage({ content: 'b' }),
51
+ makeToolMessage({
52
+ content: 'c',
53
+ additional_kwargs: { _refKey: 'tool0turn0' },
54
+ }),
55
+ ];
56
+ const spies = messages.map((m) => jest.spyOn(m, '_getType'));
57
+ const out = annotateMessagesForLLM(messages, undefined, undefined);
58
+ expect(out).toBe(messages);
59
+ for (const spy of spies) {
60
+ expect(spy).not.toHaveBeenCalled();
61
+ spy.mockRestore();
62
+ }
63
+ });
64
+
65
+ it('returns the input array reference when no ToolMessage carries metadata', () => {
66
+ const registry = new ToolOutputReferenceRegistry();
67
+ registry.set('r1', 'tool0turn0', 'stored');
68
+ const messages = [
69
+ new HumanMessage('hi'),
70
+ makeToolMessage({ content: 'data' }),
71
+ new AIMessage('answer'),
72
+ ];
73
+ const out = annotateMessagesForLLM(messages, registry, 'r1');
74
+ expect(out).toBe(messages);
75
+ });
76
+
77
+ it('annotates string content when _refKey is live in the registry', () => {
78
+ const registry = new ToolOutputReferenceRegistry();
79
+ registry.set('r1', 'tool0turn0', 'stored-raw');
80
+ const tm = makeToolMessage({
81
+ content: 'output',
82
+ additional_kwargs: { _refKey: 'tool0turn0' },
83
+ });
84
+ const out = annotateMessagesForLLM([tm], registry, 'r1');
85
+ expect(out[0].content).toBe('[ref: tool0turn0]\noutput');
86
+ expect(tm.content).toBe('output');
87
+ expect(out).not.toBe([tm]);
88
+ expect(out[0]).not.toBe(tm);
89
+ });
90
+
91
+ it('leaves content untouched but strips framework metadata when _refKey is stale', () => {
92
+ /**
93
+ * Stale `_refKey` (not in registry) doesn't trigger annotation,
94
+ * but the message still gets projected so framework keys are
95
+ * removed from `additional_kwargs` before the bytes leave for the
96
+ * provider. This protects against custom or future serializers
97
+ * that transmit `additional_kwargs`.
98
+ */
99
+ const registry = new ToolOutputReferenceRegistry();
100
+ const tm = makeToolMessage({
101
+ content: 'output',
102
+ additional_kwargs: { _refKey: 'tool0turn0', userField: 'preserved' },
103
+ });
104
+ const out = annotateMessagesForLLM([tm], registry, 'r1');
105
+ expect(out[0].content).toBe('output');
106
+ expect(out[0]).not.toBe(tm);
107
+ const projectedKwargs = (out[0] as ToolMessage).additional_kwargs;
108
+ expect(projectedKwargs._refKey).toBeUndefined();
109
+ expect(projectedKwargs.userField).toBe('preserved');
110
+ expect(tm.additional_kwargs._refKey).toBe('tool0turn0');
111
+ });
112
+
113
+ it('always applies _unresolvedRefs even when there is no registry entry', () => {
114
+ const registry = new ToolOutputReferenceRegistry();
115
+ const tm = makeToolMessage({
116
+ content: 'output',
117
+ additional_kwargs: { _unresolvedRefs: ['tool9turn9'] },
118
+ });
119
+ const out = annotateMessagesForLLM([tm], registry, 'r1');
120
+ expect(out[0].content).toBe('output\n[unresolved refs: tool9turn9]');
121
+ });
122
+
123
+ it('injects _ref into JSON-object string content', () => {
124
+ const registry = new ToolOutputReferenceRegistry();
125
+ registry.set('r1', 'tool0turn0', '{"a":1}');
126
+ const tm = makeToolMessage({
127
+ content: '{"a":1,"b":"x"}',
128
+ additional_kwargs: { _refKey: 'tool0turn0' },
129
+ });
130
+ const out = annotateMessagesForLLM([tm], registry, 'r1');
131
+ const parsed = JSON.parse(out[0].content as string);
132
+ expect(parsed[TOOL_OUTPUT_REF_KEY]).toBe('tool0turn0');
133
+ expect(parsed.a).toBe(1);
134
+ expect(parsed.b).toBe('x');
135
+ });
136
+
137
+ it('injects both _ref and _unresolved_refs into JSON-object content', () => {
138
+ /**
139
+ * Combined path: when a ToolMessage carries both a live `_refKey`
140
+ * and unresolved-ref hints, JSON-object content should receive
141
+ * both `_ref` and `_unresolved_refs` fields rather than falling
142
+ * back to the prefix/trailer form. Exercises
143
+ * `annotateToolOutputWithReference`'s collision-detection logic
144
+ * through the projection entry point.
145
+ */
146
+ const registry = new ToolOutputReferenceRegistry();
147
+ registry.set('r1', 'tool0turn0', '{"a":1}');
148
+ const tm = makeToolMessage({
149
+ content: '{"a":1,"b":"x"}',
150
+ additional_kwargs: {
151
+ _refKey: 'tool0turn0',
152
+ _unresolvedRefs: ['tool9turn9'],
153
+ },
154
+ });
155
+ const out = annotateMessagesForLLM([tm], registry, 'r1');
156
+ const parsed = JSON.parse(out[0].content as string);
157
+ expect(parsed[TOOL_OUTPUT_REF_KEY]).toBe('tool0turn0');
158
+ expect(parsed[TOOL_OUTPUT_UNRESOLVED_KEY]).toEqual(['tool9turn9']);
159
+ expect(parsed.a).toBe(1);
160
+ expect(parsed.b).toBe('x');
161
+ });
162
+
163
+ it('uses [ref: …] prefix for non-JSON string content', () => {
164
+ const registry = new ToolOutputReferenceRegistry();
165
+ registry.set('r1', 'tool0turn0', 'raw');
166
+ const tm = makeToolMessage({
167
+ content: 'plain output',
168
+ additional_kwargs: { _refKey: 'tool0turn0' },
169
+ });
170
+ const out = annotateMessagesForLLM([tm], registry, 'r1');
171
+ expect(out[0].content).toBe('[ref: tool0turn0]\nplain output');
172
+ });
173
+
174
+ it('prepends an unresolved-refs warning text block to multi-part content', () => {
175
+ const registry = new ToolOutputReferenceRegistry();
176
+ const tm = makeToolMessage({
177
+ content: [
178
+ { type: 'text', text: 'data' },
179
+ { type: 'image_url', image_url: { url: 'data:...' } },
180
+ ] as unknown as ToolMessage['content'],
181
+ additional_kwargs: { _unresolvedRefs: ['tool9turn9'] },
182
+ });
183
+ const out = annotateMessagesForLLM([tm], registry, 'r1');
184
+ const blocks = out[0].content as Array<{ type: string; text?: string }>;
185
+ expect(blocks).toHaveLength(3);
186
+ expect(blocks[0].type).toBe('text');
187
+ expect(blocks[0].text).toBe('[unresolved refs: tool9turn9]');
188
+ expect(blocks[1].type).toBe('text');
189
+ expect(blocks[1].text).toBe('data');
190
+ expect(blocks[2].type).toBe('image_url');
191
+ });
192
+
193
+ it('does not mutate the original ToolMessage instance or its content', () => {
194
+ const registry = new ToolOutputReferenceRegistry();
195
+ registry.set('r1', 'tool0turn0', 'raw');
196
+ const tm = makeToolMessage({
197
+ content: 'output',
198
+ additional_kwargs: { _refKey: 'tool0turn0' },
199
+ });
200
+ const originalContent = tm.content;
201
+ const originalKwargs = { ...tm.additional_kwargs };
202
+ annotateMessagesForLLM([tm], registry, 'r1');
203
+ expect(tm.content).toBe(originalContent);
204
+ expect(tm.additional_kwargs).toEqual(originalKwargs);
205
+ });
206
+
207
+ it('strips framework ref metadata from the projected additional_kwargs but preserves other fields', () => {
208
+ /**
209
+ * Defensive: even though LangChain's standard provider serializers
210
+ * do not transmit `additional_kwargs`, a custom adapter or future
211
+ * LangChain change could. Strip our three framework-owned keys on
212
+ * the projection so the metadata never reaches the wire under any
213
+ * serializer behavior. Non-framework fields stay put.
214
+ */
215
+ const registry = new ToolOutputReferenceRegistry();
216
+ registry.set('r1', 'tool0turn0', 'raw');
217
+ const tm = makeToolMessage({
218
+ content: 'output',
219
+ additional_kwargs: {
220
+ _refKey: 'tool0turn0',
221
+ _refScope: 'r1',
222
+ _unresolvedRefs: ['tool9turn9'],
223
+ someOtherField: 'preserved',
224
+ },
225
+ });
226
+ const out = annotateMessagesForLLM([tm], registry, 'r1');
227
+ const projected = out[0] as ToolMessage;
228
+ expect(projected.additional_kwargs._refKey).toBeUndefined();
229
+ expect(projected.additional_kwargs._refScope).toBeUndefined();
230
+ expect(projected.additional_kwargs._unresolvedRefs).toBeUndefined();
231
+ expect(projected.additional_kwargs.someOtherField).toBe('preserved');
232
+ expect(tm.additional_kwargs._refKey).toBe('tool0turn0');
233
+ expect(tm.additional_kwargs._refScope).toBe('r1');
234
+ });
235
+
236
+ it('leaves additional_kwargs empty when stripping removed every framework key', () => {
237
+ /**
238
+ * `stripFrameworkRefMetadata` returns `undefined` when no non-
239
+ * framework keys remain, and the LangChain `ToolMessage`
240
+ * constructor normalizes that to `{}` — so the projected message
241
+ * exposes an empty object, not the original kwargs.
242
+ */
243
+ const registry = new ToolOutputReferenceRegistry();
244
+ registry.set('r1', 'tool0turn0', 'raw');
245
+ const tm = makeToolMessage({
246
+ content: 'output',
247
+ additional_kwargs: {
248
+ _refKey: 'tool0turn0',
249
+ _refScope: 'r1',
250
+ },
251
+ });
252
+ const out = annotateMessagesForLLM([tm], registry, 'r1');
253
+ const projected = out[0] as ToolMessage;
254
+ expect(projected.additional_kwargs).toEqual({});
255
+ });
256
+
257
+ it('passes through non-ToolMessages unchanged in the projected array', () => {
258
+ const registry = new ToolOutputReferenceRegistry();
259
+ registry.set('r1', 'tool0turn0', 'raw');
260
+ const human = new HumanMessage('hi');
261
+ const ai = new AIMessage('answer');
262
+ const tm = makeToolMessage({
263
+ content: 'output',
264
+ additional_kwargs: { _refKey: 'tool0turn0' },
265
+ });
266
+ const out = annotateMessagesForLLM([human, ai, tm], registry, 'r1');
267
+ expect(out[0]).toBe(human);
268
+ expect(out[1]).toBe(ai);
269
+ expect(out[2]).not.toBe(tm);
270
+ });
271
+
272
+ it('projects (to strip metadata) but does not annotate when only stale _refKey is present', () => {
273
+ const registry = new ToolOutputReferenceRegistry();
274
+ registry.set('r1', 'tool1turn0', 'somethingelse');
275
+ const tm = makeToolMessage({
276
+ content: 'output',
277
+ additional_kwargs: { _refKey: 'tool0turn0' },
278
+ });
279
+ const messages = [tm];
280
+ const out = annotateMessagesForLLM(messages, registry, 'r1');
281
+ expect(out).not.toBe(messages);
282
+ expect(out[0].content).toBe('output');
283
+ expect((out[0] as ToolMessage).additional_kwargs._refKey).toBeUndefined();
284
+ });
285
+
286
+ it('returns the input array reference-equal when no ToolMessage carries any framework metadata', () => {
287
+ const registry = new ToolOutputReferenceRegistry();
288
+ registry.set('r1', 'tool0turn0', 'stored');
289
+ const messages = [
290
+ new HumanMessage('hi'),
291
+ makeToolMessage({
292
+ content: 'output',
293
+ additional_kwargs: { unrelated: 'value' },
294
+ }),
295
+ new AIMessage('answer'),
296
+ ];
297
+ const out = annotateMessagesForLLM(messages, registry, 'r1');
298
+ expect(out).toBe(messages);
299
+ });
300
+
301
+ it('annotates only the live ref when both ref and unresolved are present', () => {
302
+ const registry = new ToolOutputReferenceRegistry();
303
+ registry.set('r1', 'tool0turn0', 'raw');
304
+ const tm = makeToolMessage({
305
+ content: 'output',
306
+ additional_kwargs: {
307
+ _refKey: 'tool0turn0',
308
+ _unresolvedRefs: ['tool9turn9'],
309
+ },
310
+ });
311
+ const out = annotateMessagesForLLM([tm], registry, 'r1');
312
+ expect(out[0].content).toBe(
313
+ '[ref: tool0turn0]\noutput\n[unresolved refs: tool9turn9]'
314
+ );
315
+ });
316
+
317
+ it('uses _refScope for the registry lookup when present, ignoring runId', () => {
318
+ /**
319
+ * Anonymous ToolNode batches register under a synthetic scope
320
+ * (`\0anon-<n>`) that `config.configurable.run_id` cannot recover.
321
+ * The transform must follow the message-stamped `_refScope`
322
+ * instead of the config-derived runId.
323
+ */
324
+ const registry = new ToolOutputReferenceRegistry();
325
+ const anonScope = '\0anon-3';
326
+ registry.set(anonScope, 'tool0turn0', 'raw');
327
+
328
+ const tm = makeToolMessage({
329
+ content: 'output',
330
+ additional_kwargs: {
331
+ _refKey: 'tool0turn0',
332
+ _refScope: anonScope,
333
+ },
334
+ });
335
+ const out = annotateMessagesForLLM([tm], registry, undefined);
336
+ expect(out[0].content).toBe('[ref: tool0turn0]\noutput');
337
+ });
338
+
339
+ it('falls back to runId when _refScope is absent (legacy / pre-scope messages)', () => {
340
+ const registry = new ToolOutputReferenceRegistry();
341
+ registry.set('r1', 'tool0turn0', 'raw');
342
+ const tm = makeToolMessage({
343
+ content: 'output',
344
+ additional_kwargs: { _refKey: 'tool0turn0' },
345
+ });
346
+ const out = annotateMessagesForLLM([tm], registry, 'r1');
347
+ expect(out[0].content).toBe('[ref: tool0turn0]\noutput');
348
+ });
349
+
350
+ it('coerces a non-array _unresolvedRefs to empty without throwing', () => {
351
+ /**
352
+ * Defensive against malformed hydrated messages:
353
+ * `additional_kwargs._unresolvedRefs` is untyped at the LangChain
354
+ * layer, so a persisted message could carry a string/object/null
355
+ * by mistake. The transform must not crash the run on
356
+ * `.length` / `.join` — coerce to an empty list, strip the
357
+ * malformed key, and proceed.
358
+ */
359
+ const registry = new ToolOutputReferenceRegistry();
360
+ const tm = makeToolMessage({
361
+ content: 'output',
362
+ additional_kwargs: {
363
+ _unresolvedRefs: 'tool9turn9' as unknown as string[],
364
+ },
365
+ });
366
+ const out = annotateMessagesForLLM([tm], registry, 'r1');
367
+ expect(out[0].content).toBe('output');
368
+ expect(
369
+ (out[0] as ToolMessage).additional_kwargs._unresolvedRefs
370
+ ).toBeUndefined();
371
+ });
372
+
373
+ it('filters non-string entries out of _unresolvedRefs', () => {
374
+ const registry = new ToolOutputReferenceRegistry();
375
+ const tm = makeToolMessage({
376
+ content: 'output',
377
+ additional_kwargs: {
378
+ _unresolvedRefs: [
379
+ 'tool9turn9',
380
+ 42,
381
+ null,
382
+ { not: 'a string' },
383
+ 'tool8turn8',
384
+ ] as unknown as string[],
385
+ },
386
+ });
387
+ const out = annotateMessagesForLLM([tm], registry, 'r1');
388
+ expect(out[0].content).toBe(
389
+ 'output\n[unresolved refs: tool9turn9, tool8turn8]'
390
+ );
391
+ });
392
+
393
+ it('ignores a non-string _refKey rather than poisoning the registry lookup', () => {
394
+ const registry = new ToolOutputReferenceRegistry();
395
+ registry.set('r1', 'tool0turn0', 'raw');
396
+ const tm = makeToolMessage({
397
+ content: 'output',
398
+ additional_kwargs: {
399
+ _refKey: { malformed: true } as unknown as string,
400
+ },
401
+ });
402
+ const out = annotateMessagesForLLM([tm], registry, 'r1');
403
+ expect(out[0].content).toBe('output');
404
+ expect((out[0] as ToolMessage).additional_kwargs._refKey).toBeUndefined();
405
+ });
406
+
407
+ it('treats stale _refKey but live unresolved as unresolved-only', () => {
408
+ const registry = new ToolOutputReferenceRegistry();
409
+ const tm = makeToolMessage({
410
+ content: 'output',
411
+ additional_kwargs: {
412
+ _refKey: 'tool0turn0',
413
+ _unresolvedRefs: ['tool9turn9'],
414
+ },
415
+ });
416
+ const out = annotateMessagesForLLM([tm], registry, 'r1');
417
+ expect(out[0].content).toBe('output\n[unresolved refs: tool9turn9]');
418
+ });
419
+ });
@@ -22,6 +22,8 @@
22
22
  * complete, verbatim output with no injected fields.
23
23
  */
24
24
 
25
+ import { ToolMessage } from '@langchain/core/messages';
26
+ import type { BaseMessage } from '@langchain/core/messages';
25
27
  import {
26
28
  calculateMaxTotalToolOutputSize,
27
29
  HARD_MAX_TOOL_RESULT_CHARS,
@@ -243,6 +245,16 @@ export class ToolOutputReferenceRegistry {
243
245
  return this.runStates.get(this.keyFor(runId))?.entries.get(key);
244
246
  }
245
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
+
246
258
  /** Total number of registered outputs across every run bucket. */
247
259
  get size(): number {
248
260
  let n = 0;
@@ -588,3 +600,214 @@ function arraysShallowEqual(a: unknown, b: readonly string[]): boolean {
588
600
  }
589
601
  return true;
590
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
+ }
@@ -1,6 +1,7 @@
1
1
  // src/types/index.ts
2
2
  export * from './graph';
3
3
  export * from './llm';
4
+ export * from './messages';
4
5
  export * from './run';
5
6
  export * from './skill';
6
7
  export * from './stream';
@@ -2,3 +2,30 @@ import type Anthropic from '@anthropic-ai/sdk';
2
2
  import type { BaseMessage } from '@langchain/core/messages';
3
3
  export type AnthropicMessages = Array<AnthropicMessage | BaseMessage>;
4
4
  export type AnthropicMessage = Anthropic.MessageParam;
5
+
6
+ /**
7
+ * Per-message ref metadata stamped onto a `ToolMessage` at execution
8
+ * time. Read by `annotateMessagesForLLM` to apply transient annotation
9
+ * to a copy of the message right before it goes on the wire to the
10
+ * provider. Never read after the run-scoped registry has been cleared.
11
+ *
12
+ * Lives in `ToolMessage.additional_kwargs`. LangChain's provider
13
+ * serializers don't transmit `additional_kwargs` to provider APIs, so
14
+ * the metadata never leaks even if you forget to clean it.
15
+ */
16
+ export interface ToolMessageRefMetadata {
17
+ /** Key under which this message's untruncated output was registered. */
18
+ _refKey?: string;
19
+ /**
20
+ * Registry bucket scope under which `_refKey` was stored. For named
21
+ * runs this equals `config.configurable.run_id`; for anonymous
22
+ * invocations (no `run_id`) ToolNode mints a per-batch synthetic
23
+ * scope (`\0anon-<n>`) so concurrent batches don't collide. Stamping
24
+ * the scope on the message itself lets `annotateMessagesForLLM`
25
+ * recover it without re-deriving from config — which is impossible
26
+ * for the anonymous case, since the scope is internal to ToolNode.
27
+ */
28
+ _refScope?: string;
29
+ /** Placeholders the model used that could not be resolved this batch. */
30
+ _unresolvedRefs?: string[];
31
+ }