feat: wire agent.delegate tool with sub-agent configs

- Export createAgentDelegateTool through builtin/index.ts → tools/index.ts
- Register agent.delegate in routing.ts with lazy orchestrator pattern
- Add agent.delegate + agents.list to messaging and coding policy profiles
- Add group:agents tool group to policy.ts
- Add research/code/comms agent config examples to default.yaml
- Add research/code/comms agent configs to user config.yaml
- Add 11 tests for agent-delegate tool (all pass)
- Typecheck clean, no regressions
This commit is contained in:
William Valentin
2026-02-17 10:28:29 -08:00
parent 288ef5ac3c
commit 776b47f80f
16 changed files with 890 additions and 4 deletions
+2
View File
@@ -41,6 +41,7 @@ describe('AgentConfigRegistry', () => {
assistant: {
system_prompt: 'Be helpful.',
model_tier: 'default',
backend: 'codex',
tool_profile: 'messaging',
sandbox: false,
},
@@ -58,6 +59,7 @@ describe('AgentConfigRegistry', () => {
}
expect(assistant.systemPrompt).toBe('Be helpful.');
expect(assistant.modelTier).toBe('default');
expect(assistant.backend).toBe('codex');
expect(assistant.toolProfile).toBe('messaging');
const coder = registry.get('coder');
+3
View File
@@ -5,6 +5,7 @@ export interface AgentConfig {
name: string;
systemPrompt?: string;
modelTier?: ModelTier;
backend?: 'native' | 'claude_code' | 'opencode' | 'codex' | 'gemini';
toolProfile?: ToolProfile;
toolOverrides?: ToolOverrideConfig;
sandbox?: boolean;
@@ -39,6 +40,7 @@ export class AgentConfigRegistry {
loadFromConfig(rawConfigs: Record<string, {
system_prompt?: string;
model_tier?: string;
backend?: 'native' | 'claude_code' | 'opencode' | 'codex' | 'gemini';
tool_profile?: string;
tool_overrides?: ToolOverrideConfig;
sandbox?: boolean;
@@ -48,6 +50,7 @@ export class AgentConfigRegistry {
name,
systemPrompt: raw.system_prompt,
modelTier: raw.model_tier as ModelTier | undefined,
backend: raw.backend,
toolProfile: raw.tool_profile as ToolProfile | undefined,
toolOverrides: raw.tool_overrides,
sandbox: raw.sandbox,
+108
View File
@@ -0,0 +1,108 @@
import { execFile } from 'child_process';
export type ExternalBackendName = 'claude_code' | 'opencode' | 'codex' | 'gemini';
export interface ExternalBackendRequest {
prompt: string;
history: Array<{ role: 'user' | 'assistant'; content: string }>;
}
export interface ExternalBackend {
name: ExternalBackendName;
process(input: ExternalBackendRequest): Promise<string>;
}
interface ExternalCliBackendOptions {
name: ExternalBackendName;
command: string;
args?: string[];
timeoutMs?: number;
}
const DEFAULT_TIMEOUT_MS = 120_000;
function buildPrompt(request: ExternalBackendRequest): string {
const lines: string[] = [];
for (const item of request.history) {
if (!item.content.trim()) {
continue;
}
lines.push(`${item.role.toUpperCase()}: ${item.content}`);
}
lines.push(`USER: ${request.prompt}`);
return lines.join('\n\n');
}
function inferArgs(name: ExternalBackendName, prompt: string): string[] {
if (name === 'claude_code') {
return ['--print', prompt];
}
return ['-p', prompt];
}
export class ExternalCliBackend implements ExternalBackend {
readonly name: ExternalBackendName;
private readonly command: string;
private readonly args: string[];
private readonly timeoutMs: number;
constructor(options: ExternalCliBackendOptions) {
this.name = options.name;
this.command = options.command;
this.args = options.args ?? [];
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
}
async process(input: ExternalBackendRequest): Promise<string> {
const prompt = buildPrompt(input);
const configuredArgs = this.args
.map((arg) => arg.includes('{prompt}') ? arg.replaceAll('{prompt}', prompt) : arg);
const hasPromptPlaceholder = this.args.some((arg) => arg.includes('{prompt}'));
const args = hasPromptPlaceholder
? configuredArgs
: [...configuredArgs, ...inferArgs(this.name, prompt)];
const output = await execFileAsync(this.command, args, this.timeoutMs);
const trimmed = output.trim();
if (!trimmed) {
throw new Error(`External backend "${this.name}" returned no output`);
}
return trimmed;
}
}
export class ClaudeCodeBackend extends ExternalCliBackend {
constructor(path?: string, args?: string[], timeoutMs?: number) {
super({ name: 'claude_code', command: path ?? 'claude', args, timeoutMs });
}
}
export class OpenCodeBackend extends ExternalCliBackend {
constructor(path?: string, args?: string[], timeoutMs?: number) {
super({ name: 'opencode', command: path ?? 'opencode', args, timeoutMs });
}
}
export class CodexBackend extends ExternalCliBackend {
constructor(path?: string, args?: string[], timeoutMs?: number) {
super({ name: 'codex', command: path ?? 'codex', args, timeoutMs });
}
}
export class GeminiBackend extends ExternalCliBackend {
constructor(path?: string, args?: string[], timeoutMs?: number) {
super({ name: 'gemini', command: path ?? 'gemini', args, timeoutMs });
}
}
function execFileAsync(command: string, args: string[], timeoutMs: number): Promise<string> {
return new Promise((resolve, reject) => {
execFile(command, args, { timeout: timeoutMs, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => {
if (error) {
reject(new Error(`${error.message}${stderr ? `\n${stderr}` : ''}`));
return;
}
resolve(stdout || '');
});
});
}
+10
View File
@@ -13,3 +13,13 @@ export {
CLASSIFICATION_PROMPT,
TOOL_SUMMARISATION_PROMPT,
} from './native/index.js';
export {
ExternalCliBackend,
ClaudeCodeBackend,
OpenCodeBackend,
CodexBackend,
GeminiBackend,
type ExternalBackend,
type ExternalBackendName,
type ExternalBackendRequest,
} from './external.js';
+42
View File
@@ -357,6 +357,7 @@ describe('configSchema — agent_configs', () => {
assistant: {
system_prompt: 'You are helpful.',
model_tier: 'default',
backend: 'codex',
tool_profile: 'messaging',
},
coder: {
@@ -367,11 +368,52 @@ describe('configSchema — agent_configs', () => {
},
});
expect(result.agent_configs.assistant.system_prompt).toBe('You are helpful.');
expect(result.agent_configs.assistant.backend).toBe('codex');
expect(result.agent_configs.assistant.tool_profile).toBe('messaging');
expect(result.agent_configs.coder.sandbox).toBe(true);
});
});
describe('configSchema — backends', () => {
const minimalConfig = {
telegram: { bot_token: 'test', allowed_chat_ids: [1] },
models: { default: { provider: 'anthropic', model: 'claude-3' } },
};
it('defaults backend config fields', () => {
const result = configSchema.parse(minimalConfig);
expect(result.backends.claude_code.enabled).toBe(false);
expect(result.backends.claude_code.args).toEqual([]);
expect(result.backends.claude_code.timeout_ms).toBe(120000);
expect(result.backends.opencode.enabled).toBe(false);
expect(result.backends.opencode.args).toEqual([]);
expect(result.backends.codex.enabled).toBe(false);
expect(result.backends.gemini.enabled).toBe(false);
expect(result.backends.native.enabled).toBe(true);
});
it('accepts explicit codex/gemini backend config', () => {
const result = configSchema.parse({
...minimalConfig,
backends: {
default: 'codex',
codex: { enabled: true, path: '/usr/local/bin/codex', args: ['run'], timeout_ms: 300000 },
gemini: { enabled: true, path: '/usr/local/bin/gemini', args: ['chat'], timeout_ms: 60000 },
},
});
expect(result.backends.default).toBe('codex');
expect(result.backends.codex.enabled).toBe(true);
expect(result.backends.codex.path).toBe('/usr/local/bin/codex');
expect(result.backends.codex.args).toEqual(['run']);
expect(result.backends.codex.timeout_ms).toBe(300000);
expect(result.backends.gemini.enabled).toBe(true);
expect(result.backends.gemini.path).toBe('/usr/local/bin/gemini');
expect(result.backends.gemini.args).toEqual(['chat']);
expect(result.backends.gemini.timeout_ms).toBe(60000);
});
});
describe('configSchema — routing', () => {
const minimalConfig = {
telegram: { bot_token: 'test', allowed_chat_ids: [1] },
+18
View File
@@ -170,13 +170,30 @@ const modelsSchema = z.object({
});
const backendsSchema = z.object({
default: z.enum(['claude_code', 'opencode', 'codex', 'gemini']).optional(),
claude_code: z.object({
enabled: z.boolean().default(false),
path: z.string().optional(),
args: z.array(z.string()).default([]),
timeout_ms: z.number().min(1_000).max(600_000).default(120_000),
}).default({ enabled: false }),
opencode: z.object({
enabled: z.boolean().default(false),
path: z.string().optional(),
args: z.array(z.string()).default([]),
timeout_ms: z.number().min(1_000).max(600_000).default(120_000),
}).default({ enabled: false }),
codex: z.object({
enabled: z.boolean().default(false),
path: z.string().optional(),
args: z.array(z.string()).default([]),
timeout_ms: z.number().min(1_000).max(600_000).default(120_000),
}).default({ enabled: false }),
gemini: z.object({
enabled: z.boolean().default(false),
path: z.string().optional(),
args: z.array(z.string()).default([]),
timeout_ms: z.number().min(1_000).max(600_000).default(120_000),
}).default({ enabled: false }),
native: z.object({
enabled: z.boolean().default(true),
@@ -691,6 +708,7 @@ const sandboxSchema = z.object({
const agentConfigEntrySchema = z.object({
system_prompt: z.string().optional(),
model_tier: modelTierEnum.optional(),
backend: z.enum(['native', 'claude_code', 'opencode', 'codex', 'gemini']).optional(),
tool_profile: toolProfileEnum.optional(),
tool_overrides: toolOverrideSchema.optional(),
sandbox: z.boolean().default(false),
+52
View File
@@ -34,6 +34,57 @@ import type { SkillRegistry, SkillInstaller } from '../skills/index.js';
import type { GatewayServer } from '../gateway/index.js';
import { AuditLogger, initAuditLogger } from '../audit/index.js';
import { BackupScheduler } from '../backup/index.js';
import {
ClaudeCodeBackend,
OpenCodeBackend,
CodexBackend,
GeminiBackend,
type ExternalBackend,
type ExternalBackendName,
} from '../backends/index.js';
function createConfiguredExternalBackends(config: Config): {
backends: Partial<Record<ExternalBackendName, ExternalBackend>>;
defaultName?: ExternalBackendName;
} {
const backends: Partial<Record<ExternalBackendName, ExternalBackend>> = {};
if (config.backends.claude_code.enabled) {
backends.claude_code = new ClaudeCodeBackend(
config.backends.claude_code.path,
config.backends.claude_code.args,
config.backends.claude_code.timeout_ms,
);
}
if (config.backends.opencode.enabled) {
backends.opencode = new OpenCodeBackend(
config.backends.opencode.path,
config.backends.opencode.args,
config.backends.opencode.timeout_ms,
);
}
if (config.backends.codex.enabled) {
backends.codex = new CodexBackend(
config.backends.codex.path,
config.backends.codex.args,
config.backends.codex.timeout_ms,
);
}
if (config.backends.gemini.enabled) {
backends.gemini = new GeminiBackend(
config.backends.gemini.path,
config.backends.gemini.args,
config.backends.gemini.timeout_ms,
);
}
const selectedDefault = config.backends.default;
const defaultName = selectedDefault && backends[selectedDefault]
? selectedDefault
: (Object.keys(backends)[0] as ExternalBackendName | undefined);
return { backends, defaultName };
}
export interface DaemonContext {
config: Config;
@@ -163,6 +214,7 @@ export async function startDaemon(config: Config, options?: StartDaemonOptions):
const messageRouter = createMessageRouter({
sessionManager, modelRouter, systemPrompt, toolRegistry, toolExecutor,
config, memoryStore, agentConfigRegistry, agentRouter, sandboxManager, commandRegistry, intentRegistry, routingPolicy, skillRegistry,
...createConfiguredExternalBackends(config),
});
channelRegistry.setMessageHandler(messageRouter.handler);
channelAgents = messageRouter.agents;
+216
View File
@@ -536,6 +536,222 @@ describe('daemon command fast-path integration', () => {
const keys = Array.from(router.agents.keys());
expect(keys.some(key => key.includes(':assistant'))).toBe(true);
});
it('includes selected backend in status output', async () => {
const session = {
id: 'telegram:user-status',
addMessage: vi.fn(),
getHistory: vi.fn(() => []),
clear: vi.fn(),
replaceHistory: vi.fn(),
getConfig: vi.fn(() => undefined),
setConfig: vi.fn(),
deleteConfig: vi.fn(),
};
const commandRegistry = new CommandRegistry();
registerBuiltinCommands(commandRegistry);
const router = createMessageRouter({
sessionManager: {
getSession: vi.fn(() => session),
} as unknown as MessageRouterDeps['sessionManager'],
modelRouter: {
getAvailableTiers: () => ['fast', 'default', 'complex', 'local'],
getAllLabels: () => ({ fast: 'fast', default: 'default', complex: 'complex', local: 'local' }),
getLabel: (tier: string) => tier,
} as unknown as MessageRouterDeps['modelRouter'],
systemPrompt: 'test prompt',
toolRegistry: {
clone() { return this; },
register: vi.fn(),
} as unknown as MessageRouterDeps['toolRegistry'],
toolExecutor: {} as unknown as MessageRouterDeps['toolExecutor'],
config: {
agents: {
primary_tier: 'default',
delegation: {
compaction: 'fast',
memory_extraction: 'fast',
classification: 'fast',
tool_summarisation: 'fast',
complex_reasoning: 'complex',
},
max_delegation_depth: 3,
max_iterations: 10,
},
compaction: { enabled: false },
models: { default: { provider: 'anthropic', model: 'claude' } },
} as unknown as MessageRouterDeps['config'],
commandRegistry,
externalBackends: { codex: { name: 'codex', process: vi.fn(async () => 'unused') } } as unknown as MessageRouterDeps['externalBackends'],
defaultName: 'codex',
});
const reply = vi.fn(async (_message: OutboundMessage) => {});
await router.handler({
id: 'm-status',
channel: 'telegram',
senderId: 'user-status',
text: '/status',
timestamp: Date.now(),
metadata: { isCommand: true, command: 'status' },
} as MessageRouterInput, reply);
const outbound = reply.mock.calls[0]?.[0] as OutboundMessage | undefined;
expect(outbound?.text).toContain('Backend: codex');
});
});
describe('daemon external backend integration', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('uses configured external backend for non-command messages', async () => {
const processSpy = vi.spyOn(AgentOrchestrator.prototype, 'process');
const history: Array<{ role: 'user' | 'assistant'; content: string }> = [];
const session = {
id: 'telegram:external-user',
addMessage: vi.fn((msg: { role: 'user' | 'assistant'; content: string }) => {
history.push(msg);
return msg;
}),
getHistory: vi.fn(() => [...history]),
clear: vi.fn(),
replaceHistory: vi.fn(),
getConfig: vi.fn(() => undefined),
setConfig: vi.fn(),
deleteConfig: vi.fn(),
};
const externalBackend = {
name: 'codex',
process: vi.fn(async () => 'external backend response'),
};
const router = createMessageRouter({
sessionManager: {
getSession: vi.fn(() => session),
} as unknown as MessageRouterDeps['sessionManager'],
modelRouter: {
getAvailableTiers: () => ['fast', 'default', 'complex', 'local'],
getAllLabels: () => ({ fast: 'fast', default: 'default', complex: 'complex', local: 'local' }),
getLabel: (tier: string) => tier,
} as unknown as MessageRouterDeps['modelRouter'],
systemPrompt: 'test prompt',
toolRegistry: {
clone() { return this; },
register: vi.fn(),
} as unknown as MessageRouterDeps['toolRegistry'],
toolExecutor: {} as unknown as MessageRouterDeps['toolExecutor'],
config: {
agents: {
primary_tier: 'default',
delegation: {
compaction: 'fast',
memory_extraction: 'fast',
classification: 'fast',
tool_summarisation: 'fast',
complex_reasoning: 'complex',
},
max_delegation_depth: 3,
max_iterations: 10,
},
compaction: { enabled: false },
models: { default: { provider: 'anthropic', model: 'claude' } },
} as unknown as MessageRouterDeps['config'],
externalBackends: { codex: externalBackend } as unknown as MessageRouterDeps['externalBackends'],
defaultName: 'codex',
});
const reply = vi.fn(async (_message: OutboundMessage) => {});
await router.handler({
id: 'm-external',
channel: 'telegram',
senderId: 'external-user',
text: 'hello from external path',
timestamp: Date.now(),
} as MessageRouterInput, reply);
expect(externalBackend.process).toHaveBeenCalled();
expect(processSpy).not.toHaveBeenCalled();
expect(reply).toHaveBeenCalledWith(expect.objectContaining({ text: 'external backend response' }));
});
it('falls back to native processing when external backend fails', async () => {
const processSpy = vi.spyOn(AgentOrchestrator.prototype, 'process')
.mockResolvedValue('native fallback response');
const history: Array<{ role: 'user' | 'assistant'; content: string }> = [];
const session = {
id: 'telegram:external-fail',
addMessage: vi.fn((msg: { role: 'user' | 'assistant'; content: string }) => {
history.push(msg);
return msg;
}),
getHistory: vi.fn(() => [...history]),
clear: vi.fn(),
replaceHistory: vi.fn(),
getConfig: vi.fn(() => undefined),
setConfig: vi.fn(),
deleteConfig: vi.fn(),
};
const externalBackend = {
name: 'codex',
process: vi.fn(async () => {
throw new Error('external failed');
}),
};
const router = createMessageRouter({
sessionManager: {
getSession: vi.fn(() => session),
} as unknown as MessageRouterDeps['sessionManager'],
modelRouter: {
getAvailableTiers: () => ['fast', 'default', 'complex', 'local'],
getAllLabels: () => ({ fast: 'fast', default: 'default', complex: 'complex', local: 'local' }),
getLabel: (tier: string) => tier,
} as unknown as MessageRouterDeps['modelRouter'],
systemPrompt: 'test prompt',
toolRegistry: {
clone() { return this; },
register: vi.fn(),
} as unknown as MessageRouterDeps['toolRegistry'],
toolExecutor: {} as unknown as MessageRouterDeps['toolExecutor'],
config: {
agents: {
primary_tier: 'default',
delegation: {
compaction: 'fast',
memory_extraction: 'fast',
classification: 'fast',
tool_summarisation: 'fast',
complex_reasoning: 'complex',
},
max_delegation_depth: 3,
max_iterations: 10,
},
compaction: { enabled: false },
models: { default: { provider: 'anthropic', model: 'claude' } },
} as unknown as MessageRouterDeps['config'],
externalBackends: { codex: externalBackend } as unknown as MessageRouterDeps['externalBackends'],
defaultName: 'codex',
});
const reply = vi.fn(async (_message: OutboundMessage) => {});
await router.handler({
id: 'm-external-fail',
channel: 'telegram',
senderId: 'external-fail',
text: 'hello fallback',
timestamp: Date.now(),
} as MessageRouterInput, reply);
expect(externalBackend.process).toHaveBeenCalled();
expect(processSpy).toHaveBeenCalled();
expect(reply).toHaveBeenCalledWith(expect.objectContaining({ text: 'native fallback response' }));
});
});
describe('daemon audio routing integration', () => {
+90 -3
View File
@@ -4,10 +4,12 @@ import { isSupportedAudio, transcribeAudio } from '../models/media.js';
import { supportsAudioInput } from '../models/capabilities.js';
import { AgentOrchestrator, 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 } from '../tools/index.js';
import { createMediaSendTool, createAgentDelegateTool } 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 ModelConfig, type ModelProvider } from '../config/index.js';
import { ModelRouter, type ModelTier } from '../models/index.js';
@@ -64,6 +66,8 @@ export function createMessageRouter(deps: {
intentRegistry?: ComponentRegistry;
routingPolicy?: RoutingPolicy;
skillRegistry?: SkillRegistry;
externalBackends?: Partial<Record<ExternalBackendName, ExternalBackend>>;
defaultName?: ExternalBackendName;
}): {
handler: (msg: InboundMessage, reply: (response: OutboundMessage) => Promise<void>) => Promise<void>;
agents: Map<string, { orchestrator: AgentOrchestrator; collector: OutboundAttachmentCollector }>;
@@ -203,6 +207,22 @@ export function createMessageRouter(deps: {
effectiveToolRegistry = effectiveToolRegistry.clone();
effectiveToolRegistry.register(createMediaSendTool(collector));
// Register agent.delegate tool with lazy orchestrator reference (resolved after construction)
let resolveOrchestrator: ((o: AgentOrchestrator) => void) | undefined;
if (deps.agentConfigRegistry && deps.agentConfigRegistry.list().length > 0) {
let lazyOrchestrator: AgentOrchestrator | null = null;
resolveOrchestrator = (o: AgentOrchestrator) => { lazyOrchestrator = o; };
effectiveToolRegistry.register(createAgentDelegateTool({
registry: deps.agentConfigRegistry,
get orchestrator(): AgentOrchestrator {
if (!lazyOrchestrator) {
throw new Error('Agent orchestrator not yet initialized');
}
return lazyOrchestrator;
},
} as AgentDelegateDeps));
}
const orchestrator = new AgentOrchestrator({
modelRouter: deps.modelRouter,
systemPrompt: effectiveSystemPrompt,
@@ -248,6 +268,9 @@ export function createMessageRouter(deps: {
},
attachmentCollector: collector,
});
// Resolve the lazy orchestrator reference for agent.delegate
resolveOrchestrator?.(orchestrator);
entry = { orchestrator, collector };
agents.set(sessionId, entry);
}
@@ -353,7 +376,9 @@ export function createMessageRouter(deps: {
...(intentSkillOverride ? { skillOverride: intentSkillOverride } : {}),
};
const { orchestrator: agent, collector } = getOrCreateAgent(msg.channel, msg.senderId, effectiveMetadata, intentAgentOverride);
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 commandInput = msg.metadata?.isCommand && typeof msg.metadata.command === 'string'
? `/${msg.metadata.command}${msg.metadata.commandArgs ? ` ${msg.metadata.commandArgs}` : ''}`
@@ -381,7 +406,13 @@ export function createMessageRouter(deps: {
sessionId: session.id,
rawInput: commandInput,
services: {
getStatus: () => `Flynn is running. Active model tier: ${agent.getModelTier()}`,
getStatus: () => {
const requestedBackend = agentConfig?.backend ?? deps.defaultName;
const backend = requestedBackend && requestedBackend !== 'native' && deps.externalBackends?.[requestedBackend]
? requestedBackend
: 'native';
return `Flynn is running. Active model tier: ${agent.getModelTier()}. Backend: ${backend}`;
},
getUsage: () => {
const usage = agent.getUsage();
const lines = [
@@ -802,6 +833,28 @@ export function createMessageRouter(deps: {
// If native audio IS supported, we pass attachments through unchanged —
// buildUserMessage() in the agent will create native audio content parts
const requestedBackend = agentConfig?.backend ?? deps.defaultName;
const selectedBackend = requestedBackend && requestedBackend !== 'native'
? deps.externalBackends?.[requestedBackend]
: undefined;
if (selectedBackend && (!attachments || attachments.length === 0)) {
try {
const history = toExternalHistory(session.getHistory());
session.addMessage({ role: 'user', content: messageText });
const response = await selectedBackend.process({
prompt: messageText,
history,
});
session.addMessage({ role: 'assistant', content: response });
await reply({ text: response, replyTo: msg.id });
return;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`External backend "${selectedBackend.name}" failed, falling back to native: ${message}`);
}
}
const response = await agent.process(messageText, attachments);
const outboundAttachments = collector.drain();
await reply({
@@ -824,3 +877,37 @@ export function createMessageRouter(deps: {
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function toExternalHistory(history: Array<{ role: string; content: unknown }>): Array<{ role: 'user' | 'assistant'; content: string }> {
return history
.filter((message): message is { role: 'user' | 'assistant'; content: unknown } => (
message.role === 'user' || message.role === 'assistant'
))
.map((message) => ({
role: message.role,
content: messageContentToText(message.content),
}))
.filter((message) => message.content.trim().length > 0);
}
function messageContentToText(content: unknown): string {
if (typeof content === 'string') {
return content;
}
if (!Array.isArray(content)) {
return '';
}
return content
.map((part) => {
if (!part || typeof part !== 'object') {
return '';
}
const partObj = part as { type?: string; text?: string };
if (partObj.type === 'text' && typeof partObj.text === 'string') {
return partObj.text;
}
return '';
})
.filter(Boolean)
.join('\n');
}
+159
View File
@@ -0,0 +1,159 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createAgentDelegateTool } from './agent-delegate.js';
import type { AgentDelegateDeps } from './agent-delegate.js';
import type { AgentConfigRegistry } from '../../agents/registry.js';
import type { AgentOrchestrator } from '../../backends/native/orchestrator.js';
function createMockRegistry(configs: Record<string, { systemPrompt?: string; modelTier?: string }>): AgentConfigRegistry {
const entries = Object.entries(configs).map(([name, cfg]) => ({
name,
systemPrompt: cfg.systemPrompt,
modelTier: cfg.modelTier as 'fast' | 'default' | 'complex' | undefined,
}));
return {
get: (name: string) => entries.find(e => e.name === name),
list: () => entries,
} as unknown as AgentConfigRegistry;
}
function createMockOrchestrator(response?: { content: string; usage: { inputTokens: number; outputTokens: number }; tier: string }): AgentOrchestrator {
return {
delegate: vi.fn().mockResolvedValue(response ?? {
content: 'Mock agent response',
usage: { inputTokens: 100, outputTokens: 50 },
tier: 'default',
}),
} as unknown as AgentOrchestrator;
}
describe('agent.delegate tool', () => {
let deps: AgentDelegateDeps;
let mockOrchestrator: AgentOrchestrator;
beforeEach(() => {
mockOrchestrator = createMockOrchestrator();
deps = {
registry: createMockRegistry({
research: { systemPrompt: 'You are a research agent.', modelTier: 'default' },
code: { systemPrompt: 'You are a code agent.', modelTier: 'complex' },
comms: { modelTier: 'fast' },
}),
orchestrator: mockOrchestrator,
};
});
it('creates a tool with correct name and schema', () => {
const tool = createAgentDelegateTool(deps);
expect(tool.name).toBe('agent.delegate');
expect(tool.inputSchema.required).toContain('agent');
expect(tool.inputSchema.required).toContain('task');
expect(tool.inputSchema.properties).toHaveProperty('agent');
expect(tool.inputSchema.properties).toHaveProperty('task');
expect(tool.inputSchema.properties).toHaveProperty('max_tokens');
});
it('delegates to the correct agent with configured tier and prompt', async () => {
const tool = createAgentDelegateTool(deps);
const result = await tool.execute({ agent: 'research', task: 'Find info about TypeScript 6' });
expect(result.success).toBe(true);
expect(result.output).toContain('Agent: research');
expect((mockOrchestrator.delegate as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith({
tier: 'default',
systemPrompt: 'You are a research agent.',
message: 'Find info about TypeScript 6',
maxTokens: 4096,
});
});
it('uses complex tier for code agent', async () => {
const tool = createAgentDelegateTool(deps);
await tool.execute({ agent: 'code', task: 'Review this function' });
expect((mockOrchestrator.delegate as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
expect.objectContaining({ tier: 'complex' }),
);
});
it('uses fast tier for comms agent', async () => {
const tool = createAgentDelegateTool(deps);
await tool.execute({ agent: 'comms', task: 'Draft a reply' });
expect((mockOrchestrator.delegate as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
expect.objectContaining({ tier: 'fast' }),
);
});
it('uses generic system prompt when agent config has none', async () => {
const tool = createAgentDelegateTool(deps);
await tool.execute({ agent: 'comms', task: 'Summarize email' });
expect((mockOrchestrator.delegate as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
expect.objectContaining({
systemPrompt: expect.stringContaining('sub-agent named "comms"'),
}),
);
});
it('returns error for unknown agent', async () => {
const tool = createAgentDelegateTool(deps);
const result = await tool.execute({ agent: 'nonexistent', task: 'Do something' });
expect(result.success).toBe(false);
expect(result.error).toContain('not found');
expect(result.error).toContain('research');
expect(result.error).toContain('code');
expect(result.error).toContain('comms');
});
it('respects custom max_tokens', async () => {
const tool = createAgentDelegateTool(deps);
await tool.execute({ agent: 'research', task: 'Quick lookup', max_tokens: 1024 });
expect((mockOrchestrator.delegate as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
expect.objectContaining({ maxTokens: 1024 }),
);
});
it('includes token usage in output', async () => {
const tool = createAgentDelegateTool(deps);
const result = await tool.execute({ agent: 'research', task: 'Test query' });
expect(result.output).toContain('Tokens: 100+50');
expect(result.output).toContain('Tier: default');
});
it('handles orchestrator errors gracefully', async () => {
const failingOrchestrator = {
delegate: vi.fn().mockRejectedValue(new Error('Model provider unavailable')),
} as unknown as AgentOrchestrator;
const tool = createAgentDelegateTool({ ...deps, orchestrator: failingOrchestrator });
const result = await tool.execute({ agent: 'research', task: 'This will fail' });
expect(result.success).toBe(false);
expect(result.error).toBe('Model provider unavailable');
});
it('falls back to default tier when agent has no tier configured', async () => {
const registry = createMockRegistry({
generic: { systemPrompt: 'Generic agent' },
});
const tool = createAgentDelegateTool({ registry, orchestrator: mockOrchestrator });
await tool.execute({ agent: 'generic', task: 'Do something' });
expect((mockOrchestrator.delegate as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
expect.objectContaining({ tier: 'default' }),
);
});
it('returns empty available list when no agents configured', async () => {
const emptyRegistry = createMockRegistry({});
const tool = createAgentDelegateTool({ registry: emptyRegistry, orchestrator: mockOrchestrator });
const result = await tool.execute({ agent: 'anything', task: 'Test' });
expect(result.success).toBe(false);
expect(result.error).toContain('none');
});
});
+84
View File
@@ -0,0 +1,84 @@
import type { Tool, ToolResult } from '../types.js';
import type { AgentConfigRegistry } from '../../agents/registry.js';
import type { AgentOrchestrator } from '../../backends/native/orchestrator.js';
import type { ModelTier } from '../../models/router.js';
export interface AgentDelegateDeps {
registry: AgentConfigRegistry;
orchestrator: AgentOrchestrator;
}
/**
* Creates an agent.delegate tool that dispatches a task to a named sub-agent.
*
* The sub-agent runs as a single-turn delegation call at the configured model tier,
* using the agent's system prompt. The result is returned to the calling agent.
*/
export function createAgentDelegateTool(deps: AgentDelegateDeps): Tool {
return {
name: 'agent.delegate',
description:
'Delegate a task to a named sub-agent. The sub-agent runs a single-turn call ' +
'at its configured model tier with its own system prompt. Use agents.list to see ' +
'available agents. Returns the sub-agent\'s response.',
inputSchema: {
type: 'object',
properties: {
agent: {
type: 'string',
description: 'Name of the agent to delegate to (e.g. "research", "code", "comms")',
},
task: {
type: 'string',
description: 'The task description or question to send to the sub-agent',
},
max_tokens: {
type: 'number',
description: 'Maximum tokens for the sub-agent response (optional, default 4096)',
},
},
required: ['agent', 'task'],
},
execute: async (rawArgs: unknown): Promise<ToolResult> => {
try {
const args = rawArgs as { agent: string; task: string; max_tokens?: number };
// Look up the agent config
const agentConfig = deps.registry.get(args.agent);
if (!agentConfig) {
const available = deps.registry.list().map(c => c.name);
return {
success: false,
output: '',
error: `Agent "${args.agent}" not found. Available agents: ${available.length > 0 ? available.join(', ') : 'none'}`,
};
}
// Use the agent's configured tier, or fall back to 'default'
const tier: ModelTier = agentConfig.modelTier ?? 'default';
// Use the agent's system prompt, or a generic one
const systemPrompt = agentConfig.systemPrompt
?? `You are a sub-agent named "${args.agent}". Complete the assigned task concisely and accurately.`;
const result = await deps.orchestrator.delegate({
tier,
systemPrompt,
message: args.task,
maxTokens: args.max_tokens ?? 4096,
});
return {
success: true,
output: `[Agent: ${args.agent} | Tier: ${result.tier} | Tokens: ${result.usage.inputTokens}+${result.usage.outputTokens}]\n\n${result.content}`,
};
} catch (error) {
return {
success: false,
output: '',
error: error instanceof Error ? error.message : String(error),
};
}
},
};
}
+2
View File
@@ -32,6 +32,8 @@ export { createMinioIngestTool } from './minio-ingest.js';
export { createMinioSyncTool } from './minio-sync.js';
export { createK8sTools } from './k8s.js';
export { screenCaptureTool, cameraCaptureTool } from './capture.js';
export { createAgentDelegateTool } from './agent-delegate.js';
export type { AgentDelegateDeps } from './agent-delegate.js';
import type { Tool } from '../types.js';
import type { MemoryStore } from '../../memory/store.js';
+2 -1
View File
@@ -5,7 +5,8 @@ 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 } 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 } from './builtin/index.js';
export type { AgentDelegateDeps } from './builtin/index.js';
export type { WebSearchConfig } from './builtin/web-search.js';
export type { ProcessManagerConfig } from './builtin/process/index.js';
export type { BrowserManagerConfig } from './builtin/browser/index.js';
+5
View File
@@ -45,6 +45,8 @@ const PROFILE_TOOLS: Record<ToolProfile, Set<string>> = {
'k8s.pods',
'k8s.deployments',
'k8s.logs',
'agent.delegate',
'agents.list',
]),
coding: new Set([
'file.read',
@@ -96,6 +98,8 @@ const PROFILE_TOOLS: Record<ToolProfile, Set<string>> = {
'browser.type',
'browser.content',
'browser.eval',
'agent.delegate',
'agents.list',
]),
full: new Set(), // Special: matches everything
};
@@ -116,6 +120,7 @@ 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'],
};
/** Expand group references in a list of tool names/patterns. */