From 38a88df144bed79326df5190100a9b2583137924 Mon Sep 17 00:00:00 2001 From: JackChen Date: Wed, 15 Apr 2026 14:45:02 +0800 Subject: [PATCH] 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 --- src/orchestrator/orchestrator.ts | 5 ++++ src/types.ts | 10 +++++++ tests/orchestrator.test.ts | 50 ++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+) 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..f7b5ce9 100644 --- a/tests/orchestrator.test.ts +++ b/tests/orchestrator.test.ts @@ -155,6 +155,56 @@ 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('fires onProgress events', async () => { mockAdapterResponses = ['done']