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