@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.
- package/dist/cjs/graphs/Graph.cjs +7 -0
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/llm/invoke.cjs +13 -2
- package/dist/cjs/llm/invoke.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +84 -55
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/toolOutputReferences.cjs +182 -0
- package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +7 -0
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/llm/invoke.mjs +13 -2
- package/dist/esm/llm/invoke.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +85 -56
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/toolOutputReferences.mjs +182 -1
- package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
- package/dist/types/graphs/Graph.d.ts +9 -2
- package/dist/types/llm/invoke.d.ts +9 -0
- package/dist/types/tools/ToolNode.d.ts +11 -13
- package/dist/types/tools/toolOutputReferences.d.ts +31 -0
- package/dist/types/types/index.d.ts +1 -0
- package/dist/types/types/messages.d.ts +26 -0
- package/package.json +1 -1
- package/src/graphs/Graph.ts +8 -1
- package/src/llm/invoke.test.ts +442 -0
- package/src/llm/invoke.ts +23 -2
- package/src/tools/ToolNode.ts +94 -81
- package/src/tools/__tests__/ToolNode.outputReferences.test.ts +98 -55
- package/src/tools/__tests__/annotateMessagesForLLM.test.ts +419 -0
- package/src/tools/toolOutputReferences.ts +223 -0
- package/src/types/index.ts +1 -0
- package/src/types/messages.ts +27 -0
|
@@ -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
|
+
}
|
package/src/types/index.ts
CHANGED
package/src/types/messages.ts
CHANGED
|
@@ -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
|
+
}
|