From c4f2117a234e827b68d5b44666679aa817a2d560 Mon Sep 17 00:00:00 2001 From: matevip Date: Sat, 2 May 2026 15:39:49 +0800 Subject: [PATCH] fix(agent): tolerate LLM-mangled tool names --- .gitignore | 3 + .../graph/executor/ToolExecutionExecutor.java | 78 ++++++++++++++++++- .../src/main/resources/messages.properties | 1 - 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 6c24bfe0..04a16d35 100644 --- a/.gitignore +++ b/.gitignore @@ -98,3 +98,6 @@ CLAUDE.md # Codex CLI local artifacts .codex/ + +# QwenPaw sync state (generated each run; report is intentionally tracked) +scripts/.qwenpaw-sync-state.json diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java index a926c0ae..070a6dbf 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/executor/ToolExecutionExecutor.java @@ -23,6 +23,7 @@ import vip.mate.tool.guard.service.ToolGuardService; import java.util.*; import java.util.Collections; import java.util.concurrent.*; +import java.util.regex.Pattern; /** * 统一工具执行器(共享于 ActionNode 和 StepExecutionNode) @@ -114,6 +115,16 @@ public class ToolExecutionExecutor { } private final Map toolCallbackMap; + /** + * Maps a normalized tool name (lowercase snake_case, with `_tool`/`_function` + * suffixes stripped) to the canonical name registered in {@link #toolCallbackMap}. + * Lets us resolve names the LLM sometimes mangles (e.g. {@code WebSearch}, + * {@code web_search_tool}, {@code Read_File}) back to the registered tool + * before guard / lookup / event reporting run, so guard rules keyed on the + * canonical name aren't silently bypassed. + */ + private final Map normalizedNameLookup; + private static final Pattern CAMEL_BOUNDARY = Pattern.compile("([a-z0-9])([A-Z])"); private final ToolGuardService toolGuardService; private final ToolGuard toolGuard; // legacy fallback private final ApprovalWorkflowService approvalService; @@ -165,6 +176,7 @@ public class ToolExecutionExecutor { public ToolExecutionExecutor(AgentToolSet toolSet, ToolGuard toolGuard, ApprovalWorkflowService approvalService, ChatStreamTracker streamTracker) { this.toolCallbackMap = toolSet.callbackByName(); + this.normalizedNameLookup = buildNormalizedLookup(this.toolCallbackMap.keySet()); this.toolGuardService = null; this.toolGuard = toolGuard; this.approvalService = approvalService; @@ -181,6 +193,7 @@ public class ToolExecutionExecutor { ToolResultStorage resultStorage, vip.mate.tool.ToolConcurrencyRegistry concurrencyRegistry) { this.toolCallbackMap = toolSet.callbackByName(); + this.normalizedNameLookup = buildNormalizedLookup(this.toolCallbackMap.keySet()); this.toolGuardService = toolGuardService; this.toolGuard = toolGuard; this.approvalService = approvalService; @@ -268,7 +281,10 @@ public class ToolExecutionExecutor { for (int i = 0; i < toolCalls.size(); i++) { AssistantMessage.ToolCall toolCall = toolCalls.get(i); - String toolName = toolCall.name(); + // Resolve LLM-emitted name to canonical BEFORE guard / lookup so a + // mangled name (Read_File, web_search_tool, BrowserUseTool) can't + // bypass guard rules keyed on the canonical name. + String toolName = resolveToolName(toolCall.name()); String arguments = toolCall.arguments(); events.add(GraphEventPublisher.toolStart(toolCall.id(), toolName, arguments)); @@ -410,7 +426,7 @@ public class ToolExecutionExecutor { List events, String conversationId, String workspaceBasePath, List directOutputs) { - String toolName = toolCall.name(); + String toolName = resolveToolName(toolCall.name()); String callArguments = storedArguments != null ? storedArguments : toolCall.arguments(); ToolCallback callback = toolCallbackMap.get(toolName); @@ -842,6 +858,64 @@ public class ToolExecutionExecutor { *

Case-insensitive match because LLMs sometimes change the case of * skill names mid-conversation. */ + /** + * Resolve the LLM-emitted tool name to a registered canonical name. + * Tries exact match first (the hot path); on miss, normalizes the input + * (camelCase→snake_case, lowercase, strip {@code _tool}/{@code _function} + * suffix) and looks up the canonical equivalent. Returns the original + * string when no match is found, so the caller's downstream "tool not + * found" path still fires. + */ + String resolveToolName(String requested) { + if (requested == null || requested.isBlank()) { + return requested; + } + if (toolCallbackMap.containsKey(requested)) { + return requested; + } + String normalized = normalizeToolName(requested); + String canonical = normalizedNameLookup.get(normalized); + if (canonical != null) { + log.info("[ToolExecutor] Tool name normalized: '{}' -> '{}' (via '{}')", + requested, canonical, normalized); + return canonical; + } + return requested; + } + + static String normalizeToolName(String name) { + if (name == null || name.isBlank()) { + return ""; + } + String snake = CAMEL_BOUNDARY.matcher(name).replaceAll("$1_$2"); + String collapsed = snake.toLowerCase(Locale.ROOT) + .replaceAll("[\\s\\-.]+", "_") + .replaceAll("_+", "_"); + if (collapsed.endsWith("_tool")) { + collapsed = collapsed.substring(0, collapsed.length() - 5); + } else if (collapsed.endsWith("_function")) { + collapsed = collapsed.substring(0, collapsed.length() - 9); + } + return collapsed.replaceAll("^_+|_+$", ""); + } + + private static Map buildNormalizedLookup(Set canonicalNames) { + Map result = new HashMap<>(canonicalNames.size() * 2); + for (String name : canonicalNames) { + String norm = normalizeToolName(name); + if (norm.isEmpty()) { + continue; + } + String previous = result.putIfAbsent(norm, name); + if (previous != null && !previous.equals(name)) { + log.warn("[ToolExecutor] Two registered tools normalize to the same key '{}': " + + "'{}' and '{}' — only '{}' will resolve from mangled LLM emissions", + norm, previous, name, previous); + } + } + return Map.copyOf(result); + } + private String skillAwareNotFoundMessage(String toolName) { if (skillRuntimeService != null && toolName != null && !toolName.isBlank()) { try { diff --git a/mateclaw-server/src/main/resources/messages.properties b/mateclaw-server/src/main/resources/messages.properties index e79befb4..a9c51900 100644 --- a/mateclaw-server/src/main/resources/messages.properties +++ b/mateclaw-server/src/main/resources/messages.properties @@ -233,7 +233,6 @@ err.llm.pkce_failed=PKCE \u751f\u6210\u5931\u8d25 err.llm.chatgpt_stream_failed=ChatGPT \u6d41\u5f0f\u8c03\u7528\u5931\u8d25 err.llm.chatgpt_error=ChatGPT \u8fd4\u56de\u9519\u8bef err.llm.chatgpt_account_missing=chatgpt-account-id \u7f3a\u5931 -err.llm.model_not_supported=\u6a21\u578b\u0020\u0049\u0044\u0020\u0020\u4e0d\u652f\u6301\u5728\u0020\u0044\u0061\u0073\u0068\u0053\u0063\u006f\u0070\u0065\u0020\u539f\u751f\u534f\u8bae\u4e2d\u4f7f\u7528\u3002\u70b9\u7248\u672c\u683c\u5f0f\u7684\u7cfb\u5217\uff08\u4f8b\u5982\u0020\u0071\u0077\u0065\u006e\u0033\u002e\u0035\u002d\u002a\u3001\u0020\u0071\u0077\u0065\u006e\u0033\u002e\u0036\u002d\u002a\uff09\u53ea\u80fd\u901a\u8fc7\u517c\u5bb9\u6a21\u5f0f\u4f7f\u7528\u3002\u8bf7\u4f7f\u7528\u5141\u8bb8\u7684\u0020\u0049\u0044\uff0c\u5982\u0020\u0071\u0077\u0065\u006e\u002d\u006d\u0061\u0078\u0020\u002f\u0020\u0071\u0077\u0065\u006e\u002d\u0070\u006c\u0075\u0073\u0020\u002f\u0020\u0071\u0077\u0065\u006e\u0033\u002d\u006d\u0061\u0078\u3002 # datasource err.datasource.not_found=\u6570\u636e\u6e90\u4e0d\u5b58\u5728 err.datasource.sql_empty=SQL \u4e0d\u80fd\u4e3a\u7a7a