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:
parent
017e0f42f6
commit
0170e43c4e
|
|
@ -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]
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
10
src/types.ts
10
src/types.ts
|
|
@ -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. */
|
||||||
|
|
|
||||||
|
|
@ -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']
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue