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
+5
View File
@@ -6,6 +6,11 @@ export {
type SubAgentResult,
type DelegationConfig,
type UsageReport,
SubagentManager,
type SubagentManagerConfig,
type SpawnSubagentRequest,
type SubagentSessionSummary,
type SubagentSendResult,
} from './native/index.js';
export {
COMPACTION_SYSTEM_PROMPT,
+7
View File
@@ -14,3 +14,10 @@ export {
CLASSIFICATION_PROMPT,
TOOL_SUMMARISATION_PROMPT,
} from './prompts.js';
export {
SubagentManager,
type SubagentManagerConfig,
type SpawnSubagentRequest,
type SubagentSessionSummary,
type SubagentSendResult,
} from './subagents.js';
+249
View File
@@ -0,0 +1,249 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ToolRegistry } from '../../tools/registry.js';
const mocks = vi.hoisted(() => {
const processCalls: string[] = [];
return {
ctorConfigs: [] as Array<Record<string, unknown>>,
processCalls,
cancellable: true,
cancelCalls: 0,
};
});
vi.mock('./orchestrator.js', () => {
class AgentOrchestrator {
private readonly session: {
addMessage(message: { role: string; content: string }): void;
getHistory(): Array<{ role: string; content: string }>;
};
constructor(config: Record<string, unknown>) {
mocks.ctorConfigs.push(config);
this.session = config.session as {
addMessage(message: { role: string; content: string }): void;
getHistory(): Array<{ role: string; content: string }>;
};
}
async process(message: string): Promise<string> {
mocks.processCalls.push(message);
const output = `subagent:${message}`;
this.session.addMessage({ role: 'user', content: message });
this.session.addMessage({ role: 'assistant', content: output });
return output;
}
isCancellable(): boolean {
return mocks.cancellable;
}
cancel(): void {
mocks.cancelCalls += 1;
}
}
return { AgentOrchestrator };
});
import { SubagentManager } from './subagents.js';
type TestSession = {
id: string;
addMessage: (message: { role: string; content: string }) => void;
getHistory: () => Array<{ role: string; content: string }>;
clear: () => void;
replaceHistory: (messages: Array<{ role: string; content: string }>) => void;
getConfig: (_key: string) => string | undefined;
setConfig: (_key: string, _value: string) => void;
deleteConfig: (_key: string) => void;
};
function createSessionManagerMock() {
const sessions = new Map<string, { history: Array<{ role: string; content: string }> }>();
const closed: string[] = [];
const getSession = (frontend: string, userId: string): TestSession => {
const id = `${frontend}:${userId}`;
let state = sessions.get(id);
if (!state) {
state = { history: [] };
sessions.set(id, state);
}
return {
id,
addMessage: (message) => {
state?.history.push(message);
},
getHistory: () => [...(state?.history ?? [])],
clear: () => {
if (state) {
state.history = [];
}
},
replaceHistory: (messages) => {
if (state) {
state.history = [...messages];
}
},
getConfig: () => undefined,
setConfig: () => {},
deleteConfig: () => {},
};
};
return {
sessions,
closed,
api: {
getSession,
closeSession: (frontend: string, userId: string) => {
closed.push(`${frontend}:${userId}`);
},
},
};
}
function createAgentRegistryMock() {
const entries = [
{ name: 'research', modelTier: 'complex', systemPrompt: 'You are research.' },
{ name: 'helper', modelTier: 'default', systemPrompt: 'You are helper.' },
];
return {
get: (name: string) => entries.find((entry) => entry.name === name),
list: () => entries,
};
}
describe('SubagentManager', () => {
beforeEach(() => {
mocks.ctorConfigs = [];
mocks.processCalls.length = 0;
mocks.cancellable = true;
mocks.cancelCalls = 0;
});
it('spawns, sends, lists, cancels, and deletes subagent sessions', async () => {
const sessionManager = createSessionManagerMock();
const tools = new ToolRegistry();
for (const name of [
'file.read',
'agent.delegate',
'council.run',
'subagent.spawn',
'subagent.send',
'subagent.list',
'subagent.cancel',
'subagent.delete',
]) {
tools.register({
name,
description: name,
inputSchema: { type: 'object', properties: {} },
execute: async () => ({ success: true, output: 'ok' }),
});
}
const manager = new SubagentManager({
parentSessionId: 'telegram:alice',
modelRouter: {} as never,
sessionManager: sessionManager.api as never,
toolRegistry: tools,
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',
maxIterations: 12,
maxActiveSessions: 2,
});
const spawned = manager.spawn({ agent: 'research', subagentId: 'planner' });
expect(spawned.id).toBe('planner');
expect(spawned.agent).toBe('research');
expect(spawned.tier).toBe('complex');
// verify blocked orchestration tools are not passed to child subagents
const ctorConfig = mocks.ctorConfigs[0] as { toolRegistry: ToolRegistry };
const childToolNames = ctorConfig.toolRegistry.list().map((tool) => tool.name);
expect(childToolNames).toContain('file.read');
expect(childToolNames).not.toContain('agent.delegate');
expect(childToolNames).not.toContain('council.run');
expect(childToolNames).not.toContain('subagent.spawn');
const firstSend = await manager.send('planner', 'Draft a rollout plan');
expect(firstSend.content).toBe('subagent:Draft a rollout plan');
expect(firstSend.session.messageCount).toBe(2);
const listed = manager.list();
expect(listed).toHaveLength(1);
expect(listed[0].id).toBe('planner');
expect(listed[0].messageCount).toBe(2);
expect(manager.cancel('planner')).toBe(true);
expect(mocks.cancelCalls).toBe(1);
expect(manager.delete('planner')).toBe(true);
expect(manager.list()).toHaveLength(0);
expect(sessionManager.closed).toContain('subagent:telegram:alice:planner');
});
it('enforces active-session limits and unknown-session errors', async () => {
const sessionManager = createSessionManagerMock();
const manager = new SubagentManager({
parentSessionId: 'telegram:bob',
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: 1,
});
manager.spawn({ agent: 'helper', subagentId: 'one' });
expect(() => manager.spawn({ agent: 'helper', subagentId: 'two' })).toThrow('Subagent session limit reached');
await expect(manager.send('missing', 'hello')).rejects.toThrow('not found');
expect(manager.delete('missing')).toBe(false);
});
it('rejects unknown agent names on spawn', () => {
const sessionManager = createSessionManagerMock();
const manager = new SubagentManager({
parentSessionId: 'telegram:carol',
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,
});
expect(() => manager.spawn({ agent: 'unknown' })).toThrow('not found');
});
});
+239
View File
@@ -0,0 +1,239 @@
import { randomUUID } from 'node:crypto';
import type { AgentConfigRegistry } from '../../agents/registry.js';
import type { ToolPolicyContext } from '../../tools/policy.js';
import type { ModelRouter, ModelTier } from '../../models/router.js';
import type { SessionManager } from '../../session/manager.js';
import type { ToolRegistry } from '../../tools/registry.js';
import type { ToolExecutor } from '../../tools/executor.js';
import { AgentOrchestrator, type DelegationConfig } from './orchestrator.js';
const SUBAGENT_FRONTEND = 'subagent';
const BLOCKED_SUBAGENT_TOOL_NAMES = [
'agent.delegate',
'council.run',
'subagent.spawn',
'subagent.send',
'subagent.list',
'subagent.cancel',
'subagent.delete',
];
export interface SubagentManagerConfig {
parentSessionId: string;
modelRouter: ModelRouter;
sessionManager: SessionManager;
toolRegistry: ToolRegistry;
toolExecutor: ToolExecutor;
agentConfigRegistry: AgentConfigRegistry;
delegation: DelegationConfig;
maxDelegationDepth: number;
defaultPrimaryTier: ModelTier;
maxIterations?: number;
maxActiveSessions: number;
toolPolicyContext?: ToolPolicyContext;
}
export interface SpawnSubagentRequest {
agent: string;
subagentId?: string;
tier?: ModelTier;
systemPrompt?: string;
}
interface ManagedSubagent {
id: string;
agent: string;
tier: ModelTier;
sessionUserId: string;
createdAt: number;
updatedAt: number;
busy: boolean;
orchestrator: AgentOrchestrator;
}
export interface SubagentSessionSummary {
id: string;
agent: string;
tier: ModelTier;
messageCount: number;
createdAt: number;
updatedAt: number;
busy: boolean;
}
export interface SubagentSendResult {
content: string;
session: SubagentSessionSummary;
}
/**
* Manages multi-turn child subagent sessions scoped to a parent session.
*/
export class SubagentManager {
private readonly sessions = new Map<string, ManagedSubagent>();
constructor(private readonly config: SubagentManagerConfig) {}
spawn(request: SpawnSubagentRequest): SubagentSessionSummary {
const agentName = request.agent.trim();
if (!agentName) {
throw new Error('agent is required');
}
const agentConfig = this.config.agentConfigRegistry.get(agentName);
if (!agentConfig) {
const available = this.config.agentConfigRegistry.list().map((entry) => entry.name);
throw new Error(
`Agent \"${agentName}\" not found. Available agents: ${available.length > 0 ? available.join(', ') : 'none'}`,
);
}
const id = this.resolveSubagentId(request.subagentId);
if (this.sessions.has(id)) {
throw new Error(`Subagent session \"${id}\" already exists.`);
}
if (this.sessions.size >= this.config.maxActiveSessions) {
throw new Error(
`Subagent session limit reached (${this.config.maxActiveSessions}). Delete an existing subagent session first.`,
);
}
const tier = request.tier ?? agentConfig.modelTier ?? this.config.defaultPrimaryTier;
const systemPrompt = request.systemPrompt
?? agentConfig.systemPrompt
?? `You are subagent \"${agentName}\". Complete assigned tasks clearly and concisely.`;
const now = Date.now();
const sessionUserId = `${this.config.parentSessionId}:${id}`;
const session = this.config.sessionManager.getSession(SUBAGENT_FRONTEND, sessionUserId);
const subagentToolRegistry = this.config.toolRegistry.clone();
for (const toolName of BLOCKED_SUBAGENT_TOOL_NAMES) {
subagentToolRegistry.unregister(toolName);
}
const policyContext: ToolPolicyContext | undefined = this.config.toolPolicyContext
? {
...this.config.toolPolicyContext,
sessionId: session.id,
tier,
agent: tier,
}
: undefined;
const subagent = new AgentOrchestrator({
modelRouter: this.config.modelRouter,
systemPrompt,
session,
toolRegistry: subagentToolRegistry,
toolExecutor: this.config.toolExecutor,
primaryTier: tier,
delegation: this.config.delegation,
maxDelegationDepth: this.config.maxDelegationDepth,
maxIterations: this.config.maxIterations,
toolPolicyContext: policyContext,
});
this.sessions.set(id, {
id,
agent: agentName,
tier,
sessionUserId,
createdAt: now,
updatedAt: now,
busy: false,
orchestrator: subagent,
});
return this.getSummaryById(id);
}
async send(subagentId: string, message: string): Promise<SubagentSendResult> {
const subagent = this.requireSubagent(subagentId);
const trimmed = message.trim();
if (!trimmed) {
throw new Error('message is required');
}
subagent.busy = true;
subagent.updatedAt = Date.now();
try {
const content = await subagent.orchestrator.process(trimmed);
subagent.updatedAt = Date.now();
return {
content,
session: this.getSummary(subagent),
};
} finally {
subagent.busy = false;
}
}
cancel(subagentId: string): boolean {
const subagent = this.requireSubagent(subagentId);
if (!subagent.orchestrator.isCancellable()) {
return false;
}
subagent.orchestrator.cancel();
subagent.updatedAt = Date.now();
return true;
}
delete(subagentId: string): boolean {
const subagent = this.sessions.get(subagentId);
if (!subagent) {
return false;
}
if (subagent.orchestrator.isCancellable()) {
subagent.orchestrator.cancel();
}
const session = this.config.sessionManager.getSession(SUBAGENT_FRONTEND, subagent.sessionUserId);
session.clear();
this.config.sessionManager.closeSession(SUBAGENT_FRONTEND, subagent.sessionUserId);
this.sessions.delete(subagentId);
return true;
}
list(): SubagentSessionSummary[] {
return [...this.sessions.values()]
.map((entry) => this.getSummary(entry))
.sort((a, b) => a.id.localeCompare(b.id));
}
private resolveSubagentId(rawId: string | undefined): string {
const explicit = rawId?.trim();
if (explicit) {
return explicit;
}
return `sa-${randomUUID().slice(0, 8)}`;
}
private requireSubagent(id: string): ManagedSubagent {
const normalized = id.trim();
const subagent = this.sessions.get(normalized);
if (!subagent) {
throw new Error(`Subagent session \"${normalized}\" not found.`);
}
return subagent;
}
private getSummaryById(id: string): SubagentSessionSummary {
const subagent = this.requireSubagent(id);
return this.getSummary(subagent);
}
private getSummary(subagent: ManagedSubagent): SubagentSessionSummary {
const session = this.config.sessionManager.getSession(SUBAGENT_FRONTEND, subagent.sessionUserId);
return {
id: subagent.id,
agent: subagent.agent,
tier: subagent.tier,
messageCount: session.getHistory().length,
createdAt: subagent.createdAt,
updatedAt: subagent.updatedAt,
busy: subagent.busy,
};
}
}
+19
View File
@@ -1698,6 +1698,8 @@ describe('configSchema — agents truthfulness/autonomy', () => {
expect(result.agents.truthfulness_mode).toBe('standard');
expect(result.agents.autonomy_level).toBe('standard');
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.immutable_denylist).toEqual(
expect.arrayContaining([
expect.objectContaining({ tool: 'shell.exec', args_pattern: 'git push origin main' }),
@@ -1713,6 +1715,10 @@ describe('configSchema — agents truthfulness/autonomy', () => {
truthfulness_mode: 'strict',
autonomy_level: 'conservative',
sensitive_mode: 'confirm_without_elevation',
subagents: {
enabled: false,
max_active_sessions: 3,
},
immutable_denylist: [
{ tool: 'shell.exec', args_pattern: 'rm -rf /', reason: 'too destructive' },
],
@@ -1722,11 +1728,24 @@ describe('configSchema — agents truthfulness/autonomy', () => {
expect(result.agents.truthfulness_mode).toBe('strict');
expect(result.agents.autonomy_level).toBe('conservative');
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.immutable_denylist).toEqual([
{ tool: 'shell.exec', args_pattern: 'rm -rf /', reason: 'too destructive' },
]);
});
it('rejects invalid subagent session limits', () => {
expect(() => configSchema.parse({
...minimalConfig,
agents: {
subagents: {
max_active_sessions: 0,
},
},
})).toThrow();
});
it('rejects invalid truthfulness_mode', () => {
expect(() => configSchema.parse({
...minimalConfig,
+4
View File
@@ -535,6 +535,10 @@ const agentsSchema = z.object({
fallback_tier: modelTierEnum.default('fast'),
}).optional(),
}).default({}),
subagents: z.object({
enabled: z.boolean().default(true),
max_active_sessions: z.number().min(1).max(32).default(6),
}).default({}),
auto_escalate: z.boolean().default(false),
max_delegation_depth: z.number().min(1).max(10).default(3),
/** Maximum tool-loop iterations before the agent stops. */
+58 -7
View File
@@ -3,13 +3,13 @@ import type { Attachment } from '../channels/types.js';
import { isSupportedAudio, transcribeAudio } from '../models/media.js';
import { synthesizeSpeechAttachment } from '../models/tts.js';
import { supportsAudioInput } from '../models/capabilities.js';
import { AgentOrchestrator, type DelegationConfig } from '../backends/index.js';
import { AgentOrchestrator, SubagentManager, type DelegationConfig } from '../backends/index.js';
import { OutboundAttachmentCollector } from '../backends/native/attachments.js';
import type { ExternalBackend, ExternalBackendName } from '../backends/index.js';
import type { InboundMessage, OutboundMessage } from '../channels/index.js';
import { MemoryStore } from '../memory/index.js';
import type { Tool } from '../tools/types.js';
import { createMediaSendTool, createAgentDelegateTool, createCouncilRunTool } from '../tools/index.js';
import { createMediaSendTool, createAgentDelegateTool, createCouncilRunTool, createSubagentTools } from '../tools/index.js';
import type { AgentDelegateDeps } from '../tools/index.js';
import { createSandboxedShellTool, createSandboxedProcessStartTool, SandboxManager } from '../sandbox/index.js';
import { MODEL_PROVIDERS, type Config, type CouncilsConfig, type ModelConfig, type ModelProvider } from '../config/index.js';
@@ -382,10 +382,18 @@ export function createMessageRouter(deps: {
setBackendMode?: (mode: BackendRuntimeMode) => void;
}): {
handler: (msg: InboundMessage, reply: (response: OutboundMessage) => Promise<void>) => Promise<void>;
agents: Map<string, { orchestrator: AgentOrchestrator; collector: OutboundAttachmentCollector }>;
agents: Map<string, {
orchestrator: AgentOrchestrator;
collector: OutboundAttachmentCollector;
subagentManager?: SubagentManager;
}>;
} {
// Cache agents by session ID + agent config name to avoid recreating on every message
const agents = new Map<string, { orchestrator: AgentOrchestrator; collector: OutboundAttachmentCollector }>();
const agents = new Map<string, {
orchestrator: AgentOrchestrator;
collector: OutboundAttachmentCollector;
subagentManager?: SubagentManager;
}>();
const talkModeUntil = new Map<string, number>();
const activeRuns = new Map<string, AgentOrchestrator>();
const reactionCooldowns = new Map<string, number>();
@@ -530,7 +538,16 @@ export function createMessageRouter(deps: {
}
}
function getOrCreateAgent(channel: string, senderId: string, metadata?: Record<string, unknown>, agentOverride?: string): { orchestrator: AgentOrchestrator; collector: OutboundAttachmentCollector } {
function getOrCreateAgent(
channel: string,
senderId: string,
metadata?: Record<string, unknown>,
agentOverride?: string,
): {
orchestrator: AgentOrchestrator;
collector: OutboundAttachmentCollector;
subagentManager?: SubagentManager;
} {
// Resolve agent config name via routing (sender → channel → default fallback)
const agentConfigName = agentOverride ?? deps.agentRouter?.resolve(channel, senderId);
const agentConfig = agentConfigName ? deps.agentConfigRegistry?.get(agentConfigName) : undefined;
@@ -664,6 +681,28 @@ export function createMessageRouter(deps: {
effectiveToolRegistry = effectiveToolRegistry.clone();
effectiveToolRegistry.register(createMediaSendTool(collector));
let subagentManager: SubagentManager | undefined;
const subagentsEnabled = deps.config.agents.subagents?.enabled ?? true;
const maxSubagentSessions = deps.config.agents.subagents?.max_active_sessions ?? 6;
if (subagentsEnabled && deps.agentConfigRegistry && deps.agentConfigRegistry.list().length > 0) {
subagentManager = new SubagentManager({
parentSessionId: session.id,
modelRouter: deps.modelRouter,
sessionManager: deps.sessionManager,
toolRegistry: effectiveToolRegistry,
toolExecutor: deps.toolExecutor,
agentConfigRegistry: deps.agentConfigRegistry,
delegation: delegationConfig,
maxDelegationDepth: deps.config.agents.max_delegation_depth ?? 3,
defaultPrimaryTier: effectiveTier,
maxIterations: deps.config.agents.max_iterations,
maxActiveSessions: maxSubagentSessions,
});
for (const tool of createSubagentTools(subagentManager)) {
effectiveToolRegistry.register(tool);
}
}
// Register delegation tools with lazy orchestrator reference (resolved after construction)
let resolveOrchestrator: ((o: AgentOrchestrator) => void) | undefined;
if (deps.agentConfigRegistry && deps.agentConfigRegistry.list().length > 0) {
@@ -766,7 +805,7 @@ export function createMessageRouter(deps: {
// Resolve the lazy orchestrator reference for agent.delegate
resolveOrchestrator?.(orchestrator);
entry = { orchestrator, collector };
entry = { orchestrator, collector, subagentManager };
agents.set(sessionId, entry);
}
return entry;
@@ -960,7 +999,12 @@ export function createMessageRouter(deps: {
const agentConfigName = intentAgentOverride ?? deps.agentRouter?.resolve(msg.channel, msg.senderId);
const agentConfig = agentConfigName ? deps.agentConfigRegistry?.get(agentConfigName) : undefined;
const { orchestrator: agent, collector } = getOrCreateAgent(msg.channel, msg.senderId, effectiveMetadata, agentConfigName);
const { orchestrator: agent, collector, subagentManager } = getOrCreateAgent(
msg.channel,
msg.senderId,
effectiveMetadata,
agentConfigName,
);
const commandInput = msg.metadata?.isCommand && typeof msg.metadata.command === 'string'
? `/${msg.metadata.command}${msg.metadata.commandArgs ? ` ${msg.metadata.commandArgs}` : ''}`
@@ -999,6 +1043,13 @@ export function createMessageRouter(deps: {
names.add('council.run');
}
}
if (subagentManager) {
names.add('subagent.spawn');
names.add('subagent.send');
names.add('subagent.list');
names.add('subagent.cancel');
names.add('subagent.delete');
}
const sorted = [...names].sort();
return [
`Available tools (${sorted.length}):`,
+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];
}
+1 -1
View File
@@ -5,7 +5,7 @@ export { ToolExecutor } from './executor.js';
export type { ToolExecutorConfig } from './executor.js';
export { ToolPolicy } from './policy.js';
export type { ToolPolicyContext } from './policy.js';
export { allBuiltinTools, createWebSearchTools, createProcessTools, ProcessManager, BrowserManager, createBrowserTools, createMediaSendTool, createAudioTranscribeTool, createSessionTools, createAgentsListTool, createMessageSendTool, createCronTools, createGmailTools, createGcalTools, createGdocsTools, createGdriveTools, createGtasksTools, createMinioShareTool, createMinioIngestTool, createMinioSyncTool, createK8sTools, createAgentDelegateTool, createCouncilRunTool } from './builtin/index.js';
export { allBuiltinTools, createWebSearchTools, createProcessTools, ProcessManager, BrowserManager, createBrowserTools, createMediaSendTool, createAudioTranscribeTool, createSessionTools, createAgentsListTool, createMessageSendTool, createCronTools, createGmailTools, createGcalTools, createGdocsTools, createGdriveTools, createGtasksTools, createMinioShareTool, createMinioIngestTool, createMinioSyncTool, createK8sTools, createAgentDelegateTool, createCouncilRunTool, createSubagentTools } from './builtin/index.js';
export type { AgentDelegateDeps } from './builtin/index.js';
export type { CouncilRunDeps } from './builtin/index.js';
export type { WebSearchConfig } from './builtin/web-search.js';
+2
View File
@@ -102,6 +102,7 @@ describe('PROFILE_TOOLS', () => {
expect(PROFILE_TOOLS.messaging.has('memory.read')).toBe(true);
expect(PROFILE_TOOLS.messaging.has('web.search')).toBe(true);
expect(PROFILE_TOOLS.messaging.has('web.search.news')).toBe(true);
expect(PROFILE_TOOLS.messaging.has('subagent.spawn')).toBe(true);
});
it('coding is a superset of messaging', () => {
@@ -111,6 +112,7 @@ describe('PROFILE_TOOLS', () => {
expect(PROFILE_TOOLS.coding.has('shell.exec')).toBe(true);
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);
});
it('full is empty (special: matches everything)', () => {
+20 -1
View File
@@ -50,6 +50,11 @@ const PROFILE_TOOLS: Record<ToolProfile, Set<string>> = {
'agent.delegate',
'agents.list',
'council.run',
'subagent.spawn',
'subagent.send',
'subagent.list',
'subagent.cancel',
'subagent.delete',
]),
coding: new Set([
'file.read',
@@ -107,6 +112,11 @@ const PROFILE_TOOLS: Record<ToolProfile, Set<string>> = {
'agent.delegate',
'agents.list',
'council.run',
'subagent.spawn',
'subagent.send',
'subagent.list',
'subagent.cancel',
'subagent.delete',
]),
full: new Set(), // Special: matches everything
};
@@ -127,7 +137,16 @@ export const TOOL_GROUPS: Record<string, string[]> = {
'group:cron': ['cron.list', 'cron.trigger', 'cron.create', 'cron.delete'],
'group:minio': ['minio.share', 'minio.ingest', 'minio.sync'],
'group:k8s': ['k8s.pods', 'k8s.deployments', 'k8s.logs'],
'group:agents': ['agent.delegate', 'agents.list', 'council.run'],
'group:agents': [
'agent.delegate',
'agents.list',
'council.run',
'subagent.spawn',
'subagent.send',
'subagent.list',
'subagent.cancel',
'subagent.delete',
],
};
/** Expand group references in a list of tool names/patterns. */