diff --git a/src/agents/agent-tools.message-provider-policy.test.ts b/src/agents/agent-tools.message-provider-policy.test.ts index 265b910e52ab..5393d895b1d8 100644 --- a/src/agents/agent-tools.message-provider-policy.test.ts +++ b/src/agents/agent-tools.message-provider-policy.test.ts @@ -4,7 +4,10 @@ * unsafe or redundant for the active channel. */ import { describe, expect, it } from "vitest"; -import { filterToolNamesByMessageProvider } from "./agent-tools.message-provider-policy.js"; +import { + filterToolNamesByMessageProvider, + filterToolsByMessageProvider, +} from "./agent-tools.message-provider-policy.js"; const DEFAULT_TOOL_NAMES = ["read", "write", "tts", "web_search"]; @@ -21,4 +24,32 @@ describe("createOpenClawCodingTools message provider policy", () => { const names = new Set(filterToolNamesByMessageProvider(DEFAULT_TOOL_NAMES, "guildchat")); expect(names.has("tts")).toBe(true); }); + + it("omits unreadable tool names while applying provider policy", () => { + const readTool = { name: "read" }; + const malformedTool = { + get name(): string { + throw new Error("fuzzed unreadable tool name"); + }, + }; + const ttsTool = { name: "tts" }; + + expect(filterToolsByMessageProvider([readTool, malformedTool, ttsTool], "voice")).toEqual([ + readTool, + ]); + }); + + it("does not read tool names when no provider policy applies", () => { + const readTool = { name: "read" }; + const malformedTool = { + get name(): string { + throw new Error("fuzzed unreadable tool name"); + }, + }; + + expect(filterToolsByMessageProvider([readTool, malformedTool], "guildchat")).toEqual([ + readTool, + malformedTool, + ]); + }); }); diff --git a/src/agents/agent-tools.message-provider-policy.ts b/src/agents/agent-tools.message-provider-policy.ts index 5be579475b99..f45fc8b43657 100644 --- a/src/agents/agent-tools.message-provider-policy.ts +++ b/src/agents/agent-tools.message-provider-policy.ts @@ -14,6 +14,19 @@ const TOOL_ALLOW_BY_MESSAGE_PROVIDER: Readonly node: ["canvas", "image", "pdf", "tts", "web_fetch", "web_search"], }; +type ReadableToolName = { + readonly name: string; + readonly tool: TTool; +}; + +function readToolName(tool: { name: string }): string | undefined { + try { + return tool.name; + } catch { + return undefined; + } +} + /** Filters tool names by the active message-provider allow/deny policy. */ export function filterToolNamesByMessageProvider( toolNames: readonly string[], @@ -41,22 +54,36 @@ export function filterToolsByMessageProvider( tools: readonly TTool[], messageProvider?: string, ): TTool[] { + const normalizedProvider = normalizeOptionalLowercaseString(messageProvider); + if (!normalizedProvider) { + return [...tools]; + } + const allowedTools = TOOL_ALLOW_BY_MESSAGE_PROVIDER[normalizedProvider]; + const deniedTools = TOOL_DENY_BY_MESSAGE_PROVIDER[normalizedProvider]; + if ((!allowedTools || allowedTools.length === 0) && (!deniedTools || deniedTools.length === 0)) { + return [...tools]; + } + const readableTools: ReadableToolName[] = []; + for (const tool of tools) { + const name = readToolName(tool); + if (name) { + readableTools.push({ name, tool }); + } + } const filteredToolNames = filterToolNamesByMessageProvider( - tools.map((tool) => tool.name), - messageProvider, + readableTools.map((tool) => tool.name), + normalizedProvider, ); const remainingCounts = new Map(); for (const toolName of filteredToolNames) { remainingCounts.set(toolName, (remainingCounts.get(toolName) ?? 0) + 1); } - return tools.filter((tool) => { - // Counted matching preserves the original order and duplicate instances - // after name-level policy filtering. - const remaining = remainingCounts.get(tool.name) ?? 0; + return readableTools.flatMap(({ name, tool }) => { + const remaining = remainingCounts.get(name) ?? 0; if (remaining <= 0) { - return false; + return []; } - remainingCounts.set(tool.name, remaining - 1); - return true; + remainingCounts.set(name, remaining - 1); + return [tool]; }); }