fix(skill): stop the LLM from calling skill names as tools (issue #46)

When a user-installed skill (e.g. RedisOps) was bound to an agent, the
model frequently called the skill name directly as a tool, hit
"Tool not found: RedisOps", and either gave up or fell back to shell
guessing. Two compounding causes:

1. The system prompt block injected by SkillRuntimeService listed each
   skill as `- **RedisOps** — desc`, which is the same format used for
   tool catalogs and primed the model to call the names directly. The
   "how to use" instructions referenced `read_skill_file` /
   `run_skill_script` — names that don't exist in the tool registry,
   so even a compliant LLM couldn't follow them.

2. ToolExecutionExecutor's `callback == null` branches returned a bare
   "Tool not found: <name>" string. The model had no recovery signal
   and no hint that the name it called was actually a skill.

Fix is two-layered:

- Prompt rewrite (SkillRuntimeService.buildSkillPromptEnhancement): lead
  with an explicit warning that skills are NOT directly callable, use the
  correct camelCase tool names (readSkillFile / runSkillScript), include
  a concrete worked example anchored to the first enabled skill, and
  render the listing as a markdown table so it stops looking like a
  callable tool list. listAvailableSkills tool description and output
  follow the same pattern.

- Runtime safety net (ToolExecutionExecutor): when toolCallbackMap.get
  misses, check if the requested name (case-insensitive) matches an
  active skill. If so, return a precise hint telling the LLM the right
  invocation pattern instead of the bare error. Wired through both the
  main execute path and the pre-approved replay path. SkillRuntimeService
  is attached via a setter from AgentGraphBuilder so the executor's many
  legacy constructors stay untouched, and it's nullable so isolated
  tests still work.

Adds 5 unit tests covering: skill match -> hint, case-insensitive match,
no-match -> bare error, no SkillRuntimeService wired -> bare error,
pre-approved replay path -> hint.

Reported and reproduced by @pipima9950-glitch in issue #46.
This commit is contained in:
matevip 2026-04-30 16:36:30 +08:00
parent d20b440ce5
commit 101aa3209e
5 changed files with 158 additions and 38 deletions

View File

@ -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.

View File

@ -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;

View File

@ -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.
*
* <p>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/<file> path).",
toolName, toolName);
}
} catch (Exception e) {
// Don't let a hint-side failure mask the original error.
log.debug("[ToolExecutor] skill-aware hint check failed: {}", e.getMessage());
}
}
return "Tool not found: " + toolName;
}
// ==================== 内部数据类 ====================
private record PreparedToolCall(

View File

@ -162,30 +162,62 @@ public class SkillRuntimeService {
return "";
}
// Issue #46 prompt rewrite. Three deliberate choices vs. the old
// version, all driven by observed LLM mis-calls (e.g. calling
// "RedisOps" directly as a tool):
// 1. Lead with an explicit warning that skills are NOT callable.
// Primacy bias putting it first is what makes it stick.
// 2. Use the actual camelCase tool names (readSkillFile /
// runSkillScript). The old text said `read_skill_file` /
// `run_skill_script`, which don't exist in the tool registry,
// so even an LLM trying to comply couldn't find them.
// 3. Concrete worked example: "to use RedisOps, START with
// readSkillFile(...)". Abstract instructions reliably lose
// to a worked example in tool-use prompting.
// 4. Render the listing as a markdown table with the call pattern
// explicit, instead of `- **Name** desc` which looks
// identical to a tool list and primes the model to call
// the name directly.
StringBuilder sb = new StringBuilder();
sb.append("\n\n## Available Skills\n");
sb.append("以下技能已启用,你可以通过 skill runtime tools 使用它们:\n\n");
sb.append("\n\n## Available Skills\n\n");
sb.append("⚠️ **Skills are documentation packages, NOT directly callable tools.**\n");
sb.append("Calling a skill name as a tool (e.g. tool_use{name=\"RedisOps\"}) will fail with \"Tool not found\". ");
sb.append("To use a skill, follow this two-step pattern:\n\n");
sb.append("1. Read its SKILL.md to learn how it works:\n");
sb.append(" `readSkillFile(skillName=\"<name>\", filePath=\"SKILL.md\")`\n");
sb.append("2. Follow what SKILL.md tells you — usually that means calling:\n");
sb.append(" `runSkillScript(skillName=\"<name>\", scriptPath=\"scripts/<file>\")` or\n");
sb.append(" `readSkillFile(skillName=\"<name>\", filePath=\"references/<file>\")`\n\n");
// Concrete example anchored to the first enabled skill so the LLM
// sees a real name it just read in the listing below.
String exampleName = activeSkills.get(0).getName();
sb.append("Concrete example — to use the `").append(exampleName).append("` skill, START with:\n");
sb.append(" `readSkillFile(skillName=\"").append(exampleName).append("\", filePath=\"SKILL.md\")`\n\n");
sb.append("### Enabled skills\n");
sb.append("Pass these names as the `skillName=` argument to `readSkillFile` / `runSkillScript`. ");
sb.append("Do **not** call them as tools.\n\n");
sb.append("| Skill name | Description |\n");
sb.append("|------------|-------------|\n");
for (ResolvedSkill skill : activeSkills) {
sb.append("- **").append(skill.getName()).append("**");
sb.append("| `").append(skill.getName()).append("`");
if (skill.getIcon() != null && !skill.getIcon().isBlank()) {
sb.append(" ").append(skill.getIcon());
}
sb.append(" | ");
if (skill.getDescription() != null && !skill.getDescription().isBlank()) {
String desc = skill.getDescription();
if (desc.length() > 200) {
desc = desc.substring(0, 200) + "...";
}
sb.append("").append(desc);
// Escape pipe and newline so a multi-line description doesn't
// break the table layout.
sb.append(desc.replace("|", "\\|").replace("\n", " "));
}
sb.append("\n");
sb.append(" |\n");
}
sb.append("\n### 如何使用技能\n");
sb.append("1. 使用 `read_skill_file` 工具读取技能内部文件SKILL.md / references / scripts\n");
sb.append("2. 使用 `run_skill_script` 工具执行技能脚本\n");
sb.append("3. 所有路径相对技能根目录解析,必须以 references/ 或 scripts/ 开头\n");
return sb.toString();
}
}

View File

@ -148,9 +148,16 @@ public class SkillFileTool {
}
@Tool(description = """
列出所有当前可用的技能Skills包括名称图标和描述
使用此工具查看系统中有哪些已启用且可运行的技能
注意这里列出的是技能Skills不是 Agent如需列出可用 Agent请使用 listAvailableAgents
List all currently available Skills (documentation packages).
IMPORTANT: Skills are NOT directly callable as tools. Each name
returned here is a `skillName` argument, not a tool name. To use
a skill, call `readSkillFile(skillName="<name>", filePath="SKILL.md")`
first to read its instructions, then follow what SKILL.md tells you.
Calling a skill name as a tool will fail with "Tool not found".
Note: this returns Skills (vendor-installable docs), not Agents.
For Agents, use `listAvailableAgents`.
Returns: A formatted list of active skills with name, icon, and description.
""")
@ -160,27 +167,35 @@ public class SkillFileTool {
List<ResolvedSkill> activeSkills = runtimeService.getActiveSkills();
if (activeSkills.isEmpty()) {
return "当前没有可用的技能Skills";
return "No skills are currently available.";
}
StringBuilder sb = new StringBuilder("可用技能Skills列表\n\n");
// Issue #46: render as a table with the call pattern stated up front,
// instead of a `- **Name** desc` list that primes the LLM to call
// the names directly as tools.
StringBuilder sb = new StringBuilder();
sb.append("⚠️ These are Skills (documentation packages), NOT directly callable tools.\n");
sb.append("To use any of them, call:\n");
sb.append(" readSkillFile(skillName=\"<name from below>\", filePath=\"SKILL.md\")\n");
sb.append("then follow what SKILL.md tells you (typically `runSkillScript`).\n\n");
sb.append("| Skill name | Description |\n");
sb.append("|------------|-------------|\n");
for (ResolvedSkill skill : activeSkills) {
sb.append("- **").append(skill.getName()).append("**");
sb.append("| `").append(skill.getName()).append("`");
if (skill.getIcon() != null && !skill.getIcon().isBlank()) {
sb.append(" ").append(skill.getIcon());
}
sb.append(" | ");
if (skill.getDescription() != null && !skill.getDescription().isBlank()) {
String desc = skill.getDescription();
if (desc.length() > 200) {
desc = desc.substring(0, 200) + "...";
}
sb.append("").append(desc);
sb.append(desc.replace("|", "\\|").replace("\n", " "));
}
sb.append("\n");
sb.append(" |\n");
}
sb.append("\n共 ").append(activeSkills.size()).append(" 个可用技能。");
sb.append("\n\n使用 `readSkillFile` 读取技能详情,使用 `runSkillScript` 执行技能脚本。");
sb.append("\nTotal: ").append(activeSkills.size()).append(" skill(s).");
return sb.toString();
}