From 13e848ddf4cfa8f4cbfb672c1cb8ffb4f304646e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Mon, 8 Jun 2026 16:52:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=8A=80=E8=83=BD?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E6=A8=A1=E5=9D=97=E8=AF=95=E9=AA=8C=E6=80=A7?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E7=AD=89=E5=BE=85=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- easy-agents-bom/pom.xml | 4 + easy-agents-skill/pom.xml | 27 ++ .../skill/codec/SkillPackageCodec.java | 20 ++ .../skill/codec/ZipSkillPackageCodec.java | 254 ++++++++++++++++ .../skill/exception/SkillException.java | 26 ++ .../exception/SkillPackageException.java | 26 ++ .../exception/SkillValidationException.java | 26 ++ .../skill/factory/SkillFactory.java | 52 ++++ .../com/easyagents/skill/model/Skill.java | 175 +++++++++++ .../easyagents/skill/model/SkillAsset.java | 145 +++++++++ .../skill/model/SkillDescriptor.java | 111 +++++++ .../easyagents/skill/model/SkillMetadata.java | 79 +++++ .../skill/model/SkillReference.java | 126 ++++++++ .../easyagents/skill/model/SkillScript.java | 126 ++++++++ .../skill/model/SkillScriptLanguage.java | 50 ++++ .../skill/repository/SkillRepository.java | 58 ++++ .../memory/InMemorySkillRepository.java | 176 +++++++++++ .../skill/store/SkillContentStore.java | 41 +++ .../memory/InMemorySkillContentStore.java | 70 +++++ .../skill/util/SkillFrontmatter.java | 89 ++++++ .../easyagents/skill/util/SkillHashes.java | 47 +++ .../com/easyagents/skill/util/SkillPaths.java | 118 ++++++++ .../skill/validation/SkillValidator.java | 16 + .../defaults/DefaultSkillValidator.java | 156 ++++++++++ .../skill/codec/ZipSkillPackageCodecTest.java | 275 ++++++++++++++++++ .../memory/InMemorySkillRepositoryTest.java | 83 ++++++ .../memory/InMemorySkillContentStoreTest.java | 46 +++ .../defaults/DefaultSkillValidatorTest.java | 193 ++++++++++++ pom.xml | 7 + 29 files changed, 2622 insertions(+) create mode 100644 easy-agents-skill/pom.xml create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/codec/SkillPackageCodec.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/codec/ZipSkillPackageCodec.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/exception/SkillException.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/exception/SkillPackageException.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/exception/SkillValidationException.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/factory/SkillFactory.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/model/Skill.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillAsset.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillDescriptor.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillMetadata.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillReference.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillScript.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillScriptLanguage.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/repository/SkillRepository.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/repository/memory/InMemorySkillRepository.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/store/SkillContentStore.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/store/memory/InMemorySkillContentStore.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillFrontmatter.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillHashes.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillPaths.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/validation/SkillValidator.java create mode 100644 easy-agents-skill/src/main/java/com/easyagents/skill/validation/defaults/DefaultSkillValidator.java create mode 100644 easy-agents-skill/src/test/java/com/easyagents/skill/codec/ZipSkillPackageCodecTest.java create mode 100644 easy-agents-skill/src/test/java/com/easyagents/skill/repository/memory/InMemorySkillRepositoryTest.java create mode 100644 easy-agents-skill/src/test/java/com/easyagents/skill/store/memory/InMemorySkillContentStoreTest.java create mode 100644 easy-agents-skill/src/test/java/com/easyagents/skill/validation/defaults/DefaultSkillValidatorTest.java diff --git a/easy-agents-bom/pom.xml b/easy-agents-bom/pom.xml index 4911bcc..32234e4 100644 --- a/easy-agents-bom/pom.xml +++ b/easy-agents-bom/pom.xml @@ -256,6 +256,10 @@ + + com.easyagents + easy-agents-skill + com.easyagents easy-agents-agent-runtime diff --git a/easy-agents-skill/pom.xml b/easy-agents-skill/pom.xml new file mode 100644 index 0000000..8dbe3b6 --- /dev/null +++ b/easy-agents-skill/pom.xml @@ -0,0 +1,27 @@ + + + 4.0.0 + + com.easyagents + easy-agents + ${revision} + + + easy-agents-skill + easy-agents-skill + + + 17 + UTF-8 + + + + + junit + junit + test + + + diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/codec/SkillPackageCodec.java b/easy-agents-skill/src/main/java/com/easyagents/skill/codec/SkillPackageCodec.java new file mode 100644 index 0000000..7936b0e --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/codec/SkillPackageCodec.java @@ -0,0 +1,20 @@ +package com.easyagents.skill.codec; + +import com.easyagents.skill.model.Skill; + +import java.io.InputStream; +import java.util.List; + +/** + * Skill 包导入接口。 + */ +public interface SkillPackageCodec { + + /** + * 从 zip 输入流导入 Skill。 + * + * @param inputStream zip 输入流 + * @return Skill 列表 + */ + List importZip(InputStream inputStream); +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/codec/ZipSkillPackageCodec.java b/easy-agents-skill/src/main/java/com/easyagents/skill/codec/ZipSkillPackageCodec.java new file mode 100644 index 0000000..16590af --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/codec/ZipSkillPackageCodec.java @@ -0,0 +1,254 @@ +package com.easyagents.skill.codec; + +import com.easyagents.skill.exception.SkillPackageException; +import com.easyagents.skill.exception.SkillValidationException; +import com.easyagents.skill.factory.SkillFactory; +import com.easyagents.skill.model.*; +import com.easyagents.skill.store.SkillContentStore; +import com.easyagents.skill.store.memory.InMemorySkillContentStore; +import com.easyagents.skill.util.SkillHashes; +import com.easyagents.skill.util.SkillPaths; +import com.easyagents.skill.validation.SkillValidator; +import com.easyagents.skill.validation.defaults.DefaultSkillValidator; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URLConnection; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/** + * 基于 zip 的 Skill 包导入实现。 + */ +public class ZipSkillPackageCodec implements SkillPackageCodec { + + private static final String DEFAULT_MEDIA_TYPE = "application/octet-stream"; + + private final SkillContentStore contentStore; + private final SkillValidator validator; + + /** + * 创建使用内存内容存储的 zip Skill 包导入器。 + */ + public ZipSkillPackageCodec() { + this(new InMemorySkillContentStore()); + } + + /** + * 创建 zip Skill 包导入器。 + * + * @param contentStore 二进制内容存储 + */ + public ZipSkillPackageCodec(SkillContentStore contentStore) { + this(contentStore, new DefaultSkillValidator()); + } + + /** + * 创建 zip Skill 包导入器。 + * + * @param contentStore 二进制内容存储 + * @param validator Skill 聚合校验器 + */ + public ZipSkillPackageCodec(SkillContentStore contentStore, SkillValidator validator) { + if (contentStore == null) { + throw new SkillPackageException("Skill content store is required."); + } + if (validator == null) { + throw new SkillPackageException("Skill validator is required."); + } + this.contentStore = contentStore; + this.validator = validator; + } + + /** + * 从 zip 流导入 Skill 列表。 + * + * @param inputStream zip 输入流 + * @return Skill 列表 + */ + @Override + public List importZip(InputStream inputStream) { + if (inputStream == null) { + throw new SkillPackageException("Zip input stream is required."); + } + Map builders = new LinkedHashMap<>(); + try (ZipInputStream zipInputStream = new ZipInputStream(inputStream)) { + ZipEntry entry; + while ((entry = zipInputStream.getNextEntry()) != null) { + handleEntry(entry, zipInputStream, builders); + zipInputStream.closeEntry(); + } + } catch (IOException e) { + throw new SkillPackageException("Failed to import skill zip package.", e); + } + if (builders.isEmpty()) { + throw new SkillPackageException("Zip package must contain at least one skill folder."); + } + List skills = new ArrayList<>(); + for (SkillBuilder builder : builders.values()) { + Skill skill = builder.build(); + validator.validate(skill); + skills.add(skill); + } + for (SkillBuilder builder : builders.values()) { + builder.writeAssets(contentStore); + } + return skills; + } + + private void handleEntry(ZipEntry entry, ZipInputStream zipInputStream, Map builders) + throws IOException { + String rawPath = entry.getName(); + if (entry.isDirectory() || SkillPaths.isIgnoredSystemPath(rawPath)) { + return; + } + String normalizedPath = SkillPaths.normalize(rawPath); + if (SkillPaths.SKILL_FILE.equals(normalizedPath)) { + throw new SkillPackageException("Zip root cannot directly contain SKILL.md."); + } + int slashIndex = normalizedPath.indexOf('/'); + if (slashIndex < 0) { + throw new SkillPackageException("Zip root can only contain skill folders: " + normalizedPath); + } + + String skillId = normalizedPath.substring(0, slashIndex); + String skillPath = normalizedPath.substring(slashIndex + 1); + if (skillPath.isBlank()) { + return; + } + SkillBuilder builder = builders.computeIfAbsent(skillId, SkillBuilder::new); + builder.addFile(skillPath, readEntryBytes(zipInputStream)); + } + + private static byte[] readEntryBytes(InputStream inputStream) throws IOException { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + int readLength; + while ((readLength = inputStream.read(buffer)) >= 0) { + outputStream.write(buffer, 0, readLength); + } + return outputStream.toByteArray(); + } + + private static String detectMediaType(String path) { + String mediaType = URLConnection.guessContentTypeFromName(path); + return mediaType == null ? DEFAULT_MEDIA_TYPE : mediaType; + } + + private static final class SkillBuilder { + + private final String id; + private final Set paths = new HashSet<>(); + private String skillContent; + private final List references = new ArrayList<>(); + private final List scripts = new ArrayList<>(); + private final List assets = new ArrayList<>(); + private final List pendingAssets = new ArrayList<>(); + + private SkillBuilder(String id) { + this.id = id; + } + + private void addFile(String path, byte[] bytes) { + String normalizedPath = SkillPaths.normalize(path); + if (!paths.add(normalizedPath)) { + throw new SkillPackageException("Duplicate skill file path: " + id + "/" + normalizedPath); + } + if (SkillPaths.SKILL_FILE.equals(normalizedPath)) { + addSkillFile(bytes); + return; + } + + String topDir = SkillPaths.firstSegment(normalizedPath); + if (SkillPaths.REFERENCES_DIR.equals(topDir)) { + addReference(normalizedPath, bytes); + } else if (SkillPaths.SCRIPTS_DIR.equals(topDir)) { + addScript(normalizedPath, bytes); + } else if (SkillPaths.ASSETS_DIR.equals(topDir)) { + addAsset(normalizedPath, bytes); + } else { + throw new SkillPackageException("Unknown skill top-level directory: " + id + "/" + normalizedPath); + } + } + + private void addSkillFile(byte[] bytes) { + skillContent = new String(bytes, StandardCharsets.UTF_8); + } + + private void addReference(String path, byte[] bytes) { + if (!SkillPaths.hasExtension(path, ".md")) { + throw new SkillPackageException("Skill reference must be a markdown file: " + id + "/" + path); + } + String content = new String(bytes, StandardCharsets.UTF_8); + SkillReference reference = new SkillReference(); + reference.setPath(path); + reference.setName(SkillPaths.fileName(path)); + reference.setContent(content); + reference.setContentHash(SkillHashes.sha256Hex(bytes)); + reference.setSize(bytes.length); + references.add(reference); + } + + private void addScript(String path, byte[] bytes) { + SkillScriptLanguage language = SkillScriptLanguage.fromPath(path); + if (language == SkillScriptLanguage.UNKNOWN) { + throw new SkillPackageException("Unsupported skill script extension: " + id + "/" + path); + } + SkillScript script = new SkillScript(); + script.setPath(path); + script.setLanguage(language); + script.setContent(new String(bytes, StandardCharsets.UTF_8)); + script.setContentHash(SkillHashes.sha256Hex(bytes)); + script.setSize(bytes.length); + scripts.add(script); + } + + private void addAsset(String path, byte[] bytes) { + String contentHash = SkillHashes.sha256Hex(bytes); + SkillAsset asset = new SkillAsset(); + asset.setPath(path); + asset.setName(SkillPaths.fileName(path)); + asset.setMediaType(detectMediaType(path)); + asset.setContentRef("sha256:" + contentHash); + asset.setContentHash(contentHash); + asset.setSize(bytes.length); + assets.add(asset); + pendingAssets.add(new PendingAsset(asset, bytes)); + } + + private Skill build() { + if (skillContent == null) { + throw new SkillPackageException("Skill folder must contain SKILL.md: " + id); + } + try { + return SkillFactory.create(id, skillContent, references, scripts, assets); + } catch (SkillValidationException e) { + throw new SkillPackageException("Invalid SKILL.md frontmatter: " + id, e); + } + } + + private void writeAssets(SkillContentStore contentStore) { + for (PendingAsset pendingAsset : pendingAssets) { + String contentRef = contentStore.put(pendingAsset.bytes); + if (!pendingAsset.asset.getContentRef().equals(contentRef)) { + throw new SkillPackageException("Skill asset content ref does not match store result: " + + id + "/" + pendingAsset.asset.getPath()); + } + } + } + } + + private static final class PendingAsset { + + private final SkillAsset asset; + private final byte[] bytes; + + private PendingAsset(SkillAsset asset, byte[] bytes) { + this.asset = asset; + this.bytes = bytes; + } + } +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/exception/SkillException.java b/easy-agents-skill/src/main/java/com/easyagents/skill/exception/SkillException.java new file mode 100644 index 0000000..b263760 --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/exception/SkillException.java @@ -0,0 +1,26 @@ +package com.easyagents.skill.exception; + +/** + * Skill 模块基础运行时异常。 + */ +public class SkillException extends RuntimeException { + + /** + * 创建 Skill 异常。 + * + * @param message 异常信息 + */ + public SkillException(String message) { + super(message); + } + + /** + * 创建 Skill 异常。 + * + * @param message 异常信息 + * @param cause 原始异常 + */ + public SkillException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/exception/SkillPackageException.java b/easy-agents-skill/src/main/java/com/easyagents/skill/exception/SkillPackageException.java new file mode 100644 index 0000000..ef388ae --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/exception/SkillPackageException.java @@ -0,0 +1,26 @@ +package com.easyagents.skill.exception; + +/** + * Skill 包导入导出异常。 + */ +public class SkillPackageException extends SkillException { + + /** + * 创建 Skill 包异常。 + * + * @param message 异常信息 + */ + public SkillPackageException(String message) { + super(message); + } + + /** + * 创建 Skill 包异常。 + * + * @param message 异常信息 + * @param cause 原始异常 + */ + public SkillPackageException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/exception/SkillValidationException.java b/easy-agents-skill/src/main/java/com/easyagents/skill/exception/SkillValidationException.java new file mode 100644 index 0000000..dbf781c --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/exception/SkillValidationException.java @@ -0,0 +1,26 @@ +package com.easyagents.skill.exception; + +/** + * Skill 校验失败异常。 + */ +public class SkillValidationException extends SkillException { + + /** + * 创建 Skill 校验异常。 + * + * @param message 异常信息 + */ + public SkillValidationException(String message) { + super(message); + } + + /** + * 创建 Skill 校验异常。 + * + * @param message 异常信息 + * @param cause 原始异常 + */ + public SkillValidationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/factory/SkillFactory.java b/easy-agents-skill/src/main/java/com/easyagents/skill/factory/SkillFactory.java new file mode 100644 index 0000000..2a899cd --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/factory/SkillFactory.java @@ -0,0 +1,52 @@ +package com.easyagents.skill.factory; + +import com.easyagents.skill.model.*; +import com.easyagents.skill.util.SkillFrontmatter; + +import java.util.List; +import java.util.Map; + +/** + * Skill 标准构建入口。 + */ +public final class SkillFactory { + + private SkillFactory() { + } + + /** + * 基于 SKILL.md 内容创建 Skill。 + * + * @param id Skill ID + * @param skillContent SKILL.md 原始内容 + * @return Skill 聚合 + */ + public static Skill create(String id, String skillContent) { + return create(id, skillContent, null, null, null); + } + + /** + * 基于 SKILL.md 内容和资源列表创建 Skill。 + * + * @param id Skill ID + * @param skillContent SKILL.md 原始内容 + * @param references reference 文档列表 + * @param scripts script 脚本列表 + * @param assets asset 资产列表 + * @return Skill 聚合 + */ + public static Skill create(String id, String skillContent, List references, + List scripts, List assets) { + Map values = SkillFrontmatter.parse(skillContent); + Skill skill = new Skill(); + skill.setId(id); + skill.setName(values.get("name").toString()); + skill.setDescription(values.get("description").toString()); + skill.setMetadata(new SkillMetadata(values)); + skill.setSkillContent(skillContent); + skill.setReferences(references); + skill.setScripts(scripts); + skill.setAssets(assets); + return skill; + } +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/model/Skill.java b/easy-agents-skill/src/main/java/com/easyagents/skill/model/Skill.java new file mode 100644 index 0000000..e845538 --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/model/Skill.java @@ -0,0 +1,175 @@ +package com.easyagents.skill.model; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +/** + * Skill 聚合根。 + */ +public class Skill implements Serializable { + + private static final long serialVersionUID = 1L; + + private String id; + private String name; + private String description; + private SkillMetadata metadata = new SkillMetadata(); + private String skillContent; + private List references = new ArrayList<>(); + private List scripts = new ArrayList<>(); + private List assets = new ArrayList<>(); + + /** + * 获取 Skill ID。 + * + * @return Skill ID + */ + public String getId() { + return id; + } + + /** + * 设置 Skill ID。 + * + * @param id Skill ID + */ + public void setId(String id) { + this.id = id; + } + + /** + * 获取名称。 + * + * @return 名称 + */ + public String getName() { + return name; + } + + /** + * 设置名称。 + * + * @param name 名称 + */ + public void setName(String name) { + this.name = name; + } + + /** + * 获取描述。 + * + * @return 描述 + */ + public String getDescription() { + return description; + } + + /** + * 设置描述。 + * + * @param description 描述 + */ + public void setDescription(String description) { + this.description = description; + } + + /** + * 获取元数据。 + * + * @return 元数据 + */ + public SkillMetadata getMetadata() { + return metadata; + } + + /** + * 设置元数据。 + * + * @param metadata 元数据 + */ + public void setMetadata(SkillMetadata metadata) { + this.metadata = metadata == null ? new SkillMetadata() : metadata; + } + + /** + * 获取 SKILL.md 原始内容。 + * + * @return SKILL.md 原始内容 + */ + public String getSkillContent() { + return skillContent; + } + + /** + * 设置 SKILL.md 原始内容。 + * + * @param skillContent SKILL.md 原始内容 + */ + public void setSkillContent(String skillContent) { + this.skillContent = skillContent; + } + + /** + * 获取参考文档列表。 + * + * @return 参考文档列表 + */ + public List getReferences() { + return references; + } + + /** + * 设置参考文档列表。 + * + * @param references 参考文档列表 + */ + public void setReferences(List references) { + this.references = references == null ? new ArrayList<>() : new ArrayList<>(references); + } + + /** + * 获取脚本列表。 + * + * @return 脚本列表 + */ + public List getScripts() { + return scripts; + } + + /** + * 设置脚本列表。 + * + * @param scripts 脚本列表 + */ + public void setScripts(List scripts) { + this.scripts = scripts == null ? new ArrayList<>() : new ArrayList<>(scripts); + } + + /** + * 获取资产列表。 + * + * @return 资产列表 + */ + public List getAssets() { + return assets; + } + + /** + * 设置资产列表。 + * + * @param assets 资产列表 + */ + public void setAssets(List assets) { + this.assets = assets == null ? new ArrayList<>() : new ArrayList<>(assets); + } + + /** + * 转换为轻量描述。 + * + * @return Skill 描述 + */ + public SkillDescriptor toDescriptor() { + return new SkillDescriptor(id, name, description, metadata); + } +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillAsset.java b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillAsset.java new file mode 100644 index 0000000..e5bfda1 --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillAsset.java @@ -0,0 +1,145 @@ +package com.easyagents.skill.model; + +import java.io.Serializable; + +/** + * Skill 静态资产。 + */ +public class SkillAsset implements Serializable { + + private static final long serialVersionUID = 1L; + + private String path; + private String name; + private String mediaType; + private String contentRef; + private String contentHash; + private long size; + private SkillMetadata metadata = new SkillMetadata(); + + /** + * 获取逻辑路径。 + * + * @return 逻辑路径 + */ + public String getPath() { + return path; + } + + /** + * 设置逻辑路径。 + * + * @param path 逻辑路径 + */ + public void setPath(String path) { + this.path = path; + } + + /** + * 获取名称。 + * + * @return 名称 + */ + public String getName() { + return name; + } + + /** + * 设置名称。 + * + * @param name 名称 + */ + public void setName(String name) { + this.name = name; + } + + /** + * 获取媒体类型。 + * + * @return 媒体类型 + */ + public String getMediaType() { + return mediaType; + } + + /** + * 设置媒体类型。 + * + * @param mediaType 媒体类型 + */ + public void setMediaType(String mediaType) { + this.mediaType = mediaType; + } + + /** + * 获取内容引用。 + * + * @return 内容引用 + */ + public String getContentRef() { + return contentRef; + } + + /** + * 设置内容引用。 + * + * @param contentRef 内容引用 + */ + public void setContentRef(String contentRef) { + this.contentRef = contentRef; + } + + /** + * 获取内容 hash。 + * + * @return 内容 hash + */ + public String getContentHash() { + return contentHash; + } + + /** + * 设置内容 hash。 + * + * @param contentHash 内容 hash + */ + public void setContentHash(String contentHash) { + this.contentHash = contentHash; + } + + /** + * 获取文件大小。 + * + * @return 文件大小 + */ + public long getSize() { + return size; + } + + /** + * 设置文件大小。 + * + * @param size 文件大小 + */ + public void setSize(long size) { + this.size = size; + } + + /** + * 获取元数据。 + * + * @return 元数据 + */ + public SkillMetadata getMetadata() { + return metadata; + } + + /** + * 设置元数据。 + * + * @param metadata 元数据 + */ + public void setMetadata(SkillMetadata metadata) { + this.metadata = metadata == null ? new SkillMetadata() : metadata; + } +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillDescriptor.java b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillDescriptor.java new file mode 100644 index 0000000..a25df10 --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillDescriptor.java @@ -0,0 +1,111 @@ +package com.easyagents.skill.model; + +import java.io.Serial; +import java.io.Serializable; + +/** + * Skill 轻量描述。 + */ +public class SkillDescriptor implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private String id; + private String name; + private String description; + private SkillMetadata metadata = new SkillMetadata(); + + /** + * 创建空 Skill 描述。 + */ + public SkillDescriptor() { + } + + /** + * 创建 Skill 描述。 + * + * @param id Skill ID + * @param name Skill 名称 + * @param description Skill 描述 + * @param metadata 元数据 + */ + public SkillDescriptor(String id, String name, String description, SkillMetadata metadata) { + this.id = id; + this.name = name; + this.description = description; + setMetadata(metadata); + } + + /** + * 获取 Skill ID。 + * + * @return Skill ID + */ + public String getId() { + return id; + } + + /** + * 设置 Skill ID。 + * + * @param id Skill ID + */ + public void setId(String id) { + this.id = id; + } + + /** + * 获取名称。 + * + * @return 名称 + */ + public String getName() { + return name; + } + + /** + * 设置名称。 + * + * @param name 名称 + */ + public void setName(String name) { + this.name = name; + } + + /** + * 获取描述。 + * + * @return 描述 + */ + public String getDescription() { + return description; + } + + /** + * 设置描述。 + * + * @param description 描述 + */ + public void setDescription(String description) { + this.description = description; + } + + /** + * 获取元数据。 + * + * @return 元数据 + */ + public SkillMetadata getMetadata() { + return metadata; + } + + /** + * 设置元数据。 + * + * @param metadata 元数据 + */ + public void setMetadata(SkillMetadata metadata) { + this.metadata = metadata == null ? new SkillMetadata() : metadata; + } +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillMetadata.java b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillMetadata.java new file mode 100644 index 0000000..f05e768 --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillMetadata.java @@ -0,0 +1,79 @@ +package com.easyagents.skill.model; + +import java.io.Serializable; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Skill 元数据容器。 + */ +public class SkillMetadata implements Serializable { + + private static final long serialVersionUID = 1L; + + private Map values = new LinkedHashMap<>(); + + /** + * 创建空元数据。 + */ + public SkillMetadata() { + } + + /** + * 使用已有键值创建元数据。 + * + * @param values 元数据键值 + */ + public SkillMetadata(Map values) { + setValues(values); + } + + /** + * 获取元数据键值。 + * + * @return 元数据键值 + */ + public Map getValues() { + return values; + } + + /** + * 设置元数据键值。 + * + * @param values 元数据键值 + */ + public void setValues(Map values) { + this.values = values == null ? new LinkedHashMap<>() : new LinkedHashMap<>(values); + } + + /** + * 写入一个元数据键值。 + * + * @param key 元数据键 + * @param value 元数据值 + */ + public void put(String key, Object value) { + if (key != null && !key.isBlank()) { + values.put(key, value); + } + } + + /** + * 读取元数据值。 + * + * @param key 元数据键 + * @return 元数据值 + */ + public Object get(String key) { + return values.get(key); + } + + /** + * 判断是否为空。 + * + * @return 无元数据时为 true + */ + public boolean isEmpty() { + return values.isEmpty(); + } +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillReference.java b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillReference.java new file mode 100644 index 0000000..c27af7e --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillReference.java @@ -0,0 +1,126 @@ +package com.easyagents.skill.model; + +import java.io.Serializable; + +/** + * Skill Markdown 参考文档。 + */ +public class SkillReference implements Serializable { + + private static final long serialVersionUID = 1L; + + private String path; + private String name; + private String content; + private String contentHash; + private long size; + private SkillMetadata metadata = new SkillMetadata(); + + /** + * 获取逻辑路径。 + * + * @return 逻辑路径 + */ + public String getPath() { + return path; + } + + /** + * 设置逻辑路径。 + * + * @param path 逻辑路径 + */ + public void setPath(String path) { + this.path = path; + } + + /** + * 获取名称。 + * + * @return 名称 + */ + public String getName() { + return name; + } + + /** + * 设置名称。 + * + * @param name 名称 + */ + public void setName(String name) { + this.name = name; + } + + /** + * 获取 Markdown 内容。 + * + * @return Markdown 内容 + */ + public String getContent() { + return content; + } + + /** + * 设置 Markdown 内容。 + * + * @param content Markdown 内容 + */ + public void setContent(String content) { + this.content = content; + } + + /** + * 获取内容 hash。 + * + * @return 内容 hash + */ + public String getContentHash() { + return contentHash; + } + + /** + * 设置内容 hash。 + * + * @param contentHash 内容 hash + */ + public void setContentHash(String contentHash) { + this.contentHash = contentHash; + } + + /** + * 获取文件大小。 + * + * @return 文件大小 + */ + public long getSize() { + return size; + } + + /** + * 设置文件大小。 + * + * @param size 文件大小 + */ + public void setSize(long size) { + this.size = size; + } + + /** + * 获取元数据。 + * + * @return 元数据 + */ + public SkillMetadata getMetadata() { + return metadata; + } + + /** + * 设置元数据。 + * + * @param metadata 元数据 + */ + public void setMetadata(SkillMetadata metadata) { + this.metadata = metadata == null ? new SkillMetadata() : metadata; + } +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillScript.java b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillScript.java new file mode 100644 index 0000000..edab344 --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillScript.java @@ -0,0 +1,126 @@ +package com.easyagents.skill.model; + +import java.io.Serializable; + +/** + * Skill 脚本源码。 + */ +public class SkillScript implements Serializable { + + private static final long serialVersionUID = 1L; + + private String path; + private SkillScriptLanguage language = SkillScriptLanguage.UNKNOWN; + private String content; + private String contentHash; + private long size; + private SkillMetadata metadata = new SkillMetadata(); + + /** + * 获取逻辑路径。 + * + * @return 逻辑路径 + */ + public String getPath() { + return path; + } + + /** + * 设置逻辑路径。 + * + * @param path 逻辑路径 + */ + public void setPath(String path) { + this.path = path; + } + + /** + * 获取脚本语言。 + * + * @return 脚本语言 + */ + public SkillScriptLanguage getLanguage() { + return language; + } + + /** + * 设置脚本语言。 + * + * @param language 脚本语言 + */ + public void setLanguage(SkillScriptLanguage language) { + this.language = language == null ? SkillScriptLanguage.UNKNOWN : language; + } + + /** + * 获取脚本源码。 + * + * @return 脚本源码 + */ + public String getContent() { + return content; + } + + /** + * 设置脚本源码。 + * + * @param content 脚本源码 + */ + public void setContent(String content) { + this.content = content; + } + + /** + * 获取内容 hash。 + * + * @return 内容 hash + */ + public String getContentHash() { + return contentHash; + } + + /** + * 设置内容 hash。 + * + * @param contentHash 内容 hash + */ + public void setContentHash(String contentHash) { + this.contentHash = contentHash; + } + + /** + * 获取文件大小。 + * + * @return 文件大小 + */ + public long getSize() { + return size; + } + + /** + * 设置文件大小。 + * + * @param size 文件大小 + */ + public void setSize(long size) { + this.size = size; + } + + /** + * 获取元数据。 + * + * @return 元数据 + */ + public SkillMetadata getMetadata() { + return metadata; + } + + /** + * 设置元数据。 + * + * @param metadata 元数据 + */ + public void setMetadata(SkillMetadata metadata) { + this.metadata = metadata == null ? new SkillMetadata() : metadata; + } +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillScriptLanguage.java b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillScriptLanguage.java new file mode 100644 index 0000000..c9ce329 --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/model/SkillScriptLanguage.java @@ -0,0 +1,50 @@ +package com.easyagents.skill.model; + +/** + * Skill 脚本语言。 + */ +public enum SkillScriptLanguage { + + /** + * Python 脚本。 + */ + PYTHON, + + /** + * JavaScript 脚本。 + */ + JAVASCRIPT, + + /** + * Shell 脚本。 + */ + SHELL, + + /** + * 未知脚本语言。 + */ + UNKNOWN; + + /** + * 按脚本路径识别语言。 + * + * @param path 脚本逻辑路径 + * @return 脚本语言 + */ + public static SkillScriptLanguage fromPath(String path) { + if (path == null) { + return UNKNOWN; + } + String lower = path.toLowerCase(); + if (lower.endsWith(".py")) { + return PYTHON; + } + if (lower.endsWith(".js")) { + return JAVASCRIPT; + } + if (lower.endsWith(".sh")) { + return SHELL; + } + return UNKNOWN; + } +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/repository/SkillRepository.java b/easy-agents-skill/src/main/java/com/easyagents/skill/repository/SkillRepository.java new file mode 100644 index 0000000..03552ee --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/repository/SkillRepository.java @@ -0,0 +1,58 @@ +package com.easyagents.skill.repository; + +import com.easyagents.skill.model.Skill; +import com.easyagents.skill.model.SkillDescriptor; + +import java.util.List; +import java.util.Optional; + +/** + * Skill 聚合存储接口。 + */ +public interface SkillRepository { + + /** + * 保存 Skill。 + * + * @param skill Skill 聚合 + */ + void save(Skill skill); + + /** + * 获取完整 Skill。 + * + * @param skillId Skill ID + * @return Skill 聚合 + */ + Optional get(String skillId); + + /** + * 获取 Skill 描述。 + * + * @param skillId Skill ID + * @return Skill 描述 + */ + Optional getDescriptor(String skillId); + + /** + * 列出 Skill 描述。 + * + * @return Skill 描述列表 + */ + List listDescriptors(); + + /** + * 删除 Skill。 + * + * @param skillId Skill ID + */ + void delete(String skillId); + + /** + * 判断 Skill 是否存在。 + * + * @param skillId Skill ID + * @return 存在时为 true + */ + boolean exists(String skillId); +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/repository/memory/InMemorySkillRepository.java b/easy-agents-skill/src/main/java/com/easyagents/skill/repository/memory/InMemorySkillRepository.java new file mode 100644 index 0000000..60795d1 --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/repository/memory/InMemorySkillRepository.java @@ -0,0 +1,176 @@ +package com.easyagents.skill.repository.memory; + +import com.easyagents.skill.exception.SkillValidationException; +import com.easyagents.skill.model.*; +import com.easyagents.skill.repository.SkillRepository; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * 基于内存的 Skill 聚合仓储实现。 + */ +public class InMemorySkillRepository implements SkillRepository { + + private final ConcurrentMap skills = new ConcurrentHashMap<>(); + + /** + * 保存 Skill 聚合。 + * + * @param skill Skill 聚合 + */ + @Override + public void save(Skill skill) { + if (skill == null || isBlank(skill.getId())) { + throw new SkillValidationException("Skill id is required."); + } + skills.put(skill.getId(), copySkill(skill)); + } + + /** + * 获取完整 Skill。 + * + * @param skillId Skill ID + * @return Skill 聚合 + */ + @Override + public Optional get(String skillId) { + Skill skill = skills.get(skillId); + return skill == null ? Optional.empty() : Optional.of(copySkill(skill)); + } + + /** + * 获取 Skill 描述。 + * + * @param skillId Skill ID + * @return Skill 描述 + */ + @Override + public Optional getDescriptor(String skillId) { + Skill skill = skills.get(skillId); + return skill == null ? Optional.empty() : Optional.of(copyDescriptor(skill.toDescriptor())); + } + + /** + * 列出 Skill 描述。 + * + * @return Skill 描述列表 + */ + @Override + public List listDescriptors() { + List descriptors = new ArrayList<>(); + for (Skill skill : skills.values()) { + descriptors.add(copyDescriptor(skill.toDescriptor())); + } + return descriptors; + } + + /** + * 删除 Skill。 + * + * @param skillId Skill ID + */ + @Override + public void delete(String skillId) { + skills.remove(skillId); + } + + /** + * 判断 Skill 是否存在。 + * + * @param skillId Skill ID + * @return 存在时为 true + */ + @Override + public boolean exists(String skillId) { + return skills.containsKey(skillId); + } + + private static Skill copySkill(Skill source) { + Skill target = new Skill(); + target.setId(source.getId()); + target.setName(source.getName()); + target.setDescription(source.getDescription()); + target.setMetadata(copyMetadata(source.getMetadata())); + target.setSkillContent(source.getSkillContent()); + target.setReferences(copyReferences(source.getReferences())); + target.setScripts(copyScripts(source.getScripts())); + target.setAssets(copyAssets(source.getAssets())); + return target; + } + + private static List copyReferences(List sources) { + List targets = new ArrayList<>(); + if (sources == null) { + return targets; + } + for (SkillReference source : sources) { + SkillReference target = new SkillReference(); + target.setPath(source.getPath()); + target.setName(source.getName()); + target.setContent(source.getContent()); + target.setContentHash(source.getContentHash()); + target.setSize(source.getSize()); + target.setMetadata(copyMetadata(source.getMetadata())); + targets.add(target); + } + return targets; + } + + private static List copyScripts(List sources) { + List targets = new ArrayList<>(); + if (sources == null) { + return targets; + } + for (SkillScript source : sources) { + SkillScript target = new SkillScript(); + target.setPath(source.getPath()); + target.setLanguage(source.getLanguage()); + target.setContent(source.getContent()); + target.setContentHash(source.getContentHash()); + target.setSize(source.getSize()); + target.setMetadata(copyMetadata(source.getMetadata())); + targets.add(target); + } + return targets; + } + + private static List copyAssets(List sources) { + List targets = new ArrayList<>(); + if (sources == null) { + return targets; + } + for (SkillAsset source : sources) { + SkillAsset target = new SkillAsset(); + target.setPath(source.getPath()); + target.setName(source.getName()); + target.setMediaType(source.getMediaType()); + target.setContentRef(source.getContentRef()); + target.setContentHash(source.getContentHash()); + target.setSize(source.getSize()); + target.setMetadata(copyMetadata(source.getMetadata())); + targets.add(target); + } + return targets; + } + + private static SkillDescriptor copyDescriptor(SkillDescriptor source) { + return new SkillDescriptor( + source.getId(), + source.getName(), + source.getDescription(), + copyMetadata(source.getMetadata()) + ); + } + + private static SkillMetadata copyMetadata(SkillMetadata source) { + return source == null ? new SkillMetadata() : new SkillMetadata(source.getValues()); + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/store/SkillContentStore.java b/easy-agents-skill/src/main/java/com/easyagents/skill/store/SkillContentStore.java new file mode 100644 index 0000000..e712c4e --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/store/SkillContentStore.java @@ -0,0 +1,41 @@ +package com.easyagents.skill.store; + +import java.io.InputStream; + +/** + * Skill 二进制内容存储接口。 + */ +public interface SkillContentStore { + + /** + * 保存内容并返回内容引用。 + * + * @param bytes 内容字节 + * @return 内容引用 + */ + String put(byte[] bytes); + + /** + * 打开内容流。 + * + * @param contentRef 内容引用 + * @return 内容流 + */ + InputStream open(String contentRef); + + /** + * 读取全部内容。 + * + * @param contentRef 内容引用 + * @return 内容字节 + */ + byte[] readAllBytes(String contentRef); + + /** + * 判断内容是否存在。 + * + * @param contentRef 内容引用 + * @return 存在时为 true + */ + boolean exists(String contentRef); +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/store/memory/InMemorySkillContentStore.java b/easy-agents-skill/src/main/java/com/easyagents/skill/store/memory/InMemorySkillContentStore.java new file mode 100644 index 0000000..98632aa --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/store/memory/InMemorySkillContentStore.java @@ -0,0 +1,70 @@ +package com.easyagents.skill.store.memory; + +import com.easyagents.skill.exception.SkillException; +import com.easyagents.skill.store.SkillContentStore; +import com.easyagents.skill.util.SkillHashes; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.util.Arrays; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * 基于内存的 Skill 二进制内容存储。 + */ +public class InMemorySkillContentStore implements SkillContentStore { + + private final ConcurrentMap contents = new ConcurrentHashMap<>(); + + /** + * 保存内容并返回内容引用。 + * + * @param bytes 内容字节 + * @return 内容引用 + */ + @Override + public String put(byte[] bytes) { + byte[] safeBytes = bytes == null ? new byte[0] : Arrays.copyOf(bytes, bytes.length); + String contentRef = SkillHashes.sha256Ref(safeBytes); + contents.putIfAbsent(contentRef, safeBytes); + return contentRef; + } + + /** + * 打开内容流。 + * + * @param contentRef 内容引用 + * @return 内容流 + */ + @Override + public InputStream open(String contentRef) { + return new ByteArrayInputStream(readAllBytes(contentRef)); + } + + /** + * 读取全部内容。 + * + * @param contentRef 内容引用 + * @return 内容字节 + */ + @Override + public byte[] readAllBytes(String contentRef) { + byte[] bytes = contents.get(contentRef); + if (bytes == null) { + throw new SkillException("Skill content does not exist: " + contentRef); + } + return Arrays.copyOf(bytes, bytes.length); + } + + /** + * 判断内容是否存在。 + * + * @param contentRef 内容引用 + * @return 存在时为 true + */ + @Override + public boolean exists(String contentRef) { + return contents.containsKey(contentRef); + } +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillFrontmatter.java b/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillFrontmatter.java new file mode 100644 index 0000000..85e748e --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillFrontmatter.java @@ -0,0 +1,89 @@ +package com.easyagents.skill.util; + +import com.easyagents.skill.exception.SkillValidationException; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * SKILL.md frontmatter 解析工具。 + */ +public final class SkillFrontmatter { + + private SkillFrontmatter() { + } + + /** + * 解析 SKILL.md 开头的 frontmatter。 + * + * @param content SKILL.md 原始内容 + * @return frontmatter 键值 + */ + public static Map parse(String content) { + if (content == null || content.isBlank()) { + throw new SkillValidationException("SKILL.md content is required."); + } + String[] lines = content.split("\\R", -1); + if (lines.length == 0 || !"---".equals(lines[0])) { + throw new SkillValidationException("SKILL.md must start with frontmatter."); + } + + Map values = new LinkedHashMap<>(); + boolean closed = false; + for (int i = 1; i < lines.length; i++) { + String line = lines[i]; + if ("---".equals(line)) { + closed = true; + break; + } + if (line.isBlank()) { + continue; + } + if (Character.isWhitespace(line.charAt(0))) { + throw new SkillValidationException("Nested frontmatter is not supported."); + } + parseScalarLine(line, values); + } + if (!closed) { + throw new SkillValidationException("SKILL.md frontmatter is not closed."); + } + if (isBlank(values.get("name"))) { + throw new SkillValidationException("SKILL.md frontmatter name is required."); + } + if (isBlank(values.get("description"))) { + throw new SkillValidationException("SKILL.md frontmatter description is required."); + } + return values; + } + + private static void parseScalarLine(String line, Map values) { + int colonIndex = line.indexOf(':'); + if (colonIndex <= 0) { + throw new SkillValidationException("Only single-line key: value frontmatter is supported."); + } + String key = line.substring(0, colonIndex).trim(); + String value = stripQuotes(line.substring(colonIndex + 1).trim()); + if (key.isBlank()) { + throw new SkillValidationException("Frontmatter key cannot be blank."); + } + if (value.isBlank()) { + throw new SkillValidationException("Frontmatter value cannot be blank: " + key); + } + values.put(key, value); + } + + private static String stripQuotes(String value) { + if (value.length() >= 2) { + boolean doubleQuoted = value.startsWith("\"") && value.endsWith("\""); + boolean singleQuoted = value.startsWith("'") && value.endsWith("'"); + if (doubleQuoted || singleQuoted) { + return value.substring(1, value.length() - 1); + } + } + return value; + } + + private static boolean isBlank(Object value) { + return value == null || value.toString().isBlank(); + } +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillHashes.java b/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillHashes.java new file mode 100644 index 0000000..cefd9e1 --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillHashes.java @@ -0,0 +1,47 @@ +package com.easyagents.skill.util; + +import com.easyagents.skill.exception.SkillException; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** + * Skill 内容 hash 工具。 + */ +public final class SkillHashes { + + private static final String SHA_256 = "SHA-256"; + + private SkillHashes() { + } + + /** + * 计算 SHA-256 内容引用。 + * + * @param bytes 内容字节 + * @return sha256 内容引用 + */ + public static String sha256Ref(byte[] bytes) { + return "sha256:" + sha256Hex(bytes); + } + + /** + * 计算 SHA-256 十六进制值。 + * + * @param bytes 内容字节 + * @return 十六进制 hash + */ + public static String sha256Hex(byte[] bytes) { + try { + MessageDigest digest = MessageDigest.getInstance(SHA_256); + byte[] hashed = digest.digest(bytes == null ? new byte[0] : bytes); + StringBuilder builder = new StringBuilder(hashed.length * 2); + for (byte item : hashed) { + builder.append(String.format("%02x", item)); + } + return builder.toString(); + } catch (NoSuchAlgorithmException e) { + throw new SkillException("SHA-256 algorithm is unavailable.", e); + } + } +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillPaths.java b/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillPaths.java new file mode 100644 index 0000000..a2c61ba --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/util/SkillPaths.java @@ -0,0 +1,118 @@ +package com.easyagents.skill.util; + +import com.easyagents.skill.exception.SkillValidationException; + +import java.util.Locale; + +/** + * Skill 逻辑路径工具。 + */ +public final class SkillPaths { + + /** + * Skill 主文件名。 + */ + public static final String SKILL_FILE = "SKILL.md"; + + /** + * reference 顶级目录。 + */ + public static final String REFERENCES_DIR = "references"; + + /** + * script 顶级目录。 + */ + public static final String SCRIPTS_DIR = "scripts"; + + /** + * asset 顶级目录。 + */ + public static final String ASSETS_DIR = "assets"; + + private SkillPaths() { + } + + /** + * 规范化逻辑路径。 + * + * @param path 原始路径 + * @return 规范化后的路径 + */ + public static String normalize(String path) { + if (path == null) { + throw new SkillValidationException("Skill path is required."); + } + String normalized = path.replace('\\', '/').trim(); + while (normalized.startsWith("./")) { + normalized = normalized.substring(2); + } + if (normalized.isEmpty()) { + throw new SkillValidationException("Skill path cannot be empty."); + } + if (normalized.startsWith("/") || normalized.matches("^[A-Za-z]:.*")) { + throw new SkillValidationException("Absolute skill path is not allowed: " + path); + } + String[] segments = normalized.split("/"); + for (String segment : segments) { + if (segment.isEmpty() || ".".equals(segment) || "..".equals(segment)) { + throw new SkillValidationException("Unsafe skill path is not allowed: " + path); + } + if (segment.startsWith(".")) { + throw new SkillValidationException("Hidden skill path is not allowed: " + path); + } + } + return normalized; + } + + /** + * 获取一级目录或文件名。 + * + * @param path 逻辑路径 + * @return 一级路径段 + */ + public static String firstSegment(String path) { + String normalized = normalize(path); + int index = normalized.indexOf('/'); + return index < 0 ? normalized : normalized.substring(0, index); + } + + /** + * 获取文件名。 + * + * @param path 逻辑路径 + * @return 文件名 + */ + public static String fileName(String path) { + String normalized = normalize(path); + int index = normalized.lastIndexOf('/'); + return index < 0 ? normalized : normalized.substring(index + 1); + } + + /** + * 判断扩展名是否匹配。 + * + * @param path 逻辑路径 + * @param extension 扩展名 + * @return 匹配时为 true + */ + public static boolean hasExtension(String path, String extension) { + return normalize(path).toLowerCase(Locale.ROOT).endsWith(extension.toLowerCase(Locale.ROOT)); + } + + /** + * 判断是否为系统文件路径。 + * + * @param path 原始路径 + * @return 系统文件时为 true + */ + public static boolean isIgnoredSystemPath(String path) { + if (path == null) { + return true; + } + String normalized = path.replace('\\', '/'); + return normalized.startsWith("__MACOSX/") + || normalized.contains("/__MACOSX/") + || normalized.endsWith("/.DS_Store") + || ".DS_Store".equals(normalized); + } +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/validation/SkillValidator.java b/easy-agents-skill/src/main/java/com/easyagents/skill/validation/SkillValidator.java new file mode 100644 index 0000000..b0ef7dc --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/validation/SkillValidator.java @@ -0,0 +1,16 @@ +package com.easyagents.skill.validation; + +import com.easyagents.skill.model.Skill; + +/** + * Skill 校验接口。 + */ +public interface SkillValidator { + + /** + * 校验 Skill 聚合。 + * + * @param skill Skill 聚合 + */ + void validate(Skill skill); +} diff --git a/easy-agents-skill/src/main/java/com/easyagents/skill/validation/defaults/DefaultSkillValidator.java b/easy-agents-skill/src/main/java/com/easyagents/skill/validation/defaults/DefaultSkillValidator.java new file mode 100644 index 0000000..06fc481 --- /dev/null +++ b/easy-agents-skill/src/main/java/com/easyagents/skill/validation/defaults/DefaultSkillValidator.java @@ -0,0 +1,156 @@ +package com.easyagents.skill.validation.defaults; + +import com.easyagents.skill.exception.SkillValidationException; +import com.easyagents.skill.model.*; +import com.easyagents.skill.util.SkillFrontmatter; +import com.easyagents.skill.util.SkillHashes; +import com.easyagents.skill.util.SkillPaths; +import com.easyagents.skill.validation.SkillValidator; + +import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 默认 Skill 聚合校验器。 + */ +public class DefaultSkillValidator implements SkillValidator { + + /** + * 校验 Skill 聚合。 + * + * @param skill Skill 聚合 + */ + @Override + public void validate(Skill skill) { + if (skill == null) { + throw new SkillValidationException("Skill is required."); + } + requireText(skill.getId(), "Skill id is required."); + requireText(skill.getName(), "Skill name is required."); + requireText(skill.getDescription(), "Skill description is required."); + requireText(skill.getSkillContent(), "SKILL.md content is required."); + validateSkillFrontmatter(skill); + + Set paths = new HashSet<>(); + paths.add(SkillPaths.SKILL_FILE); + validateReferences(skill.getReferences(), paths); + validateScripts(skill.getScripts(), paths); + validateAssets(skill.getAssets(), paths); + } + + private static void validateReferences(List references, Set paths) { + if (references == null) { + return; + } + for (SkillReference reference : references) { + if (reference == null) { + throw new SkillValidationException("Skill reference cannot be null."); + } + String path = validateFilePath(reference.getPath(), SkillPaths.REFERENCES_DIR, paths); + if (!SkillPaths.hasExtension(path, ".md")) { + throw new SkillValidationException("Skill reference must be a markdown file: " + path); + } + requireContent(reference.getContent(), "Skill reference content is required: " + path); + validateTextHash(path, reference.getContent(), reference.getContentHash()); + validateSize(reference.getSize(), path); + } + } + + private static void validateScripts(List scripts, Set paths) { + if (scripts == null) { + return; + } + for (SkillScript script : scripts) { + if (script == null) { + throw new SkillValidationException("Skill script cannot be null."); + } + String path = validateFilePath(script.getPath(), SkillPaths.SCRIPTS_DIR, paths); + if (SkillScriptLanguage.fromPath(path) == SkillScriptLanguage.UNKNOWN) { + throw new SkillValidationException("Unsupported skill script extension: " + path); + } + if (script.getLanguage() == SkillScriptLanguage.UNKNOWN + || script.getLanguage() != SkillScriptLanguage.fromPath(path)) { + throw new SkillValidationException("Skill script language does not match path: " + path); + } + requireContent(script.getContent(), "Skill script content is required: " + path); + validateTextHash(path, script.getContent(), script.getContentHash()); + validateSize(script.getSize(), path); + } + } + + private static void validateAssets(List assets, Set paths) { + if (assets == null) { + return; + } + for (SkillAsset asset : assets) { + if (asset == null) { + throw new SkillValidationException("Skill asset cannot be null."); + } + String path = validateFilePath(asset.getPath(), SkillPaths.ASSETS_DIR, paths); + requireText(asset.getName(), "Skill asset name is required: " + path); + requireText(asset.getMediaType(), "Skill asset media type is required: " + path); + requireText(asset.getContentRef(), "Skill asset content ref is required: " + path); + requireText(asset.getContentHash(), "Skill asset content hash is required: " + path); + if (!asset.getContentRef().equals("sha256:" + asset.getContentHash())) { + throw new SkillValidationException("Skill asset content ref does not match hash: " + path); + } + validateSize(asset.getSize(), path); + } + } + + private static void validateSkillFrontmatter(Skill skill) { + Map values = SkillFrontmatter.parse(skill.getSkillContent()); + if (!skill.getName().equals(values.get("name").toString())) { + throw new SkillValidationException("Skill name must match SKILL.md frontmatter."); + } + if (!skill.getDescription().equals(values.get("description").toString())) { + throw new SkillValidationException("Skill description must match SKILL.md frontmatter."); + } + if (skill.getMetadata() == null || !skill.getMetadata().getValues().equals(values)) { + throw new SkillValidationException("Skill metadata must match SKILL.md frontmatter."); + } + } + + private static String validateFilePath(String path, String expectedTopDir, Set paths) { + String normalized = SkillPaths.normalize(path); + if (!expectedTopDir.equals(SkillPaths.firstSegment(normalized))) { + throw new SkillValidationException("Skill file must be under " + expectedTopDir + "/: " + normalized); + } + if (normalized.indexOf('/') < 0 || normalized.endsWith("/")) { + throw new SkillValidationException("Skill file path must include file name: " + normalized); + } + if (!paths.add(normalized)) { + throw new SkillValidationException("Duplicate skill file path: " + normalized); + } + return normalized; + } + + private static void validateTextHash(String path, String content, String contentHash) { + requireText(contentHash, "Skill file content hash is required: " + path); + String actualHash = SkillHashes.sha256Hex(content.getBytes(StandardCharsets.UTF_8)); + if (!actualHash.equals(contentHash)) { + throw new SkillValidationException("Skill file content hash does not match: " + path); + } + } + + private static void validateSize(long size, String path) { + if (size < 0) { + throw new SkillValidationException("Skill file size cannot be negative: " + path); + } + } + + private static void requireText(String value, String message) { + if (value == null || value.isBlank()) { + throw new SkillValidationException(message); + } + } + + private static void requireContent(String value, String message) { + if (value == null) { + throw new SkillValidationException(message); + } + } +} diff --git a/easy-agents-skill/src/test/java/com/easyagents/skill/codec/ZipSkillPackageCodecTest.java b/easy-agents-skill/src/test/java/com/easyagents/skill/codec/ZipSkillPackageCodecTest.java new file mode 100644 index 0000000..50831bc --- /dev/null +++ b/easy-agents-skill/src/test/java/com/easyagents/skill/codec/ZipSkillPackageCodecTest.java @@ -0,0 +1,275 @@ +package com.easyagents.skill.codec; + +import com.easyagents.skill.exception.SkillPackageException; +import com.easyagents.skill.model.Skill; +import com.easyagents.skill.store.SkillContentStore; +import com.easyagents.skill.store.memory.InMemorySkillContentStore; +import org.junit.Assert; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * ZipSkillPackageCodec 单元测试。 + */ +public class ZipSkillPackageCodecTest { + + /** + * 导入包含一个 Skill 文件夹的 zip。 + */ + @Test + public void importOneSkillFolder() { + List skills = importZip(files( + "skill-a/SKILL.md", skillMd("Skill A", "Desc A"), + "skill-a/references/rules/a.md", "# Rule", + "skill-a/scripts/run.py", "print('ok')", + "skill-a/assets/images/logo.png", "png-data" + )); + + Assert.assertEquals(1, skills.size()); + Skill skill = skills.get(0); + Assert.assertEquals("skill-a", skill.getId()); + Assert.assertEquals("Skill A", skill.getName()); + Assert.assertEquals("Desc A", skill.getDescription()); + Assert.assertEquals(1, skill.getReferences().size()); + Assert.assertEquals("references/rules/a.md", skill.getReferences().get(0).getPath()); + Assert.assertEquals(1, skill.getScripts().size()); + Assert.assertEquals(1, skill.getAssets().size()); + Assert.assertEquals("assets/images/logo.png", skill.getAssets().get(0).getPath()); + } + + /** + * 导入包含多个 Skill 文件夹的 zip。 + */ + @Test + public void importMultipleSkillFolders() { + List skills = importZip(files( + "skill-a/SKILL.md", skillMd("Skill A", "Desc A"), + "skill-b/SKILL.md", skillMd("Skill B", "Desc B") + )); + + Assert.assertEquals(2, skills.size()); + Assert.assertEquals("skill-a", skills.get(0).getId()); + Assert.assertEquals("skill-b", skills.get(1).getId()); + } + + /** + * 拒绝 zip 根目录直接包含 SKILL.md。 + */ + @Test(expected = SkillPackageException.class) + public void rejectRootSkillMd() { + importZip(files("SKILL.md", skillMd("Skill", "Desc"))); + } + + /** + * 拒绝未知顶级目录。 + */ + @Test(expected = SkillPackageException.class) + public void rejectUnknownTopLevelDirectory() { + importZip(files( + "skill-a/SKILL.md", skillMd("Skill A", "Desc A"), + "skill-a/unknown/a.md", "# Unknown" + )); + } + + /** + * 拒绝 references 下的非 md 文件。 + */ + @Test(expected = SkillPackageException.class) + public void rejectNonMarkdownReference() { + importZip(files( + "skill-a/SKILL.md", skillMd("Skill A", "Desc A"), + "skill-a/references/a.txt", "text" + )); + } + + /** + * 拒绝不支持的脚本扩展名。 + */ + @Test(expected = SkillPackageException.class) + public void rejectUnsupportedScript() { + importZip(files( + "skill-a/SKILL.md", skillMd("Skill A", "Desc A"), + "skill-a/scripts/run.rb", "puts 'ok'" + )); + } + + /** + * assets 允许任意扩展名。 + */ + @Test + public void allowArbitraryAssetExtension() { + List skills = importZip(files( + "skill-a/SKILL.md", skillMd("Skill A", "Desc A"), + "skill-a/assets/model.custom", "asset" + )); + + Assert.assertEquals(1, skills.get(0).getAssets().size()); + Assert.assertEquals("application/octet-stream", skills.get(0).getAssets().get(0).getMediaType()); + } + + /** + * 忽略 macOS 系统文件和空目录。 + */ + @Test + public void ignoreSystemFilesAndEmptyDirectories() { + Map files = files( + "__MACOSX/._x", "ignored", + "skill-a/.DS_Store", "ignored", + "skill-a/SKILL.md", skillMd("Skill A", "Desc A") + ); + List skills = new ZipSkillPackageCodec().importZip(new ByteArrayInputStream(zip(files, "skill-a/references/"))); + + Assert.assertEquals(1, skills.size()); + Assert.assertEquals("skill-a", skills.get(0).getId()); + } + + /** + * 嵌套 references 和 assets 路径正常导入。 + */ + @Test + public void allowNestedManagedDirectories() { + List skills = importZip(files( + "skill-a/SKILL.md", skillMd("Skill A", "Desc A"), + "skill-a/references/rules/a.md", "# Nested", + "skill-a/assets/images/logo.png", "logo" + )); + + Assert.assertEquals("references/rules/a.md", skills.get(0).getReferences().get(0).getPath()); + Assert.assertEquals("assets/images/logo.png", skills.get(0).getAssets().get(0).getPath()); + } + + /** + * 资产字节写入内容存储。 + */ + @Test + public void storeAssetContent() { + InMemorySkillContentStore store = new InMemorySkillContentStore(); + List skills = new ZipSkillPackageCodec(store).importZip(new ByteArrayInputStream(zip(files( + "skill-a/SKILL.md", skillMd("Skill A", "Desc A"), + "skill-a/assets/data.bin", "abc" + )))); + + String contentRef = skills.get(0).getAssets().get(0).getContentRef(); + Assert.assertTrue(store.exists(contentRef)); + Assert.assertArrayEquals("abc".getBytes(StandardCharsets.UTF_8), store.readAllBytes(contentRef)); + } + + /** + * 导入失败时不写入资产内容。 + */ + @Test + public void failedImportDoesNotWriteAssetContent() { + CountingContentStore store = new CountingContentStore(); + + try { + new ZipSkillPackageCodec(store).importZip(new ByteArrayInputStream(zip(files( + "skill-a/SKILL.md", skillMd("Skill A", "Desc A"), + "skill-a/assets/data.bin", "abc", + "skill-a/unknown/a.md", "# Unknown" + )))); + Assert.fail("Import should fail."); + } catch (SkillPackageException expected) { + Assert.assertEquals(0, store.putCount); + } + } + + /** + * 多 Skill 包中后续 Skill 失败时,不提前写入前面 Skill 的资产。 + */ + @Test + public void failedMultiSkillImportDoesNotWriteEarlierAssetContent() { + CountingContentStore store = new CountingContentStore(); + + try { + new ZipSkillPackageCodec(store).importZip(new ByteArrayInputStream(zip(files( + "skill-a/SKILL.md", skillMd("Skill A", "Desc A"), + "skill-a/assets/data.bin", "abc", + "skill-b/references/a.md", "# Missing SKILL.md" + )))); + Assert.fail("Import should fail."); + } catch (SkillPackageException expected) { + Assert.assertEquals(0, store.putCount); + } + } + + /** + * 拒绝嵌套 frontmatter。 + */ + @Test(expected = SkillPackageException.class) + public void rejectNestedFrontmatter() { + importZip(files( + "skill-a/SKILL.md", "---\nname: Skill A\ndescription: Desc A\nconfig:\n mode: strict\n---\n# Skill A\n" + )); + } + + private static List importZip(Map files) { + return new ZipSkillPackageCodec().importZip(new ByteArrayInputStream(zip(files))); + } + + private static Map files(String... keyValues) { + Map files = new LinkedHashMap<>(); + for (int i = 0; i < keyValues.length; i += 2) { + files.put(keyValues[i], keyValues[i + 1]); + } + return files; + } + + private static byte[] zip(Map files, String... directories) { + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ZipOutputStream zipOutputStream = new ZipOutputStream(bytes)) { + for (String directory : directories) { + zipOutputStream.putNextEntry(new ZipEntry(directory)); + zipOutputStream.closeEntry(); + } + for (Map.Entry file : files.entrySet()) { + zipOutputStream.putNextEntry(new ZipEntry(file.getKey())); + zipOutputStream.write(file.getValue().getBytes(StandardCharsets.UTF_8)); + zipOutputStream.closeEntry(); + } + } + return bytes.toByteArray(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private static String skillMd(String name, String description) { + return "---\nname: " + name + "\ndescription: " + description + "\n---\n# " + name + "\n"; + } + + private static final class CountingContentStore implements SkillContentStore { + + private int putCount; + + @Override + public String put(byte[] bytes) { + putCount++; + return "sha256:" + com.easyagents.skill.util.SkillHashes.sha256Hex(bytes); + } + + @Override + public InputStream open(String contentRef) { + throw new UnsupportedOperationException(); + } + + @Override + public byte[] readAllBytes(String contentRef) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean exists(String contentRef) { + return false; + } + } +} diff --git a/easy-agents-skill/src/test/java/com/easyagents/skill/repository/memory/InMemorySkillRepositoryTest.java b/easy-agents-skill/src/test/java/com/easyagents/skill/repository/memory/InMemorySkillRepositoryTest.java new file mode 100644 index 0000000..fa3a550 --- /dev/null +++ b/easy-agents-skill/src/test/java/com/easyagents/skill/repository/memory/InMemorySkillRepositoryTest.java @@ -0,0 +1,83 @@ +package com.easyagents.skill.repository.memory; + +import com.easyagents.skill.model.Skill; +import com.easyagents.skill.model.SkillDescriptor; +import com.easyagents.skill.model.SkillReference; +import org.junit.Assert; +import org.junit.Test; + +import java.util.Optional; + +/** + * InMemorySkillRepository 单元测试。 + */ +public class InMemorySkillRepositoryTest { + + /** + * 覆盖保存、读取、描述、列表、删除和存在性判断。 + */ + @Test + public void crudSkill() { + InMemorySkillRepository repository = new InMemorySkillRepository(); + Skill skill = skill(); + + repository.save(skill); + + Assert.assertTrue(repository.exists("skill-a")); + Assert.assertTrue(repository.get("skill-a").isPresent()); + Assert.assertTrue(repository.getDescriptor("skill-a").isPresent()); + Assert.assertEquals(1, repository.listDescriptors().size()); + + repository.delete("skill-a"); + + Assert.assertFalse(repository.exists("skill-a")); + Assert.assertFalse(repository.get("skill-a").isPresent()); + } + + /** + * descriptor 不携带 references/scripts/assets 内容。 + */ + @Test + public void descriptorDoesNotExposeResourceContent() { + InMemorySkillRepository repository = new InMemorySkillRepository(); + repository.save(skill()); + + Optional descriptor = repository.getDescriptor("skill-a"); + + Assert.assertTrue(descriptor.isPresent()); + Assert.assertEquals("skill-a", descriptor.get().getId()); + Assert.assertEquals("Skill A", descriptor.get().getName()); + } + + /** + * 读取返回副本,避免外部修改仓储内部状态。 + */ + @Test + public void getReturnsCopy() { + InMemorySkillRepository repository = new InMemorySkillRepository(); + repository.save(skill()); + + Skill loaded = repository.get("skill-a").get(); + loaded.setName("Changed"); + loaded.getReferences().get(0).setContent("changed"); + + Skill reloaded = repository.get("skill-a").get(); + Assert.assertEquals("Skill A", reloaded.getName()); + Assert.assertEquals("# A", reloaded.getReferences().get(0).getContent()); + } + + private static Skill skill() { + Skill skill = new Skill(); + skill.setId("skill-a"); + skill.setName("Skill A"); + skill.setDescription("Desc A"); + skill.setSkillContent("---\nname: Skill A\ndescription: Desc A\n---\n# Skill A\n"); + + SkillReference reference = new SkillReference(); + reference.setPath("references/a.md"); + reference.setName("a.md"); + reference.setContent("# A"); + skill.getReferences().add(reference); + return skill; + } +} diff --git a/easy-agents-skill/src/test/java/com/easyagents/skill/store/memory/InMemorySkillContentStoreTest.java b/easy-agents-skill/src/test/java/com/easyagents/skill/store/memory/InMemorySkillContentStoreTest.java new file mode 100644 index 0000000..1050cbe --- /dev/null +++ b/easy-agents-skill/src/test/java/com/easyagents/skill/store/memory/InMemorySkillContentStoreTest.java @@ -0,0 +1,46 @@ +package com.easyagents.skill.store.memory; + +import org.junit.Assert; +import org.junit.Test; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +/** + * InMemorySkillContentStore 单元测试。 + */ +public class InMemorySkillContentStoreTest { + + /** + * put 返回 sha256 内容引用,且相同内容引用相同。 + */ + @Test + public void putReturnsStableSha256Ref() { + InMemorySkillContentStore store = new InMemorySkillContentStore(); + + String firstRef = store.put("abc".getBytes(StandardCharsets.UTF_8)); + String secondRef = store.put("abc".getBytes(StandardCharsets.UTF_8)); + + Assert.assertTrue(firstRef.startsWith("sha256:")); + Assert.assertEquals(firstRef, secondRef); + } + + /** + * open、readAllBytes 和 exists 正常工作。 + * + * @throws Exception 读取流失败时抛出 + */ + @Test + public void openReadAndExists() throws Exception { + InMemorySkillContentStore store = new InMemorySkillContentStore(); + byte[] bytes = "abc".getBytes(StandardCharsets.UTF_8); + + String contentRef = store.put(bytes); + + Assert.assertTrue(store.exists(contentRef)); + Assert.assertArrayEquals(bytes, store.readAllBytes(contentRef)); + try (InputStream inputStream = store.open(contentRef)) { + Assert.assertArrayEquals(bytes, inputStream.readAllBytes()); + } + } +} diff --git a/easy-agents-skill/src/test/java/com/easyagents/skill/validation/defaults/DefaultSkillValidatorTest.java b/easy-agents-skill/src/test/java/com/easyagents/skill/validation/defaults/DefaultSkillValidatorTest.java new file mode 100644 index 0000000..fdaab0a --- /dev/null +++ b/easy-agents-skill/src/test/java/com/easyagents/skill/validation/defaults/DefaultSkillValidatorTest.java @@ -0,0 +1,193 @@ +package com.easyagents.skill.validation.defaults; + +import com.easyagents.skill.exception.SkillValidationException; +import com.easyagents.skill.factory.SkillFactory; +import com.easyagents.skill.model.*; +import com.easyagents.skill.util.SkillHashes; +import org.junit.Test; + +import java.nio.charset.StandardCharsets; + +/** + * DefaultSkillValidator 单元测试。 + */ +public class DefaultSkillValidatorTest { + + private final DefaultSkillValidator validator = new DefaultSkillValidator(); + + /** + * 缺失 SKILL.md 内容时失败。 + */ + @Test(expected = SkillValidationException.class) + public void rejectMissingSkillContent() { + Skill skill = validSkill(); + skill.setSkillContent(null); + + validator.validate(skill); + } + + /** + * 缺失名称时失败。 + */ + @Test(expected = SkillValidationException.class) + public void rejectMissingName() { + Skill skill = validSkill(); + skill.setName(""); + + validator.validate(skill); + } + + /** + * 缺失描述时失败。 + */ + @Test(expected = SkillValidationException.class) + public void rejectMissingDescription() { + Skill skill = validSkill(); + skill.setDescription(""); + + validator.validate(skill); + } + + /** + * 字段名称必须与 SKILL.md frontmatter 一致。 + */ + @Test(expected = SkillValidationException.class) + public void rejectNameMismatchWithFrontmatter() { + Skill skill = validSkill(); + skill.setName("Changed"); + + validator.validate(skill); + } + + /** + * 元数据必须与 SKILL.md frontmatter 一致。 + */ + @Test(expected = SkillValidationException.class) + public void rejectMetadataMismatchWithFrontmatter() { + Skill skill = validSkill(); + skill.getMetadata().put("extra", "x"); + + validator.validate(skill); + } + + /** + * 拒绝嵌套 frontmatter。 + */ + @Test(expected = SkillValidationException.class) + public void rejectNestedFrontmatter() { + Skill skill = validSkill(); + skill.setSkillContent("---\nname: Skill A\ndescription: Desc A\nconfig:\n mode: strict\n---\n# Skill A\n"); + + validator.validate(skill); + } + + /** + * 绝对路径失败。 + */ + @Test(expected = SkillValidationException.class) + public void rejectAbsolutePath() { + Skill skill = validSkill(); + SkillReference reference = reference("/references/a.md", "# A"); + skill.getReferences().add(reference); + + validator.validate(skill); + } + + /** + * ../ 路径失败。 + */ + @Test(expected = SkillValidationException.class) + public void rejectParentPath() { + Skill skill = validSkill(); + skill.getReferences().add(reference("references/../a.md", "# A")); + + validator.validate(skill); + } + + /** + * 重复路径失败。 + */ + @Test(expected = SkillValidationException.class) + public void rejectDuplicatePath() { + Skill skill = validSkill(); + skill.getReferences().add(reference("references/a.md", "# A")); + skill.getReferences().add(reference("references/a.md", "# B")); + + validator.validate(skill); + } + + /** + * 不因为文件大小较大而失败。 + */ + @Test + public void allowLargeRecordedSize() { + Skill skill = validSkill(); + SkillReference reference = reference("references/a.md", "# A"); + reference.setSize(Long.MAX_VALUE); + skill.getReferences().add(reference); + + validator.validate(skill); + } + + /** + * 允许空内容文件,只校验 hash 和 size 记录。 + */ + @Test + public void allowEmptyReferenceAndScriptContent() { + Skill skill = validSkill(); + skill.getReferences().add(reference("references/empty.md", "")); + skill.getScripts().add(script("scripts/empty.sh", "")); + + validator.validate(skill); + } + + /** + * 校验通过完整 Skill。 + */ + @Test + public void validateCompleteSkill() { + Skill skill = validSkill(); + skill.getReferences().add(reference("references/a.md", "# A")); + skill.getScripts().add(script("scripts/run.sh", "echo ok")); + skill.getAssets().add(asset("assets/a.bin", "abc")); + + validator.validate(skill); + } + + private static Skill validSkill() { + return SkillFactory.create("skill-a", "---\nname: Skill A\ndescription: Desc A\n---\n# Skill A\n"); + } + + private static SkillReference reference(String path, String content) { + SkillReference reference = new SkillReference(); + reference.setPath(path); + reference.setName("a.md"); + reference.setContent(content); + reference.setContentHash(SkillHashes.sha256Hex(content.getBytes(StandardCharsets.UTF_8))); + reference.setSize(content.getBytes(StandardCharsets.UTF_8).length); + return reference; + } + + private static SkillScript script(String path, String content) { + SkillScript script = new SkillScript(); + script.setPath(path); + script.setLanguage(SkillScriptLanguage.fromPath(path)); + script.setContent(content); + script.setContentHash(SkillHashes.sha256Hex(content.getBytes(StandardCharsets.UTF_8))); + script.setSize(content.getBytes(StandardCharsets.UTF_8).length); + return script; + } + + private static SkillAsset asset(String path, String content) { + byte[] bytes = content.getBytes(StandardCharsets.UTF_8); + String hash = SkillHashes.sha256Hex(bytes); + SkillAsset asset = new SkillAsset(); + asset.setPath(path); + asset.setName("a.bin"); + asset.setMediaType("application/octet-stream"); + asset.setContentRef("sha256:" + hash); + asset.setContentHash(hash); + asset.setSize(bytes.length); + return asset; + } +} diff --git a/pom.xml b/pom.xml index 07e17d1..d9ed2c4 100644 --- a/pom.xml +++ b/pom.xml @@ -27,6 +27,7 @@ easy-agents-embedding easy-agents-tool easy-agents-mcp + easy-agents-skill easy-agents-agent-runtime easy-agents-flow easy-agents-support @@ -430,6 +431,12 @@ ${revision} + + com.easyagents + easy-agents-skill + ${revision} + + com.easyagents easy-agents-agent-runtime