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.
This commit is contained in:
JackChen 2026-04-15 15:14:19 +08:00 committed by GitHub
parent 017e0f42f6
commit 0170e43c4e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 101 additions and 7 deletions

View File

@ -448,8 +448,10 @@ export class AgentRunner {
} }
// 3. Apply denylist filter if set // 3. Apply denylist filter if set
if (this.options.disallowedTools) { const denied = this.options.disallowedTools
const denied = new Set(this.options.disallowedTools) ? new Set(this.options.disallowedTools)
: undefined
if (denied) {
filteredTools = filteredTools.filter(t => !denied.has(t.name)) filteredTools = filteredTools.filter(t => !denied.has(t.name))
} }
@ -457,8 +459,11 @@ export class AgentRunner {
const frameworkDenied = new Set(AGENT_FRAMEWORK_DISALLOWED) const frameworkDenied = new Set(AGENT_FRAMEWORK_DISALLOWED)
filteredTools = filteredTools.filter(t => !frameworkDenied.has(t.name)) filteredTools = filteredTools.filter(t => !frameworkDenied.has(t.name))
// Runtime-added custom tools stay available regardless of filtering rules. // Runtime-added custom tools bypass preset / allowlist but respect denylist.
return [...filteredTools, ...runtimeCustomTools] const finalRuntime = denied
? runtimeCustomTools.filter(t => !denied.has(t.name))
: runtimeCustomTools
return [...filteredTools, ...finalRuntime]
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------

View File

@ -212,6 +212,11 @@ function resolveTokenBudget(primary?: number, fallback?: number): number | undef
function buildAgent(config: AgentConfig): Agent { function buildAgent(config: AgentConfig): Agent {
const registry = new ToolRegistry() const registry = new ToolRegistry()
registerBuiltInTools(registry) registerBuiltInTools(registry)
if (config.customTools) {
for (const tool of config.customTools) {
registry.register(tool, { runtimeAdded: true })
}
}
const executor = new ToolExecutor(registry) const executor = new ToolExecutor(registry)
return new Agent(config, registry, executor) 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. */ /** API key override; falls back to the provider's standard env var. */
readonly apiKey?: string readonly apiKey?: string
readonly systemPrompt?: 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. */ /** Names of tools (from the tool registry) available to this agent. */
readonly tools?: readonly string[] readonly tools?: readonly string[]
/** Names of tools explicitly disallowed for this agent. */ /** Names of tools explicitly disallowed for this agent. */

View File

@ -155,6 +155,80 @@ describe('OpenMultiAgent', () => {
expect(oma.getStatus().completedTasks).toBe(1) 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 () => { it('fires onProgress events', async () => {
mockAdapterResponses = ['done'] mockAdapterResponses = ['done']

View File

@ -216,8 +216,8 @@ describe('Tool filtering', () => {
const tools = (runner as any).resolveTools() as LLMToolDef[] const tools = (runner as any).resolveTools() as LLMToolDef[]
const toolNames = tools.map((t: LLMToolDef) => t.name).sort() const toolNames = tools.map((t: LLMToolDef) => t.name).sort()
// custom_tool is runtime-added but disallowedTools still blocks it
expect(toolNames).toEqual([ expect(toolNames).toEqual([
'custom_tool',
'file_edit', 'file_edit',
'file_read', 'file_read',
'file_write', 'file_write',
@ -286,7 +286,7 @@ describe('Tool filtering', () => {
expect(toolNames).toEqual(['custom_tool']) 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() const runtimeBuiltinNamedRegistry = new ToolRegistry()
runtimeBuiltinNamedRegistry.register(defineTool({ runtimeBuiltinNamedRegistry.register(defineTool({
name: 'file_read', name: 'file_read',
@ -306,7 +306,7 @@ describe('Tool filtering', () => {
) )
const tools = (runtimeBuiltinNamedRunner as any).resolveTools() as LLMToolDef[] const tools = (runtimeBuiltinNamedRunner as any).resolveTools() as LLMToolDef[]
expect(tools.map(t => t.name)).toEqual(['file_read']) expect(tools.map(t => t.name)).toEqual([])
}) })
}) })