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