mateclaw/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java
matevip 579d60125b Initial commit: MateClaw — Java + Vue 3 AI Assistant System
Full-stack AI assistant built on Spring AI Alibaba.
Features: ReAct Agent, Plan-and-Execute, MCP Protocol, Multi-Model, Multi-Channel.

Apache-2.0 License
2026-04-04 19:03:49 +08:00

296 lines
12 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package vip.mate.skill.runtime;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.skill.model.SkillEntity;
import vip.mate.skill.runtime.model.ResolvedSkill;
import vip.mate.skill.workspace.SkillWorkspaceManager;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 技能包解析器
* 将 SkillEntity 解析为 ResolvedSkill运行时可用的技能包
* <p>
* 三级解析流程:
* <ol>
* <li>显式 skillDirconfigJson.skillDir→ source="directory"</li>
* <li>约定路径({workspace-root}/{skillName}/)→ source="convention"</li>
* <li>数据库 skillContent → source="database"</li>
* </ol>
* 解析后依次执行:安全扫描 → 依赖检查
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SkillPackageResolver {
private final SkillFrontmatterParser frontmatterParser;
private final SkillDirectoryScanner directoryScanner;
private final SkillSecurityService securityService;
private final SkillDependencyChecker dependencyChecker;
private final ObjectMapper objectMapper;
private final SkillWorkspaceManager workspaceManager;
/**
* 解析技能实体为运行时技能包(完整流程)
*/
public ResolvedSkill resolve(SkillEntity entity) {
String configuredDir = extractSkillDirString(entity);
Path skillDir = configuredDir != null ? Paths.get(configuredDir) : null;
ResolvedSkill resolved;
// 三级解析explicit → convention → database
if (skillDir != null && Files.exists(skillDir) && Files.isDirectory(skillDir)) {
// 1. 显式配置的 skillDir
resolved = resolveFromDirectory(entity, skillDir, configuredDir, "directory");
} else {
// 2. 约定路径 {workspace-root}/{skillName}/
Path conventionPath = workspaceManager.resolveConventionPath(entity.getName());
if (Files.exists(conventionPath) && Files.isDirectory(conventionPath)) {
resolved = resolveFromDirectory(entity, conventionPath, conventionPath.toString(), "convention");
} else {
// 3. 数据库 skillContent
resolved = resolveFromDatabase(entity, configuredDir);
}
}
// 2. 安全扫描
applySecurity(resolved);
// 3. 依赖检查
applyDependencyCheck(resolved);
// 4. 综合判定 runtimeAvailable
resolveRuntimeAvailability(resolved);
return resolved;
}
// ==================== 阶段 1内容解析 ====================
private ResolvedSkill resolveFromDirectory(SkillEntity entity, Path skillDir, String configuredDir, String source) {
Path skillMd = skillDir.resolve("SKILL.md");
String content = "";
String description = entity.getDescription();
if (Files.exists(skillMd)) {
try {
content = Files.readString(skillMd);
SkillFrontmatterParser.ParsedSkillMd parsed = frontmatterParser.parse(content);
if (!parsed.getDescription().isBlank()) {
description = parsed.getDescription();
}
} catch (Exception e) {
log.warn("Failed to read SKILL.md from {}: {}", skillMd, e.getMessage());
}
}
Map<String, Object> references = directoryScanner.buildDirectoryTree(skillDir.resolve("references"));
Map<String, Object> scripts = directoryScanner.buildDirectoryTree(skillDir.resolve("scripts"));
return ResolvedSkill.builder()
.name(entity.getName())
.description(description)
.content(content)
.source(source)
.skillDir(skillDir)
.configuredSkillDir(configuredDir)
.runtimeAvailable(true) // 暂定,后续安全/依赖检查可能改写
.resolutionError(null)
.references(references)
.scripts(scripts)
.enabled(Boolean.TRUE.equals(entity.getEnabled()))
.icon(entity.getIcon())
.builtin(Boolean.TRUE.equals(entity.getBuiltin()))
.build();
}
private ResolvedSkill resolveFromDatabase(SkillEntity entity, String configuredDir) {
String content = entity.getSkillContent() != null ? entity.getSkillContent() : "";
String description = entity.getDescription();
if (!content.isBlank()) {
SkillFrontmatterParser.ParsedSkillMd parsed = frontmatterParser.parse(content);
if (!parsed.getDescription().isBlank()) {
description = parsed.getDescription();
}
}
boolean hasContent = !content.isBlank();
String error = null;
if (configuredDir != null) {
error = "Configured skillDir not found: " + configuredDir;
if (hasContent) {
error += " (fallback to database skillContent)";
}
} else if (!hasContent) {
error = "No skillDir configured and no skillContent available";
}
return ResolvedSkill.builder()
.name(entity.getName())
.description(description)
.content(content)
.source("database")
.skillDir(null)
.configuredSkillDir(configuredDir)
.runtimeAvailable(hasContent || (description != null && !description.isBlank()))
.resolutionError(error)
.references(Map.of())
.scripts(Map.of())
.enabled(Boolean.TRUE.equals(entity.getEnabled()))
.icon(entity.getIcon())
.builtin(Boolean.TRUE.equals(entity.getBuiltin()))
.build();
}
// ==================== 阶段 2安全扫描 ====================
private void applySecurity(ResolvedSkill resolved) {
try {
SkillValidationResult result = securityService.validate(resolved);
boolean trustedBuiltin = resolved.isBuiltin() && result.isBlocked();
resolved.setSecurityBlocked(result.isBlocked() && !trustedBuiltin);
resolved.setSecuritySeverity(result.getMaxSeverity() != null ? result.getMaxSeverity().name() : null);
if (trustedBuiltin) {
resolved.setSecuritySummary("Builtin skill trusted: " + result.getSummary());
} else {
resolved.setSecuritySummary(result.getSummary());
}
resolved.setSecurityWarnings(result.getWarnings());
// 转换 findings 为 JSON 友好格式
if (result.getFindings() != null && !result.getFindings().isEmpty()) {
List<ResolvedSkill.SecurityFinding> secFindings = result.getFindings().stream()
.map(f -> ResolvedSkill.SecurityFinding.builder()
.ruleId(f.getRuleId())
.severity(f.getSeverity() != null ? f.getSeverity().name() : null)
.category(f.getCategory())
.title(f.getTitle())
.description(f.getDescription())
.filePath(f.getFilePath())
.lineNumber(f.getLineNumber())
.snippet(f.getSnippet())
.remediation(f.getRemediation())
.build())
.collect(Collectors.toList());
resolved.setSecurityFindings(secFindings);
}
if (trustedBuiltin) {
log.warn("Builtin skill '{}' bypassed security block: {}", resolved.getName(), result.getSummary());
} else if (result.isBlocked()) {
log.warn("Skill '{}' blocked by security scan: {}", resolved.getName(), result.getSummary());
} else if (result.getFindings() != null && !result.getFindings().isEmpty()) {
log.info("Skill '{}' security scan: {} finding(s)", resolved.getName(), result.getFindings().size());
}
} catch (Exception e) {
log.error("Security scan failed for skill '{}': {}", resolved.getName(), e.getMessage());
resolved.setSecurityWarnings(List.of("Security scan error: " + e.getMessage()));
}
}
// ==================== 阶段 3依赖检查 ====================
private void applyDependencyCheck(ResolvedSkill resolved) {
try {
// 解析 frontmatter 获取依赖声明
String content = resolved.getContent();
if (content == null || content.isBlank()) {
resolved.setDependencyReady(true);
resolved.setDependencySummary("No dependencies declared");
return;
}
SkillFrontmatterParser.ParsedSkillMd parsed = frontmatterParser.parse(content);
SkillFrontmatterParser.SkillDependencies deps = parsed.getDependencies();
List<String> platforms = parsed.getPlatforms();
if ((deps == null || deps.isEmpty()) && (platforms == null || platforms.isEmpty())) {
resolved.setDependencyReady(true);
resolved.setDependencySummary("No dependencies declared");
return;
}
SkillDependencyChecker.DependencyCheckResult result =
dependencyChecker.check(deps, platforms, resolved.getName());
resolved.setDependencyReady(result.isSatisfied());
resolved.setMissingDependencies(result.getMissing());
resolved.setDependencySummary(result.getSummary());
if (!result.isSatisfied()) {
log.info("Skill '{}' dependencies not satisfied: {}", resolved.getName(), result.getSummary());
}
} catch (Exception e) {
log.error("Dependency check failed for skill '{}': {}", resolved.getName(), e.getMessage());
resolved.setDependencyReady(true); // 检查失败不阻断
resolved.setDependencySummary("Dependency check error: " + e.getMessage());
}
}
// ==================== 阶段 4综合判定 ====================
private void resolveRuntimeAvailability(ResolvedSkill resolved) {
// 安全阻断 → 不可用
if (resolved.isSecurityBlocked()) {
resolved.setRuntimeAvailable(false);
if (resolved.getResolutionError() == null) {
resolved.setResolutionError("Security blocked: " + resolved.getSecuritySummary());
}
return;
}
// 依赖不满足 → 不可用
if (!resolved.isDependencyReady()) {
resolved.setRuntimeAvailable(false);
if (resolved.getResolutionError() == null) {
resolved.setResolutionError("Dependencies missing: " + resolved.getDependencySummary());
}
}
// 其他情况保留原有 runtimeAvailable 判定
}
// ==================== 工具方法 ====================
private String extractSkillDirString(SkillEntity entity) {
String configJson = entity.getConfigJson();
if (configJson == null || configJson.isBlank()) {
return null;
}
try {
@SuppressWarnings("unchecked")
Map<String, Object> config = objectMapper.readValue(configJson, Map.class);
String pathStr = null;
if (config.containsKey("skillDir")) {
pathStr = config.get("skillDir").toString();
} else if (config.containsKey("path")) {
pathStr = config.get("path").toString();
} else if (config.containsKey("directory")) {
pathStr = config.get("directory").toString();
}
if (pathStr != null && !pathStr.isBlank()) {
return pathStr;
}
} catch (Exception e) {
log.debug("Failed to parse configJson for skill {}: {}", entity.getName(), e.getMessage());
}
return null;
}
}