diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index 3748a3a9..880d361a 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -141,8 +141,12 @@ public class AgentGraphBuilder { // 过滤掉 denied 工具,使模型完全看不到它们(防止 prompt injection 利用 schema) toolSet = toolSet.withDeniedToolsFiltered(toolGuardConfigService.getDeniedTools()); - // Per-agent tool 绑定过滤:如果 agent 有自定义 tool 绑定,则只保留绑定的工具 - Set boundTools = agentBindingService.getBoundToolNames(entity.getId()); + // RFC-090 §14.2 — single entry point that merges: + // (a) tools expanded from bound skills' active features, and + // (b) directly bound atomic tools (the Advanced bypass, §9.2 调整 B). + // Three-state semantics: null = no agent-level restriction (use + // global default); non-null (possibly empty) = explicit allowlist. + Set boundTools = agentBindingService.getEffectiveToolNames(entity.getId()); toolSet = toolSet.withAllowedToolsOnly(boundTools); // null = 全局默认 // 统一使用全局默认模型(AgentEntity.modelName 为历史残留字段,不参与运行时选择) diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java index f0c3532c..b88cfe00 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java @@ -3,6 +3,8 @@ package vip.mate.agent.binding.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; 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.agent.binding.model.AgentProviderPreference; import vip.mate.agent.binding.model.AgentSkillBinding; @@ -10,8 +12,11 @@ import vip.mate.agent.binding.model.AgentToolBinding; import vip.mate.agent.binding.repository.AgentProviderPreferenceMapper; import vip.mate.agent.binding.repository.AgentSkillBindingMapper; import vip.mate.agent.binding.repository.AgentToolBindingMapper; +import vip.mate.skill.runtime.SkillRuntimeService; +import vip.mate.skill.runtime.model.ResolvedSkill; import java.util.Collections; +import java.util.LinkedHashSet; import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -27,12 +32,28 @@ import java.util.stream.Collectors; */ @Slf4j @Service -@RequiredArgsConstructor public class AgentBindingService { private final AgentSkillBindingMapper skillBindingMapper; private final AgentToolBindingMapper toolBindingMapper; private final AgentProviderPreferenceMapper providerPreferenceMapper; + /** + * {@code @Lazy} — SkillRuntimeService and AgentBindingService both sit + * near the agent boot path; the lazy proxy avoids a circular bean + * graph when SkillRuntimeService initializes after binding. + */ + private final SkillRuntimeService skillRuntimeService; + + @Autowired + public AgentBindingService(AgentSkillBindingMapper skillBindingMapper, + AgentToolBindingMapper toolBindingMapper, + AgentProviderPreferenceMapper providerPreferenceMapper, + @Lazy SkillRuntimeService skillRuntimeService) { + this.skillBindingMapper = skillBindingMapper; + this.toolBindingMapper = toolBindingMapper; + this.providerPreferenceMapper = providerPreferenceMapper; + this.skillRuntimeService = skillRuntimeService; + } // ==================== Skill Bindings ==================== @@ -128,6 +149,72 @@ public class AgentBindingService { .collect(Collectors.toSet()); } + /** + * RFC-090 §14.2 — single entry point that maps an agent's bindings to + * the set of tool names allowed at runtime. + * + *

Three-state semantics (mirrors {@link #getBoundSkillIds} / + * {@link #getBoundToolNames}): + *

+ * + *

Skill expansion rules (§14.2): + *

+ */ + public Set getEffectiveToolNames(Long agentId) { + Set boundSkillIds = getBoundSkillIds(agentId); + Set directTools = getBoundToolNames(agentId); + + // (1) null + null → no restriction; defer to the global default. + if (boundSkillIds == null && directTools == null) { + return null; + } + + Set merged = new LinkedHashSet<>(); + + if (boundSkillIds != null) { + for (Long skillId : boundSkillIds) { + ResolvedSkill resolved = findResolvedSkillById(skillId); + if (resolved == null) continue; + Set skillTools = resolved.getEffectiveAllowedTools(); + if (skillTools != null && !skillTools.isEmpty()) merged.addAll(skillTools); + } + } + + if (directTools != null) { + // ∪ Advanced 直选的原子 tool(§9.2 调整 B) + merged.addAll(directTools); + } + + return merged; + } + + private ResolvedSkill findResolvedSkillById(Long skillId) { + if (skillId == null || skillRuntimeService == null) return null; + // resolveAllSkillsStatus returns every skill in the catalog, not + // just the active ones, so we still see READY/SETUP_NEEDED status + // for bound but partially-unsatisfied skills. + return skillRuntimeService.resolveAllSkillsStatus().stream() + .filter(s -> s != null && skillId.equals(s.getId())) + .findFirst() + .orElse(null); + } + public AgentToolBinding bindTool(Long agentId, String toolName) { AgentToolBinding existing = toolBindingMapper.selectOne( new LambdaQueryWrapper() diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillDependencyChecker.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillDependencyChecker.java index f6102be1..eb8ec366 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillDependencyChecker.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillDependencyChecker.java @@ -1,12 +1,14 @@ package vip.mate.skill.runtime; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; import lombok.Builder; import lombok.Data; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; +import vip.mate.skill.manifest.SkillManifest; import vip.mate.skill.runtime.SkillFrontmatterParser.SkillDependencies; import vip.mate.tool.ToolRegistry; import vip.mate.tool.model.ToolEntity; @@ -14,9 +16,11 @@ import vip.mate.tool.repository.ToolMapper; import java.io.BufferedReader; import java.io.InputStreamReader; +import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Set; import java.util.concurrent.TimeUnit; @@ -43,6 +47,18 @@ public class SkillDependencyChecker { private static final String CURRENT_OS = detectOS(); + /** + * 60s in-memory cache for command-availability probes (RFC-090 §14.7). + * Each {@code refreshActiveSkills()} pass calls + * {@link #isCommandAvailable(String)} once per declared binary; without + * caching that triples ProcessBuilder calls when many skills share the + * same prereq (e.g. python3 / ffmpeg). + */ + private final Cache commandAvailability = Caffeine.newBuilder() + .expireAfterWrite(Duration.ofSeconds(60)) + .maximumSize(256) + .build(); + /** * 检查依赖 */ @@ -112,6 +128,14 @@ public class SkillDependencyChecker { // ==================== 检查方法 ==================== private boolean isCommandAvailable(String command) { + Boolean cached = commandAvailability.getIfPresent(command); + if (cached != null) return cached; + boolean available = probeCommand(command); + commandAvailability.put(command, available); + return available; + } + + private boolean probeCommand(String command) { try { String checkCmd = isWindows() ? "where" : "which"; ProcessBuilder pb = new ProcessBuilder(checkCmd, command); @@ -188,6 +212,140 @@ public class SkillDependencyChecker { return "Missing: " + String.join(", ", missing); } + // ==================== RFC-090 Phase 2 — per-feature checks (§14.1 / §14.7) ==================== + + /** + * RFC-090 §14.7 — typed requirement classes. + * + *

Drives {@link #checkRequirement} so callers can ask for a specific + * probe regardless of how the manifest expressed it. {@code ANY} is the + * fallback when the manifest doesn't declare a type — we infer. + */ + public enum RequirementType { BINARY, ENV_VAR, API_KEY, ANY } + + /** + * Status for a single requirement after probing. + */ + public enum RequirementStatus { SATISFIED, MISSING, UNKNOWN } + + /** + * Per-feature evaluation result (RFC-090 §14.1). + * + *

Used by {@code SkillPackageResolver} to populate + * {@code featureStatuses} on {@code ResolvedSkill}. + */ + @Data + @Builder + public static class FeatureCheckResult { + private String featureId; + /** READY | SETUP_NEEDED | UNSUPPORTED — mirrors ResolvedSkill.FeatureStatus. */ + private String status; + @Builder.Default + private List missing = new ArrayList<>(); + private String reason; + } + + /** + * Probe one manifest requirement. + * + *

{@code env_var} / {@code api_key} are equivalent for the purposes + * of this check — both go through {@link System#getenv(String)} on + * the {@code check} target. + */ + public RequirementStatus checkRequirement(SkillManifest.RequirementDef req) { + if (req == null || req.getKey() == null) return RequirementStatus.UNKNOWN; + RequirementType type = inferType(req); + String target = req.getCheck() == null || req.getCheck().isBlank() ? req.getKey() : req.getCheck(); + try { + return switch (type) { + case BINARY -> isCommandAvailable(target) ? RequirementStatus.SATISFIED : RequirementStatus.MISSING; + case ENV_VAR, API_KEY -> { + String value = System.getenv(target); + yield (value != null && !value.isBlank()) ? RequirementStatus.SATISFIED : RequirementStatus.MISSING; + } + case ANY -> RequirementStatus.UNKNOWN; + }; + } catch (Exception e) { + log.debug("Requirement check failed for '{}': {}", req.getKey(), e.getMessage()); + return RequirementStatus.UNKNOWN; + } + } + + private RequirementType inferType(SkillManifest.RequirementDef req) { + String declared = req.getType(); + if (declared != null) { + return switch (declared.toLowerCase(Locale.ROOT)) { + case "binary" -> RequirementType.BINARY; + case "env_var", "env" -> RequirementType.ENV_VAR; + case "api_key", "key" -> RequirementType.API_KEY; + default -> RequirementType.ANY; + }; + } + // Fall back to a key-prefix heuristic so legacy manifests work: + // synthesized "cmd:..." / "env:..." keys get the right type. + if (req.getKey() != null) { + String k = req.getKey().toLowerCase(Locale.ROOT); + if (k.startsWith("cmd:") || k.startsWith("bin:")) return RequirementType.BINARY; + if (k.startsWith("env:")) return RequirementType.ENV_VAR; + if (k.endsWith("_api_key") || k.endsWith("_key")) return RequirementType.API_KEY; + } + return RequirementType.ANY; + } + + /** + * Evaluate a single feature against a manifest's requirement table. + * + *

Status semantics: + *

    + *
  • {@code UNSUPPORTED} — current OS not in {@code feature.platforms}
  • + *
  • {@code SETUP_NEEDED} — at least one referenced requirement is missing
  • + *
  • {@code READY} — all referenced requirements satisfied (or unknown)
  • + *
+ * Empty {@code requires}/{@code platforms} on a feature means + * "no constraint" — the feature is always READY for that axis. + */ + public FeatureCheckResult checkFeature(SkillManifest.FeatureDef feature, + Map requirementsByKey) { + FeatureCheckResult.FeatureCheckResultBuilder b = FeatureCheckResult.builder() + .featureId(feature == null ? null : feature.getId()); + if (feature == null) { + return b.status("READY").build(); + } + + // 1. Platform gate + List platforms = feature.getPlatforms(); + if (platforms != null && !platforms.isEmpty()) { + boolean platformMatch = platforms.stream().anyMatch(p -> p.equalsIgnoreCase(CURRENT_OS)); + if (!platformMatch) { + String reason = feature.getUnsupportedMessage() != null && !feature.getUnsupportedMessage().isBlank() + ? feature.getUnsupportedMessage() + : "Unsupported on " + CURRENT_OS; + return b.status("UNSUPPORTED").reason(reason).build(); + } + } + + // 2. Requirement gate + List missing = new ArrayList<>(); + if (feature.getRequires() != null && !feature.getRequires().isEmpty() + && requirementsByKey != null) { + for (String key : feature.getRequires()) { + SkillManifest.RequirementDef req = requirementsByKey.get(key); + if (req == null) { + missing.add(key + " (undeclared)"); + continue; + } + RequirementStatus st = checkRequirement(req); + if (st == RequirementStatus.MISSING) missing.add(key); + } + } + if (!missing.isEmpty()) { + return b.status("SETUP_NEEDED").missing(missing) + .reason(feature.getFallbackMessage()) + .build(); + } + return b.status("READY").build(); + } + // ==================== 结果模型 ==================== @Data diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java index 6afcc6fd..cfc28da2 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java @@ -5,6 +5,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import vip.mate.skill.manifest.SkillManifest; +import vip.mate.skill.manifest.SkillManifestParser; import vip.mate.skill.model.SkillEntity; import vip.mate.skill.repository.SkillMapper; import vip.mate.skill.runtime.model.ResolvedSkill; @@ -14,9 +16,13 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.stream.Collectors; /** @@ -37,6 +43,7 @@ import java.util.stream.Collectors; public class SkillPackageResolver { private final SkillFrontmatterParser frontmatterParser; + private final SkillManifestParser manifestParser; private final SkillDirectoryScanner directoryScanner; private final SkillSecurityService securityService; private final SkillDependencyChecker dependencyChecker; @@ -74,11 +81,15 @@ public class SkillPackageResolver { // 3. 依赖检查 applyDependencyCheck(resolved); - // 4. 综合判定 runtimeAvailable + // 4. RFC-090 §14.1 — manifest + features 矩阵 + applyManifestAndFeatures(resolved); + + // 5. 综合判定 runtimeAvailable resolveRuntimeAvailability(resolved); - // 5. RFC-042 §2.3 — persist the scan outcome so the admin UI can show - // findings after a restart (previously they lived only in memory). + // 6. RFC-042 §2.3 + RFC-090 §14.6 — persist scan outcome and + // project manifest_json back to the row so legacy columns + // stay in sync. persistScanOutcome(entity, resolved); return resolved; @@ -99,10 +110,32 @@ public class SkillPackageResolver { String newStatus = deriveScanStatus(resolved); String newJson = serializeFindings(resolved.getSecurityFindings()); + String newManifestJson = serializeManifest(resolved.getManifest()); + boolean statusChanged = !Objects.equals(entity.getSecurityScanStatus(), newStatus); boolean findingsChanged = !Objects.equals(entity.getSecurityScanResult(), newJson); + boolean manifestChanged = !Objects.equals(entity.getManifestJson(), newManifestJson); - if (!statusChanged && !findingsChanged) { + // RFC-090 §14.6 — column projection from manifest (SoT). + // Snapshot pre-projection values so we know which legacy columns + // need a row-level update. Skipped when the manifest is null. + String newSkillType = entity.getSkillType(); + String newIcon = entity.getIcon(); + String newVersion = entity.getVersion(); + String newAuthor = entity.getAuthor(); + if (resolved.getManifest() != null) { + SkillManifest m = resolved.getManifest(); + if (m.getType() != null && !m.getType().isBlank()) newSkillType = m.getType(); + if (m.getIcon() != null && !m.getIcon().isBlank()) newIcon = m.getIcon(); + if (m.getVersion() != null && !m.getVersion().isBlank()) newVersion = m.getVersion(); + if (m.getAuthor() != null && !m.getAuthor().isBlank()) newAuthor = m.getAuthor(); + } + boolean projectionChanged = !Objects.equals(entity.getSkillType(), newSkillType) + || !Objects.equals(entity.getIcon(), newIcon) + || !Objects.equals(entity.getVersion(), newVersion) + || !Objects.equals(entity.getAuthor(), newAuthor); + + if (!statusChanged && !findingsChanged && !manifestChanged && !projectionChanged) { return; } @@ -110,26 +143,53 @@ public class SkillPackageResolver { // Whitelist via LambdaUpdateWrapper (issue #45): SkillEntity has // several @TableField(updateStrategy = FieldStrategy.ALWAYS) // columns (skill_content, config_json, source_code, name_zh, - // name_en, security_scan_result). Calling updateById with a - // partial entity would tell MyBatis Plus to write NULL to every - // ALWAYS column not set on the partial — wiping the imported + // name_en, security_scan_result, manifest_json). Calling updateById + // with a partial entity would tell MyBatis Plus to write NULL to + // every ALWAYS column not set on the partial — wiping the imported // skill content on every scan write-back. LocalDateTime now = LocalDateTime.now(); - skillMapper.update(null, new LambdaUpdateWrapper() - .eq(SkillEntity::getId, entity.getId()) - .set(SkillEntity::getSecurityScanStatus, newStatus) - .set(SkillEntity::getSecurityScanResult, newJson) - .set(SkillEntity::getSecurityScanTime, now)); + LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper() + .eq(SkillEntity::getId, entity.getId()); + if (statusChanged) wrapper.set(SkillEntity::getSecurityScanStatus, newStatus); + if (findingsChanged) wrapper.set(SkillEntity::getSecurityScanResult, newJson); + if (manifestChanged) wrapper.set(SkillEntity::getManifestJson, newManifestJson); + if (projectionChanged) { + wrapper.set(SkillEntity::getSkillType, newSkillType) + .set(SkillEntity::getIcon, newIcon) + .set(SkillEntity::getVersion, newVersion) + .set(SkillEntity::getAuthor, newAuthor); + } + // Always update scan time when something changed so we have a + // freshness indicator for the admin UI. + if (statusChanged || findingsChanged) wrapper.set(SkillEntity::getSecurityScanTime, now); + skillMapper.update(null, wrapper); // Keep the in-memory entity coherent with the DB so the next // resolve in the same tick doesn't redundantly write again. - entity.setSecurityScanStatus(newStatus); - entity.setSecurityScanResult(newJson); - entity.setSecurityScanTime(now); + if (statusChanged) entity.setSecurityScanStatus(newStatus); + if (findingsChanged) entity.setSecurityScanResult(newJson); + if (statusChanged || findingsChanged) entity.setSecurityScanTime(now); + if (manifestChanged) entity.setManifestJson(newManifestJson); + if (projectionChanged) { + entity.setSkillType(newSkillType); + entity.setIcon(newIcon); + entity.setVersion(newVersion); + entity.setAuthor(newAuthor); + } } catch (Exception e) { log.warn("Failed to persist scan outcome for skill '{}': {}", entity.getName(), e.getMessage()); } } + private String serializeManifest(SkillManifest manifest) { + if (manifest == null) return null; + try { + return objectMapper.writeValueAsString(manifest); + } catch (Exception e) { + log.debug("Failed to serialize manifest: {}", e.getMessage()); + return null; + } + } + /** * Collapse the resolver's rich security state back into the {@code * PASSED / FAILED / null} tri-state used on the row. @@ -177,6 +237,7 @@ public class SkillPackageResolver { Map scripts = directoryScanner.buildDirectoryTree(skillDir.resolve("scripts")); return ResolvedSkill.builder() + .id(entity.getId()) .name(entity.getName()) .description(description) .content(content) @@ -216,6 +277,7 @@ public class SkillPackageResolver { } return ResolvedSkill.builder() + .id(entity.getId()) .name(entity.getName()) .description(description) .content(content) @@ -317,6 +379,70 @@ public class SkillPackageResolver { } } + // ==================== 阶段 3.5:manifest + features (RFC-090 §14.1 / §14.6) ==================== + + /** + * Parse the SKILL.md frontmatter into a typed manifest and evaluate + * the {@code features[]} matrix. + * + *

Backward compat: when the manifest declares no {@code features[]}, + * we synthesize a single feature {@code "default"} that inherits the + * top-level requires + platforms — giving legacy skills the same status + * (READY iff all top-level requirements are satisfied) without code + * changes upstream. + */ + private void applyManifestAndFeatures(ResolvedSkill resolved) { + try { + String content = resolved.getContent(); + SkillManifest manifest = manifestParser.parse(content); + if (manifest == null) { + // No frontmatter at all: leave manifest null and let + // legacy callers continue using dependencyReady. + return; + } + resolved.setManifest(manifest); + + // Build requirement lookup for feature checks. + Map reqByKey = new LinkedHashMap<>(); + for (SkillManifest.RequirementDef r : manifest.getRequires()) { + if (r.getKey() != null) reqByKey.put(r.getKey(), r); + } + + List features = manifest.getFeatures(); + List effectiveFeatures; + if (features == null || features.isEmpty()) { + // Synthesized "default" feature carrying top-level requires + // + platforms so legacy skills get the same status path. + List defaultRequires = new ArrayList<>(); + for (SkillManifest.RequirementDef r : manifest.getRequires()) { + if (r.getKey() != null && !r.isOptional()) defaultRequires.add(r.getKey()); + } + effectiveFeatures = List.of(SkillManifest.FeatureDef.builder() + .id("default") + .label(manifest.getName() != null ? manifest.getName() : "default") + .requires(defaultRequires) + .platforms(manifest.getPlatforms()) + .build()); + } else { + effectiveFeatures = features; + } + + Map statuses = new LinkedHashMap<>(); + Set active = new LinkedHashSet<>(); + for (SkillManifest.FeatureDef f : effectiveFeatures) { + if (f.getId() == null || f.getId().isBlank()) continue; + SkillDependencyChecker.FeatureCheckResult res = dependencyChecker.checkFeature(f, reqByKey); + statuses.put(f.getId(), res.getStatus()); + if ("READY".equals(res.getStatus())) active.add(f.getId()); + } + resolved.setFeatureStatuses(statuses); + resolved.setActiveFeatures(active); + } catch (Exception e) { + log.warn("Manifest/feature evaluation failed for skill '{}': {}", + resolved.getName(), e.getMessage()); + } + } + // ==================== 阶段 4:综合判定 ==================== private void resolveRuntimeAvailability(ResolvedSkill resolved) { diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java index 09045ce4..1badfe55 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java @@ -85,7 +85,12 @@ public class SkillRuntimeService { .filter(ResolvedSkill::isEnabled) .filter(ResolvedSkill::isRuntimeAvailable) .filter(s -> !s.isSecurityBlocked()) - .filter(ResolvedSkill::isDependencyReady) + // RFC-090 §14.1 — features-aware gate. When a manifest is + // present, require at least one READY feature. Legacy skills + // (no manifest) fall back to the old dependencyReady boolean. + .filter(s -> s.getManifest() == null + ? s.isDependencyReady() + : s.hasAnyActiveFeature()) .collect(Collectors.toList()); activeSkillsCache.put(CACHE_KEY, resolved); diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/model/ResolvedSkill.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/model/ResolvedSkill.java index 726e4777..876777ad 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/model/ResolvedSkill.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/model/ResolvedSkill.java @@ -3,10 +3,13 @@ package vip.mate.skill.runtime.model; import com.fasterxml.jackson.annotation.JsonIgnore; import lombok.Builder; import lombok.Data; +import vip.mate.skill.manifest.SkillManifest; import java.nio.file.Path; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; /** * 运行时已解析的技能包 @@ -18,6 +21,9 @@ public class ResolvedSkill { // ==================== 基础信息 ==================== + /** RFC-090 Phase 2 — 技能实体主键,便于按 id 反查。 */ + private Long id; + /** 技能名称 */ private String name; @@ -96,6 +102,84 @@ public class ResolvedSkill { /** 依赖状态摘要 */ private String dependencySummary; + // ==================== RFC-090 §14.1 — features 矩阵 ==================== + + /** + * Parsed manifest (RFC-090 §14.6 SoT). Null for legacy skills with no + * frontmatter; in that case feature/manifest-aware code falls back to + * legacy fields above. + */ + private SkillManifest manifest; + + /** + * Per-feature status keyed by {@code feature.id}. Values come from + * {@code SkillDependencyChecker.FeatureCheckResult.status}: one of + * {@code READY / SETUP_NEEDED / UNSUPPORTED}. + * + *

For backward compat: if the manifest declares no {@code features[]} + * block, the resolver synthesizes a single feature {@code "default"} + * carrying the top-level requires + platforms. + */ + @Builder.Default + private Map featureStatuses = Map.of(); + + /** + * Set of feature IDs whose status is READY. Derived from + * {@link #featureStatuses}; populated by the resolver. + */ + @Builder.Default + private Set activeFeatures = Set.of(); + + /** RFC-090 §14.1 — replacement filter for {@code dependencyReady}. */ + public boolean hasAnyActiveFeature() { + return activeFeatures != null && !activeFeatures.isEmpty(); + } + + /** + * RFC-090 §14.2 — tools that should be advertised to the LLM, given + * the current feature statuses. + * + *

Behavior: + *

    + *
  • No manifest at all → {@link Set#of()} (caller should treat as + * "no skill-derived tools" and rely on legacy paths).
  • + *
  • Manifest with no {@code features[]} → return {@code allowedTools} + * wholesale (legacy behavior).
  • + *
  • Manifest with features → only tools whose owning feature is + * READY. A feature with empty {@code feature.tools} is treated as + * "inherit the manifest-level allowed-tools" so a single-feature + * skill matches the no-features case.
  • + *
+ */ + public Set getEffectiveAllowedTools() { + if (manifest == null) return Set.of(); + List base = manifest.getAllowedTools(); + if (base == null) base = List.of(); + + // No features → wholesale allowedTools + if (manifest.getFeatures() == null || manifest.getFeatures().isEmpty()) { + return base.isEmpty() ? Set.of() : new LinkedHashSet<>(base); + } + + Set out = new LinkedHashSet<>(); + Set active = activeFeatures == null ? Set.of() : activeFeatures; + boolean anyFeatureUsedInheritance = false; + for (SkillManifest.FeatureDef f : manifest.getFeatures()) { + if (!active.contains(f.getId())) continue; + if (f.getTools() == null || f.getTools().isEmpty()) { + // Inherit manifest-level allowed-tools for any active + // feature that doesn't carve out a tool subset. + anyFeatureUsedInheritance = true; + continue; + } + out.addAll(f.getTools()); + } + if (anyFeatureUsedInheritance) { + out.addAll(base); + } + return out; + } + // ==================== 综合状态 ==================== /**