mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 20:34:39 +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());
|
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 控制面状态")
|
@Operation(summary = "curator 控制面状态")
|
||||||
@GetMapping("/curator/status")
|
@GetMapping("/curator/status")
|
||||||
@RequireWorkspaceRole("member")
|
@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";
|
static final String FIRST_RUN_KEY = "skill.curator.firstRunCompleted";
|
||||||
/** Runtime kill switch — pauses the scheduled sweep without a redeploy. */
|
/** Runtime kill switch — pauses the scheduled sweep without a redeploy. */
|
||||||
static final String PAUSED_KEY = "skill.curator.paused";
|
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. */
|
/** ISO-8601 timestamp of the last auto dry-run, for throttling. */
|
||||||
static final String LAST_DRY_RUN_KEY = "skill.curator.lastDryRunAt";
|
static final String LAST_DRY_RUN_KEY = "skill.curator.lastDryRunAt";
|
||||||
/** ISO-8601 timestamp of the first sweep observation after install. */
|
/** ISO-8601 timestamp of the first sweep observation after install. */
|
||||||
@ -57,6 +59,7 @@ public class SkillCuratorJob {
|
|||||||
private final AgentBindingService agentBindingService;
|
private final AgentBindingService agentBindingService;
|
||||||
private final SkillWorkspaceManager workspaceManager;
|
private final SkillWorkspaceManager workspaceManager;
|
||||||
private final CuratorRunNotifier notifier;
|
private final CuratorRunNotifier notifier;
|
||||||
|
private final SkillConsolidationService consolidationService;
|
||||||
|
|
||||||
@Scheduled(cron = "${mateclaw.skill.curator.cron:0 0 2 * * *}")
|
@Scheduled(cron = "${mateclaw.skill.curator.cron:0 0 2 * * *}")
|
||||||
@SchedulerLock(name = "skill-curator", lockAtMostFor = "PT10M", lockAtLeastFor = "PT30S")
|
@SchedulerLock(name = "skill-curator", lockAtMostFor = "PT10M", lockAtLeastFor = "PT30S")
|
||||||
@ -127,6 +130,16 @@ public class SkillCuratorJob {
|
|||||||
systemSettingService.saveBool(PAUSED_KEY, paused, "Skill curator paused");
|
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. */
|
/** Aggregated control-panel state for the admin UI. */
|
||||||
public Map<String, Object> status() {
|
public Map<String, Object> status() {
|
||||||
Map<String, Object> config = new LinkedHashMap<>();
|
Map<String, Object> config = new LinkedHashMap<>();
|
||||||
@ -139,6 +152,7 @@ public class SkillCuratorJob {
|
|||||||
Map<String, Object> control = new LinkedHashMap<>();
|
Map<String, Object> control = new LinkedHashMap<>();
|
||||||
control.put("activated", systemSettingService.getBool(FIRST_RUN_KEY, false));
|
control.put("activated", systemSettingService.getBool(FIRST_RUN_KEY, false));
|
||||||
control.put("paused", systemSettingService.getBool(PAUSED_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("lastObservedAt", systemSettingService.getString(LAST_OBSERVED_KEY, null));
|
||||||
control.put("lastDryRunAt", systemSettingService.getString(LAST_DRY_RUN_KEY, null));
|
control.put("lastDryRunAt", systemSettingService.getString(LAST_DRY_RUN_KEY, null));
|
||||||
control.put("lastRunAt", systemSettingService.getString(LAST_RUN_KEY, null));
|
control.put("lastRunAt", systemSettingService.getString(LAST_RUN_KEY, null));
|
||||||
@ -212,6 +226,15 @@ public class SkillCuratorJob {
|
|||||||
.appliedCounts(appliedStale, appliedArchived, appliedReactivate)
|
.appliedCounts(appliedStale, appliedArchived, appliedReactivate)
|
||||||
.blockedByBindings(agentBindingService.blockedByBindingCandidates(now));
|
.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());
|
return reportStore.write(report.build());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -38,6 +38,7 @@ public class SkillCuratorReport {
|
|||||||
private final List<TransitionRow> transitions;
|
private final List<TransitionRow> transitions;
|
||||||
private final List<BlockedByBindingRow> blockedByBindings;
|
private final List<BlockedByBindingRow> blockedByBindings;
|
||||||
private final List<String> reconciliations;
|
private final List<String> reconciliations;
|
||||||
|
private final List<ConsolidationRow> consolidations;
|
||||||
|
|
||||||
/** Set by the report store after the run directory is written. */
|
/** Set by the report store after the run directory is written. */
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
@ -54,6 +55,7 @@ public class SkillCuratorReport {
|
|||||||
this.transitions = List.copyOf(b.transitions);
|
this.transitions = List.copyOf(b.transitions);
|
||||||
this.blockedByBindings = List.copyOf(b.blockedByBindings);
|
this.blockedByBindings = List.copyOf(b.blockedByBindings);
|
||||||
this.reconciliations = List.copyOf(b.reconciliations);
|
this.reconciliations = List.copyOf(b.reconciliations);
|
||||||
|
this.consolidations = List.copyOf(b.consolidations);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setPath(Path path) {
|
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) {}
|
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() {
|
public static Builder builder() {
|
||||||
return new Builder();
|
return new Builder();
|
||||||
}
|
}
|
||||||
@ -98,6 +109,7 @@ public class SkillCuratorReport {
|
|||||||
private final List<TransitionRow> transitions = new ArrayList<>();
|
private final List<TransitionRow> transitions = new ArrayList<>();
|
||||||
private List<BlockedByBindingRow> blockedByBindings = new ArrayList<>();
|
private List<BlockedByBindingRow> blockedByBindings = new ArrayList<>();
|
||||||
private final List<String> reconciliations = new ArrayList<>();
|
private final List<String> reconciliations = new ArrayList<>();
|
||||||
|
private final List<ConsolidationRow> consolidations = new ArrayList<>();
|
||||||
|
|
||||||
public Builder runAt(LocalDateTime runAt) {
|
public Builder runAt(LocalDateTime runAt) {
|
||||||
this.runAt = runAt;
|
this.runAt = runAt;
|
||||||
@ -166,6 +178,13 @@ public class SkillCuratorReport {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Builder consolidation(ConsolidationRow row) {
|
||||||
|
if (row != null) {
|
||||||
|
this.consolidations.add(row);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public SkillCuratorReport build() {
|
public SkillCuratorReport build() {
|
||||||
return new SkillCuratorReport(this);
|
return new SkillCuratorReport(this);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -192,6 +192,20 @@ public class SkillCuratorReportStore {
|
|||||||
sb.append('\n');
|
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()) {
|
if (!r.getReconciliations().isEmpty()) {
|
||||||
sb.append("## Reconciliations\n\n");
|
sb.append("## Reconciliations\n\n");
|
||||||
for (String line : r.getReconciliations()) {
|
for (String line : r.getReconciliations()) {
|
||||||
|
|||||||
@ -42,4 +42,23 @@ public class SkillLifecycleProperties {
|
|||||||
|
|
||||||
/** Skills whose name starts with any of these prefixes are never touched. */
|
/** Skills whose name starts with any of these prefixes are never touched. */
|
||||||
private List<String> protectPrefixes = new ArrayList<>(List.of("sys-", "ops-"));
|
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("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("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("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) ---
|
// --- 第一层:技能目录(始终注入,消耗很少的 token) ---
|
||||||
catalog.append("## Available Skills\n");
|
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)
|
- 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)
|
- 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)
|
- 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
|
- delete: Remove a skill
|
||||||
|
|
||||||
SKILL.md format example:
|
SKILL.md format example:
|
||||||
@ -125,6 +129,10 @@ public class SkillManageTool {
|
|||||||
@JsonPropertyDescription("For patch action: the new text to replace with")
|
@JsonPropertyDescription("For patch action: the new text to replace with")
|
||||||
String newText,
|
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
|
// RFC-063r §2.5: carries the calling agent's ChatOrigin; hidden
|
||||||
// from the LLM by JsonSchemaGenerator. Used to stamp the new
|
// from the LLM by JsonSchemaGenerator. Used to stamp the new
|
||||||
// skill with the agent's owning workspace.
|
// 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)";
|
+ "'. 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()) {
|
return switch (action.strip().toLowerCase()) {
|
||||||
case "create" -> doCreate(normalizedName, content, workspaceId);
|
case "create" -> doCreate(normalizedName, content, workspaceId, sourceConversationId);
|
||||||
case "edit" -> doEdit(normalizedName, content);
|
case "edit" -> doEdit(normalizedName, content);
|
||||||
case "patch" -> doPatch(normalizedName, oldText, newText);
|
case "patch" -> doPatch(normalizedName, oldText, newText);
|
||||||
case "delete" -> doDelete(normalizedName);
|
case "write_file" -> doWriteFile(normalizedName, filePath, content);
|
||||||
default -> "Error: unknown action '" + action + "'. Use: create | edit | patch | delete";
|
case "delete" -> doDelete(normalizedName);
|
||||||
|
default -> "Error: unknown action '" + action + "'. Use: create | edit | patch | write_file | delete";
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Create ====================
|
// ==================== 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()) {
|
if (content == null || content.isBlank()) {
|
||||||
return "Error: content is required for create action. Provide full SKILL.md content.";
|
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.setVersion(extractVersion(content));
|
||||||
skill.setSecurityScanStatus("PASSED");
|
skill.setSecurityScanStatus("PASSED");
|
||||||
skill.setWorkspaceId(workspaceId);
|
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);
|
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 ====================
|
// ==================== Delete ====================
|
||||||
|
|
||||||
private String doDelete(String name) {
|
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;
|
private SkillWorkspaceManager workspaceManager;
|
||||||
@Mock
|
@Mock
|
||||||
private CuratorRunNotifier notifier;
|
private CuratorRunNotifier notifier;
|
||||||
|
@Mock
|
||||||
|
private SkillConsolidationService consolidationService;
|
||||||
|
|
||||||
private SkillLifecycleProperties properties;
|
private SkillLifecycleProperties properties;
|
||||||
private SkillCuratorJob job;
|
private SkillCuratorJob job;
|
||||||
@ -70,7 +72,7 @@ class SkillCuratorJobTest {
|
|||||||
void setUp() {
|
void setUp() {
|
||||||
properties = new SkillLifecycleProperties();
|
properties = new SkillLifecycleProperties();
|
||||||
job = new SkillCuratorJob(lifecycleService, skillMapper, reportStore, properties,
|
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) {
|
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 } }),
|
http.post('/skills/curator/activate', null, { params: { activate } }),
|
||||||
curatorPause: () => http.post('/skills/curator/pause'),
|
curatorPause: () => http.post('/skills/curator/pause'),
|
||||||
curatorResume: () => http.post('/skills/curator/resume'),
|
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. */
|
/** List recent curator run report ids. */
|
||||||
curatorReports: () => http.get('/skills/curator/reports'),
|
curatorReports: () => http.get('/skills/curator/reports'),
|
||||||
/** Read one curator run report (parsed run.json). */
|
/** Read one curator run report (parsed run.json). */
|
||||||
|
|||||||
@ -3827,10 +3827,20 @@ export default {
|
|||||||
reportScanned: 'Scanned',
|
reportScanned: 'Scanned',
|
||||||
reportDryRun: 'Preview',
|
reportDryRun: 'Preview',
|
||||||
reportTransitions: 'Transitions',
|
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',
|
activateSuccess: 'Skill curator activated',
|
||||||
deactivateSuccess: 'Back to preview-only mode',
|
deactivateSuccess: 'Back to preview-only mode',
|
||||||
pauseSuccess: 'Skill curator paused',
|
pauseSuccess: 'Skill curator paused',
|
||||||
resumeSuccess: 'Skill curator resumed',
|
resumeSuccess: 'Skill curator resumed',
|
||||||
|
consolidateSuccess: 'Consolidation enabled',
|
||||||
|
disableConsolidateSuccess: 'Consolidation disabled',
|
||||||
dryRunSuccess: 'Preview report generated',
|
dryRunSuccess: 'Preview report generated',
|
||||||
actionFailed: 'Action failed',
|
actionFailed: 'Action failed',
|
||||||
loadFailed: 'Failed to load skill curator status',
|
loadFailed: 'Failed to load skill curator status',
|
||||||
|
|||||||
@ -3919,10 +3919,20 @@ export default {
|
|||||||
reportScanned: '扫描',
|
reportScanned: '扫描',
|
||||||
reportDryRun: '预览',
|
reportDryRun: '预览',
|
||||||
reportTransitions: '状态变更',
|
reportTransitions: '状态变更',
|
||||||
|
reportConsolidations: '合并去重',
|
||||||
|
consolidateOn: '合并开',
|
||||||
|
consolidateOff: '合并关',
|
||||||
|
enableConsolidate: '开启合并',
|
||||||
|
disableConsolidate: '关闭合并',
|
||||||
|
consolidateHint: '合并去重会用一次 LLM 调用,把高度重复的自建技能合并成一个更通用的技能,被合并的技能将被归档(可恢复)。默认关闭。',
|
||||||
|
consolidateCreate: '新建',
|
||||||
|
consolidateEdit: '更新',
|
||||||
activateSuccess: '技能管家已激活',
|
activateSuccess: '技能管家已激活',
|
||||||
deactivateSuccess: '已退回仅预览模式',
|
deactivateSuccess: '已退回仅预览模式',
|
||||||
pauseSuccess: '技能管家已暂停',
|
pauseSuccess: '技能管家已暂停',
|
||||||
resumeSuccess: '技能管家已恢复',
|
resumeSuccess: '技能管家已恢复',
|
||||||
|
consolidateSuccess: '合并去重已开启',
|
||||||
|
disableConsolidateSuccess: '合并去重已关闭',
|
||||||
dryRunSuccess: '预览报告已生成',
|
dryRunSuccess: '预览报告已生成',
|
||||||
actionFailed: '操作失败',
|
actionFailed: '操作失败',
|
||||||
loadFailed: '加载技能管家状态失败',
|
loadFailed: '加载技能管家状态失败',
|
||||||
|
|||||||
@ -29,6 +29,9 @@
|
|||||||
<span v-if="status.control.paused" class="curator-pill pill-warn">
|
<span v-if="status.control.paused" class="curator-pill pill-warn">
|
||||||
{{ t('skillCurator.statePaused') }}
|
{{ t('skillCurator.statePaused') }}
|
||||||
</span>
|
</span>
|
||||||
|
<span class="curator-pill" :class="status.control.consolidate ? 'pill-on' : 'pill-muted'">
|
||||||
|
{{ status.control.consolidate ? t('skillCurator.consolidateOn') : t('skillCurator.consolidateOff') }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="curator-hint">
|
<p class="curator-hint">
|
||||||
{{ status.control.paused ? t('skillCurator.pausedHint')
|
{{ status.control.paused ? t('skillCurator.pausedHint')
|
||||||
@ -59,7 +62,12 @@
|
|||||||
class="btn-secondary" :disabled="busy"
|
class="btn-secondary" :disabled="busy"
|
||||||
@click="setPaused(false)"
|
@click="setPaused(false)"
|
||||||
>{{ t('skillCurator.resume') }}</button>
|
>{{ 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>
|
</div>
|
||||||
|
<p class="curator-hint">{{ t('skillCurator.consolidateHint') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Counts -->
|
<!-- Counts -->
|
||||||
@ -121,6 +129,16 @@
|
|||||||
<span class="report-meta">{{ tr.daysIdle }}d</span>
|
<span class="report-meta">{{ tr.daysIdle }}d</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -140,7 +158,7 @@ const { t } = useI18n()
|
|||||||
interface CuratorStatus {
|
interface CuratorStatus {
|
||||||
config: { enabled: boolean; scope: string; staleAfterDays: number; archiveAfterDays: number; cron: string }
|
config: { enabled: boolean; scope: string; staleAfterDays: number; archiveAfterDays: number; cron: string }
|
||||||
control: {
|
control: {
|
||||||
activated: boolean; paused: boolean
|
activated: boolean; paused: boolean; consolidate: boolean
|
||||||
lastObservedAt: string | null; lastDryRunAt: string | null
|
lastObservedAt: string | null; lastDryRunAt: string | null
|
||||||
lastRunAt: string | null; nextScheduledRun: string | null
|
lastRunAt: string | null; nextScheduledRun: string | null
|
||||||
}
|
}
|
||||||
@ -154,6 +172,7 @@ interface CuratorReport {
|
|||||||
planned?: { stale: number; archived: number; reactivated: number }
|
planned?: { stale: number; archived: number; reactivated: number }
|
||||||
applied?: { stale: number; archived: number; reactivated: number }
|
applied?: { stale: number; archived: number; reactivated: number }
|
||||||
transitions?: Array<{ name: string; from: string; to: string; daysIdle: 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)
|
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)
|
onMounted(load)
|
||||||
</script>
|
</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