feat(subagents): add idle ttl cleanup and summary tool
This commit is contained in:
@@ -136,6 +136,7 @@ describe('SubagentManager', () => {
|
||||
'subagent.list',
|
||||
'subagent.cancel',
|
||||
'subagent.delete',
|
||||
'subagent.summary',
|
||||
]) {
|
||||
tools.register({
|
||||
name,
|
||||
@@ -163,6 +164,7 @@ describe('SubagentManager', () => {
|
||||
defaultPrimaryTier: 'default',
|
||||
maxIterations: 12,
|
||||
maxActiveSessions: 2,
|
||||
idleTtlMs: 60000,
|
||||
});
|
||||
|
||||
const spawned = manager.spawn({ agent: 'research', subagentId: 'planner' });
|
||||
@@ -177,6 +179,7 @@ describe('SubagentManager', () => {
|
||||
expect(childToolNames).not.toContain('agent.delegate');
|
||||
expect(childToolNames).not.toContain('council.run');
|
||||
expect(childToolNames).not.toContain('subagent.spawn');
|
||||
expect(childToolNames).not.toContain('subagent.summary');
|
||||
|
||||
const firstSend = await manager.send('planner', 'Draft a rollout plan');
|
||||
expect(firstSend.content).toBe('subagent:Draft a rollout plan');
|
||||
@@ -186,6 +189,10 @@ describe('SubagentManager', () => {
|
||||
expect(listed).toHaveLength(1);
|
||||
expect(listed[0].id).toBe('planner');
|
||||
expect(listed[0].messageCount).toBe(2);
|
||||
const transcript = manager.getTranscript('planner');
|
||||
expect(transcript.messages).toHaveLength(2);
|
||||
expect(transcript.messages[0].role).toBe('user');
|
||||
expect(transcript.messages[1].role).toBe('assistant');
|
||||
|
||||
expect(manager.cancel('planner')).toBe(true);
|
||||
expect(mocks.cancelCalls).toBe(1);
|
||||
@@ -214,6 +221,7 @@ describe('SubagentManager', () => {
|
||||
maxDelegationDepth: 3,
|
||||
defaultPrimaryTier: 'default',
|
||||
maxActiveSessions: 1,
|
||||
idleTtlMs: 60000,
|
||||
});
|
||||
|
||||
manager.spawn({ agent: 'helper', subagentId: 'one' });
|
||||
@@ -242,8 +250,38 @@ describe('SubagentManager', () => {
|
||||
maxDelegationDepth: 3,
|
||||
defaultPrimaryTier: 'default',
|
||||
maxActiveSessions: 3,
|
||||
idleTtlMs: 60000,
|
||||
});
|
||||
|
||||
expect(() => manager.spawn({ agent: 'unknown' })).toThrow('not found');
|
||||
});
|
||||
|
||||
it('evicts idle subagent sessions based on ttl', async () => {
|
||||
const sessionManager = createSessionManagerMock();
|
||||
const manager = new SubagentManager({
|
||||
parentSessionId: 'telegram:dave',
|
||||
modelRouter: {} as never,
|
||||
sessionManager: sessionManager.api as never,
|
||||
toolRegistry: new ToolRegistry(),
|
||||
toolExecutor: {} as never,
|
||||
agentConfigRegistry: createAgentRegistryMock() as never,
|
||||
delegation: {
|
||||
compaction: 'fast',
|
||||
memory_extraction: 'fast',
|
||||
classification: 'fast',
|
||||
tool_summarisation: 'fast',
|
||||
complex_reasoning: 'complex',
|
||||
},
|
||||
maxDelegationDepth: 3,
|
||||
defaultPrimaryTier: 'default',
|
||||
maxActiveSessions: 3,
|
||||
idleTtlMs: 1000,
|
||||
});
|
||||
|
||||
manager.spawn({ agent: 'helper', subagentId: 'ttl-one' });
|
||||
await manager.send('ttl-one', 'hello');
|
||||
const removed = manager.cleanupExpired(Date.now() + 2000);
|
||||
expect(removed).toEqual(['ttl-one']);
|
||||
expect(manager.list()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { AgentConfigRegistry } from '../../agents/registry.js';
|
||||
import type { Message } from '../../models/types.js';
|
||||
import type { ToolPolicyContext } from '../../tools/policy.js';
|
||||
import type { ModelRouter, ModelTier } from '../../models/router.js';
|
||||
import type { SessionManager } from '../../session/manager.js';
|
||||
@@ -17,6 +18,7 @@ const BLOCKED_SUBAGENT_TOOL_NAMES = [
|
||||
'subagent.list',
|
||||
'subagent.cancel',
|
||||
'subagent.delete',
|
||||
'subagent.summary',
|
||||
];
|
||||
|
||||
export interface SubagentManagerConfig {
|
||||
@@ -31,6 +33,7 @@ export interface SubagentManagerConfig {
|
||||
defaultPrimaryTier: ModelTier;
|
||||
maxIterations?: number;
|
||||
maxActiveSessions: number;
|
||||
idleTtlMs: number;
|
||||
toolPolicyContext?: ToolPolicyContext;
|
||||
}
|
||||
|
||||
@@ -67,6 +70,17 @@ export interface SubagentSendResult {
|
||||
session: SubagentSessionSummary;
|
||||
}
|
||||
|
||||
export interface SubagentTranscriptEntry {
|
||||
role: string;
|
||||
content: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
export interface SubagentTranscriptResult {
|
||||
session: SubagentSessionSummary;
|
||||
messages: SubagentTranscriptEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages multi-turn child subagent sessions scoped to a parent session.
|
||||
*/
|
||||
@@ -76,6 +90,8 @@ export class SubagentManager {
|
||||
constructor(private readonly config: SubagentManagerConfig) {}
|
||||
|
||||
spawn(request: SpawnSubagentRequest): SubagentSessionSummary {
|
||||
this.cleanupExpired();
|
||||
|
||||
const agentName = request.agent.trim();
|
||||
if (!agentName) {
|
||||
throw new Error('agent is required');
|
||||
@@ -149,6 +165,8 @@ export class SubagentManager {
|
||||
}
|
||||
|
||||
async send(subagentId: string, message: string): Promise<SubagentSendResult> {
|
||||
this.cleanupExpired();
|
||||
|
||||
const subagent = this.requireSubagent(subagentId);
|
||||
const trimmed = message.trim();
|
||||
if (!trimmed) {
|
||||
@@ -170,6 +188,8 @@ export class SubagentManager {
|
||||
}
|
||||
|
||||
cancel(subagentId: string): boolean {
|
||||
this.cleanupExpired();
|
||||
|
||||
const subagent = this.requireSubagent(subagentId);
|
||||
if (!subagent.orchestrator.isCancellable()) {
|
||||
return false;
|
||||
@@ -197,11 +217,48 @@ export class SubagentManager {
|
||||
}
|
||||
|
||||
list(): SubagentSessionSummary[] {
|
||||
this.cleanupExpired();
|
||||
|
||||
return [...this.sessions.values()]
|
||||
.map((entry) => this.getSummary(entry))
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
getTranscript(subagentId: string, limit?: number): SubagentTranscriptResult {
|
||||
this.cleanupExpired();
|
||||
|
||||
const subagent = this.requireSubagent(subagentId);
|
||||
const session = this.config.sessionManager.getSession(SUBAGENT_FRONTEND, subagent.sessionUserId);
|
||||
const history = session.getHistory();
|
||||
const max = typeof limit === 'number' && Number.isFinite(limit) && limit > 0
|
||||
? Math.floor(limit)
|
||||
: history.length;
|
||||
const tail = history.slice(Math.max(0, history.length - max));
|
||||
const messages = tail.map((entry) => this.toTranscriptEntry(entry));
|
||||
return {
|
||||
session: this.getSummary(subagent),
|
||||
messages,
|
||||
};
|
||||
}
|
||||
|
||||
cleanupExpired(nowMs: number = Date.now()): string[] {
|
||||
if (this.config.idleTtlMs <= 0) {
|
||||
return [];
|
||||
}
|
||||
const removed: string[] = [];
|
||||
for (const [id, session] of this.sessions.entries()) {
|
||||
if (session.busy) {
|
||||
continue;
|
||||
}
|
||||
if ((nowMs - session.updatedAt) <= this.config.idleTtlMs) {
|
||||
continue;
|
||||
}
|
||||
this.delete(id);
|
||||
removed.push(id);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
private resolveSubagentId(rawId: string | undefined): string {
|
||||
const explicit = rawId?.trim();
|
||||
if (explicit) {
|
||||
@@ -236,4 +293,12 @@ export class SubagentManager {
|
||||
busy: subagent.busy,
|
||||
};
|
||||
}
|
||||
|
||||
private toTranscriptEntry(entry: Message): SubagentTranscriptEntry {
|
||||
return {
|
||||
role: entry.role,
|
||||
content: typeof entry.content === 'string' ? entry.content : '[multipart]',
|
||||
timestamp: entry.timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1700,6 +1700,7 @@ describe('configSchema — agents truthfulness/autonomy', () => {
|
||||
expect(result.agents.sensitive_mode).toBe('confirm_without_elevation');
|
||||
expect(result.agents.subagents.enabled).toBe(true);
|
||||
expect(result.agents.subagents.max_active_sessions).toBe(6);
|
||||
expect(result.agents.subagents.idle_ttl_ms).toBe(3600000);
|
||||
expect(result.agents.immutable_denylist).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ tool: 'shell.exec', args_pattern: 'git push origin main' }),
|
||||
@@ -1718,6 +1719,7 @@ describe('configSchema — agents truthfulness/autonomy', () => {
|
||||
subagents: {
|
||||
enabled: false,
|
||||
max_active_sessions: 3,
|
||||
idle_ttl_ms: 120000,
|
||||
},
|
||||
immutable_denylist: [
|
||||
{ tool: 'shell.exec', args_pattern: 'rm -rf /', reason: 'too destructive' },
|
||||
@@ -1730,6 +1732,7 @@ describe('configSchema — agents truthfulness/autonomy', () => {
|
||||
expect(result.agents.sensitive_mode).toBe('confirm_without_elevation');
|
||||
expect(result.agents.subagents.enabled).toBe(false);
|
||||
expect(result.agents.subagents.max_active_sessions).toBe(3);
|
||||
expect(result.agents.subagents.idle_ttl_ms).toBe(120000);
|
||||
expect(result.agents.immutable_denylist).toEqual([
|
||||
{ tool: 'shell.exec', args_pattern: 'rm -rf /', reason: 'too destructive' },
|
||||
]);
|
||||
@@ -1746,6 +1749,17 @@ describe('configSchema — agents truthfulness/autonomy', () => {
|
||||
})).toThrow();
|
||||
});
|
||||
|
||||
it('rejects invalid subagent idle ttl', () => {
|
||||
expect(() => configSchema.parse({
|
||||
...minimalConfig,
|
||||
agents: {
|
||||
subagents: {
|
||||
idle_ttl_ms: 5000,
|
||||
},
|
||||
},
|
||||
})).toThrow();
|
||||
});
|
||||
|
||||
it('rejects invalid truthfulness_mode', () => {
|
||||
expect(() => configSchema.parse({
|
||||
...minimalConfig,
|
||||
|
||||
@@ -538,6 +538,7 @@ const agentsSchema = z.object({
|
||||
subagents: z.object({
|
||||
enabled: z.boolean().default(true),
|
||||
max_active_sessions: z.number().min(1).max(32).default(6),
|
||||
idle_ttl_ms: z.number().min(60_000).max(86_400_000).default(3_600_000),
|
||||
}).default({}),
|
||||
auto_escalate: z.boolean().default(false),
|
||||
max_delegation_depth: z.number().min(1).max(10).default(3),
|
||||
|
||||
@@ -697,6 +697,7 @@ export function createMessageRouter(deps: {
|
||||
defaultPrimaryTier: effectiveTier,
|
||||
maxIterations: deps.config.agents.max_iterations,
|
||||
maxActiveSessions: maxSubagentSessions,
|
||||
idleTtlMs: deps.config.agents.subagents?.idle_ttl_ms ?? 3_600_000,
|
||||
});
|
||||
for (const tool of createSubagentTools(subagentManager)) {
|
||||
effectiveToolRegistry.register(tool);
|
||||
@@ -1049,6 +1050,7 @@ export function createMessageRouter(deps: {
|
||||
names.add('subagent.list');
|
||||
names.add('subagent.cancel');
|
||||
names.add('subagent.delete');
|
||||
names.add('subagent.summary');
|
||||
}
|
||||
const sorted = [...names].sort();
|
||||
return [
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user