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
This commit is contained in:
JackChen 2026-04-15 14:45:02 +08:00
parent 017e0f42f6
commit 38a88df144
3 changed files with 65 additions and 0 deletions

View File

@ -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)
}

View File

@ -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<any>[]
/** Names of tools (from the tool registry) available to this agent. */
readonly tools?: readonly string[]
/** Names of tools explicitly disallowed for this agent. */

View File

@ -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']