From 9d5fedb9b5866fa902e4f51a5702ca37bc27c1c5 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 3 May 2026 11:27:24 -0700 Subject: [PATCH] fix(plugins): normalize tool name conflicts --- CHANGELOG.md | 1 + src/plugins/tools.optional.test.ts | 50 ++++++++++++++++++++++++++++++ src/plugins/tools.ts | 17 +++++----- 3 files changed, 61 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4420d710c5..f4b0095d158 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai - Discord/native commands: skip slash-command registration and cleanup REST calls when `channels.discord.commands.native=false`, letting low-power gateways start without waiting on disabled native-command lifecycle requests. Fixes #76202. Thanks @vincentkoc. - Plugins/commands: normalize empty plugin command handler results and let Telegram native plugin commands send the empty-response fallback instead of throwing when a handler returns `undefined`. Fixes #74800. Thanks @vincentkoc. - Plugins/tools: cold-load selected plugin tool registries when the active registry only has partial tool coverage, so wildcard-expanded allowlists no longer hide installed plugin tools from `tools.effective`. Fixes #76780. Thanks @lilesjtu. +- Plugins/tools: compare cached and runtime plugin tool name conflicts with normalized core tool names, so case variants of core tools are blocked instead of leaking duplicate tool registrations. Thanks @vincentkoc. - Plugins/OpenRouter: advertise DeepSeek V4 thinking levels, including `xhigh` and `max`, through the runtime and lightweight provider policy surfaces so `/think` validation no longer rejects OpenRouter-routed DeepSeek V4 models. Fixes #74788. Thanks @vincentkoc. - Status/sessions: ignore malformed non-string persisted session provider/model metadata instead of throwing while rendering status summaries. Thanks @vincentkoc. - CLI/config: remove only the targeted array element for `openclaw config unset array[index]` instead of replaying the unset during config write and deleting the shifted next element. Fixes #76290. Thanks @SymbolStar and @vincentkoc. diff --git a/src/plugins/tools.optional.test.ts b/src/plugins/tools.optional.test.ts index 30e6dc367f3..fd7de8e139e 100644 --- a/src/plugins/tools.optional.test.ts +++ b/src/plugins/tools.optional.test.ts @@ -982,6 +982,56 @@ describe("resolvePluginTools optional tools", () => { }); }); + it("rejects normalized plugin tool name collisions with core tools", () => { + const registry = setRegistry([ + { + pluginId: "multi", + optional: false, + source: "/tmp/multi.js", + names: ["Message", "other_tool"], + declaredNames: ["Message", "other_tool"], + factory: () => [makeTool("Message"), makeTool("other_tool")], + }, + ]); + + const tools = resolvePluginTools( + createResolveToolsParams({ + existingToolNames: new Set(["message"]), + }), + ); + + expectResolvedToolNames(tools, ["other_tool"]); + expectSingleDiagnosticMessage( + registry.diagnostics, + "plugin tool name conflict (multi): Message", + ); + }); + + it("rejects normalized cached plugin tool name collisions with core tools", () => { + const factory = vi.fn(() => makeTool("Message")); + setRegistry([ + { + pluginId: "multi", + optional: false, + source: "/tmp/multi.js", + names: ["Message"], + declaredNames: ["Message"], + factory, + }, + ]); + + const first = resolvePluginTools(createResolveToolsParams()); + const second = resolvePluginTools( + createResolveToolsParams({ + existingToolNames: new Set(["message"]), + }), + ); + + expectResolvedToolNames(first, ["Message"]); + expect(second).toEqual([]); + expect(factory).toHaveBeenCalled(); + }); + it.each([ { name: "uses loaded plugin tools with an explicit env", diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index fbb2fd2b37c..e6949f214e5 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -567,7 +567,7 @@ function resolveCachedPluginTools(params: { } const pluginTools: AnyAgentTool[] = []; let hasNameConflict = false; - const localNames = new Set(); + const localNormalizedNames = new Set(); for (const cachedDescriptor of cached) { if ( !cachedDescriptor.optional && @@ -587,14 +587,15 @@ function resolveCachedPluginTools(params: { ) { continue; } + const normalizedDescriptorName = normalizeToolName(cachedDescriptor.descriptor.name); if ( - localNames.has(cachedDescriptor.descriptor.name) || - params.existing.has(cachedDescriptor.descriptor.name) + localNormalizedNames.has(normalizedDescriptorName) || + params.existingNormalized.has(normalizedDescriptorName) ) { hasNameConflict = true; break; } - localNames.add(cachedDescriptor.descriptor.name); + localNormalizedNames.add(normalizedDescriptorName); pluginTools.push( createCachedDescriptorPluginTool({ descriptor: cachedDescriptor, @@ -929,7 +930,7 @@ export function resolvePluginTools(params: { if (list.length === 0) { continue; } - const nameSet = new Set(); + const normalizedNameSet = new Set(); for (const toolRaw of list) { // Plugin factories run at request time and can return arbitrary values; isolate // malformed tools here so one bad plugin tool cannot poison every provider. @@ -963,7 +964,8 @@ export function resolvePluginTools(params: { }); continue; } - if (nameSet.has(tool.name) || existing.has(tool.name)) { + const normalizedToolName = normalizeToolName(tool.name); + if (normalizedNameSet.has(normalizedToolName) || existingNormalized.has(normalizedToolName)) { const message = `plugin tool name conflict (${entry.pluginId}): ${tool.name}`; if (!params.suppressNameConflicts) { context.logger.error(message); @@ -976,8 +978,9 @@ export function resolvePluginTools(params: { } continue; } - nameSet.add(tool.name); + normalizedNameSet.add(normalizedToolName); existing.add(tool.name); + existingNormalized.add(normalizedToolName); pluginToolMeta.set(tool, { pluginId: entry.pluginId, optional: entry.optional,