@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.
- package/dist/cjs/agents/AgentContext.cjs +47 -18
- package/dist/cjs/agents/AgentContext.cjs.map +1 -1
- package/dist/cjs/common/enum.cjs +1 -0
- package/dist/cjs/common/enum.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +69 -0
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/hooks/types.cjs.map +1 -1
- package/dist/cjs/main.cjs +12 -0
- package/dist/cjs/main.cjs.map +1 -1
- package/dist/cjs/summarization/node.cjs +44 -0
- package/dist/cjs/summarization/node.cjs.map +1 -1
- package/dist/cjs/tools/SubagentTool.cjs +92 -0
- package/dist/cjs/tools/SubagentTool.cjs.map +1 -0
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +261 -0
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -0
- package/dist/esm/agents/AgentContext.mjs +47 -18
- package/dist/esm/agents/AgentContext.mjs.map +1 -1
- package/dist/esm/common/enum.mjs +1 -0
- package/dist/esm/common/enum.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +69 -0
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/hooks/types.mjs.map +1 -1
- package/dist/esm/main.mjs +2 -0
- package/dist/esm/main.mjs.map +1 -1
- package/dist/esm/summarization/node.mjs +44 -0
- package/dist/esm/summarization/node.mjs.map +1 -1
- package/dist/esm/tools/SubagentTool.mjs +85 -0
- package/dist/esm/tools/SubagentTool.mjs.map +1 -0
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +256 -0
- package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -0
- package/dist/types/agents/AgentContext.d.ts +12 -0
- package/dist/types/common/enum.d.ts +2 -1
- package/dist/types/hooks/types.d.ts +12 -1
- package/dist/types/index.d.ts +2 -0
- package/dist/types/summarization/node.d.ts +2 -0
- package/dist/types/tools/SubagentTool.d.ts +36 -0
- package/dist/types/tools/subagent/SubagentExecutor.d.ts +83 -0
- package/dist/types/tools/subagent/index.d.ts +2 -0
- package/dist/types/types/graph.d.ts +25 -0
- package/dist/types/types/llm.d.ts +14 -2
- package/package.json +2 -1
- package/src/agents/AgentContext.ts +54 -17
- package/src/agents/__tests__/AgentContext.test.ts +110 -0
- package/src/common/enum.ts +1 -0
- package/src/graphs/Graph.ts +88 -0
- package/src/hooks/__tests__/compactHooks.test.ts +214 -0
- package/src/hooks/index.ts +4 -2
- package/src/hooks/types.ts +17 -1
- package/src/index.ts +2 -0
- package/src/scripts/multi-agent-subagent.ts +246 -0
- package/src/specs/subagent.test.ts +305 -0
- package/src/summarization/node.ts +53 -0
- package/src/tools/SubagentTool.ts +100 -0
- package/src/tools/__tests__/SubagentExecutor.test.ts +615 -0
- package/src/tools/__tests__/SubagentTool.test.ts +149 -0
- package/src/tools/__tests__/subagentHooks.test.ts +215 -0
- package/src/tools/subagent/SubagentExecutor.ts +344 -0
- package/src/tools/subagent/index.ts +12 -0
- package/src/types/graph.ts +27 -0
- 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
|
+
}
|