feat(subagents): add multi-turn subagent session runtime

This commit is contained in:
William Valentin
2026-02-26 13:07:34 -08:00
parent e887c3c964
commit 2171346116
21 changed files with 1111 additions and 12 deletions
+1
View File
@@ -37,6 +37,7 @@ export { createAgentDelegateTool } from './agent-delegate.js';
export type { AgentDelegateDeps } from './agent-delegate.js';
export { createCouncilRunTool } from './council-run.js';
export type { CouncilRunDeps } from './council-run.js';
export { createSubagentTools } from './subagents.js';
import type { Tool } from '../types.js';
import type { MemoryStore } from '../../memory/store.js';
+136
View File
@@ -0,0 +1,136 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createSubagentTools } from './subagents.js';
const mockController = {
spawn: vi.fn(),
send: vi.fn(),
list: vi.fn(),
cancel: vi.fn(),
delete: vi.fn(),
};
describe('subagent tools', () => {
beforeEach(() => {
mockController.spawn.mockReset();
mockController.send.mockReset();
mockController.list.mockReset();
mockController.cancel.mockReset();
mockController.delete.mockReset();
});
it('spawns a subagent and optionally runs initial task', async () => {
mockController.spawn.mockReturnValue({
id: 'planner',
agent: 'research',
tier: 'complex',
messageCount: 0,
createdAt: 1,
updatedAt: 1,
busy: false,
});
mockController.send.mockResolvedValue({
content: 'Initial answer',
session: {
id: 'planner',
agent: 'research',
tier: 'complex',
messageCount: 2,
createdAt: 1,
updatedAt: 2,
busy: false,
},
});
const tools = createSubagentTools(mockController);
const spawn = tools.find((tool) => tool.name === 'subagent.spawn');
expect(spawn).toBeDefined();
const result = await spawn!.execute({
agent: 'research',
subagent_id: 'planner',
task: 'Create a checklist',
});
expect(result.success).toBe(true);
expect(result.output).toContain('Spawned subagent');
expect(result.output).toContain('Initial answer');
expect(mockController.spawn).toHaveBeenCalledWith({
agent: 'research',
subagentId: 'planner',
tier: undefined,
systemPrompt: undefined,
});
expect(mockController.send).toHaveBeenCalledWith('planner', 'Create a checklist');
});
it('sends to, lists, cancels, and deletes subagent sessions', async () => {
mockController.send.mockResolvedValue({
content: 'Follow-up answer',
session: {
id: 'planner',
agent: 'research',
tier: 'complex',
messageCount: 4,
createdAt: 1,
updatedAt: 3,
busy: false,
},
});
mockController.list.mockReturnValue([
{
id: 'planner',
agent: 'research',
tier: 'complex',
messageCount: 4,
createdAt: 1,
updatedAt: 3,
busy: false,
},
]);
mockController.cancel.mockReturnValue(true);
mockController.delete.mockReturnValue(true);
const tools = createSubagentTools(mockController);
const send = tools.find((tool) => tool.name === 'subagent.send');
const list = tools.find((tool) => tool.name === 'subagent.list');
const cancel = tools.find((tool) => tool.name === 'subagent.cancel');
const del = tools.find((tool) => tool.name === 'subagent.delete');
const sendResult = await send!.execute({ subagent_id: 'planner', message: 'Refine the plan' });
expect(sendResult.success).toBe(true);
expect(sendResult.output).toContain('Follow-up answer');
const listResult = await list!.execute({});
expect(listResult.success).toBe(true);
expect(listResult.output).toContain('Active subagents (1)');
const cancelResult = await cancel!.execute({ subagent_id: 'planner' });
expect(cancelResult.success).toBe(true);
expect(cancelResult.output).toContain('Cancellation requested');
const deleteResult = await del!.execute({ subagent_id: 'planner' });
expect(deleteResult.success).toBe(true);
expect(deleteResult.output).toContain('Deleted subagent session');
});
it('returns structured failures when controller operations fail', async () => {
mockController.spawn.mockImplementation(() => {
throw new Error('spawn failed');
});
mockController.delete.mockReturnValue(false);
const tools = createSubagentTools(mockController);
const spawn = tools.find((tool) => tool.name === 'subagent.spawn');
const del = tools.find((tool) => tool.name === 'subagent.delete');
const spawnResult = await spawn!.execute({ agent: 'research' });
expect(spawnResult.success).toBe(false);
expect(spawnResult.error).toBe('spawn failed');
const deleteResult = await del!.execute({ subagent_id: 'missing' });
expect(deleteResult.success).toBe(false);
expect(deleteResult.error).toContain('not found');
});
});
+243
View File
@@ -0,0 +1,243 @@
import type { Tool, ToolResult } from '../types.js';
import type { ModelTier } from '../../models/router.js';
interface SubagentSessionSummary {
id: string;
agent: string;
tier: ModelTier;
messageCount: number;
createdAt: number;
updatedAt: number;
busy: boolean;
}
interface SubagentController {
spawn(request: {
agent: string;
subagentId?: string;
tier?: ModelTier;
systemPrompt?: string;
}): SubagentSessionSummary;
send(subagentId: string, message: string): Promise<{
content: string;
session: SubagentSessionSummary;
}>;
list(): SubagentSessionSummary[];
cancel(subagentId: string): boolean;
delete(subagentId: string): boolean;
}
interface SpawnArgs {
agent: string;
subagent_id?: string;
tier?: ModelTier;
system_prompt?: string;
task?: string;
}
interface SendArgs {
subagent_id: string;
message: string;
}
interface SessionArgs {
subagent_id: string;
}
function formatSummary(summary: SubagentSessionSummary): string {
return [
`id=${summary.id}`,
`agent=${summary.agent}`,
`tier=${summary.tier}`,
`messages=${summary.messageCount}`,
`busy=${summary.busy ? 'yes' : 'no'}`,
].join(' ');
}
/**
* Creates subagent session tools for multi-turn child-agent workflows.
*/
export function createSubagentTools(controller: SubagentController): Tool[] {
const spawnTool: Tool = {
name: 'subagent.spawn',
description:
'Create a new subagent session bound to a configured agent profile. Optionally run an initial task immediately.',
inputSchema: {
type: 'object',
properties: {
agent: { type: 'string', description: 'Agent profile name from agent_configs (e.g. research, coder).' },
subagent_id: { type: 'string', description: 'Optional custom subagent session ID.' },
tier: { type: 'string', description: 'Optional model tier override (fast|default|complex|local).' },
system_prompt: { type: 'string', description: 'Optional system prompt override for this subagent session.' },
task: { type: 'string', description: 'Optional initial task to run right after spawn.' },
},
required: ['agent'],
},
execute: async (rawArgs: unknown): Promise<ToolResult> => {
try {
const args = rawArgs as SpawnArgs;
const summary = controller.spawn({
agent: args.agent,
subagentId: args.subagent_id,
tier: args.tier,
systemPrompt: args.system_prompt,
});
if (typeof args.task === 'string' && args.task.trim().length > 0) {
const first = await controller.send(summary.id, args.task);
return {
success: true,
output: [
`Spawned subagent: ${formatSummary(first.session)}`,
'',
first.content,
].join('\n'),
};
}
return {
success: true,
output: `Spawned subagent: ${formatSummary(summary)}`,
};
} catch (error) {
return {
success: false,
output: '',
error: error instanceof Error ? error.message : String(error),
};
}
},
};
const sendTool: Tool = {
name: 'subagent.send',
description: 'Send a message/task to a spawned subagent session and return the response.',
inputSchema: {
type: 'object',
properties: {
subagent_id: { type: 'string', description: 'Subagent session ID returned by subagent.spawn.' },
message: { type: 'string', description: 'Task/message for the subagent session.' },
},
required: ['subagent_id', 'message'],
},
execute: async (rawArgs: unknown): Promise<ToolResult> => {
try {
const args = rawArgs as SendArgs;
const result = await controller.send(args.subagent_id, args.message);
return {
success: true,
output: [
`Subagent response (${formatSummary(result.session)}):`,
'',
result.content,
].join('\n'),
};
} catch (error) {
return {
success: false,
output: '',
error: error instanceof Error ? error.message : String(error),
};
}
},
};
const listTool: Tool = {
name: 'subagent.list',
description: 'List active spawned subagent sessions for this parent session.',
inputSchema: {
type: 'object',
properties: {},
},
execute: async (): Promise<ToolResult> => {
try {
const sessions = controller.list();
if (sessions.length === 0) {
return {
success: true,
output: 'No active subagent sessions.',
};
}
const lines = sessions.map((session) => `- ${formatSummary(session)}`);
return {
success: true,
output: `Active subagents (${sessions.length}):\n${lines.join('\n')}`,
};
} catch (error) {
return {
success: false,
output: '',
error: error instanceof Error ? error.message : String(error),
};
}
},
};
const cancelTool: Tool = {
name: 'subagent.cancel',
description: 'Request cancellation for a running subagent session turn.',
inputSchema: {
type: 'object',
properties: {
subagent_id: { type: 'string', description: 'Subagent session ID to cancel.' },
},
required: ['subagent_id'],
},
execute: async (rawArgs: unknown): Promise<ToolResult> => {
try {
const args = rawArgs as SessionArgs;
const cancelled = controller.cancel(args.subagent_id);
return {
success: true,
output: cancelled
? `Cancellation requested for subagent \"${args.subagent_id}\".`
: `No active operation to cancel for subagent \"${args.subagent_id}\".`,
};
} catch (error) {
return {
success: false,
output: '',
error: error instanceof Error ? error.message : String(error),
};
}
},
};
const deleteTool: Tool = {
name: 'subagent.delete',
description: 'Delete a subagent session and clear its conversation state.',
inputSchema: {
type: 'object',
properties: {
subagent_id: { type: 'string', description: 'Subagent session ID to delete.' },
},
required: ['subagent_id'],
},
execute: async (rawArgs: unknown): Promise<ToolResult> => {
try {
const args = rawArgs as SessionArgs;
const deleted = controller.delete(args.subagent_id);
if (!deleted) {
return {
success: false,
output: '',
error: `Subagent session \"${args.subagent_id}\" not found.`,
};
}
return {
success: true,
output: `Deleted subagent session \"${args.subagent_id}\".`,
};
} catch (error) {
return {
success: false,
output: '',
error: error instanceof Error ? error.message : String(error),
};
}
},
};
return [spawnTool, sendTool, listTool, cancelTool, deleteTool];
}