diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index cc22af1d..ea3f3b4b 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -310,6 +310,10 @@ public class AgentGraphBuilder { primaryModelConfig != null ? primaryModelConfig.getProvider() : null, providerPool); ToolExecutionExecutor executor = new ToolExecutionExecutor(toolSet, toolGuardService, approvalService, streamTracker, toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry); + // Issue #46: enable skill-aware "Tool not found" hint so when the + // LLM mis-calls a skill name as a tool, the response tells it + // the right invocation pattern instead of a dead-end error. + executor.setSkillRuntimeService(skillRuntimeService); PlanGenerationNode planGenerationNode = new PlanGenerationNode(chatModel, planningService, streamingHelper, conversationWindowManager, toolSet); StepExecutionNode stepExecutionNode = new StepExecutionNode(chatModel, toolSet, executor, planningService, streamTracker, reasoningEffort, streamingHelper, conversationWindowManager); PlanSummaryNode planSummaryNode = new PlanSummaryNode(chatModel, planningService, streamingHelper); @@ -437,6 +441,10 @@ public class AgentGraphBuilder { primaryModelConfig != null ? primaryModelConfig.getProvider() : null, providerPool); ToolExecutionExecutor executor = new ToolExecutionExecutor(toolSet, toolGuardService, approvalService, streamTracker, toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry); + // Issue #46: enable skill-aware "Tool not found" hint so when the + // LLM mis-calls a skill name as a tool, the response tells it + // the right invocation pattern instead of a dead-end error. + executor.setSkillRuntimeService(skillRuntimeService); // PR-1.2 (RFC-049 L1-B): propagate the bound model's capability so ReasoningNode // can gate the ThinkingLevelHolder override explicitly, rather than inferring // capability from reasoningEffort == null. diff --git a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java index 3d159ab4..cac7ce46 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java @@ -228,17 +228,36 @@ public abstract class BaseAgent { } } - // Tail guard: a few providers reject prompts whose history ends with an - // assistant message. Anthropic Claude returns 400 "does not support - // assistant message prefill"; DeepSeek thinking mode requires the - // last assistant turn's reasoning_content (which we may not have). - // The trailing-user-dedup above can already produce an assistant tail - // when the immediately-prior turn was an error / placeholder that got - // dropped by stage 1 / 1.5 of sanitizeForLlm. Strip remaining assistant - // tails defensively — the current user message is fed in separately as - // the final prompt by the caller, so dropping these assistant entries - // never loses information the LLM needs. - while (!messages.isEmpty() && messages.get(messages.size() - 1) instanceof AssistantMessage) { + // Tail guard — orphan-user strip (issue #47). + // + // Invariant: every caller of buildConversationHistory appends the + // current user message AFTER this history (BaseAgent.buildClient + // via .user(), StateGraphReActAgent / StateGraphPlanExecuteAgent + // via messages.add(buildCurrentUserMessage)). So the final prompt is + // [system, ...history, current_user] + // and is *always* terminated by a user message. That means a trailing + // assistant in history is FINE for every provider we support — it + // produces the correct [..., user, assistant, current_user] alternation + // (OpenAI, Anthropic, DeepSeek regular/thinking, Gemini, Qwen, …). + // + // The actual hazard is the opposite: a trailing USER in history. + // That happens when the immediately-prior turn's assistant message + // was dropped by Stage 1 (approval placeholder) or Stage 1.5 (errored + // turn / "[错误] " row), or never persisted at all (turn interrupted + // before doOnComplete saved the assistant). In that case the history + // ends with an orphan unanswered user, and appending the current user + // produces TWO consecutive user messages. Most providers concatenate + // those and answer both — leaking the orphan question's answer + // alongside the current answer. (This was the symptom reported in + // issue #47, originally caused by a tail guard that stripped trailing + // ASSISTANT messages instead of trailing USER ones — a direction- + // reversed version of this loop.) + // + // Stripping orphan users is safe: the user re-asked or asked a new + // question; the orphan turn produced no answer the model can build + // on. We lose a small amount of conversational context in exchange + // for clean alternation across every provider. + while (!messages.isEmpty() && messages.get(messages.size() - 1) instanceof UserMessage) { messages.remove(messages.size() - 1); } return messages; 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 0ed7cd4e..a926c0ae 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 @@ -123,6 +123,18 @@ public class ToolExecutionExecutor { private final ToolResultStorage resultStorage; /** RFC-008 Phase 4 metadata-driven concurrency classifier; nullable for legacy constructors. */ private final vip.mate.tool.ToolConcurrencyRegistry concurrencyRegistry; + /** + * Issue #46: when {@code toolCallbackMap} misses a name that the LLM + * called, we check whether it matches an active skill so we can + * return a precise hint instead of bare "Tool not found". Nullable — + * legacy constructors and tests may leave this unset, in which case + * the safety net falls through to the original error string. + */ + private vip.mate.skill.runtime.SkillRuntimeService skillRuntimeService; + + public void setSkillRuntimeService(vip.mate.skill.runtime.SkillRuntimeService s) { + this.skillRuntimeService = s; + } public ToolExecutionExecutor(AgentToolSet toolSet, ToolGuardService toolGuardService, ApprovalWorkflowService approvalService, ChatStreamTracker streamTracker) { @@ -326,10 +338,11 @@ public class ToolExecutionExecutor { } ToolCallback callback = toolCallbackMap.get(toolName); if (callback == null) { - log.warn("[ToolExecutor] Tool not found: {}", toolName); - events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, "Tool not found: " + toolName, false)); + String msg = skillAwareNotFoundMessage(toolName); + log.warn("[ToolExecutor] {}", msg); + events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false)); allResponses.add(new ToolResponseMessage.ToolResponse( - toolCall.id(), toolName, "Tool not found: " + toolName)); + toolCall.id(), toolName, msg)); continue; } @@ -402,9 +415,10 @@ public class ToolExecutionExecutor { ToolCallback callback = toolCallbackMap.get(toolName); if (callback == null) { - log.warn("[ToolExecutor] Pre-approved tool not found: {}", toolName); - events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, "Tool not found: " + toolName, false)); - return new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, "Tool not found: " + toolName); + String msg = skillAwareNotFoundMessage(toolName); + log.warn("[ToolExecutor] Pre-approved {}", msg); + events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false)); + return new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, msg); } try { @@ -817,6 +831,38 @@ public class ToolExecutionExecutor { return approvalResponse; } + /** + * Issue #46 — when a tool callback miss happens, check whether the + * unrecognized name actually matches an active skill. If it does, return + * a precise hint telling the LLM the right invocation pattern instead + * of bare "Tool not found: X". Without this, an LLM that called e.g. + * {@code RedisOps} as a tool gets no recovery signal and either gives + * up or falls back to shell guessing. + * + *
Case-insensitive match because LLMs sometimes change the case of
+ * skill names mid-conversation.
+ */
+ private String skillAwareNotFoundMessage(String toolName) {
+ if (skillRuntimeService != null && toolName != null && !toolName.isBlank()) {
+ try {
+ boolean isSkill = skillRuntimeService.getActiveSkills().stream()
+ .anyMatch(s -> s.getName() != null && s.getName().equalsIgnoreCase(toolName));
+ if (isSkill) {
+ return String.format(
+ "'%s' is a Skill, not a Tool — calling it as a tool fails. "
+ + "To use it, FIRST call readSkillFile(skillName=\"%s\", filePath=\"SKILL.md\") "
+ + "to read its instructions, THEN follow what SKILL.md tells you "
+ + "(typically runSkillScript with a scripts/