@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,149 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+ import { Constants } from '@/common';
3
+ import {
4
+ SubagentToolName,
5
+ SubagentToolDescription,
6
+ SubagentToolDefinition,
7
+ SubagentToolSchema,
8
+ createSubagentToolDefinition,
9
+ buildSubagentToolParams,
10
+ } from '../SubagentTool';
11
+ import type { SubagentConfig } from '@/types';
12
+
13
+ describe('SubagentTool', () => {
14
+ describe('schema structure', () => {
15
+ it('has description as required string property', () => {
16
+ expect(SubagentToolSchema.properties.description.type).toBe('string');
17
+ expect(SubagentToolSchema.required).toContain('description');
18
+ });
19
+
20
+ it('has subagent_type as required string property', () => {
21
+ expect(SubagentToolSchema.properties.subagent_type.type).toBe('string');
22
+ expect(SubagentToolSchema.required).toContain('subagent_type');
23
+ });
24
+
25
+ it('is an object type schema', () => {
26
+ expect(SubagentToolSchema.type).toBe('object');
27
+ });
28
+ });
29
+
30
+ describe('SubagentToolDefinition', () => {
31
+ it('has correct name', () => {
32
+ expect(SubagentToolDefinition.name).toBe(Constants.SUBAGENT);
33
+ });
34
+
35
+ it('references the same schema object', () => {
36
+ expect(SubagentToolDefinition.parameters).toBe(SubagentToolSchema);
37
+ });
38
+
39
+ it('has a non-empty description', () => {
40
+ expect(SubagentToolDefinition.description).toBe(SubagentToolDescription);
41
+ expect(SubagentToolDefinition.description!.length).toBeGreaterThan(0);
42
+ });
43
+ });
44
+
45
+ describe('SubagentToolName', () => {
46
+ it('equals Constants.SUBAGENT', () => {
47
+ expect(SubagentToolName).toBe('subagent');
48
+ expect(SubagentToolName).toBe(Constants.SUBAGENT);
49
+ });
50
+ });
51
+
52
+ describe('createSubagentToolDefinition', () => {
53
+ const configs: SubagentConfig[] = [
54
+ {
55
+ type: 'researcher',
56
+ name: 'Research Agent',
57
+ description: 'Searches and summarizes information',
58
+ },
59
+ {
60
+ type: 'coder',
61
+ name: 'Coding Agent',
62
+ description: 'Writes and reviews code',
63
+ },
64
+ ];
65
+
66
+ it('populates subagent_type enum from configs', () => {
67
+ const def = createSubagentToolDefinition(configs);
68
+ const schema = def.parameters as Record<string, unknown>;
69
+ const props = schema.properties as Record<
70
+ string,
71
+ Record<string, unknown>
72
+ >;
73
+ expect(props.subagent_type.enum).toEqual(['researcher', 'coder']);
74
+ });
75
+
76
+ it('includes type descriptions in tool description', () => {
77
+ const def = createSubagentToolDefinition(configs);
78
+ expect(def.description).toContain('"researcher" (Research Agent)');
79
+ expect(def.description).toContain('"coder" (Coding Agent)');
80
+ expect(def.description).toContain('Searches and summarizes information');
81
+ expect(def.description).toContain('Writes and reviews code');
82
+ });
83
+
84
+ it('has correct name', () => {
85
+ const def = createSubagentToolDefinition(configs);
86
+ expect(def.name).toBe(Constants.SUBAGENT);
87
+ });
88
+
89
+ it('has required description and subagent_type fields', () => {
90
+ const def = createSubagentToolDefinition(configs);
91
+ const schema = def.parameters as Record<string, unknown>;
92
+ expect(schema.required).toContain('description');
93
+ expect(schema.required).toContain('subagent_type');
94
+ });
95
+
96
+ it('works with single config', () => {
97
+ const def = createSubagentToolDefinition([configs[0]]);
98
+ const schema = def.parameters as Record<string, unknown>;
99
+ const props = schema.properties as Record<
100
+ string,
101
+ Record<string, unknown>
102
+ >;
103
+ expect(props.subagent_type.enum).toEqual(['researcher']);
104
+ });
105
+ });
106
+
107
+ describe('buildSubagentToolParams', () => {
108
+ const configs: SubagentConfig[] = [
109
+ {
110
+ type: 'researcher',
111
+ name: 'Research Agent',
112
+ description: 'Searches and summarizes information',
113
+ },
114
+ {
115
+ type: 'coder',
116
+ name: 'Coding Agent',
117
+ description: 'Writes and reviews code',
118
+ },
119
+ ];
120
+
121
+ it('returns name matching Constants.SUBAGENT', () => {
122
+ const params = buildSubagentToolParams(configs);
123
+ expect(params.name).toBe(Constants.SUBAGENT);
124
+ });
125
+
126
+ it('schema has enum populated from config types', () => {
127
+ const params = buildSubagentToolParams(configs);
128
+ const props = params.schema.properties as Record<
129
+ string,
130
+ Record<string, unknown>
131
+ >;
132
+ expect(props.subagent_type.enum).toEqual(['researcher', 'coder']);
133
+ });
134
+
135
+ it('description includes type listings', () => {
136
+ const params = buildSubagentToolParams(configs);
137
+ expect(params.description).toContain('"researcher" (Research Agent)');
138
+ expect(params.description).toContain('"coder" (Coding Agent)');
139
+ });
140
+
141
+ it('produces same schema as createSubagentToolDefinition', () => {
142
+ const params = buildSubagentToolParams(configs);
143
+ const def = createSubagentToolDefinition(configs);
144
+ expect(params.name).toBe(def.name);
145
+ expect(params.description).toBe(def.description);
146
+ expect(params.schema).toEqual(def.parameters);
147
+ });
148
+ });
149
+ });
@@ -0,0 +1,215 @@
1
+ import { HumanMessage } from '@langchain/core/messages';
2
+ import { FakeListChatModel } from '@langchain/core/utils/testing';
3
+ import type { ToolCall } from '@langchain/core/messages/tool';
4
+ import type * as t from '@/types';
5
+ import type {
6
+ HookCallback,
7
+ SubagentStartHookInput,
8
+ SubagentStartHookOutput,
9
+ SubagentStopHookInput,
10
+ SubagentStopHookOutput,
11
+ } from '@/hooks/types';
12
+ import { HookRegistry } from '@/hooks/HookRegistry';
13
+ import { Run } from '@/run';
14
+ import {
15
+ Constants,
16
+ GraphEvents,
17
+ Providers,
18
+ ToolEndHandler,
19
+ ModelEndHandler,
20
+ } from '@/index';
21
+ import * as providers from '@/llm/providers';
22
+
23
+ const CHILD_RESPONSE = 'Hook test child response.';
24
+
25
+ const callerConfig = {
26
+ configurable: { thread_id: 'hook-test-thread' },
27
+ streamMode: 'values' as const,
28
+ version: 'v2' as const,
29
+ };
30
+
31
+ const originalGetChatModelClass = providers.getChatModelClass;
32
+
33
+ function makeSubagentToolCall(): ToolCall {
34
+ return {
35
+ name: Constants.SUBAGENT,
36
+ args: {
37
+ description: 'Test task for hook verification',
38
+ subagent_type: 'researcher',
39
+ },
40
+ id: `call_sub_${Date.now()}`,
41
+ type: 'tool_call',
42
+ };
43
+ }
44
+
45
+ function createParentAgent(): t.AgentInputs {
46
+ return {
47
+ agentId: 'hook-parent',
48
+ provider: Providers.OPENAI,
49
+ clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
50
+ instructions: 'Delegate research tasks to subagents.',
51
+ maxContextTokens: 8000,
52
+ subagentConfigs: [
53
+ {
54
+ type: 'researcher',
55
+ name: 'Researcher',
56
+ description: 'Researches topics',
57
+ agentInputs: {
58
+ agentId: 'researcher-child',
59
+ provider: Providers.OPENAI,
60
+ clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
61
+ instructions: 'Answer concisely.',
62
+ maxContextTokens: 8000,
63
+ },
64
+ },
65
+ ],
66
+ };
67
+ }
68
+
69
+ async function createSubagentRun(
70
+ hooks: HookRegistry,
71
+ runId = `subagent-hook-${Date.now()}`
72
+ ): Promise<Run<t.IState>> {
73
+ return Run.create<t.IState>({
74
+ runId,
75
+ graphConfig: {
76
+ type: 'standard',
77
+ agents: [createParentAgent()],
78
+ },
79
+ returnContent: true,
80
+ skipCleanup: true,
81
+ customHandlers: {
82
+ [GraphEvents.TOOL_END]: new ToolEndHandler(),
83
+ [GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
84
+ },
85
+ hooks,
86
+ });
87
+ }
88
+
89
+ describe('Subagent hook integration (end-to-end via Run)', () => {
90
+ jest.setTimeout(15000);
91
+
92
+ let getChatModelClassSpy: jest.SpyInstance;
93
+
94
+ beforeEach(() => {
95
+ getChatModelClassSpy = jest
96
+ .spyOn(providers, 'getChatModelClass')
97
+ .mockImplementation(((provider: Providers) => {
98
+ if (provider === Providers.OPENAI) {
99
+ return class extends FakeListChatModel {
100
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
101
+ constructor(_options: any) {
102
+ super({ responses: [CHILD_RESPONSE] });
103
+ }
104
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
105
+ } as any;
106
+ }
107
+ return originalGetChatModelClass(provider);
108
+ }) as typeof providers.getChatModelClass);
109
+ });
110
+
111
+ afterEach(() => {
112
+ getChatModelClassSpy.mockRestore();
113
+ });
114
+
115
+ it('SubagentStart fires with correct payload through real Run pipeline', async () => {
116
+ const registry = new HookRegistry();
117
+ let captured: SubagentStartHookInput | undefined;
118
+
119
+ const hook: HookCallback<'SubagentStart'> = async (
120
+ input
121
+ ): Promise<SubagentStartHookOutput> => {
122
+ captured = input;
123
+ return {};
124
+ };
125
+ registry.register('SubagentStart', { hooks: [hook] });
126
+
127
+ const tc = makeSubagentToolCall();
128
+ const run = await createSubagentRun(registry);
129
+ run.Graph!.overrideTestModel(['Delegating...', 'Final answer.'], 5, [tc]);
130
+
131
+ await run.processStream(
132
+ { messages: [new HumanMessage('research something')] },
133
+ callerConfig
134
+ );
135
+
136
+ expect(captured).toBeDefined();
137
+ expect(captured!.hook_event_name).toBe('SubagentStart');
138
+ expect(captured!.agentType).toBe('researcher');
139
+ expect(captured!.parentAgentId).toBe('hook-parent');
140
+ expect(captured!.threadId).toBe('hook-test-thread');
141
+ expect(captured!.inputs).toHaveLength(1);
142
+ expect(captured!.inputs[0].content).toContain(
143
+ 'Test task for hook verification'
144
+ );
145
+ });
146
+
147
+ it('SubagentStop fires with messages from child execution', async () => {
148
+ const registry = new HookRegistry();
149
+ let captured: SubagentStopHookInput | undefined;
150
+
151
+ const hook: HookCallback<'SubagentStop'> = async (
152
+ input
153
+ ): Promise<SubagentStopHookOutput> => {
154
+ captured = input;
155
+ return {};
156
+ };
157
+ registry.register('SubagentStop', { hooks: [hook] });
158
+
159
+ const tc = makeSubagentToolCall();
160
+ const run = await createSubagentRun(registry);
161
+ run.Graph!.overrideTestModel(['Delegating...', 'Final answer.'], 5, [tc]);
162
+
163
+ await run.processStream(
164
+ { messages: [new HumanMessage('research something')] },
165
+ callerConfig
166
+ );
167
+
168
+ expect(captured).toBeDefined();
169
+ expect(captured!.hook_event_name).toBe('SubagentStop');
170
+ expect(captured!.agentType).toBe('researcher');
171
+ expect(captured!.threadId).toBe('hook-test-thread');
172
+ expect(captured!.messages.length).toBeGreaterThan(0);
173
+ });
174
+
175
+ it('SubagentStart deny blocks subagent execution and returns blocked message', async () => {
176
+ const registry = new HookRegistry();
177
+ const denyHook: HookCallback<
178
+ 'SubagentStart'
179
+ > = async (): Promise<SubagentStartHookOutput> => ({
180
+ decision: 'deny',
181
+ reason: 'policy violation',
182
+ });
183
+ registry.register('SubagentStart', {
184
+ pattern: '^researcher$',
185
+ hooks: [denyHook],
186
+ });
187
+
188
+ const tc = makeSubagentToolCall();
189
+ const run = await createSubagentRun(registry);
190
+ run.Graph!.overrideTestModel(
191
+ ['Delegating...', 'The subagent was blocked.'],
192
+ 5,
193
+ [tc]
194
+ );
195
+
196
+ await run.processStream(
197
+ { messages: [new HumanMessage('research something')] },
198
+ callerConfig
199
+ );
200
+
201
+ const runMessages = run.getRunMessages();
202
+ expect(runMessages).toBeDefined();
203
+
204
+ const toolMessages = runMessages!.filter(
205
+ (msg) =>
206
+ msg._getType() === 'tool' &&
207
+ 'name' in msg &&
208
+ msg.name === Constants.SUBAGENT
209
+ );
210
+ expect(toolMessages.length).toBe(1);
211
+ expect(String(toolMessages[0].content)).toContain(
212
+ 'Blocked: policy violation'
213
+ );
214
+ });
215
+ });
@@ -0,0 +1,344 @@
1
+ import { nanoid } from 'nanoid';
2
+ import { HumanMessage } from '@langchain/core/messages';
3
+ import type { BaseMessage } from '@langchain/core/messages';
4
+ import type {
5
+ AgentInputs,
6
+ StandardGraphInput,
7
+ ResolvedSubagentConfig,
8
+ SubagentConfig,
9
+ TokenCounter,
10
+ } from '@/types';
11
+ import type { AggregatedHookResult, HookRegistry } from '@/hooks';
12
+ import type { AgentContext } from '@/agents/AgentContext';
13
+ import type { StandardGraph } from '@/graphs/Graph';
14
+ import { executeHooks } from '@/hooks';
15
+
16
+ const DEFAULT_MAX_TURNS = 25;
17
+ const RECURSION_MULTIPLIER = 3;
18
+ const ERROR_MESSAGE_MAX_CHARS = 200;
19
+
20
+ const HOOK_FALLBACK: AggregatedHookResult = Object.freeze({
21
+ additionalContexts: [] as string[],
22
+ errors: [] as string[],
23
+ });
24
+
25
+ export type SubagentExecuteParams = {
26
+ description: string;
27
+ subagentType: string;
28
+ threadId?: string;
29
+ };
30
+
31
+ export type SubagentExecuteResult = {
32
+ content: string;
33
+ messages: BaseMessage[];
34
+ };
35
+
36
+ /**
37
+ * Factory that constructs a child graph for subagent execution. Injected
38
+ * rather than imported so that `SubagentExecutor` does not have a runtime
39
+ * dependency on `StandardGraph` — this avoids a circular dependency between
40
+ * `src/graphs/Graph.ts` and `src/tools/subagent/` that would otherwise break
41
+ * Rollup's chunking under `preserveModules`.
42
+ */
43
+ export type ChildGraphFactory = (input: StandardGraphInput) => StandardGraph;
44
+
45
+ export type SubagentExecutorOptions = {
46
+ configs: Map<string, ResolvedSubagentConfig>;
47
+ parentSignal?: AbortSignal;
48
+ hookRegistry?: HookRegistry;
49
+ parentRunId: string;
50
+ parentAgentId?: string;
51
+ tokenCounter?: TokenCounter;
52
+ /** Remaining nesting budget. 0 or negative blocks execution. */
53
+ maxDepth?: number;
54
+ /**
55
+ * Factory for constructing the isolated child graph. Callers pass
56
+ * `(input) => new StandardGraph(input)` — injected to break a circular
57
+ * module dependency.
58
+ */
59
+ createChildGraph: ChildGraphFactory;
60
+ };
61
+
62
+ export class SubagentExecutor {
63
+ private readonly configs: Map<string, ResolvedSubagentConfig>;
64
+ private readonly parentSignal?: AbortSignal;
65
+ private readonly hookRegistry?: HookRegistry;
66
+ private readonly parentRunId: string;
67
+ private readonly parentAgentId?: string;
68
+ private readonly tokenCounter?: TokenCounter;
69
+ private readonly maxDepth: number;
70
+ private readonly createChildGraph: ChildGraphFactory;
71
+
72
+ constructor(options: SubagentExecutorOptions) {
73
+ this.configs = options.configs;
74
+ this.parentSignal = options.parentSignal;
75
+ this.hookRegistry = options.hookRegistry;
76
+ this.parentRunId = options.parentRunId;
77
+ this.parentAgentId = options.parentAgentId;
78
+ this.tokenCounter = options.tokenCounter;
79
+ this.maxDepth = options.maxDepth ?? 1;
80
+ this.createChildGraph = options.createChildGraph;
81
+ }
82
+
83
+ async execute(params: SubagentExecuteParams): Promise<SubagentExecuteResult> {
84
+ const { description, subagentType, threadId } = params;
85
+ const config = this.configs.get(subagentType);
86
+
87
+ if (!config) {
88
+ const available = [...this.configs.keys()].join(', ');
89
+ return {
90
+ content: `Error: Unknown subagent type "${subagentType}". Available types: ${available}`,
91
+ messages: [],
92
+ };
93
+ }
94
+
95
+ if (this.maxDepth <= 0) {
96
+ return {
97
+ content: 'Error: Maximum subagent nesting depth exceeded.',
98
+ messages: [],
99
+ };
100
+ }
101
+
102
+ const childAgentId =
103
+ config.agentInputs.agentId ||
104
+ `${this.parentAgentId ?? 'agent'}_sub_${nanoid(8)}`;
105
+
106
+ if (
107
+ this.hookRegistry?.hasHookFor('SubagentStart', this.parentRunId) === true
108
+ ) {
109
+ const hookResult = await executeHooks({
110
+ registry: this.hookRegistry,
111
+ input: {
112
+ hook_event_name: 'SubagentStart',
113
+ runId: this.parentRunId,
114
+ threadId,
115
+ parentAgentId: this.parentAgentId,
116
+ agentId: childAgentId,
117
+ agentType: subagentType,
118
+ inputs: [new HumanMessage(description)],
119
+ },
120
+ sessionId: this.parentRunId,
121
+ matchQuery: subagentType,
122
+ }).catch((): AggregatedHookResult => HOOK_FALLBACK);
123
+
124
+ /**
125
+ * `ask` is treated identically to `deny` in the subagent context:
126
+ * subagents are non-interactive, so there is no prompt path for `ask`.
127
+ * Both decisions block execution and return a "Blocked" tool result.
128
+ */
129
+ if (hookResult.decision === 'deny' || hookResult.decision === 'ask') {
130
+ return {
131
+ content: `Blocked: ${hookResult.reason ?? 'Blocked by hook'}`,
132
+ messages: [],
133
+ };
134
+ }
135
+ }
136
+
137
+ const childInputs = buildChildInputs(config, childAgentId, this.maxDepth);
138
+ const childRunId = `${this.parentRunId}_sub_${nanoid(8)}`;
139
+ const maxTurns = config.maxTurns ?? DEFAULT_MAX_TURNS;
140
+
141
+ const childGraph = this.createChildGraph({
142
+ runId: childRunId,
143
+ signal: this.parentSignal,
144
+ agents: [childInputs],
145
+ tokenCounter: this.tokenCounter,
146
+ });
147
+
148
+ let result: { messages: BaseMessage[] };
149
+ try {
150
+ const workflow = childGraph.createWorkflow();
151
+ /**
152
+ * Detach the child invocation from the parent's callback chain.
153
+ * Without this, `streamEvents` in the parent's `Run.processStream`
154
+ * captures events from the child graph's LLM calls (e.g.
155
+ * `on_chat_model_stream` for the "researcher" agent) and delivers
156
+ * them to the parent's handlers. The parent then tries to resolve
157
+ * the child's agent ID in its own `agentContexts` map and throws
158
+ * "No agent context found for agent ID …". Setting `callbacks: []`
159
+ * overrides the inherited callbacks for this invoke; combined with
160
+ * the child's own empty `handlerRegistry`/`hookRegistry`, the child
161
+ * runs fully isolated.
162
+ *
163
+ * `runName` gives the child a distinct LangSmith trace root (avoids
164
+ * nested trace pollution).
165
+ */
166
+ result = await workflow.invoke(
167
+ { messages: [new HumanMessage(description)] },
168
+ {
169
+ recursionLimit: maxTurns * RECURSION_MULTIPLIER,
170
+ signal: this.parentSignal,
171
+ callbacks: [],
172
+ runName: `subagent:${subagentType}`,
173
+ configurable: {
174
+ thread_id: childRunId,
175
+ },
176
+ }
177
+ );
178
+ } catch (error) {
179
+ childGraph.clearHeavyState();
180
+ return {
181
+ content: `Subagent error: ${truncateErrorMessage(error)}`,
182
+ messages: [],
183
+ };
184
+ }
185
+
186
+ const filteredContent = filterSubagentResult(result.messages);
187
+
188
+ if (
189
+ this.hookRegistry?.hasHookFor('SubagentStop', this.parentRunId) === true
190
+ ) {
191
+ /**
192
+ * Awaited (not fire-and-forget) for deterministic test synchronization
193
+ * and consistency with PostCompact. The parent is already waiting on the
194
+ * tool result, so the small extra latency is acceptable. Errors are
195
+ * swallowed — SubagentStop is observational.
196
+ */
197
+ await executeHooks({
198
+ registry: this.hookRegistry,
199
+ input: {
200
+ hook_event_name: 'SubagentStop',
201
+ runId: this.parentRunId,
202
+ threadId,
203
+ agentId: childAgentId,
204
+ agentType: subagentType,
205
+ messages: result.messages,
206
+ },
207
+ sessionId: this.parentRunId,
208
+ matchQuery: subagentType,
209
+ }).catch(() => {
210
+ /* SubagentStop is observational — swallow errors */
211
+ });
212
+ }
213
+
214
+ childGraph.clearHeavyState();
215
+
216
+ return { content: filteredContent, messages: result.messages };
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Walk messages from last to first, returning the text content of the most
222
+ * recent AIMessage that has any. Non-text blocks (tool_use, thinking,
223
+ * redacted_thinking, tool_result) are stripped. If the last AIMessage is
224
+ * pure tool_use (e.g. the subagent hit `maxTurns` mid-tool-call), the walk
225
+ * continues to earlier AIMessages so partial progress is salvaged — this
226
+ * matches Claude Code's behavior in `agentToolUtils.finalizeAgentTool`.
227
+ * Returns "Task completed" only when no AIMessage in the history contains
228
+ * any text.
229
+ */
230
+ export function filterSubagentResult(messages: BaseMessage[]): string {
231
+ for (let i = messages.length - 1; i >= 0; i--) {
232
+ if (messages[i]._getType() !== 'ai') {
233
+ continue;
234
+ }
235
+
236
+ const content = messages[i].content;
237
+
238
+ if (typeof content === 'string') {
239
+ if (content) return content;
240
+ continue;
241
+ }
242
+
243
+ if (!Array.isArray(content)) {
244
+ continue;
245
+ }
246
+
247
+ const textParts: string[] = [];
248
+ for (const block of content) {
249
+ if (typeof block === 'string') {
250
+ textParts.push(block);
251
+ } else if ('type' in block && block.type === 'text' && 'text' in block) {
252
+ textParts.push(block.text as string);
253
+ }
254
+ }
255
+
256
+ if (textParts.length > 0) {
257
+ return textParts.join('\n');
258
+ }
259
+ }
260
+
261
+ return 'Task completed';
262
+ }
263
+
264
+ /**
265
+ * Resolve self-spawn configs by filling in agentInputs from the parent context.
266
+ * Returns configs with agentInputs guaranteed present. Throws on duplicate
267
+ * `type` values to prevent silent config shadowing.
268
+ */
269
+ export function resolveSubagentConfigs(
270
+ configs: SubagentConfig[],
271
+ parentContext: AgentContext
272
+ ): ResolvedSubagentConfig[] {
273
+ const resolved = configs
274
+ .map((config) => {
275
+ if (config.agentInputs != null) {
276
+ return config as ResolvedSubagentConfig;
277
+ }
278
+ if (config.self !== true || parentContext._sourceInputs == null) {
279
+ return null;
280
+ }
281
+ return {
282
+ ...config,
283
+ agentInputs: { ...parentContext._sourceInputs },
284
+ } as ResolvedSubagentConfig;
285
+ })
286
+ .filter((c): c is ResolvedSubagentConfig => c != null);
287
+
288
+ const seenTypes = new Set<string>();
289
+ for (const config of resolved) {
290
+ if (seenTypes.has(config.type)) {
291
+ throw new Error(
292
+ `Duplicate subagent type "${config.type}". Each SubagentConfig must have a unique "type" field.`
293
+ );
294
+ }
295
+ seenTypes.add(config.type);
296
+ }
297
+
298
+ return resolved;
299
+ }
300
+
301
+ /**
302
+ * Build child AgentInputs from a resolved config, stripping nesting and
303
+ * event-driven fields. When `allowNested: true`, the child's
304
+ * `maxSubagentDepth` is decremented so that depth is consumed as the call
305
+ * chain deepens across graph boundaries — the parent's executor-level check
306
+ * alone cannot see into the child graph's separate executor.
307
+ *
308
+ * @remarks Advanced utility: exported primarily for testing and by
309
+ * {@link SubagentExecutor}. Host applications configuring subagents should
310
+ * not need to call this directly — it is invoked internally when a subagent
311
+ * tool is dispatched. The depth-countdown contract (parent's `maxDepth` in,
312
+ * child's decremented `maxSubagentDepth` on the returned inputs) is the
313
+ * mechanism that bounds nesting across graph boundaries; callers must
314
+ * respect it.
315
+ */
316
+ export function buildChildInputs(
317
+ config: ResolvedSubagentConfig,
318
+ childAgentId: string,
319
+ parentMaxDepth: number
320
+ ): AgentInputs {
321
+ const { agentInputs } = config;
322
+ const childInputs: AgentInputs = {
323
+ ...agentInputs,
324
+ agentId: childAgentId,
325
+ toolDefinitions: undefined,
326
+ };
327
+
328
+ if (config.allowNested === true) {
329
+ childInputs.maxSubagentDepth = Math.max(0, parentMaxDepth - 1);
330
+ } else {
331
+ childInputs.subagentConfigs = undefined;
332
+ childInputs.maxSubagentDepth = undefined;
333
+ }
334
+
335
+ return childInputs;
336
+ }
337
+
338
+ function truncateErrorMessage(error: unknown): string {
339
+ const message = error instanceof Error ? error.message : String(error);
340
+ if (message.length <= ERROR_MESSAGE_MAX_CHARS) {
341
+ return message;
342
+ }
343
+ return `${message.slice(0, ERROR_MESSAGE_MAX_CHARS)}...`;
344
+ }