@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.
Files changed (66) hide show
  1. package/dist/cjs/graphs/Graph.cjs +52 -0
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/llm/invoke.cjs +13 -2
  4. package/dist/cjs/llm/invoke.cjs.map +1 -1
  5. package/dist/cjs/main.cjs +4 -0
  6. package/dist/cjs/main.cjs.map +1 -1
  7. package/dist/cjs/messages/prune.cjs +9 -2
  8. package/dist/cjs/messages/prune.cjs.map +1 -1
  9. package/dist/cjs/run.cjs +4 -0
  10. package/dist/cjs/run.cjs.map +1 -1
  11. package/dist/cjs/tools/BashExecutor.cjs +43 -0
  12. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  13. package/dist/cjs/tools/ToolNode.cjs +482 -45
  14. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  15. package/dist/cjs/tools/toolOutputReferences.cjs +657 -0
  16. package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -0
  17. package/dist/cjs/utils/truncation.cjs +28 -0
  18. package/dist/cjs/utils/truncation.cjs.map +1 -1
  19. package/dist/esm/graphs/Graph.mjs +52 -0
  20. package/dist/esm/graphs/Graph.mjs.map +1 -1
  21. package/dist/esm/llm/invoke.mjs +13 -2
  22. package/dist/esm/llm/invoke.mjs.map +1 -1
  23. package/dist/esm/main.mjs +2 -2
  24. package/dist/esm/messages/prune.mjs +9 -2
  25. package/dist/esm/messages/prune.mjs.map +1 -1
  26. package/dist/esm/run.mjs +4 -0
  27. package/dist/esm/run.mjs.map +1 -1
  28. package/dist/esm/tools/BashExecutor.mjs +42 -1
  29. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  30. package/dist/esm/tools/ToolNode.mjs +482 -45
  31. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  32. package/dist/esm/tools/toolOutputReferences.mjs +649 -0
  33. package/dist/esm/tools/toolOutputReferences.mjs.map +1 -0
  34. package/dist/esm/utils/truncation.mjs +27 -1
  35. package/dist/esm/utils/truncation.mjs.map +1 -1
  36. package/dist/types/graphs/Graph.d.ts +28 -0
  37. package/dist/types/llm/invoke.d.ts +9 -0
  38. package/dist/types/run.d.ts +1 -0
  39. package/dist/types/tools/BashExecutor.d.ts +31 -0
  40. package/dist/types/tools/ToolNode.d.ts +84 -3
  41. package/dist/types/tools/toolOutputReferences.d.ts +236 -0
  42. package/dist/types/types/index.d.ts +1 -0
  43. package/dist/types/types/messages.d.ts +26 -0
  44. package/dist/types/types/run.d.ts +9 -1
  45. package/dist/types/types/tools.d.ts +70 -0
  46. package/dist/types/utils/truncation.d.ts +21 -0
  47. package/package.json +1 -1
  48. package/src/graphs/Graph.ts +55 -0
  49. package/src/llm/invoke.test.ts +442 -0
  50. package/src/llm/invoke.ts +23 -2
  51. package/src/messages/prune.ts +9 -2
  52. package/src/run.ts +4 -0
  53. package/src/specs/prune.test.ts +413 -0
  54. package/src/tools/BashExecutor.ts +45 -0
  55. package/src/tools/ToolNode.ts +631 -55
  56. package/src/tools/__tests__/BashExecutor.test.ts +36 -0
  57. package/src/tools/__tests__/ToolNode.outputReferences.test.ts +1438 -0
  58. package/src/tools/__tests__/annotateMessagesForLLM.test.ts +419 -0
  59. package/src/tools/__tests__/toolOutputReferences.test.ts +415 -0
  60. package/src/tools/toolOutputReferences.ts +813 -0
  61. package/src/types/index.ts +1 -0
  62. package/src/types/messages.ts +27 -0
  63. package/src/types/run.ts +9 -1
  64. package/src/types/tools.ts +71 -0
  65. package/src/utils/__tests__/truncation.test.ts +66 -0
  66. 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
+ });