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:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user