mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(skill): self-evolving skills — out-of-band reflection, curator consolidation, agent-authored skill files
This commit is contained in:
parent
a366c66d23
commit
f08abad076
@ -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")
|
||||
|
||||
@ -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]";
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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()) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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]";
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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.
|
||||
@ -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.
|
||||
@ -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: []
|
||||
@ -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.
|
||||
@ -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()));
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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). */
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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: '加载技能管家状态失败',
|
||||
|
||||
@ -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
BIN
qwen3-max-search.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 356 KiB |
Loading…
Reference in New Issue
Block a user