feat(skill): Agent-autonomous skill synthesis — create/edit/patch via @Tool with security scanning

This commit is contained in:
matevip 2026-04-16 17:10:59 +08:00
parent 7d8d16e458
commit a6e9a17208
14 changed files with 952 additions and 3 deletions

View File

@ -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 = "从对话历史合成 SkillRFC-023")
@PostMapping("/synthesize-from-conversation")
public R<Map<String, Object>> synthesizeFromConversation(@RequestBody Map<String, Object> 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 导出到工作区目录")

View File

@ -74,6 +74,16 @@ public class SkillEntity {
/** 标签(逗号分隔) */
private String tags;
/** RFC-023来源对话 IDAgent 自治合成时记录) */
private String sourceConversationId;
/**
* RFC-023安全扫描状态
* NULL = 旧数据或手动创建不受扫描约束PASSED = 扫描通过FAILED = 扫描拦截
* listEnabledSkills 过滤条件NULL PASSED 才加载
*/
private String securityScanStatus;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;

View File

@ -60,13 +60,27 @@ public class SkillService {
/**
* 获取已启用的技能列表Agent 运行时使用
* <p>
* RFC-023追加 security_scan_status 过滤FAILED skill 不加载
* NULL旧数据/手动创建 PASSED扫描通过都允许
*/
public List<SkillEntity> listEnabledSkills() {
return skillMapper.selectList(new LambdaQueryWrapper<SkillEntity>()
.eq(SkillEntity::getEnabled, true)
.and(w -> w.isNull(SkillEntity::getSecurityScanStatus)
.or().eq(SkillEntity::getSecurityScanStatus, "PASSED"))
.orderByAsc(SkillEntity::getName));
}
/**
* 按名称查找技能RFC-023SkillManageTool 重名检查用
*/
public SkillEntity findByName(String name) {
return skillMapper.selectOne(new LambdaQueryWrapper<SkillEntity>()
.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) {

View File

@ -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 {
}

View File

@ -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;
/** 合成用的模型 IDnull = 跟随系统默认模型) */
private String modelId;
}

View File

@ -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 的服务
* <p>
* {@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 合成结果包含 skillIdnamestatus
*/
public SynthesisResult synthesize(String conversationId, Long agentId) {
// 1. 读取对话历史
List<MessageEntity> messages = messageMapper.selectList(
new LambdaQueryWrapper<MessageEntity>()
.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<MessageEntity>()
.eq(MessageEntity::getConversationId, conversationId)
.eq(MessageEntity::getRole, "tool"));
return count != null ? count.intValue() : 0;
}
// ==================== 内部方法 ====================
/**
* 把对话历史压缩为 LLM 可消费的摘要纯规则不调 LLM
*/
private String condenseConversation(List<MessageEntity> 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);
}
}
}

View File

@ -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 合成建议器
* <p>
* 监听 {@link ConversationCompletedEvent}当对话消息数和工具调用数达到阈值时
* 通过 hook 事件通知前端"建议保存为 Skill"<b>不自动创建 skill</b>用户确认后
* 通过 {@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
) {}
}

View File

@ -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 管理工具
* <p>
* 对标 hermes-agent skill_manager_tool.py Agent 在对话中自主创建编辑
* 修补和删除 Skill每次写入前强制安全扫描失败则拒绝并返回原因
* <p>
* 系统 prompt 引导 Agent 使用此工具
* <blockquote>
* "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."
* </blockquote>
*
* @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);
}
}

View File

@ -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

View File

@ -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

View File

@ -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 标题
## 场景
什么情况下应该使用这个 skill1-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

View File

@ -0,0 +1,9 @@
## 对话历史
以下是一段 Agent 与用户的完整对话记录已压缩包含用户请求、Agent 推理、工具调用和结果:
{conversation}
---
请从上述对话中提取一个可复用的 SKILL.md。如果对话内容太简单或不值得保存为 skill请输出空内容。

View File

@ -208,6 +208,10 @@ export interface Skill {
builtin?: boolean
tags?: string
createTime: string
/** RFC-023: 来源对话 IDAI 合成时记录) */
sourceConversationId?: string
/** RFC-023: 安全扫描状态 (PASSED / FAILED / null) */
securityScanStatus?: string
}
/** 运行时解析状态(来自 /runtime/status */

View File

@ -70,7 +70,18 @@
<span class="runtime-badge" :class="getRuntimeBadgeClass(skill)">
{{ getRuntimeLabel(skill) }}
</span>
<!-- Security Badge -->
<!-- RFC-023: AI Synthesized Badge -->
<span v-if="skill.sourceConversationId" class="runtime-badge rt-synthesized" title="Auto-synthesized from conversation">
🤖 AI
</span>
<!-- Security Scan Status (RFC-023) -->
<span v-if="skill.securityScanStatus === 'FAILED'" class="runtime-badge rt-blocked">
🛡 Scan Failed
</span>
<span v-else-if="skill.securityScanStatus === 'PASSED'" class="runtime-badge rt-ready">
Scanned
</span>
<!-- Security Badge (runtime) -->
<span v-if="getSecurityBadge(skill)" class="runtime-badge" :class="getSecurityBadge(skill)?.cls">
{{ getSecurityBadge(skill)?.label }}
</span>
@ -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); }