From a6e9a172085433b6b4fb6eab3e851991a97dc925 Mon Sep 17 00:00:00 2001 From: matevip Date: Thu, 16 Apr 2026 17:10:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(skill):=20Agent-autonomous=20skill=20synth?= =?UTF-8?q?esis=20=E2=80=94=20create/edit/patch=20via=20@Tool=20with=20sec?= =?UTF-8?q?urity=20scanning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../skill/controller/SkillController.java | 34 ++ .../vip/mate/skill/model/SkillEntity.java | 10 + .../vip/mate/skill/service/SkillService.java | 27 +- .../SkillSynthesisAutoConfiguration.java | 12 + .../synthesis/SkillSynthesisProperties.java | 26 ++ .../synthesis/SkillSynthesisService.java | 284 ++++++++++++ .../synthesis/SkillSynthesisSuggestor.java | 75 ++++ .../mate/tool/builtin/SkillManageTool.java | 409 ++++++++++++++++++ .../db/migration/h2/V11__skill_synthesis.sql | 5 + .../migration/mysql/V11__skill_synthesis.sql | 5 + .../prompts/skill/synthesize-system.txt | 40 ++ .../prompts/skill/synthesize-user.txt | 9 + mateclaw-ui/src/types/index.ts | 4 + mateclaw-ui/src/views/SkillMarket.vue | 15 +- 14 files changed, 952 insertions(+), 3 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/skill/synthesis/SkillSynthesisAutoConfiguration.java create mode 100644 mateclaw-server/src/main/java/vip/mate/skill/synthesis/SkillSynthesisProperties.java create mode 100644 mateclaw-server/src/main/java/vip/mate/skill/synthesis/SkillSynthesisService.java create mode 100644 mateclaw-server/src/main/java/vip/mate/skill/synthesis/SkillSynthesisSuggestor.java create mode 100644 mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillManageTool.java create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V11__skill_synthesis.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V11__skill_synthesis.sql create mode 100644 mateclaw-server/src/main/resources/prompts/skill/synthesize-system.txt create mode 100644 mateclaw-server/src/main/resources/prompts/skill/synthesize-user.txt 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 05a63b3f..9cb24b4f 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 @@ -7,6 +7,7 @@ import org.springframework.web.bind.annotation.*; import vip.mate.common.result.R; import vip.mate.skill.model.SkillEntity; import vip.mate.skill.service.SkillService; +import vip.mate.skill.synthesis.SkillSynthesisService; import vip.mate.skill.runtime.SkillRuntimeService; import vip.mate.skill.runtime.model.ResolvedSkill; import vip.mate.skill.workspace.SkillWorkspaceManager; @@ -31,6 +32,7 @@ public class SkillController { private final SkillService skillService; private final SkillRuntimeService skillRuntimeService; private final SkillWorkspaceManager workspaceManager; + private final SkillSynthesisService synthesisService; @Operation(summary = "获取技能列表") @GetMapping @@ -129,6 +131,38 @@ public class SkillController { "resynced", resynced)); } + // ==================== Synthesis API (RFC-023) ==================== + + @Operation(summary = "从对话历史合成 Skill(RFC-023)") + @PostMapping("/synthesize-from-conversation") + public R> synthesizeFromConversation(@RequestBody Map body) { + String conversationId = (String) body.get("conversationId"); + Long agentId = body.get("agentId") != null ? Long.valueOf(body.get("agentId").toString()) : null; + if (conversationId == null || conversationId.isBlank()) { + return R.fail("conversationId is required"); + } + + SkillSynthesisService.SynthesisResult result = synthesisService.synthesize(conversationId, agentId); + + if (result.blocked()) { + return R.ok(Map.of( + "success", false, + "blocked", true, + "skillName", result.skillName() != null ? result.skillName() : "", + "error", result.error(), + "scanSummary", result.scanSummary() != null ? result.scanSummary() : "" + )); + } + if (!result.success()) { + return R.ok(Map.of("success", false, "error", result.error())); + } + return R.ok(Map.of( + "success", true, + "skillId", result.skillId(), + "skillName", result.skillName() + )); + } + // ==================== Workspace API ==================== @Operation(summary = "将 skill 导出到工作区目录") diff --git a/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java b/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java index ba242dd2..0528cb4a 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java @@ -74,6 +74,16 @@ public class SkillEntity { /** 标签(逗号分隔) */ private String tags; + /** RFC-023:来源对话 ID(Agent 自治合成时记录) */ + private String sourceConversationId; + + /** + * RFC-023:安全扫描状态。 + * NULL = 旧数据或手动创建(不受扫描约束),PASSED = 扫描通过,FAILED = 扫描拦截。 + * listEnabledSkills 过滤条件:NULL 或 PASSED 才加载。 + */ + private String securityScanStatus; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; 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 1a733ab4..a903155c 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 @@ -60,13 +60,27 @@ public class SkillService { /** * 获取已启用的技能列表(Agent 运行时使用) + *

+ * RFC-023:追加 security_scan_status 过滤——FAILED 的 skill 不加载。 + * NULL(旧数据/手动创建)和 PASSED(扫描通过)都允许。 */ public List listEnabledSkills() { return skillMapper.selectList(new LambdaQueryWrapper() .eq(SkillEntity::getEnabled, true) + .and(w -> w.isNull(SkillEntity::getSecurityScanStatus) + .or().eq(SkillEntity::getSecurityScanStatus, "PASSED")) .orderByAsc(SkillEntity::getName)); } + /** + * 按名称查找技能(RFC-023:SkillManageTool 重名检查用) + */ + public SkillEntity findByName(String name) { + return skillMapper.selectOne(new LambdaQueryWrapper() + .eq(SkillEntity::getName, name) + .last("LIMIT 1")); + } + /** * 按类型获取技能列表 */ @@ -256,9 +270,18 @@ public class SkillService { return ""; } - // --- 第一层:技能目录(始终注入,消耗很少的 token) --- + // --- 第零层:Skill 自治引导(RFC-023,对标 hermes-agent prompt_builder.py:164-171) --- StringBuilder catalog = new StringBuilder(); - catalog.append("\n\n## Available Skills\n"); + catalog.append("\n\n## Skill Management\n\n"); + catalog.append("After completing a complex task (5+ tool calls), fixing a tricky error, "); + catalog.append("or discovering a non-trivial workflow, save the approach as a skill using "); + catalog.append("`skill_manage(action='create')` so you can reuse it next time.\n\n"); + 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"); + + // --- 第一层:技能目录(始终注入,消耗很少的 token) --- + catalog.append("## Available Skills\n"); catalog.append("以下技能已启用,你可以在对话中根据用户需求灵活运用:\n\n"); for (SkillEntity skill : enabledSkills) { diff --git a/mateclaw-server/src/main/java/vip/mate/skill/synthesis/SkillSynthesisAutoConfiguration.java b/mateclaw-server/src/main/java/vip/mate/skill/synthesis/SkillSynthesisAutoConfiguration.java new file mode 100644 index 00000000..616b972b --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/synthesis/SkillSynthesisAutoConfiguration.java @@ -0,0 +1,12 @@ +package vip.mate.skill.synthesis; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +/** + * RFC-023: 自动 Skill 合成的配置注册 + */ +@Configuration +@EnableConfigurationProperties(SkillSynthesisProperties.class) +public class SkillSynthesisAutoConfiguration { +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/synthesis/SkillSynthesisProperties.java b/mateclaw-server/src/main/java/vip/mate/skill/synthesis/SkillSynthesisProperties.java new file mode 100644 index 00000000..19b1e09d --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/synthesis/SkillSynthesisProperties.java @@ -0,0 +1,26 @@ +package vip.mate.skill.synthesis; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * RFC-023: Skill 自动合成配置 + * + * @author MateClaw Team + */ +@Data +@ConfigurationProperties(prefix = "mate.skill.synthesis") +public class SkillSynthesisProperties { + + /** 是否启用后台合成建议(对话结束后推 toast) */ + private boolean suggestEnabled = true; + + /** 触发建议的最小对话消息数 */ + private int minMessageCount = 10; + + /** 触发建议的最小工具调用数(从 metadata 统计) */ + private int minToolCallCount = 5; + + /** 合成用的模型 ID(null = 跟随系统默认模型) */ + private String modelId; +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/synthesis/SkillSynthesisService.java b/mateclaw-server/src/main/java/vip/mate/skill/synthesis/SkillSynthesisService.java new file mode 100644 index 00000000..8baeaea1 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/synthesis/SkillSynthesisService.java @@ -0,0 +1,284 @@ +package vip.mate.skill.synthesis; + +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +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.prompt.Prompt; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.stereotype.Service; +import vip.mate.agent.AgentGraphBuilder; +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.runtime.SkillSecurityService; +import vip.mate.skill.runtime.SkillValidationResult; +import vip.mate.skill.service.SkillService; +import vip.mate.skill.workspace.SkillWorkspaceManager; +import vip.mate.workspace.conversation.model.MessageEntity; +import vip.mate.workspace.conversation.repository.MessageMapper; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * RFC-023: 从对话历史蒸馏 SKILL.md 的服务 + *

+ * 供 {@code POST /api/v1/skills/synthesize-from-conversation} 和前端"建议保存 Skill"流程调用。 + * 与 {@code SkillManageTool}(Agent 自治路径)互补——本服务是"用户主动触发"路径。 + * + * @author MateClaw Team + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SkillSynthesisService { + + private final MessageMapper messageMapper; + private final SkillService skillService; + private final SkillSecurityService securityService; + private final SkillWorkspaceManager workspaceManager; + private final ModelConfigService modelConfigService; + private final AgentGraphBuilder agentGraphBuilder; + private final SkillSynthesisProperties properties; + + private static final RetryTemplate NO_RETRY = RetryTemplate.builder().maxAttempts(1).build(); + + /** + * 从对话历史合成 Skill + * + * @param conversationId 源对话 ID + * @param agentId Agent ID(用于记录来源) + * @return 合成结果(包含 skillId、name、status) + */ + public SynthesisResult synthesize(String conversationId, Long agentId) { + // 1. 读取对话历史 + List messages = messageMapper.selectList( + new LambdaQueryWrapper() + .eq(MessageEntity::getConversationId, conversationId) + .orderByAsc(MessageEntity::getCreateTime)); + if (messages.isEmpty()) { + return SynthesisResult.failed("No messages found for conversation " + conversationId); + } + + // 2. 压缩对话为 LLM 输入(纯规则,不用 LLM) + String condensed = condenseConversation(messages); + if (condensed.length() < 100) { + return SynthesisResult.failed("Conversation too short to synthesize a meaningful skill"); + } + + // 3. 调 LLM 生成 SKILL.md + String skillMd; + try { + skillMd = callLlm(condensed); + } catch (Exception e) { + log.error("[SkillSynthesis] LLM call failed for conversation={}: {}", conversationId, e.getMessage(), e); + return SynthesisResult.failed("LLM call failed: " + e.getMessage()); + } + + if (skillMd == null || skillMd.isBlank()) { + return SynthesisResult.failed("LLM returned empty content"); + } + + // 4. 提取名称 + String name = extractFrontmatterValue(skillMd, "name"); + if (name == null || name.isBlank()) { + name = "auto-skill-" + System.currentTimeMillis(); + } + name = name.strip().toLowerCase().replaceAll("[^a-z0-9._-]", "-"); + + // 去重 + SkillEntity existing = skillService.findByName(name); + if (existing != null) { + name = name + "-" + (System.currentTimeMillis() % 10000); + } + + // 5. 安全扫描 + SkillValidationResult scanResult = securityService.scanContent(skillMd, name); + String scanStatus = scanResult.isBlocked() ? "FAILED" : "PASSED"; + + if (scanResult.isBlocked()) { + log.warn("[SkillSynthesis] Security scan BLOCKED synthesized skill '{}': {}", name, scanResult.getSummary()); + return SynthesisResult.blocked(name, scanResult.getSummary()); + } + + // 6. 保存 + try { + SkillEntity skill = new SkillEntity(); + skill.setName(name); + skill.setDescription(extractFrontmatterValue(skillMd, "description")); + skill.setSkillType("custom"); + skill.setSkillContent(skillMd); + skill.setEnabled(true); + skill.setBuiltin(false); + skill.setVersion(extractFrontmatterValue(skillMd, "version")); + skill.setSourceConversationId(conversationId); + skill.setSecurityScanStatus(scanStatus); + + skillService.createSkill(skill); + + try { + workspaceManager.exportToWorkspace(name, skillMd); + } catch (Exception e) { + log.warn("[SkillSynthesis] Workspace export failed for '{}': {}", name, e.getMessage()); + } + + log.info("[SkillSynthesis] Synthesized skill '{}' from conversation={}, agentId={}", name, conversationId, agentId); + return SynthesisResult.success(skill.getId(), name); + } catch (Exception e) { + log.error("[SkillSynthesis] Failed to save skill '{}': {}", name, e.getMessage(), e); + return SynthesisResult.failed("Save failed: " + e.getMessage()); + } + } + + /** + * 统计对话中的工具调用数(用于建议器的阈值判断) + */ + public int countToolCalls(String conversationId) { + Long count = messageMapper.selectCount( + new LambdaQueryWrapper() + .eq(MessageEntity::getConversationId, conversationId) + .eq(MessageEntity::getRole, "tool")); + return count != null ? count.intValue() : 0; + } + + // ==================== 内部方法 ==================== + + /** + * 把对话历史压缩为 LLM 可消费的摘要(纯规则,不调 LLM) + */ + private String condenseConversation(List messages) { + StringBuilder sb = new StringBuilder(); + int maxLen = 12000; // 控制在 ~3K tokens + + for (MessageEntity msg : messages) { + if (sb.length() > maxLen) { + sb.append("\n... (truncated, ").append(messages.size() - messages.indexOf(msg)).append(" messages remaining)"); + break; + } + + String role = msg.getRole(); + String content = msg.getContent(); + if (content == null || content.isBlank()) continue; + + switch (role) { + case "user" -> { + sb.append("\n### User:\n"); + sb.append(truncate(content, 500)); + } + case "assistant" -> { + sb.append("\n### Assistant:\n"); + sb.append(truncate(content, 800)); + } + case "tool" -> { + sb.append("\n### Tool [").append(msg.getToolName() != null ? msg.getToolName() : "unknown").append("]:\n"); + // 工具结果只保留前 300 字符(通常很长) + sb.append(truncate(content, 300)); + } + // system messages 跳过 + } + sb.append("\n"); + } + return sb.toString(); + } + + private String callLlm(String condensed) { + String systemPrompt = PromptLoader.loadPrompt("skill/synthesize-system"); + String userTemplate = PromptLoader.loadPrompt("skill/synthesize-user"); + String userPrompt = userTemplate.replace("{conversation}", condensed); + + 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; + } + String text = response.getResult().getOutput().getText(); + + // 剥离 markdown 代码块 + if (text != null) { + text = text.strip(); + if (text.startsWith("```")) { + int firstNewline = text.indexOf('\n'); + if (firstNewline > 0) text = text.substring(firstNewline + 1); + } + if (text.endsWith("```")) { + text = text.substring(0, text.length() - 3).strip(); + } + } + return text; + } + + private ChatModel buildChatModel() { + ModelConfigEntity model = null; + if (properties.getModelId() != null && !properties.getModelId().isBlank()) { + try { + model = modelConfigService.getById(Long.parseLong(properties.getModelId())); + } catch (Exception e) { + log.warn("[SkillSynthesis] Invalid modelId '{}', falling back to default", properties.getModelId()); + } + } + if (model == null) { + model = modelConfigService.getDefaultModel(); + } + return agentGraphBuilder.buildRuntimeChatModel(model, NO_RETRY); + } + + private String extractFrontmatterValue(String content, String key) { + if (content == null || !content.startsWith("---")) return null; + int endIdx = content.indexOf("---", 3); + if (endIdx < 0) return null; + String frontmatter = content.substring(3, endIdx); + for (String line : frontmatter.split("\n")) { + String trimmed = line.strip(); + if (trimmed.startsWith(key + ":")) { + String value = trimmed.substring(key.length() + 1).strip(); + if ((value.startsWith("\"") && value.endsWith("\"")) + || (value.startsWith("'") && value.endsWith("'"))) { + value = value.substring(1, value.length() - 1); + } + return value; + } + } + return null; + } + + private static String truncate(String s, int maxLen) { + if (s == null) return ""; + return s.length() <= maxLen ? s : s.substring(0, maxLen) + "..."; + } + + // ==================== 结果 DTO ==================== + + public record SynthesisResult( + boolean success, + boolean blocked, + Long skillId, + String skillName, + String error, + String scanSummary + ) { + public static SynthesisResult success(Long id, String name) { + return new SynthesisResult(true, false, id, name, null, null); + } + + public static SynthesisResult failed(String error) { + return new SynthesisResult(false, false, null, null, error, null); + } + + public static SynthesisResult blocked(String name, String scanSummary) { + return new SynthesisResult(false, true, null, name, "Security scan blocked", scanSummary); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/synthesis/SkillSynthesisSuggestor.java b/mateclaw-server/src/main/java/vip/mate/skill/synthesis/SkillSynthesisSuggestor.java new file mode 100644 index 00000000..e579c7ae --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/skill/synthesis/SkillSynthesisSuggestor.java @@ -0,0 +1,75 @@ +package vip.mate.skill.synthesis; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import vip.mate.memory.event.ConversationCompletedEvent; + +import java.util.Map; + +/** + * RFC-023: 后台 Skill 合成建议器 + *

+ * 监听 {@link ConversationCompletedEvent},当对话消息数和工具调用数达到阈值时, + * 通过 hook 事件通知前端"建议保存为 Skill"。不自动创建 skill——用户确认后 + * 通过 {@code POST /api/v1/skills/synthesize-from-conversation} 触发合成。 + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SkillSynthesisSuggestor { + + private final SkillSynthesisProperties properties; + private final SkillSynthesisService synthesisService; + private final ApplicationEventPublisher eventPublisher; + + @Async + @EventListener + public void onConversationCompleted(ConversationCompletedEvent event) { + if (!properties.isSuggestEnabled()) return; + + // 阈值检查 1: 消息数 + if (event.messageCount() < properties.getMinMessageCount()) return; + + // 阈值检查 2: 工具调用数(需查 DB) + int toolCallCount; + try { + toolCallCount = synthesisService.countToolCalls(event.conversationId()); + } catch (Exception e) { + log.debug("[SkillSuggest] Failed to count tool calls: {}", e.getMessage()); + return; + } + if (toolCallCount < properties.getMinToolCallCount()) return; + + log.info("[SkillSuggest] Conversation {} qualifies for skill synthesis suggestion " + + "(messages={}, toolCalls={})", + event.conversationId(), event.messageCount(), toolCallCount); + + // 发布一个 SkillSynthesisSuggestionEvent,供前端 SSE 或 hook 消费 + try { + eventPublisher.publishEvent(new SkillSynthesisSuggestionEvent( + event.agentId(), + event.conversationId(), + event.messageCount(), + toolCallCount + )); + } catch (Exception e) { + log.warn("[SkillSuggest] Failed to publish suggestion event: {}", e.getMessage()); + } + } + + /** + * 建议事件——前端监听后弹出 toast 提示用户"是否保存为 Skill" + */ + public record SkillSynthesisSuggestionEvent( + Long agentId, + String conversationId, + int messageCount, + int toolCallCount + ) {} +} 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 new file mode 100644 index 00000000..261c76f6 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillManageTool.java @@ -0,0 +1,409 @@ +package vip.mate.tool.builtin; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.stereotype.Component; +import vip.mate.skill.model.SkillEntity; +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.regex.Pattern; + +/** + * RFC-023: Agent 自治 Skill 管理工具 + *

+ * 对标 hermes-agent 的 skill_manager_tool.py,让 Agent 在对话中自主创建、编辑、 + * 修补和删除 Skill。每次写入前强制安全扫描,失败则拒绝并返回原因。 + *

+ * 系统 prompt 引导 Agent 使用此工具: + *

+ * "After completing a complex task (5+ tool calls), fixing a tricky error, + * or discovering a non-trivial workflow, save the approach as a skill using + * skill_manage so you can reuse it next time." + *
+ * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SkillManageTool { + + private final SkillService skillService; + private final SkillSecurityService securityService; + private final SkillWorkspaceManager workspaceManager; + + /** Skill 名称格式:小写字母/数字/连字符/下划线/点,首字符必须是字母或数字 */ + private static final Pattern NAME_PATTERN = Pattern.compile("^[a-z0-9][a-z0-9._-]{0,63}$"); + /** Skill 内容最大长度(~25K tokens) */ + private static final int MAX_CONTENT_CHARS = 100_000; + + @Tool(description = """ + Manage reusable skills: create, edit, patch, or delete skill procedures (SKILL.md format). + + Use this tool to save successful approaches, workflows, and solutions as reusable skills. + When to create a skill: + - After completing a complex task (5+ tool calls) + - After fixing a tricky error with a non-obvious solution + - After discovering a workflow worth remembering + + When to patch a skill: + - When using a skill and finding it outdated, incomplete, or wrong + - Don't wait to be asked — patch immediately + + Actions: + - create: Create a new skill with SKILL.md content (YAML frontmatter + markdown body) + - edit: Replace entire skill content (for major rewrites) + - patch: Find-and-replace a specific section (for small fixes) + - delete: Remove a skill + + SKILL.md format example: + --- + name: skill-name + description: One-line description of what this skill does + version: "1.0" + --- + # Skill Title + + ## When to Use + Describe the scenario... + + ## Steps + 1. First step with actual commands... + 2. Second step... + + ## Gotchas + - Known pitfalls... + + Security: Content is scanned for dangerous patterns before saving. Malicious content will be rejected. + """) + public String skill_manage( + @JsonProperty(required = true) + @JsonPropertyDescription("Action: create | edit | patch | delete") + String action, + + @JsonProperty(required = true) + @JsonPropertyDescription("Skill name (lowercase letters, digits, hyphens, e.g., 'spring-boot-scaffold')") + String name, + + @JsonProperty + @JsonPropertyDescription("SKILL.md full content (required for create/edit). YAML frontmatter + markdown body.") + String content, + + @JsonProperty + @JsonPropertyDescription("For patch action: the existing text to find and replace") + String oldText, + + @JsonProperty + @JsonPropertyDescription("For patch action: the new text to replace with") + String newText + ) { + if (action == null || action.isBlank()) { + return "Error: action is required (create | edit | patch | delete)"; + } + if (name == null || name.isBlank()) { + return "Error: name is required"; + } + + String normalizedName = name.strip().toLowerCase(); + if (!NAME_PATTERN.matcher(normalizedName).matches()) { + return "Error: invalid skill name '" + normalizedName + + "'. Must match: lowercase letters, digits, hyphens, dots (1-64 chars, start with letter/digit)"; + } + + return switch (action.strip().toLowerCase()) { + case "create" -> doCreate(normalizedName, content); + 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"; + }; + } + + // ==================== Create ==================== + + private String doCreate(String name, String content) { + if (content == null || content.isBlank()) { + return "Error: content is required for create action. Provide full SKILL.md content."; + } + 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 + "' already exists. Use action='edit' to update or action='patch' for small fixes."; + } + + // 安全扫描 + String scanError = runSecurityScan(content, name); + if (scanError != null) return scanError; + + // 创建 skill + try { + SkillEntity skill = new SkillEntity(); + skill.setName(name); + skill.setDescription(extractDescription(content)); + skill.setSkillType("custom"); + skill.setSkillContent(content); + skill.setEnabled(true); + skill.setBuiltin(false); + skill.setVersion(extractVersion(content)); + skill.setSecurityScanStatus("PASSED"); + + skillService.createSkill(skill); + + // 同步到 workspace 文件系统 + try { + workspaceManager.exportToWorkspace(name, content); + } catch (Exception e) { + log.warn("[SkillManage] Workspace export failed for '{}': {}", name, e.getMessage()); + } + + log.info("[SkillManage] Agent created skill: name={}, contentLen={}", name, content.length()); + return "Skill '" + name + "' created successfully (security scan: PASSED). " + + "It is now available in your skill list for future conversations."; + } catch (Exception e) { + log.error("[SkillManage] Failed to create skill '{}': {}", name, e.getMessage(), e); + return "Error creating skill: " + e.getMessage(); + } + } + + // ==================== Edit (full rewrite) ==================== + + private String doEdit(String name, String content) { + if (content == null || content.isBlank()) { + return "Error: content is required for edit action. Provide full replacement SKILL.md content."; + } + 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. Use action='create' to create it."; + } + if (Boolean.TRUE.equals(existing.getBuiltin())) { + return "Error: cannot edit builtin skill '" + name + "'."; + } + + // 安全扫描 + String scanError = runSecurityScan(content, name); + if (scanError != null) return scanError; + + try { + existing.setSkillContent(content); + existing.setDescription(extractDescription(content)); + existing.setVersion(extractVersion(content)); + existing.setSecurityScanStatus("PASSED"); + skillService.updateSkill(existing); + + try { + workspaceManager.exportToWorkspace(name, content); + } catch (Exception e) { + log.warn("[SkillManage] Workspace export failed for '{}': {}", name, e.getMessage()); + } + + log.info("[SkillManage] Agent edited skill: name={}, contentLen={}", name, content.length()); + return "Skill '" + name + "' updated successfully (security scan: PASSED)."; + } catch (Exception e) { + log.error("[SkillManage] Failed to edit skill '{}': {}", name, e.getMessage(), e); + return "Error editing skill: " + e.getMessage(); + } + } + + // ==================== Patch (find-and-replace) ==================== + + private String doPatch(String name, String oldText, String newText) { + if (oldText == null || oldText.isBlank()) { + return "Error: oldText is required for patch action."; + } + if (newText == null) { + return "Error: newText is required for patch action (use empty string to delete a section)."; + } + + SkillEntity existing = skillService.findByName(name); + if (existing == null) { + return "Error: skill '" + name + "' not found."; + } + if (Boolean.TRUE.equals(existing.getBuiltin())) { + return "Error: cannot patch builtin skill '" + name + "'."; + } + + String currentContent = existing.getSkillContent(); + if (currentContent == null || currentContent.isBlank()) { + return "Error: skill '" + name + "' has no content to patch."; + } + + // 精确匹配 + String patchedContent; + if (currentContent.contains(oldText)) { + patchedContent = currentContent.replace(oldText, newText); + } else { + // 宽松匹配:归一化空白后重试 + String normalizedCurrent = normalizeWhitespace(currentContent); + String normalizedOld = normalizeWhitespace(oldText); + if (normalizedCurrent.contains(normalizedOld)) { + // 找到原始位置(用归一化版本定位,然后在原文中做替换) + int normIdx = normalizedCurrent.indexOf(normalizedOld); + // 回映射到原始文本(近似:找最近的原始位置) + int approxStart = findApproximatePosition(currentContent, oldText); + if (approxStart >= 0) { + int approxEnd = approxStart + oldText.length(); + patchedContent = currentContent.substring(0, approxStart) + newText + + currentContent.substring(Math.min(approxEnd, currentContent.length())); + } else { + return "Error: could not locate oldText in skill content (fuzzy match found but position mapping failed). " + + "Try using action='edit' with full content instead."; + } + } else { + return "Error: oldText not found in skill '" + name + "'. Check for whitespace differences. " + + "Tip: use action='edit' to replace entire content if patch is too tricky."; + } + } + + if (patchedContent.length() > MAX_CONTENT_CHARS) { + return "Error: patched content too large (" + patchedContent.length() + " chars, max " + MAX_CONTENT_CHARS + ")"; + } + + // 安全扫描 + String scanError = runSecurityScan(patchedContent, name); + if (scanError != null) return scanError; + + try { + existing.setSkillContent(patchedContent); + existing.setDescription(extractDescription(patchedContent)); + existing.setSecurityScanStatus("PASSED"); + skillService.updateSkill(existing); + + try { + workspaceManager.exportToWorkspace(name, patchedContent); + } catch (Exception e) { + log.warn("[SkillManage] Workspace export failed for '{}': {}", name, e.getMessage()); + } + + log.info("[SkillManage] Agent patched skill: name={}", name); + return "Skill '" + name + "' patched successfully (security scan: PASSED)."; + } catch (Exception e) { + log.error("[SkillManage] Failed to patch skill '{}': {}", name, e.getMessage(), e); + return "Error patching skill: " + e.getMessage(); + } + } + + // ==================== Delete ==================== + + private String doDelete(String name) { + SkillEntity existing = skillService.findByName(name); + if (existing == null) { + return "Error: skill '" + name + "' not found."; + } + if (Boolean.TRUE.equals(existing.getBuiltin())) { + return "Error: cannot delete builtin skill '" + name + "'."; + } + + try { + skillService.deleteSkill(existing.getId()); + log.info("[SkillManage] Agent deleted skill: name={}", name); + return "Skill '" + name + "' deleted."; + } catch (Exception e) { + log.error("[SkillManage] Failed to delete skill '{}': {}", name, e.getMessage(), e); + return "Error deleting skill: " + e.getMessage(); + } + } + + // ==================== Helpers ==================== + + /** + * 运行安全扫描。通过返回 null,拒绝返回错误信息字符串。 + */ + private String runSecurityScan(String content, String name) { + try { + SkillValidationResult result = securityService.scanContent(content, name); + if (result.isBlocked()) { + log.warn("[SkillManage] Security scan BLOCKED skill '{}': {}", name, result.getSummary()); + StringBuilder sb = new StringBuilder(); + sb.append("Security scan BLOCKED: skill content contains dangerous patterns.\n"); + for (SkillValidationResult.Finding f : result.getFindings()) { + if (f.getSeverity().isBlockLevel()) { + sb.append("- [").append(f.getSeverity()).append("] ").append(f.getTitle()); + if (f.getRemediation() != null) { + sb.append(" → Fix: ").append(f.getRemediation()); + } + sb.append("\n"); + } + } + sb.append("Please fix the issues and try again."); + return sb.toString(); + } + // 有 warning 但不 block 的情况,记录日志但允许通过 + if (!result.getWarnings().isEmpty()) { + log.info("[SkillManage] Security scan passed with {} warnings for skill '{}'", + result.getWarnings().size(), name); + } + return null; // 通过 + } catch (Exception e) { + log.error("[SkillManage] Security scan failed for '{}': {}", name, e.getMessage(), e); + return "Error: security scan failed (" + e.getMessage() + "). Skill not saved."; + } + } + + /** 从 YAML frontmatter 提取 description */ + private String extractDescription(String content) { + String fm = extractFrontmatterValue(content, "description"); + return fm != null ? fm : ""; + } + + /** 从 YAML frontmatter 提取 version */ + private String extractVersion(String content) { + String v = extractFrontmatterValue(content, "version"); + return v != null ? v : "1.0"; + } + + /** + * 简单提取 YAML frontmatter 中的值(不引入 YAML 库依赖)。 + * 支持格式:{@code key: value} 和 {@code key: "value"} + */ + private String extractFrontmatterValue(String content, String key) { + if (content == null || !content.startsWith("---")) return null; + int endIdx = content.indexOf("---", 3); + if (endIdx < 0) return null; + String frontmatter = content.substring(3, endIdx); + for (String line : frontmatter.split("\n")) { + String trimmed = line.strip(); + if (trimmed.startsWith(key + ":")) { + String value = trimmed.substring(key.length() + 1).strip(); + // 去引号 + if ((value.startsWith("\"") && value.endsWith("\"")) + || (value.startsWith("'") && value.endsWith("'"))) { + value = value.substring(1, value.length() - 1); + } + return value; + } + } + return null; + } + + /** 空白归一化(连续空白 → 单空格,trim) */ + private String normalizeWhitespace(String text) { + return text.replaceAll("\\s+", " ").strip(); + } + + /** 近似定位 oldText 在 content 中的位置(容忍空白差异) */ + private int findApproximatePosition(String content, String oldText) { + // 按行首几个非空白词匹配 + String[] lines = oldText.split("\n"); + if (lines.length == 0) return -1; + String firstLine = lines[0].strip(); + if (firstLine.isBlank() && lines.length > 1) firstLine = lines[1].strip(); + if (firstLine.isBlank()) return -1; + // 取前 30 字符作为锚点 + String anchor = firstLine.substring(0, Math.min(30, firstLine.length())); + return content.indexOf(anchor); + } +} diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V11__skill_synthesis.sql b/mateclaw-server/src/main/resources/db/migration/h2/V11__skill_synthesis.sql new file mode 100644 index 00000000..a6e7e58f --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V11__skill_synthesis.sql @@ -0,0 +1,5 @@ +-- V11: Auto Skill Synthesis (RFC-023) +-- Agent 自治创建 skill 后记录来源对话和安全扫描状态 +ALTER TABLE mate_skill ADD COLUMN IF NOT EXISTS source_conversation_id VARCHAR(64) DEFAULT NULL; +ALTER TABLE mate_skill ADD COLUMN IF NOT EXISTS security_scan_status VARCHAR(16) DEFAULT NULL; +-- security_scan_status: NULL(旧数据/手动创建) / PASSED / FAILED diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V11__skill_synthesis.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V11__skill_synthesis.sql new file mode 100644 index 00000000..a6e7e58f --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V11__skill_synthesis.sql @@ -0,0 +1,5 @@ +-- V11: Auto Skill Synthesis (RFC-023) +-- Agent 自治创建 skill 后记录来源对话和安全扫描状态 +ALTER TABLE mate_skill ADD COLUMN IF NOT EXISTS source_conversation_id VARCHAR(64) DEFAULT NULL; +ALTER TABLE mate_skill ADD COLUMN IF NOT EXISTS security_scan_status VARCHAR(16) DEFAULT NULL; +-- security_scan_status: NULL(旧数据/手动创建) / PASSED / FAILED diff --git a/mateclaw-server/src/main/resources/prompts/skill/synthesize-system.txt b/mateclaw-server/src/main/resources/prompts/skill/synthesize-system.txt new file mode 100644 index 00000000..feb1b7ce --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/skill/synthesize-system.txt @@ -0,0 +1,40 @@ +你是 MateClaw 的 Skill 工程师。你的任务是从一段 Agent 对话历史中提取出一个**可复用的操作指南**(SKILL.md 格式)。 + +## 输出格式 + +严格输出 SKILL.md 格式(YAML frontmatter + Markdown 正文),不要包含 markdown 代码块标记: + +--- +name: skill-name-lowercase +description: 一句话描述这个 skill 解决什么问题 +version: "1.0" +--- +# Skill 标题 + +## 场景 +什么情况下应该使用这个 skill(1-2 句话) + +## 前置条件 +- 需要的环境/工具/权限(如果有的话) + +## 步骤 +1. 第一步(包含实际命令,不要虚构) +2. 第二步 +3. ... + +## 注意事项 +- 已知的坑或边界条件 +- 常见错误和解决方法 + +## 相关技能 +- [[other-skill-name]](如果知道的话) + +## 核心规则 + +1. **只基于对话中实际执行过的命令和步骤**——不要虚构未出现的命令 +2. **不要包含敏感信息**——API key、密码、绝对路径、内网地址等必须去除或用占位符替代 +3. **name 必须是小写字母 + 数字 + 连字符**,简洁明了(如 `spring-boot-scaffold`、`docker-debug`) +4. **description 不超过 100 字** +5. **步骤要具体可操作**——读者看完应该能直接执行,而不是"请根据情况调整" +6. **如果对话中尝试了多种方法,只保留最终成功的路径**——失败的尝试放到"注意事项"里 +7. **如果对话太简单(< 3 个有意义的步骤),返回空内容**——不要强行产出低质量 skill diff --git a/mateclaw-server/src/main/resources/prompts/skill/synthesize-user.txt b/mateclaw-server/src/main/resources/prompts/skill/synthesize-user.txt new file mode 100644 index 00000000..6d38abdb --- /dev/null +++ b/mateclaw-server/src/main/resources/prompts/skill/synthesize-user.txt @@ -0,0 +1,9 @@ +## 对话历史 + +以下是一段 Agent 与用户的完整对话记录(已压缩),包含用户请求、Agent 推理、工具调用和结果: + +{conversation} + +--- + +请从上述对话中提取一个可复用的 SKILL.md。如果对话内容太简单或不值得保存为 skill,请输出空内容。 diff --git a/mateclaw-ui/src/types/index.ts b/mateclaw-ui/src/types/index.ts index 30be7604..0f0a3104 100644 --- a/mateclaw-ui/src/types/index.ts +++ b/mateclaw-ui/src/types/index.ts @@ -208,6 +208,10 @@ export interface Skill { builtin?: boolean tags?: string createTime: string + /** RFC-023: 来源对话 ID(AI 合成时记录) */ + sourceConversationId?: string + /** RFC-023: 安全扫描状态 (PASSED / FAILED / null) */ + securityScanStatus?: string } /** 运行时解析状态(来自 /runtime/status) */ diff --git a/mateclaw-ui/src/views/SkillMarket.vue b/mateclaw-ui/src/views/SkillMarket.vue index 124824e1..b6ce074d 100644 --- a/mateclaw-ui/src/views/SkillMarket.vue +++ b/mateclaw-ui/src/views/SkillMarket.vue @@ -70,7 +70,18 @@ {{ getRuntimeLabel(skill) }} - + + + 🤖 AI + + + + 🛡️ Scan Failed + + + ✓ Scanned + + {{ getSecurityBadge(skill)?.label }} @@ -566,6 +577,8 @@ function getSkillTypeLabel(type: string) { .rt-fallback { background: var(--mc-primary-bg); color: var(--mc-primary-hover); } .rt-error { background: var(--mc-danger-bg); color: var(--mc-danger); } .rt-blocked { background: var(--mc-danger-bg); color: var(--mc-danger); font-weight: 600; } +.rt-synthesized { background: #f0f0ff; color: #6366f1; } +:root.dark .rt-synthesized { background: rgba(99, 102, 241, 0.15); color: #818cf8; } .rt-sec-warning { background: var(--mc-primary-bg); color: var(--mc-primary-hover); } .rt-deps-missing { background: var(--mc-primary-bg); color: var(--mc-primary-hover); } .rt-disabled { background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); }