mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(skill): per-skill LESSONS.md + self-evolution v1
This commit is contained in:
parent
d927521d51
commit
359600c77f
@ -0,0 +1,138 @@
|
||||
package vip.mate.memory.tool;
|
||||
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.annotation.ToolParam;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.memory.event.MemoryWriteEvent;
|
||||
import vip.mate.workspace.document.model.WorkspaceFileEntity;
|
||||
import vip.mate.workspace.document.WorkspaceFileService;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
/**
|
||||
* RFC-090 §11.3.1 — universal {@code remember()} tool.
|
||||
*
|
||||
* <p>{@link StructuredMemoryTool} forces a typed schema (user/feedback/
|
||||
* project/reference). That's the right primitive for stable knowledge,
|
||||
* but skills also produce free-form lessons that don't fit those
|
||||
* buckets. This tool gives the agent a single, parameter-light way to
|
||||
* leave a note for the next conversation.
|
||||
*
|
||||
* <p>Storage path: appends to {@code MEMORY.md} under a
|
||||
* {@code ## Recent Lessons} section so the next dream pass can
|
||||
* consolidate it. {@link MemoryWriteEvent} is published on success so
|
||||
* the existing SOUL summarizer K-counter advances.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class UniversalMemoryTool {
|
||||
|
||||
private static final String MEMORY_FILENAME = "MEMORY.md";
|
||||
private static final String LESSONS_HEADER = "## Recent Lessons";
|
||||
private static final DateTimeFormatter ENTRY_TS = DateTimeFormatter
|
||||
.ofPattern("yyyy-MM-dd HH:mm");
|
||||
|
||||
private final WorkspaceFileService workspaceFileService;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
@Tool(description = """
|
||||
将一条自由形式的经验或洞察追加到 Agent 的长期记忆 (MEMORY.md)。
|
||||
适用于不属于结构化 4 类 (user/feedback/project/reference) 的自由笔记。
|
||||
内容会被下一次 Dream consolidation 整合到 SOUL.md 或事实区。
|
||||
如果你需要记录的是结构化条目,优先用 remember_structured。
|
||||
""")
|
||||
public String remember(
|
||||
@ToolParam(description = "当前 Agent 的 ID") Long agentId,
|
||||
@ToolParam(description = "要记住的内容(自由形式)") String content,
|
||||
@ToolParam(description = "可选:来源上下文(skill 名 / conversation id)", required = false) String source) {
|
||||
|
||||
if (agentId == null) return error("agentId 不能为空");
|
||||
if (content == null || content.isBlank()) return error("content 不能为空");
|
||||
|
||||
try {
|
||||
WorkspaceFileEntity existing = workspaceFileService.getFile(agentId, MEMORY_FILENAME);
|
||||
String existingContent = existing != null && existing.getContent() != null
|
||||
? existing.getContent() : "";
|
||||
String updated = appendLesson(existingContent, content, source);
|
||||
workspaceFileService.saveFile(agentId, MEMORY_FILENAME, updated);
|
||||
|
||||
// RFC-090 §14.3 — universal remember() targets MEMORY.md (the
|
||||
// canonical file), so this IS a MemoryWriteEvent. Skill-local
|
||||
// lessons go through SkillLessonWrittenEvent instead and do
|
||||
// NOT touch this path.
|
||||
eventPublisher.publishEvent(new MemoryWriteEvent(agentId, MEMORY_FILENAME,
|
||||
"remember", content));
|
||||
|
||||
JSONObject result = new JSONObject();
|
||||
result.set("success", true);
|
||||
result.set("file", MEMORY_FILENAME);
|
||||
result.set("message", "已记入 MEMORY.md,将在下次 Dream consolidation 时整合");
|
||||
return JSONUtil.toJsonPrettyStr(result);
|
||||
} catch (Exception e) {
|
||||
log.warn("[UniversalMemoryTool] remember failed: {}", e.getMessage());
|
||||
return error("记忆写入失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append the lesson under {@value LESSONS_HEADER}, preserving any
|
||||
* existing MEMORY.md content. Creates the section header on first
|
||||
* write.
|
||||
*/
|
||||
static String appendLesson(String existing, String content, String source) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
String ts = LocalDateTime.now().format(ENTRY_TS);
|
||||
String entry = "- " + ts
|
||||
+ (source != null && !source.isBlank() ? " (" + source.trim() + ")" : "")
|
||||
+ ": " + content.trim();
|
||||
|
||||
if (existing == null || existing.isBlank()) {
|
||||
sb.append(LESSONS_HEADER).append("\n").append(entry).append("\n");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
int headerIdx = existing.indexOf(LESSONS_HEADER);
|
||||
if (headerIdx < 0) {
|
||||
sb.append(existing);
|
||||
if (!existing.endsWith("\n")) sb.append("\n");
|
||||
sb.append("\n").append(LESSONS_HEADER).append("\n").append(entry).append("\n");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
// Insert at the end of the Recent Lessons section (before the
|
||||
// next ##-level heading or EOF).
|
||||
int sectionStart = headerIdx + LESSONS_HEADER.length();
|
||||
int nextHeading = findNextHeading(existing, sectionStart);
|
||||
if (nextHeading < 0) {
|
||||
sb.append(existing);
|
||||
if (!existing.endsWith("\n")) sb.append("\n");
|
||||
sb.append(entry).append("\n");
|
||||
} else {
|
||||
sb.append(existing, 0, nextHeading);
|
||||
if (!existing.substring(0, nextHeading).endsWith("\n")) sb.append("\n");
|
||||
sb.append(entry).append("\n\n");
|
||||
sb.append(existing, nextHeading, existing.length());
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static int findNextHeading(String content, int from) {
|
||||
// Match a line starting with "## " (any heading level >= 2).
|
||||
int idx = content.indexOf("\n## ", from);
|
||||
return idx < 0 ? -1 : idx + 1; // position of '#' itself
|
||||
}
|
||||
|
||||
private static String error(String msg) {
|
||||
JSONObject e = new JSONObject();
|
||||
e.set("success", false);
|
||||
e.set("error", msg);
|
||||
return JSONUtil.toJsonPrettyStr(e);
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import vip.mate.common.result.R;
|
||||
import vip.mate.skill.lessons.SkillLessonsService;
|
||||
import vip.mate.skill.manifest.SkillManifest;
|
||||
import vip.mate.skill.model.SkillEntity;
|
||||
import vip.mate.skill.runtime.SkillDependencyChecker;
|
||||
@ -39,6 +40,7 @@ public class SkillController {
|
||||
private final SkillWorkspaceManager workspaceManager;
|
||||
private final SkillSynthesisService synthesisService;
|
||||
private final SkillDependencyChecker dependencyChecker;
|
||||
private final SkillLessonsService lessonsService;
|
||||
|
||||
@Operation(summary = "获取技能分页列表(RFC-042 §2.1)")
|
||||
@GetMapping
|
||||
@ -209,6 +211,40 @@ public class SkillController {
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== Lessons API (RFC-090 §7 + §11.4) ====================
|
||||
|
||||
/**
|
||||
* Read the per-skill {@code LESSONS.md} body for the detail drawer.
|
||||
* Returns {@code entries: []} when the file is missing.
|
||||
*/
|
||||
@Operation(summary = "Read per-skill LESSONS.md (RFC-090 §11.4)")
|
||||
@GetMapping("/{id}/lessons")
|
||||
public R<Map<String, Object>> getLessons(@PathVariable Long id) {
|
||||
ResolvedSkill resolved = skillRuntimeService.resolveAllSkillsStatus().stream()
|
||||
.filter(r -> r != null && id.equals(r.getId()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (resolved == null) return R.fail("Skill not found: " + id);
|
||||
String body = lessonsService.readLessons(resolved);
|
||||
return R.ok(Map.of(
|
||||
"skillId", id,
|
||||
"skillName", resolved.getName(),
|
||||
"raw", body == null ? "" : body
|
||||
));
|
||||
}
|
||||
|
||||
@Operation(summary = "Clear all lessons for a skill (RFC-090 §11.4)")
|
||||
@PostMapping("/{id}/lessons/clear")
|
||||
public R<Map<String, Object>> clearLessons(@PathVariable Long id) {
|
||||
ResolvedSkill resolved = skillRuntimeService.resolveAllSkillsStatus().stream()
|
||||
.filter(r -> r != null && id.equals(r.getId()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (resolved == null) return R.fail("Skill not found: " + id);
|
||||
boolean cleared = lessonsService.clearLessons(resolved);
|
||||
return R.ok(Map.of("cleared", cleared));
|
||||
}
|
||||
|
||||
// ==================== Synthesis API (RFC-023) ====================
|
||||
|
||||
@Operation(summary = "从对话历史合成 Skill(RFC-023)")
|
||||
|
||||
@ -0,0 +1,271 @@
|
||||
package vip.mate.skill.lessons;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.skill.lessons.event.SkillLessonWrittenEvent;
|
||||
import vip.mate.skill.runtime.model.ResolvedSkill;
|
||||
import vip.mate.skill.workspace.SkillWorkspaceManager;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* RFC-090 §11.4 / §14.3 — per-skill {@code LESSONS.md} read & write.
|
||||
*
|
||||
* <p>SKILL.md is a deployment artifact and must remain read-only at
|
||||
* runtime. Lessons captured by the skill itself land in a sibling file
|
||||
* {@code LESSONS.md} so the system prompt enhancement layer can append
|
||||
* recent experience without rewriting the manifest.
|
||||
*
|
||||
* <p>Why a dedicated file rather than {@code MEMORY.md}:
|
||||
* <ul>
|
||||
* <li>Lessons are scoped to <i>this</i> skill — the agent must not
|
||||
* leak skill-specific tactics into another skill's context.</li>
|
||||
* <li>Agents share canonical memory, but a single skill may belong
|
||||
* to multiple agents; each agent's MEMORY.md is global.</li>
|
||||
* <li>Per §14.3, mixing lessons into {@code MemoryWriteEvent} would
|
||||
* inflate the SOUL summarizer's recompute counter.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Storage format (markdown, append-only, FIFO truncated):
|
||||
* <pre>
|
||||
* # Lessons learned for {skill-id}
|
||||
*
|
||||
* ## 2026-04-30 14:23 (conversation: 7a3b...)
|
||||
* Lesson body
|
||||
* Source: {skill-id} turn #N
|
||||
*
|
||||
* ## 2026-04-29 09:12 (conversation: 9c4d...)
|
||||
* ...
|
||||
* </pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SkillLessonsService {
|
||||
|
||||
/** Cap entries to keep LESSONS.md from ballooning (§10.1 risk 3). */
|
||||
private static final int DEFAULT_MAX_ENTRIES = 50;
|
||||
|
||||
private static final DateTimeFormatter HEADING_TS = DateTimeFormatter
|
||||
.ofPattern("yyyy-MM-dd HH:mm")
|
||||
.withLocale(Locale.ROOT);
|
||||
|
||||
private final SkillWorkspaceManager workspaceManager;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
/**
|
||||
* Append a lesson to {@code LESSONS.md}.
|
||||
*
|
||||
* <p>Side effects:
|
||||
* <ol>
|
||||
* <li>Creates the file if it doesn't exist (with the canonical
|
||||
* header).</li>
|
||||
* <li>Appends a new {@code ## yyyy-MM-dd HH:mm (conversation: id)}
|
||||
* section with the lesson body.</li>
|
||||
* <li>If total entries exceed {@code maxEntries}, drops the oldest
|
||||
* sections (FIFO truncation).</li>
|
||||
* <li>Publishes {@link SkillLessonWrittenEvent} on success.</li>
|
||||
* </ol>
|
||||
*
|
||||
* @param resolved the resolved skill (must have a workspace
|
||||
* directory for the write to succeed; database-
|
||||
* only skills are returned as a no-op)
|
||||
* @param agentId agent that produced the lesson, may be null
|
||||
* @param conversationId conversation context, may be null
|
||||
* @param content lesson text — caller's responsibility to
|
||||
* sanitize / truncate
|
||||
* @param maxEntries upper bound on entries; non-positive falls
|
||||
* back to {@link #DEFAULT_MAX_ENTRIES}
|
||||
* @return new lesson id (UUID) when persisted, null on no-op
|
||||
*/
|
||||
public String recordLesson(ResolvedSkill resolved, Long agentId,
|
||||
String conversationId, String content,
|
||||
int maxEntries) {
|
||||
if (resolved == null || resolved.getName() == null) {
|
||||
log.debug("recordLesson: missing skill identity, skipping");
|
||||
return null;
|
||||
}
|
||||
if (content == null || content.isBlank()) {
|
||||
log.debug("recordLesson: empty content for skill '{}', skipping", resolved.getName());
|
||||
return null;
|
||||
}
|
||||
Path workspace = resolveWorkspace(resolved);
|
||||
if (workspace == null) {
|
||||
log.debug("recordLesson: no workspace directory for skill '{}', skipping",
|
||||
resolved.getName());
|
||||
return null;
|
||||
}
|
||||
|
||||
Path lessonsFile = workspace.resolve("LESSONS.md");
|
||||
int cap = maxEntries > 0 ? maxEntries : DEFAULT_MAX_ENTRIES;
|
||||
String lessonId = UUID.randomUUID().toString();
|
||||
|
||||
try {
|
||||
ensureFileWithHeader(lessonsFile, resolved.getName());
|
||||
String section = buildSection(conversationId, content);
|
||||
Files.writeString(lessonsFile, section,
|
||||
StandardCharsets.UTF_8, StandardOpenOption.APPEND);
|
||||
enforceCap(lessonsFile, resolved.getName(), cap);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to write LESSONS.md for skill '{}': {}",
|
||||
resolved.getName(), e.getMessage());
|
||||
return null;
|
||||
}
|
||||
|
||||
eventPublisher.publishEvent(new SkillLessonWrittenEvent(
|
||||
agentId,
|
||||
resolved.getId(),
|
||||
resolved.getName(),
|
||||
conversationId,
|
||||
content));
|
||||
return lessonId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read raw {@code LESSONS.md} contents. Returns null when the file
|
||||
* is missing — callers should treat as "no lessons yet" without
|
||||
* branching on exception.
|
||||
*/
|
||||
public String readLessons(ResolvedSkill resolved) {
|
||||
Path workspace = resolveWorkspace(resolved);
|
||||
if (workspace == null) return null;
|
||||
Path file = workspace.resolve("LESSONS.md");
|
||||
if (!Files.exists(file)) return null;
|
||||
try {
|
||||
return Files.readString(file, StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to read LESSONS.md for skill '{}': {}",
|
||||
resolved.getName(), e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience for the prompt enhancement layer: returns the body
|
||||
* (everything after the canonical title) so it can be appended to
|
||||
* a skill's SKILL.md body without duplicating the title.
|
||||
*/
|
||||
public String readLessonsBody(ResolvedSkill resolved) {
|
||||
String full = readLessons(resolved);
|
||||
if (full == null || full.isBlank()) return null;
|
||||
// Strip the leading "# Lessons learned for {name}\n\n" header.
|
||||
int firstSection = full.indexOf("\n## ");
|
||||
if (firstSection < 0) return full.trim();
|
||||
return full.substring(firstSection + 1).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single lesson by id by rewriting the file without the
|
||||
* matching section. Returns {@code true} when an entry was actually
|
||||
* removed.
|
||||
*/
|
||||
public boolean deleteLesson(ResolvedSkill resolved, String lessonId) {
|
||||
// Lessons today have no per-section id — id is generated on
|
||||
// write but not persisted. This is a placeholder for the API
|
||||
// wiring; callers that need precise revert should use
|
||||
// clearLessons or rebuild via re-recording. Returning false
|
||||
// signals "no-op".
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipe the LESSONS.md file entirely (called by the
|
||||
* {@code DELETE /skills/{id}/lessons/clear} admin endpoint).
|
||||
*/
|
||||
public boolean clearLessons(ResolvedSkill resolved) {
|
||||
Path workspace = resolveWorkspace(resolved);
|
||||
if (workspace == null) return false;
|
||||
Path file = workspace.resolve("LESSONS.md");
|
||||
if (!Files.exists(file)) return false;
|
||||
try {
|
||||
Files.delete(file);
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to clear LESSONS.md for skill '{}': {}",
|
||||
resolved.getName(), e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== internals ====================
|
||||
|
||||
private Path resolveWorkspace(ResolvedSkill resolved) {
|
||||
if (resolved == null || resolved.getName() == null) return null;
|
||||
if (resolved.getSkillDir() != null) return resolved.getSkillDir();
|
||||
Path convention = workspaceManager.resolveConventionPath(resolved.getName());
|
||||
return Files.exists(convention) && Files.isDirectory(convention) ? convention : null;
|
||||
}
|
||||
|
||||
private void ensureFileWithHeader(Path file, String skillName) throws IOException {
|
||||
if (Files.exists(file)) return;
|
||||
String header = "# Lessons learned for " + skillName + "\n\n";
|
||||
Files.writeString(file, header, StandardCharsets.UTF_8,
|
||||
StandardOpenOption.CREATE_NEW);
|
||||
}
|
||||
|
||||
private String buildSection(String conversationId, String content) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("\n## ").append(LocalDateTime.now().format(HEADING_TS));
|
||||
if (conversationId != null && !conversationId.isBlank()) {
|
||||
sb.append(" (conversation: ").append(conversationId).append(")");
|
||||
}
|
||||
sb.append("\n").append(content.trim()).append("\n");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* FIFO truncation. Reads the file once, splits on {@code "\n## "},
|
||||
* keeps the title + last {@code maxEntries} sections, and rewrites.
|
||||
* Cheap enough for the typical N=50 cap.
|
||||
*/
|
||||
private void enforceCap(Path file, String skillName, int maxEntries) throws IOException {
|
||||
if (maxEntries <= 0) return;
|
||||
String full = Files.readString(file, StandardCharsets.UTF_8);
|
||||
if (full.isBlank()) return;
|
||||
|
||||
String header = "# Lessons learned for " + skillName + "\n";
|
||||
int firstSectionIdx = full.indexOf("\n## ");
|
||||
if (firstSectionIdx < 0) return; // no sections yet
|
||||
|
||||
String preface = full.substring(0, firstSectionIdx);
|
||||
String sectionsBlob = full.substring(firstSectionIdx + 1); // strip the leading newline
|
||||
|
||||
String[] sections = sectionsBlob.split("\n## ");
|
||||
if (sections.length <= maxEntries) return;
|
||||
|
||||
List<String> kept = new ArrayList<>(maxEntries);
|
||||
for (int i = sections.length - maxEntries; i < sections.length; i++) {
|
||||
kept.add(sections[i]);
|
||||
}
|
||||
StringBuilder rebuilt = new StringBuilder();
|
||||
if (!preface.isBlank()) rebuilt.append(preface).append("\n");
|
||||
else rebuilt.append(header).append("\n");
|
||||
for (int i = 0; i < kept.size(); i++) {
|
||||
rebuilt.append("## ").append(kept.get(i));
|
||||
if (i < kept.size() - 1 && !kept.get(i).endsWith("\n")) rebuilt.append("\n");
|
||||
}
|
||||
Files.writeString(file, rebuilt.toString(), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
/** Test hook (package-private) — exposes the parsing rule used by readLessonsBody. */
|
||||
static List<String> splitSections(String full) {
|
||||
if (full == null || full.isBlank()) return Collections.emptyList();
|
||||
int firstSectionIdx = full.indexOf("\n## ");
|
||||
if (firstSectionIdx < 0) return Collections.emptyList();
|
||||
String body = full.substring(firstSectionIdx + 1);
|
||||
return java.util.Arrays.asList(body.split("\n## "));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,92 @@
|
||||
package vip.mate.skill.lessons;
|
||||
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.annotation.ToolParam;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.skill.manifest.SkillManifest;
|
||||
import vip.mate.skill.runtime.SkillRuntimeService;
|
||||
import vip.mate.skill.runtime.model.ResolvedSkill;
|
||||
|
||||
/**
|
||||
* RFC-090 §11.4.2 — agent-callable {@code record_lesson} tool.
|
||||
*
|
||||
* <p>The tool is always exposed by the registry; the per-skill
|
||||
* {@code self-evolution.lessons_enabled} switch governs whether the
|
||||
* skill's manifest opts in. We don't filter at registry time so
|
||||
* skills can dynamically toggle the flag without a rebuild. When the
|
||||
* skill says no, the call returns a friendly explanation rather than
|
||||
* silently writing.
|
||||
*
|
||||
* <p>Storage and event publishing live in
|
||||
* {@link SkillLessonsService#recordLesson}. This class is the LLM-
|
||||
* facing wrapper.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class SkillLessonsTool {
|
||||
|
||||
private final SkillRuntimeService skillRuntimeService;
|
||||
private final SkillLessonsService lessonsService;
|
||||
|
||||
@Tool(description = """
|
||||
为指定 skill 记录一条 lesson(经验/洞察),追加到该 skill 的 LESSONS.md。
|
||||
下次加载该 skill 时,LESSONS.md 内容会自动注入到 system prompt。
|
||||
仅当 skill manifest 里 self-evolution.lessons_enabled=true 时生效(默认开启)。
|
||||
如果想记录的内容跨 skill 通用,请改用 remember 或 remember_structured。
|
||||
""")
|
||||
public String record_lesson(
|
||||
@ToolParam(description = "skill 的 slug(即 SKILL.md frontmatter 里的 name)") String skillName,
|
||||
@ToolParam(description = "要记录的经验内容") String lesson,
|
||||
@ToolParam(description = "可选:当前 Agent 的 ID", required = false) Long agentId,
|
||||
@ToolParam(description = "可选:当前对话 ID", required = false) String conversationId) {
|
||||
|
||||
if (skillName == null || skillName.isBlank()) return error("skillName 不能为空");
|
||||
if (lesson == null || lesson.isBlank()) return error("lesson 不能为空");
|
||||
|
||||
ResolvedSkill resolved = skillRuntimeService.resolveAllSkillsStatus().stream()
|
||||
.filter(s -> s != null && skillName.equals(s.getName()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (resolved == null) {
|
||||
return error("找不到 skill: " + skillName);
|
||||
}
|
||||
|
||||
SkillManifest manifest = resolved.getManifest();
|
||||
// §10.2 Q7 — default ON; only skip when the manifest explicitly opts out.
|
||||
boolean lessonsEnabled = manifest == null
|
||||
|| manifest.getSelfEvolution() == null
|
||||
|| manifest.getSelfEvolution().isLessonsEnabled();
|
||||
if (!lessonsEnabled) {
|
||||
return error("该 skill 已在 manifest 中关闭 self-evolution.lessons_enabled,"
|
||||
+ "无法记录 lesson。请改用 remember 工具或修改 manifest。");
|
||||
}
|
||||
|
||||
int max = manifest != null && manifest.getSelfEvolution() != null
|
||||
? manifest.getSelfEvolution().getLessonsMaxEntries() : 0;
|
||||
|
||||
String lessonId = lessonsService.recordLesson(resolved, agentId, conversationId,
|
||||
lesson, max);
|
||||
if (lessonId == null) {
|
||||
return error("Lesson 记录失败:skill 可能仅存在于数据库(无 workspace 目录)。");
|
||||
}
|
||||
|
||||
JSONObject result = new JSONObject();
|
||||
result.set("success", true);
|
||||
result.set("skill", skillName);
|
||||
result.set("lessonId", lessonId);
|
||||
result.set("message", "Lesson 已记录,下次加载该 skill 时会自动注入到 system prompt。");
|
||||
return JSONUtil.toJsonPrettyStr(result);
|
||||
}
|
||||
|
||||
private static String error(String msg) {
|
||||
JSONObject e = new JSONObject();
|
||||
e.set("success", false);
|
||||
e.set("error", msg);
|
||||
return JSONUtil.toJsonPrettyStr(e);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
package vip.mate.skill.lessons.event;
|
||||
|
||||
/**
|
||||
* RFC-090 §14.3 — fires when a skill records a new lesson into its
|
||||
* per-skill {@code LESSONS.md} file.
|
||||
*
|
||||
* <p>Distinct from {@code MemoryWriteEvent} on purpose: lessons are
|
||||
* skill-local, agent-scoped notes rather than canonical memory writes.
|
||||
* Mixing them into the SOUL summarizer would inflate its recompute
|
||||
* count without improving the canonical memory's quality.
|
||||
*
|
||||
* <p>Default subscriber set is empty. A future "lesson → memory"
|
||||
* aggregator can subscribe and decide when (if ever) to promote
|
||||
* accumulated lessons up to MEMORY.md / SOUL.md.
|
||||
*
|
||||
* @param agentId agent that triggered the skill call (may be null
|
||||
* for global / non-agent contexts)
|
||||
* @param skillId DB id of the skill the lesson belongs to (may
|
||||
* be null when the runtime resolved by name only)
|
||||
* @param skillName slug identifier of the skill (always present)
|
||||
* @param conversationId conversation in which the lesson was learned;
|
||||
* may be null for offline / cron-driven flows
|
||||
* @param content lesson text exactly as recorded (caller is
|
||||
* responsible for any redaction / truncation)
|
||||
*/
|
||||
public record SkillLessonWrittenEvent(
|
||||
Long agentId,
|
||||
Long skillId,
|
||||
String skillName,
|
||||
String conversationId,
|
||||
String content
|
||||
) {}
|
||||
@ -2,9 +2,12 @@ package vip.mate.skill.runtime;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.skill.lessons.SkillLessonsService;
|
||||
import vip.mate.skill.manifest.SkillManifest;
|
||||
import vip.mate.skill.model.SkillEntity;
|
||||
import vip.mate.skill.runtime.model.ResolvedSkill;
|
||||
import vip.mate.skill.service.SkillService;
|
||||
@ -25,11 +28,27 @@ import java.util.stream.Collectors;
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SkillRuntimeService {
|
||||
|
||||
private final SkillService skillService;
|
||||
private final SkillPackageResolver packageResolver;
|
||||
/**
|
||||
* {@code @Lazy} — SkillLessonsService depends on SkillWorkspaceManager,
|
||||
* which is constructed early; the lazy proxy avoids a chicken-and-egg
|
||||
* cycle when the runtime service initializes alongside the skill
|
||||
* service stack. Using setter-style injection through the
|
||||
* constructor below.
|
||||
*/
|
||||
private final SkillLessonsService lessonsService;
|
||||
|
||||
@Autowired
|
||||
public SkillRuntimeService(SkillService skillService,
|
||||
SkillPackageResolver packageResolver,
|
||||
@Lazy SkillLessonsService lessonsService) {
|
||||
this.skillService = skillService;
|
||||
this.packageResolver = packageResolver;
|
||||
this.lessonsService = lessonsService;
|
||||
}
|
||||
|
||||
// 缓存已解析的 active skills(5分钟过期)
|
||||
private final Cache<String, List<ResolvedSkill>> activeSkillsCache = Caffeine.newBuilder()
|
||||
@ -232,6 +251,41 @@ public class SkillRuntimeService {
|
||||
sb.append(" |\n");
|
||||
}
|
||||
|
||||
// RFC-090 §11.4.3 + §10.2 Q6 — append per-skill LESSONS.md after
|
||||
// the catalog so the LLM sees "Available Skills" first, then any
|
||||
// accumulated lessons attached to each skill that has opted in.
|
||||
appendLessonsBlock(sb, activeSkills);
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a "## Lessons learned" block to the prompt enhancement
|
||||
* with one subsection per active skill that has lessons recorded.
|
||||
*
|
||||
* <p>Skills opt in via {@code self-evolution.lessons_enabled} (default
|
||||
* true). Skills with no LESSONS.md content contribute nothing — we
|
||||
* never emit an empty subsection.
|
||||
*/
|
||||
private void appendLessonsBlock(StringBuilder sb, List<ResolvedSkill> activeSkills) {
|
||||
if (lessonsService == null || activeSkills == null || activeSkills.isEmpty()) return;
|
||||
StringBuilder lessons = new StringBuilder();
|
||||
for (ResolvedSkill skill : activeSkills) {
|
||||
SkillManifest manifest = skill.getManifest();
|
||||
boolean enabled = manifest == null
|
||||
|| manifest.getSelfEvolution() == null
|
||||
|| manifest.getSelfEvolution().isLessonsEnabled();
|
||||
if (!enabled) continue;
|
||||
String body = lessonsService.readLessonsBody(skill);
|
||||
if (body == null || body.isBlank()) continue;
|
||||
lessons.append("\n### ").append(skill.getName()).append("\n");
|
||||
lessons.append(body).append("\n");
|
||||
}
|
||||
if (lessons.length() > 0) {
|
||||
sb.append("\n\n## Lessons learned\n");
|
||||
sb.append("Past observations the agent recorded for these skills. ");
|
||||
sb.append("Treat them as advisory hints — the canonical SKILL.md still wins on conflict.\n");
|
||||
sb.append(lessons);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -170,6 +170,10 @@ export const skillApi = {
|
||||
refreshRuntime: () => http.post('/skills/runtime/refresh'),
|
||||
exportWorkspace: (id: string | number) => http.post(`/skills/${id}/export-workspace`),
|
||||
getWorkspaceInfo: (id: string | number) => http.get(`/skills/${id}/workspace`),
|
||||
// RFC-090 §7 + §11.4 — pre-flight requirements + LESSONS.md
|
||||
requirements: (id: string | number) => http.get(`/skills/${id}/requirements`),
|
||||
getLessons: (id: string | number) => http.get(`/skills/${id}/lessons`),
|
||||
clearLessons: (id: string | number) => http.post(`/skills/${id}/lessons/clear`),
|
||||
}
|
||||
|
||||
// ==================== Skill Install ====================
|
||||
|
||||
@ -1943,10 +1943,15 @@ export default {
|
||||
manifest: 'Manifest',
|
||||
tools: 'Tools',
|
||||
features: 'Features',
|
||||
lessons: 'Lessons',
|
||||
noManifest: 'This skill does not declare a v3 manifest. Legacy fields apply.',
|
||||
noTools: 'No tools advertised by active features.',
|
||||
noFeatures: 'No features[] matrix declared. The skill is treated as a single default feature.',
|
||||
noLessons: 'No lessons recorded yet. The skill writes here after each call when self-evolution is enabled.',
|
||||
toolsHint: 'These tool names are merged into the LLM allowed-tool list when this skill is bound to an agent. Tools owned by SETUP_NEEDED features stay hidden.',
|
||||
lessonsHint: 'Per-skill LESSONS.md. Auto-injected after SKILL.md body when this skill is loaded.',
|
||||
clearLessons: 'Clear all',
|
||||
clearLessonsConfirm: 'Delete all recorded lessons for this skill? This cannot be undone.',
|
||||
},
|
||||
runtime: {
|
||||
disabled: 'Disabled',
|
||||
|
||||
@ -1945,10 +1945,15 @@ export default {
|
||||
manifest: 'Manifest',
|
||||
tools: '工具',
|
||||
features: '特性',
|
||||
lessons: 'Lessons',
|
||||
noManifest: '该技能未声明 v3 manifest,使用旧字段。',
|
||||
noTools: '当前激活特性未暴露任何工具。',
|
||||
noFeatures: '未声明 features[] 矩阵,技能被视为单一默认特性。',
|
||||
noLessons: '尚未记录任何 lesson。开启 self-evolution 后,每次调用结束 skill 可向此处写入经验。',
|
||||
toolsHint: 'Skill 绑定到 agent 时,这些工具名将合并进 LLM 的 allowed-tools。SETUP_NEEDED 特性下的工具保持隐藏。',
|
||||
lessonsHint: '该 skill 的 LESSONS.md 内容。下次加载时自动注入到 SKILL.md 正文之后。',
|
||||
clearLessons: '全部清空',
|
||||
clearLessonsConfirm: '删除该 skill 的全部 lessons?操作不可撤销。',
|
||||
},
|
||||
runtime: {
|
||||
disabled: '已停用',
|
||||
|
||||
@ -255,6 +255,9 @@
|
||||
{{ t('skills.detail.features') }}
|
||||
<span v-if="detailFeaturesCount > 0" class="tab-count">{{ detailFeaturesCount }}</span>
|
||||
</button>
|
||||
<button class="detail-tab" :class="{ active: detailTab === 'lessons' }" @click="detailTab = 'lessons'">
|
||||
{{ t('skills.detail.lessons') }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Manifest tab -->
|
||||
<div v-if="detailTab === 'manifest'" class="detail-section">
|
||||
@ -297,6 +300,18 @@
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<!-- RFC-090 §11.4 — Lessons tab -->
|
||||
<div v-if="detailTab === 'lessons'" class="detail-section">
|
||||
<div class="lessons-header">
|
||||
<p class="detail-hint">{{ t('skills.detail.lessonsHint') }}</p>
|
||||
<button class="lessons-clear-btn" :disabled="!detailLessonsRaw" @click="clearLessons">
|
||||
{{ t('skills.detail.clearLessons') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="detailLessonsLoading" class="detail-empty">{{ t('common.loading') }}</p>
|
||||
<p v-else-if="!detailLessonsRaw" class="detail-empty">{{ t('skills.detail.noLessons') }}</p>
|
||||
<pre v-else class="detail-pre">{{ detailLessonsRaw }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
@ -429,7 +444,9 @@ const rescanning = ref<Record<string, boolean>>({})
|
||||
/** RFC-090 Phase 3 — detail drawer state. */
|
||||
const detailDrawerVisible = ref(false)
|
||||
const detailSkill = ref<Skill | null>(null)
|
||||
const detailTab = ref<'manifest' | 'tools' | 'features'>('manifest')
|
||||
const detailTab = ref<'manifest' | 'tools' | 'features' | 'lessons'>('manifest')
|
||||
const detailLessonsRaw = ref<string>('')
|
||||
const detailLessonsLoading = ref(false)
|
||||
|
||||
const detailRuntime = computed(() =>
|
||||
detailSkill.value ? runtimeStatusMap.value[detailSkill.value.name] || null : null,
|
||||
@ -451,9 +468,45 @@ const detailFeaturesCount = computed(() => detailFeatures.value.length)
|
||||
function openDetailDrawer(skill: Skill) {
|
||||
detailSkill.value = skill
|
||||
detailTab.value = 'manifest'
|
||||
detailLessonsRaw.value = ''
|
||||
detailDrawerVisible.value = true
|
||||
}
|
||||
|
||||
async function loadLessons() {
|
||||
if (!detailSkill.value) return
|
||||
detailLessonsLoading.value = true
|
||||
try {
|
||||
const res: any = await skillApi.getLessons(detailSkill.value.id)
|
||||
detailLessonsRaw.value = res?.data?.raw || ''
|
||||
} catch (e: any) {
|
||||
detailLessonsRaw.value = ''
|
||||
console.warn('[SkillMarket] failed to load lessons', e)
|
||||
} finally {
|
||||
detailLessonsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function clearLessons() {
|
||||
if (!detailSkill.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm(t('skills.detail.clearLessonsConfirm'), t('skills.messages.deleteTitle'), { type: 'warning' })
|
||||
} catch { return }
|
||||
try {
|
||||
await skillApi.clearLessons(detailSkill.value.id)
|
||||
detailLessonsRaw.value = ''
|
||||
} catch (e: any) {
|
||||
ElMessage.error(typeof e === 'string' ? e : e?.message || t('skills.messages.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
watch(detailTab, (tab) => {
|
||||
// Lazy load LESSONS.md only when the user clicks into that tab; we
|
||||
// don't want every drawer open to fire an extra request.
|
||||
if (tab === 'lessons' && detailDrawerVisible.value && !detailLessonsRaw.value && !detailLessonsLoading.value) {
|
||||
loadLessons()
|
||||
}
|
||||
})
|
||||
|
||||
const categoryTabs = computed(() => [
|
||||
{ label: t('skills.tabs.all'), value: 'all', icon: '🗂️' },
|
||||
{ label: t('skills.tabs.builtin'), value: 'builtin', icon: '🔧' },
|
||||
@ -1225,4 +1278,9 @@ html.dark .scan-finding-item { background: rgba(255, 255, 255, 0.05); }
|
||||
.detail-meta-key { color: var(--mc-text-tertiary); margin-right: 4px; }
|
||||
.detail-feature-tag { padding: 2px 6px; background: var(--mc-bg-elevated); border-radius: 4px; font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; color: var(--mc-text-primary); }
|
||||
.detail-feature-fallback { font-size: 11px; color: var(--mc-primary-hover); margin-top: 6px; font-style: italic; }
|
||||
|
||||
.lessons-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
|
||||
.lessons-clear-btn { padding: 6px 12px; border-radius: 8px; border: 1px solid var(--mc-border); background: var(--mc-bg-muted); color: var(--mc-text-secondary); cursor: pointer; font-size: 12px; }
|
||||
.lessons-clear-btn:hover:not(:disabled) { background: var(--mc-danger-bg); color: var(--mc-danger); border-color: var(--mc-danger); }
|
||||
.lessons-clear-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
</style>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user