From 776b47f80f0748a9a8972e1df2816149dce1def6 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Tue, 17 Feb 2026 10:28:29 -0800 Subject: [PATCH] feat: wire agent.delegate tool with sub-agent configs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- config/default.yaml | 63 +++++++ docs/plans/state.json | 34 ++++ src/agents/registry.test.ts | 2 + src/agents/registry.ts | 3 + src/backends/external.ts | 108 ++++++++++++ src/backends/index.ts | 10 ++ src/config/schema.test.ts | 42 +++++ src/config/schema.ts | 18 ++ src/daemon/index.ts | 52 ++++++ src/daemon/routing.test.ts | 216 +++++++++++++++++++++++ src/daemon/routing.ts | 93 +++++++++- src/tools/builtin/agent-delegate.test.ts | 159 +++++++++++++++++ src/tools/builtin/agent-delegate.ts | 84 +++++++++ src/tools/builtin/index.ts | 2 + src/tools/index.ts | 3 +- src/tools/policy.ts | 5 + 16 files changed, 890 insertions(+), 4 deletions(-) create mode 100644 src/backends/external.ts create mode 100644 src/tools/builtin/agent-delegate.test.ts create mode 100644 src/tools/builtin/agent-delegate.ts diff --git a/config/default.yaml b/config/default.yaml index e331bc0..0540459 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -186,6 +186,35 @@ models: # Then reference them in fallback_chain: # fallback_chain: [ollama, llamacpp, local] +# Optional: external CLI backends for non-tool conversation turns. +# - native remains available unless you disable it. +# - set `default` to route normal text turns to a backend by default. +# - per-agent override is available via `agent_configs..backend`. +# backends: +# default: codex # claude_code | opencode | codex | gemini +# native: +# enabled: true +# claude_code: +# enabled: false +# # path: /usr/local/bin/claude +# # args: ["--print", "{prompt}"] +# # timeout_ms: 120000 +# opencode: +# enabled: false +# # path: /usr/local/bin/opencode +# # args: ["-p", "{prompt}"] +# # timeout_ms: 120000 +# codex: +# enabled: false +# # path: /usr/local/bin/codex +# # args: ["-p", "{prompt}"] +# # timeout_ms: 120000 +# gemini: +# enabled: false +# # path: /usr/local/bin/gemini +# # args: ["-p", "{prompt}"] +# # timeout_ms: 120000 + # Optional: Kubernetes / homelab awareness tools (k8s.pods, k8s.deployments, k8s.logs) # k8s: # enabled: false @@ -422,3 +451,37 @@ hooks: # wake_phrase: "hey flynn" # timeout_ms: 120000 # allow_manual_toggle: true + +# ── Sub-Agent Configs ──────────────────────────────────────────────── +# Named agent configurations for delegation via agent.delegate tool. +# Each agent gets a focused system prompt, model tier, and tool profile. +# +# agent_configs: +# research: +# model_tier: default +# tool_profile: messaging +# system_prompt: | +# You are a research agent. Your job is to find, verify, and synthesize +# information from the web. Be thorough but concise. Cite sources when +# possible. Return structured findings — not conversational filler. +# Use web.search to find sources, web.fetch to read them, and file.write +# to save findings when asked. +# +# code: +# model_tier: complex +# tool_profile: coding +# system_prompt: | +# You are a code agent. Your job is to read, write, debug, refactor, +# and review code. You have full access to the filesystem and shell. +# Be precise. Read files before modifying them. Run tests after changes. +# Use file.edit/file.patch for surgical edits, not full file rewrites. +# Commit conventions: conventional commits, small atomic changes. +# +# comms: +# model_tier: fast +# tool_profile: messaging +# system_prompt: | +# You are a communications agent. Your job is to draft messages, +# summarize emails, triage inbox items, and prepare quick replies. +# Be concise and match the operator's tone. Skip marketing emails. +# Never send messages without explicit instruction — draft only. diff --git a/docs/plans/state.json b/docs/plans/state.json index ed64ba5..11b8757 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -3560,6 +3560,40 @@ "docs/plans/state.json" ], "test_status": "pnpm test:run src/backends/native/orchestrator.test.ts src/config/schema.test.ts src/gateway/handlers/agent.test.ts src/gateway/handlers/handlers.test.ts src/gateway/protocol.test.ts src/commands/builtin/index.test.ts src/frontends/tui/commands.test.ts src/frontends/tui/minimal.test.ts + pnpm typecheck passing" + }, + "line-zalo-outbound-attachment-forwarding": { + "status": "completed", + "date": "2026-02-17", + "updated": "2026-02-17", + "summary": "Updated LINE and Zalo adapters to preserve attachment-only outbound sends, forward URL attachments as chat lines, and emit explicit warnings for binary payloads that still require provider-specific upload implementations.", + "files_modified": [ + "src/channels/line/adapter.ts", + "src/channels/line/adapter.test.ts", + "src/channels/zalo/adapter.ts", + "src/channels/zalo/adapter.test.ts", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/channels/line/adapter.test.ts src/channels/zalo/adapter.test.ts + pnpm typecheck passing" + }, + "external-backend-codex-gemini-routing": { + "status": "completed", + "date": "2026-02-17", + "updated": "2026-02-17", + "summary": "Added external CLI backend support for `codex` and `gemini` (alongside `claude_code`/`opencode`) with configurable command args/timeouts, daemon wiring, per-agent backend selection, `/status` backend visibility, and native fallback when external execution fails.", + "files_modified": [ + "src/backends/external.ts", + "src/backends/index.ts", + "src/config/schema.ts", + "src/config/schema.test.ts", + "src/agents/registry.ts", + "src/agents/registry.test.ts", + "src/daemon/index.ts", + "src/daemon/routing.ts", + "src/daemon/routing.test.ts", + "config/default.yaml", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/agents/registry.test.ts src/config/schema.test.ts src/daemon/routing.test.ts + pnpm typecheck passing" } }, "overall_progress": { diff --git a/src/agents/registry.test.ts b/src/agents/registry.test.ts index 5708186..38a8a92 100644 --- a/src/agents/registry.test.ts +++ b/src/agents/registry.test.ts @@ -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'); diff --git a/src/agents/registry.ts b/src/agents/registry.ts index 25f300b..55a8d91 100644 --- a/src/agents/registry.ts +++ b/src/agents/registry.ts @@ -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; +} + +export interface ExternalBackend { + name: ExternalBackendName; + process(input: ExternalBackendRequest): Promise; +} + +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 { + 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 { + 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 || ''); + }); + }); +} diff --git a/src/backends/index.ts b/src/backends/index.ts index 39f35fb..a127894 100644 --- a/src/backends/index.ts +++ b/src/backends/index.ts @@ -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'; diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index e1dbb85..86babc3 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -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] }, diff --git a/src/config/schema.ts b/src/config/schema.ts index b3a41b0..49675a4 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -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), diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 8cdb404..3c51f90 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -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>; + defaultName?: ExternalBackendName; +} { + const backends: Partial> = {}; + + 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; diff --git a/src/daemon/routing.test.ts b/src/daemon/routing.test.ts index 2214e48..f8860d6 100644 --- a/src/daemon/routing.test.ts +++ b/src/daemon/routing.test.ts @@ -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', () => { diff --git a/src/daemon/routing.ts b/src/daemon/routing.ts index ab65070..ba47d45 100644 --- a/src/daemon/routing.ts +++ b/src/daemon/routing.ts @@ -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>; + defaultName?: ExternalBackendName; }): { handler: (msg: InboundMessage, reply: (response: OutboundMessage) => Promise) => Promise; agents: Map; @@ -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'); +} diff --git a/src/tools/builtin/agent-delegate.test.ts b/src/tools/builtin/agent-delegate.test.ts new file mode 100644 index 0000000..be0019a --- /dev/null +++ b/src/tools/builtin/agent-delegate.test.ts @@ -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): 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)).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)).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)).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)).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)).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)).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'); + }); +}); diff --git a/src/tools/builtin/agent-delegate.ts b/src/tools/builtin/agent-delegate.ts new file mode 100644 index 0000000..59b4924 --- /dev/null +++ b/src/tools/builtin/agent-delegate.ts @@ -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 => { + 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), + }; + } + }, + }; +} diff --git a/src/tools/builtin/index.ts b/src/tools/builtin/index.ts index 03463a4..c223e76 100644 --- a/src/tools/builtin/index.ts +++ b/src/tools/builtin/index.ts @@ -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'; diff --git a/src/tools/index.ts b/src/tools/index.ts index cbe9fc0..96bf594 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -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'; diff --git a/src/tools/policy.ts b/src/tools/policy.ts index a4ab8ee..ed7099d 100644 --- a/src/tools/policy.ts +++ b/src/tools/policy.ts @@ -45,6 +45,8 @@ const PROFILE_TOOLS: Record> = { 'k8s.pods', 'k8s.deployments', 'k8s.logs', + 'agent.delegate', + 'agents.list', ]), coding: new Set([ 'file.read', @@ -96,6 +98,8 @@ const PROFILE_TOOLS: Record> = { '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 = { '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. */