From 0170e43c4e6719c230b892c7b300b3bc63b62e43 Mon Sep 17 00:00:00 2001 From: JackChen Date: Wed, 15 Apr 2026 15:14:19 +0800 Subject: [PATCH] feat: add customTools to AgentConfig (#109) * feat: add customTools support to AgentConfig for orchestrator-level tool injection Users can now pass custom ToolDefinition objects via AgentConfig.customTools, which are registered alongside built-in tools in all orchestrator paths (runAgent, runTeam, runTasks). Custom tools bypass allowlist/preset filtering but can still be blocked by disallowedTools. Ref #108 * test: add disallowedTools blocking custom tool test * fix: apply disallowedTools filtering to runtime-added custom tools Previously runtime-added tools bypassed all filtering including disallowedTools, contradicting the documented behavior. Now custom tools still bypass preset/allowlist but respect the denylist. --- src/agent/runner.ts | 13 ++++-- src/orchestrator/orchestrator.ts | 5 +++ src/types.ts | 10 +++++ tests/orchestrator.test.ts | 74 ++++++++++++++++++++++++++++++++ tests/tool-filtering.test.ts | 6 +-- 5 files changed, 101 insertions(+), 7 deletions(-) diff --git a/src/agent/runner.ts b/src/agent/runner.ts index d1a1ebb..df1cbc0 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -448,8 +448,10 @@ export class AgentRunner { } // 3. Apply denylist filter if set - if (this.options.disallowedTools) { - const denied = new Set(this.options.disallowedTools) + const denied = this.options.disallowedTools + ? new Set(this.options.disallowedTools) + : undefined + if (denied) { filteredTools = filteredTools.filter(t => !denied.has(t.name)) } @@ -457,8 +459,11 @@ export class AgentRunner { const frameworkDenied = new Set(AGENT_FRAMEWORK_DISALLOWED) filteredTools = filteredTools.filter(t => !frameworkDenied.has(t.name)) - // Runtime-added custom tools stay available regardless of filtering rules. - return [...filteredTools, ...runtimeCustomTools] + // Runtime-added custom tools bypass preset / allowlist but respect denylist. + const finalRuntime = denied + ? runtimeCustomTools.filter(t => !denied.has(t.name)) + : runtimeCustomTools + return [...filteredTools, ...finalRuntime] } // ------------------------------------------------------------------------- diff --git a/src/orchestrator/orchestrator.ts b/src/orchestrator/orchestrator.ts index fe016a0..df5e4c9 100644 --- a/src/orchestrator/orchestrator.ts +++ b/src/orchestrator/orchestrator.ts @@ -212,6 +212,11 @@ function resolveTokenBudget(primary?: number, fallback?: number): number | undef function buildAgent(config: AgentConfig): Agent { const registry = new ToolRegistry() registerBuiltInTools(registry) + if (config.customTools) { + for (const tool of config.customTools) { + registry.register(tool, { runtimeAdded: true }) + } + } const executor = new ToolExecutor(registry) return new Agent(config, registry, executor) } diff --git a/src/types.ts b/src/types.ts index bb0c033..b6a69ec 100644 --- a/src/types.ts +++ b/src/types.ts @@ -223,6 +223,16 @@ export interface AgentConfig { /** API key override; falls back to the provider's standard env var. */ readonly apiKey?: string readonly systemPrompt?: string + /** + * Custom tool definitions to register alongside built-in tools. + * Created via `defineTool()`. Custom tools bypass `tools` (allowlist) + * and `toolPreset` filtering, but can still be blocked by `disallowedTools`. + * + * Tool names must not collide with built-in tool names; a duplicate name + * will throw at registration time. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readonly customTools?: readonly ToolDefinition[] /** Names of tools (from the tool registry) available to this agent. */ readonly tools?: readonly string[] /** Names of tools explicitly disallowed for this agent. */ diff --git a/tests/orchestrator.test.ts b/tests/orchestrator.test.ts index 5ef0052..0210647 100644 --- a/tests/orchestrator.test.ts +++ b/tests/orchestrator.test.ts @@ -155,6 +155,80 @@ describe('OpenMultiAgent', () => { expect(oma.getStatus().completedTasks).toBe(1) }) + it('registers customTools so they are available to the LLM', async () => { + mockAdapterResponses = ['used custom tool'] + + const { z } = await import('zod') + const { defineTool } = await import('../src/tool/framework.js') + + const myTool = defineTool({ + name: 'my_custom_tool', + description: 'A custom tool for testing', + inputSchema: z.object({ query: z.string() }), + execute: async ({ query }) => ({ data: query }), + }) + + const oma = new OpenMultiAgent({ defaultModel: 'mock-model' }) + await oma.runAgent( + { ...agentConfig('solo'), customTools: [myTool] }, + 'Use the custom tool', + ) + + const toolNames = capturedChatOptions[0]?.tools?.map(t => t.name) ?? [] + expect(toolNames).toContain('my_custom_tool') + }) + + it('customTools bypass tools allowlist and toolPreset filtering', async () => { + mockAdapterResponses = ['done'] + + const { z } = await import('zod') + const { defineTool } = await import('../src/tool/framework.js') + + const myTool = defineTool({ + name: 'my_custom_tool', + description: 'A custom tool for testing', + inputSchema: z.object({ query: z.string() }), + execute: async ({ query }) => ({ data: query }), + }) + + const oma = new OpenMultiAgent({ defaultModel: 'mock-model' }) + + // toolPreset 'readonly' only allows file_read, grep, glob — custom tool should still appear + await oma.runAgent( + { ...agentConfig('solo'), customTools: [myTool], toolPreset: 'readonly' }, + 'test', + ) + + const toolNames = capturedChatOptions[0]?.tools?.map(t => t.name) ?? [] + expect(toolNames).toContain('my_custom_tool') + // built-in tools outside the preset should be filtered + expect(toolNames).not.toContain('bash') + }) + + it('customTools can be blocked by disallowedTools', async () => { + mockAdapterResponses = ['done'] + + const { z } = await import('zod') + const { defineTool } = await import('../src/tool/framework.js') + + const myTool = defineTool({ + name: 'my_custom_tool', + description: 'A custom tool for testing', + inputSchema: z.object({ query: z.string() }), + execute: async ({ query }) => ({ data: query }), + }) + + const oma = new OpenMultiAgent({ defaultModel: 'mock-model' }) + + await oma.runAgent( + { ...agentConfig('solo'), customTools: [myTool], disallowedTools: ['my_custom_tool'] }, + 'test', + ) + + const toolNames = capturedChatOptions[0]?.tools?.map(t => t.name) ?? [] + expect(toolNames).not.toContain('my_custom_tool') + }) + it('fires onProgress events', async () => { mockAdapterResponses = ['done'] diff --git a/tests/tool-filtering.test.ts b/tests/tool-filtering.test.ts index f42b8e1..418e702 100644 --- a/tests/tool-filtering.test.ts +++ b/tests/tool-filtering.test.ts @@ -216,8 +216,8 @@ describe('Tool filtering', () => { 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([ - 'custom_tool', 'file_edit', 'file_read', 'file_write', @@ -286,7 +286,7 @@ describe('Tool filtering', () => { expect(toolNames).toEqual(['custom_tool']) }) - it('runtime-added tools bypass filtering regardless of tool name', () => { + it('runtime-added tools are blocked by disallowedTools', () => { const runtimeBuiltinNamedRegistry = new ToolRegistry() runtimeBuiltinNamedRegistry.register(defineTool({ name: 'file_read', @@ -306,7 +306,7 @@ describe('Tool filtering', () => { ) const tools = (runtimeBuiltinNamedRunner as any).resolveTools() as LLMToolDef[] - expect(tools.map(t => t.name)).toEqual(['file_read']) + expect(tools.map(t => t.name)).toEqual([]) }) })