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:
@@ -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.<name>.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.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 || '');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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] },
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user