@librechat/agents 3.1.68 → 3.1.71-dev.0

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