mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(skill): features matrix + effective-tool expansion
This commit is contained in:
parent
323ba1b82e
commit
688b37b652
@ -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 为历史残留字段,不参与运行时选择)
|
||||
|
||||
@ -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>()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.5:manifest + 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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
// ==================== 综合状态 ====================
|
||||
|
||||
/**
|
||||
|
||||
Loading…
Reference in New Issue
Block a user