@librechat/agents 3.1.66-dev.0 → 3.1.67-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 (60) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +47 -18
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/common/enum.cjs +1 -0
  4. package/dist/cjs/common/enum.cjs.map +1 -1
  5. package/dist/cjs/graphs/Graph.cjs +69 -0
  6. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  7. package/dist/cjs/hooks/types.cjs.map +1 -1
  8. package/dist/cjs/main.cjs +12 -0
  9. package/dist/cjs/main.cjs.map +1 -1
  10. package/dist/cjs/summarization/node.cjs +44 -0
  11. package/dist/cjs/summarization/node.cjs.map +1 -1
  12. package/dist/cjs/tools/SubagentTool.cjs +92 -0
  13. package/dist/cjs/tools/SubagentTool.cjs.map +1 -0
  14. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +261 -0
  15. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -0
  16. package/dist/esm/agents/AgentContext.mjs +47 -18
  17. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  18. package/dist/esm/common/enum.mjs +1 -0
  19. package/dist/esm/common/enum.mjs.map +1 -1
  20. package/dist/esm/graphs/Graph.mjs +69 -0
  21. package/dist/esm/graphs/Graph.mjs.map +1 -1
  22. package/dist/esm/hooks/types.mjs.map +1 -1
  23. package/dist/esm/main.mjs +2 -0
  24. package/dist/esm/main.mjs.map +1 -1
  25. package/dist/esm/summarization/node.mjs +44 -0
  26. package/dist/esm/summarization/node.mjs.map +1 -1
  27. package/dist/esm/tools/SubagentTool.mjs +85 -0
  28. package/dist/esm/tools/SubagentTool.mjs.map +1 -0
  29. package/dist/esm/tools/subagent/SubagentExecutor.mjs +256 -0
  30. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -0
  31. package/dist/types/agents/AgentContext.d.ts +12 -0
  32. package/dist/types/common/enum.d.ts +2 -1
  33. package/dist/types/hooks/types.d.ts +12 -1
  34. package/dist/types/index.d.ts +2 -0
  35. package/dist/types/summarization/node.d.ts +2 -0
  36. package/dist/types/tools/SubagentTool.d.ts +36 -0
  37. package/dist/types/tools/subagent/SubagentExecutor.d.ts +83 -0
  38. package/dist/types/tools/subagent/index.d.ts +2 -0
  39. package/dist/types/types/graph.d.ts +25 -0
  40. package/dist/types/types/llm.d.ts +14 -2
  41. package/package.json +2 -1
  42. package/src/agents/AgentContext.ts +54 -17
  43. package/src/agents/__tests__/AgentContext.test.ts +110 -0
  44. package/src/common/enum.ts +1 -0
  45. package/src/graphs/Graph.ts +88 -0
  46. package/src/hooks/__tests__/compactHooks.test.ts +214 -0
  47. package/src/hooks/index.ts +4 -2
  48. package/src/hooks/types.ts +17 -1
  49. package/src/index.ts +2 -0
  50. package/src/scripts/multi-agent-subagent.ts +246 -0
  51. package/src/specs/subagent.test.ts +305 -0
  52. package/src/summarization/node.ts +53 -0
  53. package/src/tools/SubagentTool.ts +100 -0
  54. package/src/tools/__tests__/SubagentExecutor.test.ts +615 -0
  55. package/src/tools/__tests__/SubagentTool.test.ts +149 -0
  56. package/src/tools/__tests__/subagentHooks.test.ts +215 -0
  57. package/src/tools/subagent/SubagentExecutor.ts +344 -0
  58. package/src/tools/subagent/index.ts +12 -0
  59. package/src/types/graph.ts +27 -0
  60. package/src/types/llm.ts +16 -2
@@ -0,0 +1,615 @@
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 } from '@/common';
6
+ import { AgentContext } from '@/agents/AgentContext';
7
+ import type { AgentInputs, ResolvedSubagentConfig } from '@/types';
8
+ import {
9
+ SubagentExecutor,
10
+ filterSubagentResult,
11
+ resolveSubagentConfigs,
12
+ buildChildInputs,
13
+ } from '../subagent';
14
+ import type { StandardGraph } from '@/graphs/Graph';
15
+
16
+ jest.setTimeout(15000);
17
+
18
+ const makeChildInputs = (agentId = 'child-agent'): AgentInputs => ({
19
+ agentId,
20
+ provider: Providers.OPENAI,
21
+ clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
22
+ instructions: 'You are a helper agent.',
23
+ maxContextTokens: 8000,
24
+ });
25
+
26
+ const makeConfig = (
27
+ type = 'researcher',
28
+ overrides: Partial<ResolvedSubagentConfig> = {}
29
+ ): ResolvedSubagentConfig => ({
30
+ type,
31
+ name: 'Test Researcher',
32
+ description: 'Researches things',
33
+ agentInputs: makeChildInputs(),
34
+ ...overrides,
35
+ });
36
+
37
+ describe('filterSubagentResult', () => {
38
+ it('extracts text from last AIMessage string content', () => {
39
+ const messages: BaseMessage[] = [
40
+ new HumanMessage('task'),
41
+ new AIMessage('Here is the result'),
42
+ ];
43
+ expect(filterSubagentResult(messages)).toBe('Here is the result');
44
+ });
45
+
46
+ it('extracts text blocks from array content', () => {
47
+ const messages: BaseMessage[] = [
48
+ new AIMessage({
49
+ content: [
50
+ { type: 'text', text: 'First part.' },
51
+ { type: 'text', text: 'Second part.' },
52
+ ],
53
+ }),
54
+ ];
55
+ expect(filterSubagentResult(messages)).toBe('First part.\nSecond part.');
56
+ });
57
+
58
+ it('strips tool_use blocks from array content', () => {
59
+ const messages: BaseMessage[] = [
60
+ new AIMessage({
61
+ content: [
62
+ { type: 'tool_use', id: 'call_1', name: 'search', input: {} },
63
+ { type: 'text', text: 'Final answer.' },
64
+ ],
65
+ }),
66
+ ];
67
+ expect(filterSubagentResult(messages)).toBe('Final answer.');
68
+ });
69
+
70
+ it('strips thinking blocks from array content', () => {
71
+ const messages: BaseMessage[] = [
72
+ new AIMessage({
73
+ content: [
74
+ { type: 'thinking', thinking: 'Let me think...' },
75
+ { type: 'text', text: 'The result.' },
76
+ ],
77
+ }),
78
+ ];
79
+ expect(filterSubagentResult(messages)).toBe('The result.');
80
+ });
81
+
82
+ it('returns "Task completed" when no text blocks remain', () => {
83
+ const messages: BaseMessage[] = [
84
+ new AIMessage({
85
+ content: [
86
+ { type: 'tool_use', id: 'call_1', name: 'do_thing', input: {} },
87
+ ],
88
+ }),
89
+ ];
90
+ expect(filterSubagentResult(messages)).toBe('Task completed');
91
+ });
92
+
93
+ it('returns "Task completed" for empty string content', () => {
94
+ const messages: BaseMessage[] = [new AIMessage('')];
95
+ expect(filterSubagentResult(messages)).toBe('Task completed');
96
+ });
97
+
98
+ it('returns "Task completed" when no messages', () => {
99
+ expect(filterSubagentResult([])).toBe('Task completed');
100
+ });
101
+
102
+ it('returns "Task completed" when no AIMessage found', () => {
103
+ const messages: BaseMessage[] = [
104
+ new HumanMessage('task'),
105
+ new ToolMessage({ content: 'result', tool_call_id: 'x' }),
106
+ ];
107
+ expect(filterSubagentResult(messages)).toBe('Task completed');
108
+ });
109
+
110
+ it('uses last AIMessage, not first', () => {
111
+ const messages: BaseMessage[] = [
112
+ new AIMessage('First response'),
113
+ new ToolMessage({ content: 'tool output', tool_call_id: 'x' }),
114
+ new AIMessage('Final response'),
115
+ ];
116
+ expect(filterSubagentResult(messages)).toBe('Final response');
117
+ });
118
+
119
+ it('salvages text from an earlier AIMessage when the last has only tool_use', () => {
120
+ /**
121
+ * Scenario: subagent hit maxTurns mid-tool-call. The last AIMessage is
122
+ * pure tool_use with no text. Partial progress from an earlier turn
123
+ * should still be returned instead of "Task completed".
124
+ */
125
+ const messages: BaseMessage[] = [
126
+ new HumanMessage('task'),
127
+ new AIMessage({
128
+ content: [
129
+ { type: 'text', text: 'Let me search.' },
130
+ { type: 'tool_use', id: 'c1', name: 'search', input: {} },
131
+ ],
132
+ }),
133
+ new ToolMessage({ content: 'Paris.', tool_call_id: 'c1' }),
134
+ new AIMessage({
135
+ content: [{ type: 'tool_use', id: 'c2', name: 'search', input: {} }],
136
+ }),
137
+ ];
138
+ expect(filterSubagentResult(messages)).toBe('Let me search.');
139
+ });
140
+
141
+ it('salvages from earlier AIMessage when last has empty string content', () => {
142
+ const messages: BaseMessage[] = [
143
+ new AIMessage('Partial answer.'),
144
+ new ToolMessage({ content: 'tool out', tool_call_id: 'x' }),
145
+ new AIMessage(''),
146
+ ];
147
+ expect(filterSubagentResult(messages)).toBe('Partial answer.');
148
+ });
149
+ });
150
+
151
+ describe('resolveSubagentConfigs', () => {
152
+ const parentInputs: AgentInputs = {
153
+ agentId: 'parent',
154
+ provider: Providers.OPENAI,
155
+ clientOptions: { modelName: 'gpt-4o', apiKey: 'test' },
156
+ instructions: 'You are a parent agent.',
157
+ maxContextTokens: 16000,
158
+ };
159
+
160
+ it('passes through configs with explicit agentInputs', () => {
161
+ const config = makeConfig();
162
+ const parentContext = AgentContext.fromConfig(parentInputs);
163
+ const resolved = resolveSubagentConfigs([config], parentContext);
164
+ expect(resolved).toHaveLength(1);
165
+ expect(resolved[0].agentInputs.agentId).toBe('child-agent');
166
+ });
167
+
168
+ it('resolves self-spawn from parent _sourceInputs', () => {
169
+ const selfConfig = {
170
+ type: 'self',
171
+ name: 'Self Spawn',
172
+ description: 'Context isolation only',
173
+ self: true,
174
+ };
175
+ const parentContext = AgentContext.fromConfig(parentInputs);
176
+ const resolved = resolveSubagentConfigs([selfConfig], parentContext);
177
+ expect(resolved).toHaveLength(1);
178
+ expect(resolved[0].agentInputs.provider).toBe(Providers.OPENAI);
179
+ expect(resolved[0].agentInputs.instructions).toBe(
180
+ 'You are a parent agent.'
181
+ );
182
+ });
183
+
184
+ it('filters out configs with self=true when _sourceInputs is missing', () => {
185
+ const selfConfig = {
186
+ type: 'self',
187
+ name: 'Self Spawn',
188
+ description: 'Context isolation only',
189
+ self: true,
190
+ };
191
+ const parentContext = new AgentContext({
192
+ agentId: 'bare',
193
+ provider: Providers.OPENAI,
194
+ instructionTokens: 0,
195
+ });
196
+ const resolved = resolveSubagentConfigs([selfConfig], parentContext);
197
+ expect(resolved).toHaveLength(0);
198
+ });
199
+
200
+ it('filters out configs without agentInputs and self=false', () => {
201
+ const badConfig = {
202
+ type: 'broken',
203
+ name: 'Broken',
204
+ description: 'Missing inputs',
205
+ };
206
+ const parentContext = AgentContext.fromConfig(parentInputs);
207
+ const resolved = resolveSubagentConfigs([badConfig], parentContext);
208
+ expect(resolved).toHaveLength(0);
209
+ });
210
+
211
+ it('throws on duplicate subagent types', () => {
212
+ const parentContext = AgentContext.fromConfig(parentInputs);
213
+ const dup1 = makeConfig('researcher');
214
+ const dup2 = makeConfig('researcher');
215
+ expect(() => resolveSubagentConfigs([dup1, dup2], parentContext)).toThrow(
216
+ /Duplicate subagent type "researcher"/
217
+ );
218
+ });
219
+ });
220
+
221
+ describe('buildChildInputs', () => {
222
+ const parentAgentInputs: AgentInputs = {
223
+ agentId: 'parent',
224
+ provider: Providers.OPENAI,
225
+ clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test' },
226
+ instructions: 'parent',
227
+ maxContextTokens: 8000,
228
+ subagentConfigs: [{ type: 'researcher', name: 'R', description: 'd' }],
229
+ maxSubagentDepth: 3,
230
+ };
231
+
232
+ it('strips subagentConfigs and maxSubagentDepth when allowNested is false', () => {
233
+ const config: ResolvedSubagentConfig = {
234
+ type: 'researcher',
235
+ name: 'R',
236
+ description: 'd',
237
+ agentInputs: parentAgentInputs,
238
+ };
239
+ const result = buildChildInputs(config, 'child', 3);
240
+ expect(result.subagentConfigs).toBeUndefined();
241
+ expect(result.maxSubagentDepth).toBeUndefined();
242
+ });
243
+
244
+ it('decrements maxSubagentDepth when allowNested is true', () => {
245
+ const config: ResolvedSubagentConfig = {
246
+ type: 'researcher',
247
+ name: 'R',
248
+ description: 'd',
249
+ agentInputs: parentAgentInputs,
250
+ allowNested: true,
251
+ };
252
+ const result = buildChildInputs(config, 'child', 3);
253
+ expect(result.maxSubagentDepth).toBe(2);
254
+ expect(result.subagentConfigs).toEqual(parentAgentInputs.subagentConfigs);
255
+ });
256
+
257
+ it('clamps decremented depth to 0 (never negative)', () => {
258
+ const config: ResolvedSubagentConfig = {
259
+ type: 'researcher',
260
+ name: 'R',
261
+ description: 'd',
262
+ agentInputs: parentAgentInputs,
263
+ allowNested: true,
264
+ };
265
+ const result = buildChildInputs(config, 'child', 0);
266
+ expect(result.maxSubagentDepth).toBe(0);
267
+ });
268
+
269
+ it('always strips toolDefinitions (forces traditional mode)', () => {
270
+ const inputsWithToolDefs: AgentInputs = {
271
+ ...parentAgentInputs,
272
+ toolDefinitions: [{ name: 't', description: 'x' }],
273
+ };
274
+ const config: ResolvedSubagentConfig = {
275
+ type: 'researcher',
276
+ name: 'R',
277
+ description: 'd',
278
+ agentInputs: inputsWithToolDefs,
279
+ };
280
+ const result = buildChildInputs(config, 'child', 3);
281
+ expect(result.toolDefinitions).toBeUndefined();
282
+ });
283
+
284
+ it('overrides agentId with the passed childAgentId', () => {
285
+ const config: ResolvedSubagentConfig = {
286
+ type: 'researcher',
287
+ name: 'R',
288
+ description: 'd',
289
+ agentInputs: parentAgentInputs,
290
+ };
291
+ const result = buildChildInputs(config, 'my-child', 3);
292
+ expect(result.agentId).toBe('my-child');
293
+ });
294
+ });
295
+
296
+ describe('SubagentExecutor', () => {
297
+ const config = makeConfig();
298
+
299
+ /**
300
+ * Build a stub `createChildGraph` factory that returns a minimal
301
+ * `StandardGraph`-shaped object whose `createWorkflow().invoke()`
302
+ * resolves to `invokeResult`. Avoids `jest.spyOn(StandardGraph)` so
303
+ * that SubagentExecutor does not need a runtime dep on the graphs
304
+ * module (circular-dep-safe).
305
+ */
306
+ function makeStubGraphFactory(
307
+ invokeResult: { messages: BaseMessage[] },
308
+ clearSpy?: jest.Mock
309
+ ): { factory: () => StandardGraph; clearHeavyState: jest.Mock } {
310
+ const mockClear = clearSpy ?? jest.fn();
311
+ const factory = (): StandardGraph =>
312
+ ({
313
+ createWorkflow: (): { invoke: jest.Mock } => ({
314
+ invoke: jest.fn().mockResolvedValue(invokeResult),
315
+ }),
316
+ clearHeavyState: mockClear,
317
+ }) as unknown as StandardGraph;
318
+ return { factory, clearHeavyState: mockClear };
319
+ }
320
+
321
+ function makeThrowingGraphFactory(error: Error): () => StandardGraph {
322
+ return (): StandardGraph =>
323
+ ({
324
+ createWorkflow: (): { invoke: jest.Mock } => ({
325
+ invoke: jest.fn().mockRejectedValue(error),
326
+ }),
327
+ clearHeavyState: jest.fn(),
328
+ }) as unknown as StandardGraph;
329
+ }
330
+
331
+ /** No-op factory for tests that never reach child graph construction. */
332
+ function makeNoopGraphFactory(): () => StandardGraph {
333
+ return (): StandardGraph =>
334
+ ({
335
+ createWorkflow: (): { invoke: jest.Mock } => ({
336
+ invoke: jest.fn().mockResolvedValue({ messages: [] }),
337
+ }),
338
+ clearHeavyState: jest.fn(),
339
+ }) as unknown as StandardGraph;
340
+ }
341
+
342
+ function createExecutor(
343
+ overrides: Partial<ConstructorParameters<typeof SubagentExecutor>[0]> = {}
344
+ ): SubagentExecutor {
345
+ return new SubagentExecutor({
346
+ configs: new Map([[config.type, config]]),
347
+ parentRunId: 'test-run',
348
+ parentAgentId: 'parent-agent',
349
+ createChildGraph: makeNoopGraphFactory(),
350
+ ...overrides,
351
+ });
352
+ }
353
+
354
+ it('returns error for unknown subagent type', async () => {
355
+ const executor = createExecutor();
356
+ const result = await executor.execute({
357
+ description: 'Do something',
358
+ subagentType: 'nonexistent',
359
+ });
360
+ expect(result.content).toContain('Unknown subagent type');
361
+ expect(result.content).toContain('nonexistent');
362
+ expect(result.content).toContain('researcher');
363
+ expect(result.messages).toEqual([]);
364
+ });
365
+
366
+ it('returns error when maxDepth is 0 (nesting budget exhausted)', async () => {
367
+ const executor = createExecutor({ maxDepth: 0 });
368
+ const result = await executor.execute({
369
+ description: 'Do something',
370
+ subagentType: 'researcher',
371
+ });
372
+ expect(result.content).toContain('Maximum subagent nesting depth');
373
+ expect(result.messages).toEqual([]);
374
+ });
375
+
376
+ it('executes child graph and returns filtered content', async () => {
377
+ const { factory, clearHeavyState } = makeStubGraphFactory({
378
+ messages: [
379
+ new HumanMessage('research this topic'),
380
+ new AIMessage('Here is my research summary.'),
381
+ ],
382
+ });
383
+ const executor = createExecutor({ createChildGraph: factory });
384
+
385
+ const result = await executor.execute({
386
+ description: 'Research this topic',
387
+ subagentType: 'researcher',
388
+ });
389
+
390
+ expect(result.content).toBe('Here is my research summary.');
391
+ expect(result.messages).toHaveLength(2);
392
+ expect(clearHeavyState).toHaveBeenCalled();
393
+ });
394
+
395
+ it('returns error message when child graph throws', async () => {
396
+ const executor = createExecutor({
397
+ createChildGraph: makeThrowingGraphFactory(
398
+ new Error('Graph recursion limit reached')
399
+ ),
400
+ });
401
+
402
+ const result = await executor.execute({
403
+ description: 'Do something',
404
+ subagentType: 'researcher',
405
+ });
406
+
407
+ expect(result.content).toContain('Subagent error');
408
+ expect(result.content).toContain('Graph recursion limit reached');
409
+ expect(result.messages).toEqual([]);
410
+ });
411
+
412
+ it('truncates long error messages to 200 chars', async () => {
413
+ const longMessage = 'x'.repeat(500);
414
+ const executor = createExecutor({
415
+ createChildGraph: makeThrowingGraphFactory(new Error(longMessage)),
416
+ });
417
+
418
+ const result = await executor.execute({
419
+ description: 'Do something',
420
+ subagentType: 'researcher',
421
+ });
422
+
423
+ /**
424
+ * Expected composition: "Subagent error: " (16) + 200 truncated chars + "..." (3) = 219.
425
+ * Assert the exact envelope to catch regressions in the truncation constant.
426
+ */
427
+ const MAX_TRUNCATED_LENGTH = 'Subagent error: '.length + 200 + '...'.length;
428
+ expect(result.content.length).toBe(MAX_TRUNCATED_LENGTH);
429
+ expect(result.content.startsWith('Subagent error: ')).toBe(true);
430
+ expect(result.content.endsWith('...')).toBe(true);
431
+ });
432
+
433
+ it('does not truncate short error messages', async () => {
434
+ const shortMessage = 'brief error detail';
435
+ const executor = createExecutor({
436
+ createChildGraph: makeThrowingGraphFactory(new Error(shortMessage)),
437
+ });
438
+
439
+ const result = await executor.execute({
440
+ description: 'Do something',
441
+ subagentType: 'researcher',
442
+ });
443
+
444
+ expect(result.content).toBe(`Subagent error: ${shortMessage}`);
445
+ expect(result.content.endsWith('...')).toBe(false);
446
+ });
447
+
448
+ it('builds child with decremented maxSubagentDepth when allowNested=true', async () => {
449
+ const nestedConfig: ResolvedSubagentConfig = {
450
+ type: 'nested',
451
+ name: 'Nested',
452
+ description: 'allows nesting',
453
+ allowNested: true,
454
+ agentInputs: {
455
+ ...makeChildInputs('nested-child'),
456
+ subagentConfigs: [
457
+ {
458
+ type: 'nested',
459
+ name: 'Nested',
460
+ description: 'allows nesting',
461
+ allowNested: true,
462
+ },
463
+ ],
464
+ maxSubagentDepth: 3,
465
+ },
466
+ };
467
+
468
+ let observedChildInputs: AgentInputs | undefined;
469
+ const executor = new SubagentExecutor({
470
+ configs: new Map([[nestedConfig.type, nestedConfig]]),
471
+ parentRunId: 'test-run',
472
+ parentAgentId: 'parent',
473
+ maxDepth: 3,
474
+ createChildGraph: (input): StandardGraph => {
475
+ observedChildInputs = input.agents[0];
476
+ return {
477
+ createWorkflow: (): { invoke: jest.Mock } => ({
478
+ invoke: jest.fn().mockResolvedValue({
479
+ messages: [new AIMessage('nested done')],
480
+ }),
481
+ }),
482
+ clearHeavyState: jest.fn(),
483
+ } as unknown as StandardGraph;
484
+ },
485
+ });
486
+
487
+ await executor.execute({
488
+ description: 'nested task',
489
+ subagentType: 'nested',
490
+ });
491
+
492
+ expect(observedChildInputs).toBeDefined();
493
+ expect(observedChildInputs!.maxSubagentDepth).toBe(2);
494
+ expect(observedChildInputs!.subagentConfigs).toBeDefined();
495
+ });
496
+
497
+ it('strips subagentConfigs from child when allowNested is not set', async () => {
498
+ let observedChildInputs: AgentInputs | undefined;
499
+ const executor = createExecutor({
500
+ maxDepth: 3,
501
+ createChildGraph: (input): StandardGraph => {
502
+ observedChildInputs = input.agents[0];
503
+ return {
504
+ createWorkflow: (): { invoke: jest.Mock } => ({
505
+ invoke: jest.fn().mockResolvedValue({
506
+ messages: [new AIMessage('done')],
507
+ }),
508
+ }),
509
+ clearHeavyState: jest.fn(),
510
+ } as unknown as StandardGraph;
511
+ },
512
+ });
513
+
514
+ await executor.execute({
515
+ description: 'task',
516
+ subagentType: 'researcher',
517
+ });
518
+
519
+ expect(observedChildInputs).toBeDefined();
520
+ expect(observedChildInputs!.subagentConfigs).toBeUndefined();
521
+ expect(observedChildInputs!.maxSubagentDepth).toBeUndefined();
522
+ });
523
+
524
+ describe('hooks', () => {
525
+ let capturedStart: unknown;
526
+ let capturedStop: unknown;
527
+
528
+ beforeEach(() => {
529
+ capturedStart = undefined;
530
+ capturedStop = undefined;
531
+ });
532
+
533
+ it('fires SubagentStart before execution', async () => {
534
+ const registry = new HookRegistry();
535
+ registry.register('SubagentStart', {
536
+ hooks: [
537
+ async (input): Promise<Record<string, never>> => {
538
+ capturedStart = input;
539
+ return {};
540
+ },
541
+ ],
542
+ });
543
+
544
+ const { factory } = makeStubGraphFactory({
545
+ messages: [new AIMessage('done')],
546
+ });
547
+ const executor = createExecutor({
548
+ hookRegistry: registry,
549
+ createChildGraph: factory,
550
+ });
551
+
552
+ await executor.execute({
553
+ description: 'Test task',
554
+ subagentType: 'researcher',
555
+ });
556
+
557
+ expect(capturedStart).toBeDefined();
558
+ const input = capturedStart as Record<string, unknown>;
559
+ expect(input.hook_event_name).toBe('SubagentStart');
560
+ expect(input.parentAgentId).toBe('parent-agent');
561
+ expect(input.agentType).toBe('researcher');
562
+ });
563
+
564
+ it('fires SubagentStop after execution', async () => {
565
+ const registry = new HookRegistry();
566
+ registry.register('SubagentStop', {
567
+ hooks: [
568
+ async (input): Promise<Record<string, never>> => {
569
+ capturedStop = input;
570
+ return {};
571
+ },
572
+ ],
573
+ });
574
+
575
+ const { factory } = makeStubGraphFactory({
576
+ messages: [new AIMessage('done')],
577
+ });
578
+ const executor = createExecutor({
579
+ hookRegistry: registry,
580
+ createChildGraph: factory,
581
+ });
582
+
583
+ await executor.execute({
584
+ description: 'Test task',
585
+ subagentType: 'researcher',
586
+ });
587
+
588
+ expect(capturedStop).toBeDefined();
589
+ const input = capturedStop as Record<string, unknown>;
590
+ expect(input.hook_event_name).toBe('SubagentStop');
591
+ expect(input.agentType).toBe('researcher');
592
+ });
593
+
594
+ it('SubagentStart deny blocks execution', async () => {
595
+ const registry = new HookRegistry();
596
+ registry.register('SubagentStart', {
597
+ hooks: [
598
+ async (): Promise<{ decision: 'deny'; reason: string }> => ({
599
+ decision: 'deny',
600
+ reason: 'Not authorized',
601
+ }),
602
+ ],
603
+ });
604
+
605
+ const executor = createExecutor({ hookRegistry: registry });
606
+ const result = await executor.execute({
607
+ description: 'Blocked task',
608
+ subagentType: 'researcher',
609
+ });
610
+
611
+ expect(result.content).toBe('Blocked: Not authorized');
612
+ expect(result.messages).toEqual([]);
613
+ });
614
+ });
615
+ });