366 lines
12 KiB
TypeScript
366 lines
12 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
import { AgentRunner, TOOL_PRESETS } from '../src/agent/runner.js'
|
|
import { ToolRegistry, defineTool } from '../src/tool/framework.js'
|
|
import { ToolExecutor } from '../src/tool/executor.js'
|
|
import { z } from 'zod'
|
|
import type { LLMAdapter, LLMResponse, LLMToolDef } from '../src/types.js'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mock adapter
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const mockAdapter: LLMAdapter = {
|
|
name: 'mock',
|
|
async chat() {
|
|
return {
|
|
id: 'mock-1',
|
|
content: [{ type: 'text', text: 'response' }],
|
|
model: 'mock-model',
|
|
stop_reason: 'end_turn',
|
|
usage: { input_tokens: 10, output_tokens: 20 },
|
|
} satisfies LLMResponse
|
|
},
|
|
async *stream() {
|
|
// Not used in these tests
|
|
},
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test tools
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function createTestTools() {
|
|
const registry = new ToolRegistry()
|
|
|
|
// Register test tools that match our presets
|
|
registry.register(defineTool({
|
|
name: 'file_read',
|
|
description: 'Read file',
|
|
inputSchema: z.object({ path: z.string() }),
|
|
execute: async () => ({ data: 'content', isError: false }),
|
|
}))
|
|
|
|
registry.register(defineTool({
|
|
name: 'file_write',
|
|
description: 'Write file',
|
|
inputSchema: z.object({ path: z.string(), content: z.string() }),
|
|
execute: async () => ({ data: 'ok', isError: false }),
|
|
}))
|
|
|
|
registry.register(defineTool({
|
|
name: 'file_edit',
|
|
description: 'Edit file',
|
|
inputSchema: z.object({ path: z.string(), oldString: z.string(), newString: z.string() }),
|
|
execute: async () => ({ data: 'ok', isError: false }),
|
|
}))
|
|
|
|
registry.register(defineTool({
|
|
name: 'grep',
|
|
description: 'Search text',
|
|
inputSchema: z.object({ pattern: z.string(), path: z.string() }),
|
|
execute: async () => ({ data: 'matches', isError: false }),
|
|
}))
|
|
|
|
registry.register(defineTool({
|
|
name: 'glob',
|
|
description: 'List paths',
|
|
inputSchema: z.object({ path: z.string().optional() }),
|
|
execute: async () => ({ data: 'paths', isError: false }),
|
|
}))
|
|
|
|
registry.register(defineTool({
|
|
name: 'bash',
|
|
description: 'Run shell command',
|
|
inputSchema: z.object({ command: z.string() }),
|
|
execute: async () => ({ data: 'output', isError: false }),
|
|
}))
|
|
|
|
// Extra tool not in any preset
|
|
registry.register(defineTool({
|
|
name: 'custom_tool',
|
|
description: 'Custom tool',
|
|
inputSchema: z.object({ input: z.string() }),
|
|
execute: async () => ({ data: 'custom', isError: false }),
|
|
}), { runtimeAdded: true })
|
|
|
|
return registry
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Tool filtering', () => {
|
|
const registry = createTestTools()
|
|
const executor = new ToolExecutor(registry)
|
|
|
|
describe('TOOL_PRESETS', () => {
|
|
it('readonly preset has correct tools', () => {
|
|
expect(TOOL_PRESETS.readonly).toEqual(['file_read', 'grep', 'glob'])
|
|
})
|
|
|
|
it('readwrite preset has correct tools', () => {
|
|
expect(TOOL_PRESETS.readwrite).toEqual(['file_read', 'file_write', 'file_edit', 'grep', 'glob'])
|
|
})
|
|
|
|
it('full preset has correct tools', () => {
|
|
expect(TOOL_PRESETS.full).toEqual(['file_read', 'file_write', 'file_edit', 'grep', 'glob', 'bash'])
|
|
})
|
|
})
|
|
|
|
describe('resolveTools - no filtering', () => {
|
|
it('returns all tools when no filters are set', () => {
|
|
const runner = new AgentRunner(mockAdapter, registry, executor, {
|
|
model: 'test-model',
|
|
})
|
|
|
|
const tools = (runner as any).resolveTools() as LLMToolDef[]
|
|
const toolNames = tools.map((t: LLMToolDef) => t.name).sort()
|
|
|
|
expect(toolNames).toEqual([
|
|
'bash',
|
|
'custom_tool',
|
|
'file_edit',
|
|
'file_read',
|
|
'file_write',
|
|
'glob',
|
|
'grep',
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('resolveTools - preset filtering', () => {
|
|
it('readonly preset filters correctly', () => {
|
|
const runner = new AgentRunner(mockAdapter, registry, executor, {
|
|
model: 'test-model',
|
|
toolPreset: 'readonly',
|
|
})
|
|
|
|
const tools = (runner as any).resolveTools() as LLMToolDef[]
|
|
const toolNames = tools.map((t: LLMToolDef) => t.name).sort()
|
|
|
|
expect(toolNames).toEqual(['custom_tool', 'file_read', 'glob', 'grep'])
|
|
})
|
|
|
|
it('readwrite preset filters correctly', () => {
|
|
const runner = new AgentRunner(mockAdapter, registry, executor, {
|
|
model: 'test-model',
|
|
toolPreset: 'readwrite',
|
|
})
|
|
|
|
const tools = (runner as any).resolveTools() as LLMToolDef[]
|
|
const toolNames = tools.map((t: LLMToolDef) => t.name).sort()
|
|
|
|
expect(toolNames).toEqual([
|
|
'custom_tool',
|
|
'file_edit',
|
|
'file_read',
|
|
'file_write',
|
|
'glob',
|
|
'grep',
|
|
])
|
|
})
|
|
|
|
it('full preset filters correctly', () => {
|
|
const runner = new AgentRunner(mockAdapter, registry, executor, {
|
|
model: 'test-model',
|
|
toolPreset: 'full',
|
|
})
|
|
|
|
const tools = (runner as any).resolveTools() as LLMToolDef[]
|
|
const toolNames = tools.map((t: LLMToolDef) => t.name).sort()
|
|
|
|
expect(toolNames).toEqual([
|
|
'bash',
|
|
'custom_tool',
|
|
'file_edit',
|
|
'file_read',
|
|
'file_write',
|
|
'glob',
|
|
'grep',
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('resolveTools - allowlist filtering', () => {
|
|
it('allowlist filters correctly', () => {
|
|
const runner = new AgentRunner(mockAdapter, registry, executor, {
|
|
model: 'test-model',
|
|
allowedTools: ['file_read', 'bash'],
|
|
})
|
|
|
|
const tools = (runner as any).resolveTools() as LLMToolDef[]
|
|
const toolNames = tools.map((t: LLMToolDef) => t.name).sort()
|
|
|
|
expect(toolNames).toEqual(['bash', 'custom_tool', 'file_read'])
|
|
})
|
|
|
|
it('empty allowlist returns no tools', () => {
|
|
const runner = new AgentRunner(mockAdapter, registry, executor, {
|
|
model: 'test-model',
|
|
allowedTools: [],
|
|
})
|
|
|
|
const tools = (runner as any).resolveTools()
|
|
expect((tools as LLMToolDef[]).map(t => t.name)).toEqual(['custom_tool'])
|
|
})
|
|
})
|
|
|
|
describe('resolveTools - denylist filtering', () => {
|
|
it('denylist filters correctly', () => {
|
|
const runner = new AgentRunner(mockAdapter, registry, executor, {
|
|
model: 'test-model',
|
|
disallowedTools: ['bash', 'custom_tool'],
|
|
})
|
|
|
|
const tools = (runner as any).resolveTools() as LLMToolDef[]
|
|
const toolNames = tools.map((t: LLMToolDef) => t.name).sort()
|
|
|
|
// custom_tool is runtime-added but disallowedTools still blocks it
|
|
expect(toolNames).toEqual([
|
|
'file_edit',
|
|
'file_read',
|
|
'file_write',
|
|
'glob',
|
|
'grep',
|
|
])
|
|
})
|
|
|
|
it('empty denylist returns all tools', () => {
|
|
const runner = new AgentRunner(mockAdapter, registry, executor, {
|
|
model: 'test-model',
|
|
disallowedTools: [],
|
|
})
|
|
|
|
const tools = (runner as any).resolveTools()
|
|
expect(tools).toHaveLength(7) // All registered tools
|
|
})
|
|
})
|
|
|
|
describe('resolveTools - combined filtering (preset + allowlist + denylist)', () => {
|
|
it('preset + allowlist + denylist work together', () => {
|
|
// Start with readwrite preset: ['file_read', 'file_write', 'file_edit', 'grep', 'glob']
|
|
// Then allowlist: intersect with ['file_read', 'file_write', 'grep'] = ['file_read', 'file_write', 'grep']
|
|
// Then denylist: subtract ['file_write'] = ['file_read', 'grep']
|
|
const runner = new AgentRunner(mockAdapter, registry, executor, {
|
|
model: 'test-model',
|
|
toolPreset: 'readwrite',
|
|
allowedTools: ['file_read', 'file_write', 'grep'],
|
|
disallowedTools: ['file_write'],
|
|
})
|
|
|
|
const tools = (runner as any).resolveTools() as LLMToolDef[]
|
|
const toolNames = tools.map((t: LLMToolDef) => t.name).sort()
|
|
|
|
expect(toolNames).toEqual(['custom_tool', 'file_read', 'grep'])
|
|
})
|
|
|
|
it('preset filters first, then allowlist intersects, then denylist subtracts', () => {
|
|
// Start with readonly preset: ['file_read', 'grep', 'glob']
|
|
// Allowlist intersect with ['file_read', 'bash']: ['file_read']
|
|
// Denylist subtract ['file_read']: []
|
|
const runner = new AgentRunner(mockAdapter, registry, executor, {
|
|
model: 'test-model',
|
|
toolPreset: 'readonly',
|
|
allowedTools: ['file_read', 'bash'],
|
|
disallowedTools: ['file_read'],
|
|
})
|
|
|
|
const tools = (runner as any).resolveTools()
|
|
expect((tools as LLMToolDef[]).map(t => t.name)).toEqual(['custom_tool'])
|
|
})
|
|
})
|
|
|
|
describe('resolveTools - custom tool behavior', () => {
|
|
it('always includes custom tools regardless of filtering', () => {
|
|
const runner = new AgentRunner(mockAdapter, registry, executor, {
|
|
model: 'test-model',
|
|
toolPreset: 'readonly',
|
|
allowedTools: ['file_read'],
|
|
disallowedTools: ['file_read', 'bash', 'grep'],
|
|
})
|
|
|
|
const tools = (runner as any).resolveTools() as LLMToolDef[]
|
|
const toolNames = tools.map((t: LLMToolDef) => t.name).sort()
|
|
|
|
expect(toolNames).toEqual(['custom_tool'])
|
|
})
|
|
|
|
it('runtime-added tools are blocked by disallowedTools', () => {
|
|
const runtimeBuiltinNamedRegistry = new ToolRegistry()
|
|
runtimeBuiltinNamedRegistry.register(defineTool({
|
|
name: 'file_read',
|
|
description: 'Runtime override',
|
|
inputSchema: z.object({ path: z.string() }),
|
|
execute: async () => ({ data: 'runtime', isError: false }),
|
|
}), { runtimeAdded: true })
|
|
|
|
const runtimeBuiltinNamedRunner = new AgentRunner(
|
|
mockAdapter,
|
|
runtimeBuiltinNamedRegistry,
|
|
new ToolExecutor(runtimeBuiltinNamedRegistry),
|
|
{
|
|
model: 'test-model',
|
|
disallowedTools: ['file_read'],
|
|
},
|
|
)
|
|
|
|
const tools = (runtimeBuiltinNamedRunner as any).resolveTools() as LLMToolDef[]
|
|
expect(tools.map(t => t.name)).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('resolveTools - validation warnings', () => {
|
|
let consoleWarnSpy: any
|
|
|
|
beforeEach(() => {
|
|
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
})
|
|
|
|
afterEach(() => {
|
|
consoleWarnSpy.mockRestore()
|
|
})
|
|
|
|
it('warns when tool appears in both allowedTools and disallowedTools', () => {
|
|
const runner = new AgentRunner(mockAdapter, registry, executor, {
|
|
model: 'test-model',
|
|
allowedTools: ['file_read', 'bash'],
|
|
disallowedTools: ['bash', 'grep'],
|
|
})
|
|
|
|
;(runner as any).resolveTools()
|
|
|
|
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('tools ["bash"] appear in both allowedTools and disallowedTools')
|
|
)
|
|
})
|
|
|
|
it('warns when both toolPreset and allowedTools are set', () => {
|
|
const runner = new AgentRunner(mockAdapter, registry, executor, {
|
|
model: 'test-model',
|
|
toolPreset: 'readonly',
|
|
allowedTools: ['file_read', 'bash'],
|
|
})
|
|
|
|
;(runner as any).resolveTools()
|
|
|
|
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('both toolPreset and allowedTools are set')
|
|
)
|
|
})
|
|
|
|
it('does not warn when no overlap between allowedTools and disallowedTools', () => {
|
|
const runner = new AgentRunner(mockAdapter, registry, executor, {
|
|
model: 'test-model',
|
|
allowedTools: ['file_read'],
|
|
disallowedTools: ['bash'],
|
|
})
|
|
|
|
;(runner as any).resolveTools()
|
|
|
|
expect(consoleWarnSpy).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
})
|
|
|