发布 v1.1.0 #2

Merged
czm merged 34 commits from develop into main 2026-08-20 11:35:40 +08:00
29 changed files with 2622 additions and 0 deletions
Showing only changes of commit 13e848ddf4 - Show all commits

View File

@@ -256,6 +256,10 @@
<!--store end-->
<!--agent runtime start-->
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-skill</artifactId>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-agent-runtime</artifactId>

27
easy-agents-skill/pom.xml Normal file
View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents</artifactId>
<version>${revision}</version>
</parent>
<name>easy-agents-skill</name>
<artifactId>easy-agents-skill</artifactId>
<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -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<Skill> importZip(InputStream inputStream);
}

View File

@@ -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<Skill> importZip(InputStream inputStream) {
if (inputStream == null) {
throw new SkillPackageException("Zip input stream is required.");
}
Map<String, SkillBuilder> 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<Skill> 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<String, SkillBuilder> 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<String> paths = new HashSet<>();
private String skillContent;
private final List<SkillReference> references = new ArrayList<>();
private final List<SkillScript> scripts = new ArrayList<>();
private final List<SkillAsset> assets = new ArrayList<>();
private final List<PendingAsset> 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;
}
}
}

View File

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

View File

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

View File

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

View File

@@ -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<SkillReference> references,
List<SkillScript> scripts, List<SkillAsset> assets) {
Map<String, Object> 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;
}
}

View File

@@ -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<SkillReference> references = new ArrayList<>();
private List<SkillScript> scripts = new ArrayList<>();
private List<SkillAsset> 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<SkillReference> getReferences() {
return references;
}
/**
* 设置参考文档列表。
*
* @param references 参考文档列表
*/
public void setReferences(List<SkillReference> references) {
this.references = references == null ? new ArrayList<>() : new ArrayList<>(references);
}
/**
* 获取脚本列表。
*
* @return 脚本列表
*/
public List<SkillScript> getScripts() {
return scripts;
}
/**
* 设置脚本列表。
*
* @param scripts 脚本列表
*/
public void setScripts(List<SkillScript> scripts) {
this.scripts = scripts == null ? new ArrayList<>() : new ArrayList<>(scripts);
}
/**
* 获取资产列表。
*
* @return 资产列表
*/
public List<SkillAsset> getAssets() {
return assets;
}
/**
* 设置资产列表。
*
* @param assets 资产列表
*/
public void setAssets(List<SkillAsset> assets) {
this.assets = assets == null ? new ArrayList<>() : new ArrayList<>(assets);
}
/**
* 转换为轻量描述。
*
* @return Skill 描述
*/
public SkillDescriptor toDescriptor() {
return new SkillDescriptor(id, name, description, metadata);
}
}

View File

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

View File

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

View File

@@ -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<String, Object> values = new LinkedHashMap<>();
/**
* 创建空元数据。
*/
public SkillMetadata() {
}
/**
* 使用已有键值创建元数据。
*
* @param values 元数据键值
*/
public SkillMetadata(Map<String, Object> values) {
setValues(values);
}
/**
* 获取元数据键值。
*
* @return 元数据键值
*/
public Map<String, Object> getValues() {
return values;
}
/**
* 设置元数据键值。
*
* @param values 元数据键值
*/
public void setValues(Map<String, Object> 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();
}
}

View File

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

View File

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

View File

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

View File

@@ -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<Skill> get(String skillId);
/**
* 获取 Skill 描述。
*
* @param skillId Skill ID
* @return Skill 描述
*/
Optional<SkillDescriptor> getDescriptor(String skillId);
/**
* 列出 Skill 描述。
*
* @return Skill 描述列表
*/
List<SkillDescriptor> listDescriptors();
/**
* 删除 Skill。
*
* @param skillId Skill ID
*/
void delete(String skillId);
/**
* 判断 Skill 是否存在。
*
* @param skillId Skill ID
* @return 存在时为 true
*/
boolean exists(String skillId);
}

View File

@@ -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<String, Skill> 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<Skill> 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<SkillDescriptor> getDescriptor(String skillId) {
Skill skill = skills.get(skillId);
return skill == null ? Optional.empty() : Optional.of(copyDescriptor(skill.toDescriptor()));
}
/**
* 列出 Skill 描述。
*
* @return Skill 描述列表
*/
@Override
public List<SkillDescriptor> listDescriptors() {
List<SkillDescriptor> 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<SkillReference> copyReferences(List<SkillReference> sources) {
List<SkillReference> 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<SkillScript> copyScripts(List<SkillScript> sources) {
List<SkillScript> 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<SkillAsset> copyAssets(List<SkillAsset> sources) {
List<SkillAsset> 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();
}
}

View File

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

View File

@@ -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<String, byte[]> 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);
}
}

View File

@@ -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<String, Object> 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<String, Object> 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<String, Object> 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();
}
}

View File

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

View File

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

View File

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

View File

@@ -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<String> 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<SkillReference> references, Set<String> 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<SkillScript> scripts, Set<String> 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<SkillAsset> assets, Set<String> 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<String, Object> 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<String> 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);
}
}
}

View File

@@ -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<Skill> 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<Skill> 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<Skill> 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<String, String> files = files(
"__MACOSX/._x", "ignored",
"skill-a/.DS_Store", "ignored",
"skill-a/SKILL.md", skillMd("Skill A", "Desc A")
);
List<Skill> 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<Skill> 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<Skill> 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<Skill> importZip(Map<String, String> files) {
return new ZipSkillPackageCodec().importZip(new ByteArrayInputStream(zip(files)));
}
private static Map<String, String> files(String... keyValues) {
Map<String, String> 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<String, String> 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<String, String> 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;
}
}
}

View File

@@ -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<SkillDescriptor> 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;
}
}

View File

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

View File

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

View File

@@ -27,6 +27,7 @@
<module>easy-agents-embedding</module>
<module>easy-agents-tool</module>
<module>easy-agents-mcp</module>
<module>easy-agents-skill</module>
<module>easy-agents-agent-runtime</module>
<module>easy-agents-flow</module>
<module>easy-agents-support</module>
@@ -430,6 +431,12 @@
<version>${revision}</version>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-skill</artifactId>
<version>${revision}</version>
</dependency>
<dependency>
<groupId>com.easyagents</groupId>
<artifactId>easy-agents-agent-runtime</artifactId>