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