From aded30bbb1c595c98229cf3cfa0f1923324689d8 Mon Sep 17 00:00:00 2001 From: matevip Date: Tue, 12 May 2026 17:20:19 +0800 Subject: [PATCH] feat(agent): auto-redirect skill-as-tool calls to readSkillFile --- .../graph/executor/ToolExecutionExecutor.java | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) 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 f0bd059d..c0db1130 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 @@ -443,6 +443,17 @@ public class ToolExecutionExecutor { } ToolCallback callback = toolCallbackMap.get(toolName); if (callback == null) { + SkillRedirect redirect = tryAutoRedirectSkillCall(toolName, arguments, safeOrigin); + if (redirect != null) { + // Auto-redirect succeeds with success=true on the SSE event so the + // model treats the SKILL.md content as the answer to a different, + // valid question (rather than as another failed call to recover from). + events.add(GraphEventPublisher.toolComplete( + toolCall.id(), toolName, redirect.response(), true)); + allResponses.add(new ToolResponseMessage.ToolResponse( + toolCall.id(), toolName, redirect.response())); + continue; + } String msg = skillAwareNotFoundMessage(toolName); log.warn("[ToolExecutor] {}", msg); events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false)); @@ -521,6 +532,18 @@ public class ToolExecutionExecutor { ToolCallback callback = toolCallbackMap.get(toolName); if (callback == null) { + // Same auto-redirect for pre-approved replays — a stale skill-as-tool + // approval shouldn't dead-end the conversation either. + ChatOrigin replayOriginForRedirect = ChatOrigin.EMPTY + .withConversationId(conversationId) + .withWorkspace(null, workspaceBasePath); + SkillRedirect redirect = tryAutoRedirectSkillCall(toolName, callArguments, replayOriginForRedirect); + if (redirect != null) { + events.add(GraphEventPublisher.toolComplete( + toolCall.id(), toolName, redirect.response(), true)); + return new ToolResponseMessage.ToolResponse( + toolCall.id(), toolName, redirect.response()); + } String msg = skillAwareNotFoundMessage(toolName); log.warn("[ToolExecutor] Pre-approved {}", msg); events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false)); @@ -1049,6 +1072,79 @@ public class ToolExecutionExecutor { return "Tool not found: " + toolName; } + /** + * Holder for an auto-redirect outcome: the SKILL.md content (wrapped + * with a one-line nudge) that we substitute as the tool response when + * the LLM mistakenly calls a skill name as if it were a tool. + * + *

{@code success=true} on the substituted response so the model + * doesn't read it as "tool failed, try harder" — semantically we + * answered a different question than the one it asked, and we want + * the model to follow the redirect rather than thrash. + */ + private record SkillRedirect(String response) {} + + /** + * When the LLM calls a skill name as if it were a tool, transparently + * fetch its SKILL.md and return that as the tool response. Smaller + * models (qwen-turbo et al.) often can't act on a "not a tool — go + * read X first" hint; they keep emitting the same wrong call until the + * iteration cap. With auto-redirect, the model receives runnable + * instructions on the very first attempt and can copy the runSkillScript + * shape from SKILL.md verbatim. + * + *

Returns {@code null} if {@code toolName} isn't a registered skill, + * if {@code readSkillFile} isn't available in this agent's tool set, or + * if the redirect call itself errored — the caller then falls through + * to the usual {@code skillAwareNotFoundMessage} hint. + */ + private SkillRedirect tryAutoRedirectSkillCall(String toolName, String originalArgs, ChatOrigin origin) { + if (skillRuntimeService == null || toolName == null || toolName.isBlank()) return null; + try { + boolean isSkill = skillRuntimeService.getActiveSkills().stream() + .anyMatch(s -> s.getName() != null && s.getName().equalsIgnoreCase(toolName)); + if (!isSkill) return null; + } catch (Exception e) { + log.debug("[ToolExecutor] auto-redirect skill lookup failed: {}", e.getMessage()); + return null; + } + + ToolCallback readSkillFile = toolCallbackMap.get("readSkillFile"); + if (readSkillFile == null) { + log.debug("[ToolExecutor] readSkillFile not bound to this agent — cannot auto-redirect '{}'", toolName); + return null; + } + + String redirectArgs = "{\"skillName\":\"" + + jsonStringEscape(toolName) + + "\",\"filePath\":\"SKILL.md\"}"; + String skillMd; + try { + ToolContext ctx = (origin != null ? origin : ChatOrigin.EMPTY).toToolContext(); + skillMd = readSkillFile.call(redirectArgs, ctx); + } catch (Exception e) { + log.warn("[ToolExecutor] Auto-redirect readSkillFile failed for '{}': {}", toolName, e.getMessage()); + return null; + } + + log.info("[ToolExecutor] Auto-redirected skill-as-tool call '{}' → readSkillFile (returned {} chars)", + toolName, skillMd != null ? skillMd.length() : 0); + + String safeArgs = originalArgs == null || originalArgs.isBlank() ? "{}" : originalArgs; + String response = String.format( + "[auto-redirect] You called '%s' as a tool, but it's a Skill (documentation package). " + + "Its SKILL.md is loaded below — read the script invocation example, then call " + + "`runSkillScript(skillName=\"%s\", scriptPath=\"scripts/\", args=[...])` " + + "to actually run it. Your original payload was: %s%n%n---%n%s", + toolName, toolName, safeArgs, skillMd == null ? "" : skillMd); + return new SkillRedirect(response); + } + + /** Minimal JSON string escaping for the synthetic readSkillFile arg payload. */ + private static String jsonStringEscape(String s) { + return s.replace("\\", "\\\\").replace("\"", "\\\""); + } + // ==================== 内部数据类 ==================== private record PreparedToolCall(