feat(skill): features matrix + effective-tool expansion

This commit is contained in:
matevip 2026-05-01 09:48:39 +08:00
parent 323ba1b82e
commit 688b37b652
6 changed files with 484 additions and 20 deletions

View File

@ -141,8 +141,12 @@ public class AgentGraphBuilder {
// 过滤掉 denied 工具使模型完全看不到它们防止 prompt injection 利用 schema
toolSet = toolSet.withDeniedToolsFiltered(toolGuardConfigService.getDeniedTools());
// Per-agent tool 绑定过滤如果 agent 有自定义 tool 绑定则只保留绑定的工具
Set<String> 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<String> boundTools = agentBindingService.getEffectiveToolNames(entity.getId());
toolSet = toolSet.withAllowedToolsOnly(boundTools); // null = 全局默认
// 统一使用全局默认模型AgentEntity.modelName 为历史残留字段不参与运行时选择

View File

@ -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.
*
* <p>Three-state semantics (mirrors {@link #getBoundSkillIds} /
* {@link #getBoundToolNames}):
* <ul>
* <li><b>{@code null} bound skills + {@code null} bound tools</b>
* returns {@code null}. Caller treats this as "no agent-level
* restriction; let the upstream {@code ToolSet} pass through
* its global default".</li>
* <li><b>at least one side non-null</b> returns the union, which
* may be empty (= "this agent is explicitly restricted to no
* tools"). The caller must distinguish empty from null.</li>
* </ul>
*
* <p>Skill expansion rules (§14.2):
* <ul>
* <li>Resolved skill found contribute
* {@code ResolvedSkill.getEffectiveAllowedTools()} only tools
* whose owning feature is READY (unavailable features stay
* hidden from the LLM, §10.2 Q8).</li>
* <li>Skill bound but unresolved (e.g. legacy or missing manifest)
* contribute nothing through this path; legacy SKILL.md prompt
* enhancement still runs separately.</li>
* </ul>
*/
public Set<String> getEffectiveToolNames(Long agentId) {
Set<Long> boundSkillIds = getBoundSkillIds(agentId);
Set<String> directTools = getBoundToolNames(agentId);
// (1) null + null no restriction; defer to the global default.
if (boundSkillIds == null && directTools == null) {
return null;
}
Set<String> merged = new LinkedHashSet<>();
if (boundSkillIds != null) {
for (Long skillId : boundSkillIds) {
ResolvedSkill resolved = findResolvedSkillById(skillId);
if (resolved == null) continue;
Set<String> 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<AgentToolBinding>()

View File

@ -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<String, Boolean> 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.
*
* <p>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).
*
* <p>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<String> missing = new ArrayList<>();
private String reason;
}
/**
* Probe one manifest requirement.
*
* <p>{@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.
*
* <p>Status semantics:
* <ul>
* <li>{@code UNSUPPORTED} current OS not in {@code feature.platforms}</li>
* <li>{@code SETUP_NEEDED} at least one referenced requirement is missing</li>
* <li>{@code READY} all referenced requirements satisfied (or unknown)</li>
* </ul>
* 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<String, SkillManifest.RequirementDef> requirementsByKey) {
FeatureCheckResult.FeatureCheckResultBuilder b = FeatureCheckResult.builder()
.featureId(feature == null ? null : feature.getId());
if (feature == null) {
return b.status("READY").build();
}
// 1. Platform gate
List<String> 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<String> 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

View File

@ -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<SkillEntity>()
.eq(SkillEntity::getId, entity.getId())
.set(SkillEntity::getSecurityScanStatus, newStatus)
.set(SkillEntity::getSecurityScanResult, newJson)
.set(SkillEntity::getSecurityScanTime, now));
LambdaUpdateWrapper<SkillEntity> wrapper = new LambdaUpdateWrapper<SkillEntity>()
.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<String, Object> 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.5manifest + features (RFC-090 §14.1 / §14.6) ====================
/**
* Parse the SKILL.md frontmatter into a typed manifest and evaluate
* the {@code features[]} matrix.
*
* <p>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<String, SkillManifest.RequirementDef> reqByKey = new LinkedHashMap<>();
for (SkillManifest.RequirementDef r : manifest.getRequires()) {
if (r.getKey() != null) reqByKey.put(r.getKey(), r);
}
List<SkillManifest.FeatureDef> features = manifest.getFeatures();
List<SkillManifest.FeatureDef> effectiveFeatures;
if (features == null || features.isEmpty()) {
// Synthesized "default" feature carrying top-level requires
// + platforms so legacy skills get the same status path.
List<String> 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<String, String> statuses = new LinkedHashMap<>();
Set<String> 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) {

View File

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

View File

@ -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}.
*
* <p>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<String, String> featureStatuses = Map.of();
/**
* Set of feature IDs whose status is READY. Derived from
* {@link #featureStatuses}; populated by the resolver.
*/
@Builder.Default
private Set<String> 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.
*
* <p>Behavior:
* <ul>
* <li>No manifest at all {@link Set#of()} (caller should treat as
* "no skill-derived tools" and rely on legacy paths).</li>
* <li>Manifest with no {@code features[]} return {@code allowedTools}
* wholesale (legacy behavior).</li>
* <li>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.</li>
* </ul>
*/
public Set<String> getEffectiveAllowedTools() {
if (manifest == null) return Set.of();
List<String> 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<String> out = new LinkedHashSet<>();
Set<String> 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;
}
// ==================== 综合状态 ====================
/**