@librechat/agents 3.1.71-dev.0 → 3.1.71
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/BashExecutor.cjs +3 -1
- package/dist/cjs/tools/BashExecutor.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 +195 -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/BashExecutor.mjs +3 -1
- package/dist/esm/tools/BashExecutor.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 +195 -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 +29 -3
- 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 +446 -0
- package/src/llm/invoke.ts +45 -5
- package/src/tools/BashExecutor.ts +3 -1
- package/src/tools/ToolNode.ts +94 -81
- package/src/tools/__tests__/BashExecutor.test.ts +13 -0
- package/src/tools/__tests__/ToolNode.outputReferences.test.ts +98 -55
- package/src/tools/__tests__/annotateMessagesForLLM.test.ts +479 -0
- package/src/tools/toolOutputReferences.ts +235 -0
- package/src/types/index.ts +1 -0
- package/src/types/messages.ts +27 -0
|
@@ -0,0 +1,479 @@
|
|
|
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
|
+
artifact?: unknown;
|
|
16
|
+
additional_kwargs?: Record<string, unknown>;
|
|
17
|
+
}): ToolMessage {
|
|
18
|
+
return new ToolMessage({
|
|
19
|
+
name: fields.name ?? 'echo',
|
|
20
|
+
tool_call_id: fields.tool_call_id ?? 'tc1',
|
|
21
|
+
status: fields.status ?? 'success',
|
|
22
|
+
artifact: fields.artifact,
|
|
23
|
+
additional_kwargs: fields.additional_kwargs,
|
|
24
|
+
content: fields.content,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('annotateMessagesForLLM', () => {
|
|
29
|
+
it('returns the input array reference when registry is undefined', () => {
|
|
30
|
+
const messages = [
|
|
31
|
+
new HumanMessage('hi'),
|
|
32
|
+
makeToolMessage({
|
|
33
|
+
content: 'data',
|
|
34
|
+
additional_kwargs: { _refKey: 'tool0turn0' },
|
|
35
|
+
}),
|
|
36
|
+
];
|
|
37
|
+
const out = annotateMessagesForLLM(messages, undefined, 'r1');
|
|
38
|
+
expect(out).toBe(messages);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('does not iterate any message when the feature is disabled (registry undefined)', () => {
|
|
42
|
+
/**
|
|
43
|
+
* Hard guarantee: when the host hasn't enabled
|
|
44
|
+
* `RunConfig.toolOutputReferences`, calling
|
|
45
|
+
* `annotateMessagesForLLM` must short-circuit at O(1) without
|
|
46
|
+
* touching a single ToolMessage. We assert by spying on
|
|
47
|
+
* `_getType` — the first per-message call inside the loop — and
|
|
48
|
+
* confirming it was never invoked.
|
|
49
|
+
*/
|
|
50
|
+
const messages = [
|
|
51
|
+
makeToolMessage({ content: 'a' }),
|
|
52
|
+
makeToolMessage({ content: 'b' }),
|
|
53
|
+
makeToolMessage({
|
|
54
|
+
content: 'c',
|
|
55
|
+
additional_kwargs: { _refKey: 'tool0turn0' },
|
|
56
|
+
}),
|
|
57
|
+
];
|
|
58
|
+
const spies = messages.map((m) => jest.spyOn(m, '_getType'));
|
|
59
|
+
const out = annotateMessagesForLLM(messages, undefined, undefined);
|
|
60
|
+
expect(out).toBe(messages);
|
|
61
|
+
for (const spy of spies) {
|
|
62
|
+
expect(spy).not.toHaveBeenCalled();
|
|
63
|
+
spy.mockRestore();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('returns the input array reference when no ToolMessage carries metadata', () => {
|
|
68
|
+
const registry = new ToolOutputReferenceRegistry();
|
|
69
|
+
registry.set('r1', 'tool0turn0', 'stored');
|
|
70
|
+
const messages = [
|
|
71
|
+
new HumanMessage('hi'),
|
|
72
|
+
makeToolMessage({ content: 'data' }),
|
|
73
|
+
new AIMessage('answer'),
|
|
74
|
+
];
|
|
75
|
+
const out = annotateMessagesForLLM(messages, registry, 'r1');
|
|
76
|
+
expect(out).toBe(messages);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('annotates string content when _refKey is live in the registry', () => {
|
|
80
|
+
const registry = new ToolOutputReferenceRegistry();
|
|
81
|
+
registry.set('r1', 'tool0turn0', 'stored-raw');
|
|
82
|
+
const tm = makeToolMessage({
|
|
83
|
+
content: 'output',
|
|
84
|
+
additional_kwargs: { _refKey: 'tool0turn0' },
|
|
85
|
+
});
|
|
86
|
+
const out = annotateMessagesForLLM([tm], registry, 'r1');
|
|
87
|
+
expect(out[0].content).toBe('[ref: tool0turn0]\noutput');
|
|
88
|
+
expect(tm.content).toBe('output');
|
|
89
|
+
expect(out).not.toBe([tm]);
|
|
90
|
+
expect(out[0]).not.toBe(tm);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('leaves content untouched but strips framework metadata when _refKey is stale', () => {
|
|
94
|
+
/**
|
|
95
|
+
* Stale `_refKey` (not in registry) doesn't trigger annotation,
|
|
96
|
+
* but the message still gets projected so framework keys are
|
|
97
|
+
* removed from `additional_kwargs` before the bytes leave for the
|
|
98
|
+
* provider. This protects against custom or future serializers
|
|
99
|
+
* that transmit `additional_kwargs`.
|
|
100
|
+
*/
|
|
101
|
+
const registry = new ToolOutputReferenceRegistry();
|
|
102
|
+
const tm = makeToolMessage({
|
|
103
|
+
content: 'output',
|
|
104
|
+
additional_kwargs: { _refKey: 'tool0turn0', userField: 'preserved' },
|
|
105
|
+
});
|
|
106
|
+
const out = annotateMessagesForLLM([tm], registry, 'r1');
|
|
107
|
+
expect(out[0].content).toBe('output');
|
|
108
|
+
expect(out[0]).not.toBe(tm);
|
|
109
|
+
const projectedKwargs = (out[0] as ToolMessage).additional_kwargs;
|
|
110
|
+
expect(projectedKwargs._refKey).toBeUndefined();
|
|
111
|
+
expect(projectedKwargs.userField).toBe('preserved');
|
|
112
|
+
expect(tm.additional_kwargs._refKey).toBe('tool0turn0');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('always applies _unresolvedRefs even when there is no registry entry', () => {
|
|
116
|
+
const registry = new ToolOutputReferenceRegistry();
|
|
117
|
+
const tm = makeToolMessage({
|
|
118
|
+
content: 'output',
|
|
119
|
+
additional_kwargs: { _unresolvedRefs: ['tool9turn9'] },
|
|
120
|
+
});
|
|
121
|
+
const out = annotateMessagesForLLM([tm], registry, 'r1');
|
|
122
|
+
expect(out[0].content).toBe('output\n[unresolved refs: tool9turn9]');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('injects _ref into JSON-object string content', () => {
|
|
126
|
+
const registry = new ToolOutputReferenceRegistry();
|
|
127
|
+
registry.set('r1', 'tool0turn0', '{"a":1}');
|
|
128
|
+
const tm = makeToolMessage({
|
|
129
|
+
content: '{"a":1,"b":"x"}',
|
|
130
|
+
additional_kwargs: { _refKey: 'tool0turn0' },
|
|
131
|
+
});
|
|
132
|
+
const out = annotateMessagesForLLM([tm], registry, 'r1');
|
|
133
|
+
const parsed = JSON.parse(out[0].content as string);
|
|
134
|
+
expect(parsed[TOOL_OUTPUT_REF_KEY]).toBe('tool0turn0');
|
|
135
|
+
expect(parsed.a).toBe(1);
|
|
136
|
+
expect(parsed.b).toBe('x');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('injects both _ref and _unresolved_refs into JSON-object content', () => {
|
|
140
|
+
/**
|
|
141
|
+
* Combined path: when a ToolMessage carries both a live `_refKey`
|
|
142
|
+
* and unresolved-ref hints, JSON-object content should receive
|
|
143
|
+
* both `_ref` and `_unresolved_refs` fields rather than falling
|
|
144
|
+
* back to the prefix/trailer form. Exercises
|
|
145
|
+
* `annotateToolOutputWithReference`'s collision-detection logic
|
|
146
|
+
* through the projection entry point.
|
|
147
|
+
*/
|
|
148
|
+
const registry = new ToolOutputReferenceRegistry();
|
|
149
|
+
registry.set('r1', 'tool0turn0', '{"a":1}');
|
|
150
|
+
const tm = makeToolMessage({
|
|
151
|
+
content: '{"a":1,"b":"x"}',
|
|
152
|
+
additional_kwargs: {
|
|
153
|
+
_refKey: 'tool0turn0',
|
|
154
|
+
_unresolvedRefs: ['tool9turn9'],
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
const out = annotateMessagesForLLM([tm], registry, 'r1');
|
|
158
|
+
const parsed = JSON.parse(out[0].content as string);
|
|
159
|
+
expect(parsed[TOOL_OUTPUT_REF_KEY]).toBe('tool0turn0');
|
|
160
|
+
expect(parsed[TOOL_OUTPUT_UNRESOLVED_KEY]).toEqual(['tool9turn9']);
|
|
161
|
+
expect(parsed.a).toBe(1);
|
|
162
|
+
expect(parsed.b).toBe('x');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('uses [ref: …] prefix for non-JSON string content', () => {
|
|
166
|
+
const registry = new ToolOutputReferenceRegistry();
|
|
167
|
+
registry.set('r1', 'tool0turn0', 'raw');
|
|
168
|
+
const tm = makeToolMessage({
|
|
169
|
+
content: 'plain output',
|
|
170
|
+
additional_kwargs: { _refKey: 'tool0turn0' },
|
|
171
|
+
});
|
|
172
|
+
const out = annotateMessagesForLLM([tm], registry, 'r1');
|
|
173
|
+
expect(out[0].content).toBe('[ref: tool0turn0]\nplain output');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('prepends an unresolved-refs warning text block to multi-part content', () => {
|
|
177
|
+
const registry = new ToolOutputReferenceRegistry();
|
|
178
|
+
const tm = makeToolMessage({
|
|
179
|
+
content: [
|
|
180
|
+
{ type: 'text', text: 'data' },
|
|
181
|
+
{ type: 'image_url', image_url: { url: 'data:...' } },
|
|
182
|
+
] as unknown as ToolMessage['content'],
|
|
183
|
+
additional_kwargs: { _unresolvedRefs: ['tool9turn9'] },
|
|
184
|
+
});
|
|
185
|
+
const out = annotateMessagesForLLM([tm], registry, 'r1');
|
|
186
|
+
const blocks = out[0].content as Array<{ type: string; text?: string }>;
|
|
187
|
+
expect(blocks).toHaveLength(3);
|
|
188
|
+
expect(blocks[0].type).toBe('text');
|
|
189
|
+
expect(blocks[0].text).toBe('[unresolved refs: tool9turn9]');
|
|
190
|
+
expect(blocks[1].type).toBe('text');
|
|
191
|
+
expect(blocks[1].text).toBe('data');
|
|
192
|
+
expect(blocks[2].type).toBe('image_url');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('preserves artifact on the projected ToolMessage', () => {
|
|
196
|
+
/**
|
|
197
|
+
* Hosts attach `artifact` to ToolMessages via the
|
|
198
|
+
* `content_and_artifact` response format (e.g. code execution
|
|
199
|
+
* sessions, MCP tools that return structured side-data). The
|
|
200
|
+
* projection must round-trip the artifact untouched so downstream
|
|
201
|
+
* consumers (audit logs, code-session tracking) keep working.
|
|
202
|
+
*/
|
|
203
|
+
const registry = new ToolOutputReferenceRegistry();
|
|
204
|
+
registry.set('r1', 'tool0turn0', 'raw');
|
|
205
|
+
const artifact = {
|
|
206
|
+
session_id: 'abc',
|
|
207
|
+
files: [{ id: 'f1', name: 'a.txt' }],
|
|
208
|
+
};
|
|
209
|
+
const tm = makeToolMessage({
|
|
210
|
+
content: 'output',
|
|
211
|
+
artifact,
|
|
212
|
+
additional_kwargs: { _refKey: 'tool0turn0' },
|
|
213
|
+
});
|
|
214
|
+
const out = annotateMessagesForLLM([tm], registry, 'r1');
|
|
215
|
+
const projected = out[0] as ToolMessage;
|
|
216
|
+
expect(projected.artifact).toBe(artifact);
|
|
217
|
+
expect(projected.content).toBe('[ref: tool0turn0]\noutput');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('does not mutate the original ToolMessage instance or its content', () => {
|
|
221
|
+
const registry = new ToolOutputReferenceRegistry();
|
|
222
|
+
registry.set('r1', 'tool0turn0', 'raw');
|
|
223
|
+
const tm = makeToolMessage({
|
|
224
|
+
content: 'output',
|
|
225
|
+
additional_kwargs: { _refKey: 'tool0turn0' },
|
|
226
|
+
});
|
|
227
|
+
const originalContent = tm.content;
|
|
228
|
+
const originalKwargs = { ...tm.additional_kwargs };
|
|
229
|
+
annotateMessagesForLLM([tm], registry, 'r1');
|
|
230
|
+
expect(tm.content).toBe(originalContent);
|
|
231
|
+
expect(tm.additional_kwargs).toEqual(originalKwargs);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('strips framework ref metadata from the projected additional_kwargs but preserves other fields', () => {
|
|
235
|
+
/**
|
|
236
|
+
* Defensive: even though LangChain's standard provider serializers
|
|
237
|
+
* do not transmit `additional_kwargs`, a custom adapter or future
|
|
238
|
+
* LangChain change could. Strip our three framework-owned keys on
|
|
239
|
+
* the projection so the metadata never reaches the wire under any
|
|
240
|
+
* serializer behavior. Non-framework fields stay put.
|
|
241
|
+
*/
|
|
242
|
+
const registry = new ToolOutputReferenceRegistry();
|
|
243
|
+
registry.set('r1', 'tool0turn0', 'raw');
|
|
244
|
+
const tm = makeToolMessage({
|
|
245
|
+
content: 'output',
|
|
246
|
+
additional_kwargs: {
|
|
247
|
+
_refKey: 'tool0turn0',
|
|
248
|
+
_refScope: 'r1',
|
|
249
|
+
_unresolvedRefs: ['tool9turn9'],
|
|
250
|
+
someOtherField: 'preserved',
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
const out = annotateMessagesForLLM([tm], registry, 'r1');
|
|
254
|
+
const projected = out[0] as ToolMessage;
|
|
255
|
+
expect(projected.additional_kwargs._refKey).toBeUndefined();
|
|
256
|
+
expect(projected.additional_kwargs._refScope).toBeUndefined();
|
|
257
|
+
expect(projected.additional_kwargs._unresolvedRefs).toBeUndefined();
|
|
258
|
+
expect(projected.additional_kwargs.someOtherField).toBe('preserved');
|
|
259
|
+
expect(tm.additional_kwargs._refKey).toBe('tool0turn0');
|
|
260
|
+
expect(tm.additional_kwargs._refScope).toBe('r1');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('leaves additional_kwargs empty when stripping removed every framework key', () => {
|
|
264
|
+
/**
|
|
265
|
+
* `stripFrameworkRefMetadata` returns `undefined` when no non-
|
|
266
|
+
* framework keys remain, and the LangChain `ToolMessage`
|
|
267
|
+
* constructor normalizes that to `{}` — so the projected message
|
|
268
|
+
* exposes an empty object, not the original kwargs.
|
|
269
|
+
*/
|
|
270
|
+
const registry = new ToolOutputReferenceRegistry();
|
|
271
|
+
registry.set('r1', 'tool0turn0', 'raw');
|
|
272
|
+
const tm = makeToolMessage({
|
|
273
|
+
content: 'output',
|
|
274
|
+
additional_kwargs: {
|
|
275
|
+
_refKey: 'tool0turn0',
|
|
276
|
+
_refScope: 'r1',
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
const out = annotateMessagesForLLM([tm], registry, 'r1');
|
|
280
|
+
const projected = out[0] as ToolMessage;
|
|
281
|
+
expect(projected.additional_kwargs).toEqual({});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('passes through non-ToolMessages unchanged in the projected array', () => {
|
|
285
|
+
const registry = new ToolOutputReferenceRegistry();
|
|
286
|
+
registry.set('r1', 'tool0turn0', 'raw');
|
|
287
|
+
const human = new HumanMessage('hi');
|
|
288
|
+
const ai = new AIMessage('answer');
|
|
289
|
+
const tm = makeToolMessage({
|
|
290
|
+
content: 'output',
|
|
291
|
+
additional_kwargs: { _refKey: 'tool0turn0' },
|
|
292
|
+
});
|
|
293
|
+
const out = annotateMessagesForLLM([human, ai, tm], registry, 'r1');
|
|
294
|
+
expect(out[0]).toBe(human);
|
|
295
|
+
expect(out[1]).toBe(ai);
|
|
296
|
+
expect(out[2]).not.toBe(tm);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('projects (to strip metadata) but does not annotate when only stale _refKey is present', () => {
|
|
300
|
+
const registry = new ToolOutputReferenceRegistry();
|
|
301
|
+
registry.set('r1', 'tool1turn0', 'somethingelse');
|
|
302
|
+
const tm = makeToolMessage({
|
|
303
|
+
content: 'output',
|
|
304
|
+
additional_kwargs: { _refKey: 'tool0turn0' },
|
|
305
|
+
});
|
|
306
|
+
const messages = [tm];
|
|
307
|
+
const out = annotateMessagesForLLM(messages, registry, 'r1');
|
|
308
|
+
expect(out).not.toBe(messages);
|
|
309
|
+
expect(out[0].content).toBe('output');
|
|
310
|
+
expect((out[0] as ToolMessage).additional_kwargs._refKey).toBeUndefined();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('returns the input array reference-equal when no ToolMessage carries any framework metadata', () => {
|
|
314
|
+
const registry = new ToolOutputReferenceRegistry();
|
|
315
|
+
registry.set('r1', 'tool0turn0', 'stored');
|
|
316
|
+
const messages = [
|
|
317
|
+
new HumanMessage('hi'),
|
|
318
|
+
makeToolMessage({
|
|
319
|
+
content: 'output',
|
|
320
|
+
additional_kwargs: { unrelated: 'value' },
|
|
321
|
+
}),
|
|
322
|
+
new AIMessage('answer'),
|
|
323
|
+
];
|
|
324
|
+
const out = annotateMessagesForLLM(messages, registry, 'r1');
|
|
325
|
+
expect(out).toBe(messages);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('annotates only the live ref when both ref and unresolved are present', () => {
|
|
329
|
+
const registry = new ToolOutputReferenceRegistry();
|
|
330
|
+
registry.set('r1', 'tool0turn0', 'raw');
|
|
331
|
+
const tm = makeToolMessage({
|
|
332
|
+
content: 'output',
|
|
333
|
+
additional_kwargs: {
|
|
334
|
+
_refKey: 'tool0turn0',
|
|
335
|
+
_unresolvedRefs: ['tool9turn9'],
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
const out = annotateMessagesForLLM([tm], registry, 'r1');
|
|
339
|
+
expect(out[0].content).toBe(
|
|
340
|
+
'[ref: tool0turn0]\noutput\n[unresolved refs: tool9turn9]'
|
|
341
|
+
);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('uses _refScope for the registry lookup when present, ignoring runId', () => {
|
|
345
|
+
/**
|
|
346
|
+
* Anonymous ToolNode batches register under a synthetic scope
|
|
347
|
+
* (`\0anon-<n>`) that `config.configurable.run_id` cannot recover.
|
|
348
|
+
* The transform must follow the message-stamped `_refScope`
|
|
349
|
+
* instead of the config-derived runId.
|
|
350
|
+
*/
|
|
351
|
+
const registry = new ToolOutputReferenceRegistry();
|
|
352
|
+
const anonScope = '\0anon-3';
|
|
353
|
+
registry.set(anonScope, 'tool0turn0', 'raw');
|
|
354
|
+
|
|
355
|
+
const tm = makeToolMessage({
|
|
356
|
+
content: 'output',
|
|
357
|
+
additional_kwargs: {
|
|
358
|
+
_refKey: 'tool0turn0',
|
|
359
|
+
_refScope: anonScope,
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
const out = annotateMessagesForLLM([tm], registry, undefined);
|
|
363
|
+
expect(out[0].content).toBe('[ref: tool0turn0]\noutput');
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('falls back to runId when _refScope is absent (legacy / pre-scope messages)', () => {
|
|
367
|
+
const registry = new ToolOutputReferenceRegistry();
|
|
368
|
+
registry.set('r1', 'tool0turn0', 'raw');
|
|
369
|
+
const tm = makeToolMessage({
|
|
370
|
+
content: 'output',
|
|
371
|
+
additional_kwargs: { _refKey: 'tool0turn0' },
|
|
372
|
+
});
|
|
373
|
+
const out = annotateMessagesForLLM([tm], registry, 'r1');
|
|
374
|
+
expect(out[0].content).toBe('[ref: tool0turn0]\noutput');
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('coerces a non-array _unresolvedRefs to empty without throwing', () => {
|
|
378
|
+
/**
|
|
379
|
+
* Defensive against malformed hydrated messages:
|
|
380
|
+
* `additional_kwargs._unresolvedRefs` is untyped at the LangChain
|
|
381
|
+
* layer, so a persisted message could carry a string/object/null
|
|
382
|
+
* by mistake. The transform must not crash the run on
|
|
383
|
+
* `.length` / `.join` — coerce to an empty list, strip the
|
|
384
|
+
* malformed key, and proceed.
|
|
385
|
+
*/
|
|
386
|
+
const registry = new ToolOutputReferenceRegistry();
|
|
387
|
+
const tm = makeToolMessage({
|
|
388
|
+
content: 'output',
|
|
389
|
+
additional_kwargs: {
|
|
390
|
+
_unresolvedRefs: 'tool9turn9' as unknown as string[],
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
const out = annotateMessagesForLLM([tm], registry, 'r1');
|
|
394
|
+
expect(out[0].content).toBe('output');
|
|
395
|
+
expect(
|
|
396
|
+
(out[0] as ToolMessage).additional_kwargs._unresolvedRefs
|
|
397
|
+
).toBeUndefined();
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('filters non-string entries out of _unresolvedRefs', () => {
|
|
401
|
+
const registry = new ToolOutputReferenceRegistry();
|
|
402
|
+
const tm = makeToolMessage({
|
|
403
|
+
content: 'output',
|
|
404
|
+
additional_kwargs: {
|
|
405
|
+
_unresolvedRefs: [
|
|
406
|
+
'tool9turn9',
|
|
407
|
+
42,
|
|
408
|
+
null,
|
|
409
|
+
{ not: 'a string' },
|
|
410
|
+
'tool8turn8',
|
|
411
|
+
] as unknown as string[],
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
const out = annotateMessagesForLLM([tm], registry, 'r1');
|
|
415
|
+
expect(out[0].content).toBe(
|
|
416
|
+
'output\n[unresolved refs: tool9turn9, tool8turn8]'
|
|
417
|
+
);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('ignores a non-string _refKey rather than poisoning the registry lookup', () => {
|
|
421
|
+
const registry = new ToolOutputReferenceRegistry();
|
|
422
|
+
registry.set('r1', 'tool0turn0', 'raw');
|
|
423
|
+
const tm = makeToolMessage({
|
|
424
|
+
content: 'output',
|
|
425
|
+
additional_kwargs: {
|
|
426
|
+
_refKey: { malformed: true } as unknown as string,
|
|
427
|
+
},
|
|
428
|
+
});
|
|
429
|
+
const out = annotateMessagesForLLM([tm], registry, 'r1');
|
|
430
|
+
expect(out[0].content).toBe('output');
|
|
431
|
+
expect((out[0] as ToolMessage).additional_kwargs._refKey).toBeUndefined();
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it('skips a ToolMessage whose additional_kwargs is a primitive without throwing', () => {
|
|
435
|
+
/**
|
|
436
|
+
* The `in` operator throws `TypeError` on primitives, so without
|
|
437
|
+
* a runtime object guard, a hydrated ToolMessage carrying e.g.
|
|
438
|
+
* `additional_kwargs: 'not-an-object'` (from a buggy serializer)
|
|
439
|
+
* would crash `attemptInvoke` before the provider call. Verify
|
|
440
|
+
* we skip that message and process subsequent live-ref messages
|
|
441
|
+
* normally.
|
|
442
|
+
*/
|
|
443
|
+
const registry = new ToolOutputReferenceRegistry();
|
|
444
|
+
registry.set('r1', 'tool0turn0', 'raw');
|
|
445
|
+
|
|
446
|
+
const malformed = new ToolMessage({
|
|
447
|
+
name: 'echo',
|
|
448
|
+
tool_call_id: 'mal',
|
|
449
|
+
status: 'success',
|
|
450
|
+
content: 'malformed-output',
|
|
451
|
+
});
|
|
452
|
+
/* Force a primitive past LangChain's typed setter via a cast. */
|
|
453
|
+
(malformed as unknown as { additional_kwargs: unknown }).additional_kwargs =
|
|
454
|
+
'not-an-object' as unknown;
|
|
455
|
+
|
|
456
|
+
const live = makeToolMessage({
|
|
457
|
+
content: 'live-output',
|
|
458
|
+
additional_kwargs: { _refKey: 'tool0turn0' },
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
const out = annotateMessagesForLLM([malformed, live], registry, 'r1');
|
|
462
|
+
/* Malformed message passes through unchanged; live ref still annotates. */
|
|
463
|
+
expect(out[0]).toBe(malformed);
|
|
464
|
+
expect(out[1].content).toBe('[ref: tool0turn0]\nlive-output');
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('treats stale _refKey but live unresolved as unresolved-only', () => {
|
|
468
|
+
const registry = new ToolOutputReferenceRegistry();
|
|
469
|
+
const tm = makeToolMessage({
|
|
470
|
+
content: 'output',
|
|
471
|
+
additional_kwargs: {
|
|
472
|
+
_refKey: 'tool0turn0',
|
|
473
|
+
_unresolvedRefs: ['tool9turn9'],
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
const out = annotateMessagesForLLM([tm], registry, 'r1');
|
|
477
|
+
expect(out[0].content).toBe('output\n[unresolved refs: tool9turn9]');
|
|
478
|
+
});
|
|
479
|
+
});
|