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
+108
View File
@@ -0,0 +1,108 @@
import { execFile } from 'child_process';
export type ExternalBackendName = 'claude_code' | 'opencode' | 'codex' | 'gemini';
export interface ExternalBackendRequest {
prompt: string;
history: Array<{ role: 'user' | 'assistant'; content: string }>;
}
export interface ExternalBackend {
name: ExternalBackendName;
process(input: ExternalBackendRequest): Promise<string>;
}
interface ExternalCliBackendOptions {
name: ExternalBackendName;
command: string;
args?: string[];
timeoutMs?: number;
}
const DEFAULT_TIMEOUT_MS = 120_000;
function buildPrompt(request: ExternalBackendRequest): string {
const lines: string[] = [];
for (const item of request.history) {
if (!item.content.trim()) {
continue;
}
lines.push(`${item.role.toUpperCase()}: ${item.content}`);
}
lines.push(`USER: ${request.prompt}`);
return lines.join('\n\n');
}
function inferArgs(name: ExternalBackendName, prompt: string): string[] {
if (name === 'claude_code') {
return ['--print', prompt];
}
return ['-p', prompt];
}
export class ExternalCliBackend implements ExternalBackend {
readonly name: ExternalBackendName;
private readonly command: string;
private readonly args: string[];
private readonly timeoutMs: number;
constructor(options: ExternalCliBackendOptions) {
this.name = options.name;
this.command = options.command;
this.args = options.args ?? [];
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
}
async process(input: ExternalBackendRequest): Promise<string> {
const prompt = buildPrompt(input);
const configuredArgs = this.args
.map((arg) => arg.includes('{prompt}') ? arg.replaceAll('{prompt}', prompt) : arg);
const hasPromptPlaceholder = this.args.some((arg) => arg.includes('{prompt}'));
const args = hasPromptPlaceholder
? configuredArgs
: [...configuredArgs, ...inferArgs(this.name, prompt)];
const output = await execFileAsync(this.command, args, this.timeoutMs);
const trimmed = output.trim();
if (!trimmed) {
throw new Error(`External backend "${this.name}" returned no output`);
}
return trimmed;
}
}
export class ClaudeCodeBackend extends ExternalCliBackend {
constructor(path?: string, args?: string[], timeoutMs?: number) {
super({ name: 'claude_code', command: path ?? 'claude', args, timeoutMs });
}
}
export class OpenCodeBackend extends ExternalCliBackend {
constructor(path?: string, args?: string[], timeoutMs?: number) {
super({ name: 'opencode', command: path ?? 'opencode', args, timeoutMs });
}
}
export class CodexBackend extends ExternalCliBackend {
constructor(path?: string, args?: string[], timeoutMs?: number) {
super({ name: 'codex', command: path ?? 'codex', args, timeoutMs });
}
}
export class GeminiBackend extends ExternalCliBackend {
constructor(path?: string, args?: string[], timeoutMs?: number) {
super({ name: 'gemini', command: path ?? 'gemini', args, timeoutMs });
}
}
function execFileAsync(command: string, args: string[], timeoutMs: number): Promise<string> {
return new Promise((resolve, reject) => {
execFile(command, args, { timeout: timeoutMs, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => {
if (error) {
reject(new Error(`${error.message}${stderr ? `\n${stderr}` : ''}`));
return;
}
resolve(stdout || '');
});
});
}