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
+159
View File
@@ -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');
});
});
+84
View File
@@ -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),
};
}
},
};
}
+2
View File
@@ -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';