diff --git a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java index ce3c0243..bbee1d22 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java @@ -895,6 +895,15 @@ public class SkillController { return R.ok(skillCuratorJob.status()); } + @Operation(summary = "开启/关闭 curator 合并去重 pass") + @PostMapping("/curator/consolidate") + @RequireWorkspaceRole("admin") + public R> curatorConsolidate( + @RequestParam(defaultValue = "true") boolean enabled) { + skillCuratorJob.setConsolidate(enabled); + return R.ok(skillCuratorJob.status()); + } + @Operation(summary = "curator 控制面状态") @GetMapping("/curator/status") @RequireWorkspaceRole("member") diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillConsolidationService.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillConsolidationService.java new file mode 100644 index 00000000..0df55269 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillConsolidationService.java @@ -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}. + * + *

The umbrella write is routed through {@link SkillManageTool} so it + * inherits the full security scan / validation pipeline; the absorbed skills + * are archived (not deleted) through {@link SkillLifecycleService} so they + * stay recoverable. The reviewer can only ever cause skills already in the + * curator's candidate set to be archived — names it invents are ignored. + * + * @author MateClaw Team + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SkillConsolidationService { + + private final SkillService skillService; + private final SkillManageTool skillManageTool; + private final SkillLifecycleService lifecycleService; + private final ModelConfigService modelConfigService; + private final AgentGraphBuilder agentGraphBuilder; + private final SkillLifecycleProperties properties; + private final ObjectMapper objectMapper; + + private static final int CATALOG_BODY_TRUNCATE_CHARS = 1500; + + /** + * Run a consolidation pass over the given candidate skills, recording + * outcomes into the sweep report. No-op when consolidation is disabled or + * there are too few candidates to bother. + */ + public void consolidate(List candidates, LocalDateTime now, + boolean dryRun, SkillCuratorReport.Builder report) { + if (!properties.isConsolidate()) { + return; + } + List withContent = candidates.stream() + .filter(s -> s.getSkillContent() != null && !s.getSkillContent().isBlank()) + .toList(); + if (withContent.size() < properties.getConsolidateMinSkills()) { + return; + } + + // Index by name so the reviewer can only ever archive in-scope skills. + Map byName = new LinkedHashMap<>(); + for (SkillEntity s : withContent) { + byName.put(s.getName(), s); + } + + JsonNode groups = askReviewer(withContent); + if (groups == null || !groups.isArray() || groups.isEmpty()) { + return; + } + + int applied = 0; + for (JsonNode group : groups) { + if (applied >= properties.getConsolidateMaxGroupsPerRun()) { + break; + } + if (applyGroup(group, byName, now, dryRun, report)) { + applied++; + } + } + } + + private boolean applyGroup(JsonNode group, Map byName, + LocalDateTime now, boolean dryRun, SkillCuratorReport.Builder report) { + String umbrellaName = group.path("umbrella_name").asText("").strip().toLowerCase(); + String umbrellaContent = group.path("umbrella_content").asText(null); + String reason = group.path("reason").asText(""); + if (umbrellaName.isBlank() || umbrellaContent == null || umbrellaContent.isBlank()) { + return false; + } + + // Restrict absorbed skills to the in-scope candidate set, excluding the + // umbrella itself — the reviewer cannot archive anything outside it. + List absorb = new ArrayList<>(); + for (JsonNode n : group.path("absorb")) { + String nm = n.asText("").strip().toLowerCase(); + if (!nm.isBlank() && !nm.equals(umbrellaName) && byName.containsKey(nm) && !absorb.contains(nm)) { + absorb.add(nm); + } + } + SkillEntity existingUmbrella = skillService.findByName(umbrellaName); + boolean willCreate = existingUmbrella == null; + // A real merge must touch at least two distinct skills: a brand-new + // umbrella needs >=2 absorbed; reusing an existing skill as the + // umbrella needs >=1 absorbed (the umbrella itself is the second). + boolean realMerge = willCreate ? absorb.size() >= 2 : !absorb.isEmpty(); + if (!realMerge) { + log.debug("[SkillConsolidate] Skipping group '{}' — not a real merge", umbrellaName); + return false; + } + + if (dryRun) { + report.consolidation(new SkillCuratorReport.ConsolidationRow( + umbrellaName, willCreate, absorb, false, reason)); + return true; + } + + // Stamp the umbrella with a source conversation from one absorbed skill + // so it stays curator-eligible under the AGENT_CREATED scope. + String lineageConv = absorb.stream() + .map(byName::get) + .map(SkillEntity::getSourceConversationId) + .filter(c -> c != null && !c.isBlank()) + .findFirst().orElse(null); + ToolContext ctx = toolContext(lineageConv); + + String act = willCreate ? "create" : "edit"; + String result = skillManageTool.skill_manage(act, umbrellaName, umbrellaContent, null, null, null, ctx); + boolean umbrellaOk = result != null + && !result.startsWith("Error") && !result.startsWith("Security scan BLOCKED"); + if (!umbrellaOk) { + log.debug("[SkillConsolidate] Umbrella {} '{}' rejected: {}", act, umbrellaName, result); + return false; + } + + // Archive the absorbed narrow skills (recoverable, never deleted). + for (String nm : absorb) { + SkillEntity victim = byName.get(nm); + if (victim == null) { + continue; + } + try { + lifecycleService.applyManual(victim, LifecycleTransition.TO_ARCHIVED, now, + "consolidated into " + umbrellaName); + } catch (Exception e) { + log.warn("[SkillConsolidate] Failed to archive absorbed skill '{}': {}", nm, e.getMessage()); + } + } + + log.info("[SkillConsolidate] {} umbrella '{}' absorbing {} — {}", act, umbrellaName, absorb, reason); + report.consolidation(new SkillCuratorReport.ConsolidationRow( + umbrellaName, willCreate, absorb, true, reason)); + return true; + } + + private JsonNode askReviewer(List skills) { + try { + String systemPrompt = PromptLoader.loadPrompt("skill/consolidate-system"); + String userPrompt = PromptLoader.loadPrompt("skill/consolidate-user") + .replace("{skills}", buildCatalog(skills, properties.getConsolidateCatalogCharBudget())); + ChatModel chatModel = buildChatModel(); + Prompt prompt = new Prompt(List.of( + new SystemMessage(systemPrompt), + new UserMessage(userPrompt))); + ChatResponse response = chatModel.call(prompt); + if (response == null || response.getResult() == null + || response.getResult().getOutput() == null) { + return null; + } + return parseJsonResponse(response.getResult().getOutput().getText()); + } catch (Exception e) { + log.warn("[SkillConsolidate] Reviewer call failed: {}", e.getMessage()); + return null; + } + } + + private String buildCatalog(List skills, int charBudget) { + StringBuilder sb = new StringBuilder(); + for (SkillEntity skill : skills) { + String entry = "### " + skill.getName() + "\n" + + (skill.getDescription() == null ? "" : skill.getDescription().strip() + "\n") + + truncate(skill.getSkillContent(), CATALOG_BODY_TRUNCATE_CHARS) + "\n\n"; + if (sb.length() + entry.length() > charBudget) { + sb.append("... (catalog truncated)\n"); + break; + } + sb.append(entry); + } + return sb.toString().strip(); + } + + private ToolContext toolContext(String sourceConversationId) { + ChatOrigin origin = new ChatOrigin(null, sourceConversationId, "", null, null, + null, null, false, null, null, null, null); + 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]"; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorJob.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorJob.java index 28620ae0..882e2895 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorJob.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorJob.java @@ -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 status() { Map config = new LinkedHashMap<>(); @@ -139,6 +152,7 @@ public class SkillCuratorJob { Map 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 mergeCandidates = loadCandidates().stream() + .filter(s -> !"archived".equals(s.getLifecycleState())) + .toList(); + consolidationService.consolidate(mergeCandidates, now, dryRun, report); + } + return reportStore.write(report.build()); } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorReport.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorReport.java index a2cb8b79..f03d5287 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorReport.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorReport.java @@ -38,6 +38,7 @@ public class SkillCuratorReport { private final List transitions; private final List blockedByBindings; private final List reconciliations; + private final List 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 absorbed, boolean applied, String reason) {} + public static Builder builder() { return new Builder(); } @@ -98,6 +109,7 @@ public class SkillCuratorReport { private final List transitions = new ArrayList<>(); private List blockedByBindings = new ArrayList<>(); private final List reconciliations = new ArrayList<>(); + private final List 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); } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorReportStore.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorReportStore.java index 1843d2c3..31f24f0f 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorReportStore.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillCuratorReportStore.java @@ -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()) { diff --git a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleProperties.java b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleProperties.java index 9ed64593..7c49755d 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleProperties.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/lifecycle/SkillLifecycleProperties.java @@ -42,4 +42,23 @@ public class SkillLifecycleProperties { /** Skills whose name starts with any of these prefixes are never touched. */ private List 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; } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/reflection/SkillReflectionAutoConfiguration.java b/mateclaw-server/src/main/java/vip/mate/skill/reflection/SkillReflectionAutoConfiguration.java new file mode 100644 index 00000000..060c0d78 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/reflection/SkillReflectionAutoConfiguration.java @@ -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 { +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/reflection/SkillReflectionProperties.java b/mateclaw-server/src/main/java/vip/mate/skill/reflection/SkillReflectionProperties.java new file mode 100644 index 00000000..5b5777a8 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/reflection/SkillReflectionProperties.java @@ -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; +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/reflection/SkillReflectionService.java b/mateclaw-server/src/main/java/vip/mate/skill/reflection/SkillReflectionService.java new file mode 100644 index 00000000..ae9efe50 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/reflection/SkillReflectionService.java @@ -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. + * + *

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 + * what to write, never how. + * + * @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 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 messages = conversationService.listMessages(conversationId); + if (messages == null || messages.isEmpty()) { + return false; + } + int maxReview = properties.getMaxMessages(); + List 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 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 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]"; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java b/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java index 69f17d40..4c662ccb 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java @@ -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"); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillManageTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillManageTool.java index d590ddbd..46bcbc2b 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillManageTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillManageTool.java @@ -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) { diff --git a/mateclaw-server/src/main/resources/prompts/skill/consolidate-system.txt b/mateclaw-server/src/main/resources/prompts/skill/consolidate-system.txt new file mode 100644 index 00000000..a8f54f4c --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/skill/consolidate-system.txt @@ -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":"","umbrella_content":"","absorb":["",""],"reason":""} + +"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. diff --git a/mateclaw-server/src/main/resources/prompts/skill/consolidate-user.txt b/mateclaw-server/src/main/resources/prompts/skill/consolidate-user.txt new file mode 100644 index 00000000..e9b083d8 --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/skill/consolidate-user.txt @@ -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. diff --git a/mateclaw-server/src/main/resources/prompts/skill/reflect-system.txt b/mateclaw-server/src/main/resources/prompts/skill/reflect-system.txt new file mode 100644 index 00000000..d81b4bdc --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/skill/reflect-system.txt @@ -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":"","reason":"","content":""} +{"action":"edit","name":"","reason":"","content":""} +{"action":"patch","name":"","reason":"","oldText":"","newText":""} + +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: [] diff --git a/mateclaw-server/src/main/resources/prompts/skill/reflect-user.txt b/mateclaw-server/src/main/resources/prompts/skill/reflect-user.txt new file mode 100644 index 00000000..40cea4f7 --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/skill/reflect-user.txt @@ -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. diff --git a/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillConsolidationServiceTest.java b/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillConsolidationServiceTest.java new file mode 100644 index 00000000..bcec7f67 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillConsolidationServiceTest.java @@ -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 candidates(int n) { + List 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 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 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())); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillCuratorJobTest.java b/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillCuratorJobTest.java index ba00a062..93dacba6 100644 --- a/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillCuratorJobTest.java +++ b/mateclaw-server/src/test/java/vip/mate/skill/lifecycle/SkillCuratorJobTest.java @@ -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) { diff --git a/mateclaw-server/src/test/java/vip/mate/skill/reflection/SkillReflectionServiceTest.java b/mateclaw-server/src/test/java/vip/mate/skill/reflection/SkillReflectionServiceTest.java new file mode 100644 index 00000000..102a6532 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/reflection/SkillReflectionServiceTest.java @@ -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 transcriptWithTurns(int turns) { + List 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"); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillManageToolWriteFileTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillManageToolWriteFileTest.java new file mode 100644 index 00000000..c7af0fbe --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillManageToolWriteFileTest.java @@ -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); + } +} diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 90bbf043..f1693ec1 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -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). */ diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index c93ba60a..c5ed6e98 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -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', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index b2de588f..85ff93fe 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -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: '加载技能管家状态失败', diff --git a/mateclaw-ui/src/views/Settings/SkillCurator/index.vue b/mateclaw-ui/src/views/Settings/SkillCurator/index.vue index 698f024e..aed01526 100644 --- a/mateclaw-ui/src/views/Settings/SkillCurator/index.vue +++ b/mateclaw-ui/src/views/Settings/SkillCurator/index.vue @@ -29,6 +29,9 @@ {{ t('skillCurator.statePaused') }} + + {{ status.control.consolidate ? t('skillCurator.consolidateOn') : t('skillCurator.consolidateOff') }} +

{{ status.control.paused ? t('skillCurator.pausedHint') @@ -59,7 +62,12 @@ class="btn-secondary" :disabled="busy" @click="setPaused(false)" >{{ t('skillCurator.resume') }} + +

{{ t('skillCurator.consolidateHint') }}

@@ -121,6 +129,16 @@ {{ tr.daysIdle }}d +
+
{{ t('skillCurator.reportConsolidations') }}
+
+ {{ c.umbrella }} + {{ c.umbrellaCreated ? t('skillCurator.consolidateCreate') : t('skillCurator.consolidateEdit') }} ⇐ {{ (c.absorbed || []).join(', ') }} + + {{ c.applied ? t('skillCurator.reportApplied') : t('skillCurator.reportDryRun') }} + +
+
@@ -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) diff --git a/qwen3-max-search.png b/qwen3-max-search.png new file mode 100644 index 00000000..7d2d3c27 Binary files /dev/null and b/qwen3-max-search.png differ