package vip.mate.skill.lifecycle; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.messages.SystemMessage; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.model.ToolContext; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.stereotype.Service; import vip.mate.agent.AgentGraphBuilder; import vip.mate.agent.context.ChatOrigin; import vip.mate.agent.prompt.PromptLoader; import vip.mate.llm.model.ModelConfigEntity; import vip.mate.llm.service.ModelConfigService; import vip.mate.skill.model.SkillEntity; import vip.mate.skill.service.SkillService; import vip.mate.tool.builtin.SkillManageTool; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * Consolidation pass for the skill curator: merges near-duplicate * agent-created skills into a broader umbrella skill, then archives the * narrow ones it absorbed. Off by default — opt in via * {@code mateclaw.skill.curator.consolidate}. * *

The umbrella write is routed through {@link SkillManageTool} so it * inherits the full security scan / validation pipeline; the absorbed skills * are archived (not deleted) through {@link SkillLifecycleService} so they * stay recoverable. The reviewer can only ever cause skills already in the * curator's candidate set to be archived — names it invents are ignored. * * @author MateClaw Team */ @Slf4j @Service @RequiredArgsConstructor public class SkillConsolidationService { private final SkillService skillService; private final SkillManageTool skillManageTool; private final SkillLifecycleService lifecycleService; private final ModelConfigService modelConfigService; private final AgentGraphBuilder agentGraphBuilder; private final SkillLifecycleProperties properties; private final ObjectMapper objectMapper; private static final int CATALOG_BODY_TRUNCATE_CHARS = 1500; /** * Run a consolidation pass over the given candidate skills, recording * outcomes into the sweep report. No-op when consolidation is disabled or * there are too few candidates to bother. */ public void consolidate(List candidates, LocalDateTime now, boolean dryRun, SkillCuratorReport.Builder report) { if (!properties.isConsolidate()) { return; } List withContent = candidates.stream() .filter(s -> s.getSkillContent() != null && !s.getSkillContent().isBlank()) .toList(); if (withContent.size() < properties.getConsolidateMinSkills()) { return; } // Index by name so the reviewer can only ever archive in-scope skills. Map byName = new LinkedHashMap<>(); for (SkillEntity s : withContent) { byName.put(s.getName(), s); } JsonNode groups = askReviewer(withContent); if (groups == null || !groups.isArray() || groups.isEmpty()) { return; } int applied = 0; for (JsonNode group : groups) { if (applied >= properties.getConsolidateMaxGroupsPerRun()) { break; } if (applyGroup(group, byName, now, dryRun, report)) { applied++; } } } private boolean applyGroup(JsonNode group, Map byName, LocalDateTime now, boolean dryRun, SkillCuratorReport.Builder report) { String umbrellaName = group.path("umbrella_name").asText("").strip().toLowerCase(); String umbrellaContent = group.path("umbrella_content").asText(null); String reason = group.path("reason").asText(""); if (umbrellaName.isBlank() || umbrellaContent == null || umbrellaContent.isBlank()) { return false; } // Restrict absorbed skills to the in-scope candidate set, excluding the // umbrella itself — the reviewer cannot archive anything outside it. List absorb = new ArrayList<>(); for (JsonNode n : group.path("absorb")) { String nm = n.asText("").strip().toLowerCase(); if (!nm.isBlank() && !nm.equals(umbrellaName) && byName.containsKey(nm) && !absorb.contains(nm)) { absorb.add(nm); } } SkillEntity existingUmbrella = skillService.findByName(umbrellaName); boolean willCreate = existingUmbrella == null; // A real merge must touch at least two distinct skills: a brand-new // umbrella needs >=2 absorbed; reusing an existing skill as the // umbrella needs >=1 absorbed (the umbrella itself is the second). boolean realMerge = willCreate ? absorb.size() >= 2 : !absorb.isEmpty(); if (!realMerge) { log.debug("[SkillConsolidate] Skipping group '{}' — not a real merge", umbrellaName); return false; } if (dryRun) { report.consolidation(new SkillCuratorReport.ConsolidationRow( umbrellaName, willCreate, absorb, false, reason)); return true; } // Stamp the umbrella with a source conversation from one absorbed skill // so it stays curator-eligible under the AGENT_CREATED scope. String lineageConv = absorb.stream() .map(byName::get) .map(SkillEntity::getSourceConversationId) .filter(c -> c != null && !c.isBlank()) .findFirst().orElse(null); ToolContext ctx = toolContext(lineageConv); String act = willCreate ? "create" : "edit"; String result = skillManageTool.skill_manage(act, umbrellaName, umbrellaContent, null, null, null, ctx); boolean umbrellaOk = result != null && !result.startsWith("Error") && !result.startsWith("Security scan BLOCKED"); if (!umbrellaOk) { log.debug("[SkillConsolidate] Umbrella {} '{}' rejected: {}", act, umbrellaName, result); return false; } // Archive the absorbed narrow skills (recoverable, never deleted). for (String nm : absorb) { SkillEntity victim = byName.get(nm); if (victim == null) { continue; } try { lifecycleService.applyManual(victim, LifecycleTransition.TO_ARCHIVED, now, "consolidated into " + umbrellaName); } catch (Exception e) { log.warn("[SkillConsolidate] Failed to archive absorbed skill '{}': {}", nm, e.getMessage()); } } log.info("[SkillConsolidate] {} umbrella '{}' absorbing {} — {}", act, umbrellaName, absorb, reason); report.consolidation(new SkillCuratorReport.ConsolidationRow( umbrellaName, willCreate, absorb, true, reason)); return true; } private JsonNode askReviewer(List skills) { try { String systemPrompt = PromptLoader.loadPrompt("skill/consolidate-system"); String userPrompt = PromptLoader.loadPrompt("skill/consolidate-user") .replace("{skills}", buildCatalog(skills, properties.getConsolidateCatalogCharBudget())); ChatModel chatModel = buildChatModel(); Prompt prompt = new Prompt(List.of( new SystemMessage(systemPrompt), new UserMessage(userPrompt))); ChatResponse response = chatModel.call(prompt); if (response == null || response.getResult() == null || response.getResult().getOutput() == null) { return null; } return parseJsonResponse(response.getResult().getOutput().getText()); } catch (Exception e) { log.warn("[SkillConsolidate] Reviewer call failed: {}", e.getMessage()); return null; } } private String buildCatalog(List skills, int charBudget) { StringBuilder sb = new StringBuilder(); for (SkillEntity skill : skills) { String entry = "### " + skill.getName() + "\n" + (skill.getDescription() == null ? "" : skill.getDescription().strip() + "\n") + truncate(skill.getSkillContent(), CATALOG_BODY_TRUNCATE_CHARS) + "\n\n"; if (sb.length() + entry.length() > charBudget) { sb.append("... (catalog truncated)\n"); break; } sb.append(entry); } return sb.toString().strip(); } private ToolContext toolContext(String sourceConversationId) { ChatOrigin origin = new ChatOrigin(null, sourceConversationId, "", null, null, null, null, false, null, null, null, null, null); return new ToolContext(Map.of(ChatOrigin.CTX_KEY, origin)); } private ChatModel buildChatModel() { ModelConfigEntity model = null; if (properties.getConsolidateModelId() != null && !properties.getConsolidateModelId().isBlank()) { try { model = modelConfigService.getModel(Long.parseLong(properties.getConsolidateModelId())); } catch (Exception e) { log.warn("[SkillConsolidate] Invalid consolidateModelId '{}', using default", properties.getConsolidateModelId()); } } if (model == null) { model = modelConfigService.getDefaultModel(); } return agentGraphBuilder.buildRuntimeChatModel(model); } private JsonNode parseJsonResponse(String response) { if (response == null || response.isBlank()) { return null; } String cleaned = response.strip(); if (cleaned.startsWith("```json")) { cleaned = cleaned.substring(7); } else if (cleaned.startsWith("```")) { cleaned = cleaned.substring(3); } if (cleaned.endsWith("```")) { cleaned = cleaned.substring(0, cleaned.length() - 3); } try { return objectMapper.readTree(cleaned.strip()); } catch (Exception e) { log.debug("[SkillConsolidate] JSON parse failed: {}", e.getMessage()); return null; } } private static String truncate(String s, int maxLen) { if (s == null) { return ""; } return s.length() <= maxLen ? s : s.substring(0, maxLen) + "... [truncated]"; } }