发布 v1.1.0 #2
@@ -256,6 +256,10 @@
|
|||||||
<!--store end-->
|
<!--store end-->
|
||||||
|
|
||||||
<!--agent runtime start-->
|
<!--agent runtime start-->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.easyagents</groupId>
|
||||||
|
<artifactId>easy-agents-skill</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.easyagents</groupId>
|
<groupId>com.easyagents</groupId>
|
||||||
<artifactId>easy-agents-agent-runtime</artifactId>
|
<artifactId>easy-agents-agent-runtime</artifactId>
|
||||||
|
|||||||
27
easy-agents-skill/pom.xml
Normal file
27
easy-agents-skill/pom.xml
Normal 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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
7
pom.xml
7
pom.xml
@@ -27,6 +27,7 @@
|
|||||||
<module>easy-agents-embedding</module>
|
<module>easy-agents-embedding</module>
|
||||||
<module>easy-agents-tool</module>
|
<module>easy-agents-tool</module>
|
||||||
<module>easy-agents-mcp</module>
|
<module>easy-agents-mcp</module>
|
||||||
|
<module>easy-agents-skill</module>
|
||||||
<module>easy-agents-agent-runtime</module>
|
<module>easy-agents-agent-runtime</module>
|
||||||
<module>easy-agents-flow</module>
|
<module>easy-agents-flow</module>
|
||||||
<module>easy-agents-support</module>
|
<module>easy-agents-support</module>
|
||||||
@@ -430,6 +431,12 @@
|
|||||||
<version>${revision}</version>
|
<version>${revision}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.easyagents</groupId>
|
||||||
|
<artifactId>easy-agents-skill</artifactId>
|
||||||
|
<version>${revision}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.easyagents</groupId>
|
<groupId>com.easyagents</groupId>
|
||||||
<artifactId>easy-agents-agent-runtime</artifactId>
|
<artifactId>easy-agents-agent-runtime</artifactId>
|
||||||
|
|||||||
Reference in New Issue
Block a user