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
+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');
}