feat(skill): self-evolving skills — out-of-band reflection, curator consolidation, agent-authored skill files

This commit is contained in:
matevip 2026-06-23 13:51:04 +08:00
parent a366c66d23
commit f08abad076
24 changed files with 1410 additions and 9 deletions

View File

@ -895,6 +895,15 @@ public class SkillController {
return R.ok(skillCuratorJob.status());
}
@Operation(summary = "开启/关闭 curator 合并去重 pass")
@PostMapping("/curator/consolidate")
@RequireWorkspaceRole("admin")
public R<Map<String, Object>> curatorConsolidate(
@RequestParam(defaultValue = "true") boolean enabled) {
skillCuratorJob.setConsolidate(enabled);
return R.ok(skillCuratorJob.status());
}
@Operation(summary = "curator 控制面状态")
@GetMapping("/curator/status")
@RequireWorkspaceRole("member")

View File

@ -0,0 +1,255 @@
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}.
*
* <p>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<SkillEntity> candidates, LocalDateTime now,
boolean dryRun, SkillCuratorReport.Builder report) {
if (!properties.isConsolidate()) {
return;
}
List<SkillEntity> 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<String, SkillEntity> 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<String, SkillEntity> 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<String> 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<SkillEntity> 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<SkillEntity> 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);
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]";
}
}

View File

@ -39,6 +39,8 @@ public class SkillCuratorJob {
static final String FIRST_RUN_KEY = "skill.curator.firstRunCompleted";
/** Runtime kill switch — pauses the scheduled sweep without a redeploy. */
static final String PAUSED_KEY = "skill.curator.paused";
/** Runtime override for the consolidation pass (falls back to config). */
static final String CONSOLIDATE_KEY = "skill.curator.consolidate";
/** ISO-8601 timestamp of the last auto dry-run, for throttling. */
static final String LAST_DRY_RUN_KEY = "skill.curator.lastDryRunAt";
/** ISO-8601 timestamp of the first sweep observation after install. */
@ -57,6 +59,7 @@ public class SkillCuratorJob {
private final AgentBindingService agentBindingService;
private final SkillWorkspaceManager workspaceManager;
private final CuratorRunNotifier notifier;
private final SkillConsolidationService consolidationService;
@Scheduled(cron = "${mateclaw.skill.curator.cron:0 0 2 * * *}")
@SchedulerLock(name = "skill-curator", lockAtMostFor = "PT10M", lockAtLeastFor = "PT30S")
@ -127,6 +130,16 @@ public class SkillCuratorJob {
systemSettingService.saveBool(PAUSED_KEY, paused, "Skill curator paused");
}
/** Set the runtime consolidation flag (overrides the config default). */
public void setConsolidate(boolean on) {
systemSettingService.saveBool(CONSOLIDATE_KEY, on, "Skill curator consolidation enabled");
}
/** Effective consolidation switch: runtime override, falling back to config. */
private boolean effectiveConsolidate() {
return systemSettingService.getBool(CONSOLIDATE_KEY, properties.isConsolidate());
}
/** Aggregated control-panel state for the admin UI. */
public Map<String, Object> status() {
Map<String, Object> config = new LinkedHashMap<>();
@ -139,6 +152,7 @@ public class SkillCuratorJob {
Map<String, Object> control = new LinkedHashMap<>();
control.put("activated", systemSettingService.getBool(FIRST_RUN_KEY, false));
control.put("paused", systemSettingService.getBool(PAUSED_KEY, false));
control.put("consolidate", effectiveConsolidate());
control.put("lastObservedAt", systemSettingService.getString(LAST_OBSERVED_KEY, null));
control.put("lastDryRunAt", systemSettingService.getString(LAST_DRY_RUN_KEY, null));
control.put("lastRunAt", systemSettingService.getString(LAST_RUN_KEY, null));
@ -212,6 +226,15 @@ public class SkillCuratorJob {
.appliedCounts(appliedStale, appliedArchived, appliedReactivate)
.blockedByBindings(agentBindingService.blockedByBindingCandidates(now));
// Consolidation pass (opt-in). Reload candidates so it sees the state
// left by the aging pass above and never merges a just-archived skill.
if (effectiveConsolidate()) {
List<SkillEntity> mergeCandidates = loadCandidates().stream()
.filter(s -> !"archived".equals(s.getLifecycleState()))
.toList();
consolidationService.consolidate(mergeCandidates, now, dryRun, report);
}
return reportStore.write(report.build());
}

View File

@ -38,6 +38,7 @@ public class SkillCuratorReport {
private final List<TransitionRow> transitions;
private final List<BlockedByBindingRow> blockedByBindings;
private final List<String> reconciliations;
private final List<ConsolidationRow> consolidations;
/** Set by the report store after the run directory is written. */
@JsonIgnore
@ -54,6 +55,7 @@ public class SkillCuratorReport {
this.transitions = List.copyOf(b.transitions);
this.blockedByBindings = List.copyOf(b.blockedByBindings);
this.reconciliations = List.copyOf(b.reconciliations);
this.consolidations = List.copyOf(b.consolidations);
}
public void setPath(Path path) {
@ -81,6 +83,15 @@ public class SkillCuratorReport {
public record TransitionRow(Long skillId, String name, String from, String to, long daysIdle) {}
/**
* One consolidation group: narrow skills in {@code absorbed} were folded
* into the {@code umbrella} skill. {@code umbrellaCreated} distinguishes a
* brand-new umbrella from an edit of an existing skill; {@code applied} is
* false for a dry-run preview.
*/
public record ConsolidationRow(String umbrella, boolean umbrellaCreated,
List<String> absorbed, boolean applied, String reason) {}
public static Builder builder() {
return new Builder();
}
@ -98,6 +109,7 @@ public class SkillCuratorReport {
private final List<TransitionRow> transitions = new ArrayList<>();
private List<BlockedByBindingRow> blockedByBindings = new ArrayList<>();
private final List<String> reconciliations = new ArrayList<>();
private final List<ConsolidationRow> consolidations = new ArrayList<>();
public Builder runAt(LocalDateTime runAt) {
this.runAt = runAt;
@ -166,6 +178,13 @@ public class SkillCuratorReport {
return this;
}
public Builder consolidation(ConsolidationRow row) {
if (row != null) {
this.consolidations.add(row);
}
return this;
}
public SkillCuratorReport build() {
return new SkillCuratorReport(this);
}

View File

@ -192,6 +192,20 @@ public class SkillCuratorReportStore {
sb.append('\n');
}
if (!r.getConsolidations().isEmpty()) {
sb.append("## Consolidations\n\n");
sb.append("Narrow agent-created skills merged into a broader umbrella skill.\n\n");
sb.append("| umbrella | new? | absorbed | applied | reason |\n|---|---|---|---|---|\n");
for (SkillCuratorReport.ConsolidationRow c : r.getConsolidations()) {
sb.append("| ").append(c.umbrella())
.append(" | ").append(c.umbrellaCreated() ? "create" : "edit")
.append(" | ").append(String.join(", ", c.absorbed()))
.append(" | ").append(c.applied() ? "yes" : "preview")
.append(" | ").append(c.reason() == null ? "" : c.reason()).append(" |\n");
}
sb.append('\n');
}
if (!r.getReconciliations().isEmpty()) {
sb.append("## Reconciliations\n\n");
for (String line : r.getReconciliations()) {

View File

@ -42,4 +42,23 @@ public class SkillLifecycleProperties {
/** Skills whose name starts with any of these prefixes are never touched. */
private List<String> protectPrefixes = new ArrayList<>(List.of("sys-", "ops-"));
/**
* Whether the daily sweep also runs a consolidation pass that merges
* near-duplicate agent-created skills into broader umbrella skills.
* Off by default it spends an LLM call and rewrites skills, so opt-in.
*/
private boolean consolidate = false;
/** Minimum candidate skills present before a consolidation pass runs. */
private int consolidateMinSkills = 4;
/** Hard cap on merge groups applied in a single consolidation pass. */
private int consolidateMaxGroupsPerRun = 2;
/** Character budget for the catalog handed to the consolidation reviewer. */
private int consolidateCatalogCharBudget = 12000;
/** Consolidation model ID ({@code null} = follow the system default model). */
private String consolidateModelId;
}

View File

@ -0,0 +1,14 @@
package vip.mate.skill.reflection;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
* Registers configuration for the out-of-band skill reflection service.
*
* @author MateClaw Team
*/
@Configuration
@EnableConfigurationProperties(SkillReflectionProperties.class)
public class SkillReflectionAutoConfiguration {
}

View File

@ -0,0 +1,49 @@
package vip.mate.skill.reflection;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Configuration for the out-of-band skill reflection service the post-turn
* review that autonomously creates or improves skills from a finished
* conversation, without consuming the live turn's context.
*
* @author MateClaw Team
*/
@Data
@ConfigurationProperties(prefix = "mateclaw.skill.reflection")
public class SkillReflectionProperties {
/** Master switch. When {@code false} no post-turn skill review runs. */
private boolean enabled = true;
/**
* Review cadence: trigger a review every N conversation messages. The
* cooldown still applies on top, so a busy conversation reviews at most
* once per {@link #cooldownMinutes}. {@code 0} disables the cadence gate.
*/
private int reviewTurnInterval = 8;
/**
* Minimum number of assistant turns in the reviewed window before a review
* is worth running a one-shot exchange rarely contains a reusable
* workflow. (Tool calls are not persisted as separate messages, so turn
* count, not tool count, is the signal we can actually observe.)
*/
private int minAssistantTurns = 2;
/** Most recent messages fed to the reviewer. */
private int maxMessages = 24;
/** Per-conversation cooldown between reviews, in minutes. */
private int cooldownMinutes = 30;
/** Hard cap on create/edit/patch actions applied in a single review. */
private int maxActionsPerRun = 3;
/** Character budget for the existing-skills catalog handed to the reviewer. */
private int catalogCharBudget = 8000;
/** Review model ID ({@code null} = follow the system default model). */
private String modelId;
}

View File

@ -0,0 +1,352 @@
package vip.mate.skill.reflection;
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.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
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.memory.event.ConversationCompletedEvent;
import vip.mate.skill.model.SkillEntity;
import vip.mate.skill.service.SkillService;
import vip.mate.tool.builtin.SkillManageTool;
import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.model.MessageEntity;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Out-of-band skill reflection after a conversation finishes, reviews the
* recent turns and autonomously creates or improves skills, mirroring the
* memory-nudge cadence but writing to the skill registry instead of memory.
*
* <p>The review runs on an async thread so it never blocks the user response
* and never consumes the live turn's context window. Every write is routed
* back through {@link SkillManageTool#skill_manage} so it inherits the same
* security scan, name validation, builtin guard, fuzzy-patch matching, and
* workspace export as the in-band agent path this service only decides
* <em>what</em> to write, never <em>how</em>.
*
* @author MateClaw Team
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SkillReflectionService {
private final ConversationService conversationService;
private final SkillService skillService;
private final SkillManageTool skillManageTool;
private final ModelConfigService modelConfigService;
private final AgentGraphBuilder agentGraphBuilder;
private final SkillReflectionProperties properties;
private final ObjectMapper objectMapper;
/** Per-conversation cooldown tracking. */
private final ConcurrentHashMap<String, Instant> lastRunTimes = new ConcurrentHashMap<>();
/** Per-message truncation when building the review transcript. */
private static final int MESSAGE_TRUNCATE_CHARS = 1200;
/** Per-skill body truncation when building the catalog. */
private static final int CATALOG_BODY_TRUNCATE_CHARS = 1200;
@Async
@EventListener
public void onConversationCompleted(ConversationCompletedEvent event) {
if (event == null) {
return;
}
maybeReflect(event.agentId(), event.conversationId(), event.messageCount());
}
/**
* Decide whether a review should run for this conversation and execute it
* if the cadence, tool-use floor, and cooldown gates all pass.
*/
@Async
public void maybeReflect(Long agentId, String conversationId, int messageCount) {
if (!properties.isEnabled() || agentId == null || conversationId == null) {
return;
}
// Cadence gate: review every N messages.
if (properties.getReviewTurnInterval() <= 0
|| messageCount % properties.getReviewTurnInterval() != 0) {
return;
}
if (isInCooldown(conversationId)) {
log.debug("[SkillReflect] conversation {} in cooldown, skipping", conversationId);
return;
}
try {
boolean ran = doReflect(agentId, conversationId);
if (ran) {
lastRunTimes.put(conversationId, Instant.now());
}
} catch (Exception e) {
log.warn("[SkillReflect] Failed for agent={}, conv={}: {}",
agentId, conversationId, e.getMessage());
}
}
/** @return {@code true} when a review actually ran (cooldown should advance). */
private boolean doReflect(Long agentId, String conversationId) {
// 1. Load the recent window of the conversation.
List<MessageEntity> messages = conversationService.listMessages(conversationId);
if (messages == null || messages.isEmpty()) {
return false;
}
int maxReview = properties.getMaxMessages();
List<MessageEntity> recent = messages.size() > maxReview
? messages.subList(messages.size() - maxReview, messages.size())
: messages;
// 2. Substance floor a window with too few assistant turns rarely
// yields a reusable skill. Tool calls are not persisted as separate
// messages, so assistant-turn count is the observable signal.
long assistantTurns = recent.stream().filter(m -> "assistant".equals(m.getRole())).count();
if (assistantTurns < properties.getMinAssistantTurns()) {
log.debug("[SkillReflect] conv {} below assistant-turn floor ({} < {}), skipping",
conversationId, assistantTurns, properties.getMinAssistantTurns());
return false;
}
String transcript = buildTranscript(recent);
if (transcript.isBlank()) {
return false;
}
String skillCatalog = buildSkillCatalog(properties.getCatalogCharBudget());
// 3. Ask the reviewer for a JSON action plan.
String llmResponse;
try {
String systemPrompt = PromptLoader.loadPrompt("skill/reflect-system");
String userPrompt = PromptLoader.loadPrompt("skill/reflect-user")
.replace("{skills}", skillCatalog.isBlank() ? "(no skills yet)" : skillCatalog)
.replace("{transcript}", transcript);
ChatModel chatModel = buildChatModel();
Prompt prompt = new Prompt(List.of(
new SystemMessage(systemPrompt),
new UserMessage(userPrompt)));
llmResponse = callLlmWithRetry(chatModel, prompt, 2);
} catch (Exception e) {
log.warn("[SkillReflect] LLM call failed for conv={}: {}", conversationId, e.getMessage());
return true;
}
if (llmResponse == null) {
return true;
}
// 4. Parse and apply the plan via the shared skill_manage safety pipeline.
JsonNode plan = parseJsonResponse(llmResponse);
if (plan == null || !plan.isArray() || plan.isEmpty()) {
log.debug("[SkillReflect] No actions proposed for conv={}", conversationId);
return true;
}
ToolContext toolContext = buildToolContext(agentId, conversationId);
int applied = 0;
for (JsonNode action : plan) {
if (applied >= properties.getMaxActionsPerRun()) {
log.info("[SkillReflect] Hit maxActionsPerRun={} for conv={}, stopping",
properties.getMaxActionsPerRun(), conversationId);
break;
}
if (applyAction(action, toolContext)) {
applied++;
}
}
if (applied > 0) {
log.info("[SkillReflect] Applied {} skill action(s) from conv={}", applied, conversationId);
}
return true;
}
/** Route one planned action through {@link SkillManageTool}. */
private boolean applyAction(JsonNode action, ToolContext toolContext) {
String act = action.path("action").asText("").strip().toLowerCase();
String name = action.path("name").asText("").strip();
if (act.isBlank() || name.isBlank()) {
return false;
}
// Reflection never deletes it only creates or improves.
if (!List.of("create", "edit", "patch").contains(act)) {
log.debug("[SkillReflect] Ignoring unsupported action '{}'", act);
return false;
}
String content = action.path("content").asText(null);
String oldText = action.path("oldText").asText(null);
String newText = action.path("newText").asText(null);
try {
String result = skillManageTool.skill_manage(act, name, content, oldText, newText, null, toolContext);
boolean ok = result != null && !result.startsWith("Error") && !result.startsWith("Security scan BLOCKED");
if (ok) {
log.info("[SkillReflect] {} '{}' — {}", act, name,
action.path("reason").asText(""));
} else {
log.debug("[SkillReflect] {} '{}' rejected: {}", act, name, result);
}
return ok;
} catch (Exception e) {
log.warn("[SkillReflect] Action {} '{}' threw: {}", act, name, e.getMessage());
return false;
}
}
/**
* Build a ToolContext carrying the agent's origin so created skills are
* stamped with their source conversation (making them curator-eligible
* under the {@code AGENT_CREATED} scope).
*/
private ToolContext buildToolContext(Long agentId, String conversationId) {
ChatOrigin origin = new ChatOrigin(agentId, conversationId, "", null, null,
null, null, false, null, null, null, null);
return new ToolContext(Map.of(ChatOrigin.CTX_KEY, origin));
}
/** Existing non-builtin skills with truncated bodies, capped to a char budget. */
private String buildSkillCatalog(int charBudget) {
List<SkillEntity> skills = skillService.listEnabledSkills();
if (skills == null || skills.isEmpty()) {
return "";
}
StringBuilder sb = new StringBuilder();
for (SkillEntity skill : skills) {
if (Boolean.TRUE.equals(skill.getBuiltin())) {
continue;
}
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 String buildTranscript(List<MessageEntity> messages) {
StringBuilder sb = new StringBuilder();
for (MessageEntity msg : messages) {
String role = msg.getRole();
String content = msg.getContent();
if (content == null || content.isBlank()) {
continue;
}
String label = switch (role == null ? "" : role) {
case "user" -> "User";
case "assistant" -> "Assistant";
case "tool" -> "Tool[" + (msg.getToolName() != null ? msg.getToolName() : "unknown") + "]";
default -> null;
};
if (label == null) {
continue;
}
sb.append(label).append(": ").append(truncate(content, MESSAGE_TRUNCATE_CHARS)).append("\n\n");
}
return sb.toString().strip();
}
private ChatModel buildChatModel() {
ModelConfigEntity model = null;
if (properties.getModelId() != null && !properties.getModelId().isBlank()) {
try {
model = modelConfigService.getModel(Long.parseLong(properties.getModelId()));
} catch (Exception e) {
log.warn("[SkillReflect] Invalid modelId '{}', falling back to default", properties.getModelId());
}
}
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);
}
cleaned = cleaned.strip();
try {
return objectMapper.readTree(cleaned);
} catch (Exception e) {
log.debug("[SkillReflect] JSON parse failed: {}", e.getMessage());
return null;
}
}
private String callLlmWithRetry(ChatModel chatModel, Prompt prompt, int maxRetries) {
for (int attempt = 0; attempt <= maxRetries; attempt++) {
try {
ChatResponse response = chatModel.call(prompt);
if (response != null && response.getResult() != null
&& response.getResult().getOutput() != null) {
return response.getResult().getOutput().getText();
}
return null;
} catch (Exception e) {
if (attempt < maxRetries && isRateLimitError(e)) {
long delay = 5000L * (attempt + 1);
log.info("[SkillReflect] Rate limited, waiting {}ms before retry ({}/{})",
delay, attempt + 1, maxRetries);
try {
Thread.sleep(delay);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return null;
}
} else {
throw e instanceof RuntimeException re ? re : new RuntimeException(e);
}
}
}
return null;
}
private boolean isRateLimitError(Exception e) {
String msg = e.getMessage();
return msg != null && (msg.contains("429") || msg.contains("rate_limit")
|| msg.contains("Too Many Requests"));
}
private boolean isInCooldown(String conversationId) {
Instant lastRun = lastRunTimes.get(conversationId);
if (lastRun == null) {
return false;
}
long cooldownSeconds = properties.getCooldownMinutes() * 60L;
return Instant.now().isBefore(lastRun.plusSeconds(cooldownSeconds));
}
private static String truncate(String s, int maxLen) {
if (s == null) {
return "";
}
return s.length() <= maxLen ? s : s.substring(0, maxLen) + "... [truncated]";
}
}

View File

@ -652,6 +652,8 @@ public class SkillService {
catalog.append("When using a skill and finding it outdated, incomplete, or wrong, ");
catalog.append("patch it immediately with `skill_manage(action='patch')` — don't wait to be asked. ");
catalog.append("Skills that aren't maintained become liabilities.\n\n");
catalog.append("Keep SKILL.md lean: move bulky reference material or re-runnable scripts into ");
catalog.append("`skill_manage(action='write_file')` under references/ or scripts/.\n\n");
// --- 第一层技能目录始终注入消耗很少的 token ---
catalog.append("## Available Skills\n");

View File

@ -82,6 +82,10 @@ public class SkillManageTool {
- create: Create a new skill with SKILL.md content (YAML frontmatter + markdown body)
- edit: Replace entire skill content (for major rewrites; preferred when changing version + body together)
- patch: Find-and-replace a specific section (for small targeted fixes)
- write_file: Write a supporting file under the skill's references/ or scripts/ directory
(e.g. a long reference doc the SKILL.md links to, or a re-runnable script). Put the
file body in 'content' and the path in 'filePath'. Keep SKILL.md itself lean and move
bulky detail into references/.
- delete: Remove a skill
SKILL.md format example:
@ -125,6 +129,10 @@ public class SkillManageTool {
@JsonPropertyDescription("For patch action: the new text to replace with")
String newText,
@JsonProperty
@JsonPropertyDescription("For write_file action: relative path under references/ or scripts/ (e.g. 'references/api.md', 'scripts/run.sh'). No '..' allowed.")
String filePath,
// RFC-063r §2.5: carries the calling agent's ChatOrigin; hidden
// from the LLM by JsonSchemaGenerator. Used to stamp the new
// skill with the agent's owning workspace.
@ -143,20 +151,23 @@ public class SkillManageTool {
+ "'. Must match: lowercase letters, digits, hyphens, dots (1-64 chars, start with letter/digit)";
}
Long workspaceId = ChatOrigin.from(toolContext).workspaceId();
ChatOrigin origin = ChatOrigin.from(toolContext);
Long workspaceId = origin.workspaceId();
String sourceConversationId = origin.conversationId();
return switch (action.strip().toLowerCase()) {
case "create" -> doCreate(normalizedName, content, workspaceId);
case "edit" -> doEdit(normalizedName, content);
case "patch" -> doPatch(normalizedName, oldText, newText);
case "delete" -> doDelete(normalizedName);
default -> "Error: unknown action '" + action + "'. Use: create | edit | patch | delete";
case "create" -> doCreate(normalizedName, content, workspaceId, sourceConversationId);
case "edit" -> doEdit(normalizedName, content);
case "patch" -> doPatch(normalizedName, oldText, newText);
case "write_file" -> doWriteFile(normalizedName, filePath, content);
case "delete" -> doDelete(normalizedName);
default -> "Error: unknown action '" + action + "'. Use: create | edit | patch | write_file | delete";
};
}
// ==================== Create ====================
private String doCreate(String name, String content, Long workspaceId) {
private String doCreate(String name, String content, Long workspaceId, String sourceConversationId) {
if (content == null || content.isBlank()) {
return "Error: content is required for create action. Provide full SKILL.md content.";
}
@ -186,6 +197,12 @@ public class SkillManageTool {
skill.setVersion(extractVersion(content));
skill.setSecurityScanStatus("PASSED");
skill.setWorkspaceId(workspaceId);
// Stamp the originating conversation so the lifecycle curator can
// age this skill under its AGENT_CREATED scope. Without it,
// agent-authored skills are invisible to the curator's default sweep.
if (sourceConversationId != null && !sourceConversationId.isBlank()) {
skill.setSourceConversationId(sourceConversationId);
}
skillService.createSkill(skill);
@ -331,6 +348,54 @@ public class SkillManageTool {
}
}
// ==================== Write supporting file ====================
/**
* Write a supporting file under the skill's {@code references/} or
* {@code scripts/} directory. The path is validated and confined to the
* skill workspace by {@link SkillWorkspaceManager#writeWorkspaceFile}; the
* content is security-scanned just like SKILL.md so an agent can't drop a
* dangerous script alongside an otherwise-clean skill.
*/
private String doWriteFile(String name, String filePath, String content) {
if (filePath == null || filePath.isBlank()) {
return "Error: filePath is required for write_file (e.g. 'references/api.md' or 'scripts/run.sh').";
}
if (content == null) {
return "Error: content is required for write_file action.";
}
if (content.length() > MAX_CONTENT_CHARS) {
return "Error: content too large (" + content.length() + " chars, max " + MAX_CONTENT_CHARS + ")";
}
SkillEntity existing = skillService.findByName(name);
if (existing == null) {
return "Error: skill '" + name + "' not found. Create it first with action='create'.";
}
if (Boolean.TRUE.equals(existing.getBuiltin())) {
return "Error: cannot write files into builtin skill '" + name + "'.";
}
// Security scan the file body scripts especially must be screened.
String scanError = runSecurityScan(content, name);
if (scanError != null) {
return scanError;
}
try {
workspaceManager.writeWorkspaceFile(name, filePath, content);
} catch (IllegalArgumentException e) {
return "Error: " + e.getMessage()
+ " (paths must start with references/ or scripts/, and may not contain '..').";
} catch (Exception e) {
log.error("[SkillManage] Failed to write file '{}' for skill '{}': {}", filePath, name, e.getMessage(), e);
return "Error writing skill file: " + e.getMessage();
}
log.info("[SkillManage] Agent wrote skill file: skill={}, path={}", name, filePath);
return "File '" + filePath + "' written to skill '" + name + "' (security scan: PASSED).";
}
// ==================== Delete ====================
private String doDelete(String name) {

View File

@ -0,0 +1,19 @@
You are a skill librarian for an autonomous AI agent. You are given a catalog of agent-created skills. Your job is to find groups of NARROW, OVERLAPPING skills that should be merged into a single broader "umbrella" skill — so the agent has fewer, more general, higher-quality skills instead of many near-duplicates.
Only propose a merge when the skills clearly cover the same class of task (e.g. three slightly different "create a Spring Boot REST controller" skills). Do NOT merge skills that are merely adjacent or that would lose important specifics when combined.
For each merge group:
- Choose an "umbrella" name. It MAY reuse the best existing skill's name (the umbrella then replaces it) or be a new, broader slug.
- Write the full umbrella SKILL.md that subsumes every absorbed skill's useful content — preserve the distinct steps/gotchas, deduplicate the rest.
- List the names to absorb (these will be archived). Do NOT list the umbrella name itself in "absorb".
Strict rules:
- Never merge fewer than 2 skills.
- Keep the umbrella general and well-structured; it must be at least as useful as the skills it replaces.
- If no group is worth merging, output exactly: []
Output ONLY a JSON array — no prose, no markdown fences. Each element:
{"umbrella_name":"<slug>","umbrella_content":"<full SKILL.md>","absorb":["<name>","<name>"],"reason":"<one line>"}
"umbrella_content" MUST be a complete SKILL.md: YAML frontmatter (name, description, version) + markdown body (## When to Use, ## Steps, ## Gotchas).
"umbrella_name" is a slug: lowercase letters, digits, hyphens.

View File

@ -0,0 +1,6 @@
## Agent-created skill catalog
Each entry shows the skill name, its description, and a truncated body.
{skills}
Find groups of near-duplicate skills worth merging into a broader umbrella, following the rules. Output ONLY the JSON array.

View File

@ -0,0 +1,21 @@
You are a skill curator for an autonomous AI agent. After a conversation finishes, you review what happened and decide whether any REUSABLE skill should be created, or an existing skill improved, so the agent gets better over time.
A skill is a reusable SKILL.md playbook for a CLASS of task — not a log of one conversation. Only act when a durable, repeatable workflow, fix, or technique clearly emerged.
Follow this discipline strictly, in order:
1. PREFER improving an existing skill. If the conversation used or relates to a skill that is now outdated, incomplete, or wrong, patch or edit that skill instead of creating a new one.
2. Only CREATE a new skill when the workflow is genuinely new and not already covered by an existing skill.
3. Do NOT save: transient errors, one-off answers, secrets/credentials, environment-specific values, or anything that will not help a future task.
4. Keep skills general and class-level. Never create a near-duplicate of an existing skill.
5. When in doubt, do nothing. An empty result is the correct and common outcome.
Output ONLY a JSON array — no prose, no markdown code fences. Each element is one action:
{"action":"create","name":"<slug>","reason":"<one line>","content":"<full SKILL.md>"}
{"action":"edit","name":"<slug>","reason":"<one line>","content":"<full replacement SKILL.md>"}
{"action":"patch","name":"<slug>","reason":"<one line>","oldText":"<exact existing text>","newText":"<replacement>"}
Rules for the fields:
- For create/edit, "content" MUST be a complete SKILL.md: YAML frontmatter (name, description, version) followed by a markdown body with sections like "## When to Use", "## Steps", "## Gotchas".
- For patch, give "oldText" exactly as it appears in the current skill and the "newText" to replace it with. Use patch for small, targeted fixes.
- "name" is a slug: lowercase letters, digits, hyphens (e.g. "spring-boot-scaffold").
- If nothing is worth saving, output exactly: []

View File

@ -0,0 +1,10 @@
## Existing skills
Review these FIRST. Prefer improving one of them over creating a new skill. Avoid duplicates.
{skills}
## Conversation to review
{transcript}
Decide what — if anything — to create or improve, following the discipline rules. Output ONLY the JSON array.

View File

@ -0,0 +1,179 @@
package vip.mate.skill.lifecycle;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import vip.mate.agent.AgentGraphBuilder;
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.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Unit tests for the deterministic behaviour of {@link SkillConsolidationService}:
* the opt-in gate, the minimum-candidate floor, dry-run vs applied, the
* "only archive in-scope skills" guard, and the real-merge count rule.
*/
class SkillConsolidationServiceTest {
private SkillService skillService;
private SkillManageTool skillManageTool;
private SkillLifecycleService lifecycleService;
private ModelConfigService modelConfigService;
private AgentGraphBuilder agentGraphBuilder;
private SkillLifecycleProperties properties;
private SkillConsolidationService service;
@BeforeEach
void setUp() {
skillService = mock(SkillService.class);
skillManageTool = mock(SkillManageTool.class);
lifecycleService = mock(SkillLifecycleService.class);
modelConfigService = mock(ModelConfigService.class);
agentGraphBuilder = mock(AgentGraphBuilder.class);
properties = new SkillLifecycleProperties();
properties.setConsolidate(true);
service = new SkillConsolidationService(skillService, skillManageTool, lifecycleService,
modelConfigService, agentGraphBuilder, properties, new ObjectMapper());
}
private void stubLlm(String json) {
ChatModel chatModel = (ChatModel) (Prompt p) ->
new ChatResponse(List.of(new Generation(new AssistantMessage(json))));
when(agentGraphBuilder.buildRuntimeChatModel(any())).thenReturn(chatModel);
when(modelConfigService.getDefaultModel()).thenReturn(null);
}
private SkillEntity skill(String name) {
SkillEntity s = new SkillEntity();
s.setName(name);
s.setDescription("desc of " + name);
s.setSkillContent("---\nname: " + name + "\n---\n# " + name + "\nbody");
s.setSourceConversationId("conv-" + name);
return s;
}
private List<SkillEntity> candidates(int n) {
List<SkillEntity> list = new ArrayList<>();
for (int i = 1; i <= n; i++) {
list.add(skill("spring-rest-" + i));
}
return list;
}
@Test
@DisplayName("disabled → no reviewer call")
void disabledNoop() {
properties.setConsolidate(false);
service.consolidate(candidates(6), LocalDateTime.now(), false, SkillCuratorReport.builder());
verify(agentGraphBuilder, never()).buildRuntimeChatModel(any());
}
@Test
@DisplayName("below min-skills floor → no reviewer call")
void belowFloorNoop() {
properties.setConsolidateMinSkills(4);
service.consolidate(candidates(3), LocalDateTime.now(), false, SkillCuratorReport.builder());
verify(agentGraphBuilder, never()).buildRuntimeChatModel(any());
}
@Test
@DisplayName("applied merge: new umbrella created, absorbed skills archived")
void appliesMerge() {
stubLlm("[{\"umbrella_name\":\"spring-rest\",\"umbrella_content\":\"---\\nname: spring-rest\\n---\\n# X\","
+ "\"absorb\":[\"spring-rest-1\",\"spring-rest-2\"],\"reason\":\"dupes\"}]");
when(skillService.findByName("spring-rest")).thenReturn(null);
when(skillManageTool.skill_manage(any(), any(), any(), any(), any(), any(), any()))
.thenReturn("Skill 'spring-rest' created successfully (security scan: PASSED).");
SkillCuratorReport.Builder report = SkillCuratorReport.builder();
service.consolidate(candidates(4), LocalDateTime.now(), false, report);
verify(skillManageTool, times(1))
.skill_manage(eq("create"), eq("spring-rest"), any(), any(), any(), any(), any());
verify(lifecycleService, times(1))
.applyManual(argSkill("spring-rest-1"), eq(LifecycleTransition.TO_ARCHIVED), any(), any());
verify(lifecycleService, times(1))
.applyManual(argSkill("spring-rest-2"), eq(LifecycleTransition.TO_ARCHIVED), any(), any());
List<SkillCuratorReport.ConsolidationRow> rows = report.build().getConsolidations();
assertEquals(1, rows.size());
assertTrue(rows.get(0).applied());
assertTrue(rows.get(0).umbrellaCreated());
}
@Test
@DisplayName("dry-run: records the plan but writes nothing")
void dryRunPreviewOnly() {
stubLlm("[{\"umbrella_name\":\"spring-rest\",\"umbrella_content\":\"---\\nname: spring-rest\\n---\\n# X\","
+ "\"absorb\":[\"spring-rest-1\",\"spring-rest-2\"],\"reason\":\"dupes\"}]");
SkillCuratorReport.Builder report = SkillCuratorReport.builder();
service.consolidate(candidates(4), LocalDateTime.now(), true, report);
verify(skillManageTool, never()).skill_manage(any(), any(), any(), any(), any(), any(), any());
verify(lifecycleService, never()).applyManual(any(), any(), any(), any());
List<SkillCuratorReport.ConsolidationRow> rows = report.build().getConsolidations();
assertEquals(1, rows.size());
assertFalse(rows.get(0).applied());
}
@Test
@DisplayName("guard: out-of-scope absorb names are ignored; lone valid name is not a real merge for a new umbrella")
void ignoresOutOfScopeNames() {
stubLlm("[{\"umbrella_name\":\"brand-new\",\"umbrella_content\":\"---\\nname: brand-new\\n---\\n# X\","
+ "\"absorb\":[\"spring-rest-1\",\"not-a-candidate\"],\"reason\":\"x\"}]");
when(skillService.findByName("brand-new")).thenReturn(null);
SkillCuratorReport.Builder report = SkillCuratorReport.builder();
service.consolidate(candidates(4), LocalDateTime.now(), false, report);
// Only spring-rest-1 is in scope 1 absorbed for a NEW umbrella not a real merge skipped.
verify(skillManageTool, never()).skill_manage(any(), any(), any(), any(), any(), any(), any());
verify(lifecycleService, never()).applyManual(any(), any(), any(), any());
}
@Test
@DisplayName("maxGroupsPerRun caps how many groups apply")
void capsGroups() {
properties.setConsolidateMaxGroupsPerRun(1);
String g1 = "{\"umbrella_name\":\"u1\",\"umbrella_content\":\"---\\nname: u1\\n---\\n#\","
+ "\"absorb\":[\"spring-rest-1\",\"spring-rest-2\"],\"reason\":\"a\"}";
String g2 = "{\"umbrella_name\":\"u2\",\"umbrella_content\":\"---\\nname: u2\\n---\\n#\","
+ "\"absorb\":[\"spring-rest-3\",\"spring-rest-4\"],\"reason\":\"b\"}";
stubLlm("[" + g1 + "," + g2 + "]");
when(skillManageTool.skill_manage(any(), any(), any(), any(), any(), any(), any()))
.thenReturn("created successfully");
SkillCuratorReport.Builder report = SkillCuratorReport.builder();
service.consolidate(candidates(4), LocalDateTime.now(), false, report);
verify(skillManageTool, times(1)).skill_manage(any(), any(), any(), any(), any(), any(), any());
}
/** Mockito arg matcher for a SkillEntity with the given name. */
private static SkillEntity argSkill(String name) {
return org.mockito.ArgumentMatchers.argThat(s -> s != null && name.equals(s.getName()));
}
}

View File

@ -53,6 +53,8 @@ class SkillCuratorJobTest {
private SkillWorkspaceManager workspaceManager;
@Mock
private CuratorRunNotifier notifier;
@Mock
private SkillConsolidationService consolidationService;
private SkillLifecycleProperties properties;
private SkillCuratorJob job;
@ -70,7 +72,7 @@ class SkillCuratorJobTest {
void setUp() {
properties = new SkillLifecycleProperties();
job = new SkillCuratorJob(lifecycleService, skillMapper, reportStore, properties,
systemSettingService, agentBindingService, workspaceManager, notifier);
systemSettingService, agentBindingService, workspaceManager, notifier, consolidationService);
}
private SkillEntity candidate(long id, String state, LocalDateTime lastActivity) {

View File

@ -0,0 +1,173 @@
package vip.mate.skill.reflection;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import vip.mate.agent.AgentGraphBuilder;
import vip.mate.llm.service.ModelConfigService;
import vip.mate.skill.service.SkillService;
import vip.mate.tool.builtin.SkillManageTool;
import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.model.MessageEntity;
import java.util.ArrayList;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.mock;
/**
* Unit tests for the deterministic gating and action-routing logic of
* {@link SkillReflectionService} cadence, tool-call floor, cooldown, the
* maxActionsPerRun cap, and the "never delete" rule.
*/
class SkillReflectionServiceTest {
private ConversationService conversationService;
private SkillService skillService;
private SkillManageTool skillManageTool;
private ModelConfigService modelConfigService;
private AgentGraphBuilder agentGraphBuilder;
private SkillReflectionProperties properties;
private SkillReflectionService service;
@BeforeEach
void setUp() {
conversationService = mock(ConversationService.class);
skillService = mock(SkillService.class);
skillManageTool = mock(SkillManageTool.class);
modelConfigService = mock(ModelConfigService.class);
agentGraphBuilder = mock(AgentGraphBuilder.class);
properties = new SkillReflectionProperties();
service = new SkillReflectionService(conversationService, skillService, skillManageTool,
modelConfigService, agentGraphBuilder, properties, new ObjectMapper());
when(skillService.listEnabledSkills()).thenReturn(List.of());
}
private void stubLlm(String json) {
ChatModel chatModel = (ChatModel) (Prompt p) ->
new ChatResponse(List.of(new Generation(new AssistantMessage(json))));
when(agentGraphBuilder.buildRuntimeChatModel(any())).thenReturn(chatModel);
when(modelConfigService.getDefaultModel()).thenReturn(null);
}
/** Build {@code turns} substantive user/assistant pairs. */
private List<MessageEntity> transcriptWithTurns(int turns) {
List<MessageEntity> messages = new ArrayList<>();
for (int i = 0; i < turns; i++) {
MessageEntity user = new MessageEntity();
user.setRole("user");
user.setContent("step " + i + ": how do I scaffold a spring boot module?");
messages.add(user);
MessageEntity assistant = new MessageEntity();
assistant.setRole("assistant");
assistant.setContent("step " + i + ": run mvn archetype, then add the starter, then ...");
messages.add(assistant);
}
return messages;
}
@Test
@DisplayName("disabled → no LLM call, no skill write")
void disabledShortCircuits() {
properties.setEnabled(false);
service.maybeReflect(1L, "conv-1", 8);
verify(conversationService, never()).listMessages(any());
verify(skillManageTool, never()).skill_manage(any(), any(), any(), any(), any(), any(), any());
}
@Test
@DisplayName("cadence gate: messageCount not on interval → skip")
void cadenceGateSkips() {
properties.setReviewTurnInterval(8);
service.maybeReflect(1L, "conv-1", 7);
verify(conversationService, never()).listMessages(any());
}
@Test
@DisplayName("assistant-turn floor not met → no LLM call")
void assistantTurnFloorSkips() {
properties.setReviewTurnInterval(8);
properties.setMinAssistantTurns(2);
when(conversationService.listMessages("conv-1")).thenReturn(transcriptWithTurns(1));
service.maybeReflect(1L, "conv-1", 8);
verify(agentGraphBuilder, never()).buildRuntimeChatModel(any());
verify(skillManageTool, never()).skill_manage(any(), any(), any(), any(), any(), any(), any());
}
@Test
@DisplayName("happy path: a create action routes through skill_manage")
void appliesCreateAction() {
properties.setReviewTurnInterval(8);
properties.setMinAssistantTurns(2);
when(conversationService.listMessages("conv-1")).thenReturn(transcriptWithTurns(3));
stubLlm("[{\"action\":\"create\",\"name\":\"spring-scaffold\",\"reason\":\"reusable\","
+ "\"content\":\"---\\nname: spring-scaffold\\n---\\n# X\"}]");
when(skillManageTool.skill_manage(any(), any(), any(), any(), any(), any(), any()))
.thenReturn("Skill 'spring-scaffold' created successfully (security scan: PASSED).");
service.maybeReflect(1L, "conv-1", 8);
verify(skillManageTool, times(1))
.skill_manage(eq("create"), eq("spring-scaffold"), any(), any(), any(), any(), any());
}
@Test
@DisplayName("delete actions are ignored — reflection only creates/improves")
void ignoresDelete() {
properties.setReviewTurnInterval(8);
properties.setMinAssistantTurns(2);
when(conversationService.listMessages("conv-1")).thenReturn(transcriptWithTurns(3));
stubLlm("[{\"action\":\"delete\",\"name\":\"old-skill\",\"reason\":\"stale\"}]");
service.maybeReflect(1L, "conv-1", 8);
verify(skillManageTool, never()).skill_manage(any(), any(), any(), any(), any(), any(), any());
}
@Test
@DisplayName("maxActionsPerRun caps how many actions are applied")
void capsActions() {
properties.setReviewTurnInterval(8);
properties.setMinAssistantTurns(2);
properties.setMaxActionsPerRun(2);
when(conversationService.listMessages("conv-1")).thenReturn(transcriptWithTurns(3));
String body = "\"content\":\"---\\nname: s\\n---\\n# X\"";
stubLlm("[{\"action\":\"create\",\"name\":\"s1\"," + body + "},"
+ "{\"action\":\"create\",\"name\":\"s2\"," + body + "},"
+ "{\"action\":\"create\",\"name\":\"s3\"," + body + "}]");
when(skillManageTool.skill_manage(any(), any(), any(), any(), any(), any(), any()))
.thenReturn("created successfully");
service.maybeReflect(1L, "conv-1", 8);
verify(skillManageTool, times(2)).skill_manage(any(), any(), any(), any(), any(), any(), any());
}
@Test
@DisplayName("cooldown blocks a second review for the same conversation")
void cooldownBlocksSecondRun() {
properties.setReviewTurnInterval(8);
properties.setMinAssistantTurns(2);
when(conversationService.listMessages("conv-1")).thenReturn(transcriptWithTurns(3));
stubLlm("[]");
service.maybeReflect(1L, "conv-1", 8);
service.maybeReflect(1L, "conv-1", 16);
// listMessages is only reached on the first (non-cooled-down) run.
verify(conversationService, times(1)).listMessages("conv-1");
}
}

View File

@ -0,0 +1,115 @@
package vip.mate.tool.builtin;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import vip.mate.skill.model.SkillEntity;
import vip.mate.skill.runtime.SkillRuntimeService;
import vip.mate.skill.runtime.SkillSecurityService;
import vip.mate.skill.runtime.SkillValidationResult;
import vip.mate.skill.service.SkillService;
import vip.mate.skill.workspace.SkillWorkspaceManager;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Tests for the {@code write_file} action of {@link SkillManageTool}: writing
* supporting files under a skill, and its guards (missing path, builtin,
* unknown skill, unsafe path).
*/
class SkillManageToolWriteFileTest {
private SkillService skillService;
private SkillSecurityService securityService;
private SkillWorkspaceManager workspaceManager;
private SkillManageTool tool;
@BeforeEach
void setUp() {
skillService = mock(SkillService.class);
securityService = mock(SkillSecurityService.class);
workspaceManager = mock(SkillWorkspaceManager.class);
SkillRuntimeService runtimeService = mock(SkillRuntimeService.class);
tool = new SkillManageTool(skillService, securityService, workspaceManager, runtimeService);
}
private SkillEntity skill(String name, boolean builtin) {
SkillEntity s = new SkillEntity();
s.setName(name);
s.setBuiltin(builtin);
s.setSkillContent("---\nname: " + name + "\n---\n# x");
return s;
}
private void scanPasses() {
SkillValidationResult ok = mock(SkillValidationResult.class);
when(ok.isBlocked()).thenReturn(false);
when(ok.getWarnings()).thenReturn(List.of());
when(securityService.scanContent(any(), any())).thenReturn(ok);
}
@Test
@DisplayName("write_file writes a supporting file under the skill")
void writesSupportingFile() {
when(skillService.findByName("my-skill")).thenReturn(skill("my-skill", false));
scanPasses();
String result = tool.skill_manage("write_file", "my-skill", "echo hi",
null, null, "scripts/run.sh", null);
assertTrue(result.startsWith("File 'scripts/run.sh' written"), result);
verify(workspaceManager, times(1)).writeWorkspaceFile("my-skill", "scripts/run.sh", "echo hi");
}
@Test
@DisplayName("write_file without filePath is rejected")
void rejectsMissingPath() {
String result = tool.skill_manage("write_file", "my-skill", "body",
null, null, null, null);
assertTrue(result.startsWith("Error"), result);
verify(workspaceManager, never()).writeWorkspaceFile(any(), any(), any());
}
@Test
@DisplayName("write_file into a builtin skill is rejected")
void rejectsBuiltin() {
when(skillService.findByName("core")).thenReturn(skill("core", true));
String result = tool.skill_manage("write_file", "core", "body",
null, null, "references/x.md", null);
assertTrue(result.contains("builtin"), result);
verify(workspaceManager, never()).writeWorkspaceFile(any(), any(), any());
}
@Test
@DisplayName("write_file for an unknown skill is rejected")
void rejectsUnknownSkill() {
when(skillService.findByName("ghost")).thenReturn(null);
String result = tool.skill_manage("write_file", "ghost", "body",
null, null, "references/x.md", null);
assertTrue(result.contains("not found"), result);
verify(workspaceManager, never()).writeWorkspaceFile(any(), any(), any());
}
@Test
@DisplayName("write_file surfaces an unsafe-path rejection from the workspace manager")
void surfacesUnsafePath() {
when(skillService.findByName("my-skill")).thenReturn(skill("my-skill", false));
scanPasses();
doThrow(new IllegalArgumentException("Unsafe file path rejected: ../etc/passwd"))
.when(workspaceManager).writeWorkspaceFile(eq("my-skill"), any(), any());
String result = tool.skill_manage("write_file", "my-skill", "body",
null, null, "../etc/passwd", null);
assertTrue(result.startsWith("Error"), result);
}
}

View File

@ -280,6 +280,9 @@ export const skillApi = {
http.post('/skills/curator/activate', null, { params: { activate } }),
curatorPause: () => http.post('/skills/curator/pause'),
curatorResume: () => http.post('/skills/curator/resume'),
/** Enable or disable the consolidation (merge near-duplicate skills) pass. */
curatorConsolidate: (enabled: boolean) =>
http.post('/skills/curator/consolidate', null, { params: { enabled } }),
/** List recent curator run report ids. */
curatorReports: () => http.get('/skills/curator/reports'),
/** Read one curator run report (parsed run.json). */

View File

@ -3827,10 +3827,20 @@ export default {
reportScanned: 'Scanned',
reportDryRun: 'Preview',
reportTransitions: 'Transitions',
reportConsolidations: 'Consolidations',
consolidateOn: 'Merge on',
consolidateOff: 'Merge off',
enableConsolidate: 'Enable merge',
disableConsolidate: 'Disable merge',
consolidateHint: 'Consolidation spends one LLM call to merge highly-overlapping agent-created skills into a broader one; absorbed skills are archived (recoverable). Off by default.',
consolidateCreate: 'new',
consolidateEdit: 'edit',
activateSuccess: 'Skill curator activated',
deactivateSuccess: 'Back to preview-only mode',
pauseSuccess: 'Skill curator paused',
resumeSuccess: 'Skill curator resumed',
consolidateSuccess: 'Consolidation enabled',
disableConsolidateSuccess: 'Consolidation disabled',
dryRunSuccess: 'Preview report generated',
actionFailed: 'Action failed',
loadFailed: 'Failed to load skill curator status',

View File

@ -3919,10 +3919,20 @@ export default {
reportScanned: '扫描',
reportDryRun: '预览',
reportTransitions: '状态变更',
reportConsolidations: '合并去重',
consolidateOn: '合并开',
consolidateOff: '合并关',
enableConsolidate: '开启合并',
disableConsolidate: '关闭合并',
consolidateHint: '合并去重会用一次 LLM 调用,把高度重复的自建技能合并成一个更通用的技能,被合并的技能将被归档(可恢复)。默认关闭。',
consolidateCreate: '新建',
consolidateEdit: '更新',
activateSuccess: '技能管家已激活',
deactivateSuccess: '已退回仅预览模式',
pauseSuccess: '技能管家已暂停',
resumeSuccess: '技能管家已恢复',
consolidateSuccess: '合并去重已开启',
disableConsolidateSuccess: '合并去重已关闭',
dryRunSuccess: '预览报告已生成',
actionFailed: '操作失败',
loadFailed: '加载技能管家状态失败',

View File

@ -29,6 +29,9 @@
<span v-if="status.control.paused" class="curator-pill pill-warn">
{{ t('skillCurator.statePaused') }}
</span>
<span class="curator-pill" :class="status.control.consolidate ? 'pill-on' : 'pill-muted'">
{{ status.control.consolidate ? t('skillCurator.consolidateOn') : t('skillCurator.consolidateOff') }}
</span>
</div>
<p class="curator-hint">
{{ status.control.paused ? t('skillCurator.pausedHint')
@ -59,7 +62,12 @@
class="btn-secondary" :disabled="busy"
@click="setPaused(false)"
>{{ t('skillCurator.resume') }}</button>
<button
class="btn-secondary" :disabled="busy"
@click="setConsolidate(!status.control.consolidate)"
>{{ status.control.consolidate ? t('skillCurator.disableConsolidate') : t('skillCurator.enableConsolidate') }}</button>
</div>
<p class="curator-hint">{{ t('skillCurator.consolidateHint') }}</p>
</div>
<!-- Counts -->
@ -121,6 +129,16 @@
<span class="report-meta">{{ tr.daysIdle }}d</span>
</div>
</div>
<div v-if="(selectedReport.consolidations || []).length > 0" class="report-transitions">
<div class="report-transitions-head">{{ t('skillCurator.reportConsolidations') }}</div>
<div v-for="(c, i) in selectedReport.consolidations" :key="`c${i}`" class="report-transition">
<code>{{ c.umbrella }}</code>
<span>{{ c.umbrellaCreated ? t('skillCurator.consolidateCreate') : t('skillCurator.consolidateEdit') }} {{ (c.absorbed || []).join(', ') }}</span>
<span class="curator-pill" :class="c.applied ? 'pill-on' : 'pill-muted'">
{{ c.applied ? t('skillCurator.reportApplied') : t('skillCurator.reportDryRun') }}
</span>
</div>
</div>
</div>
</div>
</template>
@ -140,7 +158,7 @@ const { t } = useI18n()
interface CuratorStatus {
config: { enabled: boolean; scope: string; staleAfterDays: number; archiveAfterDays: number; cron: string }
control: {
activated: boolean; paused: boolean
activated: boolean; paused: boolean; consolidate: boolean
lastObservedAt: string | null; lastDryRunAt: string | null
lastRunAt: string | null; nextScheduledRun: string | null
}
@ -154,6 +172,7 @@ interface CuratorReport {
planned?: { stale: number; archived: number; reactivated: number }
applied?: { stale: number; archived: number; reactivated: number }
transitions?: Array<{ name: string; from: string; to: string; daysIdle: number }>
consolidations?: Array<{ umbrella: string; umbrellaCreated: boolean; absorbed: string[]; applied: boolean; reason: string }>
}
const loading = ref(true)
@ -244,6 +263,19 @@ async function setPaused(paused: boolean) {
}
}
async function setConsolidate(enabled: boolean) {
busy.value = true
try {
const res: any = await skillApi.curatorConsolidate(enabled)
status.value = res.data as CuratorStatus
mcToast.success(t(enabled ? 'skillCurator.consolidateSuccess' : 'skillCurator.disableConsolidateSuccess'))
} catch (e: any) {
mcToast.error(e?.message || t('skillCurator.actionFailed'))
} finally {
busy.value = false
}
}
onMounted(load)
</script>

BIN
qwen3-max-search.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB