@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,442 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { tool } from '@langchain/core/tools';
|
|
3
|
+
import {
|
|
4
|
+
AIMessage,
|
|
5
|
+
AIMessageChunk,
|
|
6
|
+
HumanMessage,
|
|
7
|
+
ToolMessage,
|
|
8
|
+
} from '@langchain/core/messages';
|
|
9
|
+
import { describe, it, expect, jest } from '@jest/globals';
|
|
10
|
+
import type { BaseMessage } from '@langchain/core/messages';
|
|
11
|
+
import type { StructuredToolInterface } from '@langchain/core/tools';
|
|
12
|
+
import type * as t from '@/types';
|
|
13
|
+
import { attemptInvoke, tryFallbackProviders } from '@/llm/invoke';
|
|
14
|
+
import { ToolOutputReferenceRegistry } from '@/tools/toolOutputReferences';
|
|
15
|
+
import { ToolNode } from '@/tools/ToolNode';
|
|
16
|
+
import { Providers } from '@/common';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Minimal stub model shape `attemptInvoke` reads. Either `invoke` or
|
|
20
|
+
* `stream` is populated depending on which path the test exercises;
|
|
21
|
+
* extending the real `BaseChatModel` would pull in too much surface.
|
|
22
|
+
*/
|
|
23
|
+
type StubModel = {
|
|
24
|
+
invoke?: (messages: BaseMessage[], config?: unknown) => Promise<AIMessage>;
|
|
25
|
+
stream?: (
|
|
26
|
+
messages: BaseMessage[],
|
|
27
|
+
config?: unknown
|
|
28
|
+
) => AsyncGenerator<AIMessageChunk>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function buildCapturingModel(): {
|
|
32
|
+
invokeMessages: BaseMessage[][];
|
|
33
|
+
model: StubModel;
|
|
34
|
+
} {
|
|
35
|
+
const invokeMessages: BaseMessage[][] = [];
|
|
36
|
+
const responseMsg = new AIMessage({ content: 'ok' });
|
|
37
|
+
const model: StubModel = {
|
|
38
|
+
invoke: jest.fn(async (messages: BaseMessage[]): Promise<AIMessage> => {
|
|
39
|
+
invokeMessages.push(messages);
|
|
40
|
+
return responseMsg;
|
|
41
|
+
}),
|
|
42
|
+
};
|
|
43
|
+
return { invokeMessages, model };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function buildStreamingCapturingModel(): {
|
|
47
|
+
streamMessages: BaseMessage[][];
|
|
48
|
+
model: StubModel;
|
|
49
|
+
} {
|
|
50
|
+
const streamMessages: BaseMessage[][] = [];
|
|
51
|
+
const model: StubModel = {
|
|
52
|
+
stream: jest.fn(async function* (
|
|
53
|
+
messages: BaseMessage[]
|
|
54
|
+
): AsyncGenerator<AIMessageChunk> {
|
|
55
|
+
streamMessages.push(messages);
|
|
56
|
+
yield new AIMessageChunk({ content: 'ok' });
|
|
57
|
+
}),
|
|
58
|
+
};
|
|
59
|
+
return { streamMessages, model };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('attemptInvoke applies lazy ref annotation', () => {
|
|
63
|
+
it('annotates ToolMessages with live _refKey before sending to provider (non-streaming)', async () => {
|
|
64
|
+
const registry = new ToolOutputReferenceRegistry();
|
|
65
|
+
registry.set('run-1', 'tool0turn0', 'stored');
|
|
66
|
+
const context = {
|
|
67
|
+
getOrCreateToolOutputRegistry: () => registry,
|
|
68
|
+
} as unknown as Parameters<typeof attemptInvoke>[0]['context'];
|
|
69
|
+
|
|
70
|
+
const messages: BaseMessage[] = [
|
|
71
|
+
new HumanMessage('hi'),
|
|
72
|
+
new ToolMessage({
|
|
73
|
+
name: 'echo',
|
|
74
|
+
tool_call_id: 'tc1',
|
|
75
|
+
status: 'success',
|
|
76
|
+
content: 'output',
|
|
77
|
+
additional_kwargs: { _refKey: 'tool0turn0' },
|
|
78
|
+
}),
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
const { invokeMessages, model } = buildCapturingModel();
|
|
82
|
+
|
|
83
|
+
await attemptInvoke(
|
|
84
|
+
{
|
|
85
|
+
model: model as t.ChatModel,
|
|
86
|
+
messages,
|
|
87
|
+
provider: Providers.ANTHROPIC,
|
|
88
|
+
context,
|
|
89
|
+
},
|
|
90
|
+
{ configurable: { run_id: 'run-1' } }
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
expect(invokeMessages).toHaveLength(1);
|
|
94
|
+
const sent = invokeMessages[0];
|
|
95
|
+
expect(sent[1].content).toBe('[ref: tool0turn0]\noutput');
|
|
96
|
+
|
|
97
|
+
const original = messages[1] as ToolMessage;
|
|
98
|
+
expect(original.content).toBe('output');
|
|
99
|
+
expect(original.additional_kwargs._refKey).toBe('tool0turn0');
|
|
100
|
+
expect(messages[1]).not.toBe(sent[1]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('annotates messages passed to model.stream (streaming path)', async () => {
|
|
104
|
+
const registry = new ToolOutputReferenceRegistry();
|
|
105
|
+
registry.set('run-2', 'tool0turn0', 'stored');
|
|
106
|
+
const context = {
|
|
107
|
+
getOrCreateToolOutputRegistry: () => registry,
|
|
108
|
+
} as unknown as Parameters<typeof attemptInvoke>[0]['context'];
|
|
109
|
+
|
|
110
|
+
const messages: BaseMessage[] = [
|
|
111
|
+
new ToolMessage({
|
|
112
|
+
name: 'echo',
|
|
113
|
+
tool_call_id: 'tc1',
|
|
114
|
+
status: 'success',
|
|
115
|
+
content: 'output',
|
|
116
|
+
additional_kwargs: { _refKey: 'tool0turn0' },
|
|
117
|
+
}),
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
const { streamMessages, model } = buildStreamingCapturingModel();
|
|
121
|
+
|
|
122
|
+
await attemptInvoke(
|
|
123
|
+
{
|
|
124
|
+
model: model as t.ChatModel,
|
|
125
|
+
messages,
|
|
126
|
+
provider: Providers.ANTHROPIC,
|
|
127
|
+
context,
|
|
128
|
+
onChunk: () => {
|
|
129
|
+
/* swallow */
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
{ configurable: { run_id: 'run-2' } }
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
expect(streamMessages).toHaveLength(1);
|
|
136
|
+
expect(streamMessages[0][0].content).toBe('[ref: tool0turn0]\noutput');
|
|
137
|
+
expect(messages[0].content).toBe('output');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('passes messages unchanged when no registry is exposed on context (e.g. summarization)', async () => {
|
|
141
|
+
const messages: BaseMessage[] = [
|
|
142
|
+
new ToolMessage({
|
|
143
|
+
name: 'echo',
|
|
144
|
+
tool_call_id: 'tc1',
|
|
145
|
+
status: 'success',
|
|
146
|
+
content: 'output',
|
|
147
|
+
additional_kwargs: { _refKey: 'tool0turn0' },
|
|
148
|
+
}),
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
const { invokeMessages, model } = buildCapturingModel();
|
|
152
|
+
|
|
153
|
+
await attemptInvoke({
|
|
154
|
+
model: model as t.ChatModel,
|
|
155
|
+
messages,
|
|
156
|
+
provider: Providers.ANTHROPIC,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
expect(invokeMessages).toHaveLength(1);
|
|
160
|
+
expect(invokeMessages[0][0].content).toBe('output');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('skips annotation for stale _refKey not present in current run registry (cross-run scenario)', async () => {
|
|
164
|
+
const registry = new ToolOutputReferenceRegistry();
|
|
165
|
+
// run-3 registry holds tool0turn0 - the current run's live ref
|
|
166
|
+
registry.set('run-3', 'tool0turn0', 'live-stored');
|
|
167
|
+
|
|
168
|
+
const context = {
|
|
169
|
+
getOrCreateToolOutputRegistry: () => registry,
|
|
170
|
+
} as unknown as Parameters<typeof attemptInvoke>[0]['context'];
|
|
171
|
+
|
|
172
|
+
const messages: BaseMessage[] = [
|
|
173
|
+
// Stale ToolMessage from a hydrated prior run - its _refKey points
|
|
174
|
+
// at a key that exists in registry, but conceptually different
|
|
175
|
+
// semantics. For this test, use a key that doesn't exist in the
|
|
176
|
+
// current registry to demonstrate the no-op behavior.
|
|
177
|
+
new ToolMessage({
|
|
178
|
+
name: 'echo',
|
|
179
|
+
tool_call_id: 'old',
|
|
180
|
+
status: 'success',
|
|
181
|
+
content: 'old-output',
|
|
182
|
+
additional_kwargs: { _refKey: 'tool5turn5' },
|
|
183
|
+
}),
|
|
184
|
+
new ToolMessage({
|
|
185
|
+
name: 'echo',
|
|
186
|
+
tool_call_id: 'new',
|
|
187
|
+
status: 'success',
|
|
188
|
+
content: 'new-output',
|
|
189
|
+
additional_kwargs: { _refKey: 'tool0turn0' },
|
|
190
|
+
}),
|
|
191
|
+
];
|
|
192
|
+
|
|
193
|
+
const { invokeMessages, model } = buildCapturingModel();
|
|
194
|
+
|
|
195
|
+
await attemptInvoke(
|
|
196
|
+
{
|
|
197
|
+
model: model as t.ChatModel,
|
|
198
|
+
messages,
|
|
199
|
+
provider: Providers.ANTHROPIC,
|
|
200
|
+
context,
|
|
201
|
+
},
|
|
202
|
+
{ configurable: { run_id: 'run-3' } }
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
const sent = invokeMessages[0];
|
|
206
|
+
expect(sent[0].content).toBe('old-output');
|
|
207
|
+
expect(sent[1].content).toBe('[ref: tool0turn0]\nnew-output');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('applies unresolved-refs annotation regardless of registry presence', async () => {
|
|
211
|
+
const registry = new ToolOutputReferenceRegistry();
|
|
212
|
+
const context = {
|
|
213
|
+
getOrCreateToolOutputRegistry: () => registry,
|
|
214
|
+
} as unknown as Parameters<typeof attemptInvoke>[0]['context'];
|
|
215
|
+
|
|
216
|
+
const messages: BaseMessage[] = [
|
|
217
|
+
new ToolMessage({
|
|
218
|
+
name: 'echo',
|
|
219
|
+
tool_call_id: 'tc1',
|
|
220
|
+
status: 'error',
|
|
221
|
+
content: 'Error: bad ref',
|
|
222
|
+
additional_kwargs: { _unresolvedRefs: ['tool9turn9'] },
|
|
223
|
+
}),
|
|
224
|
+
];
|
|
225
|
+
|
|
226
|
+
const { invokeMessages, model } = buildCapturingModel();
|
|
227
|
+
|
|
228
|
+
await attemptInvoke(
|
|
229
|
+
{
|
|
230
|
+
model: model as t.ChatModel,
|
|
231
|
+
messages,
|
|
232
|
+
provider: Providers.ANTHROPIC,
|
|
233
|
+
context,
|
|
234
|
+
},
|
|
235
|
+
{ configurable: { run_id: 'run-err' } }
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
expect(invokeMessages[0][0].content).toBe(
|
|
239
|
+
'Error: bad ref\n[unresolved refs: tool9turn9]'
|
|
240
|
+
);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('annotates refs registered under an anonymous-batch scope (no run_id)', async () => {
|
|
244
|
+
/**
|
|
245
|
+
* Regression: anonymous ToolNode invocations register refs under
|
|
246
|
+
* a synthetic per-batch scope (`\0anon-<n>`) that
|
|
247
|
+
* `config.configurable.run_id` cannot recover. The transform must
|
|
248
|
+
* read the message-stamped `_refScope` rather than relying on the
|
|
249
|
+
* config-derived runId, otherwise the registry lookup misses and
|
|
250
|
+
* the LLM never sees the `[ref: …]` marker.
|
|
251
|
+
*/
|
|
252
|
+
const registry = new ToolOutputReferenceRegistry();
|
|
253
|
+
const anonScope = '\0anon-0';
|
|
254
|
+
registry.set(anonScope, 'tool0turn0', 'stored');
|
|
255
|
+
|
|
256
|
+
const context = {
|
|
257
|
+
getOrCreateToolOutputRegistry: () => registry,
|
|
258
|
+
} as unknown as Parameters<typeof attemptInvoke>[0]['context'];
|
|
259
|
+
|
|
260
|
+
const messages: BaseMessage[] = [
|
|
261
|
+
new ToolMessage({
|
|
262
|
+
name: 'echo',
|
|
263
|
+
tool_call_id: 'tc1',
|
|
264
|
+
status: 'success',
|
|
265
|
+
content: 'output',
|
|
266
|
+
additional_kwargs: {
|
|
267
|
+
_refKey: 'tool0turn0',
|
|
268
|
+
_refScope: anonScope,
|
|
269
|
+
},
|
|
270
|
+
}),
|
|
271
|
+
];
|
|
272
|
+
|
|
273
|
+
const { invokeMessages, model } = buildCapturingModel();
|
|
274
|
+
|
|
275
|
+
await attemptInvoke({
|
|
276
|
+
model: model as t.ChatModel,
|
|
277
|
+
messages,
|
|
278
|
+
provider: Providers.ANTHROPIC,
|
|
279
|
+
context,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
expect(invokeMessages[0][0].content).toBe('[ref: tool0turn0]\noutput');
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe('tryFallbackProviders applies the same lazy annotation transform', () => {
|
|
287
|
+
it('threads context through to attemptInvoke so fallback messages are annotated', async () => {
|
|
288
|
+
const registry = new ToolOutputReferenceRegistry();
|
|
289
|
+
registry.set('run-fb', 'tool0turn0', 'stored');
|
|
290
|
+
const context = {
|
|
291
|
+
getOrCreateToolOutputRegistry: () => registry,
|
|
292
|
+
} as unknown as Parameters<typeof attemptInvoke>[0]['context'];
|
|
293
|
+
|
|
294
|
+
const messages: BaseMessage[] = [
|
|
295
|
+
new ToolMessage({
|
|
296
|
+
name: 'echo',
|
|
297
|
+
tool_call_id: 'tc1',
|
|
298
|
+
status: 'success',
|
|
299
|
+
content: 'output',
|
|
300
|
+
additional_kwargs: { _refKey: 'tool0turn0' },
|
|
301
|
+
}),
|
|
302
|
+
];
|
|
303
|
+
|
|
304
|
+
const { invokeMessages, model } = buildCapturingModel();
|
|
305
|
+
/**
|
|
306
|
+
* Mock `initializeModel` indirectly by stubbing the LLM init via
|
|
307
|
+
* Jest's manual `mock` so the fallback path returns our capturing
|
|
308
|
+
* model. Skipping this here would require pulling in the real
|
|
309
|
+
* provider init chain (Anthropic, etc.) which the rest of this
|
|
310
|
+
* test layer does not bring in.
|
|
311
|
+
*/
|
|
312
|
+
jest.doMock('@/llm/init', () => ({
|
|
313
|
+
initializeModel: (): unknown => model,
|
|
314
|
+
}));
|
|
315
|
+
|
|
316
|
+
// Reset the module so the doMock takes effect.
|
|
317
|
+
jest.resetModules();
|
|
318
|
+
const { tryFallbackProviders: freshTry } = (await import(
|
|
319
|
+
'@/llm/invoke'
|
|
320
|
+
)) as { tryFallbackProviders: typeof tryFallbackProviders };
|
|
321
|
+
|
|
322
|
+
await freshTry({
|
|
323
|
+
fallbacks: [{ provider: Providers.ANTHROPIC }],
|
|
324
|
+
messages,
|
|
325
|
+
primaryError: new Error('primary failed'),
|
|
326
|
+
context,
|
|
327
|
+
config: { configurable: { run_id: 'run-fb' } },
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
expect(invokeMessages.length).toBeGreaterThanOrEqual(1);
|
|
331
|
+
expect(invokeMessages[invokeMessages.length - 1][0].content).toBe(
|
|
332
|
+
'[ref: tool0turn0]\noutput'
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
jest.dontMock('@/llm/init');
|
|
336
|
+
jest.resetModules();
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
describe('cross-run hydration through ToolNode + attemptInvoke', () => {
|
|
341
|
+
it('annotates run 2 refs but leaves hydrated run 1 ToolMessages untouched', async () => {
|
|
342
|
+
/**
|
|
343
|
+
* Smoke test for the headline scenario: ToolMessages produced in
|
|
344
|
+
* run 1 are persisted with clean content + `_refKey`/`_refScope`
|
|
345
|
+
* metadata. When those messages are hydrated into run 2's state
|
|
346
|
+
* and run 2 produces its own tool output, the annotation transform
|
|
347
|
+
* must (a) annotate run 2's fresh tool message because its
|
|
348
|
+
* `_refScope` is live in run 2's registry, and (b) leave run 1's
|
|
349
|
+
* tool message clean because run 1's scope is not in run 2's
|
|
350
|
+
* registry. Same `tool0turn0` key collides across runs without any
|
|
351
|
+
* confusion.
|
|
352
|
+
*/
|
|
353
|
+
const echo = tool(async (input) => (input as { command: string }).command, {
|
|
354
|
+
name: 'echo',
|
|
355
|
+
description: 'echoes its command back',
|
|
356
|
+
schema: z.object({ command: z.string() }),
|
|
357
|
+
}) as unknown as StructuredToolInterface;
|
|
358
|
+
|
|
359
|
+
/* Run 1 */
|
|
360
|
+
const run1Node = new ToolNode({
|
|
361
|
+
tools: [echo],
|
|
362
|
+
toolOutputReferences: { enabled: true },
|
|
363
|
+
});
|
|
364
|
+
const run1Result = (await run1Node.invoke(
|
|
365
|
+
{
|
|
366
|
+
messages: [
|
|
367
|
+
new AIMessage({
|
|
368
|
+
content: '',
|
|
369
|
+
tool_calls: [
|
|
370
|
+
{ id: 'r1c1', name: 'echo', args: { command: 'run-1-output' } },
|
|
371
|
+
],
|
|
372
|
+
}),
|
|
373
|
+
],
|
|
374
|
+
},
|
|
375
|
+
{ configurable: { run_id: 'run-1' } }
|
|
376
|
+
)) as { messages: ToolMessage[] };
|
|
377
|
+
|
|
378
|
+
const run1ToolMsg = run1Result.messages[0];
|
|
379
|
+
expect(run1ToolMsg.content).toBe('run-1-output');
|
|
380
|
+
expect(run1ToolMsg.additional_kwargs._refKey).toBe('tool0turn0');
|
|
381
|
+
expect(run1ToolMsg.additional_kwargs._refScope).toBe('run-1');
|
|
382
|
+
|
|
383
|
+
/* Run 2 - fresh ToolNode and registry, simulating a new session */
|
|
384
|
+
const run2Node = new ToolNode({
|
|
385
|
+
tools: [echo],
|
|
386
|
+
toolOutputReferences: { enabled: true },
|
|
387
|
+
});
|
|
388
|
+
const run2Result = (await run2Node.invoke(
|
|
389
|
+
{
|
|
390
|
+
messages: [
|
|
391
|
+
new AIMessage({
|
|
392
|
+
content: '',
|
|
393
|
+
tool_calls: [
|
|
394
|
+
{ id: 'r2c1', name: 'echo', args: { command: 'run-2-output' } },
|
|
395
|
+
],
|
|
396
|
+
}),
|
|
397
|
+
],
|
|
398
|
+
},
|
|
399
|
+
{ configurable: { run_id: 'run-2' } }
|
|
400
|
+
)) as { messages: ToolMessage[] };
|
|
401
|
+
|
|
402
|
+
const run2ToolMsg = run2Result.messages[0];
|
|
403
|
+
expect(run2ToolMsg.content).toBe('run-2-output');
|
|
404
|
+
expect(run2ToolMsg.additional_kwargs._refKey).toBe('tool0turn0');
|
|
405
|
+
expect(run2ToolMsg.additional_kwargs._refScope).toBe('run-2');
|
|
406
|
+
|
|
407
|
+
/* Hydrate run 1's message + run 2's message into a single state */
|
|
408
|
+
const hydrated: BaseMessage[] = [
|
|
409
|
+
new HumanMessage('first request'),
|
|
410
|
+
run1ToolMsg,
|
|
411
|
+
new HumanMessage('second request'),
|
|
412
|
+
run2ToolMsg,
|
|
413
|
+
];
|
|
414
|
+
|
|
415
|
+
/* attemptInvoke with run 2's registry */
|
|
416
|
+
const context = {
|
|
417
|
+
getOrCreateToolOutputRegistry: () =>
|
|
418
|
+
run2Node._unsafeGetToolOutputRegistry(),
|
|
419
|
+
} as unknown as Parameters<typeof attemptInvoke>[0]['context'];
|
|
420
|
+
|
|
421
|
+
const { invokeMessages, model } = buildCapturingModel();
|
|
422
|
+
await attemptInvoke(
|
|
423
|
+
{
|
|
424
|
+
model: model as t.ChatModel,
|
|
425
|
+
messages: hydrated,
|
|
426
|
+
provider: Providers.ANTHROPIC,
|
|
427
|
+
context,
|
|
428
|
+
},
|
|
429
|
+
{ configurable: { run_id: 'run-2' } }
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
const sent = invokeMessages[0];
|
|
433
|
+
/* Run 1's hydrated tool message stays clean — its scope is stale */
|
|
434
|
+
expect(sent[1].content).toBe('run-1-output');
|
|
435
|
+
/* Run 2's tool message gets annotated — its scope is live */
|
|
436
|
+
expect(sent[3].content).toBe('[ref: tool0turn0]\nrun-2-output');
|
|
437
|
+
|
|
438
|
+
/* Persisted state is unchanged */
|
|
439
|
+
expect(hydrated[1].content).toBe('run-1-output');
|
|
440
|
+
expect(hydrated[3].content).toBe('run-2-output');
|
|
441
|
+
});
|
|
442
|
+
});
|
package/src/llm/invoke.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { ToolCall } from '@langchain/core/messages/tool';
|
|
|
5
5
|
import type { BaseMessage } from '@langchain/core/messages';
|
|
6
6
|
import type * as t from '@/types';
|
|
7
7
|
import { manualToolStreamProviders } from '@/llm/providers';
|
|
8
|
+
import { annotateMessagesForLLM } from '@/tools/toolOutputReferences';
|
|
8
9
|
import { modifyDeltaProperties } from '@/messages';
|
|
9
10
|
import { ChatModelStreamHandler } from '@/stream';
|
|
10
11
|
import { GraphEvents, Providers } from '@/common';
|
|
@@ -13,6 +14,15 @@ import { initializeModel } from '@/llm/init';
|
|
|
13
14
|
/**
|
|
14
15
|
* Context passed to `attemptInvoke` for the default stream handler.
|
|
15
16
|
* Matches the subset of Graph that `ChatModelStreamHandler.handle` needs.
|
|
17
|
+
*
|
|
18
|
+
* `attemptInvoke` additionally calls `context?.getOrCreateToolOutputRegistry?.()`
|
|
19
|
+
* (if defined) to pull the run-scoped tool output registry off the graph
|
|
20
|
+
* so it can project each relevant `ToolMessage` into a transient
|
|
21
|
+
* annotated copy right before sending to the provider — annotations
|
|
22
|
+
* never persist back into graph state.
|
|
23
|
+
*
|
|
24
|
+
* Callers without a registry (e.g. summarization) simply pass no
|
|
25
|
+
* `context` and the transform safely no-ops.
|
|
16
26
|
*/
|
|
17
27
|
export type InvokeContext = Parameters<ChatModelStreamHandler['handle']>[3];
|
|
18
28
|
|
|
@@ -47,8 +57,19 @@ export async function attemptInvoke(
|
|
|
47
57
|
},
|
|
48
58
|
config?: RunnableConfig
|
|
49
59
|
): Promise<Partial<t.BaseGraphState>> {
|
|
60
|
+
/**
|
|
61
|
+
* Pull the run-scoped tool output registry off the graph (when one
|
|
62
|
+
* exists) and project ToolMessages carrying ref metadata into a
|
|
63
|
+
* transient annotated copy. The original `messages` array stays
|
|
64
|
+
* untouched so the graph state never sees `[ref: …]` / `_ref`
|
|
65
|
+
* payload.
|
|
66
|
+
*/
|
|
67
|
+
const registry = context?.getOrCreateToolOutputRegistry();
|
|
68
|
+
const runId = config?.configurable?.run_id as string | undefined;
|
|
69
|
+
const messagesForProvider = annotateMessagesForLLM(messages, registry, runId);
|
|
70
|
+
|
|
50
71
|
if (model.stream) {
|
|
51
|
-
const stream = await model.stream(
|
|
72
|
+
const stream = await model.stream(messagesForProvider, config);
|
|
52
73
|
let finalChunk: AIMessageChunk | undefined;
|
|
53
74
|
|
|
54
75
|
if (onChunk) {
|
|
@@ -83,7 +104,7 @@ export async function attemptInvoke(
|
|
|
83
104
|
return { messages: [finalChunk as AIMessageChunk] };
|
|
84
105
|
}
|
|
85
106
|
|
|
86
|
-
const finalMessage = await model.invoke(
|
|
107
|
+
const finalMessage = await model.invoke(messagesForProvider, config);
|
|
87
108
|
if ((finalMessage.tool_calls?.length ?? 0) > 0) {
|
|
88
109
|
finalMessage.tool_calls = finalMessage.tool_calls?.filter(
|
|
89
110
|
(tool_call: ToolCall) => !!tool_call.name
|