Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/soft-dots-bow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@voltagent/core": patch
---

fix: preserve user-defined `searchTools` and `callTool` when tool routing is enabled

- User-defined tools named `searchTools` or `callTool` now take precedence over internal tool-routing support tools.
- These tools are no longer silently filtered from routing pool/state just because of their names.
- `toolRouting: false` no longer fails for user-defined tools that reuse those names.
105 changes: 105 additions & 0 deletions packages/core/src/agent/agent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2956,6 +2956,24 @@ Use pandas and summarize findings.`.split("\n"),
});

describe("prepareTools", () => {
type TestAgentInternals = {
createOperationContext(input: string): unknown;
prepareTools(
adHocTools: unknown[],
oc: unknown,
maxSteps: number,
options?: unknown,
): Promise<
Record<
string,
{
description?: string;
execute: (args: unknown, options?: unknown) => unknown;
}
>
>;
};

it("should merge static and runtime tools with runtime overrides", async () => {
const staticOnlyTool = new Tool({
name: "static-only",
Expand Down Expand Up @@ -3004,6 +3022,93 @@ Use pandas and summarize findings.`.split("\n"),
expect(prepared["static-only"].description).toBe("Static only tool");
});

it("should prefer user-defined callTool/searchTools when tool routing is enabled", async () => {
const callTool = new Tool({
name: "callTool",
description: "Custom callTool",
parameters: z.object({
toolName: z.string(),
}),
execute: async ({ toolName }) => `called:${toolName}`,
});
const searchTools = new Tool({
name: "searchTools",
description: "Custom searchTools",
parameters: z.object({
query: z.string(),
}),
execute: async ({ query }) => `searched:${query}`,
});
const normalTool = new Tool({
name: "normalTool",
description: "Normal tool",
parameters: z.object({}),
execute: async () => "normal",
});

const agent = new Agent({
name: "TestAgent",
instructions: "Test",
model: mockModel as any,
toolRouting: {},
tools: [callTool, searchTools, normalTool],
});

const testAgent = agent as unknown as TestAgentInternals;
const operationContext = testAgent.createOperationContext("input message");
const prepared = await testAgent.prepareTools([], operationContext, 3, {});

await expect(prepared.callTool.execute({ toolName: "normalTool" })).resolves.toBe(
"called:normalTool",
);
await expect(prepared.searchTools.execute({ query: "normalTool" })).resolves.toBe(
"searched:normalTool",
);

const poolNames = agent.getFullState().toolRouting?.pool?.map((tool) => tool.name) ?? [];
expect(poolNames).toEqual(expect.arrayContaining(["callTool", "searchTools", "normalTool"]));
});

it("should allow user-defined callTool/searchTools when tool routing is disabled per request", async () => {
const callTool = new Tool({
name: "callTool",
description: "Custom callTool",
parameters: z.object({
toolName: z.string(),
}),
execute: async ({ toolName }) => `called:${toolName}`,
});
const searchTools = new Tool({
name: "searchTools",
description: "Custom searchTools",
parameters: z.object({
query: z.string(),
}),
execute: async ({ query }) => `searched:${query}`,
});

const agent = new Agent({
name: "TestAgent",
instructions: "Test",
model: mockModel as any,
toolRouting: {},
tools: [callTool, searchTools],
});

const testAgent = agent as unknown as TestAgentInternals;
const operationContext = testAgent.createOperationContext("input message");
const prepared = await testAgent.prepareTools([], operationContext, 3, {
toolRouting: false,
});

await expect(prepared.callTool.execute({ toolName: "searchTools" })).resolves.toBe(
"called:searchTools",
);
await expect(prepared.searchTools.execute({ query: "callTool" })).resolves.toBe(
"searched:callTool",
);
});

it("should add delegate tool when subagents are present", async () => {
const agent = new Agent({
name: "TestAgent",
Expand Down
39 changes: 19 additions & 20 deletions packages/core/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5438,18 +5438,18 @@ export class Agent {
const toolRouting = this.resolveToolRouting(options);
oc.systemContext.set(TOOL_ROUTING_CONTEXT_KEY, toolRouting);
if (toolRouting === false) {
const supportNames = this.getToolRoutingSupportToolNames();
const allNames = new Set([
...Object.keys(preparedStaticTools),
...Object.keys(preparedDynamicTools),
]);
const conflicts = [...allNames].filter((name) => supportNames.has(name));
if (conflicts.length > 0) {
const conflicts = new Set<string>();
for (const tool of [...this.toolManager.getAllTools(), ...tempManager.getAllTools()]) {
if (this.isToolRoutingSupportTool(tool)) {
conflicts.add(tool.name);
}
}
if (conflicts.size > 0) {
throw new Error(
[
"toolRouting is disabled but reserved routing tool names are in use:",
conflicts.join(", "),
"Rename these tools or enable toolRouting to use the built-in names.",
"toolRouting is disabled but internal routing support tools are in use:",
Array.from(conflicts).join(", "),
"Enable toolRouting or remove internal routing tools before disabling it for this request.",
].join(" "),
);
}
Expand Down Expand Up @@ -5791,10 +5791,7 @@ export class Agent {
if (!tool || typeof tool !== "object") {
return false;
}
if (TOOL_ROUTING_INTERNAL_TOOL_SYMBOL in tool) {
return true;
}
return tool.name === TOOL_ROUTING_SEARCH_TOOL_NAME || tool.name === TOOL_ROUTING_CALL_TOOL_NAME;
return Object.prototype.hasOwnProperty.call(tool, TOOL_ROUTING_INTERNAL_TOOL_SYMBOL);
}

private isToolExecutableForRouting(tool: BaseTool | ProviderTool): boolean {
Expand Down Expand Up @@ -7179,7 +7176,6 @@ export class Agent {
this.toolRouting && typeof this.toolRouting === "object" ? this.toolRouting : undefined;
const toolRoutingState: AgentToolRoutingState | undefined = toolRoutingConfig
? (() => {
const supportNames = this.getToolRoutingSupportToolNames();
const searchTool = this.toolManager.getToolByName(TOOL_ROUTING_SEARCH_TOOL_NAME);
const callTool = this.toolManager.getToolByName(TOOL_ROUTING_CALL_TOOL_NAME);
const searchApiTool =
Expand All @@ -7190,9 +7186,11 @@ export class Agent {
callTool && this.isToolRoutingSupportTool(callTool)
? new ToolManager([callTool], this.logger).getToolsForApi()[0]
: undefined;
const poolApiTools = this.toolPoolManager
.getToolsForApi()
.filter((tool) => !supportNames.has(tool.name));
const poolTools = this.toolPoolManager
.getAllTools()
.filter((tool) => !this.isToolRoutingSupportTool(tool));
const poolApiTools =
poolTools.length > 0 ? new ToolManager(poolTools, this.logger).getToolsForApi() : [];
const exposeApiTools =
toolRoutingConfig.expose && toolRoutingConfig.expose.length > 0
? new ToolManager(toolRoutingConfig.expose, this.logger).getToolsForApi()
Expand Down Expand Up @@ -7572,9 +7570,10 @@ export class Agent {
private upsertToolRoutingSupportTool(tool: Tool<any, any>): void {
const existing = this.toolManager.getToolByName(tool.name);
if (existing && !this.isToolRoutingSupportTool(existing)) {
throw new Error(
`Tool routing requires reserved tool name "${tool.name}". Rename the conflicting tool to enable tool routing.`,
this.logger.debug(
`Tool routing support tool "${tool.name}" not added because a user-defined tool with the same name exists.`,
);
return;
}

if (existing && this.isToolRoutingSupportTool(existing)) {
Expand Down