@librechat/agents 3.1.70 → 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 +52 -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/main.cjs +4 -0
- package/dist/cjs/main.cjs.map +1 -1
- package/dist/cjs/messages/prune.cjs +9 -2
- package/dist/cjs/messages/prune.cjs.map +1 -1
- package/dist/cjs/run.cjs +4 -0
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/tools/BashExecutor.cjs +43 -0
- package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +482 -45
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/toolOutputReferences.cjs +657 -0
- package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -0
- package/dist/cjs/utils/truncation.cjs +28 -0
- package/dist/cjs/utils/truncation.cjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +52 -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/main.mjs +2 -2
- package/dist/esm/messages/prune.mjs +9 -2
- package/dist/esm/messages/prune.mjs.map +1 -1
- package/dist/esm/run.mjs +4 -0
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/tools/BashExecutor.mjs +42 -1
- package/dist/esm/tools/BashExecutor.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +482 -45
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/toolOutputReferences.mjs +649 -0
- package/dist/esm/tools/toolOutputReferences.mjs.map +1 -0
- package/dist/esm/utils/truncation.mjs +27 -1
- package/dist/esm/utils/truncation.mjs.map +1 -1
- package/dist/types/graphs/Graph.d.ts +28 -0
- package/dist/types/llm/invoke.d.ts +9 -0
- package/dist/types/run.d.ts +1 -0
- package/dist/types/tools/BashExecutor.d.ts +31 -0
- package/dist/types/tools/ToolNode.d.ts +84 -3
- package/dist/types/tools/toolOutputReferences.d.ts +236 -0
- package/dist/types/types/index.d.ts +1 -0
- package/dist/types/types/messages.d.ts +26 -0
- package/dist/types/types/run.d.ts +9 -1
- package/dist/types/types/tools.d.ts +70 -0
- package/dist/types/utils/truncation.d.ts +21 -0
- package/package.json +1 -1
- package/src/graphs/Graph.ts +55 -0
- package/src/llm/invoke.test.ts +442 -0
- package/src/llm/invoke.ts +23 -2
- package/src/messages/prune.ts +9 -2
- package/src/run.ts +4 -0
- package/src/specs/prune.test.ts +413 -0
- package/src/tools/BashExecutor.ts +45 -0
- package/src/tools/ToolNode.ts +631 -55
- package/src/tools/__tests__/BashExecutor.test.ts +36 -0
- package/src/tools/__tests__/ToolNode.outputReferences.test.ts +1438 -0
- package/src/tools/__tests__/annotateMessagesForLLM.test.ts +419 -0
- package/src/tools/__tests__/toolOutputReferences.test.ts +415 -0
- package/src/tools/toolOutputReferences.ts +813 -0
- package/src/types/index.ts +1 -0
- package/src/types/messages.ts +27 -0
- package/src/types/run.ts +9 -1
- package/src/types/tools.ts +71 -0
- package/src/utils/__tests__/truncation.test.ts +66 -0
- package/src/utils/truncation.ts +30 -0
|
@@ -0,0 +1,1438 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { tool } from '@langchain/core/tools';
|
|
3
|
+
import { AIMessage, ToolMessage } from '@langchain/core/messages';
|
|
4
|
+
import { describe, it, expect, jest, afterEach } from '@jest/globals';
|
|
5
|
+
import type { StructuredToolInterface } from '@langchain/core/tools';
|
|
6
|
+
import type * as t from '@/types';
|
|
7
|
+
import * as events from '@/utils/events';
|
|
8
|
+
import { HookRegistry } from '@/hooks';
|
|
9
|
+
import { ToolNode } from '../ToolNode';
|
|
10
|
+
import { ToolOutputReferenceRegistry } from '../toolOutputReferences';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Reads the lazy ref-metadata stamped onto a `ToolMessage` by ToolNode.
|
|
14
|
+
* The metadata replaces the durable `[ref: …]` content mutation that the
|
|
15
|
+
* earlier eager-annotation design used; the LLM-facing annotation is
|
|
16
|
+
* applied at request time by `annotateMessagesForLLM` instead.
|
|
17
|
+
*/
|
|
18
|
+
function getRefKey(msg: ToolMessage): string | undefined {
|
|
19
|
+
return (msg.additional_kwargs as { _refKey?: string } | undefined)?._refKey;
|
|
20
|
+
}
|
|
21
|
+
function getRefScope(msg: ToolMessage): string | undefined {
|
|
22
|
+
return (msg.additional_kwargs as { _refScope?: string } | undefined)
|
|
23
|
+
?._refScope;
|
|
24
|
+
}
|
|
25
|
+
function getUnresolvedRefs(msg: ToolMessage): string[] {
|
|
26
|
+
return (
|
|
27
|
+
(msg.additional_kwargs as { _unresolvedRefs?: string[] } | undefined)
|
|
28
|
+
?._unresolvedRefs ?? []
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Captures the `command` arg each time the tool is invoked and returns
|
|
34
|
+
* a configurable string output. The tool shape matches a typical bash
|
|
35
|
+
* executor: single required string arg, string response.
|
|
36
|
+
*/
|
|
37
|
+
function createEchoTool(options: {
|
|
38
|
+
capturedArgs: string[];
|
|
39
|
+
outputs: string[];
|
|
40
|
+
name?: string;
|
|
41
|
+
}): StructuredToolInterface {
|
|
42
|
+
const { capturedArgs, outputs, name = 'echo' } = options;
|
|
43
|
+
let callCount = 0;
|
|
44
|
+
return tool(
|
|
45
|
+
async (input) => {
|
|
46
|
+
const args = input as { command: string };
|
|
47
|
+
capturedArgs.push(args.command);
|
|
48
|
+
const output = outputs[callCount] ?? outputs[outputs.length - 1];
|
|
49
|
+
callCount++;
|
|
50
|
+
return output;
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name,
|
|
54
|
+
description: 'Echo test tool',
|
|
55
|
+
schema: z.object({ command: z.string() }),
|
|
56
|
+
}
|
|
57
|
+
) as unknown as StructuredToolInterface;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function aiMsgWithCalls(
|
|
61
|
+
calls: Array<{ id: string; name: string; command: string }>
|
|
62
|
+
): AIMessage {
|
|
63
|
+
return new AIMessage({
|
|
64
|
+
content: '',
|
|
65
|
+
tool_calls: calls.map((c) => ({
|
|
66
|
+
id: c.id,
|
|
67
|
+
name: c.name,
|
|
68
|
+
args: { command: c.command },
|
|
69
|
+
})),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function invokeBatch(
|
|
74
|
+
toolNode: ToolNode,
|
|
75
|
+
calls: Array<{ id: string; name: string; command: string }>,
|
|
76
|
+
runId: string = 'test-run'
|
|
77
|
+
): Promise<ToolMessage[]> {
|
|
78
|
+
const aiMsg = aiMsgWithCalls(calls);
|
|
79
|
+
const result = (await toolNode.invoke(
|
|
80
|
+
{ messages: [aiMsg] },
|
|
81
|
+
{ configurable: { run_id: runId } }
|
|
82
|
+
)) as ToolMessage[] | { messages: ToolMessage[] };
|
|
83
|
+
return Array.isArray(result) ? result : result.messages;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe('ToolNode tool output references', () => {
|
|
87
|
+
describe('disabled (default)', () => {
|
|
88
|
+
it('does not annotate outputs or register anything when disabled', async () => {
|
|
89
|
+
const capturedArgs: string[] = [];
|
|
90
|
+
const t1 = createEchoTool({
|
|
91
|
+
capturedArgs,
|
|
92
|
+
outputs: ['plain-output'],
|
|
93
|
+
});
|
|
94
|
+
const node = new ToolNode({ tools: [t1] });
|
|
95
|
+
|
|
96
|
+
const [msg] = await invokeBatch(node, [
|
|
97
|
+
{ id: 'c1', name: 'echo', command: 'hello' },
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
expect(msg.content).toBe('plain-output');
|
|
101
|
+
expect(node._unsafeGetToolOutputRegistry()).toBeUndefined();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('does not substitute placeholders when disabled', async () => {
|
|
105
|
+
const capturedArgs: string[] = [];
|
|
106
|
+
const t1 = createEchoTool({ capturedArgs, outputs: ['X'] });
|
|
107
|
+
const node = new ToolNode({ tools: [t1] });
|
|
108
|
+
|
|
109
|
+
await invokeBatch(node, [
|
|
110
|
+
{ id: 'c1', name: 'echo', command: 'raw {{tool0turn0}}' },
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
expect(capturedArgs).toEqual(['raw {{tool0turn0}}']);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('enabled', () => {
|
|
118
|
+
it('keeps string outputs clean and stamps the ref key as metadata', async () => {
|
|
119
|
+
const t1 = createEchoTool({
|
|
120
|
+
capturedArgs: [],
|
|
121
|
+
outputs: ['hello world'],
|
|
122
|
+
});
|
|
123
|
+
const node = new ToolNode({
|
|
124
|
+
tools: [t1],
|
|
125
|
+
toolOutputReferences: { enabled: true },
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const [msg] = await invokeBatch(node, [
|
|
129
|
+
{ id: 'c1', name: 'echo', command: 'run' },
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
expect(msg.content).toBe('hello world');
|
|
133
|
+
expect(getRefKey(msg)).toBe('tool0turn0');
|
|
134
|
+
/**
|
|
135
|
+
* `_refScope` is what lets `annotateMessagesForLLM` recover the
|
|
136
|
+
* registry bucket at request time without re-deriving it from
|
|
137
|
+
* `config.configurable.run_id` (which fails for anonymous
|
|
138
|
+
* batches). For named runs it equals the run_id.
|
|
139
|
+
*/
|
|
140
|
+
expect(getRefScope(msg)).toBe('test-run');
|
|
141
|
+
expect(getUnresolvedRefs(msg)).toEqual([]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('keeps JSON-object string outputs unmodified and stamps ref metadata', async () => {
|
|
145
|
+
const t1 = createEchoTool({
|
|
146
|
+
capturedArgs: [],
|
|
147
|
+
outputs: ['{"a":1,"b":"x"}'],
|
|
148
|
+
});
|
|
149
|
+
const node = new ToolNode({
|
|
150
|
+
tools: [t1],
|
|
151
|
+
toolOutputReferences: { enabled: true },
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const [msg] = await invokeBatch(node, [
|
|
155
|
+
{ id: 'c1', name: 'echo', command: 'run' },
|
|
156
|
+
]);
|
|
157
|
+
|
|
158
|
+
const parsed = JSON.parse(msg.content as string);
|
|
159
|
+
expect(parsed.a).toBe(1);
|
|
160
|
+
expect(parsed.b).toBe('x');
|
|
161
|
+
expect(parsed._ref).toBeUndefined();
|
|
162
|
+
expect(getRefKey(msg)).toBe('tool0turn0');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('keeps JSON array outputs unmodified and stamps ref metadata', async () => {
|
|
166
|
+
const t1 = createEchoTool({ capturedArgs: [], outputs: ['[1,2,3]'] });
|
|
167
|
+
const node = new ToolNode({
|
|
168
|
+
tools: [t1],
|
|
169
|
+
toolOutputReferences: { enabled: true },
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const [msg] = await invokeBatch(node, [
|
|
173
|
+
{ id: 'c1', name: 'echo', command: 'run' },
|
|
174
|
+
]);
|
|
175
|
+
|
|
176
|
+
expect(msg.content).toBe('[1,2,3]');
|
|
177
|
+
expect(getRefKey(msg)).toBe('tool0turn0');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('registers the un-annotated output for piping into later calls', async () => {
|
|
181
|
+
const capturedArgs: string[] = [];
|
|
182
|
+
const t1 = createEchoTool({
|
|
183
|
+
capturedArgs,
|
|
184
|
+
outputs: ['raw-payload', 'second-call'],
|
|
185
|
+
});
|
|
186
|
+
const node = new ToolNode({
|
|
187
|
+
tools: [t1],
|
|
188
|
+
toolOutputReferences: { enabled: true },
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
await invokeBatch(node, [{ id: 'c1', name: 'echo', command: 'first' }]);
|
|
192
|
+
await invokeBatch(node, [
|
|
193
|
+
{
|
|
194
|
+
id: 'c2',
|
|
195
|
+
name: 'echo',
|
|
196
|
+
command: 'echo {{tool0turn0}}',
|
|
197
|
+
},
|
|
198
|
+
]);
|
|
199
|
+
|
|
200
|
+
expect(capturedArgs).toEqual(['first', 'echo raw-payload']);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('increments the turn counter per ToolNode batch', async () => {
|
|
204
|
+
const capturedArgs: string[] = [];
|
|
205
|
+
const t1 = createEchoTool({
|
|
206
|
+
capturedArgs,
|
|
207
|
+
outputs: ['one', 'two', 'three'],
|
|
208
|
+
});
|
|
209
|
+
const node = new ToolNode({
|
|
210
|
+
tools: [t1],
|
|
211
|
+
toolOutputReferences: { enabled: true },
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const [m0] = await invokeBatch(node, [
|
|
215
|
+
{ id: 'b1c1', name: 'echo', command: 'a' },
|
|
216
|
+
]);
|
|
217
|
+
const [m1] = await invokeBatch(node, [
|
|
218
|
+
{ id: 'b2c1', name: 'echo', command: 'b' },
|
|
219
|
+
]);
|
|
220
|
+
const [m2] = await invokeBatch(node, [
|
|
221
|
+
{ id: 'b3c1', name: 'echo', command: '{{tool0turn1}}' },
|
|
222
|
+
]);
|
|
223
|
+
|
|
224
|
+
expect(getRefKey(m0)).toBe('tool0turn0');
|
|
225
|
+
expect(getRefKey(m1)).toBe('tool0turn1');
|
|
226
|
+
expect(getRefKey(m2)).toBe('tool0turn2');
|
|
227
|
+
expect(capturedArgs[2]).toBe('two');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('uses array index within a batch for the tool<idx> segment', async () => {
|
|
231
|
+
const capturedA: string[] = [];
|
|
232
|
+
const capturedB: string[] = [];
|
|
233
|
+
const tA = createEchoTool({
|
|
234
|
+
capturedArgs: capturedA,
|
|
235
|
+
outputs: ['A-out'],
|
|
236
|
+
name: 'alpha',
|
|
237
|
+
});
|
|
238
|
+
const tB = createEchoTool({
|
|
239
|
+
capturedArgs: capturedB,
|
|
240
|
+
outputs: ['B-out'],
|
|
241
|
+
name: 'beta',
|
|
242
|
+
});
|
|
243
|
+
const node = new ToolNode({
|
|
244
|
+
tools: [tA, tB],
|
|
245
|
+
toolOutputReferences: { enabled: true },
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const messages = await invokeBatch(node, [
|
|
249
|
+
{ id: 'c1', name: 'alpha', command: 'a' },
|
|
250
|
+
{ id: 'c2', name: 'beta', command: 'b' },
|
|
251
|
+
]);
|
|
252
|
+
|
|
253
|
+
expect(getRefKey(messages[0])).toBe('tool0turn0');
|
|
254
|
+
expect(getRefKey(messages[1])).toBe('tool1turn0');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('reports unresolved placeholders after the output', async () => {
|
|
258
|
+
const capturedArgs: string[] = [];
|
|
259
|
+
const t1 = createEchoTool({ capturedArgs, outputs: ['done'] });
|
|
260
|
+
const node = new ToolNode({
|
|
261
|
+
tools: [t1],
|
|
262
|
+
toolOutputReferences: { enabled: true },
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const [msg] = await invokeBatch(node, [
|
|
266
|
+
{
|
|
267
|
+
id: 'c1',
|
|
268
|
+
name: 'echo',
|
|
269
|
+
command: 'see {{tool9turn9}}',
|
|
270
|
+
},
|
|
271
|
+
]);
|
|
272
|
+
|
|
273
|
+
expect(capturedArgs[0]).toBe('see {{tool9turn9}}');
|
|
274
|
+
expect(msg.content).toBe('done');
|
|
275
|
+
expect(getUnresolvedRefs(msg)).toEqual(['tool9turn9']);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('stores the raw untruncated output in the registry, independent of the LLM-visible truncation', async () => {
|
|
279
|
+
const raw = 'X'.repeat(8_000);
|
|
280
|
+
const capturedArgs: string[] = [];
|
|
281
|
+
const t1 = createEchoTool({
|
|
282
|
+
capturedArgs,
|
|
283
|
+
outputs: [raw, 'second'],
|
|
284
|
+
});
|
|
285
|
+
const node = new ToolNode({
|
|
286
|
+
tools: [t1],
|
|
287
|
+
maxToolResultChars: 200,
|
|
288
|
+
toolOutputReferences: { enabled: true },
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
const [first] = await invokeBatch(
|
|
292
|
+
node,
|
|
293
|
+
[{ id: 'c1', name: 'echo', command: 'first' }],
|
|
294
|
+
'raw-preservation'
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
expect((first.content as string).length).toBeLessThan(raw.length);
|
|
298
|
+
expect(first.content).toContain('truncated');
|
|
299
|
+
|
|
300
|
+
await invokeBatch(
|
|
301
|
+
node,
|
|
302
|
+
[{ id: 'c2', name: 'echo', command: 'echo {{tool0turn0}}' }],
|
|
303
|
+
'raw-preservation'
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
expect(capturedArgs[1]).toBe(`echo ${raw}`);
|
|
307
|
+
expect(
|
|
308
|
+
node
|
|
309
|
+
._unsafeGetToolOutputRegistry()!
|
|
310
|
+
.get('raw-preservation', 'tool0turn0')
|
|
311
|
+
).toBe(raw);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('uses each batch\'s own turn when ToolNode is invoked concurrently within a run', async () => {
|
|
315
|
+
const gates: Record<string, () => void> = {};
|
|
316
|
+
const slowTool = tool(
|
|
317
|
+
async (input) => {
|
|
318
|
+
const args = input as { command: string };
|
|
319
|
+
await new Promise<void>((resolve) => {
|
|
320
|
+
gates[args.command] = resolve;
|
|
321
|
+
});
|
|
322
|
+
return `output-${args.command}`;
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
name: 'slow',
|
|
326
|
+
description: 'awaits a per-command gate',
|
|
327
|
+
schema: z.object({ command: z.string() }),
|
|
328
|
+
}
|
|
329
|
+
) as unknown as StructuredToolInterface;
|
|
330
|
+
|
|
331
|
+
const node = new ToolNode({
|
|
332
|
+
tools: [slowTool],
|
|
333
|
+
toolOutputReferences: { enabled: true },
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// Two batches of the SAME run, started concurrently — batch A
|
|
337
|
+
// captures turn 0 in its sync prefix, batch B captures turn 1.
|
|
338
|
+
// If the turn were read from shared state after the awaits, the
|
|
339
|
+
// reads would race and both batches would see the latest value.
|
|
340
|
+
const first = node.invoke(
|
|
341
|
+
{
|
|
342
|
+
messages: [aiMsgWithCalls([{ id: 'a', name: 'slow', command: 'A' }])],
|
|
343
|
+
},
|
|
344
|
+
{ configurable: { run_id: 'concurrent-run' } }
|
|
345
|
+
);
|
|
346
|
+
const second = node.invoke(
|
|
347
|
+
{
|
|
348
|
+
messages: [aiMsgWithCalls([{ id: 'b', name: 'slow', command: 'B' }])],
|
|
349
|
+
},
|
|
350
|
+
{ configurable: { run_id: 'concurrent-run' } }
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
await new Promise<void>((resolve) => {
|
|
354
|
+
const check = (): void => {
|
|
355
|
+
if (
|
|
356
|
+
Object.prototype.hasOwnProperty.call(gates, 'A') &&
|
|
357
|
+
Object.prototype.hasOwnProperty.call(gates, 'B')
|
|
358
|
+
) {
|
|
359
|
+
resolve();
|
|
360
|
+
} else {
|
|
361
|
+
setTimeout(check, 5);
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
check();
|
|
365
|
+
});
|
|
366
|
+
// Release B first (the later-scheduled batch), then A. Under the
|
|
367
|
+
// old code this would bake turn=1 into BOTH results because
|
|
368
|
+
// `currentTurn` was overwritten during B's sync prefix.
|
|
369
|
+
gates.B();
|
|
370
|
+
gates.A();
|
|
371
|
+
|
|
372
|
+
const [resA, resB] = (await Promise.all([first, second])) as Array<{
|
|
373
|
+
messages: ToolMessage[];
|
|
374
|
+
}>;
|
|
375
|
+
|
|
376
|
+
expect(getRefKey(resA.messages[0])).toBe('tool0turn0');
|
|
377
|
+
expect(resA.messages[0].content).toBe('output-A');
|
|
378
|
+
expect(getRefKey(resB.messages[0])).toBe('tool0turn1');
|
|
379
|
+
expect(resB.messages[0].content).toBe('output-B');
|
|
380
|
+
|
|
381
|
+
const registry = node._unsafeGetToolOutputRegistry()!;
|
|
382
|
+
expect(registry.get('concurrent-run', 'tool0turn0')).toBe('output-A');
|
|
383
|
+
expect(registry.get('concurrent-run', 'tool0turn1')).toBe('output-B');
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('clips registered outputs to maxOutputSize', async () => {
|
|
387
|
+
const t1 = createEchoTool({
|
|
388
|
+
capturedArgs: [],
|
|
389
|
+
outputs: ['{"payload":"' + 'y'.repeat(200) + '"}'],
|
|
390
|
+
});
|
|
391
|
+
const node = new ToolNode({
|
|
392
|
+
tools: [t1],
|
|
393
|
+
toolOutputReferences: { enabled: true, maxOutputSize: 40 },
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
await invokeBatch(node, [{ id: 'c1', name: 'echo', command: 'x' }]);
|
|
397
|
+
|
|
398
|
+
const registry = node._unsafeGetToolOutputRegistry();
|
|
399
|
+
expect(registry).toBeDefined();
|
|
400
|
+
expect(
|
|
401
|
+
registry!.get('test-run', 'tool0turn0')!.length
|
|
402
|
+
).toBeLessThanOrEqual(40);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('honors maxTotalSize via FIFO eviction across batches', async () => {
|
|
406
|
+
const t1 = createEchoTool({
|
|
407
|
+
capturedArgs: [],
|
|
408
|
+
outputs: ['aaaaa', 'bbbbb', 'ccccc'],
|
|
409
|
+
});
|
|
410
|
+
const node = new ToolNode({
|
|
411
|
+
tools: [t1],
|
|
412
|
+
toolOutputReferences: {
|
|
413
|
+
enabled: true,
|
|
414
|
+
maxOutputSize: 10,
|
|
415
|
+
maxTotalSize: 10,
|
|
416
|
+
},
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
await invokeBatch(node, [{ id: 'c1', name: 'echo', command: 'x' }]);
|
|
420
|
+
await invokeBatch(node, [{ id: 'c2', name: 'echo', command: 'x' }]);
|
|
421
|
+
await invokeBatch(node, [{ id: 'c3', name: 'echo', command: 'x' }]);
|
|
422
|
+
|
|
423
|
+
const registry = node._unsafeGetToolOutputRegistry()!;
|
|
424
|
+
expect(registry.get('test-run', 'tool0turn0')).toBeUndefined();
|
|
425
|
+
expect(registry.get('test-run', 'tool0turn1')).toBe('bbbbb');
|
|
426
|
+
expect(registry.get('test-run', 'tool0turn2')).toBe('ccccc');
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it('does not register error outputs', async () => {
|
|
430
|
+
const boom = tool(
|
|
431
|
+
async () => {
|
|
432
|
+
throw new Error('nope');
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
name: 'boom',
|
|
436
|
+
description: 'always errors',
|
|
437
|
+
schema: z.object({ command: z.string() }),
|
|
438
|
+
}
|
|
439
|
+
) as unknown as StructuredToolInterface;
|
|
440
|
+
|
|
441
|
+
const node = new ToolNode({
|
|
442
|
+
tools: [boom],
|
|
443
|
+
toolOutputReferences: { enabled: true },
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
const [msg] = await invokeBatch(node, [
|
|
447
|
+
{ id: 'c1', name: 'boom', command: 'x' },
|
|
448
|
+
]);
|
|
449
|
+
|
|
450
|
+
expect(getRefKey(msg)).toBeUndefined();
|
|
451
|
+
expect(
|
|
452
|
+
node._unsafeGetToolOutputRegistry()!.get('test-run', 'tool0turn0')
|
|
453
|
+
).toBeUndefined();
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it('surfaces unresolved refs on thrown-error ToolMessages', async () => {
|
|
457
|
+
const boom = tool(
|
|
458
|
+
async () => {
|
|
459
|
+
throw new Error('nope');
|
|
460
|
+
},
|
|
461
|
+
{
|
|
462
|
+
name: 'boom',
|
|
463
|
+
description: 'always errors',
|
|
464
|
+
schema: z.object({ command: z.string() }),
|
|
465
|
+
}
|
|
466
|
+
) as unknown as StructuredToolInterface;
|
|
467
|
+
|
|
468
|
+
const node = new ToolNode({
|
|
469
|
+
tools: [boom],
|
|
470
|
+
toolOutputReferences: { enabled: true },
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
const [msg] = await invokeBatch(node, [
|
|
474
|
+
{ id: 'c1', name: 'boom', command: 'see {{tool9turn9}}' },
|
|
475
|
+
]);
|
|
476
|
+
|
|
477
|
+
expect(msg.content).toContain('Error: nope');
|
|
478
|
+
expect(msg.content as string).not.toContain('[unresolved refs:');
|
|
479
|
+
expect(getUnresolvedRefs(msg)).toEqual(['tool9turn9']);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it('surfaces unresolved refs on tool-returned error ToolMessages', async () => {
|
|
483
|
+
const errReturn = tool(
|
|
484
|
+
async () =>
|
|
485
|
+
new ToolMessage({
|
|
486
|
+
status: 'error',
|
|
487
|
+
content: 'handled failure',
|
|
488
|
+
name: 'errReturn',
|
|
489
|
+
tool_call_id: 'c1',
|
|
490
|
+
}),
|
|
491
|
+
{
|
|
492
|
+
name: 'errReturn',
|
|
493
|
+
description: 'returns error ToolMessage',
|
|
494
|
+
schema: z.object({ command: z.string() }),
|
|
495
|
+
}
|
|
496
|
+
) as unknown as StructuredToolInterface;
|
|
497
|
+
|
|
498
|
+
const node = new ToolNode({
|
|
499
|
+
tools: [errReturn],
|
|
500
|
+
toolOutputReferences: { enabled: true },
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
const [msg] = await invokeBatch(node, [
|
|
504
|
+
{ id: 'c1', name: 'errReturn', command: 'see {{tool9turn9}}' },
|
|
505
|
+
]);
|
|
506
|
+
|
|
507
|
+
expect(msg.content).toBe('handled failure');
|
|
508
|
+
expect(getUnresolvedRefs(msg)).toEqual(['tool9turn9']);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it('isolates state between overlapping runs on the same ToolNode', async () => {
|
|
512
|
+
const sharedRegistry = new ToolOutputReferenceRegistry();
|
|
513
|
+
const capturedArgs: string[] = [];
|
|
514
|
+
const tl = createEchoTool({
|
|
515
|
+
capturedArgs,
|
|
516
|
+
outputs: ['out-A', 'out-B', 'resolved-in-B'],
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
const node = new ToolNode({
|
|
520
|
+
tools: [tl],
|
|
521
|
+
toolOutputRegistry: sharedRegistry,
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
// Run A records `tool0turn0` → 'out-A' in its bucket.
|
|
525
|
+
await node.invoke(
|
|
526
|
+
{
|
|
527
|
+
messages: [
|
|
528
|
+
aiMsgWithCalls([{ id: 'a1', name: 'echo', command: 'a' }]),
|
|
529
|
+
],
|
|
530
|
+
},
|
|
531
|
+
{ configurable: { run_id: 'run-A' } }
|
|
532
|
+
);
|
|
533
|
+
expect(sharedRegistry.get('run-A', 'tool0turn0')).toBe('out-A');
|
|
534
|
+
|
|
535
|
+
// Run B records `tool0turn0` → 'out-B' in its own bucket.
|
|
536
|
+
// Under the old global-reset design, starting run B would have
|
|
537
|
+
// wiped run A's registered output; with partitioning, A's
|
|
538
|
+
// bucket survives untouched.
|
|
539
|
+
await node.invoke(
|
|
540
|
+
{
|
|
541
|
+
messages: [
|
|
542
|
+
aiMsgWithCalls([{ id: 'b1', name: 'echo', command: 'b' }]),
|
|
543
|
+
],
|
|
544
|
+
},
|
|
545
|
+
{ configurable: { run_id: 'run-B' } }
|
|
546
|
+
);
|
|
547
|
+
expect(sharedRegistry.get('run-A', 'tool0turn0')).toBe('out-A');
|
|
548
|
+
expect(sharedRegistry.get('run-B', 'tool0turn0')).toBe('out-B');
|
|
549
|
+
|
|
550
|
+
// Run B's next batch resolves `{{tool0turn0}}` against its own
|
|
551
|
+
// partition (out-B), not run A's partition (out-A).
|
|
552
|
+
await node.invoke(
|
|
553
|
+
{
|
|
554
|
+
messages: [
|
|
555
|
+
aiMsgWithCalls([
|
|
556
|
+
{ id: 'b2', name: 'echo', command: 'see {{tool0turn0}}' },
|
|
557
|
+
]),
|
|
558
|
+
],
|
|
559
|
+
},
|
|
560
|
+
{ configurable: { run_id: 'run-B' } }
|
|
561
|
+
);
|
|
562
|
+
expect(capturedArgs[2]).toBe('see out-B');
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it('gives concurrent anonymous invocations independent scopes', async () => {
|
|
566
|
+
const gates: Record<string, () => void> = {};
|
|
567
|
+
const slowTool = tool(
|
|
568
|
+
async (input) => {
|
|
569
|
+
const args = input as { command: string };
|
|
570
|
+
await new Promise<void>((resolve) => {
|
|
571
|
+
gates[args.command] = resolve;
|
|
572
|
+
});
|
|
573
|
+
return `out-${args.command}`;
|
|
574
|
+
},
|
|
575
|
+
{
|
|
576
|
+
name: 'slow',
|
|
577
|
+
description: 'awaits a per-command gate',
|
|
578
|
+
schema: z.object({ command: z.string() }),
|
|
579
|
+
}
|
|
580
|
+
) as unknown as StructuredToolInterface;
|
|
581
|
+
|
|
582
|
+
const node = new ToolNode({
|
|
583
|
+
tools: [slowTool],
|
|
584
|
+
toolOutputReferences: { enabled: true },
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
// Two invocations without `run_id`, started concurrently. Before
|
|
588
|
+
// the unique-anon-scope fix, the second invocation's sync prefix
|
|
589
|
+
// would have deleted the shared anonymous bucket that the first
|
|
590
|
+
// invocation's tool was about to register into.
|
|
591
|
+
const first = node.invoke({
|
|
592
|
+
messages: [aiMsgWithCalls([{ id: 'a1', name: 'slow', command: 'A' }])],
|
|
593
|
+
});
|
|
594
|
+
const second = node.invoke({
|
|
595
|
+
messages: [aiMsgWithCalls([{ id: 'b1', name: 'slow', command: 'B' }])],
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
await new Promise<void>((resolve) => {
|
|
599
|
+
const check = (): void => {
|
|
600
|
+
if (
|
|
601
|
+
Object.prototype.hasOwnProperty.call(gates, 'A') &&
|
|
602
|
+
Object.prototype.hasOwnProperty.call(gates, 'B')
|
|
603
|
+
) {
|
|
604
|
+
resolve();
|
|
605
|
+
} else {
|
|
606
|
+
setTimeout(check, 5);
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
check();
|
|
610
|
+
});
|
|
611
|
+
gates.B();
|
|
612
|
+
gates.A();
|
|
613
|
+
|
|
614
|
+
const [resA, resB] = (await Promise.all([first, second])) as Array<{
|
|
615
|
+
messages: ToolMessage[];
|
|
616
|
+
}>;
|
|
617
|
+
|
|
618
|
+
// Each invocation stamps its own ref metadata — neither's
|
|
619
|
+
// registered tool0turn0 was clobbered by the other's sync-prefix
|
|
620
|
+
// reset.
|
|
621
|
+
expect(getRefKey(resA.messages[0])).toBe('tool0turn0');
|
|
622
|
+
expect(resA.messages[0].content).toBe('out-A');
|
|
623
|
+
expect(getRefKey(resB.messages[0])).toBe('tool0turn0');
|
|
624
|
+
expect(resB.messages[0].content).toBe('out-B');
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Each anonymous invocation stamps a distinct synthetic
|
|
628
|
+
* `_refScope` so the lazy annotation transform can later look
|
|
629
|
+
* up the right registry bucket — `config.configurable.run_id`
|
|
630
|
+
* is undefined for both calls and would collapse them to the
|
|
631
|
+
* same `\0anon` bucket without this stamping.
|
|
632
|
+
*/
|
|
633
|
+
const scopeA = getRefScope(resA.messages[0]);
|
|
634
|
+
const scopeB = getRefScope(resB.messages[0]);
|
|
635
|
+
expect(scopeA).toMatch(/^\0anon-\d+$/);
|
|
636
|
+
expect(scopeB).toMatch(/^\0anon-\d+$/);
|
|
637
|
+
expect(scopeA).not.toBe(scopeB);
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it('clears state on every batch when run_id is absent (anonymous caller)', async () => {
|
|
641
|
+
const capturedArgs: string[] = [];
|
|
642
|
+
const t1 = createEchoTool({
|
|
643
|
+
capturedArgs,
|
|
644
|
+
outputs: ['first-anonymous', 'second-anonymous'],
|
|
645
|
+
});
|
|
646
|
+
const node = new ToolNode({
|
|
647
|
+
tools: [t1],
|
|
648
|
+
toolOutputReferences: { enabled: true },
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
await node.invoke({
|
|
652
|
+
messages: [aiMsgWithCalls([{ id: 'a1', name: 'echo', command: 'a' }])],
|
|
653
|
+
});
|
|
654
|
+
const result = (await node.invoke({
|
|
655
|
+
messages: [
|
|
656
|
+
aiMsgWithCalls([
|
|
657
|
+
{ id: 'a2', name: 'echo', command: 'echo {{tool0turn0}}' },
|
|
658
|
+
]),
|
|
659
|
+
],
|
|
660
|
+
})) as { messages: ToolMessage[] };
|
|
661
|
+
|
|
662
|
+
expect(capturedArgs[1]).toBe('echo {{tool0turn0}}');
|
|
663
|
+
expect(getUnresolvedRefs(result.messages[0])).toEqual(['tool0turn0']);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it('lets two ToolNodes sharing a registry resolve each other\'s refs', async () => {
|
|
667
|
+
const sharedRegistry = new ToolOutputReferenceRegistry();
|
|
668
|
+
const capturedA: string[] = [];
|
|
669
|
+
const capturedB: string[] = [];
|
|
670
|
+
const toolA = createEchoTool({
|
|
671
|
+
capturedArgs: capturedA,
|
|
672
|
+
outputs: ['agent-A-output'],
|
|
673
|
+
name: 'alpha',
|
|
674
|
+
});
|
|
675
|
+
const toolB = createEchoTool({
|
|
676
|
+
capturedArgs: capturedB,
|
|
677
|
+
outputs: ['agent-B-output'],
|
|
678
|
+
name: 'beta',
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
// Two independent ToolNodes (simulating one per agent in a
|
|
682
|
+
// multi-agent graph) sharing one registry instance.
|
|
683
|
+
const nodeA = new ToolNode({
|
|
684
|
+
tools: [toolA],
|
|
685
|
+
toolOutputRegistry: sharedRegistry,
|
|
686
|
+
});
|
|
687
|
+
const nodeB = new ToolNode({
|
|
688
|
+
tools: [toolB],
|
|
689
|
+
toolOutputRegistry: sharedRegistry,
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
await nodeA.invoke(
|
|
693
|
+
{
|
|
694
|
+
messages: [
|
|
695
|
+
aiMsgWithCalls([{ id: 'a1', name: 'alpha', command: 'first' }]),
|
|
696
|
+
],
|
|
697
|
+
},
|
|
698
|
+
{ configurable: { run_id: 'shared-run' } }
|
|
699
|
+
);
|
|
700
|
+
|
|
701
|
+
await nodeB.invoke(
|
|
702
|
+
{
|
|
703
|
+
messages: [
|
|
704
|
+
aiMsgWithCalls([
|
|
705
|
+
{ id: 'b1', name: 'beta', command: 'see {{tool0turn0}}' },
|
|
706
|
+
]),
|
|
707
|
+
],
|
|
708
|
+
},
|
|
709
|
+
{ configurable: { run_id: 'shared-run' } }
|
|
710
|
+
);
|
|
711
|
+
|
|
712
|
+
// nodeB resolved nodeA's tool0turn0 placeholder (cross-node),
|
|
713
|
+
// and its own output landed under the *next* turn (1), not 0.
|
|
714
|
+
expect(capturedB[0]).toBe('see agent-A-output');
|
|
715
|
+
expect(sharedRegistry.get('shared-run', 'tool0turn0')).toBe(
|
|
716
|
+
'agent-A-output'
|
|
717
|
+
);
|
|
718
|
+
expect(sharedRegistry.get('shared-run', 'tool0turn1')).toBe(
|
|
719
|
+
'agent-B-output'
|
|
720
|
+
);
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
it('emits resolved args in ON_RUN_STEP_COMPLETED, not the template', async () => {
|
|
724
|
+
const capturedArgs: string[] = [];
|
|
725
|
+
const t1 = createEchoTool({
|
|
726
|
+
capturedArgs,
|
|
727
|
+
outputs: ['STORED', 'second'],
|
|
728
|
+
});
|
|
729
|
+
const stepCompletedArgs: string[] = [];
|
|
730
|
+
jest
|
|
731
|
+
.spyOn(events, 'safeDispatchCustomEvent')
|
|
732
|
+
.mockImplementation(async (event, data) => {
|
|
733
|
+
if (event === 'on_run_step_completed') {
|
|
734
|
+
const step = data as {
|
|
735
|
+
result: { tool_call: { args: string } };
|
|
736
|
+
};
|
|
737
|
+
stepCompletedArgs.push(step.result.tool_call.args);
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
const node = new ToolNode({
|
|
742
|
+
tools: [t1],
|
|
743
|
+
toolCallStepIds: new Map([
|
|
744
|
+
['a1', 'step_a1'],
|
|
745
|
+
['a2', 'step_a2'],
|
|
746
|
+
]),
|
|
747
|
+
toolOutputReferences: { enabled: true },
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
await invokeBatch(
|
|
751
|
+
node,
|
|
752
|
+
[{ id: 'a1', name: 'echo', command: 'first' }],
|
|
753
|
+
'resolved-args'
|
|
754
|
+
);
|
|
755
|
+
await invokeBatch(
|
|
756
|
+
node,
|
|
757
|
+
[
|
|
758
|
+
{
|
|
759
|
+
id: 'a2',
|
|
760
|
+
name: 'echo',
|
|
761
|
+
command: 'echo {{tool0turn0}}',
|
|
762
|
+
},
|
|
763
|
+
],
|
|
764
|
+
'resolved-args'
|
|
765
|
+
);
|
|
766
|
+
|
|
767
|
+
// Second step-completed event should reflect the post-
|
|
768
|
+
// substitution command, not the `{{…}}` template.
|
|
769
|
+
expect(stepCompletedArgs).toHaveLength(2);
|
|
770
|
+
expect(JSON.parse(stepCompletedArgs[1]).command).toBe('echo STORED');
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
it('records unresolved refs as metadata on non-string ToolMessage content (content untouched)', async () => {
|
|
774
|
+
const complexTool = tool(
|
|
775
|
+
async () =>
|
|
776
|
+
new ToolMessage({
|
|
777
|
+
status: 'success',
|
|
778
|
+
content: [
|
|
779
|
+
{ type: 'text', text: 'data' },
|
|
780
|
+
{ type: 'image_url', image_url: { url: 'data:...' } },
|
|
781
|
+
],
|
|
782
|
+
name: 'complex',
|
|
783
|
+
tool_call_id: 'c1',
|
|
784
|
+
}),
|
|
785
|
+
{
|
|
786
|
+
name: 'complex',
|
|
787
|
+
description: 'returns multi-part content',
|
|
788
|
+
schema: z.object({ command: z.string() }),
|
|
789
|
+
}
|
|
790
|
+
) as unknown as StructuredToolInterface;
|
|
791
|
+
|
|
792
|
+
const node = new ToolNode({
|
|
793
|
+
tools: [complexTool],
|
|
794
|
+
toolOutputReferences: { enabled: true },
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
const [msg] = await invokeBatch(
|
|
798
|
+
node,
|
|
799
|
+
[{ id: 'c1', name: 'complex', command: 'see {{tool9turn9}}' }],
|
|
800
|
+
'non-string'
|
|
801
|
+
);
|
|
802
|
+
|
|
803
|
+
expect(Array.isArray(msg.content)).toBe(true);
|
|
804
|
+
const blocks = msg.content as Array<{ type: string; text?: string }>;
|
|
805
|
+
// Multi-part content is untouched at storage time — the lazy
|
|
806
|
+
// transform handles the unresolved-refs warning at request time.
|
|
807
|
+
expect(blocks).toHaveLength(2);
|
|
808
|
+
expect(blocks[0].type).toBe('text');
|
|
809
|
+
expect(blocks[0].text).toBe('data');
|
|
810
|
+
expect(blocks[1].type).toBe('image_url');
|
|
811
|
+
expect(getUnresolvedRefs(msg)).toEqual(['tool9turn9']);
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
it('resets the registry and turn counter when the runId changes', async () => {
|
|
815
|
+
const capturedArgs: string[] = [];
|
|
816
|
+
const t1 = createEchoTool({
|
|
817
|
+
capturedArgs,
|
|
818
|
+
outputs: ['from-run-A', 'from-run-B'],
|
|
819
|
+
});
|
|
820
|
+
const node = new ToolNode({
|
|
821
|
+
tools: [t1],
|
|
822
|
+
toolOutputReferences: { enabled: true },
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
const aiMsgA = aiMsgWithCalls([
|
|
826
|
+
{ id: 'a1', name: 'echo', command: 'first' },
|
|
827
|
+
]);
|
|
828
|
+
await node.invoke(
|
|
829
|
+
{ messages: [aiMsgA] },
|
|
830
|
+
{ configurable: { run_id: 'run-A' } }
|
|
831
|
+
);
|
|
832
|
+
|
|
833
|
+
const aiMsgB = aiMsgWithCalls([
|
|
834
|
+
{
|
|
835
|
+
id: 'b1',
|
|
836
|
+
name: 'echo',
|
|
837
|
+
command: 'echo {{tool0turn0}}',
|
|
838
|
+
},
|
|
839
|
+
]);
|
|
840
|
+
const resultB = (await node.invoke(
|
|
841
|
+
{ messages: [aiMsgB] },
|
|
842
|
+
{ configurable: { run_id: 'run-B' } }
|
|
843
|
+
)) as { messages: ToolMessage[] };
|
|
844
|
+
|
|
845
|
+
expect(capturedArgs[1]).toBe('echo {{tool0turn0}}');
|
|
846
|
+
expect(resultB.messages[0].content).toBe('from-run-B');
|
|
847
|
+
expect(getRefKey(resultB.messages[0])).toBe('tool0turn0');
|
|
848
|
+
expect(getUnresolvedRefs(resultB.messages[0])).toEqual(['tool0turn0']);
|
|
849
|
+
});
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
describe('event-driven dispatch path', () => {
|
|
853
|
+
afterEach(() => {
|
|
854
|
+
jest.restoreAllMocks();
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
function mockEventDispatch(mockResults: t.ToolExecuteResult[]): void {
|
|
858
|
+
jest
|
|
859
|
+
.spyOn(events, 'safeDispatchCustomEvent')
|
|
860
|
+
.mockImplementation(async (event, data) => {
|
|
861
|
+
if (event !== 'on_tool_execute') {
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
const request = data as Record<string, unknown>;
|
|
865
|
+
if (typeof request.resolve === 'function') {
|
|
866
|
+
(request.resolve as (r: t.ToolExecuteResult[]) => void)(
|
|
867
|
+
mockResults
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function createSchemaStub(name: string): StructuredToolInterface {
|
|
874
|
+
return tool(async () => 'unused', {
|
|
875
|
+
name,
|
|
876
|
+
description: 'schema-only stub; host executes via ON_TOOL_EXECUTE',
|
|
877
|
+
schema: z.object({ command: z.string() }),
|
|
878
|
+
}) as unknown as StructuredToolInterface;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
it('keeps host-returned output clean and stamps the ref key as metadata', async () => {
|
|
882
|
+
const node = new ToolNode({
|
|
883
|
+
tools: [createSchemaStub('echo')],
|
|
884
|
+
eventDrivenMode: true,
|
|
885
|
+
agentId: 'agent-x',
|
|
886
|
+
toolCallStepIds: new Map([['ec1', 'step_ec1']]),
|
|
887
|
+
toolOutputReferences: { enabled: true },
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
mockEventDispatch([
|
|
891
|
+
{ toolCallId: 'ec1', content: 'host-output', status: 'success' },
|
|
892
|
+
]);
|
|
893
|
+
|
|
894
|
+
const aiMsg = new AIMessage({
|
|
895
|
+
content: '',
|
|
896
|
+
tool_calls: [{ id: 'ec1', name: 'echo', args: { command: 'run' } }],
|
|
897
|
+
});
|
|
898
|
+
const result = (await node.invoke(
|
|
899
|
+
{ messages: [aiMsg] },
|
|
900
|
+
{ configurable: { run_id: 'run-host' } }
|
|
901
|
+
)) as { messages: ToolMessage[] };
|
|
902
|
+
|
|
903
|
+
expect(result.messages[0].content).toBe('host-output');
|
|
904
|
+
expect(getRefKey(result.messages[0])).toBe('tool0turn0');
|
|
905
|
+
expect(
|
|
906
|
+
node._unsafeGetToolOutputRegistry()!.get('run-host', 'tool0turn0')
|
|
907
|
+
).toBe('host-output');
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
it('substitutes `{{…}}` in the request sent to the host', async () => {
|
|
911
|
+
const node = new ToolNode({
|
|
912
|
+
tools: [createSchemaStub('echo')],
|
|
913
|
+
eventDrivenMode: true,
|
|
914
|
+
agentId: 'agent-x',
|
|
915
|
+
toolCallStepIds: new Map([
|
|
916
|
+
['ec1', 'step_ec1'],
|
|
917
|
+
['ec2', 'step_ec2'],
|
|
918
|
+
]),
|
|
919
|
+
toolOutputReferences: { enabled: true },
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
mockEventDispatch([
|
|
923
|
+
{ toolCallId: 'ec1', content: 'FIRST', status: 'success' },
|
|
924
|
+
]);
|
|
925
|
+
await node.invoke(
|
|
926
|
+
{
|
|
927
|
+
messages: [
|
|
928
|
+
new AIMessage({
|
|
929
|
+
content: '',
|
|
930
|
+
tool_calls: [{ id: 'ec1', name: 'echo', args: { command: 'a' } }],
|
|
931
|
+
}),
|
|
932
|
+
],
|
|
933
|
+
},
|
|
934
|
+
{ configurable: { run_id: 'run-subst' } }
|
|
935
|
+
);
|
|
936
|
+
|
|
937
|
+
jest.restoreAllMocks();
|
|
938
|
+
const capturedRequests: t.ToolCallRequest[] = [];
|
|
939
|
+
jest
|
|
940
|
+
.spyOn(events, 'safeDispatchCustomEvent')
|
|
941
|
+
.mockImplementation(async (event, data) => {
|
|
942
|
+
if (event !== 'on_tool_execute') {
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
const batch = data as t.ToolExecuteBatchRequest;
|
|
946
|
+
for (const req of batch.toolCalls) {
|
|
947
|
+
capturedRequests.push(req);
|
|
948
|
+
}
|
|
949
|
+
batch.resolve([
|
|
950
|
+
{ toolCallId: 'ec2', content: 'SECOND', status: 'success' },
|
|
951
|
+
]);
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
await node.invoke(
|
|
955
|
+
{
|
|
956
|
+
messages: [
|
|
957
|
+
new AIMessage({
|
|
958
|
+
content: '',
|
|
959
|
+
tool_calls: [
|
|
960
|
+
{
|
|
961
|
+
id: 'ec2',
|
|
962
|
+
name: 'echo',
|
|
963
|
+
args: { command: 'see {{tool0turn0}}' },
|
|
964
|
+
},
|
|
965
|
+
],
|
|
966
|
+
}),
|
|
967
|
+
],
|
|
968
|
+
},
|
|
969
|
+
{ configurable: { run_id: 'run-subst' } }
|
|
970
|
+
);
|
|
971
|
+
|
|
972
|
+
expect(capturedRequests).toHaveLength(1);
|
|
973
|
+
expect(capturedRequests[0].args).toEqual({ command: 'see FIRST' });
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
it('surfaces unresolved refs on host-returned error results', async () => {
|
|
977
|
+
const node = new ToolNode({
|
|
978
|
+
tools: [createSchemaStub('echo')],
|
|
979
|
+
eventDrivenMode: true,
|
|
980
|
+
agentId: 'agent-x',
|
|
981
|
+
toolCallStepIds: new Map([['ec1', 'step_ec1']]),
|
|
982
|
+
toolOutputReferences: { enabled: true },
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
mockEventDispatch([
|
|
986
|
+
{
|
|
987
|
+
toolCallId: 'ec1',
|
|
988
|
+
content: '',
|
|
989
|
+
status: 'error',
|
|
990
|
+
errorMessage: 'host failure',
|
|
991
|
+
},
|
|
992
|
+
]);
|
|
993
|
+
const result = (await node.invoke({
|
|
994
|
+
messages: [
|
|
995
|
+
new AIMessage({
|
|
996
|
+
content: '',
|
|
997
|
+
tool_calls: [
|
|
998
|
+
{
|
|
999
|
+
id: 'ec1',
|
|
1000
|
+
name: 'echo',
|
|
1001
|
+
args: { command: 'see {{tool9turn9}}' },
|
|
1002
|
+
},
|
|
1003
|
+
],
|
|
1004
|
+
}),
|
|
1005
|
+
],
|
|
1006
|
+
})) as { messages: ToolMessage[] };
|
|
1007
|
+
|
|
1008
|
+
expect(result.messages[0].content).toContain('Error: host failure');
|
|
1009
|
+
expect(result.messages[0].content as string).not.toContain(
|
|
1010
|
+
'[unresolved refs:'
|
|
1011
|
+
);
|
|
1012
|
+
expect(getUnresolvedRefs(result.messages[0])).toEqual(['tool9turn9']);
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
it('reports unresolved refs even when the host succeeds', async () => {
|
|
1016
|
+
const node = new ToolNode({
|
|
1017
|
+
tools: [createSchemaStub('echo')],
|
|
1018
|
+
eventDrivenMode: true,
|
|
1019
|
+
agentId: 'agent-x',
|
|
1020
|
+
toolCallStepIds: new Map([['ec1', 'step_ec1']]),
|
|
1021
|
+
toolOutputReferences: { enabled: true },
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
mockEventDispatch([
|
|
1025
|
+
{ toolCallId: 'ec1', content: 'done', status: 'success' },
|
|
1026
|
+
]);
|
|
1027
|
+
const result = (await node.invoke({
|
|
1028
|
+
messages: [
|
|
1029
|
+
new AIMessage({
|
|
1030
|
+
content: '',
|
|
1031
|
+
tool_calls: [
|
|
1032
|
+
{
|
|
1033
|
+
id: 'ec1',
|
|
1034
|
+
name: 'echo',
|
|
1035
|
+
args: { command: 'see {{tool9turn9}}' },
|
|
1036
|
+
},
|
|
1037
|
+
],
|
|
1038
|
+
}),
|
|
1039
|
+
],
|
|
1040
|
+
})) as { messages: ToolMessage[] };
|
|
1041
|
+
|
|
1042
|
+
expect(result.messages[0].content).toBe('done');
|
|
1043
|
+
expect(getUnresolvedRefs(result.messages[0])).toEqual(['tool9turn9']);
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
it('registers the post-hook output when PostToolUse replaces it', async () => {
|
|
1047
|
+
const hooks = new HookRegistry();
|
|
1048
|
+
hooks.register('PostToolUse', {
|
|
1049
|
+
hooks: [
|
|
1050
|
+
async (): Promise<{ updatedOutput: string }> => ({
|
|
1051
|
+
updatedOutput: 'hooked-output',
|
|
1052
|
+
}),
|
|
1053
|
+
],
|
|
1054
|
+
});
|
|
1055
|
+
const node = new ToolNode({
|
|
1056
|
+
tools: [createSchemaStub('echo')],
|
|
1057
|
+
eventDrivenMode: true,
|
|
1058
|
+
agentId: 'agent-x',
|
|
1059
|
+
toolCallStepIds: new Map([['ec1', 'step_ec1']]),
|
|
1060
|
+
toolOutputReferences: { enabled: true },
|
|
1061
|
+
hookRegistry: hooks,
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
mockEventDispatch([
|
|
1065
|
+
{ toolCallId: 'ec1', content: 'raw-output', status: 'success' },
|
|
1066
|
+
]);
|
|
1067
|
+
const result = (await node.invoke(
|
|
1068
|
+
{
|
|
1069
|
+
messages: [
|
|
1070
|
+
new AIMessage({
|
|
1071
|
+
content: '',
|
|
1072
|
+
tool_calls: [
|
|
1073
|
+
{ id: 'ec1', name: 'echo', args: { command: 'run' } },
|
|
1074
|
+
],
|
|
1075
|
+
}),
|
|
1076
|
+
],
|
|
1077
|
+
},
|
|
1078
|
+
{ configurable: { run_id: 'run-posthook' } }
|
|
1079
|
+
)) as { messages: ToolMessage[] };
|
|
1080
|
+
|
|
1081
|
+
expect(result.messages[0].content).toBe('hooked-output');
|
|
1082
|
+
expect(getRefKey(result.messages[0])).toBe('tool0turn0');
|
|
1083
|
+
expect(
|
|
1084
|
+
node._unsafeGetToolOutputRegistry()!.get('run-posthook', 'tool0turn0')
|
|
1085
|
+
).toBe('hooked-output');
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
it('aborts event dispatch when a direct tool throws with handleToolErrors=false', async () => {
|
|
1089
|
+
const directBoom = tool(
|
|
1090
|
+
async () => {
|
|
1091
|
+
throw new Error('direct branch failed');
|
|
1092
|
+
},
|
|
1093
|
+
{
|
|
1094
|
+
name: 'directBoom',
|
|
1095
|
+
description: 'direct tool that throws',
|
|
1096
|
+
schema: z.object({ command: z.string() }),
|
|
1097
|
+
}
|
|
1098
|
+
) as unknown as StructuredToolInterface;
|
|
1099
|
+
const eventStub = tool(async () => 'unused', {
|
|
1100
|
+
name: 'eventTool',
|
|
1101
|
+
description: 'schema-only stub',
|
|
1102
|
+
schema: z.object({ command: z.string() }),
|
|
1103
|
+
}) as unknown as StructuredToolInterface;
|
|
1104
|
+
|
|
1105
|
+
let hostCalled = false;
|
|
1106
|
+
jest
|
|
1107
|
+
.spyOn(events, 'safeDispatchCustomEvent')
|
|
1108
|
+
.mockImplementation(async (event, data) => {
|
|
1109
|
+
if (event === 'on_tool_execute') {
|
|
1110
|
+
hostCalled = true;
|
|
1111
|
+
(data as t.ToolExecuteBatchRequest).resolve([]);
|
|
1112
|
+
}
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
const node = new ToolNode({
|
|
1116
|
+
tools: [directBoom, eventStub],
|
|
1117
|
+
eventDrivenMode: true,
|
|
1118
|
+
handleToolErrors: false,
|
|
1119
|
+
agentId: 'agent-failfast',
|
|
1120
|
+
directToolNames: new Set(['directBoom']),
|
|
1121
|
+
toolCallStepIds: new Map([
|
|
1122
|
+
['d1', 'step_d1'],
|
|
1123
|
+
['e1', 'step_e1'],
|
|
1124
|
+
]),
|
|
1125
|
+
toolOutputReferences: { enabled: true },
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
await expect(
|
|
1129
|
+
node.invoke(
|
|
1130
|
+
{
|
|
1131
|
+
messages: [
|
|
1132
|
+
new AIMessage({
|
|
1133
|
+
content: '',
|
|
1134
|
+
tool_calls: [
|
|
1135
|
+
{ id: 'd1', name: 'directBoom', args: { command: 'x' } },
|
|
1136
|
+
{ id: 'e1', name: 'eventTool', args: { command: 'y' } },
|
|
1137
|
+
],
|
|
1138
|
+
}),
|
|
1139
|
+
],
|
|
1140
|
+
},
|
|
1141
|
+
{ configurable: { run_id: 'failfast-run' } }
|
|
1142
|
+
)
|
|
1143
|
+
).rejects.toThrow('direct branch failed');
|
|
1144
|
+
|
|
1145
|
+
expect(hostCalled).toBe(false);
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
it('isolates PreToolUse-injected refs from same-turn direct outputs in the mixed path', async () => {
|
|
1149
|
+
// PreToolUse hook rewrites the event call's args to include
|
|
1150
|
+
// `{{tool0turn0}}`. In the mixed direct+event path that
|
|
1151
|
+
// placeholder must NOT resolve to the same-turn direct
|
|
1152
|
+
// output that just registered — it should be reported as
|
|
1153
|
+
// unresolved (matching cross-batch resolution semantics).
|
|
1154
|
+
const directCapturedArgs: string[] = [];
|
|
1155
|
+
const directTool = createEchoTool({
|
|
1156
|
+
capturedArgs: directCapturedArgs,
|
|
1157
|
+
outputs: ['direct-same-turn'],
|
|
1158
|
+
name: 'directTool',
|
|
1159
|
+
});
|
|
1160
|
+
const eventStub = tool(async () => 'unused', {
|
|
1161
|
+
name: 'eventTool',
|
|
1162
|
+
description: 'schema-only stub',
|
|
1163
|
+
schema: z.object({ command: z.string() }),
|
|
1164
|
+
}) as unknown as StructuredToolInterface;
|
|
1165
|
+
|
|
1166
|
+
const hooks = new HookRegistry();
|
|
1167
|
+
hooks.register('PreToolUse', {
|
|
1168
|
+
pattern: 'eventTool',
|
|
1169
|
+
hooks: [
|
|
1170
|
+
async (): Promise<{ updatedInput: { command: string } }> => ({
|
|
1171
|
+
updatedInput: { command: 'see {{tool0turn0}}' },
|
|
1172
|
+
}),
|
|
1173
|
+
],
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
const hostCapturedArgs: Record<string, unknown>[] = [];
|
|
1177
|
+
jest
|
|
1178
|
+
.spyOn(events, 'safeDispatchCustomEvent')
|
|
1179
|
+
.mockImplementation(async (event, data) => {
|
|
1180
|
+
if (event !== 'on_tool_execute') {
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
const batch = data as t.ToolExecuteBatchRequest;
|
|
1184
|
+
for (const req of batch.toolCalls) {
|
|
1185
|
+
hostCapturedArgs.push(req.args);
|
|
1186
|
+
}
|
|
1187
|
+
batch.resolve(
|
|
1188
|
+
batch.toolCalls.map((req) => ({
|
|
1189
|
+
toolCallId: req.id,
|
|
1190
|
+
content: 'event-out',
|
|
1191
|
+
status: 'success' as const,
|
|
1192
|
+
}))
|
|
1193
|
+
);
|
|
1194
|
+
});
|
|
1195
|
+
|
|
1196
|
+
const node = new ToolNode({
|
|
1197
|
+
tools: [directTool, eventStub],
|
|
1198
|
+
eventDrivenMode: true,
|
|
1199
|
+
agentId: 'agent-snap',
|
|
1200
|
+
directToolNames: new Set(['directTool']),
|
|
1201
|
+
toolCallStepIds: new Map([
|
|
1202
|
+
['d1', 'step_d1'],
|
|
1203
|
+
['e1', 'step_e1'],
|
|
1204
|
+
]),
|
|
1205
|
+
hookRegistry: hooks,
|
|
1206
|
+
toolOutputReferences: { enabled: true },
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
await node.invoke(
|
|
1210
|
+
{
|
|
1211
|
+
messages: [
|
|
1212
|
+
new AIMessage({
|
|
1213
|
+
content: '',
|
|
1214
|
+
tool_calls: [
|
|
1215
|
+
{
|
|
1216
|
+
id: 'd1',
|
|
1217
|
+
name: 'directTool',
|
|
1218
|
+
args: { command: 'first' },
|
|
1219
|
+
},
|
|
1220
|
+
{
|
|
1221
|
+
id: 'e1',
|
|
1222
|
+
name: 'eventTool',
|
|
1223
|
+
args: { command: 'orig' },
|
|
1224
|
+
},
|
|
1225
|
+
],
|
|
1226
|
+
}),
|
|
1227
|
+
],
|
|
1228
|
+
},
|
|
1229
|
+
{ configurable: { run_id: 'snap-run' } }
|
|
1230
|
+
);
|
|
1231
|
+
|
|
1232
|
+
// Hook injected `{{tool0turn0}}`. The direct tool registered
|
|
1233
|
+
// `tool0turn0` in the same batch, but the snapshot was taken
|
|
1234
|
+
// pre-direct so the placeholder must remain unresolved.
|
|
1235
|
+
expect(hostCapturedArgs).toHaveLength(1);
|
|
1236
|
+
expect(hostCapturedArgs[0]).toEqual({
|
|
1237
|
+
command: 'see {{tool0turn0}}',
|
|
1238
|
+
});
|
|
1239
|
+
});
|
|
1240
|
+
|
|
1241
|
+
it('keeps same-turn refs isolated in the mixed direct+event path', async () => {
|
|
1242
|
+
// Build a ToolNode with both a direct tool (via directToolNames)
|
|
1243
|
+
// and an event-driven schema stub. Share one registry across
|
|
1244
|
+
// both batches so refs only cross batch boundaries.
|
|
1245
|
+
const sharedRegistry = new ToolOutputReferenceRegistry();
|
|
1246
|
+
|
|
1247
|
+
const directCapturedArgs: string[] = [];
|
|
1248
|
+
const directTool = createEchoTool({
|
|
1249
|
+
capturedArgs: directCapturedArgs,
|
|
1250
|
+
outputs: ['direct-A-output', 'direct-B-output'],
|
|
1251
|
+
name: 'directTool',
|
|
1252
|
+
});
|
|
1253
|
+
const eventStub = tool(async () => 'unused', {
|
|
1254
|
+
name: 'eventTool',
|
|
1255
|
+
description: 'schema-only stub',
|
|
1256
|
+
schema: z.object({ command: z.string() }),
|
|
1257
|
+
}) as unknown as StructuredToolInterface;
|
|
1258
|
+
|
|
1259
|
+
const hostCapturedArgs: Record<string, unknown>[] = [];
|
|
1260
|
+
jest
|
|
1261
|
+
.spyOn(events, 'safeDispatchCustomEvent')
|
|
1262
|
+
.mockImplementation(async (event, data) => {
|
|
1263
|
+
if (event !== 'on_tool_execute') {
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
const batch = data as t.ToolExecuteBatchRequest;
|
|
1267
|
+
for (const req of batch.toolCalls) {
|
|
1268
|
+
hostCapturedArgs.push(req.args);
|
|
1269
|
+
}
|
|
1270
|
+
batch.resolve(
|
|
1271
|
+
batch.toolCalls.map((req) => ({
|
|
1272
|
+
toolCallId: req.id,
|
|
1273
|
+
content: `event-${(req.args as { command: string }).command}`,
|
|
1274
|
+
status: 'success' as const,
|
|
1275
|
+
}))
|
|
1276
|
+
);
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
const node = new ToolNode({
|
|
1280
|
+
tools: [directTool, eventStub],
|
|
1281
|
+
eventDrivenMode: true,
|
|
1282
|
+
agentId: 'agent-mixed',
|
|
1283
|
+
directToolNames: new Set(['directTool']),
|
|
1284
|
+
toolCallStepIds: new Map([
|
|
1285
|
+
['d1', 'step_d1'],
|
|
1286
|
+
['e1', 'step_e1'],
|
|
1287
|
+
['d2', 'step_d2'],
|
|
1288
|
+
['e2', 'step_e2'],
|
|
1289
|
+
]),
|
|
1290
|
+
toolOutputRegistry: sharedRegistry,
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
// Batch 1: mixed direct (index 0) + event (index 1). The event
|
|
1294
|
+
// call attempts `{{tool0turn0}}` — which points at the direct
|
|
1295
|
+
// call running *in the same batch*. Correct behavior: the
|
|
1296
|
+
// placeholder stays unresolved (cross-batch only), and the
|
|
1297
|
+
// event args received by the host carry the literal template
|
|
1298
|
+
// string. The unresolved-refs hint is stamped into the resulting
|
|
1299
|
+
// ToolMessage's `additional_kwargs._unresolvedRefs` so the lazy
|
|
1300
|
+
// annotation transform surfaces it to the LLM at request time.
|
|
1301
|
+
await node.invoke(
|
|
1302
|
+
{
|
|
1303
|
+
messages: [
|
|
1304
|
+
new AIMessage({
|
|
1305
|
+
content: '',
|
|
1306
|
+
tool_calls: [
|
|
1307
|
+
{
|
|
1308
|
+
id: 'd1',
|
|
1309
|
+
name: 'directTool',
|
|
1310
|
+
args: { command: 'first' },
|
|
1311
|
+
},
|
|
1312
|
+
{
|
|
1313
|
+
id: 'e1',
|
|
1314
|
+
name: 'eventTool',
|
|
1315
|
+
args: { command: 'echo {{tool0turn0}}' },
|
|
1316
|
+
},
|
|
1317
|
+
],
|
|
1318
|
+
}),
|
|
1319
|
+
],
|
|
1320
|
+
},
|
|
1321
|
+
{ configurable: { run_id: 'mixed-run' } }
|
|
1322
|
+
);
|
|
1323
|
+
|
|
1324
|
+
expect(hostCapturedArgs).toHaveLength(1);
|
|
1325
|
+
expect(hostCapturedArgs[0]).toEqual({
|
|
1326
|
+
command: 'echo {{tool0turn0}}',
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
// Batch 2: ref across the boundary now resolves — direct's
|
|
1330
|
+
// registered output from batch 1 (tool0turn0) is available.
|
|
1331
|
+
await node.invoke(
|
|
1332
|
+
{
|
|
1333
|
+
messages: [
|
|
1334
|
+
new AIMessage({
|
|
1335
|
+
content: '',
|
|
1336
|
+
tool_calls: [
|
|
1337
|
+
{
|
|
1338
|
+
id: 'd2',
|
|
1339
|
+
name: 'directTool',
|
|
1340
|
+
args: { command: 'second' },
|
|
1341
|
+
},
|
|
1342
|
+
{
|
|
1343
|
+
id: 'e2',
|
|
1344
|
+
name: 'eventTool',
|
|
1345
|
+
args: { command: 'echo {{tool0turn0}}' },
|
|
1346
|
+
},
|
|
1347
|
+
],
|
|
1348
|
+
}),
|
|
1349
|
+
],
|
|
1350
|
+
},
|
|
1351
|
+
{ configurable: { run_id: 'mixed-run' } }
|
|
1352
|
+
);
|
|
1353
|
+
|
|
1354
|
+
expect(hostCapturedArgs[1]).toEqual({
|
|
1355
|
+
command: 'echo direct-A-output',
|
|
1356
|
+
});
|
|
1357
|
+
});
|
|
1358
|
+
|
|
1359
|
+
it('re-resolves placeholders when PreToolUse rewrites args', async () => {
|
|
1360
|
+
const hooks = new HookRegistry();
|
|
1361
|
+
hooks.register('PreToolUse', {
|
|
1362
|
+
hooks: [
|
|
1363
|
+
async (): Promise<{ updatedInput: { command: string } }> => ({
|
|
1364
|
+
updatedInput: { command: 'rewritten {{tool0turn0}}' },
|
|
1365
|
+
}),
|
|
1366
|
+
],
|
|
1367
|
+
});
|
|
1368
|
+
const node = new ToolNode({
|
|
1369
|
+
tools: [createSchemaStub('echo')],
|
|
1370
|
+
eventDrivenMode: true,
|
|
1371
|
+
agentId: 'agent-x',
|
|
1372
|
+
toolCallStepIds: new Map([
|
|
1373
|
+
['ec1', 'step_ec1'],
|
|
1374
|
+
['ec2', 'step_ec2'],
|
|
1375
|
+
]),
|
|
1376
|
+
toolOutputReferences: { enabled: true },
|
|
1377
|
+
hookRegistry: hooks,
|
|
1378
|
+
});
|
|
1379
|
+
|
|
1380
|
+
mockEventDispatch([
|
|
1381
|
+
{ toolCallId: 'ec1', content: 'STORED', status: 'success' },
|
|
1382
|
+
]);
|
|
1383
|
+
await node.invoke(
|
|
1384
|
+
{
|
|
1385
|
+
messages: [
|
|
1386
|
+
new AIMessage({
|
|
1387
|
+
content: '',
|
|
1388
|
+
tool_calls: [
|
|
1389
|
+
{ id: 'ec1', name: 'echo', args: { command: 'first' } },
|
|
1390
|
+
],
|
|
1391
|
+
}),
|
|
1392
|
+
],
|
|
1393
|
+
},
|
|
1394
|
+
{ configurable: { run_id: 'run-hookresolve' } }
|
|
1395
|
+
);
|
|
1396
|
+
|
|
1397
|
+
jest.restoreAllMocks();
|
|
1398
|
+
const capturedRequests: t.ToolCallRequest[] = [];
|
|
1399
|
+
jest
|
|
1400
|
+
.spyOn(events, 'safeDispatchCustomEvent')
|
|
1401
|
+
.mockImplementation(async (event, data) => {
|
|
1402
|
+
if (event !== 'on_tool_execute') {
|
|
1403
|
+
return;
|
|
1404
|
+
}
|
|
1405
|
+
const batch = data as t.ToolExecuteBatchRequest;
|
|
1406
|
+
for (const req of batch.toolCalls) {
|
|
1407
|
+
capturedRequests.push(req);
|
|
1408
|
+
}
|
|
1409
|
+
batch.resolve([
|
|
1410
|
+
{ toolCallId: 'ec2', content: 'done', status: 'success' },
|
|
1411
|
+
]);
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
await node.invoke(
|
|
1415
|
+
{
|
|
1416
|
+
messages: [
|
|
1417
|
+
new AIMessage({
|
|
1418
|
+
content: '',
|
|
1419
|
+
tool_calls: [
|
|
1420
|
+
{
|
|
1421
|
+
id: 'ec2',
|
|
1422
|
+
name: 'echo',
|
|
1423
|
+
args: { command: 'input-without-placeholder' },
|
|
1424
|
+
},
|
|
1425
|
+
],
|
|
1426
|
+
}),
|
|
1427
|
+
],
|
|
1428
|
+
},
|
|
1429
|
+
{ configurable: { run_id: 'run-hookresolve' } }
|
|
1430
|
+
);
|
|
1431
|
+
|
|
1432
|
+
expect(capturedRequests).toHaveLength(1);
|
|
1433
|
+
expect(capturedRequests[0].args).toEqual({
|
|
1434
|
+
command: 'rewritten STORED',
|
|
1435
|
+
});
|
|
1436
|
+
});
|
|
1437
|
+
});
|
|
1438
|
+
});
|