mateclaw/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillLoadTool.java

98 lines
4.4 KiB
Java

package vip.mate.tool.builtin;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import vip.mate.agent.context.AgentWorkspaceResolver;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.llm.routing.AgentBindingResolver;
import vip.mate.skill.runtime.SkillRuntimeService;
import vip.mate.skill.runtime.model.ResolvedSkill;
import java.util.Set;
/**
* Explicit skill-load entry point.
* <p>
* Pulls a skill package's SKILL.md (or a named sub-file) into the conversation
* as a tool observation. Naming it {@code load_skill} — rather than reusing the
* lower-level {@code readSkillFile} — gives the model a clear "load this skill"
* verb that matches the catalog guidance, and the call is detected by the
* action node to pin the skill at the top of the runtime catalog so the model
* does not reload it on later iterations.
* <p>
* The full content is returned as a normal tool result; it flows into message
* history via the standard tool-response path and never mutates the system
* prompt, so the prompt-cache prefix stays stable.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SkillLoadTool {
private final SkillRuntimeService runtimeService;
private final SkillFileTool skillFileTool;
private final AgentWorkspaceResolver workspaceResolver;
@Lazy
@Autowired
private AgentBindingResolver agentBindingResolver;
@Tool(name = "load_skill", description = """
Load a skill package's SKILL.md into the conversation.
Call this when a skill in the catalog matches the task.
Parameters:
- skillName: Skill name exactly as shown in the catalog.
- filePath: Optional sub-file inside the skill (e.g. "references/api.md").
Omit to load SKILL.md.
The full content is returned as a tool observation; later turns see it in
message history, so do NOT load the same skill again once it is loaded.
Skills are documentation packages — calling a skill name directly as a
tool will fail; load it first, then follow its instructions.
""")
public String loadSkill(
@ToolParam(description = "Skill name as shown in the catalog")
String skillName,
@ToolParam(description = "Optional sub-file path inside the skill (e.g. references/api.md)",
required = false)
String filePath,
@Nullable ToolContext ctx
) {
if (skillName == null || skillName.isBlank()) {
return "Error: skillName is required. Call listAvailableSkills() to see loadable skills.";
}
ChatOrigin origin = ChatOrigin.from(ctx);
// Resolve only within the conversation's workspace (+ builtin/global), so
// an agent can never load another workspace's same-named skill.
ResolvedSkill skill = runtimeService.findActiveSkill(skillName, workspaceResolver.resolve(origin));
if (skill == null) {
log.info("load_skill: skill '{}' not found or not enabled", skillName);
return "Error: Skill '" + skillName + "' not found or not enabled. "
+ "Call listAvailableSkills(keyword=\"" + skillName + "\") to find the correct name.";
}
Long agentId = origin.agentId();
if (agentId != null) {
Set<Long> boundSkillIds = agentBindingResolver.getBoundSkillIds(agentId);
if (boundSkillIds != null && (skill.getId() == null || !boundSkillIds.contains(skill.getId()))) {
log.info("load_skill: agent {} is not allowed to load skill '{}'", agentId, skillName);
return "Error: Skill '" + skillName + "' is not available for this agent.";
}
}
String path = (filePath == null || filePath.isBlank()) ? "SKILL.md" : filePath;
log.info("load_skill: loading skill='{}', path='{}'", skillName, path);
// Delegate to the shared reader: it resolves the skill, paginates large
// sub-files, and records usage. SKILL.md is returned in full by default.
return skillFileTool.readSkillFile(skillName, path, null, null, ctx);
}
}