mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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.
216 lines
8.1 KiB
Java
216 lines
8.1 KiB
Java
package vip.mate.tool.builtin;
|
|
|
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
|
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
|
|
import lombok.RequiredArgsConstructor;
|
|
import lombok.extern.slf4j.Slf4j;
|
|
import org.springframework.ai.tool.annotation.Tool;
|
|
import org.springframework.stereotype.Component;
|
|
import vip.mate.skill.runtime.SkillFileAccessPolicy;
|
|
import vip.mate.skill.runtime.SkillRuntimeService;
|
|
import vip.mate.skill.runtime.model.ResolvedSkill;
|
|
|
|
import java.nio.file.Files;
|
|
import java.nio.file.Path;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.stream.Collectors;
|
|
|
|
/**
|
|
* 技能文件读取工具
|
|
* 允许 Agent 在运行时读取 skill 内部文件
|
|
*/
|
|
@Slf4j
|
|
@Component
|
|
@RequiredArgsConstructor
|
|
public class SkillFileTool {
|
|
|
|
private final SkillRuntimeService runtimeService;
|
|
private final SkillFileAccessPolicy accessPolicy;
|
|
|
|
@Tool(description = """
|
|
Read a file from a skill's directory (SKILL.md, references/, or scripts/).
|
|
Use this when you need to access skill documentation or reference files.
|
|
|
|
Parameters:
|
|
- skillName: Name of the skill (e.g., "channel_message")
|
|
- filePath: Relative path within skill directory, must start with "references/" or "scripts/"
|
|
(e.g., "references/config.md", "scripts/helper.py")
|
|
To read SKILL.md itself, use "SKILL.md" as filePath
|
|
|
|
Returns: File content as string, or error message if file not found or access denied.
|
|
|
|
Security: Only files under references/ and scripts/ can be accessed. Path traversal is blocked.
|
|
""")
|
|
public String readSkillFile(
|
|
@JsonProperty(required = true)
|
|
@JsonPropertyDescription("Skill name")
|
|
String skillName,
|
|
|
|
@JsonProperty(required = true)
|
|
@JsonPropertyDescription("Relative file path (e.g., 'references/doc.md' or 'scripts/run.py')")
|
|
String filePath
|
|
) {
|
|
log.info("Reading skill file: skill={}, path={}", skillName, filePath);
|
|
|
|
// 查找 active skill
|
|
ResolvedSkill skill = runtimeService.findActiveSkill(skillName);
|
|
if (skill == null) {
|
|
return "Error: Skill '" + skillName + "' not found or not enabled";
|
|
}
|
|
|
|
// 特殊处理:读取 SKILL.md
|
|
if ("SKILL.md".equals(filePath)) {
|
|
if (skill.getContent() != null && !skill.getContent().isBlank()) {
|
|
return skill.getContent();
|
|
}
|
|
return "Error: SKILL.md content not available";
|
|
}
|
|
|
|
// 目录型 skill
|
|
if (skill.getSkillDir() == null) {
|
|
return "Error: Skill '" + skillName + "' is database-based, no file system access available";
|
|
}
|
|
|
|
// 验证路径安全性
|
|
Path resolvedPath = accessPolicy.validateAndResolve(skill.getSkillDir(), filePath);
|
|
if (resolvedPath == null) {
|
|
return "Error: Invalid or unsafe file path: " + filePath;
|
|
}
|
|
|
|
// 读取文件
|
|
try {
|
|
if (!Files.exists(resolvedPath)) {
|
|
return "Error: File not found: " + filePath;
|
|
}
|
|
|
|
if (!Files.isRegularFile(resolvedPath)) {
|
|
return "Error: Path is not a file: " + filePath;
|
|
}
|
|
|
|
String content = Files.readString(resolvedPath);
|
|
log.info("Successfully read skill file: {} bytes", content.length());
|
|
return content;
|
|
|
|
} catch (Exception e) {
|
|
log.error("Failed to read skill file {}/{}: {}", skillName, filePath, e.getMessage());
|
|
return "Error: Failed to read file: " + e.getMessage();
|
|
}
|
|
}
|
|
|
|
@Tool(description = """
|
|
List all files in a skill's references/ and scripts/ directories.
|
|
Use this to explore what files are available in a skill before reading them.
|
|
|
|
Parameters:
|
|
- skillName: Name of the skill (e.g., "channel_message")
|
|
|
|
Returns: A tree listing of files under references/ and scripts/.
|
|
""")
|
|
public String listSkillFiles(
|
|
@JsonProperty(required = true)
|
|
@JsonPropertyDescription("Skill name")
|
|
String skillName
|
|
) {
|
|
log.info("Listing skill files: skill={}", skillName);
|
|
|
|
ResolvedSkill skill = runtimeService.findActiveSkill(skillName);
|
|
if (skill == null) {
|
|
return "Error: Skill '" + skillName + "' not found or not enabled";
|
|
}
|
|
|
|
StringBuilder sb = new StringBuilder();
|
|
sb.append("Skill: ").append(skillName).append("\n\n");
|
|
|
|
if (skill.getSkillDir() != null) {
|
|
sb.append("Source: directory (").append(skill.getSkillDir()).append(")\n\n");
|
|
} else {
|
|
sb.append("Source: database (no file system directory)\n\n");
|
|
}
|
|
|
|
// References
|
|
sb.append("references/\n");
|
|
if (skill.getReferences() != null && !skill.getReferences().isEmpty()) {
|
|
formatTree(sb, skill.getReferences(), " ");
|
|
} else {
|
|
sb.append(" (empty)\n");
|
|
}
|
|
|
|
// Scripts
|
|
sb.append("\nscripts/\n");
|
|
if (skill.getScripts() != null && !skill.getScripts().isEmpty()) {
|
|
formatTree(sb, skill.getScripts(), " ");
|
|
} else {
|
|
sb.append(" (empty)\n");
|
|
}
|
|
|
|
return sb.toString();
|
|
}
|
|
|
|
@Tool(description = """
|
|
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.
|
|
""")
|
|
public String listAvailableSkills() {
|
|
log.info("Listing available skills");
|
|
|
|
List<ResolvedSkill> activeSkills = runtimeService.getActiveSkills();
|
|
|
|
if (activeSkills.isEmpty()) {
|
|
return "No skills are currently available.";
|
|
}
|
|
|
|
// 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("`");
|
|
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(desc.replace("|", "\\|").replace("\n", " "));
|
|
}
|
|
sb.append(" |\n");
|
|
}
|
|
sb.append("\nTotal: ").append(activeSkills.size()).append(" skill(s).");
|
|
return sb.toString();
|
|
}
|
|
|
|
@SuppressWarnings("unchecked")
|
|
private void formatTree(StringBuilder sb, Map<String, Object> tree, String indent) {
|
|
for (Map.Entry<String, Object> entry : tree.entrySet()) {
|
|
String name = entry.getKey();
|
|
Object value = entry.getValue();
|
|
if (value instanceof Map) {
|
|
sb.append(indent).append(name).append("/\n");
|
|
formatTree(sb, (Map<String, Object>) value, indent + " ");
|
|
} else {
|
|
sb.append(indent).append(name).append("\n");
|
|
}
|
|
}
|
|
}
|
|
}
|