feat(subagents): add idle ttl cleanup and summary tool

This commit is contained in:
William Valentin
2026-02-26 13:12:53 -08:00
parent 2171346116
commit b679261683
17 changed files with 226 additions and 15 deletions
+23
View File
@@ -5,6 +5,7 @@ const mockController = {
spawn: vi.fn(),
send: vi.fn(),
list: vi.fn(),
getTranscript: vi.fn(),
cancel: vi.fn(),
delete: vi.fn(),
};
@@ -14,6 +15,7 @@ describe('subagent tools', () => {
mockController.spawn.mockReset();
mockController.send.mockReset();
mockController.list.mockReset();
mockController.getTranscript.mockReset();
mockController.cancel.mockReset();
mockController.delete.mockReset();
});
@@ -89,12 +91,28 @@ describe('subagent tools', () => {
]);
mockController.cancel.mockReturnValue(true);
mockController.delete.mockReturnValue(true);
mockController.getTranscript.mockReturnValue({
session: {
id: 'planner',
agent: 'research',
tier: 'complex',
messageCount: 4,
createdAt: 1,
updatedAt: 3,
busy: false,
},
messages: [
{ role: 'user', content: 'Refine the plan' },
{ role: 'assistant', content: 'Follow-up answer' },
],
});
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 summary = tools.find((tool) => tool.name === 'subagent.summary');
const del = tools.find((tool) => tool.name === 'subagent.delete');
const sendResult = await send!.execute({ subagent_id: 'planner', message: 'Refine the plan' });
@@ -109,6 +127,11 @@ describe('subagent tools', () => {
expect(cancelResult.success).toBe(true);
expect(cancelResult.output).toContain('Cancellation requested');
const summaryResult = await summary!.execute({ subagent_id: 'planner' });
expect(summaryResult.success).toBe(true);
expect(summaryResult.output).toContain('Subagent summary');
expect(summaryResult.output).toContain('transcript_messages=2');
const deleteResult = await del!.execute({ subagent_id: 'planner' });
expect(deleteResult.success).toBe(true);
expect(deleteResult.output).toContain('Deleted subagent session');
+61 -1
View File
@@ -23,6 +23,14 @@ interface SubagentController {
session: SubagentSessionSummary;
}>;
list(): SubagentSessionSummary[];
getTranscript(subagentId: string, limit?: number): {
session: SubagentSessionSummary;
messages: Array<{
role: string;
content: string;
timestamp?: number;
}>;
};
cancel(subagentId: string): boolean;
delete(subagentId: string): boolean;
}
@@ -44,6 +52,11 @@ interface SessionArgs {
subagent_id: string;
}
interface SummaryArgs {
subagent_id: string;
limit?: number;
}
function formatSummary(summary: SubagentSessionSummary): string {
return [
`id=${summary.id}`,
@@ -204,6 +217,53 @@ export function createSubagentTools(controller: SubagentController): Tool[] {
},
};
const summaryTool: Tool = {
name: 'subagent.summary',
description: 'Get a compact transcript summary for a subagent session.',
inputSchema: {
type: 'object',
properties: {
subagent_id: { type: 'string', description: 'Subagent session ID to summarize.' },
limit: { type: 'number', description: 'Maximum number of trailing messages to include (default 20).' },
},
required: ['subagent_id'],
},
execute: async (rawArgs: unknown): Promise<ToolResult> => {
try {
const args = rawArgs as SummaryArgs;
const transcript = controller.getTranscript(args.subagent_id, args.limit ?? 20);
const userCount = transcript.messages.filter((entry) => entry.role === 'user').length;
const assistantCount = transcript.messages.filter((entry) => entry.role === 'assistant').length;
const lastUser = [...transcript.messages].reverse().find((entry) => entry.role === 'user');
const lastAssistant = [...transcript.messages].reverse().find((entry) => entry.role === 'assistant');
const previewLines = transcript.messages.map((entry, idx) => (
`${idx + 1}. [${entry.role}] ${entry.content.slice(0, 200)}`
));
return {
success: true,
output: [
`Subagent summary (${formatSummary(transcript.session)}):`,
`- transcript_messages=${transcript.messages.length}`,
`- user_messages=${userCount}`,
`- assistant_messages=${assistantCount}`,
`- last_user=${lastUser ? JSON.stringify(lastUser.content.slice(0, 200)) : 'none'}`,
`- last_assistant=${lastAssistant ? JSON.stringify(lastAssistant.content.slice(0, 200)) : 'none'}`,
'',
'Transcript:',
...(previewLines.length > 0 ? previewLines : ['(empty)']),
].join('\n'),
};
} 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.',
@@ -239,5 +299,5 @@ export function createSubagentTools(controller: SubagentController): Tool[] {
},
};
return [spawnTool, sendTool, listTool, cancelTool, deleteTool];
return [spawnTool, sendTool, listTool, cancelTool, summaryTool, deleteTool];
}
+1
View File
@@ -113,6 +113,7 @@ describe('PROFILE_TOOLS', () => {
expect(PROFILE_TOOLS.coding.has('file.write')).toBe(true);
expect(PROFILE_TOOLS.coding.has('process.start')).toBe(true);
expect(PROFILE_TOOLS.coding.has('subagent.send')).toBe(true);
expect(PROFILE_TOOLS.coding.has('subagent.summary')).toBe(true);
});
it('full is empty (special: matches everything)', () => {
+3
View File
@@ -55,6 +55,7 @@ const PROFILE_TOOLS: Record<ToolProfile, Set<string>> = {
'subagent.list',
'subagent.cancel',
'subagent.delete',
'subagent.summary',
]),
coding: new Set([
'file.read',
@@ -117,6 +118,7 @@ const PROFILE_TOOLS: Record<ToolProfile, Set<string>> = {
'subagent.list',
'subagent.cancel',
'subagent.delete',
'subagent.summary',
]),
full: new Set(), // Special: matches everything
};
@@ -146,6 +148,7 @@ export const TOOL_GROUPS: Record<string, string[]> = {
'subagent.list',
'subagent.cancel',
'subagent.delete',
'subagent.summary',
],
};