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