Files
waggle-os/packages/agent/tests/subagent-tools.test.ts
Oleg Maslov b20b138fe4 moving
2026-09-02 10:14:22 +02:00

287 lines
11 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest';
import { createSubAgentTools } from '../src/subagent-tools.js';
import type { ToolDefinition } from '../src/tools.js';
import type { AgentLoopConfig, AgentResponse } from '../src/agent-loop.js';
import { measureOpenAiToolSchemaChars } from '../src/tool-filter.js';
function makeMockTools(): ToolDefinition[] {
return [
{ name: 'web_search', description: 'Search', parameters: { type: 'object', properties: {} }, execute: async () => 'results' },
{ name: 'web_fetch', description: 'Fetch', parameters: { type: 'object', properties: {} }, execute: async () => 'content' },
{ name: 'read_file', description: 'Read', parameters: { type: 'object', properties: {} }, execute: async () => 'file content' },
{ name: 'write_file', description: 'Write', parameters: { type: 'object', properties: {} }, execute: async () => 'ok' },
{ name: 'bash', description: 'Shell', parameters: { type: 'object', properties: {} }, execute: async () => 'output' },
{ name: 'search_memory', description: 'Memory', parameters: { type: 'object', properties: {} }, execute: async () => 'memories' },
{ name: 'save_memory', description: 'Save', parameters: { type: 'object', properties: {} }, execute: async () => 'saved' },
];
}
function makeMockRunner(): (config: AgentLoopConfig) => Promise<AgentResponse> {
return vi.fn(async (config: AgentLoopConfig): Promise<AgentResponse> => ({
content: `Result from sub-agent. Task: ${config.messages[0]?.content}`,
usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 },
toolsUsed: ['web_search', 'read_file'],
model: config.model,
}));
}
describe('subagent-tools', () => {
function createTools(runLoop?: ReturnType<typeof makeMockRunner>) {
return createSubAgentTools({
availableTools: makeMockTools(),
runLoop: runLoop ?? makeMockRunner(),
litellmUrl: 'http://localhost:4000',
litellmApiKey: 'test-key',
defaultModel: 'test-model',
});
}
function run(tools: ReturnType<typeof createTools>, name: string, args: Record<string, unknown> = {}) {
const tool = tools.find(t => t.name === name);
if (!tool) throw new Error(`Tool ${name} not found`);
return tool.execute(args);
}
it('spawn_agent runs a sub-agent and returns result', async () => {
const runner = makeMockRunner();
const tools = createTools(runner);
const result = await run(tools, 'spawn_agent', {
name: 'Research Bot',
role: 'researcher',
task: 'Find information about TypeScript 5.0',
});
expect(result).toContain('Sub-Agent Result: Research Bot');
expect(result).toContain('researcher');
expect(result).toContain('web_search, read_file');
expect(runner).toHaveBeenCalledOnce();
// Verify the runner was called with correct role-based tools
const config = runner.mock.calls[0][0];
const toolNames = config.tools.map((t: ToolDefinition) => t.name);
expect(toolNames).toContain('web_search');
expect(toolNames).toContain('web_fetch');
expect(toolNames).toContain('read_file');
expect(toolNames).not.toContain('bash'); // researcher shouldn't have bash
});
it('spawn_agent with coder role gets coding tools', async () => {
const runner = makeMockRunner();
const tools = createTools(runner);
await run(tools, 'spawn_agent', {
name: 'Coder',
role: 'coder',
task: 'Fix the bug',
});
const config = runner.mock.calls[0][0];
const toolNames = config.tools.map((t: ToolDefinition) => t.name);
expect(toolNames).toContain('bash');
expect(toolNames).toContain('write_file');
});
it('spawn_agent with custom role uses specified tools', async () => {
const runner = makeMockRunner();
const tools = createTools(runner);
await run(tools, 'spawn_agent', {
name: 'Custom',
role: 'custom',
task: 'Do something',
tools: ['bash', 'read_file'],
});
const config = runner.mock.calls[0][0];
const toolNames = config.tools.map((t: ToolDefinition) => t.name);
expect(toolNames).toEqual(['read_file', 'bash']);
});
it('spawn_agent handles errors gracefully', async () => {
const runner = vi.fn(async () => { throw new Error('LLM connection failed'); });
const tools = createSubAgentTools({
availableTools: makeMockTools(),
runLoop: runner,
litellmUrl: 'http://localhost:4000',
litellmApiKey: 'test-key',
});
const result = await run(tools, 'spawn_agent', {
name: 'Failing Bot',
role: 'researcher',
task: 'This will fail',
});
expect(result).toContain('Sub-Agent Error');
expect(result).toContain('LLM connection failed');
});
it('list_agents shows completed agents after spawns', async () => {
const tools = createTools();
await run(tools, 'spawn_agent', {
name: 'Completed Bot',
role: 'analyst',
task: 'Analyze data',
});
const result = await run(tools, 'list_agents');
expect(result).toContain('Completed Agents');
expect(result).toContain('Completed Bot');
});
it('spawn_agent includes context in system prompt', async () => {
const runner = makeMockRunner();
const tools = createTools(runner);
await run(tools, 'spawn_agent', {
name: 'Context Bot',
role: 'researcher',
task: 'Research X',
context: 'Project is about AI agents',
});
const config = runner.mock.calls[0][0];
expect(config.systemPrompt).toContain('Project is about AI agents');
});
it('spawn_agent respects valid max_turns and rejects fractional zero-turn limits', async () => {
const runner = makeMockRunner();
const tools = createTools(runner);
await run(tools, 'spawn_agent', {
name: 'Limited Bot',
role: 'researcher',
task: 'Quick task',
max_turns: 5,
});
await run(tools, 'spawn_agent', {
name: 'Fractional Bot',
role: 'researcher',
task: 'Quick task',
max_turns: 0.5,
});
expect(runner.mock.calls[0][0].maxTurns).toBe(5);
expect(runner.mock.calls[1][0].maxTurns).toBe(9);
});
it('bounds a large custom tool pool and caps requested turns with the task policy', async () => {
const availableTools = [
...Array.from({ length: 37 }, (_, index) => ({
name: `code_tool_${index}`,
description: `Run code tests and inspect this implementation.${' x'.repeat(120)}`,
parameters: { type: 'object', properties: {} },
execute: async () => 'ok',
} satisfies ToolDefinition)),
{
name: 'read_file',
description: 'Read a file for code inspection.',
parameters: { type: 'object', properties: {} },
execute: async () => 'content',
} satisfies ToolDefinition,
];
const runner = makeMockRunner();
const tools = createSubAgentTools({
availableTools,
runLoop: runner,
litellmUrl: 'http://localhost:4000',
litellmApiKey: 'test-key',
defaultModel: 'test-model',
});
await run(tools, 'spawn_agent', {
name: 'Bounded Coder',
role: 'custom',
task: 'Run code tests and inspect this implementation',
tools: availableTools.map((tool) => tool.name),
max_turns: 50,
});
const config = runner.mock.calls[0][0];
expect(config.tools.length).toBeLessThanOrEqual(14);
expect(config.tools.map((tool) => tool.name)).toContain('read_file');
expect(measureOpenAiToolSchemaChars(config.tools)).toBeLessThanOrEqual(8_000);
expect(config).toMatchObject({
maxTurns: 9,
maxToolRounds: 8,
maxTokenBudget: 80_000,
synthesisReserveTokens: 14_000,
toolContextBudget: {
maxSingleResultChars: 8_000,
recentResultCount: 2,
historicalResultChars: 750,
},
});
});
// Gap L (Skills 2.0 verification): sub-agent results must persist beyond
// the 30-min in-memory eviction via a caller-supplied completion callback.
describe('onSubAgentComplete — gap L (result persistence)', () => {
it('fires the callback once with the full SubAgentResult on success', async () => {
const onComplete = vi.fn(async () => {});
const tools = createSubAgentTools({
availableTools: makeMockTools(),
runLoop: makeMockRunner(),
litellmUrl: 'http://localhost:4000',
litellmApiKey: 'test-key',
defaultModel: 'test-model',
onSubAgentComplete: onComplete,
});
await run(tools, 'spawn_agent', {
name: 'Persisted Bot',
role: 'analyst',
task: 'Do a thing',
});
expect(onComplete).toHaveBeenCalledOnce();
const arg = onComplete.mock.calls[0][0];
expect(arg).toMatchObject({
agentName: 'Persisted Bot',
role: 'analyst',
response: expect.stringContaining('Do a thing'),
toolsUsed: expect.any(Array),
usage: { inputTokens: 100, outputTokens: 50 },
});
expect(typeof arg.duration).toBe('number');
expect(typeof arg.completedAt).toBe('number');
expect(typeof arg.agentId).toBe('string');
});
it('does NOT fire the callback when the sub-agent errors', async () => {
const onComplete = vi.fn(async () => {});
const failingRunner = vi.fn(async () => { throw new Error('LLM down'); });
const tools = createSubAgentTools({
availableTools: makeMockTools(),
runLoop: failingRunner,
litellmUrl: 'http://localhost:4000',
litellmApiKey: 'test-key',
onSubAgentComplete: onComplete,
});
const result = await run(tools, 'spawn_agent', {
name: 'Broken Bot',
role: 'researcher',
task: 'Will fail',
});
expect(result).toContain('Sub-Agent Error');
expect(onComplete).not.toHaveBeenCalled();
});
it('swallows callback errors — persistence failure must not break the return', async () => {
const onComplete = vi.fn(async () => { throw new Error('DB write failed'); });
const tools = createSubAgentTools({
availableTools: makeMockTools(),
runLoop: makeMockRunner(),
litellmUrl: 'http://localhost:4000',
litellmApiKey: 'test-key',
onSubAgentComplete: onComplete,
});
const result = await run(tools, 'spawn_agent', {
name: 'Survivor Bot',
role: 'researcher',
task: 'Main agent still gets the result',
});
expect(onComplete).toHaveBeenCalledOnce();
// Main-agent-visible string unaffected by the persistence failure
expect(result).toContain('Sub-Agent Result: Survivor Bot');
expect(result).not.toContain('DB write failed');
});
it('still works when no callback is provided (backwards-compatible)', async () => {
const tools = createTools();
const result = await run(tools, 'spawn_agent', {
name: 'Legacy Bot',
role: 'researcher',
task: 'No callback wired',
});
expect(result).toContain('Sub-Agent Result: Legacy Bot');
});
});
});