@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,1148 @@
1
+ import { describe, it, expect, beforeEach } from '@jest/globals';
2
+ import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
3
+ import type { BaseMessage } from '@langchain/core/messages';
4
+ import { HookRegistry } from '@/hooks/HookRegistry';
5
+ import { Providers, GraphEvents } from '@/common';
6
+ import { HandlerRegistry } from '@/events';
7
+ import { AgentContext } from '@/agents/AgentContext';
8
+ import type { AgentInputs, ResolvedSubagentConfig } from '@/types';
9
+ import {
10
+ SubagentExecutor,
11
+ filterSubagentResult,
12
+ resolveSubagentConfigs,
13
+ buildChildInputs,
14
+ summarizeEvent,
15
+ } from '../subagent';
16
+ import type { StandardGraph } from '@/graphs/Graph';
17
+
18
+ jest.setTimeout(15000);
19
+
20
+ const makeChildInputs = (agentId = 'child-agent'): AgentInputs => ({
21
+ agentId,
22
+ provider: Providers.OPENAI,
23
+ clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
24
+ instructions: 'You are a helper agent.',
25
+ maxContextTokens: 8000,
26
+ });
27
+
28
+ const makeConfig = (
29
+ type = 'researcher',
30
+ overrides: Partial<ResolvedSubagentConfig> = {}
31
+ ): ResolvedSubagentConfig => ({
32
+ type,
33
+ name: 'Test Researcher',
34
+ description: 'Researches things',
35
+ agentInputs: makeChildInputs(),
36
+ ...overrides,
37
+ });
38
+
39
+ describe('filterSubagentResult', () => {
40
+ it('extracts text from last AIMessage string content', () => {
41
+ const messages: BaseMessage[] = [
42
+ new HumanMessage('task'),
43
+ new AIMessage('Here is the result'),
44
+ ];
45
+ expect(filterSubagentResult(messages)).toBe('Here is the result');
46
+ });
47
+
48
+ it('extracts text blocks from array content', () => {
49
+ const messages: BaseMessage[] = [
50
+ new AIMessage({
51
+ content: [
52
+ { type: 'text', text: 'First part.' },
53
+ { type: 'text', text: 'Second part.' },
54
+ ],
55
+ }),
56
+ ];
57
+ expect(filterSubagentResult(messages)).toBe('First part.\nSecond part.');
58
+ });
59
+
60
+ it('strips tool_use blocks from array content', () => {
61
+ const messages: BaseMessage[] = [
62
+ new AIMessage({
63
+ content: [
64
+ { type: 'tool_use', id: 'call_1', name: 'search', input: {} },
65
+ { type: 'text', text: 'Final answer.' },
66
+ ],
67
+ }),
68
+ ];
69
+ expect(filterSubagentResult(messages)).toBe('Final answer.');
70
+ });
71
+
72
+ it('strips thinking blocks from array content', () => {
73
+ const messages: BaseMessage[] = [
74
+ new AIMessage({
75
+ content: [
76
+ { type: 'thinking', thinking: 'Let me think...' },
77
+ { type: 'text', text: 'The result.' },
78
+ ],
79
+ }),
80
+ ];
81
+ expect(filterSubagentResult(messages)).toBe('The result.');
82
+ });
83
+
84
+ it('returns "Task completed" when no text blocks remain', () => {
85
+ const messages: BaseMessage[] = [
86
+ new AIMessage({
87
+ content: [
88
+ { type: 'tool_use', id: 'call_1', name: 'do_thing', input: {} },
89
+ ],
90
+ }),
91
+ ];
92
+ expect(filterSubagentResult(messages)).toBe('Task completed');
93
+ });
94
+
95
+ it('returns "Task completed" for empty string content', () => {
96
+ const messages: BaseMessage[] = [new AIMessage('')];
97
+ expect(filterSubagentResult(messages)).toBe('Task completed');
98
+ });
99
+
100
+ it('returns "Task completed" when no messages', () => {
101
+ expect(filterSubagentResult([])).toBe('Task completed');
102
+ });
103
+
104
+ it('returns "Task completed" when no AIMessage found', () => {
105
+ const messages: BaseMessage[] = [
106
+ new HumanMessage('task'),
107
+ new ToolMessage({ content: 'result', tool_call_id: 'x' }),
108
+ ];
109
+ expect(filterSubagentResult(messages)).toBe('Task completed');
110
+ });
111
+
112
+ it('uses last AIMessage, not first', () => {
113
+ const messages: BaseMessage[] = [
114
+ new AIMessage('First response'),
115
+ new ToolMessage({ content: 'tool output', tool_call_id: 'x' }),
116
+ new AIMessage('Final response'),
117
+ ];
118
+ expect(filterSubagentResult(messages)).toBe('Final response');
119
+ });
120
+
121
+ it('salvages text from an earlier AIMessage when the last has only tool_use', () => {
122
+ /**
123
+ * Scenario: subagent hit maxTurns mid-tool-call. The last AIMessage is
124
+ * pure tool_use with no text. Partial progress from an earlier turn
125
+ * should still be returned instead of "Task completed".
126
+ */
127
+ const messages: BaseMessage[] = [
128
+ new HumanMessage('task'),
129
+ new AIMessage({
130
+ content: [
131
+ { type: 'text', text: 'Let me search.' },
132
+ { type: 'tool_use', id: 'c1', name: 'search', input: {} },
133
+ ],
134
+ }),
135
+ new ToolMessage({ content: 'Paris.', tool_call_id: 'c1' }),
136
+ new AIMessage({
137
+ content: [{ type: 'tool_use', id: 'c2', name: 'search', input: {} }],
138
+ }),
139
+ ];
140
+ expect(filterSubagentResult(messages)).toBe('Let me search.');
141
+ });
142
+
143
+ it('salvages from earlier AIMessage when last has empty string content', () => {
144
+ const messages: BaseMessage[] = [
145
+ new AIMessage('Partial answer.'),
146
+ new ToolMessage({ content: 'tool out', tool_call_id: 'x' }),
147
+ new AIMessage(''),
148
+ ];
149
+ expect(filterSubagentResult(messages)).toBe('Partial answer.');
150
+ });
151
+ });
152
+
153
+ describe('resolveSubagentConfigs', () => {
154
+ const parentInputs: AgentInputs = {
155
+ agentId: 'parent',
156
+ provider: Providers.OPENAI,
157
+ clientOptions: { modelName: 'gpt-4o', apiKey: 'test' },
158
+ instructions: 'You are a parent agent.',
159
+ maxContextTokens: 16000,
160
+ };
161
+
162
+ it('passes through configs with explicit agentInputs', () => {
163
+ const config = makeConfig();
164
+ const parentContext = AgentContext.fromConfig(parentInputs);
165
+ const resolved = resolveSubagentConfigs([config], parentContext);
166
+ expect(resolved).toHaveLength(1);
167
+ expect(resolved[0].agentInputs.agentId).toBe('child-agent');
168
+ });
169
+
170
+ it('resolves self-spawn from parent _sourceInputs', () => {
171
+ const selfConfig = {
172
+ type: 'self',
173
+ name: 'Self Spawn',
174
+ description: 'Context isolation only',
175
+ self: true,
176
+ };
177
+ const parentContext = AgentContext.fromConfig(parentInputs);
178
+ const resolved = resolveSubagentConfigs([selfConfig], parentContext);
179
+ expect(resolved).toHaveLength(1);
180
+ expect(resolved[0].agentInputs.provider).toBe(Providers.OPENAI);
181
+ expect(resolved[0].agentInputs.instructions).toBe(
182
+ 'You are a parent agent.'
183
+ );
184
+ });
185
+
186
+ it('filters out configs with self=true when _sourceInputs is missing', () => {
187
+ const selfConfig = {
188
+ type: 'self',
189
+ name: 'Self Spawn',
190
+ description: 'Context isolation only',
191
+ self: true,
192
+ };
193
+ const parentContext = new AgentContext({
194
+ agentId: 'bare',
195
+ provider: Providers.OPENAI,
196
+ instructionTokens: 0,
197
+ });
198
+ const resolved = resolveSubagentConfigs([selfConfig], parentContext);
199
+ expect(resolved).toHaveLength(0);
200
+ });
201
+
202
+ it('filters out configs without agentInputs and self=false', () => {
203
+ const badConfig = {
204
+ type: 'broken',
205
+ name: 'Broken',
206
+ description: 'Missing inputs',
207
+ };
208
+ const parentContext = AgentContext.fromConfig(parentInputs);
209
+ const resolved = resolveSubagentConfigs([badConfig], parentContext);
210
+ expect(resolved).toHaveLength(0);
211
+ });
212
+
213
+ it('throws on duplicate subagent types', () => {
214
+ const parentContext = AgentContext.fromConfig(parentInputs);
215
+ const dup1 = makeConfig('researcher');
216
+ const dup2 = makeConfig('researcher');
217
+ expect(() => resolveSubagentConfigs([dup1, dup2], parentContext)).toThrow(
218
+ /Duplicate subagent type "researcher"/
219
+ );
220
+ });
221
+ });
222
+
223
+ describe('buildChildInputs', () => {
224
+ const parentAgentInputs: AgentInputs = {
225
+ agentId: 'parent',
226
+ provider: Providers.OPENAI,
227
+ clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test' },
228
+ instructions: 'parent',
229
+ maxContextTokens: 8000,
230
+ subagentConfigs: [{ type: 'researcher', name: 'R', description: 'd' }],
231
+ maxSubagentDepth: 3,
232
+ };
233
+
234
+ it('strips subagentConfigs and maxSubagentDepth when allowNested is false', () => {
235
+ const config: ResolvedSubagentConfig = {
236
+ type: 'researcher',
237
+ name: 'R',
238
+ description: 'd',
239
+ agentInputs: parentAgentInputs,
240
+ };
241
+ const result = buildChildInputs(config, 'child', 3);
242
+ expect(result.subagentConfigs).toBeUndefined();
243
+ expect(result.maxSubagentDepth).toBeUndefined();
244
+ });
245
+
246
+ it('decrements maxSubagentDepth when allowNested is true', () => {
247
+ const config: ResolvedSubagentConfig = {
248
+ type: 'researcher',
249
+ name: 'R',
250
+ description: 'd',
251
+ agentInputs: parentAgentInputs,
252
+ allowNested: true,
253
+ };
254
+ const result = buildChildInputs(config, 'child', 3);
255
+ expect(result.maxSubagentDepth).toBe(2);
256
+ expect(result.subagentConfigs).toEqual(parentAgentInputs.subagentConfigs);
257
+ });
258
+
259
+ it('clamps decremented depth to 0 (never negative)', () => {
260
+ const config: ResolvedSubagentConfig = {
261
+ type: 'researcher',
262
+ name: 'R',
263
+ description: 'd',
264
+ agentInputs: parentAgentInputs,
265
+ allowNested: true,
266
+ };
267
+ const result = buildChildInputs(config, 'child', 0);
268
+ expect(result.maxSubagentDepth).toBe(0);
269
+ });
270
+
271
+ it('always strips toolDefinitions (forces traditional mode)', () => {
272
+ const inputsWithToolDefs: AgentInputs = {
273
+ ...parentAgentInputs,
274
+ toolDefinitions: [{ name: 't', description: 'x' }],
275
+ };
276
+ const config: ResolvedSubagentConfig = {
277
+ type: 'researcher',
278
+ name: 'R',
279
+ description: 'd',
280
+ agentInputs: inputsWithToolDefs,
281
+ };
282
+ const result = buildChildInputs(config, 'child', 3);
283
+ expect(result.toolDefinitions).toBeUndefined();
284
+ });
285
+
286
+ it('strips parent-run-scoped initialSummary and discoveredTools from child inputs', () => {
287
+ /**
288
+ * Codex P1: a child inheriting `initialSummary` or `discoveredTools` from
289
+ * the parent's shallow-spread AgentInputs leaks unrelated conversation
290
+ * context / prior tool-search state into an isolated subagent run,
291
+ * defeating the context-isolation contract. Both fields must be cleared.
292
+ */
293
+ const inputsWithRunContext: AgentInputs = {
294
+ ...parentAgentInputs,
295
+ initialSummary: { text: 'prior conversation summary', tokenCount: 42 },
296
+ discoveredTools: ['prior_tool_a', 'prior_tool_b'],
297
+ };
298
+ const config: ResolvedSubagentConfig = {
299
+ type: 'researcher',
300
+ name: 'R',
301
+ description: 'd',
302
+ agentInputs: inputsWithRunContext,
303
+ };
304
+ const result = buildChildInputs(config, 'child', 3);
305
+ expect(result.initialSummary).toBeUndefined();
306
+ expect(result.discoveredTools).toBeUndefined();
307
+ });
308
+
309
+ it('overrides agentId with the passed childAgentId', () => {
310
+ const config: ResolvedSubagentConfig = {
311
+ type: 'researcher',
312
+ name: 'R',
313
+ description: 'd',
314
+ agentInputs: parentAgentInputs,
315
+ };
316
+ const result = buildChildInputs(config, 'my-child', 3);
317
+ expect(result.agentId).toBe('my-child');
318
+ });
319
+ });
320
+
321
+ describe('SubagentExecutor', () => {
322
+ const config = makeConfig();
323
+
324
+ /**
325
+ * Build a stub `createChildGraph` factory that returns a minimal
326
+ * `StandardGraph`-shaped object whose `createWorkflow().invoke()`
327
+ * resolves to `invokeResult`. Avoids `jest.spyOn(StandardGraph)` so
328
+ * that SubagentExecutor does not need a runtime dep on the graphs
329
+ * module (circular-dep-safe).
330
+ */
331
+ function makeStubGraphFactory(
332
+ invokeResult: { messages: BaseMessage[] },
333
+ clearSpy?: jest.Mock
334
+ ): { factory: () => StandardGraph; clearHeavyState: jest.Mock } {
335
+ const mockClear = clearSpy ?? jest.fn();
336
+ const factory = (): StandardGraph =>
337
+ ({
338
+ createWorkflow: (): { invoke: jest.Mock } => ({
339
+ invoke: jest.fn().mockResolvedValue(invokeResult),
340
+ }),
341
+ clearHeavyState: mockClear,
342
+ }) as unknown as StandardGraph;
343
+ return { factory, clearHeavyState: mockClear };
344
+ }
345
+
346
+ function makeThrowingGraphFactory(error: Error): () => StandardGraph {
347
+ return (): StandardGraph =>
348
+ ({
349
+ createWorkflow: (): { invoke: jest.Mock } => ({
350
+ invoke: jest.fn().mockRejectedValue(error),
351
+ }),
352
+ clearHeavyState: jest.fn(),
353
+ }) as unknown as StandardGraph;
354
+ }
355
+
356
+ /** No-op factory for tests that never reach child graph construction. */
357
+ function makeNoopGraphFactory(): () => StandardGraph {
358
+ return (): StandardGraph =>
359
+ ({
360
+ createWorkflow: (): { invoke: jest.Mock } => ({
361
+ invoke: jest.fn().mockResolvedValue({ messages: [] }),
362
+ }),
363
+ clearHeavyState: jest.fn(),
364
+ }) as unknown as StandardGraph;
365
+ }
366
+
367
+ function createExecutor(
368
+ overrides: Partial<ConstructorParameters<typeof SubagentExecutor>[0]> = {}
369
+ ): SubagentExecutor {
370
+ return new SubagentExecutor({
371
+ configs: new Map([[config.type, config]]),
372
+ parentRunId: 'test-run',
373
+ parentAgentId: 'parent-agent',
374
+ createChildGraph: makeNoopGraphFactory(),
375
+ ...overrides,
376
+ });
377
+ }
378
+
379
+ it('returns error for unknown subagent type', async () => {
380
+ const executor = createExecutor();
381
+ const result = await executor.execute({
382
+ description: 'Do something',
383
+ subagentType: 'nonexistent',
384
+ });
385
+ expect(result.content).toContain('Unknown subagent type');
386
+ expect(result.content).toContain('nonexistent');
387
+ expect(result.content).toContain('researcher');
388
+ expect(result.messages).toEqual([]);
389
+ });
390
+
391
+ it('returns error when maxDepth is 0 (nesting budget exhausted)', async () => {
392
+ const executor = createExecutor({ maxDepth: 0 });
393
+ const result = await executor.execute({
394
+ description: 'Do something',
395
+ subagentType: 'researcher',
396
+ });
397
+ expect(result.content).toContain('Maximum subagent nesting depth');
398
+ expect(result.messages).toEqual([]);
399
+ });
400
+
401
+ it('executes child graph and returns filtered content', async () => {
402
+ const { factory, clearHeavyState } = makeStubGraphFactory({
403
+ messages: [
404
+ new HumanMessage('research this topic'),
405
+ new AIMessage('Here is my research summary.'),
406
+ ],
407
+ });
408
+ const executor = createExecutor({ createChildGraph: factory });
409
+
410
+ const result = await executor.execute({
411
+ description: 'Research this topic',
412
+ subagentType: 'researcher',
413
+ });
414
+
415
+ expect(result.content).toBe('Here is my research summary.');
416
+ expect(result.messages).toHaveLength(2);
417
+ expect(clearHeavyState).toHaveBeenCalled();
418
+ });
419
+
420
+ it('returns error message when child graph throws', async () => {
421
+ const executor = createExecutor({
422
+ createChildGraph: makeThrowingGraphFactory(
423
+ new Error('Graph recursion limit reached')
424
+ ),
425
+ });
426
+
427
+ const result = await executor.execute({
428
+ description: 'Do something',
429
+ subagentType: 'researcher',
430
+ });
431
+
432
+ expect(result.content).toContain('Subagent error');
433
+ expect(result.content).toContain('Graph recursion limit reached');
434
+ expect(result.messages).toEqual([]);
435
+ });
436
+
437
+ it('truncates long error messages to 200 chars', async () => {
438
+ const longMessage = 'x'.repeat(500);
439
+ const executor = createExecutor({
440
+ createChildGraph: makeThrowingGraphFactory(new Error(longMessage)),
441
+ });
442
+
443
+ const result = await executor.execute({
444
+ description: 'Do something',
445
+ subagentType: 'researcher',
446
+ });
447
+
448
+ /**
449
+ * Expected composition: "Subagent error: " (16) + 200 truncated chars + "..." (3) = 219.
450
+ * Assert the exact envelope to catch regressions in the truncation constant.
451
+ */
452
+ const MAX_TRUNCATED_LENGTH = 'Subagent error: '.length + 200 + '...'.length;
453
+ expect(result.content.length).toBe(MAX_TRUNCATED_LENGTH);
454
+ expect(result.content.startsWith('Subagent error: ')).toBe(true);
455
+ expect(result.content.endsWith('...')).toBe(true);
456
+ });
457
+
458
+ it('does not truncate short error messages', async () => {
459
+ const shortMessage = 'brief error detail';
460
+ const executor = createExecutor({
461
+ createChildGraph: makeThrowingGraphFactory(new Error(shortMessage)),
462
+ });
463
+
464
+ const result = await executor.execute({
465
+ description: 'Do something',
466
+ subagentType: 'researcher',
467
+ });
468
+
469
+ expect(result.content).toBe(`Subagent error: ${shortMessage}`);
470
+ expect(result.content.endsWith('...')).toBe(false);
471
+ });
472
+
473
+ it('builds child with decremented maxSubagentDepth when allowNested=true', async () => {
474
+ const nestedConfig: ResolvedSubagentConfig = {
475
+ type: 'nested',
476
+ name: 'Nested',
477
+ description: 'allows nesting',
478
+ allowNested: true,
479
+ agentInputs: {
480
+ ...makeChildInputs('nested-child'),
481
+ subagentConfigs: [
482
+ {
483
+ type: 'nested',
484
+ name: 'Nested',
485
+ description: 'allows nesting',
486
+ allowNested: true,
487
+ },
488
+ ],
489
+ maxSubagentDepth: 3,
490
+ },
491
+ };
492
+
493
+ let observedChildInputs: AgentInputs | undefined;
494
+ const executor = new SubagentExecutor({
495
+ configs: new Map([[nestedConfig.type, nestedConfig]]),
496
+ parentRunId: 'test-run',
497
+ parentAgentId: 'parent',
498
+ maxDepth: 3,
499
+ createChildGraph: (input): StandardGraph => {
500
+ observedChildInputs = input.agents[0];
501
+ return {
502
+ createWorkflow: (): { invoke: jest.Mock } => ({
503
+ invoke: jest.fn().mockResolvedValue({
504
+ messages: [new AIMessage('nested done')],
505
+ }),
506
+ }),
507
+ clearHeavyState: jest.fn(),
508
+ } as unknown as StandardGraph;
509
+ },
510
+ });
511
+
512
+ await executor.execute({
513
+ description: 'nested task',
514
+ subagentType: 'nested',
515
+ });
516
+
517
+ expect(observedChildInputs).toBeDefined();
518
+ expect(observedChildInputs!.maxSubagentDepth).toBe(2);
519
+ expect(observedChildInputs!.subagentConfigs).toBeDefined();
520
+ });
521
+
522
+ it('strips subagentConfigs from child when allowNested is not set', async () => {
523
+ let observedChildInputs: AgentInputs | undefined;
524
+ const executor = createExecutor({
525
+ maxDepth: 3,
526
+ createChildGraph: (input): StandardGraph => {
527
+ observedChildInputs = input.agents[0];
528
+ return {
529
+ createWorkflow: (): { invoke: jest.Mock } => ({
530
+ invoke: jest.fn().mockResolvedValue({
531
+ messages: [new AIMessage('done')],
532
+ }),
533
+ }),
534
+ clearHeavyState: jest.fn(),
535
+ } as unknown as StandardGraph;
536
+ },
537
+ });
538
+
539
+ await executor.execute({
540
+ description: 'task',
541
+ subagentType: 'researcher',
542
+ });
543
+
544
+ expect(observedChildInputs).toBeDefined();
545
+ expect(observedChildInputs!.subagentConfigs).toBeUndefined();
546
+ expect(observedChildInputs!.maxSubagentDepth).toBeUndefined();
547
+ });
548
+
549
+ describe('hooks', () => {
550
+ let capturedStart: unknown;
551
+ let capturedStop: unknown;
552
+
553
+ beforeEach(() => {
554
+ capturedStart = undefined;
555
+ capturedStop = undefined;
556
+ });
557
+
558
+ it('fires SubagentStart before execution', async () => {
559
+ const registry = new HookRegistry();
560
+ registry.register('SubagentStart', {
561
+ hooks: [
562
+ async (input): Promise<Record<string, never>> => {
563
+ capturedStart = input;
564
+ return {};
565
+ },
566
+ ],
567
+ });
568
+
569
+ const { factory } = makeStubGraphFactory({
570
+ messages: [new AIMessage('done')],
571
+ });
572
+ const executor = createExecutor({
573
+ hookRegistry: registry,
574
+ createChildGraph: factory,
575
+ });
576
+
577
+ await executor.execute({
578
+ description: 'Test task',
579
+ subagentType: 'researcher',
580
+ });
581
+
582
+ expect(capturedStart).toBeDefined();
583
+ const input = capturedStart as Record<string, unknown>;
584
+ expect(input.hook_event_name).toBe('SubagentStart');
585
+ expect(input.parentAgentId).toBe('parent-agent');
586
+ expect(input.agentType).toBe('researcher');
587
+ });
588
+
589
+ it('fires SubagentStop after execution', async () => {
590
+ const registry = new HookRegistry();
591
+ registry.register('SubagentStop', {
592
+ hooks: [
593
+ async (input): Promise<Record<string, never>> => {
594
+ capturedStop = input;
595
+ return {};
596
+ },
597
+ ],
598
+ });
599
+
600
+ const { factory } = makeStubGraphFactory({
601
+ messages: [new AIMessage('done')],
602
+ });
603
+ const executor = createExecutor({
604
+ hookRegistry: registry,
605
+ createChildGraph: factory,
606
+ });
607
+
608
+ await executor.execute({
609
+ description: 'Test task',
610
+ subagentType: 'researcher',
611
+ });
612
+
613
+ expect(capturedStop).toBeDefined();
614
+ const input = capturedStop as Record<string, unknown>;
615
+ expect(input.hook_event_name).toBe('SubagentStop');
616
+ expect(input.agentType).toBe('researcher');
617
+ });
618
+
619
+ it('SubagentStart deny blocks execution', async () => {
620
+ const registry = new HookRegistry();
621
+ registry.register('SubagentStart', {
622
+ hooks: [
623
+ async (): Promise<{ decision: 'deny'; reason: string }> => ({
624
+ decision: 'deny',
625
+ reason: 'Not authorized',
626
+ }),
627
+ ],
628
+ });
629
+
630
+ const executor = createExecutor({ hookRegistry: registry });
631
+ const result = await executor.execute({
632
+ description: 'Blocked task',
633
+ subagentType: 'researcher',
634
+ });
635
+
636
+ expect(result.content).toBe('Blocked: Not authorized');
637
+ expect(result.messages).toEqual([]);
638
+ });
639
+ });
640
+
641
+ describe('event forwarding', () => {
642
+ it('emits start/stop ON_SUBAGENT_UPDATE envelopes when parentHandlerRegistry is provided', async () => {
643
+ const events: unknown[] = [];
644
+ const registry = new HandlerRegistry();
645
+ registry.register(GraphEvents.ON_SUBAGENT_UPDATE, {
646
+ handle: (_event, data): void => {
647
+ events.push(data);
648
+ },
649
+ });
650
+
651
+ const { factory } = makeStubGraphFactory({
652
+ messages: [new AIMessage('done')],
653
+ });
654
+ const executor = createExecutor({
655
+ createChildGraph: factory,
656
+ parentHandlerRegistry: registry,
657
+ });
658
+
659
+ await executor.execute({
660
+ description: 'Test task',
661
+ subagentType: 'researcher',
662
+ });
663
+
664
+ const phases = events.map((e) => (e as { phase: string }).phase);
665
+ expect(phases[0]).toBe('start');
666
+ expect(phases[phases.length - 1]).toBe('stop');
667
+ });
668
+
669
+ it('keeps toolDefinitions on child when registry has ON_TOOL_EXECUTE handler', async () => {
670
+ const registry = new HandlerRegistry();
671
+ registry.register(GraphEvents.ON_TOOL_EXECUTE, {
672
+ handle: (): void => {},
673
+ });
674
+ let observedChildInputs: AgentInputs | undefined;
675
+ const configWithDefs: ResolvedSubagentConfig = {
676
+ type: 'researcher',
677
+ name: 'Research Specialist',
678
+ description: 'Researches topics',
679
+ agentInputs: {
680
+ agentId: 'researcher',
681
+ provider: Providers.OPENAI,
682
+ toolDefinitions: [
683
+ { name: 'web', description: 'search', parameters: {} },
684
+ ],
685
+ } as AgentInputs,
686
+ };
687
+
688
+ const executor = new SubagentExecutor({
689
+ configs: new Map([[configWithDefs.type, configWithDefs]]),
690
+ parentRunId: 'run',
691
+ parentAgentId: 'parent',
692
+ parentHandlerRegistry: registry,
693
+ createChildGraph: (input): StandardGraph => {
694
+ observedChildInputs = input.agents[0];
695
+ return {
696
+ createWorkflow: (): { invoke: jest.Mock } => ({
697
+ invoke: jest.fn().mockResolvedValue({
698
+ messages: [new AIMessage('ok')],
699
+ }),
700
+ }),
701
+ clearHeavyState: jest.fn(),
702
+ } as unknown as StandardGraph;
703
+ },
704
+ });
705
+
706
+ await executor.execute({
707
+ description: 'find weather',
708
+ subagentType: 'researcher',
709
+ });
710
+
711
+ expect(observedChildInputs?.toolDefinitions).toHaveLength(1);
712
+ expect(observedChildInputs?.toolDefinitions?.[0]?.name).toBe('web');
713
+ });
714
+
715
+ it('strips toolDefinitions when registry is present but ON_TOOL_EXECUTE handler is absent', async () => {
716
+ const registry = new HandlerRegistry();
717
+ let observedChildInputs: AgentInputs | undefined;
718
+ const configWithDefs: ResolvedSubagentConfig = {
719
+ type: 'researcher',
720
+ name: 'Research Specialist',
721
+ description: 'Researches topics',
722
+ agentInputs: {
723
+ agentId: 'researcher',
724
+ provider: Providers.OPENAI,
725
+ toolDefinitions: [
726
+ { name: 'web', description: 'search', parameters: {} },
727
+ ],
728
+ } as AgentInputs,
729
+ };
730
+
731
+ const executor = new SubagentExecutor({
732
+ configs: new Map([[configWithDefs.type, configWithDefs]]),
733
+ parentRunId: 'run',
734
+ parentAgentId: 'parent',
735
+ parentHandlerRegistry: registry,
736
+ createChildGraph: (input): StandardGraph => {
737
+ observedChildInputs = input.agents[0];
738
+ return {
739
+ createWorkflow: (): { invoke: jest.Mock } => ({
740
+ invoke: jest.fn().mockResolvedValue({
741
+ messages: [new AIMessage('ok')],
742
+ }),
743
+ }),
744
+ clearHeavyState: jest.fn(),
745
+ } as unknown as StandardGraph;
746
+ },
747
+ });
748
+
749
+ await executor.execute({
750
+ description: 'find weather',
751
+ subagentType: 'researcher',
752
+ });
753
+
754
+ expect(observedChildInputs?.toolDefinitions).toBeUndefined();
755
+ });
756
+
757
+ it('forwards parentToolCallId from execute params to SubagentUpdateEvent envelopes', async () => {
758
+ const events: unknown[] = [];
759
+ const registry = new HandlerRegistry();
760
+ registry.register(GraphEvents.ON_SUBAGENT_UPDATE, {
761
+ handle: (_event, data): void => {
762
+ events.push(data);
763
+ },
764
+ });
765
+
766
+ const { factory } = makeStubGraphFactory({
767
+ messages: [new AIMessage('done')],
768
+ });
769
+ const executor = createExecutor({
770
+ createChildGraph: factory,
771
+ parentHandlerRegistry: registry,
772
+ });
773
+
774
+ await executor.execute({
775
+ description: 'Task',
776
+ subagentType: 'researcher',
777
+ parentToolCallId: 'call_abc123',
778
+ });
779
+
780
+ expect(events.length).toBeGreaterThan(0);
781
+ for (const e of events) {
782
+ expect((e as { parentToolCallId?: string }).parentToolCallId).toBe(
783
+ 'call_abc123'
784
+ );
785
+ }
786
+ });
787
+
788
+ it('still strips toolDefinitions when no parentHandlerRegistry is provided (legacy isolation)', async () => {
789
+ let observedChildInputs: AgentInputs | undefined;
790
+ const configWithDefs: ResolvedSubagentConfig = {
791
+ type: 'researcher',
792
+ name: 'Research Specialist',
793
+ description: 'Researches topics',
794
+ agentInputs: {
795
+ agentId: 'researcher',
796
+ provider: Providers.OPENAI,
797
+ toolDefinitions: [
798
+ { name: 'web', description: 'search', parameters: {} },
799
+ ],
800
+ } as AgentInputs,
801
+ };
802
+
803
+ const executor = new SubagentExecutor({
804
+ configs: new Map([[configWithDefs.type, configWithDefs]]),
805
+ parentRunId: 'run',
806
+ parentAgentId: 'parent',
807
+ createChildGraph: (input): StandardGraph => {
808
+ observedChildInputs = input.agents[0];
809
+ return {
810
+ createWorkflow: (): { invoke: jest.Mock } => ({
811
+ invoke: jest.fn().mockResolvedValue({
812
+ messages: [new AIMessage('ok')],
813
+ }),
814
+ }),
815
+ clearHeavyState: jest.fn(),
816
+ } as unknown as StandardGraph;
817
+ },
818
+ });
819
+
820
+ await executor.execute({
821
+ description: 'find weather',
822
+ subagentType: 'researcher',
823
+ });
824
+
825
+ expect(observedChildInputs?.toolDefinitions).toBeUndefined();
826
+ });
827
+
828
+ it('accepts parentHandlerRegistry as a lazy getter', async () => {
829
+ const lazyHolder: { registry?: InstanceType<typeof HandlerRegistry> } =
830
+ {};
831
+ const events: unknown[] = [];
832
+ const { factory } = makeStubGraphFactory({
833
+ messages: [new AIMessage('done')],
834
+ });
835
+ const executor = createExecutor({
836
+ createChildGraph: factory,
837
+ parentHandlerRegistry: () => lazyHolder.registry,
838
+ });
839
+
840
+ lazyHolder.registry = new HandlerRegistry();
841
+ lazyHolder.registry.register(GraphEvents.ON_SUBAGENT_UPDATE, {
842
+ handle: (_event, data): void => {
843
+ events.push(data);
844
+ },
845
+ });
846
+
847
+ await executor.execute({
848
+ description: 'Task',
849
+ subagentType: 'researcher',
850
+ });
851
+
852
+ expect(events.length).toBeGreaterThan(0);
853
+ expect((events[0] as { phase: string }).phase).toBe('start');
854
+ });
855
+
856
+ it('routes child ON_TOOL_EXECUTE dispatches through the parent registry', async () => {
857
+ /**
858
+ * Drives the forwarder callback the executor installs on the child's
859
+ * `workflow.invoke({ callbacks: [forwarder] })`. We capture that
860
+ * callback when the child workflow runs, then synthesize the same
861
+ * `handleCustomEvent` call that a real `ToolNode` would make when
862
+ * the child LLM emits a tool_call. If the forwarder routes correctly,
863
+ * the parent's `ON_TOOL_EXECUTE` handler receives the batch and
864
+ * resolves the promise with our canned results.
865
+ */
866
+
867
+ const parentToolHandler = jest.fn(
868
+ async (_event: string, rawData: unknown): Promise<void> => {
869
+ const req = rawData as {
870
+ toolCalls: Array<{ id: string; name: string }>;
871
+ resolve: (results: unknown[]) => void;
872
+ };
873
+ req.resolve(
874
+ req.toolCalls.map((tc) => ({
875
+ toolCallId: tc.id,
876
+ status: 'success',
877
+ content: `ran ${tc.name}`,
878
+ }))
879
+ );
880
+ }
881
+ );
882
+
883
+ const registry = new HandlerRegistry();
884
+ registry.register(GraphEvents.ON_TOOL_EXECUTE, {
885
+ handle: parentToolHandler,
886
+ });
887
+
888
+ let capturedInvokeOptions: unknown;
889
+ const factory: () => StandardGraph = (): StandardGraph =>
890
+ ({
891
+ createWorkflow: (): { invoke: jest.Mock } => ({
892
+ invoke: jest.fn().mockImplementation(async (_state, options) => {
893
+ capturedInvokeOptions = options;
894
+ return { messages: [new AIMessage('ok')] };
895
+ }),
896
+ }),
897
+ clearHeavyState: jest.fn(),
898
+ }) as unknown as StandardGraph;
899
+
900
+ const executor = createExecutor({
901
+ createChildGraph: factory,
902
+ parentHandlerRegistry: registry,
903
+ });
904
+
905
+ await executor.execute({
906
+ description: 'Task',
907
+ subagentType: 'researcher',
908
+ parentToolCallId: 'call_parent_123',
909
+ });
910
+
911
+ const opts = capturedInvokeOptions as
912
+ | { callbacks?: unknown[] }
913
+ | undefined;
914
+ expect(opts?.callbacks).toBeDefined();
915
+ const forwarder = (opts?.callbacks ?? [])[0] as {
916
+ handleCustomEvent?: (
917
+ eventName: string,
918
+ data: unknown,
919
+ runId: string,
920
+ tags?: string[],
921
+ metadata?: Record<string, unknown>
922
+ ) => Promise<void> | void;
923
+ };
924
+ expect(typeof forwarder.handleCustomEvent).toBe('function');
925
+
926
+ /** Simulate the child's ToolNode emitting a real batch request. */
927
+ const resolvePromise = new Promise<
928
+ Array<{ toolCallId: string; status: string; content: string }>
929
+ >((resolve, reject) => {
930
+ const batchRequest = {
931
+ toolCalls: [{ id: 'call_child_xyz', name: 'calculator', args: {} }],
932
+ agentId: 'researcher',
933
+ resolve,
934
+ reject,
935
+ };
936
+ forwarder.handleCustomEvent?.(
937
+ GraphEvents.ON_TOOL_EXECUTE,
938
+ batchRequest,
939
+ 'child-run-id'
940
+ );
941
+ });
942
+
943
+ const results = await resolvePromise;
944
+ expect(parentToolHandler).toHaveBeenCalledTimes(1);
945
+ expect(results).toEqual([
946
+ {
947
+ toolCallId: 'call_child_xyz',
948
+ status: 'success',
949
+ content: 'ran calculator',
950
+ },
951
+ ]);
952
+ });
953
+
954
+ it('does NOT forward ON_TOOL_EXECUTE when the parent registry has no handler (safe fallback)', async () => {
955
+ /**
956
+ * The executor strips `toolDefinitions` when the parent registry has
957
+ * no `ON_TOOL_EXECUTE` handler (see the companion strip-on-no-handler
958
+ * test). Defence-in-depth: if the LLM somehow still dispatches a tool
959
+ * call, the forwarder must not silently consume it without resolving;
960
+ * reject would be better than hang. This test confirms no handler
961
+ * is invoked on the parent side so it's clear a forwarded request
962
+ * would need separate treatment.
963
+ */
964
+
965
+ const registry = new HandlerRegistry();
966
+ /** Only ON_SUBAGENT_UPDATE registered — no ON_TOOL_EXECUTE. */
967
+ registry.register(GraphEvents.ON_SUBAGENT_UPDATE, { handle: jest.fn() });
968
+
969
+ let capturedInvokeOptions: unknown;
970
+ const factory: () => StandardGraph = (): StandardGraph =>
971
+ ({
972
+ createWorkflow: (): { invoke: jest.Mock } => ({
973
+ invoke: jest.fn().mockImplementation(async (_state, options) => {
974
+ capturedInvokeOptions = options;
975
+ return { messages: [new AIMessage('ok')] };
976
+ }),
977
+ }),
978
+ clearHeavyState: jest.fn(),
979
+ }) as unknown as StandardGraph;
980
+
981
+ const executor = createExecutor({
982
+ createChildGraph: factory,
983
+ parentHandlerRegistry: registry,
984
+ });
985
+
986
+ await executor.execute({
987
+ description: 'Task',
988
+ subagentType: 'researcher',
989
+ });
990
+
991
+ const opts = capturedInvokeOptions as { callbacks?: unknown[] };
992
+ const forwarder = (opts.callbacks ?? [])[0] as {
993
+ handleCustomEvent?: (
994
+ eventName: string,
995
+ data: unknown
996
+ ) => Promise<void> | void;
997
+ };
998
+
999
+ let resolved = false;
1000
+ const batchRequest = {
1001
+ toolCalls: [{ id: 'call_x', name: 'calculator', args: {} }],
1002
+ agentId: 'researcher',
1003
+ resolve: (): void => {
1004
+ resolved = true;
1005
+ },
1006
+ reject: (): void => {},
1007
+ };
1008
+ await forwarder.handleCustomEvent?.(
1009
+ GraphEvents.ON_TOOL_EXECUTE,
1010
+ batchRequest
1011
+ );
1012
+
1013
+ /** No handler exists → nothing resolves the promise. This is the
1014
+ * state that justifies the `keepToolDefinitions` gate: without the
1015
+ * gate we'd deadlock here. The gate ensures the LLM never sees
1016
+ * tools in the first place, making this scenario unreachable in
1017
+ * practice — the test just documents the fallback. */
1018
+ expect(resolved).toBe(false);
1019
+ });
1020
+
1021
+ it('emits an `error` phase envelope when the child graph throws', async () => {
1022
+ const events: unknown[] = [];
1023
+ const registry = new HandlerRegistry();
1024
+ registry.register(GraphEvents.ON_SUBAGENT_UPDATE, {
1025
+ handle: (_event, data): void => {
1026
+ events.push(data);
1027
+ },
1028
+ });
1029
+
1030
+ const executor = createExecutor({
1031
+ createChildGraph: makeThrowingGraphFactory(
1032
+ new Error('recursion limit')
1033
+ ),
1034
+ parentHandlerRegistry: registry,
1035
+ });
1036
+
1037
+ const result = await executor.execute({
1038
+ description: 'Task',
1039
+ subagentType: 'researcher',
1040
+ parentToolCallId: 'call_err',
1041
+ });
1042
+
1043
+ expect(result.content).toContain('Subagent error: recursion limit');
1044
+ const phases = events.map((e) => (e as { phase: string }).phase);
1045
+ expect(phases).toContain('start');
1046
+ expect(phases).toContain('error');
1047
+ const errEvent = events.find(
1048
+ (e) => (e as { phase: string }).phase === 'error'
1049
+ ) as { data?: { message?: string }; parentToolCallId?: string };
1050
+ expect(errEvent.data?.message).toContain('recursion limit');
1051
+ expect(errEvent.parentToolCallId).toBe('call_err');
1052
+ });
1053
+ });
1054
+ });
1055
+
1056
+ describe('summarizeEvent', () => {
1057
+ it('labels a run step tool_calls stepDetails by tool name', () => {
1058
+ const label = summarizeEvent(GraphEvents.ON_RUN_STEP, {
1059
+ stepDetails: {
1060
+ type: 'tool_calls',
1061
+ tool_calls: [{ name: 'calculator', id: 'c1' }],
1062
+ },
1063
+ });
1064
+ expect(label).toBe('Using tool: calculator');
1065
+ });
1066
+
1067
+ it('joins multiple tool names on a single run step', () => {
1068
+ const label = summarizeEvent(GraphEvents.ON_RUN_STEP, {
1069
+ stepDetails: {
1070
+ type: 'tool_calls',
1071
+ tool_calls: [{ name: 'web' }, { name: 'calculator' }],
1072
+ },
1073
+ });
1074
+ expect(label).toBe('Using tool: web, calculator');
1075
+ });
1076
+
1077
+ it('falls back to "Planning tool call" when tool_calls is empty', () => {
1078
+ const label = summarizeEvent(GraphEvents.ON_RUN_STEP, {
1079
+ stepDetails: { type: 'tool_calls', tool_calls: [] },
1080
+ });
1081
+ expect(label).toBe('Planning tool call');
1082
+ });
1083
+
1084
+ it('labels message_creation steps as "Thinking…"', () => {
1085
+ const label = summarizeEvent(GraphEvents.ON_RUN_STEP, {
1086
+ stepDetails: { type: 'message_creation' },
1087
+ });
1088
+ expect(label).toBe('Thinking…');
1089
+ });
1090
+
1091
+ it('labels ON_TOOL_EXECUTE with the batch of tool names', () => {
1092
+ const label = summarizeEvent(GraphEvents.ON_TOOL_EXECUTE, {
1093
+ toolCalls: [{ name: 'web' }, { name: 'calculator' }],
1094
+ });
1095
+ expect(label).toBe('Calling web, calculator');
1096
+ });
1097
+
1098
+ it('falls back to a generic "Calling tool" when toolCalls is empty', () => {
1099
+ const label = summarizeEvent(GraphEvents.ON_TOOL_EXECUTE, {
1100
+ toolCalls: [],
1101
+ });
1102
+ expect(label).toBe('Calling tool');
1103
+ });
1104
+
1105
+ it('labels completed run steps by completed tool name', () => {
1106
+ const label = summarizeEvent(GraphEvents.ON_RUN_STEP_COMPLETED, {
1107
+ result: { type: 'tool_call', tool_call: { name: 'calculator' } },
1108
+ });
1109
+ expect(label).toBe('Tool calculator complete');
1110
+ });
1111
+
1112
+ it('labels completed steps without a tool name as "Step complete"', () => {
1113
+ const label = summarizeEvent(GraphEvents.ON_RUN_STEP_COMPLETED, {
1114
+ result: { type: 'message_creation' },
1115
+ });
1116
+ expect(label).toBe('Step complete');
1117
+ });
1118
+
1119
+ it('labels ON_MESSAGE_DELTA as "Streaming…"', () => {
1120
+ expect(summarizeEvent(GraphEvents.ON_MESSAGE_DELTA, {})).toBe('Streaming…');
1121
+ });
1122
+
1123
+ it('falls back to top-level `step.type` when `stepDetails` is absent', () => {
1124
+ /**
1125
+ * Covers the `step.stepDetails?.type ?? step.type ?? 'step'` chain
1126
+ * when the payload uses the top-level form (no `stepDetails` wrapper).
1127
+ * Exercises the second clause of the fallback so future changes to
1128
+ * the resolution order fail fast.
1129
+ */
1130
+ expect(
1131
+ summarizeEvent(GraphEvents.ON_RUN_STEP, { type: 'tool_calls' })
1132
+ ).toBe('Planning tool call');
1133
+ expect(
1134
+ summarizeEvent(GraphEvents.ON_RUN_STEP, { type: 'message_creation' })
1135
+ ).toBe('Thinking…');
1136
+ });
1137
+
1138
+ it('falls back to "Step: step" when neither `stepDetails.type` nor `step.type` is present', () => {
1139
+ /** Exercises the final `?? 'step'` default plus the generic
1140
+ * `Step: <detailType>` branch when a run step arrives with an
1141
+ * unrecognized shape. */
1142
+ expect(summarizeEvent(GraphEvents.ON_RUN_STEP, {})).toBe('Step: step');
1143
+ });
1144
+
1145
+ it('returns the event name for unknown events', () => {
1146
+ expect(summarizeEvent('on_unknown_event', {})).toBe('on_unknown_event');
1147
+ });
1148
+ });