fix(skill,agent): curation observation anchor, per-skill adoption, and empty plan-summary fallback

This commit is contained in:
matevip 2026-08-07 05:12:33 -04:00
parent 99c18b968a
commit c72c8b48e4
16 changed files with 861 additions and 13 deletions

View File

@ -85,32 +85,68 @@ public class PlanSummaryNode implements NodeAction {
chatModel, prompt, conversationId, "plan_summary");
String summary = result.text();
String thinking = result.thinking() == null ? "" : result.thinking();
// An interleaved-thinking model can spend its whole turn reasoning and
// return empty text. That empty string used to pass straight through:
// it became the plan's summary, then the run's terminal answer, and the
// goal evaluator skipped on "terminalAnswer empty" so a plan whose
// steps had all succeeded ended with a dangling reasoning block and no
// report. The step results are already in hand, so answer from those
// rather than hand back nothing.
if (summary == null || summary.isBlank()) {
log.warn("[PlanSummary] Plan {} produced an empty summary "
+ "(thinking={} chars, {} step results); falling back to step results",
planId, thinking.length(), completedResults.size());
summary = buildFallbackSummary(goal, completedResults, SUMMARY_EMPTY_NOTE);
}
// The steps themselves succeeded only the summary text was missing
// so the plan completes rather than being marked failed.
planningService.completePlan(planId, summary);
log.info("[PlanSummary] Plan {} completed with summary: {}",
planId, summary.length() > 100 ? summary.substring(0, 100) + "..." : summary);
planId, truncate(summary, 100));
return PlanStateAccessor.output()
.finalSummary(summary)
.finalSummaryThinking(result.thinking())
.finalSummaryThinking(thinking)
.contentStreamed(true)
.thinkingStreamed(!result.thinking().isEmpty())
.thinkingStreamed(!thinking.isEmpty())
.mergeUsage(state, result)
.build();
} catch (Exception e) {
log.error("[PlanSummary] Failed to summarize plan {}: {}", planId, e.getMessage(), e);
String fallbackSummary = buildFallbackSummary(goal, completedResults);
String fallbackSummary = buildFallbackSummary(goal, completedResults, SUMMARY_FAILED_NOTE);
planningService.markPlanFailed(planId, "汇总阶段失败:" + truncate(e.getMessage(), 100));
return Map.of(PlanStateKeys.FINAL_SUMMARY, fallbackSummary);
}
}
/** Reason line for the summary call throwing. */
private static final String SUMMARY_FAILED_NOTE = "LLM 汇总失败,以下为步骤原始结果";
/**
* LLM 汇总调用失败时生成本地 fallback 摘要
* 每条步骤结果截断至 300 避免把过长内容包括错误体直接暴露给用户
* Reason line for the summary call returning nothing. Distinct from the
* failure note because nothing actually failed the steps ran, the model
* simply produced no text and telling the user their run failed would be
* wrong.
*/
private static String buildFallbackSummary(String goal, List<String> completedResults) {
StringBuilder sb = new StringBuilder("目标:").append(goal).append("\n\n执行摘要LLM 汇总失败,以下为步骤原始结果):\n");
private static final String SUMMARY_EMPTY_NOTE = "模型未产出汇总正文,以下为步骤原始结果";
/**
* LLM 汇总不可用时生成本地 fallback 摘要
* 每条步骤结果截断至 300 避免把过长内容包括错误体直接暴露给用户
*
* @param note 说明为何回落到步骤原始结果
*/
private static String buildFallbackSummary(String goal, List<String> completedResults, String note) {
StringBuilder sb = new StringBuilder("目标:").append(goal)
.append("\n\n执行摘要").append(note).append("\n");
if (completedResults == null || completedResults.isEmpty()) {
sb.append("(没有已完成的步骤结果可供汇总)\n");
return sb.toString();
}
for (String r : completedResults) {
sb.append(truncate(r, 300)).append("\n");
}

View File

@ -1149,6 +1149,61 @@ public class SkillController {
}
}
@Operation(summary = "列出未纳入自治治理的技能")
@GetMapping("/curator/unmanaged")
@RequireWorkspaceRole("member")
public R<List<Map<String, Object>>> curatorUnmanaged() {
return R.ok(skillLifecycleService.listUnmanaged());
}
@Operation(summary = "列出已纳入自治治理的技能")
@GetMapping("/curator/managed")
@RequireWorkspaceRole("member")
public R<List<Map<String, Object>>> curatorManaged() {
return R.ok(skillLifecycleService.listManaged());
}
@Operation(summary = "将技能移交给自治治理(不重置闲置时钟)")
@PostMapping("/curator/adopt")
@RequireWorkspaceRole("admin")
public R<Map<String, Object>> curatorAdopt(@RequestBody List<String> skillIds) {
return R.ok(setAdoptedBulk(skillIds, true));
}
@Operation(summary = "撤销移交,技能归还用户所有")
@PostMapping("/curator/release")
@RequireWorkspaceRole("admin")
public R<Map<String, Object>> curatorRelease(@RequestBody List<String> skillIds) {
return R.ok(setAdoptedBulk(skillIds, false));
}
/**
* Apply adopt/release across a batch, reporting per-skill outcomes rather
* than failing the whole call on one bad id a partial batch that silently
* rolled back would leave the operator unsure which skills moved.
*/
private Map<String, Object> setAdoptedBulk(List<String> skillIds, boolean adopt) {
List<String> changed = new ArrayList<>();
List<Map<String, Object>> rejected = new ArrayList<>();
for (String raw : skillIds == null ? List.<String>of() : skillIds) {
// Ids stay strings end-to-end; parse once here so a malformed one
// is a reported rejection rather than a framework-level failure.
try {
skillLifecycleService.setAdopted(Long.parseLong(String.valueOf(raw).strip()), adopt);
changed.add(String.valueOf(raw));
} catch (Exception e) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("id", String.valueOf(raw));
row.put("message", e.getMessage());
rejected.add(row);
}
}
Map<String, Object> out = new LinkedHashMap<>();
out.put("changed", changed);
out.put("rejected", rejected);
return out;
}
@Operation(summary = "列出技能库还原点")
@GetMapping("/curator/snapshots")
@RequireWorkspaceRole("member")

View File

@ -210,7 +210,24 @@ public class SkillCuratorJob {
List<SkillEntity> candidates = loadCandidates();
int plannedStale = 0, plannedArchived = 0, plannedReactivate = 0;
int appliedStale = 0, appliedArchived = 0, appliedReactivate = 0;
int newlyObserved = 0;
for (SkillEntity skill : candidates) {
// A candidate no sweep has seen before starts its idle clock now
// rather than being judged on time it spent outside curation.
// planTransition already returns NONE for these; stamping the
// anchor is what lets the next sweep judge it for real.
//
// A dry run must not write, but it must still reach the same
// verdict a real run would this report is what an operator reads
// to decide whether widening the scope is safe, so predicting
// archives that a real run would defer would be a lie.
if (SkillLifecycleService.isUnobserved(skill)) {
newlyObserved++;
if (!dryRun) {
lifecycleService.markObserved(skill, now);
}
continue;
}
LifecycleTransition t = lifecycleService.planTransition(skill, now);
report.add(skill, t);
if (t == LifecycleTransition.TO_STALE) {
@ -236,6 +253,7 @@ public class SkillCuratorJob {
}
report.scanned(candidates.size())
.newlyObserved(newlyObserved)
.plannedCounts(plannedStale, plannedArchived, plannedReactivate)
.appliedCounts(appliedStale, appliedArchived, appliedReactivate)
.blockedByBindings(agentBindingService.blockedByBindingCandidates(now));

View File

@ -33,6 +33,7 @@ public class SkillCuratorReport {
private final boolean dryRun;
private final Config config;
private final int scanned;
private final int newlyObserved;
private final Counts planned;
private final Counts applied;
private final List<TransitionRow> transitions;
@ -50,6 +51,7 @@ public class SkillCuratorReport {
this.dryRun = b.dryRun;
this.config = new Config(b.staleAfterDays, b.archiveAfterDays, b.scope);
this.scanned = b.scanned;
this.newlyObserved = b.newlyObserved;
this.planned = new Counts(b.plannedStale, b.plannedArchived, b.plannedReactivated);
this.applied = new Counts(b.appliedStale, b.appliedArchived, b.appliedReactivated);
this.transitions = List.copyOf(b.transitions);
@ -62,6 +64,16 @@ public class SkillCuratorReport {
this.path = path;
}
/**
* Candidates seen by curation for the first time this run. They are
* deferred rather than judged, so an operator reading a first sweep
* after widening the scope can tell "nothing was archived because it is
* all brand new to the curator" from "nothing needed archiving".
*/
public int newlyObserved() {
return newlyObserved;
}
/** Applied count of skills marked stale (0 for a dry-run). */
public int markedStale() {
return applied.stale();
@ -104,6 +116,7 @@ public class SkillCuratorReport {
private int archiveAfterDays;
private String scope;
private int scanned;
private int newlyObserved;
private int plannedStale, plannedArchived, plannedReactivated;
private int appliedStale, appliedArchived, appliedReactivated;
private final List<TransitionRow> transitions = new ArrayList<>();
@ -128,6 +141,11 @@ public class SkillCuratorReport {
return this;
}
public Builder newlyObserved(int newlyObserved) {
this.newlyObserved = newlyObserved;
return this;
}
public Builder scanned(int scanned) {
this.scanned = scanned;
return this;
@ -138,8 +156,9 @@ public class SkillCuratorReport {
if (t == null || t == LifecycleTransition.NONE) {
return this;
}
LocalDateTime anchor = skill.getLastActivityAt() != null
? skill.getLastActivityAt() : skill.getCreateTime();
// Shared with the decision path a report that computed idle days
// its own way could contradict the transition it is describing.
LocalDateTime anchor = SkillLifecycleService.anchor(skill);
long days = anchor == null || runAt == null ? 0L : Duration.between(anchor, runAt).toDays();
String from = Optional.ofNullable(skill.getLifecycleState()).orElse("active");
String to = switch (t) {

View File

@ -1,5 +1,6 @@
package vip.mate.skill.lifecycle;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
@ -9,6 +10,7 @@ import org.springframework.stereotype.Service;
import vip.mate.audit.service.AuditEventService;
import vip.mate.exception.MateClawException;
import vip.mate.skill.model.SkillEntity;
import vip.mate.skill.model.SkillOrigin;
import vip.mate.skill.repository.SkillMapper;
import vip.mate.skill.runtime.SkillRuntimeService;
import vip.mate.skill.workspace.SkillWorkspaceManager;
@ -16,6 +18,8 @@ import vip.mate.skill.workspace.SkillWorkspaceProperties;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@ -68,14 +72,45 @@ public class SkillLifecycleService {
// ==================== Pure decision functions ====================
/** Activity anchor: last recorded activity, falling back to creation time. */
public LocalDateTime anchor(SkillEntity skill) {
/**
* Activity anchor, in order of preference: real recorded activity, then
* the moment curation first saw the skill, then creation time.
*
* <p>The middle term is what keeps a newly-eligible skill from being
* judged on time it spent outside curation entirely. Creation time stays
* as the final fallback so rows predating the column, and bare entities
* built in tests, behave exactly as before.
*
* <p>Single source of truth: the run report renders idle days from this
* same method, so a report can never disagree with the decision it
* describes.
*/
public static LocalDateTime anchor(SkillEntity skill) {
if (skill.getLastActivityAt() != null) {
return skill.getLastActivityAt();
}
if (skill.getCuratorSeenAt() != null) {
return skill.getCuratorSeenAt();
}
return skill.getCreateTime();
}
/**
* Whether no sweep has observed this skill yet, so judging it now would
* apply the idle thresholds to time it spent outside curation.
*/
public static boolean isUnobserved(SkillEntity skill) {
return skill.getCuratorSeenAt() == null && skill.getLastActivityAt() == null;
}
/** Stamp the observation anchor, starting the skill's idle clock now. */
public void markObserved(SkillEntity skill, LocalDateTime now) {
skillMapper.update(null, new LambdaUpdateWrapper<SkillEntity>()
.eq(SkillEntity::getId, skill.getId())
.set(SkillEntity::getCuratorSeenAt, now));
skill.setCuratorSeenAt(now);
}
/** Skills the curator must never touch (filtered out before the state machine). */
public boolean isExempt(SkillEntity skill) {
if (Boolean.TRUE.equals(skill.getBuiltin())) {
@ -108,6 +143,12 @@ public class SkillLifecycleService {
if (isExempt(skill)) {
return LifecycleTransition.NONE;
}
// Never observed: the thresholds would be measured against time this
// skill spent outside curation. Defer for a full cycle instead; the
// sweep stamps the observation anchor so the next pass has a real one.
if (isUnobserved(skill)) {
return LifecycleTransition.NONE;
}
LocalDateTime anchor = anchor(skill);
if (anchor == null) {
return LifecycleTransition.NONE;
@ -208,6 +249,107 @@ public class SkillLifecycleService {
return skillMapper.selectById(id);
}
/**
* Hand a skill over to autonomous curation, or take it back.
*
* <p>Adoption deliberately does <em>not</em> buy a fresh idle window. An
* operator hands over a skill knowing it is already idle, so the
* observation anchor is set to creation time the same anchor the skill
* would have had if it had been curator-managed all along. Handing over a
* library you stopped using therefore ages it out, which is the point of
* handing it over. This is the deliberate difference from the implicit
* first-sight seeding, where a skill arrives in scope through no decision
* of the operator's and must not be judged on time spent outside it.
*
* <p>Releasing restores user ownership, so adoption is reversible.
*
* @param adopt {@code true} to hand over, {@code false} to take back
* @throws MateClawException when the skill does not exist, or when it is
* builtin (never curatable in the first place)
*/
public SkillEntity setAdopted(Long id, boolean adopt) {
SkillEntity skill = skillMapper.selectById(id);
if (skill == null) {
throw new MateClawException("err.skill.not_found", 404, "Skill not found: " + id);
}
if (isExempt(skill)) {
throw new MateClawException("err.skill.not_adoptable", 400,
"Skill '" + skill.getName() + "' is exempt from curation (builtin, pinned, "
+ "protected prefix, or a virtual mcp/acp skill)");
}
LambdaUpdateWrapper<SkillEntity> update = new LambdaUpdateWrapper<SkillEntity>()
.eq(SkillEntity::getId, id)
.set(SkillEntity::getOrigin,
adopt ? SkillOrigin.AGENT.code() : SkillOrigin.USER.code());
if (adopt) {
// No fresh window: anchor where an always-managed skill would be.
update.set(SkillEntity::getCuratorSeenAt, skill.getCreateTime());
}
skillMapper.update(null, update);
recordAudit(adopt ? "ADOPT" : "RELEASE", skill,
Map.of("origin", adopt ? SkillOrigin.AGENT.code() : SkillOrigin.USER.code()));
return skillMapper.selectById(id);
}
/**
* Skills outside autonomous curation, with the reason each one is out.
*
* <p>Without this a large library can look fully curated while most of it
* is invisible to the sweep, and the only lever was widening the scope for
* everything at once.
*/
public List<Map<String, Object>> listUnmanaged() {
return roster(false);
}
/**
* Skills currently under autonomous curation the set an operator can
* hand back. Without it adoption would be one-way from the UI.
*/
public List<Map<String, Object>> listManaged() {
return roster(true);
}
/**
* Shared roster projection. {@code managed} selects skills the curator may
* touch ({@code origin} agent/routine) or the complement; exempt skills are
* dropped from both sides because they are not curatable either way, so
* offering adopt or release on them would be a lie.
*/
private List<Map<String, Object>> roster(boolean managed) {
LambdaQueryWrapper<SkillEntity> q = new LambdaQueryWrapper<SkillEntity>()
.eq(SkillEntity::getBuiltin, false);
if (managed) {
q.in(SkillEntity::getOrigin, SkillOrigin.curatorManagedCodes());
} else {
q.and(w -> w.isNull(SkillEntity::getOrigin)
.or().eq(SkillEntity::getOrigin, SkillOrigin.USER.code()));
}
List<SkillEntity> rows = skillMapper.selectList(q);
LocalDateTime now = LocalDateTime.now();
List<Map<String, Object>> out = new ArrayList<>();
for (SkillEntity skill : rows) {
if (isExempt(skill)) {
continue;
}
LocalDateTime anchor = anchor(skill);
Map<String, Object> row = new LinkedHashMap<>();
// Snowflake id as a string: 19 digits exceed JS Number precision.
row.put("id", String.valueOf(skill.getId()));
row.put("name", skill.getName());
row.put("description", skill.getDescription());
row.put("lifecycleState", skill.getLifecycleState());
row.put("origin", skill.getOrigin());
row.put("reason", managed
? skill.getOrigin()
: (skill.getOrigin() == null ? "predates-provenance" : "user-authored"));
row.put("unobserved", isUnobserved(skill));
row.put("daysIdle", anchor == null ? null : Duration.between(anchor, now).toDays());
out.add(row);
}
return out;
}
/**
* Push the activity anchor of a skill to now and pull it back to
* {@code active} if it had drifted to {@code stale}. Best-effort: a

View File

@ -163,6 +163,22 @@ public class SkillEntity {
*/
private LocalDateTime lastActivityAt;
/**
* When autonomous curation first saw this skill as a candidate.
*
* <p>Distinct from {@link #createTime}: a skill can exist for a long time
* before it falls under curation at all widening the curator scope pulls
* a whole library in at once. Anchoring the idle clock on creation would
* have such a skill enter curation already looking long-idle and archive it
* on the very first sweep, so the moment curation began watching is
* recorded separately.
*
* <p>{@code null} means no sweep has seen it yet; the next one stamps it
* and defers judgement for a full cycle.
*/
@TableField(value = "curator_seen_at", updateStrategy = FieldStrategy.ALWAYS)
private LocalDateTime curatorSeenAt;
/** Wall-clock time the skill entered the archived state. */
private LocalDateTime archivedAt;

View File

@ -0,0 +1,18 @@
-- V178: Record when curation first saw a skill, separately from when it was created.
--
-- The idle clock anchored on create_time, which conflated two different
-- moments: when a skill was written, and when it first fell under curation.
-- For a skill that only becomes eligible later — widening the curator scope
-- brings a whole library in at once — those differ by however long the skill
-- existed unmanaged, so it entered curation already looking years idle and was
-- archived on the very next sweep.
--
-- The backfill covers only rows already inside the default AGENT_CREATED scope,
-- so their clocks continue exactly as before and upgrading changes nothing.
-- Rows outside the scope stay NULL and get seeded the first time a sweep
-- actually sees them.
ALTER TABLE mate_skill ADD COLUMN IF NOT EXISTS curator_seen_at TIMESTAMP;
UPDATE mate_skill SET curator_seen_at = create_time
WHERE curator_seen_at IS NULL AND origin IN ('agent', 'routine');

View File

@ -0,0 +1,18 @@
-- V178: Record when curation first saw a skill, separately from when it was created.
--
-- The idle clock anchored on create_time, which conflated two different
-- moments: when a skill was written, and when it first fell under curation.
-- For a skill that only becomes eligible later — widening the curator scope
-- brings a whole library in at once — those differ by however long the skill
-- existed unmanaged, so it entered curation already looking years idle and was
-- archived on the very next sweep.
--
-- The backfill covers only rows already inside the default AGENT_CREATED scope,
-- so their clocks continue exactly as before and upgrading changes nothing.
-- Rows outside the scope stay NULL and get seeded the first time a sweep
-- actually sees them.
ALTER TABLE mate_skill ADD COLUMN IF NOT EXISTS curator_seen_at TIMESTAMP(3);
UPDATE mate_skill SET curator_seen_at = create_time
WHERE curator_seen_at IS NULL AND origin IN ('agent', 'routine');

View File

@ -0,0 +1,22 @@
-- V178: Record when curation first saw a skill, separately from when it was created.
--
-- The idle clock anchored on create_time, which conflated two different
-- moments: when a skill was written, and when it first fell under curation.
-- For a skill that only becomes eligible later — widening the curator scope
-- brings a whole library in at once — those differ by however long the skill
-- existed unmanaged, so it entered curation already looking years idle and was
-- archived on the very next sweep.
--
-- The backfill covers only rows already inside the default AGENT_CREATED scope,
-- so their clocks continue exactly as before and upgrading changes nothing.
-- Rows outside the scope stay NULL and get seeded the first time a sweep
-- actually sees them.
--
-- MySQL lacks `ADD COLUMN IF NOT EXISTS`; use an INFORMATION_SCHEMA guard.
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_skill' AND COLUMN_NAME = 'curator_seen_at');
SET @s := IF(@c = 0, 'ALTER TABLE mate_skill ADD COLUMN curator_seen_at DATETIME DEFAULT NULL', 'SELECT 1');
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
UPDATE mate_skill SET curator_seen_at = create_time
WHERE curator_seen_at IS NULL AND origin IN ('agent', 'routine');

View File

@ -0,0 +1,158 @@
package vip.mate.agent.graph.plan.node;
import com.alibaba.cloud.ai.graph.OverAllState;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.model.ChatModel;
import vip.mate.agent.graph.NodeStreamingChatHelper;
import vip.mate.agent.graph.plan.state.PlanStateKeys;
import vip.mate.planning.service.PlanningService;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Pins the summary node's behaviour when the model returns no text.
*
* <p>An interleaved-thinking model can burn its whole turn on reasoning and
* come back with an empty string. That empty summary used to flow through as
* the run's terminal answer, the goal evaluator skipped on "terminalAnswer
* empty", and a plan whose steps had all succeeded ended with a dangling
* reasoning block and nothing to show for it. The step results are already in
* hand at that point, so the node must answer from those.
*/
class PlanSummaryEmptyResultTest {
private ChatModel chatModel;
private PlanningService planningService;
private NodeStreamingChatHelper streamingHelper;
private PlanSummaryNode node;
@BeforeEach
void setUp() {
chatModel = mock(ChatModel.class);
planningService = mock(PlanningService.class);
streamingHelper = mock(NodeStreamingChatHelper.class);
node = new PlanSummaryNode(chatModel, planningService, streamingHelper);
}
private static NodeStreamingChatHelper.StreamResult result(String text, String thinking) {
return new NodeStreamingChatHelper.StreamResult(text, thinking, null, List.of(), false, 0, 0);
}
private OverAllState state() {
Map<String, Object> vals = new HashMap<>();
vals.put(PlanStateKeys.PLAN_ID, 42L);
vals.put(PlanStateKeys.GOAL, "对订单数据做质量体检");
vals.put(PlanStateKeys.COMPLETED_RESULTS,
List.of("第 1 步:主键唯一性,发现 3 条重复", "第 2 步:缺失率统计完成"));
return new OverAllState(vals);
}
private String summaryOf(Map<String, Object> out) {
return String.valueOf(out.get(PlanStateKeys.FINAL_SUMMARY));
}
@Test
@DisplayName("empty summary text falls back to the step results")
void emptyTextFallsBackToStepResults() throws Exception {
// The exact shape that slipped through: empty string, not null. A null
// tripped an NPE and reached the catch-block fallback by accident; "" did
// not, so it was the only unhandled case.
when(streamingHelper.streamCall(any(), any(), any(), anyString()))
.thenReturn(result("", "let me think about this for 36 seconds"));
Map<String, Object> out = node.apply(state());
String summary = summaryOf(out);
assertFalse(summary.isBlank(), "an empty model summary must not become an empty answer");
assertTrue(summary.contains("主键唯一性"), summary);
assertTrue(summary.contains("缺失率统计"), summary);
}
@Test
@DisplayName("whitespace-only summary is treated the same as empty")
void whitespaceOnlyTextFallsBack() throws Exception {
when(streamingHelper.streamCall(any(), any(), any(), anyString()))
.thenReturn(result(" \n ", ""));
assertTrue(summaryOf(node.apply(state())).contains("主键唯一性"));
}
@Test
@DisplayName("an empty summary completes the plan — the steps did succeed")
void emptySummaryStillCompletesThePlan() throws Exception {
when(streamingHelper.streamCall(any(), any(), any(), anyString()))
.thenReturn(result("", "thinking"));
node.apply(state());
// Nothing failed: every step ran, only the summary text was missing.
// Marking the plan failed would misreport a successful run.
verify(planningService).completePlan(eq(42L), anyString());
verify(planningService, never()).markPlanFailed(any(), anyString());
}
@Test
@DisplayName("the fallback says the model produced nothing, not that the run failed")
void emptySummaryNoteDistinguishesFromFailure() throws Exception {
when(streamingHelper.streamCall(any(), any(), any(), anyString()))
.thenReturn(result("", "thinking"));
String summary = summaryOf(node.apply(state()));
assertTrue(summary.contains("未产出汇总正文"), summary);
assertFalse(summary.contains("汇总失败"),
"nothing failed here; calling it a failure misreports a successful run: " + summary);
}
@Test
@DisplayName("null thinking does not blow up the node")
void nullThinkingIsTolerated() throws Exception {
when(streamingHelper.streamCall(any(), any(), any(), anyString()))
.thenReturn(result("", null));
assertTrue(summaryOf(node.apply(state())).contains("主键唯一性"));
}
@Test
@DisplayName("a real summary is passed through untouched")
void realSummaryIsUnchanged() throws Exception {
when(streamingHelper.streamCall(any(), any(), any(), anyString()))
.thenReturn(result("体检完成:共发现 5 类问题。", "thinking"));
Map<String, Object> out = node.apply(state());
assertEquals("体检完成:共发现 5 类问题。", summaryOf(out));
verify(planningService).completePlan(eq(42L), eq("体检完成:共发现 5 类问题。"));
}
@Test
@DisplayName("empty summary with no step results still yields a readable answer")
void emptySummaryWithNoStepResults() throws Exception {
when(streamingHelper.streamCall(any(), any(), any(), anyString()))
.thenReturn(result("", "thinking"));
Map<String, Object> vals = new HashMap<>();
vals.put(PlanStateKeys.PLAN_ID, 42L);
vals.put(PlanStateKeys.GOAL, "空计划");
vals.put(PlanStateKeys.COMPLETED_RESULTS, List.of());
String summary = summaryOf(node.apply(new OverAllState(vals)));
assertFalse(summary.isBlank());
assertTrue(summary.contains("没有已完成的步骤结果"), summary);
}
}

View File

@ -199,6 +199,54 @@ class SkillCuratorJobTest {
assertEquals(1, cap.getValue().getApplied().stale());
}
/** A candidate curation has never seen: no activity, no observation stamp. */
private SkillEntity unobservedCandidate(long id, LocalDateTime createdAt) {
SkillEntity s = candidate(id, "active", null);
s.setCreateTime(createdAt);
return s;
}
@Test
void unobservedCandidateIsSeededAndNotJudged() {
when(systemSettingService.getBool(eq(SkillCuratorJob.PAUSED_KEY), anyBoolean())).thenReturn(false);
when(systemSettingService.getBool(eq(SkillCuratorJob.FIRST_RUN_KEY), anyBoolean())).thenReturn(true);
stubSweep(List.of(unobservedCandidate(1L, now.minusDays(900))));
job.run();
// Stamped so the next sweep has a real anchor, but never judged this time.
verify(lifecycleService).markObserved(any(), any());
verify(lifecycleService, never()).planTransition(any(), any());
verify(lifecycleService, never()).apply(any(), any(), any());
ArgumentCaptor<SkillCuratorReport> cap = ArgumentCaptor.forClass(SkillCuratorReport.class);
verify(reportStore).write(cap.capture());
assertEquals(1, cap.getValue().getNewlyObserved());
assertEquals(0, cap.getValue().getPlanned().archived());
}
@Test
void dryRunDoesNotSeedButReachesTheSameVerdict() {
// The preview is what an operator reads before widening the scope, so
// it must not predict archives that a real run would defer while
// still writing nothing.
when(systemSettingService.getBool(eq(SkillCuratorJob.PAUSED_KEY), anyBoolean())).thenReturn(false);
when(systemSettingService.getBool(eq(SkillCuratorJob.FIRST_RUN_KEY), anyBoolean())).thenReturn(false);
when(systemSettingService.getString(eq(SkillCuratorJob.LAST_OBSERVED_KEY), any()))
.thenReturn(now.minusDays(2).toString());
stubSweep(List.of(unobservedCandidate(1L, now.minusDays(900))));
job.run();
verify(lifecycleService, never()).markObserved(any(), any());
verify(lifecycleService, never()).planTransition(any(), any());
ArgumentCaptor<SkillCuratorReport> cap = ArgumentCaptor.forClass(SkillCuratorReport.class);
verify(reportStore).write(cap.capture());
assertEquals(1, cap.getValue().getNewlyObserved());
assertEquals(0, cap.getValue().getPlanned().archived());
}
@Test
void reconcileReactivatesArchivedRowWhoseWorkspaceReturned() {
when(systemSettingService.getBool(eq(SkillCuratorJob.PAUSED_KEY), anyBoolean())).thenReturn(false);

View File

@ -1,4 +1,7 @@
package vip.mate.skill.lifecycle;
import vip.mate.skill.model.SkillOrigin;
import org.mockito.ArgumentCaptor;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.fasterxml.jackson.databind.ObjectMapper;
@ -6,6 +9,7 @@ import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.apache.ibatis.session.Configuration;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
@ -21,6 +25,7 @@ import vip.mate.skill.workspace.SkillWorkspaceProperties;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.eq;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@ -82,6 +87,125 @@ class SkillLifecycleServiceTest {
return s;
}
// ==================== adopt / release ====================
@Test
@DisplayName("adopting anchors to creation time — it does not buy a fresh window")
void adoptDoesNotResetTheIdleClock() {
// The operator hands over a skill knowing it is idle; granting it a new
// 90-day lease would defeat the reason they handed it over.
SkillEntity s = unobserved(now.minusDays(400));
s.setOrigin(null);
when(skillMapper.selectById(1L)).thenReturn(s);
service.setAdopted(1L, true);
ArgumentCaptor<LambdaUpdateWrapper<SkillEntity>> cap = updateCaptor();
verify(skillMapper).update(eq(null), cap.capture());
String sql = cap.getValue().getSqlSet();
assertTrue(sql.contains("origin"), sql);
assertTrue(sql.contains("curator_seen_at"), sql);
// With the anchor at creation time the skill ages immediately.
s.setCuratorSeenAt(s.getCreateTime());
assertEquals(LifecycleTransition.TO_ARCHIVED, service.planTransition(s, now));
}
@Test
@DisplayName("releasing hands ownership back and leaves the clock alone")
void releaseRestoresUserOwnership() {
SkillEntity s = skill("dynamic", "active", now.minusDays(10));
s.setOrigin(SkillOrigin.AGENT.code());
when(skillMapper.selectById(1L)).thenReturn(s);
service.setAdopted(1L, false);
ArgumentCaptor<LambdaUpdateWrapper<SkillEntity>> cap = updateCaptor();
verify(skillMapper).update(eq(null), cap.capture());
String sql = cap.getValue().getSqlSet();
assertTrue(sql.contains("origin"), sql);
assertFalse(sql.contains("curator_seen_at"), "release must not touch the clock: " + sql);
}
@Test
@DisplayName("an exempt skill cannot be adopted")
void exemptSkillIsNotAdoptable() {
SkillEntity builtin = skill("builtin", "active", now.minusDays(10));
builtin.setBuiltin(true);
when(skillMapper.selectById(1L)).thenReturn(builtin);
assertThrows(MateClawException.class, () -> service.setAdopted(1L, true));
verify(skillMapper, never()).update(any(), any());
}
@Test
@DisplayName("adopting a missing skill is a 404, not a silent no-op")
void adoptMissingSkillThrows() {
when(skillMapper.selectById(404L)).thenReturn(null);
assertThrows(MateClawException.class, () -> service.setAdopted(404L, true));
}
@SuppressWarnings("unchecked")
private ArgumentCaptor<LambdaUpdateWrapper<SkillEntity>> updateCaptor() {
return ArgumentCaptor.forClass((Class<LambdaUpdateWrapper<SkillEntity>>) (Class<?>) LambdaUpdateWrapper.class);
}
// ==================== observation anchor ====================
/** A skill curation has never seen: no activity, no observation stamp. */
private SkillEntity unobserved(LocalDateTime createdAt) {
SkillEntity s = skill("dynamic", "active", null);
s.setCreateTime(createdAt);
return s;
}
@Test
@DisplayName("a never-observed skill is deferred however old it is")
void unobservedSkillIsDeferred() {
// Widening the curator scope pulls in skills created years ago. Judging
// them on creation time would archive the whole batch on the first sweep.
SkillEntity s = unobserved(now.minusDays(900));
assertTrue(SkillLifecycleService.isUnobserved(s));
assertEquals(LifecycleTransition.NONE, service.planTransition(s, now));
}
@Test
@DisplayName("once observed, the idle clock runs from the observation, not creation")
void observationAnchorReplacesCreationTime() {
SkillEntity s = unobserved(now.minusDays(900));
s.setCuratorSeenAt(now.minusDays(2));
assertFalse(SkillLifecycleService.isUnobserved(s));
assertEquals(now.minusDays(2), SkillLifecycleService.anchor(s));
assertEquals(LifecycleTransition.NONE, service.planTransition(s, now));
}
@Test
@DisplayName("an observed skill ages normally once the threshold passes")
void observedSkillStillAges() {
SkillEntity s = unobserved(now.minusDays(900));
s.setCuratorSeenAt(now.minusDays(95));
assertEquals(LifecycleTransition.TO_ARCHIVED, service.planTransition(s, now));
}
@Test
@DisplayName("real activity outranks the observation stamp")
void activityOutranksObservation() {
SkillEntity s = skill("dynamic", "active", now.minusDays(1));
s.setCuratorSeenAt(now.minusDays(400));
assertEquals(now.minusDays(1), SkillLifecycleService.anchor(s));
assertEquals(LifecycleTransition.NONE, service.planTransition(s, now));
}
@Test
@DisplayName("creation time remains the fallback for rows predating the column")
void creationTimeRemainsFallback() {
SkillEntity s = skill("dynamic", "active", now.minusDays(95));
assertEquals(now.minusDays(95), SkillLifecycleService.anchor(s));
}
// ==================== planTransition ====================
@Test

View File

@ -327,6 +327,17 @@ export const skillApi = {
// ---- Curator restore points ----
/** List recent skill-library restore points (newest first). */
/** Skills currently under autonomous curation — the set that can be released. */
curatorManaged: () => http.get('/skills/curator/managed'),
/** Skills outside autonomous curation, with the reason each one is out. */
curatorUnmanaged: () => http.get('/skills/curator/unmanaged'),
/**
* Hand skills over to autonomous curation. Ids stay strings 19-digit
* snowflake ids lose precision through the JS Number type.
*/
curatorAdopt: (skillIds: string[]) => http.post('/skills/curator/adopt', skillIds),
/** Take skills back from autonomous curation. */
curatorRelease: (skillIds: string[]) => http.post('/skills/curator/release', skillIds),
curatorSnapshots: () => http.get('/skills/curator/snapshots'),
/** Capture a restore point on demand. */
curatorSnapshotCapture: (reason?: string) =>

View File

@ -4217,6 +4217,21 @@ export default {
snapshotRestore: 'Roll back to this',
snapshotRestoreConfirm: 'This rewrites every skill body and lifecycle state back to how they were at {time} ({n} skills), overwriting the current content. A restore point is captured first, so this is itself undoable. Continue?',
snapshotRestoreSuccess: 'Restored {restored} skill(s); {missing} no longer present and skipped',
unmanaged: 'Skills outside curation',
unmanagedHint: 'These skills are not subject to automatic archival. Handing one over puts it on the idle clock — adoption does not grant a fresh window, so an already-idle skill may be archived on the next sweep.',
managedList: 'Under curation',
unmanagedList: 'Outside curation',
noManaged: 'No skills under curation yet',
release: 'Release',
releaseSuccess: "Released '{name}'",
unobserved: 'Clock not started yet',
noUnmanaged: 'No unmanaged skills',
unmanagedReasonLegacy: 'Predates the provenance field; authorship unknowable',
unmanagedReasonUser: 'Created by a user',
unmanagedDaysIdle: 'Idle {n} day(s)',
adopt: 'Adopt',
adoptConfirm: 'Hand \'{name}\' over to autonomous curation? This does not reset its idle clock — if it is already long idle, the next sweep may archive it.',
adoptSuccess: 'Adopted \'{name}\'',
consolidateCreate: 'new',
consolidateEdit: 'edit',
activateSuccess: 'Skill curator activated',

View File

@ -4309,6 +4309,21 @@ export default {
snapshotRestore: '回滚到此',
snapshotRestoreConfirm: '将把所有技能的正文和生命周期状态回滚到 {time} 的状态(共 {n} 个技能),当前内容会被覆盖。回滚前会自动留一个还原点,所以此操作可以再撤销。确认继续?',
snapshotRestoreSuccess: '已恢复 {restored} 个技能,{missing} 个已不存在而跳过',
unmanaged: '未纳入治理的技能',
unmanagedHint: '这些技能不受自动归档管理。移交后由 curator 按闲置时长治理——移交不会重置闲置时钟,已经长期闲置的技能可能在下一轮就被归档。',
managedList: '已纳管',
unmanagedList: '未纳管',
noManaged: '还没有纳入治理的技能',
release: '撤销移交',
releaseSuccess: '已撤销「{name}」的移交',
unobserved: '尚未开始计时',
noUnmanaged: '没有未纳管的技能',
unmanagedReasonLegacy: '早于溯源字段,作者不可考',
unmanagedReasonUser: '用户创建',
unmanagedDaysIdle: '已闲置 {n} 天',
adopt: '移交治理',
adoptConfirm: '确定把「{name}」移交给自治治理?移交不会重置闲置时钟,若它已长期闲置,下一轮扫描可能直接归档。',
adoptSuccess: '已移交「{name}」',
consolidateCreate: '新建',
consolidateEdit: '更新',
activateSuccess: '技能管家已激活',

View File

@ -157,6 +157,64 @@
</ul>
</div>
<!-- Skills outside curation -->
<div class="settings-card">
<div class="card-head">
<h3 class="card-title">{{ t('skillCurator.unmanaged') }}</h3>
<div class="curator-actions">
<button class="btn-secondary" :disabled="busy" @click="loadUnmanaged">
{{ t('common.refresh', 'Refresh') }}
</button>
</div>
</div>
<p class="curator-hint">{{ t('skillCurator.unmanagedHint') }}</p>
<h4 class="roster-heading">{{ t('skillCurator.managedList') }}</h4>
<p v-if="managed.length === 0" class="empty-note">{{ t('skillCurator.noManaged') }}</p>
<ul v-else class="routine-list">
<li v-for="m in managed" :key="m.id" class="routine-item">
<div class="routine-main">
<p class="routine-text">{{ m.name }}</p>
<div class="routine-meta">
<span>{{ m.reason }}</span>
<span v-if="m.unobserved">{{ t('skillCurator.unobserved') }}</span>
<span v-else-if="m.daysIdle != null">
{{ t('skillCurator.unmanagedDaysIdle', { n: m.daysIdle }) }}
</span>
</div>
</div>
<div class="routine-actions">
<button class="btn-secondary" :disabled="busy" @click="release(m)">
{{ t('skillCurator.release') }}
</button>
</div>
</li>
</ul>
<h4 class="roster-heading">{{ t('skillCurator.unmanagedList') }}</h4>
<p v-if="unmanaged.length === 0" class="empty-note">{{ t('skillCurator.noUnmanaged') }}</p>
<ul v-else class="routine-list">
<li v-for="u in unmanaged" :key="u.id" class="routine-item">
<div class="routine-main">
<p class="routine-text">{{ u.name }}</p>
<div class="routine-meta">
<span>{{ u.reason === 'predates-provenance'
? t('skillCurator.unmanagedReasonLegacy')
: t('skillCurator.unmanagedReasonUser') }}</span>
<span v-if="u.daysIdle != null">
{{ t('skillCurator.unmanagedDaysIdle', { n: u.daysIdle }) }}
</span>
</div>
</div>
<div class="routine-actions">
<button class="btn-secondary" :disabled="busy" @click="adopt(u)">
{{ t('skillCurator.adopt') }}
</button>
</div>
</li>
</ul>
</div>
<!-- Restore points -->
<div class="settings-card">
<div class="card-head">
@ -299,6 +357,19 @@ interface SkillSnapshot {
createdAt: string | null
}
interface UnmanagedSkill {
/** Snowflake id kept as a string end-to-end — 19 digits exceed Number precision. */
id: string
name: string
description?: string | null
lifecycleState?: string | null
origin?: string | null
reason: string
/** No sweep has observed it yet, so its idle clock has not started. */
unobserved?: boolean
daysIdle?: number | null
}
const loading = ref(true)
const error = ref('')
const busy = ref(false)
@ -307,6 +378,8 @@ const routines = ref<RoutineCandidate[]>([])
const gates = ref<RoutineGates>({ minOccurrences: 3, minDistinctDays: 3, enabled: true })
const routineFilter = ref<string>('observing')
const snapshots = ref<SkillSnapshot[]>([])
const unmanaged = ref<UnmanagedSkill[]>([])
const managed = ref<UnmanagedSkill[]>([])
const routineFilters = [
{ value: 'observing', label: 'skillCurator.routineFilterObserving' },
@ -331,7 +404,7 @@ async function load() {
try {
const res: any = await skillApi.curatorStatus()
status.value = res.data as CuratorStatus
await Promise.all([loadReports(), loadRoutines(), loadSnapshots()])
await Promise.all([loadReports(), loadRoutines(), loadSnapshots(), loadUnmanaged()])
} catch (e: any) {
error.value = e?.message || t('skillCurator.loadFailed')
} finally {
@ -508,6 +581,59 @@ async function reopenRoutine(r: RoutineCandidate) {
}
}
// ==================== Skills outside curation ====================
async function loadUnmanaged() {
try {
const [un, mg]: any[] = await Promise.all([
skillApi.curatorUnmanaged(),
skillApi.curatorManaged(),
])
unmanaged.value = Array.isArray(un.data) ? un.data : []
managed.value = Array.isArray(mg.data) ? mg.data : []
} catch {
unmanaged.value = []
managed.value = []
}
}
async function release(u: UnmanagedSkill) {
busy.value = true
try {
await skillApi.curatorRelease([u.id])
mcToast.success(t('skillCurator.releaseSuccess', { name: u.name }))
await loadUnmanaged()
} catch (e: any) {
mcToast.error(e?.message || t('skillCurator.actionFailed'))
} finally {
busy.value = false
}
}
async function adopt(u: UnmanagedSkill) {
// Adoption does not grant a fresh idle window an already-idle skill can
// age out on the very next sweep so state that before acting on it.
try {
await ElMessageBox.confirm(
t('skillCurator.adoptConfirm', { name: u.name }),
t('skillCurator.adopt'),
{ type: 'warning' },
)
} catch {
return
}
busy.value = true
try {
await skillApi.curatorAdopt([u.id])
mcToast.success(t('skillCurator.adoptSuccess', { name: u.name }))
await loadUnmanaged()
} catch (e: any) {
mcToast.error(e?.message || t('skillCurator.actionFailed'))
} finally {
busy.value = false
}
}
// ==================== Restore points ====================
async function loadSnapshots() {
@ -604,6 +730,13 @@ onMounted(load)
.kv-row code { font-family: var(--mc-font-mono, ui-monospace, Menlo, monospace); font-size: 12px; }
.empty-note { margin: 0; font-size: 13px; color: var(--mc-text-tertiary); }
.roster-heading {
margin: 16px 0 8px;
font-size: 13px;
font-weight: 600;
color: var(--mc-text-secondary);
}
.roster-heading:first-of-type { margin-top: 8px; }
.report-list { display: flex; gap: 8px; flex-wrap: wrap; }
.report-item { font-family: var(--mc-font-mono, ui-monospace, Menlo, monospace); font-size: 12px; padding: 5px 10px; border-radius: 8px; border: 1px solid var(--mc-border); background: var(--mc-bg-muted); color: var(--mc-text-secondary); cursor: pointer; transition: all 0.15s; }
.report-item:hover { border-color: var(--mc-text-tertiary); }