feat(subagents): add multi-turn subagent session runtime
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user