refactor: 精简标准 Skill 包模型
- 统一使用 SKILL.md 与通用资源表达 - 删除仓储、专用资源类型和低价值兼容接口 - 保留安全 ZIP 编解码、校验和内容存储能力
This commit is contained in:
@@ -14,6 +14,13 @@ Codec 支持根目录单 Skill、单目录 Skill 和多目录 Skill 三种输入
|
||||
|
||||
`SKILL.md` 使用 YAML frontmatter 与 Markdown 正文。未知字段、嵌套 Map/List、布尔值和数字会保留;语义校验通过结构化 issue 返回路径、行列、错误码与修复建议。
|
||||
|
||||
模块内的可移植模型只有两层:
|
||||
|
||||
- `SkillDocument`:`SKILL.md` 原文、frontmatter 和 Markdown 正文
|
||||
- `SkillResource`:除 `SKILL.md` 外的任意安全相对路径文件
|
||||
|
||||
Skill 的数据库主键、分类、权限、发布和审批属于上层技能库,不进入标准包模型。
|
||||
|
||||
## 推荐调用方式
|
||||
|
||||
无参 `ZipSkillPackageCodec` 使用实例级临时文件存储,适合一次性导入导出。它拥有临时目录,必须关闭:
|
||||
@@ -41,10 +48,10 @@ codec.encode(result.getSkillPackage(), outputStream, writeOptions);
|
||||
|
||||
校验通过 `SkillValidationMode` 区分两个明确上下文:
|
||||
|
||||
- `DRAFT_IMPORT`:ZIP 导入和兼容预检使用;历史下划线名称保留为 warning,允许先进入草稿修复。
|
||||
- `DRAFT_IMPORT`:ZIP 导入和草稿编辑使用;可修复的命名问题返回 warning。
|
||||
- `STANDARD`:正式新建、发布校验和标准 ZIP 导出使用;下划线名称等互操作问题作为 error。
|
||||
|
||||
`SkillFactory.createStrict`、`SkillFactory.createWithResourcesStrict`、`DefaultSkillValidator.validate` 与 `ZipSkillPackageCodec.encode` 执行 `STANDARD` 校验。为保持旧调用兼容,`SkillFactory.create` 仍可构建导入草稿,原有 `validateReport(skill)` 与 `validateReport(skill, limits)` 继续使用 `DRAFT_IMPORT`;新调用方需要显式上下文时使用三参数 `validateReport`。
|
||||
`DefaultSkillValidator.validate` 与 `ZipSkillPackageCodec.encode` 执行 `STANDARD` 校验。`SkillFactory.create` 构建草稿;需要指定校验上下文时调用三参数 `validateReport`。
|
||||
|
||||
## 安全边界
|
||||
|
||||
@@ -59,14 +66,14 @@ codec.encode(result.getSkillPackage(), outputStream, writeOptions);
|
||||
|
||||
限额通过 `SkillPackageLimits` 配置,并由 `SkillPackageReadOptions`、`SkillPackageWriteOptions` 传入单次操作。
|
||||
|
||||
## 从旧接口迁移
|
||||
## 编解码结果
|
||||
|
||||
`importZip(InputStream)` 为兼容入口,现已废弃。新调用方应使用 `decode`,以获得:
|
||||
`decode` 返回:
|
||||
|
||||
- 包布局 `SkillPackageLayout`
|
||||
- 标准化 `SkillPackage`
|
||||
- 包哈希
|
||||
- 聚合校验报告
|
||||
- `STRICT` 或 `REPORT_ONLY` 读取模式
|
||||
- `COMMIT_ON_VALID` 或 `REPORT_ONLY` 读取模式
|
||||
|
||||
写出统一使用 `encode`。自定义校验器是附加业务校验,不能替代 Codec 内置的标准安全校验。
|
||||
写出统一使用 `encode`。Codec 始终执行内置标准与安全校验。
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
package com.easyagents.skill.codec;
|
||||
|
||||
import com.easyagents.skill.exception.SkillPackageException;
|
||||
import com.easyagents.skill.model.Skill;
|
||||
import com.easyagents.skill.model.SkillPackage;
|
||||
import com.easyagents.skill.model.SkillPackageLayout;
|
||||
import com.easyagents.skill.validation.SkillValidationReport;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Skill 包双向流式编解码接口。
|
||||
*/
|
||||
public interface SkillPackageCodec {
|
||||
|
||||
/**
|
||||
* 从 ZIP 输入流导入 Skill。
|
||||
*
|
||||
* @param inputStream ZIP 输入流
|
||||
* @return Skill 列表
|
||||
* @throws SkillPackageException ZIP 结构、内容或安全校验失败
|
||||
* @deprecated 请使用 {@link #decode(InputStream, SkillPackageReadOptions)} 获取包形态、hash 和诊断。
|
||||
*/
|
||||
@Deprecated
|
||||
List<Skill> importZip(InputStream inputStream);
|
||||
|
||||
/**
|
||||
* 解码 Skill ZIP。
|
||||
*
|
||||
* <p>兼容默认实现委托旧导入接口;正式 Codec 应覆盖。</p>
|
||||
*
|
||||
* @param inputStream ZIP 输入流
|
||||
* @param options 读取选项
|
||||
* @return 解码结果
|
||||
* @throws SkillPackageException ZIP 结构、内容、安全校验或资源存储失败
|
||||
*/
|
||||
default SkillPackageReadResult decode(InputStream inputStream, SkillPackageReadOptions options) {
|
||||
List<Skill> skills = importZip(inputStream);
|
||||
SkillPackageLayout layout = skills.size() > 1
|
||||
? SkillPackageLayout.MULTI_DIRECTORY : SkillPackageLayout.SINGLE_DIRECTORY;
|
||||
return new SkillPackageReadResult(new SkillPackage(layout, skills), new SkillValidationReport(), null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Skill 包编码为标准 ZIP。
|
||||
*
|
||||
* @param skillPackage Skill 包
|
||||
* @param outputStream 输出流,不由本方法关闭
|
||||
* @param options 写出选项
|
||||
* @return 编码结果
|
||||
* @throws SkillPackageException Skill 包不合法、资源不可读或 ZIP 写出失败
|
||||
*/
|
||||
default SkillPackageWriteResult encode(SkillPackage skillPackage, OutputStream outputStream,
|
||||
SkillPackageWriteOptions options) {
|
||||
throw new SkillPackageException("This SkillPackageCodec does not support encoding.");
|
||||
}
|
||||
}
|
||||
@@ -12,17 +12,6 @@ public final class SkillPackageWriteResult {
|
||||
private final int entryCount;
|
||||
private final SkillPackageLayout layout;
|
||||
|
||||
/**
|
||||
* 创建编码结果。
|
||||
*
|
||||
* @param packageHash 输出 ZIP SHA-256
|
||||
* @param size 输出字节数
|
||||
* @param entryCount 输出 entry 数
|
||||
*/
|
||||
public SkillPackageWriteResult(String packageHash, long size, int entryCount) {
|
||||
this(packageHash, size, entryCount, SkillPackageLayout.SINGLE_DIRECTORY);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带包形态的编码结果。
|
||||
*
|
||||
|
||||
@@ -4,7 +4,6 @@ import com.easyagents.skill.exception.SkillPackageException;
|
||||
import com.easyagents.skill.exception.SkillValidationException;
|
||||
import com.easyagents.skill.model.Skill;
|
||||
import com.easyagents.skill.model.SkillDocument;
|
||||
import com.easyagents.skill.model.SkillMetadata;
|
||||
import com.easyagents.skill.model.SkillPackage;
|
||||
import com.easyagents.skill.model.SkillPackageLayout;
|
||||
import com.easyagents.skill.model.SkillPackageLimits;
|
||||
@@ -22,7 +21,6 @@ import com.easyagents.skill.validation.SkillValidationIssue;
|
||||
import com.easyagents.skill.validation.SkillValidationMode;
|
||||
import com.easyagents.skill.validation.SkillValidationReport;
|
||||
import com.easyagents.skill.validation.SkillValidationSeverity;
|
||||
import com.easyagents.skill.validation.SkillValidator;
|
||||
import com.easyagents.skill.validation.defaults.DefaultSkillValidator;
|
||||
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
|
||||
import org.apache.commons.compress.archivers.zip.ZipFile;
|
||||
@@ -62,7 +60,7 @@ import java.util.zip.ZipOutputStream;
|
||||
/**
|
||||
* 标准 Skill ZIP 的安全双向流式 Codec。
|
||||
*/
|
||||
public class ZipSkillPackageCodec implements SkillPackageCodec, AutoCloseable {
|
||||
public class ZipSkillPackageCodec implements AutoCloseable {
|
||||
|
||||
private static final String DEFAULT_MEDIA_TYPE = "application/octet-stream";
|
||||
private static final int CENTRAL_DIRECTORY_HEADER_SIZE = 46;
|
||||
@@ -79,7 +77,6 @@ public class ZipSkillPackageCodec implements SkillPackageCodec, AutoCloseable {
|
||||
private static final int UINT16_MAX = 0xFFFF;
|
||||
|
||||
private final SkillContentStore contentStore;
|
||||
private final SkillValidator additionalValidator;
|
||||
private final boolean ownsContentStore;
|
||||
|
||||
/**
|
||||
@@ -88,7 +85,7 @@ public class ZipSkillPackageCodec implements SkillPackageCodec, AutoCloseable {
|
||||
* <p>该实例拥有默认内容存储,使用完毕后应调用 {@link #close()} 清理临时内容。</p>
|
||||
*/
|
||||
public ZipSkillPackageCodec() {
|
||||
this(new TemporaryFileSkillContentStore(), null, true);
|
||||
this(new TemporaryFileSkillContentStore(), true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,36 +94,17 @@ public class ZipSkillPackageCodec implements SkillPackageCodec, AutoCloseable {
|
||||
* @param contentStore 二进制内容存储
|
||||
*/
|
||||
public ZipSkillPackageCodec(SkillContentStore contentStore) {
|
||||
this(contentStore, null, false);
|
||||
this(contentStore, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 ZIP Codec。
|
||||
*
|
||||
* @param contentStore 二进制内容存储
|
||||
* @param validator Skill 聚合校验器
|
||||
*/
|
||||
public ZipSkillPackageCodec(SkillContentStore contentStore, SkillValidator validator) {
|
||||
this(contentStore, requireValidator(validator), false);
|
||||
}
|
||||
|
||||
private ZipSkillPackageCodec(SkillContentStore contentStore, SkillValidator validator,
|
||||
boolean ownsContentStore) {
|
||||
private ZipSkillPackageCodec(SkillContentStore contentStore, boolean ownsContentStore) {
|
||||
if (contentStore == null) {
|
||||
throw new SkillPackageException("Skill content store is required.");
|
||||
}
|
||||
this.contentStore = contentStore;
|
||||
this.additionalValidator = validator;
|
||||
this.ownsContentStore = ownsContentStore;
|
||||
}
|
||||
|
||||
private static SkillValidator requireValidator(SkillValidator validator) {
|
||||
if (validator == null) {
|
||||
throw new SkillPackageException("Skill validator is required.");
|
||||
}
|
||||
return validator;
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭 Codec 自有的默认临时内容存储。
|
||||
*
|
||||
@@ -139,19 +117,6 @@ public class ZipSkillPackageCodec implements SkillPackageCodec, AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用兼容入口导入 ZIP。
|
||||
*
|
||||
* @param inputStream ZIP 输入流
|
||||
* @return Skill 列表
|
||||
* @deprecated 请使用 {@link #decode(InputStream, SkillPackageReadOptions)}。
|
||||
*/
|
||||
@Deprecated
|
||||
@Override
|
||||
public List<Skill> importZip(InputStream inputStream) {
|
||||
return decode(inputStream, SkillPackageReadOptions.defaults()).getSkillPackage().getSkills();
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全解码 Skill ZIP。
|
||||
*
|
||||
@@ -159,7 +124,6 @@ public class ZipSkillPackageCodec implements SkillPackageCodec, AutoCloseable {
|
||||
* @param options 读取选项
|
||||
* @return 解码结果
|
||||
*/
|
||||
@Override
|
||||
public SkillPackageReadResult decode(InputStream inputStream, SkillPackageReadOptions options) {
|
||||
if (inputStream == null) {
|
||||
throw packageError("ZIP_INPUT_REQUIRED", null, "ZIP input stream is required.");
|
||||
@@ -221,7 +185,6 @@ public class ZipSkillPackageCodec implements SkillPackageCodec, AutoCloseable {
|
||||
* @param options 写出选项
|
||||
* @return 编码结果
|
||||
*/
|
||||
@Override
|
||||
public SkillPackageWriteResult encode(SkillPackage skillPackage, OutputStream outputStream,
|
||||
SkillPackageWriteOptions options) {
|
||||
if (skillPackage == null || skillPackage.getSkills() == null || skillPackage.getSkills().isEmpty()) {
|
||||
@@ -370,9 +333,7 @@ public class ZipSkillPackageCodec implements SkillPackageCodec, AutoCloseable {
|
||||
throw validationPackageError(e,
|
||||
layout == SkillPackageLayout.ROOT_SKILL ? null : group.root);
|
||||
}
|
||||
Map<String, Object> frontmatter = document.getFrontmatter().getValues();
|
||||
String name = scalar(frontmatter.get("name"));
|
||||
String description = scalar(frontmatter.get("description"));
|
||||
String name = scalar(document.getFrontmatter().get("name"));
|
||||
|
||||
List<SkillResource> resources = new ArrayList<>();
|
||||
for (ArchiveFile file : group.files) {
|
||||
@@ -385,14 +346,9 @@ public class ZipSkillPackageCodec implements SkillPackageCodec, AutoCloseable {
|
||||
resources.sort(Comparator.comparing(SkillResource::getPath));
|
||||
|
||||
Skill skill = new Skill();
|
||||
skill.setId(null);
|
||||
skill.setPackageRoot(layout == SkillPackageLayout.ROOT_SKILL ? name : group.root);
|
||||
skill.setName(name);
|
||||
skill.setDescription(description);
|
||||
skill.setMetadata(new SkillMetadata(frontmatter));
|
||||
skill.setDocument(document);
|
||||
skill.setResources(resources);
|
||||
SkillResources.refreshLegacyViews(skill);
|
||||
return skill;
|
||||
}
|
||||
|
||||
@@ -400,7 +356,7 @@ public class ZipSkillPackageCodec implements SkillPackageCodec, AutoCloseable {
|
||||
StageTracker stages) throws IOException {
|
||||
SkillResourceKind kind = SkillResources.classify(file.relativePath);
|
||||
String mediaType = detectMediaType(file.relativePath);
|
||||
boolean text = SkillResources.isText(file.relativePath, kind, mediaType);
|
||||
boolean text = SkillResources.isText(file.relativePath, mediaType);
|
||||
long singleLimit = text ? limits.getMaxTextFileBytes() : limits.getMaxBinaryFileBytes();
|
||||
if (file.entry.getSize() > singleLimit) {
|
||||
throw packageError(text ? "TEXT_FILE_SIZE_LIMIT" : "BINARY_FILE_SIZE_LIMIT", file.fullPath,
|
||||
@@ -489,24 +445,16 @@ public class ZipSkillPackageCodec implements SkillPackageCodec, AutoCloseable {
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 Codec 不可绕过的标准安全校验,并追加调用方业务校验。
|
||||
* 执行 Codec 不可绕过的标准安全校验。
|
||||
*
|
||||
* @param skill Skill 聚合
|
||||
* @param limits 当前读写操作限额
|
||||
* @param mode 标准校验模式
|
||||
* @return 合并后的结构化报告
|
||||
* @return 结构化报告
|
||||
*/
|
||||
private SkillValidationReport validateForCodec(Skill skill, SkillPackageLimits limits,
|
||||
SkillValidationMode mode) {
|
||||
SkillValidationReport report = new DefaultSkillValidator(limits)
|
||||
.validateReport(skill, limits, mode);
|
||||
if (additionalValidator != null) {
|
||||
SkillValidationReport additionalReport = additionalValidator.getClass() == DefaultSkillValidator.class
|
||||
? additionalValidator.validateReport(skill, null, mode)
|
||||
: additionalValidator.validateReport(skill, limits, mode);
|
||||
report.merge(additionalReport);
|
||||
}
|
||||
return report;
|
||||
return new DefaultSkillValidator(limits).validateReport(skill, limits, mode);
|
||||
}
|
||||
|
||||
private List<OutputFile> prepareOutput(List<Skill> skills, SkillPackageLimits limits) {
|
||||
@@ -557,7 +505,7 @@ public class ZipSkillPackageCodec implements SkillPackageCodec, AutoCloseable {
|
||||
files.add(OutputFile.text(skillPath, skillContent));
|
||||
totalSize = checkedOutputTotal(totalSize, skillSize, limits, skillPath);
|
||||
|
||||
List<SkillResource> resources = SkillResources.canonicalResources(skill);
|
||||
List<SkillResource> resources = new ArrayList<>(skill.getResources());
|
||||
resources.sort(Comparator.comparing(SkillResource::getPath));
|
||||
for (SkillResource resource : resources) {
|
||||
String path = skill.getName() + "/" + SkillPaths.normalize(resource.getPath());
|
||||
@@ -1533,22 +1481,27 @@ public class ZipSkillPackageCodec implements SkillPackageCodec, AutoCloseable {
|
||||
}
|
||||
SkillPackageException rollbackFailure = null;
|
||||
for (int index = records.size() - 1; index >= 0; index--) {
|
||||
StageRecord record = records.get(index);
|
||||
if (record.cleaned) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
contentStore.rollback(records.get(index).stage);
|
||||
contentStore.rollback(record.stage);
|
||||
record.cleaned = true;
|
||||
} catch (RuntimeException cleanupError) {
|
||||
if (rollbackFailure == null) {
|
||||
rollbackFailure = new SkillPackageException(
|
||||
"SKILL_CONTENT_ROLLBACK_ERROR", records.get(index).path,
|
||||
"SKILL_CONTENT_ROLLBACK_ERROR", record.path,
|
||||
"Failed to rollback staged Skill package content.", cleanupError);
|
||||
} else {
|
||||
rollbackFailure.addSuppressed(cleanupError);
|
||||
}
|
||||
}
|
||||
}
|
||||
finalized = true;
|
||||
if (rollbackFailure != null) {
|
||||
throw rollbackFailure;
|
||||
}
|
||||
finalized = true;
|
||||
}
|
||||
|
||||
private void cleanupAfterFailure(Throwable primary) {
|
||||
@@ -1557,17 +1510,21 @@ public class ZipSkillPackageCodec implements SkillPackageCodec, AutoCloseable {
|
||||
}
|
||||
for (int index = records.size() - 1; index >= 0; index--) {
|
||||
StageRecord record = records.get(index);
|
||||
if (record.cleaned) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (record.committedRef == null) {
|
||||
contentStore.rollback(record.stage);
|
||||
} else {
|
||||
contentStore.release(record.committedRef);
|
||||
}
|
||||
record.cleaned = true;
|
||||
} catch (RuntimeException cleanupError) {
|
||||
primary.addSuppressed(cleanupError);
|
||||
}
|
||||
}
|
||||
finalized = true;
|
||||
finalized = records.stream().allMatch(record -> record.cleaned);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1577,6 +1534,7 @@ public class ZipSkillPackageCodec implements SkillPackageCodec, AutoCloseable {
|
||||
private final SkillResource resource;
|
||||
private final String path;
|
||||
private String committedRef;
|
||||
private boolean cleaned;
|
||||
|
||||
private StageRecord(SkillContentStage stage, SkillResource resource, String path) {
|
||||
this.stage = stage;
|
||||
|
||||
@@ -2,8 +2,6 @@ package com.easyagents.skill.factory;
|
||||
|
||||
import com.easyagents.skill.model.*;
|
||||
import com.easyagents.skill.util.SkillFrontmatter;
|
||||
import com.easyagents.skill.util.SkillResources;
|
||||
import com.easyagents.skill.validation.defaults.DefaultSkillValidator;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -19,92 +17,37 @@ public final class 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 原始内容
|
||||
* @return 通过正式标准校验的 Skill 聚合
|
||||
*/
|
||||
public static Skill createStrict(String id, String skillContent) {
|
||||
Skill skill = create(id, skillContent);
|
||||
new DefaultSkillValidator().validate(skill);
|
||||
public static Skill create(String skillContent) {
|
||||
SkillDocument document = SkillFrontmatter.parseDocument(skillContent);
|
||||
Map<String, Object> values = document.getFrontmatter().getValues();
|
||||
requiredScalar(values, "name");
|
||||
requiredScalar(values, "description");
|
||||
Skill skill = new Skill();
|
||||
skill.setDocument(document);
|
||||
return skill;
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于 SKILL.md 内容和通用资源创建 Skill。
|
||||
*
|
||||
* @param id 仓储 ID,可为空
|
||||
* @param skillContent SKILL.md 原始内容
|
||||
* @param resources 通用资源列表
|
||||
* @return Skill 聚合
|
||||
*/
|
||||
public static Skill createWithResources(String id, String skillContent, List<SkillResource> resources) {
|
||||
Skill skill = create(id, skillContent);
|
||||
public static Skill createWithResources(String skillContent, List<SkillResource> resources) {
|
||||
Skill skill = create(skillContent);
|
||||
skill.setResources(resources);
|
||||
SkillResources.refreshLegacyViews(skill);
|
||||
return skill;
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于 SKILL.md 和通用资源创建并严格校验正式标准 Skill。
|
||||
*
|
||||
* @param id 仓储 ID,可为空
|
||||
* @param skillContent SKILL.md 原始内容
|
||||
* @param resources 通用资源列表
|
||||
* @return 通过正式标准校验的 Skill 聚合
|
||||
*/
|
||||
public static Skill createWithResourcesStrict(String id, String skillContent,
|
||||
List<SkillResource> resources) {
|
||||
Skill skill = createWithResources(id, skillContent, resources);
|
||||
new DefaultSkillValidator().validate(skill);
|
||||
return skill;
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于 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) {
|
||||
SkillDocument document = SkillFrontmatter.parseDocument(skillContent);
|
||||
Map<String, Object> values = document.getFrontmatter().getValues();
|
||||
String name = requiredScalar(values, "name");
|
||||
String description = requiredScalar(values, "description");
|
||||
Skill skill = new Skill();
|
||||
skill.setId(id);
|
||||
skill.setName(name);
|
||||
skill.setDescription(description);
|
||||
skill.setMetadata(new SkillMetadata(values));
|
||||
skill.setDocument(document);
|
||||
skill.setReferences(references);
|
||||
skill.setScripts(scripts);
|
||||
skill.setAssets(assets);
|
||||
skill.setResources(SkillResources.canonicalResources(skill));
|
||||
return skill;
|
||||
}
|
||||
|
||||
private static String requiredScalar(Map<String, Object> values, String key) {
|
||||
private static void requiredScalar(Map<String, Object> values, String key) {
|
||||
Object value = values.get(key);
|
||||
if (!(value instanceof String text) || text.isBlank()) {
|
||||
throw new com.easyagents.skill.exception.SkillValidationException(
|
||||
"SKILL.md frontmatter " + key + " must be a non-blank string.");
|
||||
}
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package com.easyagents.skill.model;
|
||||
|
||||
import com.easyagents.skill.util.SkillResources;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@@ -13,36 +11,10 @@ public class Skill implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private String id;
|
||||
private String packageRoot;
|
||||
private String name;
|
||||
private String description;
|
||||
private SkillMetadata metadata = new SkillMetadata();
|
||||
private String skillContent;
|
||||
private SkillDocument document;
|
||||
private List<SkillResource> resources = new ArrayList<>();
|
||||
private boolean resourcesInitialized;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取导入包中的顶层目录名;该值不是仓储 ID。
|
||||
@@ -68,16 +40,7 @@ public class Skill implements Serializable {
|
||||
* @return 名称
|
||||
*/
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置名称。
|
||||
*
|
||||
* @param name 名称
|
||||
*/
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
return frontmatterString("name");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,34 +49,7 @@ public class Skill implements Serializable {
|
||||
* @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;
|
||||
return frontmatterString("description");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,12 +88,6 @@ public class Skill implements Serializable {
|
||||
public void setDocument(SkillDocument document) {
|
||||
this.document = document;
|
||||
this.skillContent = document == null ? null : document.render();
|
||||
if (document != null) {
|
||||
java.util.Map<String, Object> values = document.getFrontmatter().getValues();
|
||||
this.metadata = new SkillMetadata(values);
|
||||
this.name = values.get("name") instanceof String text ? text : null;
|
||||
this.description = values.get("description") instanceof String text ? text : null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,12 +96,6 @@ public class Skill implements Serializable {
|
||||
* @return 通用资源列表
|
||||
*/
|
||||
public List<SkillResource> getResources() {
|
||||
if (!resourcesInitialized) {
|
||||
resources = resources == null || resources.isEmpty()
|
||||
? new ArrayList<>(SkillResources.fromLegacyViews(this))
|
||||
: new ArrayList<>(resources);
|
||||
resourcesInitialized = true;
|
||||
}
|
||||
return resources;
|
||||
}
|
||||
|
||||
@@ -182,81 +106,13 @@ public class Skill implements Serializable {
|
||||
*/
|
||||
public void setResources(List<SkillResource> resources) {
|
||||
this.resources = resources == null ? new ArrayList<>() : new ArrayList<>(resources);
|
||||
this.resourcesInitialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断正式通用资源列表是否已被显式初始化。
|
||||
*
|
||||
* <p>该标记用于区分“尚未迁移的旧资源视图”和“调用方明确设置的空资源列表”,
|
||||
* 避免删除最后一个正式资源后又从旧兼容视图恢复该资源。</p>
|
||||
*
|
||||
* @return 已显式设置通用资源列表时为 true
|
||||
*/
|
||||
public boolean isResourcesInitialized() {
|
||||
return resourcesInitialized;
|
||||
private String frontmatterString(String key) {
|
||||
if (document == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取参考文档列表。
|
||||
*
|
||||
* @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);
|
||||
Object value = document.getFrontmatter().get(key);
|
||||
return value instanceof String text ? text : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
package com.easyagents.skill.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* Skill 静态资产兼容视图。
|
||||
*
|
||||
* @deprecated 请使用 {@link SkillResource}。
|
||||
*/
|
||||
@Deprecated
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
package com.easyagents.skill.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* Skill Markdown 参考文档兼容视图。
|
||||
*
|
||||
* @deprecated 请使用 {@link SkillResource}。
|
||||
*/
|
||||
@Deprecated
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ public class SkillResource implements Serializable {
|
||||
private String contentRef;
|
||||
private String contentHash;
|
||||
private long size;
|
||||
private SkillMetadata metadata = new SkillMetadata();
|
||||
|
||||
/**
|
||||
* 获取 Skill 根目录相对路径。
|
||||
@@ -146,24 +145,6 @@ public class SkillResource implements Serializable {
|
||||
this.size = size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资源扩展元数据。
|
||||
*
|
||||
* @return 扩展元数据
|
||||
*/
|
||||
public SkillMetadata getMetadata() {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置资源扩展元数据。
|
||||
*
|
||||
* @param metadata 扩展元数据
|
||||
*/
|
||||
public void setMetadata(SkillMetadata metadata) {
|
||||
this.metadata = metadata == null ? new SkillMetadata() : metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断资源是否以内联文本保存。
|
||||
*
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
package com.easyagents.skill.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* Skill 脚本源码兼容视图。
|
||||
*
|
||||
* @deprecated 请使用 {@link SkillResource}。
|
||||
*/
|
||||
@Deprecated
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
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.setPackageRoot(source.getPackageRoot());
|
||||
target.setName(source.getName());
|
||||
target.setDescription(source.getDescription());
|
||||
target.setMetadata(copyMetadata(source.getMetadata()));
|
||||
SkillDocument copiedDocument = copyDocument(source.getDocument());
|
||||
if (copiedDocument == null) {
|
||||
target.setSkillContent(source.getSkillContent());
|
||||
} else {
|
||||
target.setDocument(copiedDocument);
|
||||
}
|
||||
target.setResources(copyResources(source.getResources()));
|
||||
target.setReferences(copyReferences(source.getReferences()));
|
||||
target.setScripts(copyScripts(source.getScripts()));
|
||||
target.setAssets(copyAssets(source.getAssets()));
|
||||
return target;
|
||||
}
|
||||
|
||||
private static SkillDocument copyDocument(SkillDocument source) {
|
||||
if (source == null) {
|
||||
return null;
|
||||
}
|
||||
SkillDocument target = new SkillDocument(source.getRawContent(),
|
||||
source.getFrontmatter().getValues(), source.getMarkdownBody(),
|
||||
source.getFrontmatterLocations(), source.getDiagnostics());
|
||||
target.setModified(source.isModified());
|
||||
return target;
|
||||
}
|
||||
|
||||
private static List<SkillResource> copyResources(List<SkillResource> sources) {
|
||||
List<SkillResource> targets = new ArrayList<>();
|
||||
if (sources == null) {
|
||||
return targets;
|
||||
}
|
||||
for (SkillResource source : sources) {
|
||||
SkillResource target = new SkillResource();
|
||||
target.setPath(source.getPath());
|
||||
target.setKind(source.getKind());
|
||||
target.setMediaType(source.getMediaType());
|
||||
target.setTextContent(source.getTextContent());
|
||||
target.setContentRef(source.getContentRef());
|
||||
target.setContentHash(source.getContentHash());
|
||||
target.setSize(source.getSize());
|
||||
target.setMetadata(copyMetadata(source.getMetadata()));
|
||||
targets.add(target);
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.easyagents.skill.store;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
@@ -12,8 +11,6 @@ public final class SkillContentStage {
|
||||
private final String contentRef;
|
||||
private final String contentHash;
|
||||
private final long size;
|
||||
private final boolean alreadyCommitted;
|
||||
private final Path compatibilityPath;
|
||||
|
||||
/**
|
||||
* 创建内容阶段结果。
|
||||
@@ -22,21 +19,12 @@ public final class SkillContentStage {
|
||||
* @param contentRef 最终内容引用
|
||||
* @param contentHash SHA-256
|
||||
* @param size 字节大小
|
||||
* @param alreadyCommitted 是否由兼容实现提前写入正式存储
|
||||
*/
|
||||
public SkillContentStage(String stageId, String contentRef, String contentHash,
|
||||
long size, boolean alreadyCommitted) {
|
||||
this(stageId, contentRef, contentHash, size, alreadyCommitted, null);
|
||||
}
|
||||
|
||||
private SkillContentStage(String stageId, String contentRef, String contentHash,
|
||||
long size, boolean alreadyCommitted, Path compatibilityPath) {
|
||||
public SkillContentStage(String stageId, String contentRef, String contentHash, long size) {
|
||||
this.stageId = Objects.requireNonNull(stageId, "stageId");
|
||||
this.contentRef = Objects.requireNonNull(contentRef, "contentRef");
|
||||
this.contentHash = Objects.requireNonNull(contentHash, "contentHash");
|
||||
this.size = size;
|
||||
this.alreadyCommitted = alreadyCommitted;
|
||||
this.compatibilityPath = compatibilityPath;
|
||||
}
|
||||
|
||||
/** @return 暂存标识 */
|
||||
@@ -59,18 +47,4 @@ public final class SkillContentStage {
|
||||
return size;
|
||||
}
|
||||
|
||||
/** @return 已提前提交时为 true */
|
||||
public boolean isAlreadyCommitted() {
|
||||
return alreadyCommitted;
|
||||
}
|
||||
|
||||
static SkillContentStage compatibility(Path path, String contentHash, long size) {
|
||||
String contentRef = "sha256:" + contentHash;
|
||||
return new SkillContentStage(contentRef, contentRef, contentHash,
|
||||
size, false, path);
|
||||
}
|
||||
|
||||
Path compatibilityPath() {
|
||||
return compatibilityPath;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
package com.easyagents.skill.store;
|
||||
|
||||
import com.easyagents.skill.exception.SkillException;
|
||||
import com.easyagents.skill.util.SkillHashes;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.security.DigestOutputStream;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
/**
|
||||
* Skill 二进制内容流式存储与引用生命周期接口。
|
||||
@@ -25,62 +15,14 @@ public interface SkillContentStore {
|
||||
*/
|
||||
String put(byte[] bytes);
|
||||
|
||||
/**
|
||||
* 流式保存内容并返回内容引用。
|
||||
*
|
||||
* <p>兼容默认实现仅缓存单个文件;正式持久化实现应覆盖该方法以直接流式写入。</p>
|
||||
*
|
||||
* @param inputStream 内容流,不由本方法关闭
|
||||
* @param maxBytes 最大允许字节数
|
||||
* @return 内容引用
|
||||
*/
|
||||
default String put(InputStream inputStream, long maxBytes) {
|
||||
return put(readBounded(inputStream, maxBytes));
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂存内容,供完成全包校验后统一提交。
|
||||
*
|
||||
* <p>为兼容旧实现,默认实现会立即写入;正式实现应覆盖并提供真实暂存区。</p>
|
||||
*
|
||||
* @param inputStream 内容流,不由本方法关闭
|
||||
* @param maxBytes 最大允许字节数
|
||||
* @return 暂存结果
|
||||
*/
|
||||
default SkillContentStage stage(InputStream inputStream, long maxBytes) {
|
||||
if (inputStream == null || maxBytes < 0) {
|
||||
throw new SkillException("Valid Skill content stream and limit are required.");
|
||||
}
|
||||
Path temporaryFile = null;
|
||||
try {
|
||||
temporaryFile = Files.createTempFile("easy-agents-skill-content-", ".stage");
|
||||
MessageDigest digest = SkillHashes.newSha256Digest();
|
||||
long total = 0;
|
||||
try (DigestOutputStream output = new DigestOutputStream(
|
||||
Files.newOutputStream(temporaryFile, StandardOpenOption.TRUNCATE_EXISTING), digest)) {
|
||||
byte[] buffer = new byte[16 * 1024];
|
||||
int read;
|
||||
while ((read = inputStream.read(buffer)) >= 0) {
|
||||
if (read == 0) {
|
||||
continue;
|
||||
}
|
||||
if (total > maxBytes - read) {
|
||||
throw new SkillException("Skill content exceeds " + maxBytes + " bytes.");
|
||||
}
|
||||
output.write(buffer, 0, read);
|
||||
total += read;
|
||||
}
|
||||
}
|
||||
return SkillContentStage.compatibility(
|
||||
temporaryFile, SkillHashes.toHex(digest.digest()), total);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
deleteTemporaryFile(temporaryFile);
|
||||
if (e instanceof SkillException skillException) {
|
||||
throw skillException;
|
||||
}
|
||||
throw new SkillException("Failed to stage Skill content stream.", e);
|
||||
}
|
||||
}
|
||||
SkillContentStage stage(InputStream inputStream, long maxBytes);
|
||||
|
||||
/**
|
||||
* 提交暂存内容。
|
||||
@@ -88,59 +30,28 @@ public interface SkillContentStore {
|
||||
* @param stage 暂存结果
|
||||
* @return 最终内容引用
|
||||
*/
|
||||
default String commit(SkillContentStage stage) {
|
||||
if (stage == null) {
|
||||
throw new SkillException("Skill content stage is required.");
|
||||
}
|
||||
Path compatibilityPath = stage.compatibilityPath();
|
||||
if (compatibilityPath != null) {
|
||||
try (InputStream input = Files.newInputStream(compatibilityPath)) {
|
||||
String contentRef = put(input, stage.getSize());
|
||||
if (!stage.getContentRef().equals(contentRef)) {
|
||||
release(contentRef);
|
||||
throw new SkillException("Skill content store returned a non-hash content reference.");
|
||||
}
|
||||
return contentRef;
|
||||
} catch (IOException e) {
|
||||
throw new SkillException("Failed to commit staged Skill content.", e);
|
||||
} finally {
|
||||
deleteTemporaryFile(compatibilityPath);
|
||||
}
|
||||
}
|
||||
return stage.getContentRef();
|
||||
}
|
||||
String commit(SkillContentStage stage);
|
||||
|
||||
/**
|
||||
* 回滚尚未提交的内容。
|
||||
*
|
||||
* @param stage 暂存结果
|
||||
*/
|
||||
default void rollback(SkillContentStage stage) {
|
||||
if (stage != null) {
|
||||
deleteTemporaryFile(stage.compatibilityPath());
|
||||
if (stage.isAlreadyCommitted()) {
|
||||
release(stage.getContentRef());
|
||||
}
|
||||
}
|
||||
}
|
||||
void rollback(SkillContentStage stage);
|
||||
|
||||
/**
|
||||
* 增加正式内容引用计数。
|
||||
*
|
||||
* @param contentRef 内容引用
|
||||
*/
|
||||
default void retain(String contentRef) {
|
||||
// 旧实现没有引用计数,保留兼容空操作。
|
||||
}
|
||||
void retain(String contentRef);
|
||||
|
||||
/**
|
||||
* 释放正式内容引用;引用归零后实现可以删除物理内容。
|
||||
*
|
||||
* @param contentRef 内容引用
|
||||
*/
|
||||
default void release(String contentRef) {
|
||||
// 旧实现没有引用计数,保留兼容空操作。
|
||||
}
|
||||
void release(String contentRef);
|
||||
|
||||
/**
|
||||
* 打开内容流。
|
||||
@@ -150,14 +61,6 @@ public interface SkillContentStore {
|
||||
*/
|
||||
InputStream open(String contentRef);
|
||||
|
||||
/**
|
||||
* 读取全部内容。
|
||||
*
|
||||
* @param contentRef 内容引用
|
||||
* @return 内容字节
|
||||
*/
|
||||
byte[] readAllBytes(String contentRef);
|
||||
|
||||
/**
|
||||
* 判断内容是否存在。
|
||||
*
|
||||
@@ -165,43 +68,4 @@ public interface SkillContentStore {
|
||||
* @return 存在时为 true
|
||||
*/
|
||||
boolean exists(String contentRef);
|
||||
|
||||
private static byte[] readBounded(InputStream inputStream, long maxBytes) {
|
||||
if (inputStream == null) {
|
||||
throw new SkillException("Skill content input stream is required.");
|
||||
}
|
||||
if (maxBytes < 0) {
|
||||
throw new SkillException("Skill content max bytes cannot be negative.");
|
||||
}
|
||||
try {
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream((int) Math.min(maxBytes, 8_192));
|
||||
byte[] buffer = new byte[8_192];
|
||||
long total = 0;
|
||||
int read;
|
||||
while ((read = inputStream.read(buffer)) >= 0) {
|
||||
if (read == 0) {
|
||||
continue;
|
||||
}
|
||||
total += read;
|
||||
if (total > maxBytes) {
|
||||
throw new SkillException("Skill content exceeds " + maxBytes + " bytes.");
|
||||
}
|
||||
output.write(buffer, 0, read);
|
||||
}
|
||||
return output.toByteArray();
|
||||
} catch (IOException e) {
|
||||
throw new SkillException("Failed to read Skill content stream.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void deleteTemporaryFile(Path path) {
|
||||
if (path == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException ignored) {
|
||||
// 临时文件清理由操作系统兜底,调用方异常语义保持不变。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,19 +86,7 @@ public final class TemporaryFileSkillContentStore implements SkillContentStore,
|
||||
@Override
|
||||
public String put(byte[] bytes) {
|
||||
byte[] safeBytes = bytes == null ? new byte[0] : bytes;
|
||||
return put(new ByteArrayInputStream(safeBytes), safeBytes.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式保存内容并持有一个正式引用。
|
||||
*
|
||||
* @param inputStream 内容流,不由本方法关闭
|
||||
* @param maxBytes 最大允许字节数
|
||||
* @return SHA-256 内容引用
|
||||
*/
|
||||
@Override
|
||||
public String put(InputStream inputStream, long maxBytes) {
|
||||
SkillContentStage stage = stage(inputStream, maxBytes);
|
||||
SkillContentStage stage = stage(new ByteArrayInputStream(safeBytes), safeBytes.length);
|
||||
try {
|
||||
return commit(stage);
|
||||
} catch (RuntimeException exception) {
|
||||
@@ -153,7 +141,7 @@ public final class TemporaryFileSkillContentStore implements SkillContentStore,
|
||||
ensureOpenLocked();
|
||||
stagedContents.put(stageId, stagedContent);
|
||||
}
|
||||
return new SkillContentStage(stageId, contentRef, contentHash, size, false);
|
||||
return new SkillContentStage(stageId, contentRef, contentHash, size);
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
deleteFileQuietly(stagedPath);
|
||||
if (exception instanceof SkillException skillException) {
|
||||
@@ -309,24 +297,6 @@ public final class TemporaryFileSkillContentStore implements SkillContentStore,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取正式内容的全部字节。
|
||||
*
|
||||
* <p>该兼容方法会按接口约定返回一个字节数组;流式调用方应优先使用 {@link #open(String)}。</p>
|
||||
*
|
||||
* @param contentRef 内容引用
|
||||
* @return 内容字节
|
||||
*/
|
||||
@Override
|
||||
public byte[] readAllBytes(String contentRef) {
|
||||
try (InputStream input = open(contentRef)) {
|
||||
return input.readAllBytes();
|
||||
} catch (IOException exception) {
|
||||
throw new SkillException("Failed to read temporary Skill content: " + contentRef,
|
||||
exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断正式内容是否仍有有效引用。
|
||||
*
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
package com.easyagents.skill.store.memory;
|
||||
|
||||
import com.easyagents.skill.exception.SkillException;
|
||||
import com.easyagents.skill.store.SkillContentStage;
|
||||
import com.easyagents.skill.store.SkillContentStore;
|
||||
import com.easyagents.skill.util.SkillHashes;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Arrays;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* 基于内存的 Skill 内容存储,支持真实暂存与引用计数,适用于测试和轻量场景。
|
||||
*/
|
||||
public class InMemorySkillContentStore implements SkillContentStore {
|
||||
|
||||
private final ConcurrentMap<String, StoredContent> contents = new ConcurrentHashMap<>();
|
||||
private final ConcurrentMap<String, byte[]> stagedContents = 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.compute(contentRef, (key, current) -> {
|
||||
if (current == null) {
|
||||
return new StoredContent(safeBytes);
|
||||
}
|
||||
if (!Arrays.equals(current.bytes, safeBytes)) {
|
||||
throw new SkillException("SHA-256 collision detected for Skill content: " + contentRef);
|
||||
}
|
||||
current.references.incrementAndGet();
|
||||
return current;
|
||||
});
|
||||
return contentRef;
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式保存内容并持有一个引用。
|
||||
*
|
||||
* @param inputStream 内容流
|
||||
* @param maxBytes 最大允许字节数
|
||||
* @return 内容引用
|
||||
*/
|
||||
@Override
|
||||
public String put(InputStream inputStream, long maxBytes) {
|
||||
return put(readBounded(inputStream, maxBytes));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将内容写入独立暂存区。
|
||||
*
|
||||
* @param inputStream 内容流
|
||||
* @param maxBytes 最大允许字节数
|
||||
* @return 暂存结果
|
||||
*/
|
||||
@Override
|
||||
public SkillContentStage stage(InputStream inputStream, long maxBytes) {
|
||||
byte[] bytes = readBounded(inputStream, maxBytes);
|
||||
String hash = SkillHashes.sha256Hex(bytes);
|
||||
String stageId = UUID.randomUUID().toString();
|
||||
stagedContents.put(stageId, bytes);
|
||||
return new SkillContentStage(stageId, "sha256:" + hash, hash, bytes.length, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 原子地将暂存内容转为正式引用。
|
||||
*
|
||||
* @param stage 暂存结果
|
||||
* @return 正式内容引用
|
||||
*/
|
||||
@Override
|
||||
public String commit(SkillContentStage stage) {
|
||||
if (stage == null) {
|
||||
throw new SkillException("Skill content stage is required.");
|
||||
}
|
||||
byte[] bytes = stagedContents.remove(stage.getStageId());
|
||||
if (bytes == null) {
|
||||
throw new SkillException("Skill content stage does not exist: " + stage.getStageId());
|
||||
}
|
||||
String contentRef = put(bytes);
|
||||
if (!contentRef.equals(stage.getContentRef())) {
|
||||
release(contentRef);
|
||||
throw new SkillException("Skill staged content hash changed before commit.");
|
||||
}
|
||||
return contentRef;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除暂存内容。
|
||||
*
|
||||
* @param stage 暂存结果
|
||||
*/
|
||||
@Override
|
||||
public void rollback(SkillContentStage stage) {
|
||||
if (stage != null) {
|
||||
stagedContents.remove(stage.getStageId());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加正式内容引用计数。
|
||||
*
|
||||
* @param contentRef 内容引用
|
||||
*/
|
||||
@Override
|
||||
public void retain(String contentRef) {
|
||||
contents.compute(contentRef, (key, content) -> {
|
||||
if (content == null) {
|
||||
throw new SkillException("Skill content does not exist: " + contentRef);
|
||||
}
|
||||
content.references.incrementAndGet();
|
||||
return content;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放正式内容引用并在归零时删除内容。
|
||||
*
|
||||
* @param contentRef 内容引用
|
||||
*/
|
||||
@Override
|
||||
public void release(String contentRef) {
|
||||
contents.computeIfPresent(contentRef, (key, content) ->
|
||||
content.references.decrementAndGet() <= 0 ? null : content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开内容流。
|
||||
*
|
||||
* @param contentRef 内容引用
|
||||
* @return 内容流
|
||||
*/
|
||||
@Override
|
||||
public InputStream open(String contentRef) {
|
||||
return new ByteArrayInputStream(readAllBytes(contentRef));
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取全部内容。
|
||||
*
|
||||
* @param contentRef 内容引用
|
||||
* @return 内容副本
|
||||
*/
|
||||
@Override
|
||||
public byte[] readAllBytes(String contentRef) {
|
||||
StoredContent content = contents.get(contentRef);
|
||||
if (content == null) {
|
||||
throw new SkillException("Skill content does not exist: " + contentRef);
|
||||
}
|
||||
return Arrays.copyOf(content.bytes, content.bytes.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断内容是否存在。
|
||||
*
|
||||
* @param contentRef 内容引用
|
||||
* @return 存在时为 true
|
||||
*/
|
||||
@Override
|
||||
public boolean exists(String contentRef) {
|
||||
return contents.containsKey(contentRef);
|
||||
}
|
||||
|
||||
private static byte[] readBounded(InputStream inputStream, long maxBytes) {
|
||||
if (inputStream == null || maxBytes < 0) {
|
||||
throw new SkillException("Valid Skill content stream and limit are required.");
|
||||
}
|
||||
try {
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream((int) Math.min(maxBytes, 8_192));
|
||||
byte[] buffer = new byte[8_192];
|
||||
long total = 0;
|
||||
int read;
|
||||
while ((read = inputStream.read(buffer)) >= 0) {
|
||||
if (read == 0) {
|
||||
continue;
|
||||
}
|
||||
total += read;
|
||||
if (total > maxBytes) {
|
||||
throw new SkillException("Skill content exceeds " + maxBytes + " bytes.");
|
||||
}
|
||||
output.write(buffer, 0, read);
|
||||
}
|
||||
return output.toByteArray();
|
||||
} catch (IOException e) {
|
||||
throw new SkillException("Failed to read Skill content stream.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class StoredContent {
|
||||
|
||||
private final byte[] bytes;
|
||||
private final AtomicInteger references = new AtomicInteger(1);
|
||||
|
||||
private StoredContent(byte[] bytes) {
|
||||
this.bytes = Arrays.copyOf(bytes, bytes.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,25 @@
|
||||
package com.easyagents.skill.util;
|
||||
|
||||
import com.easyagents.skill.model.Skill;
|
||||
import com.easyagents.skill.model.SkillAsset;
|
||||
import com.easyagents.skill.model.SkillMetadata;
|
||||
import com.easyagents.skill.model.SkillReference;
|
||||
import com.easyagents.skill.model.SkillResource;
|
||||
import com.easyagents.skill.model.SkillResourceKind;
|
||||
import com.easyagents.skill.model.SkillScript;
|
||||
import com.easyagents.skill.model.SkillScriptLanguage;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 通用 Skill 资源与旧资源视图之间的兼容适配工具。
|
||||
* 通用 Skill 资源识别工具。
|
||||
*/
|
||||
public final class SkillResources {
|
||||
|
||||
private static final Set<String> TEXT_EXTENSIONS = Set.of(
|
||||
".md", ".markdown", ".txt", ".json", ".yaml", ".yml", ".xml", ".csv", ".tsv",
|
||||
".htm", ".html", ".css", ".properties", ".toml", ".ini", ".sql", ".java", ".kt", ".kts",
|
||||
".htm", ".html", ".svg", ".css", ".scss", ".less", ".properties", ".toml", ".ini",
|
||||
".conf", ".config", ".env", ".sql", ".graphql", ".gql", ".proto", ".java", ".kt", ".kts",
|
||||
".py", ".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".sh", ".bash", ".zsh",
|
||||
".rb", ".go", ".rs", ".c", ".h", ".cpp", ".hpp", ".gradle", ".groovy"
|
||||
".rb", ".go", ".rs", ".c", ".h", ".cpp", ".hpp", ".gradle", ".groovy", ".vue", ".svelte"
|
||||
);
|
||||
|
||||
private static final Set<String> TEXT_FILE_NAMES = Set.of(
|
||||
"dockerfile", "makefile", "gradlew", ".gitignore", ".gitattributes", ".editorconfig"
|
||||
);
|
||||
|
||||
private SkillResources() {
|
||||
@@ -50,138 +46,22 @@ public final class SkillResources {
|
||||
* 判断资源是否应按严格 UTF-8 文本处理。
|
||||
*
|
||||
* @param path 资源路径
|
||||
* @param kind 资源语义类型
|
||||
* @param mediaType 媒体类型
|
||||
* @return 文本资源时为 true
|
||||
*/
|
||||
public static boolean isText(String path, SkillResourceKind kind, String mediaType) {
|
||||
if (kind == SkillResourceKind.SCRIPT) {
|
||||
return true;
|
||||
}
|
||||
if (kind == SkillResourceKind.ASSET) {
|
||||
return false;
|
||||
}
|
||||
if (mediaType != null && (mediaType.startsWith("text/")
|
||||
|| mediaType.contains("json") || mediaType.contains("yaml")
|
||||
|| mediaType.contains("xml") || mediaType.contains("javascript"))) {
|
||||
public static boolean isText(String path, String mediaType) {
|
||||
String normalizedMediaType = mediaType == null ? "" : mediaType.toLowerCase(Locale.ROOT);
|
||||
if (normalizedMediaType.startsWith("text/")
|
||||
|| normalizedMediaType.contains("json") || normalizedMediaType.contains("yaml")
|
||||
|| normalizedMediaType.contains("xml") || normalizedMediaType.contains("javascript")
|
||||
|| normalizedMediaType.contains("typescript") || normalizedMediaType.contains("toml")
|
||||
|| normalizedMediaType.contains("sql")) {
|
||||
return true;
|
||||
}
|
||||
String lowerPath = path.toLowerCase(Locale.ROOT);
|
||||
return TEXT_EXTENSIONS.stream().anyMatch(lowerPath::endsWith);
|
||||
String fileName = SkillPaths.fileName(lowerPath);
|
||||
return TEXT_FILE_NAMES.contains(fileName)
|
||||
|| TEXT_EXTENSIONS.stream().anyMatch(lowerPath::endsWith);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Skill 的正式通用资源;旧模型会被按需转换。
|
||||
*
|
||||
* @param skill Skill 聚合
|
||||
* @return 通用资源副本
|
||||
*/
|
||||
public static List<SkillResource> canonicalResources(Skill skill) {
|
||||
if (skill == null) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
return new ArrayList<>(skill.getResources());
|
||||
}
|
||||
|
||||
/**
|
||||
* 将尚未迁移的旧 references、scripts、assets 视图转换为正式通用资源。
|
||||
*
|
||||
* <p>该方法只读取旧视图,不读取或修改正式资源列表,由 {@link Skill#getResources()}
|
||||
* 在第一次正式访问时完成一次性迁移。</p>
|
||||
*
|
||||
* @param skill Skill 聚合
|
||||
* @return 从旧视图转换得到的通用资源副本
|
||||
*/
|
||||
public static List<SkillResource> fromLegacyViews(Skill skill) {
|
||||
List<SkillResource> resources = new ArrayList<>();
|
||||
if (skill == null) {
|
||||
return resources;
|
||||
}
|
||||
if (skill.getReferences() != null) {
|
||||
for (SkillReference reference : skill.getReferences()) {
|
||||
SkillResource resource = base(reference.getPath(), SkillResourceKind.REFERENCE,
|
||||
"text/markdown", reference.getContentHash(), reference.getSize(), reference.getMetadata());
|
||||
resource.setTextContent(reference.getContent());
|
||||
resources.add(resource);
|
||||
}
|
||||
}
|
||||
if (skill.getScripts() != null) {
|
||||
for (SkillScript script : skill.getScripts()) {
|
||||
SkillResource resource = base(script.getPath(), SkillResourceKind.SCRIPT,
|
||||
"text/plain", script.getContentHash(), script.getSize(), script.getMetadata());
|
||||
resource.setTextContent(script.getContent());
|
||||
resources.add(resource);
|
||||
}
|
||||
}
|
||||
if (skill.getAssets() != null) {
|
||||
for (SkillAsset asset : skill.getAssets()) {
|
||||
SkillResource resource = base(asset.getPath(), SkillResourceKind.ASSET,
|
||||
asset.getMediaType(), asset.getContentHash(), asset.getSize(), asset.getMetadata());
|
||||
resource.setContentRef(asset.getContentRef());
|
||||
resources.add(resource);
|
||||
}
|
||||
}
|
||||
return resources;
|
||||
}
|
||||
|
||||
/**
|
||||
* 依据通用资源刷新旧 references、scripts、assets 兼容视图。
|
||||
*
|
||||
* @param skill Skill 聚合
|
||||
*/
|
||||
public static void refreshLegacyViews(Skill skill) {
|
||||
List<SkillReference> references = new ArrayList<>();
|
||||
List<SkillScript> scripts = new ArrayList<>();
|
||||
List<SkillAsset> assets = new ArrayList<>();
|
||||
for (SkillResource resource : skill.getResources()) {
|
||||
if (resource.getKind() == SkillResourceKind.REFERENCE && resource.isText()) {
|
||||
SkillReference reference = new SkillReference();
|
||||
reference.setPath(resource.getPath());
|
||||
reference.setName(SkillPaths.fileName(resource.getPath()));
|
||||
reference.setContent(resource.getTextContent());
|
||||
reference.setContentHash(resource.getContentHash());
|
||||
reference.setSize(resource.getSize());
|
||||
reference.setMetadata(copy(resource.getMetadata()));
|
||||
references.add(reference);
|
||||
} else if (resource.getKind() == SkillResourceKind.SCRIPT && resource.isText()) {
|
||||
SkillScript script = new SkillScript();
|
||||
script.setPath(resource.getPath());
|
||||
script.setLanguage(SkillScriptLanguage.fromPath(resource.getPath()));
|
||||
script.setContent(resource.getTextContent());
|
||||
script.setContentHash(resource.getContentHash());
|
||||
script.setSize(resource.getSize());
|
||||
script.setMetadata(copy(resource.getMetadata()));
|
||||
scripts.add(script);
|
||||
} else if (resource.getKind() == SkillResourceKind.ASSET) {
|
||||
SkillAsset asset = new SkillAsset();
|
||||
asset.setPath(resource.getPath());
|
||||
asset.setName(SkillPaths.fileName(resource.getPath()));
|
||||
asset.setMediaType(resource.getMediaType());
|
||||
asset.setContentRef(resource.getContentRef());
|
||||
asset.setContentHash(resource.getContentHash());
|
||||
asset.setSize(resource.getSize());
|
||||
asset.setMetadata(copy(resource.getMetadata()));
|
||||
assets.add(asset);
|
||||
}
|
||||
}
|
||||
skill.setReferences(references);
|
||||
skill.setScripts(scripts);
|
||||
skill.setAssets(assets);
|
||||
}
|
||||
|
||||
private static SkillResource base(String path, SkillResourceKind kind, String mediaType,
|
||||
String hash, long size, SkillMetadata metadata) {
|
||||
SkillResource resource = new SkillResource();
|
||||
resource.setPath(path);
|
||||
resource.setKind(kind);
|
||||
resource.setMediaType(mediaType);
|
||||
resource.setContentHash(hash);
|
||||
resource.setSize(size);
|
||||
resource.setMetadata(copy(metadata));
|
||||
return resource;
|
||||
}
|
||||
|
||||
private static SkillMetadata copy(SkillMetadata metadata) {
|
||||
return metadata == null ? new SkillMetadata() : new SkillMetadata(metadata.getValues());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
package com.easyagents.skill.validation;
|
||||
|
||||
import com.easyagents.skill.exception.SkillValidationException;
|
||||
import com.easyagents.skill.model.Skill;
|
||||
import com.easyagents.skill.model.SkillPackageLimits;
|
||||
|
||||
/**
|
||||
* Skill 校验接口。
|
||||
*/
|
||||
public interface SkillValidator {
|
||||
|
||||
/**
|
||||
* 校验 Skill 聚合。
|
||||
*
|
||||
* @param skill Skill 聚合
|
||||
*/
|
||||
void validate(Skill skill);
|
||||
|
||||
/**
|
||||
* 聚合校验 Skill 并返回结构化报告。
|
||||
*
|
||||
* <p>兼容默认实现会把旧命令式异常转换为单个错误;新实现应覆盖以返回全部问题。</p>
|
||||
*
|
||||
* @param skill Skill 聚合
|
||||
* @return 结构化校验报告
|
||||
*/
|
||||
default SkillValidationReport validateReport(Skill skill) {
|
||||
SkillValidationReport report = new SkillValidationReport();
|
||||
try {
|
||||
validate(skill);
|
||||
} catch (SkillValidationException e) {
|
||||
report.add(new SkillValidationIssue(e.getCode(), SkillValidationSeverity.ERROR, e.getPath(),
|
||||
e.getLine(), e.getColumn(), e.getMessage(), null));
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用当前 Codec 操作的安全限额聚合校验 Skill。
|
||||
*
|
||||
* <p>兼容实现默认委托给原有校验入口;需要检查包限额的实现应覆盖本方法。</p>
|
||||
*
|
||||
* @param skill Skill 聚合
|
||||
* @param limits 当前读写操作的安全限额
|
||||
* @return 结构化校验报告
|
||||
*/
|
||||
default SkillValidationReport validateReport(Skill skill, SkillPackageLimits limits) {
|
||||
return validateReport(skill);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用指定标准模式和当前 Codec 安全限额聚合校验 Skill。
|
||||
*
|
||||
* <p>兼容实现默认忽略模式;需要区分草稿导入与正式标准约束的实现应覆盖本方法。</p>
|
||||
*
|
||||
* @param skill Skill 聚合
|
||||
* @param limits 当前读写操作的安全限额
|
||||
* @param mode 标准校验模式
|
||||
* @return 结构化校验报告
|
||||
*/
|
||||
default SkillValidationReport validateReport(Skill skill, SkillPackageLimits limits,
|
||||
SkillValidationMode mode) {
|
||||
return validateReport(skill, limits);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import com.easyagents.skill.model.SkillDocument;
|
||||
import com.easyagents.skill.model.SkillPackageLimits;
|
||||
import com.easyagents.skill.model.SkillResource;
|
||||
import com.easyagents.skill.model.SkillResourceKind;
|
||||
import com.easyagents.skill.model.SkillScriptLanguage;
|
||||
import com.easyagents.skill.model.SkillSourceLocation;
|
||||
import com.easyagents.skill.util.SkillFrontmatter;
|
||||
import com.easyagents.skill.util.SkillHashes;
|
||||
@@ -17,7 +16,6 @@ import com.easyagents.skill.validation.SkillValidationIssue;
|
||||
import com.easyagents.skill.validation.SkillValidationMode;
|
||||
import com.easyagents.skill.validation.SkillValidationReport;
|
||||
import com.easyagents.skill.validation.SkillValidationSeverity;
|
||||
import com.easyagents.skill.validation.SkillValidator;
|
||||
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.util.HashMap;
|
||||
@@ -31,7 +29,7 @@ import java.util.regex.Pattern;
|
||||
/**
|
||||
* 默认 Skill 聚合结构化校验器。
|
||||
*/
|
||||
public class DefaultSkillValidator implements SkillValidator {
|
||||
public class DefaultSkillValidator {
|
||||
|
||||
private static final Pattern CANONICAL_NAME = Pattern.compile("[a-z0-9]+(?:-[a-z0-9]+)*");
|
||||
private static final Pattern LEGACY_UNDERSCORE_NAME = Pattern.compile("[a-z0-9]+(?:_[a-z0-9]+)+");
|
||||
@@ -61,7 +59,6 @@ public class DefaultSkillValidator implements SkillValidator {
|
||||
* @param skill Skill 聚合
|
||||
* @throws SkillValidationException 校验失败
|
||||
*/
|
||||
@Override
|
||||
public void validate(Skill skill) {
|
||||
validateReport(skill, limits, SkillValidationMode.STANDARD).throwIfInvalid();
|
||||
}
|
||||
@@ -72,7 +69,6 @@ public class DefaultSkillValidator implements SkillValidator {
|
||||
* @param skill Skill 聚合
|
||||
* @return 结构化校验报告
|
||||
*/
|
||||
@Override
|
||||
public SkillValidationReport validateReport(Skill skill) {
|
||||
return validateReport(skill, limits);
|
||||
}
|
||||
@@ -84,7 +80,6 @@ public class DefaultSkillValidator implements SkillValidator {
|
||||
* @param operationLimits 当前读写操作的安全限额
|
||||
* @return 结构化校验报告
|
||||
*/
|
||||
@Override
|
||||
public SkillValidationReport validateReport(Skill skill, SkillPackageLimits operationLimits) {
|
||||
return validateReport(skill, operationLimits, SkillValidationMode.DRAFT_IMPORT);
|
||||
}
|
||||
@@ -97,7 +92,6 @@ public class DefaultSkillValidator implements SkillValidator {
|
||||
* @param mode 标准校验模式
|
||||
* @return 结构化校验报告
|
||||
*/
|
||||
@Override
|
||||
public SkillValidationReport validateReport(Skill skill, SkillPackageLimits operationLimits,
|
||||
SkillValidationMode mode) {
|
||||
SkillPackageLimits effectiveLimits = operationLimits == null ? limits : operationLimits;
|
||||
@@ -110,11 +104,11 @@ public class DefaultSkillValidator implements SkillValidator {
|
||||
|
||||
SkillDocument document = parseDocument(skill, report, effectiveLimits);
|
||||
validateName(skill, document, report, effectiveMode);
|
||||
validateDescription(skill, document, report);
|
||||
validateDescription(document, report);
|
||||
if (document != null) {
|
||||
validateDocument(skill, document, report);
|
||||
validateDocument(document, report);
|
||||
}
|
||||
List<SkillResource> resources = SkillResources.canonicalResources(skill);
|
||||
List<SkillResource> resources = skill.getResources();
|
||||
validateAggregateLimits(skill, resources, report, effectiveLimits);
|
||||
validateResources(skill.getName(), resources, report, effectiveLimits);
|
||||
return report;
|
||||
@@ -123,7 +117,7 @@ public class DefaultSkillValidator implements SkillValidator {
|
||||
private static void validateName(Skill skill, SkillDocument document,
|
||||
SkillValidationReport report, SkillValidationMode mode) {
|
||||
SkillSourceLocation location = sourceLocation(document, "name");
|
||||
String name = skill.getName();
|
||||
String name = frontmatterString(document, "name");
|
||||
if (isBlank(name)) {
|
||||
report.add(errorAt("NAME_REQUIRED", SkillPaths.SKILL_FILE, location, "Skill name is required.",
|
||||
"Set frontmatter name to a portable Skill name."));
|
||||
@@ -153,13 +147,13 @@ public class DefaultSkillValidator implements SkillValidator {
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateDescription(Skill skill, SkillDocument document,
|
||||
SkillValidationReport report) {
|
||||
private static void validateDescription(SkillDocument document, SkillValidationReport report) {
|
||||
SkillSourceLocation location = sourceLocation(document, "description");
|
||||
if (isBlank(skill.getDescription())) {
|
||||
String description = frontmatterString(document, "description");
|
||||
if (isBlank(description)) {
|
||||
report.add(errorAt("DESCRIPTION_REQUIRED", SkillPaths.SKILL_FILE, location,
|
||||
"Skill description is required.", "Describe both the capability and when to use it."));
|
||||
} else if (skill.getDescription().length() > 1_024) {
|
||||
} else if (description.length() > 1_024) {
|
||||
report.add(errorAt("DESCRIPTION_TOO_LONG", SkillPaths.SKILL_FILE, location,
|
||||
"Skill description cannot exceed 1024 characters.", "Shorten the description."));
|
||||
}
|
||||
@@ -181,10 +175,8 @@ public class DefaultSkillValidator implements SkillValidator {
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateDocument(Skill skill, SkillDocument document, SkillValidationReport report) {
|
||||
private static void validateDocument(SkillDocument document, SkillValidationReport report) {
|
||||
Map<String, Object> values = document.getFrontmatter().getValues();
|
||||
validateCoreString(document, values, "name", skill.getName(), report);
|
||||
validateCoreString(document, values, "description", skill.getDescription(), report);
|
||||
validateOptionalString(document, values, "license", null, report);
|
||||
Object compatibility = values.get("compatibility");
|
||||
if (compatibility != null && (!(compatibility instanceof String text)
|
||||
@@ -195,12 +187,6 @@ public class DefaultSkillValidator implements SkillValidator {
|
||||
}
|
||||
validateOptionalString(document, values, "allowed-tools", "INVALID_ALLOWED_TOOLS", report);
|
||||
validateMetadataField(document, values.get("metadata"), report);
|
||||
if (skill.getMetadata() == null || !skill.getMetadata().getValues().equals(values)) {
|
||||
report.add(errorAt("METADATA_MISMATCH", SkillPaths.SKILL_FILE,
|
||||
sourceLocation(document, "name"),
|
||||
"Skill metadata must match SKILL.md frontmatter.",
|
||||
"Reparse SKILL.md before saving the aggregate."));
|
||||
}
|
||||
if (document.getMarkdownBody().length() > 30_000) {
|
||||
report.add(new SkillValidationIssue("LONG_SKILL_BODY", SkillValidationSeverity.WARNING,
|
||||
SkillPaths.SKILL_FILE, null, null,
|
||||
@@ -209,21 +195,6 @@ public class DefaultSkillValidator implements SkillValidator {
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateCoreString(SkillDocument document, Map<String, Object> values,
|
||||
String key, String expected,
|
||||
SkillValidationReport report) {
|
||||
Object value = values.get(key);
|
||||
if (!(value instanceof String text) || text.isBlank()) {
|
||||
report.add(errorAt(key.toUpperCase(Locale.ROOT) + "_REQUIRED", SkillPaths.SKILL_FILE,
|
||||
sourceLocation(document, key),
|
||||
"SKILL.md frontmatter " + key + " must be a non-blank string.", null));
|
||||
} else if (!text.equals(expected)) {
|
||||
report.add(errorAt(key.toUpperCase(Locale.ROOT) + "_MISMATCH", SkillPaths.SKILL_FILE,
|
||||
sourceLocation(document, key),
|
||||
"Skill " + key + " must match SKILL.md frontmatter.", null));
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateOptionalString(SkillDocument document, Map<String, Object> values,
|
||||
String key, String code,
|
||||
SkillValidationReport report) {
|
||||
@@ -335,11 +306,9 @@ public class DefaultSkillValidator implements SkillValidator {
|
||||
|
||||
private static void validateResourceContent(SkillResource resource, String path,
|
||||
SkillValidationReport report, SkillPackageLimits limits) {
|
||||
boolean expectedText = SkillResources.isText(path, resource.getKind(), resource.getMediaType());
|
||||
boolean expectedText = SkillResources.isText(path, resource.getMediaType());
|
||||
if (expectedText != resource.isText()) {
|
||||
String code = resource.getKind() == SkillResourceKind.SCRIPT
|
||||
? "SCRIPT_TEXT_REQUIRED" : "RESOURCE_CONTENT_MODE_MISMATCH";
|
||||
report.add(error(code, path,
|
||||
report.add(error("RESOURCE_CONTENT_MODE_MISMATCH", path,
|
||||
expectedText
|
||||
? "Resource path and media type require strict UTF-8 text content."
|
||||
: "Resource path and media type require a binary content reference.",
|
||||
@@ -350,13 +319,6 @@ public class DefaultSkillValidator implements SkillValidator {
|
||||
path, null, null, "Script resource is empty.",
|
||||
"Add script source or remove the unused file."));
|
||||
}
|
||||
if (resource.getKind() == SkillResourceKind.SCRIPT
|
||||
&& SkillScriptLanguage.fromPath(path) == SkillScriptLanguage.UNKNOWN) {
|
||||
report.add(new SkillValidationIssue("SCRIPT_LANGUAGE_UNRECOGNIZED",
|
||||
SkillValidationSeverity.WARNING, path, null, null,
|
||||
"Script language is not recognized from its extension.",
|
||||
"Use .py, .js, or .sh for first-class editing and syntax highlighting."));
|
||||
}
|
||||
if (isBlank(resource.getMediaType())) {
|
||||
report.add(error("MEDIA_TYPE_REQUIRED", path, "Skill resource media type is required.", null));
|
||||
}
|
||||
@@ -525,4 +487,12 @@ public class DefaultSkillValidator implements SkillValidator {
|
||||
private static boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
private static String frontmatterString(SkillDocument document, String key) {
|
||||
if (document == null) {
|
||||
return null;
|
||||
}
|
||||
Object value = document.getFrontmatter().get(key);
|
||||
return value instanceof String text ? text : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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.Skill;
|
||||
import com.easyagents.skill.model.SkillPackage;
|
||||
@@ -14,7 +13,6 @@ 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 org.apache.commons.compress.archivers.zip.UnixStat;
|
||||
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
|
||||
import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream;
|
||||
@@ -69,7 +67,7 @@ public class ZipSkillPackageCodecTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 单目录包导入通用资源并保持旧资源视图。
|
||||
* 单目录包导入通用资源。
|
||||
*/
|
||||
@Test
|
||||
public void decodeWrappedSingleSkillWithGenericResources() {
|
||||
@@ -86,17 +84,16 @@ public class ZipSkillPackageCodecTest {
|
||||
Assert.assertEquals(SkillPackageLayout.SINGLE_DIRECTORY,
|
||||
result.getSkillPackage().getLayout());
|
||||
Skill skill = result.getSkillPackage().getSkills().get(0);
|
||||
Assert.assertNull(skill.getId());
|
||||
Assert.assertEquals("skill-a", skill.getPackageRoot());
|
||||
Assert.assertEquals(5, skill.getResources().size());
|
||||
Assert.assertTrue(skill.getResources().stream().anyMatch(resource ->
|
||||
resource.getKind() == SkillResourceKind.EXAMPLE));
|
||||
Assert.assertTrue(skill.getResources().stream().anyMatch(resource ->
|
||||
resource.getKind() == SkillResourceKind.OTHER));
|
||||
Assert.assertEquals(1, skill.getReferences().size());
|
||||
Assert.assertEquals(1, skill.getScripts().size());
|
||||
Assert.assertEquals(1, skill.getAssets().size());
|
||||
Assert.assertTrue(store.exists(skill.getAssets().get(0).getContentRef()));
|
||||
SkillResource binary = skill.getResources().stream()
|
||||
.filter(resource -> resource.getKind() == SkillResourceKind.ASSET)
|
||||
.findFirst().orElseThrow();
|
||||
Assert.assertTrue(store.exists(binary.getContentRef()));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -148,7 +145,7 @@ public class ZipSkillPackageCodecTest {
|
||||
"nested-skill/SKILL.md", utf8(markdown)
|
||||
))).getSkillPackage().getSkills().get(0);
|
||||
|
||||
Assert.assertTrue(skill.getMetadata().get("metadata") instanceof Map);
|
||||
Assert.assertTrue(skill.getDocument().getFrontmatter().get("metadata") instanceof Map);
|
||||
Assert.assertEquals(markdown, skill.getSkillContent());
|
||||
}
|
||||
|
||||
@@ -469,6 +466,56 @@ public class ZipSkillPackageCodecTest {
|
||||
Assert.assertEquals(0, store.committedCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源的文本或二进制表示由文件类型决定,不受顶层语义目录约束。
|
||||
*/
|
||||
@Test
|
||||
public void decodeContentModeIndependentlyFromSemanticDirectory() throws Exception {
|
||||
InMemorySkillContentStore store = new InMemorySkillContentStore();
|
||||
byte[] opaqueScript = new byte[]{0, (byte) 0xFF, 1};
|
||||
SkillPackageReadResult result = decode(new ZipSkillPackageCodec(store), zip(files(
|
||||
"skill-a/SKILL.md", utf8(skillMd("skill-a")),
|
||||
"skill-a/assets/template.md", utf8("# Template"),
|
||||
"skill-a/scripts/helper.bin", opaqueScript
|
||||
)));
|
||||
|
||||
List<SkillResource> resources = result.getSkillPackage().getSkills().get(0).getResources();
|
||||
SkillResource template = resources.stream()
|
||||
.filter(resource -> "assets/template.md".equals(resource.getPath()))
|
||||
.findFirst().orElseThrow();
|
||||
SkillResource helper = resources.stream()
|
||||
.filter(resource -> "scripts/helper.bin".equals(resource.getPath()))
|
||||
.findFirst().orElseThrow();
|
||||
|
||||
Assert.assertTrue(template.isText());
|
||||
Assert.assertEquals("# Template", template.getTextContent());
|
||||
Assert.assertFalse(helper.isText());
|
||||
try (InputStream input = store.open(helper.getContentRef())) {
|
||||
Assert.assertArrayEquals(opaqueScript, input.readAllBytes());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预检回滚首次失败时,异常清理路径会重试尚未释放的 stage。
|
||||
*/
|
||||
@Test
|
||||
public void retryTransientReportOnlyRollbackFailure() {
|
||||
FlakyRollbackContentStore store = new FlakyRollbackContentStore();
|
||||
|
||||
try {
|
||||
new ZipSkillPackageCodec(store).decode(
|
||||
new ByteArrayInputStream(zip(files(
|
||||
"skill-a/SKILL.md", utf8(skillMd("skill-a")),
|
||||
"skill-a/assets/data.bin", new byte[]{1, 2, 3}
|
||||
))), SkillPackageReadOptions.reportOnly());
|
||||
Assert.fail("Transient rollback failure should remain visible to the caller.");
|
||||
} catch (SkillPackageException expected) {
|
||||
Assert.assertEquals("SKILL_CONTENT_ROLLBACK_ERROR", expected.getCode());
|
||||
Assert.assertEquals(2, store.rollbackAttempts);
|
||||
Assert.assertTrue(store.rollbackCompleted);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 多 Skill 预检报告为每个相对路径补齐各自根目录。
|
||||
*/
|
||||
@@ -493,12 +540,14 @@ public class ZipSkillPackageCodecTest {
|
||||
*/
|
||||
@Test
|
||||
public void exportPrefixesValidationPathsForMultipleSkills() {
|
||||
Skill first = SkillFactory.create("first", skillMd("skill-a"));
|
||||
Skill first = SkillFactory.create(skillMd("skill-a"));
|
||||
first.setPackageRoot("skill-a");
|
||||
first.setDescription(null);
|
||||
Skill second = SkillFactory.create("second", skillMd("skill-b"));
|
||||
first.getDocument().removeFrontmatter("description");
|
||||
first.setDocument(first.getDocument());
|
||||
Skill second = SkillFactory.create(skillMd("skill-b"));
|
||||
second.setPackageRoot("skill-b");
|
||||
second.setDescription(null);
|
||||
second.getDocument().removeFrontmatter("description");
|
||||
second.setDocument(second.getDocument());
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
|
||||
try {
|
||||
@@ -596,8 +645,6 @@ public class ZipSkillPackageCodecTest {
|
||||
"skill-a/references/a.md", utf8("# A"),
|
||||
"skill-a/assets/data.bin", new byte[]{1, 2, 3}
|
||||
)));
|
||||
first.getSkillPackage().getSkills().get(0).setId("internal-repository-id");
|
||||
|
||||
ByteArrayOutputStream firstZip = new ByteArrayOutputStream();
|
||||
SkillPackageWriteResult firstWrite = codec.encode(first.getSkillPackage(), firstZip,
|
||||
SkillPackageWriteOptions.defaults());
|
||||
@@ -609,13 +656,11 @@ public class ZipSkillPackageCodecTest {
|
||||
Assert.assertEquals(firstWrite.getPackageHash(), secondWrite.getPackageHash());
|
||||
Assert.assertEquals(SkillPackageLayout.SINGLE_DIRECTORY, firstWrite.getLayout());
|
||||
Assert.assertEquals(SkillHashes.sha256Hex(firstZip.toByteArray()), firstWrite.getPackageHash());
|
||||
Assert.assertFalse(zipContains(firstZip.toByteArray(), "internal-repository-id"));
|
||||
|
||||
SkillPackageReadResult roundTrip = decode(codec, firstZip.toByteArray());
|
||||
Skill original = first.getSkillPackage().getSkills().get(0);
|
||||
Skill decoded = roundTrip.getSkillPackage().getSkills().get(0);
|
||||
Assert.assertNull(decoded.getId());
|
||||
Assert.assertEquals(original.getMetadata().getValues(), decoded.getMetadata().getValues());
|
||||
Assert.assertEquals(original.getDocument().getFrontmatter().getValues(),
|
||||
decoded.getDocument().getFrontmatter().getValues());
|
||||
Assert.assertEquals(original.getSkillContent(), decoded.getSkillContent());
|
||||
Assert.assertEquals(original.getResources().stream().map(resource -> resource.getPath()).toList(),
|
||||
decoded.getResources().stream().map(resource -> resource.getPath()).toList());
|
||||
@@ -630,7 +675,7 @@ public class ZipSkillPackageCodecTest {
|
||||
@Test
|
||||
public void highCompressionTextRoundTripsWithSameLimits() {
|
||||
String content = skillMd("skill-a") + "a".repeat(20_000);
|
||||
Skill skill = SkillFactory.create("id", content);
|
||||
Skill skill = SkillFactory.create(content);
|
||||
SkillPackage skillPackage = new SkillPackage(
|
||||
SkillPackageLayout.SINGLE_DIRECTORY, List.of(skill));
|
||||
SkillPackageLimits limits = SkillPackageLimits.builder()
|
||||
@@ -678,7 +723,7 @@ public class ZipSkillPackageCodecTest {
|
||||
@Test
|
||||
public void enforceOutputLimitsOnGeneratedSkillEntryPath() {
|
||||
ZipSkillPackageCodec codec = new ZipSkillPackageCodec();
|
||||
Skill skill = SkillFactory.create("id", skillMd("skill-a"));
|
||||
Skill skill = SkillFactory.create(skillMd("skill-a"));
|
||||
String skillPath = "skill-a/SKILL.md";
|
||||
|
||||
assertExportIssue(codec, skill, new SkillPackageWriteOptions(
|
||||
@@ -690,95 +735,17 @@ public class ZipSkillPackageCodecTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除最后一个正式资源后不得从滞留的旧兼容视图恢复并再次导出。
|
||||
* Codec 内建的 YAML 与包大小安全校验不可绕过。
|
||||
*/
|
||||
@Test
|
||||
public void emptyCanonicalResourcesDoNotFallBackToLegacyViews() {
|
||||
String content = "# Rules";
|
||||
SkillResource reference = new SkillResource();
|
||||
reference.setPath("references/rules.md");
|
||||
reference.setKind(SkillResourceKind.REFERENCE);
|
||||
reference.setMediaType("text/markdown");
|
||||
reference.setTextContent(content);
|
||||
reference.setContentHash(SkillHashes.sha256Hex(utf8(content)));
|
||||
reference.setSize(utf8(content).length);
|
||||
Skill skill = SkillFactory.createWithResources(
|
||||
"id", skillMd("skill-a"), List.of(reference));
|
||||
Assert.assertEquals(1, skill.getReferences().size());
|
||||
skill.getResources().clear();
|
||||
|
||||
public void enforceMandatoryYamlAndPackageLimits() {
|
||||
ZipSkillPackageCodec codec = new ZipSkillPackageCodec();
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
codec.encode(new SkillPackage(SkillPackageLayout.SINGLE_DIRECTORY, List.of(skill)),
|
||||
output, SkillPackageWriteOptions.defaults());
|
||||
Skill decoded = codec.decode(new ByteArrayInputStream(output.toByteArray()),
|
||||
SkillPackageReadOptions.defaults()).getSkillPackage().getSkills().get(0);
|
||||
|
||||
Assert.assertTrue(decoded.getResources().isEmpty());
|
||||
Assert.assertTrue(decoded.getReferences().isEmpty());
|
||||
}
|
||||
|
||||
/**
|
||||
* 注入的默认校验器仍属于附加校验,其更严格限额不得被类型判断或操作选项绕过。
|
||||
*/
|
||||
@Test
|
||||
public void injectedDefaultValidatorAppliesItsStricterLimits() {
|
||||
ZipSkillPackageCodec codec = new ZipSkillPackageCodec(
|
||||
new InMemorySkillContentStore(),
|
||||
new com.easyagents.skill.validation.defaults.DefaultSkillValidator(
|
||||
SkillPackageLimits.builder().maxTextFileBytes(128).build()));
|
||||
Skill skill = SkillFactory.create("id", skillMd("skill-a") + "x".repeat(300));
|
||||
|
||||
assertExportIssue(codec, skill, SkillPackageWriteOptions.defaults(),
|
||||
"TEXT_FILE_SIZE_LIMIT", SkillPaths.SKILL_FILE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义限额感知校验器必须收到当前 Codec 操作选项。
|
||||
*/
|
||||
@Test
|
||||
public void customValidatorReceivesOperationLimits() {
|
||||
SkillPackageLimits limits = SkillPackageLimits.builder().maxPathLength(64).build();
|
||||
SkillPackageLimits[] observed = new SkillPackageLimits[1];
|
||||
SkillValidator validator = new SkillValidator() {
|
||||
@Override
|
||||
public void validate(Skill skill) {
|
||||
// 限额感知实现通过下方结构化入口完成业务校验。
|
||||
}
|
||||
|
||||
@Override
|
||||
public com.easyagents.skill.validation.SkillValidationReport validateReport(
|
||||
Skill skill, SkillPackageLimits operationLimits) {
|
||||
observed[0] = operationLimits;
|
||||
return new com.easyagents.skill.validation.SkillValidationReport();
|
||||
}
|
||||
};
|
||||
ZipSkillPackageCodec codec = new ZipSkillPackageCodec(
|
||||
new InMemorySkillContentStore(), validator);
|
||||
|
||||
codec.encode(new SkillPackage(SkillPackageLayout.SINGLE_DIRECTORY,
|
||||
List.of(SkillFactory.create("id", skillMd("skill-a")))),
|
||||
new ByteArrayOutputStream(), new SkillPackageWriteOptions(limits));
|
||||
|
||||
Assert.assertSame(limits, observed[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* noop 业务校验器不能绕过 Codec 内建的 YAML 与包大小安全校验。
|
||||
*/
|
||||
@Test
|
||||
public void noopValidatorCannotBypassMandatoryYamlAndPackageLimits() {
|
||||
SkillValidator noopValidator = skill -> {
|
||||
// 业务层不增加规则,Codec 仍必须独立完成标准安全校验。
|
||||
};
|
||||
ZipSkillPackageCodec codec = new ZipSkillPackageCodec(
|
||||
new InMemorySkillContentStore(), noopValidator);
|
||||
Skill malformedYaml = SkillFactory.create("id", skillMd("skill-a"));
|
||||
Skill malformedYaml = SkillFactory.create(skillMd("skill-a"));
|
||||
malformedYaml.setSkillContent("---\nname: skill-a\ndescription: [\n---\n# Invalid\n");
|
||||
assertExportIssue(codec, malformedYaml, SkillPackageWriteOptions.defaults(),
|
||||
"INVALID_FRONTMATTER_YAML", SkillPaths.SKILL_FILE);
|
||||
|
||||
Skill oversized = SkillFactory.create("id", skillMd("skill-a") + "x".repeat(300));
|
||||
Skill oversized = SkillFactory.create(skillMd("skill-a") + "x".repeat(300));
|
||||
SkillPackageLimits limits = SkillPackageLimits.builder()
|
||||
.maxTextFileBytes(128)
|
||||
.build();
|
||||
@@ -786,22 +753,6 @@ public class ZipSkillPackageCodecTest {
|
||||
"TEXT_FILE_SIZE_LIMIT", SkillPaths.SKILL_FILE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Codec 强制标准校验后仍执行调用方注入的业务校验器。
|
||||
*/
|
||||
@Test
|
||||
public void preserveAdditionalBusinessValidator() {
|
||||
SkillValidator businessValidator = skill -> {
|
||||
throw new SkillValidationException("CUSTOM_POLICY", SkillPaths.SKILL_FILE,
|
||||
null, null, "Custom Skill policy rejected the package.", null);
|
||||
};
|
||||
ZipSkillPackageCodec codec = new ZipSkillPackageCodec(
|
||||
new InMemorySkillContentStore(), businessValidator);
|
||||
|
||||
assertExportIssue(codec, SkillFactory.create("id", skillMd("skill-a")),
|
||||
SkillPackageWriteOptions.defaults(), "CUSTOM_POLICY", SkillPaths.SKILL_FILE);
|
||||
}
|
||||
|
||||
/**
|
||||
* SKILL.md 与普通文本资源中的孤立 UTF-16 surrogate 均以结构化错误拒绝。
|
||||
*/
|
||||
@@ -809,12 +760,12 @@ public class ZipSkillPackageCodecTest {
|
||||
public void rejectMalformedUtf16SurrogateDuringExport() {
|
||||
String isolatedSurrogate = String.valueOf((char) 0xD800);
|
||||
ZipSkillPackageCodec codec = new ZipSkillPackageCodec();
|
||||
Skill invalidDocument = SkillFactory.create("id", skillMd("skill-a"));
|
||||
Skill invalidDocument = SkillFactory.create(skillMd("skill-a"));
|
||||
invalidDocument.setSkillContent(skillMd("skill-a") + isolatedSurrogate);
|
||||
assertExportIssue(codec, invalidDocument, SkillPackageWriteOptions.defaults(),
|
||||
"INVALID_UTF8", SkillPaths.SKILL_FILE);
|
||||
|
||||
Skill invalidResource = SkillFactory.create("id", skillMd("skill-a"));
|
||||
Skill invalidResource = SkillFactory.create(skillMd("skill-a"));
|
||||
SkillResource resource = new SkillResource();
|
||||
resource.setPath("references/invalid.md");
|
||||
resource.setKind(SkillResourceKind.REFERENCE);
|
||||
@@ -826,7 +777,7 @@ public class ZipSkillPackageCodecTest {
|
||||
assertExportIssue(codec, invalidResource, SkillPackageWriteOptions.defaults(),
|
||||
"INVALID_UTF8", "references/invalid.md");
|
||||
|
||||
Skill invalidPath = SkillFactory.create("id", skillMd("skill-a"));
|
||||
Skill invalidPath = SkillFactory.create(skillMd("skill-a"));
|
||||
SkillResource pathResource = new SkillResource();
|
||||
String malformedPath = "references/" + isolatedSurrogate + ".md";
|
||||
pathResource.setPath(malformedPath);
|
||||
@@ -845,7 +796,7 @@ public class ZipSkillPackageCodecTest {
|
||||
*/
|
||||
@Test
|
||||
public void rejectMissingResourcePathBeforeExportWritesBytes() {
|
||||
Skill skill = SkillFactory.create("id", skillMd("skill-a"));
|
||||
Skill skill = SkillFactory.create(skillMd("skill-a"));
|
||||
SkillResource resource = new SkillResource();
|
||||
resource.setKind(SkillResourceKind.REFERENCE);
|
||||
resource.setMediaType("text/markdown");
|
||||
@@ -902,7 +853,7 @@ public class ZipSkillPackageCodecTest {
|
||||
*/
|
||||
@Test
|
||||
public void aggregateInvalidSkillBeforePreparingOutput() {
|
||||
Skill skill = com.easyagents.skill.factory.SkillFactory.create("id", skillMd("skill-a"));
|
||||
Skill skill = SkillFactory.create(skillMd("skill-a"));
|
||||
skill.setSkillContent(null);
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
|
||||
@@ -975,7 +926,7 @@ public class ZipSkillPackageCodecTest {
|
||||
resource.setContentRef("sha256:" + hash);
|
||||
resource.setContentHash(hash);
|
||||
resource.setSize(expected.length);
|
||||
Skill skill = com.easyagents.skill.factory.SkillFactory.create("id", skillMd("skill-a"));
|
||||
Skill skill = SkillFactory.create(skillMd("skill-a"));
|
||||
skill.setResources(List.of(resource));
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
|
||||
@@ -1258,21 +1209,6 @@ public class ZipSkillPackageCodecTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean zipContains(byte[] zip, String value) {
|
||||
try (ZipInputStream input = new ZipInputStream(new ByteArrayInputStream(zip), StandardCharsets.UTF_8)) {
|
||||
ZipEntry entry;
|
||||
while ((entry = input.getNextEntry()) != null) {
|
||||
if (entry.getName().contains(value)
|
||||
|| new String(input.readAllBytes(), StandardCharsets.UTF_8).contains(value)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] utf8(String value) {
|
||||
return value.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
@@ -1285,7 +1221,7 @@ public class ZipSkillPackageCodecTest {
|
||||
return "---\nname: " + name + "\ndescription: ''\n---\n# " + name + "\n";
|
||||
}
|
||||
|
||||
private static final class CountingContentStore implements SkillContentStore {
|
||||
private static final class CountingContentStore extends InMemorySkillContentStore {
|
||||
|
||||
private int putCount;
|
||||
|
||||
@@ -1300,11 +1236,6 @@ public class ZipSkillPackageCodecTest {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] readAllBytes(String contentRef) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean exists(String contentRef) {
|
||||
return false;
|
||||
@@ -1356,6 +1287,12 @@ public class ZipSkillPackageCodecTest {
|
||||
stagedCount--;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void retain(String contentRef) {
|
||||
delegate.retain(contentRef);
|
||||
committedCount++;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void release(String contentRef) {
|
||||
delegate.release(contentRef);
|
||||
@@ -1367,18 +1304,13 @@ public class ZipSkillPackageCodecTest {
|
||||
return delegate.open(contentRef);
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] readAllBytes(String contentRef) {
|
||||
return delegate.readAllBytes(contentRef);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean exists(String contentRef) {
|
||||
return delegate.exists(contentRef);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class CorruptReadContentStore implements SkillContentStore {
|
||||
private static final class CorruptReadContentStore extends InMemorySkillContentStore {
|
||||
|
||||
@Override
|
||||
public String put(byte[] bytes) {
|
||||
@@ -1390,14 +1322,25 @@ public class ZipSkillPackageCodecTest {
|
||||
return new ByteArrayInputStream(utf8("xyz"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] readAllBytes(String contentRef) {
|
||||
return utf8("xyz");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean exists(String contentRef) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class FlakyRollbackContentStore extends InMemorySkillContentStore {
|
||||
|
||||
private int rollbackAttempts;
|
||||
private boolean rollbackCompleted;
|
||||
|
||||
@Override
|
||||
public void rollback(SkillContentStage stage) {
|
||||
rollbackAttempts++;
|
||||
if (rollbackAttempts == 1) {
|
||||
throw new IllegalStateException("simulated transient rollback failure");
|
||||
}
|
||||
super.rollback(stage);
|
||||
rollbackCompleted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
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 com.easyagents.skill.util.SkillResources;
|
||||
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());
|
||||
}
|
||||
|
||||
/**
|
||||
* 仓储复制旧资源对象时应先迁移正式资源,不能因空 canonical 列表丢失 reference。
|
||||
*/
|
||||
@Test
|
||||
public void repositoryCopyMigratesLegacyOnlyResources() {
|
||||
InMemorySkillRepository repository = new InMemorySkillRepository();
|
||||
Skill legacy = skill();
|
||||
Assert.assertFalse(legacy.isResourcesInitialized());
|
||||
|
||||
repository.save(legacy);
|
||||
Skill loaded = repository.get("skill-a").orElseThrow();
|
||||
|
||||
Assert.assertTrue(loaded.isResourcesInitialized());
|
||||
Assert.assertEquals(1, SkillResources.canonicalResources(loaded).size());
|
||||
Assert.assertEquals("references/a.md", loaded.getResources().get(0).getPath());
|
||||
}
|
||||
|
||||
/**
|
||||
* 显式清空正式资源列表后,仓储往返不得从旧兼容视图恢复已删除资源。
|
||||
*/
|
||||
@Test
|
||||
public void repositoryCopyPreservesExplicitlyEmptyCanonicalResources() {
|
||||
InMemorySkillRepository repository = new InMemorySkillRepository();
|
||||
Skill skill = skill();
|
||||
Assert.assertEquals(1, skill.getResources().size());
|
||||
skill.getResources().clear();
|
||||
|
||||
repository.save(skill);
|
||||
Skill loaded = repository.get("skill-a").orElseThrow();
|
||||
|
||||
Assert.assertTrue(loaded.isResourcesInitialized());
|
||||
Assert.assertTrue(SkillResources.canonicalResources(loaded).isEmpty());
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import com.easyagents.skill.codec.ZipSkillPackageCodec;
|
||||
import com.easyagents.skill.exception.SkillException;
|
||||
import com.easyagents.skill.store.SkillContentStage;
|
||||
import com.easyagents.skill.store.SkillContentStore;
|
||||
import com.easyagents.skill.store.memory.InMemorySkillContentStore;
|
||||
import com.easyagents.skill.util.SkillHashes;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
@@ -167,7 +166,6 @@ public class TemporaryFileSkillContentStoreTest {
|
||||
ZipSkillPackageCodec defaultCodec = new ZipSkillPackageCodec();
|
||||
SkillContentStore defaultStore = (SkillContentStore) contentStoreField.get(defaultCodec);
|
||||
Assert.assertTrue(defaultStore instanceof TemporaryFileSkillContentStore);
|
||||
Assert.assertFalse(defaultStore instanceof InMemorySkillContentStore);
|
||||
Path defaultDirectory = ((TemporaryFileSkillContentStore) defaultStore).storageDirectory();
|
||||
defaultCodec.close();
|
||||
Assert.assertFalse(Files.exists(defaultDirectory));
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.easyagents.skill.store.memory;
|
||||
|
||||
import com.easyagents.skill.exception.SkillException;
|
||||
import com.easyagents.skill.store.SkillContentStage;
|
||||
import com.easyagents.skill.store.SkillContentStore;
|
||||
import com.easyagents.skill.util.SkillHashes;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Codec 测试使用的内存内容存储。
|
||||
*/
|
||||
public class InMemorySkillContentStore implements SkillContentStore {
|
||||
|
||||
private final Map<String, StoredContent> contents = new HashMap<>();
|
||||
private final Map<String, byte[]> stages = new HashMap<>();
|
||||
|
||||
@Override
|
||||
public String put(byte[] bytes) {
|
||||
byte[] copy = bytes == null ? new byte[0] : Arrays.copyOf(bytes, bytes.length);
|
||||
String ref = SkillHashes.sha256Ref(copy);
|
||||
StoredContent stored = contents.get(ref);
|
||||
if (stored == null) {
|
||||
contents.put(ref, new StoredContent(copy));
|
||||
} else {
|
||||
stored.references++;
|
||||
}
|
||||
return ref;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SkillContentStage stage(InputStream inputStream, long maxBytes) {
|
||||
byte[] bytes = readBounded(inputStream, maxBytes);
|
||||
String stageId = UUID.randomUUID().toString();
|
||||
String hash = SkillHashes.sha256Hex(bytes);
|
||||
stages.put(stageId, bytes);
|
||||
return new SkillContentStage(stageId, "sha256:" + hash, hash, bytes.length);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String commit(SkillContentStage stage) {
|
||||
byte[] bytes = stage == null ? null : stages.remove(stage.getStageId());
|
||||
if (bytes == null) {
|
||||
throw new SkillException("Skill content stage does not exist.");
|
||||
}
|
||||
return put(bytes);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void rollback(SkillContentStage stage) {
|
||||
if (stage != null) {
|
||||
stages.remove(stage.getStageId());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void retain(String contentRef) {
|
||||
StoredContent stored = require(contentRef);
|
||||
stored.references++;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void release(String contentRef) {
|
||||
StoredContent stored = contents.get(contentRef);
|
||||
if (stored != null && --stored.references <= 0) {
|
||||
contents.remove(contentRef);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream open(String contentRef) {
|
||||
StoredContent stored = require(contentRef);
|
||||
return new ByteArrayInputStream(Arrays.copyOf(stored.bytes, stored.bytes.length));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean exists(String contentRef) {
|
||||
return contents.containsKey(contentRef);
|
||||
}
|
||||
|
||||
private StoredContent require(String contentRef) {
|
||||
StoredContent stored = contents.get(contentRef);
|
||||
if (stored == null) {
|
||||
throw new SkillException("Skill content does not exist: " + contentRef);
|
||||
}
|
||||
return stored;
|
||||
}
|
||||
|
||||
private static byte[] readBounded(InputStream inputStream, long maxBytes) {
|
||||
try {
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
byte[] buffer = new byte[8_192];
|
||||
long total = 0;
|
||||
int read;
|
||||
while ((read = inputStream.read(buffer)) >= 0) {
|
||||
total += read;
|
||||
if (total > maxBytes) {
|
||||
throw new SkillException("Skill content exceeds limit.");
|
||||
}
|
||||
output.write(buffer, 0, read);
|
||||
}
|
||||
return output.toByteArray();
|
||||
} catch (IOException exception) {
|
||||
throw new SkillException("Failed to read Skill content.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class StoredContent {
|
||||
|
||||
private final byte[] bytes;
|
||||
private int references = 1;
|
||||
|
||||
private StoredContent(byte[] bytes) {
|
||||
this.bytes = bytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
package com.easyagents.skill.store.memory;
|
||||
|
||||
import com.easyagents.skill.store.SkillContentStage;
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂存内容在 commit 前不可见,rollback 后不残留。
|
||||
*/
|
||||
@Test
|
||||
public void stageCommitAndRollback() {
|
||||
InMemorySkillContentStore store = new InMemorySkillContentStore();
|
||||
byte[] bytes = "abc".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
SkillContentStage rolledBack = store.stage(new java.io.ByteArrayInputStream(bytes), bytes.length);
|
||||
Assert.assertFalse(store.exists(rolledBack.getContentRef()));
|
||||
store.rollback(rolledBack);
|
||||
Assert.assertFalse(store.exists(rolledBack.getContentRef()));
|
||||
|
||||
SkillContentStage committed = store.stage(new java.io.ByteArrayInputStream(bytes), bytes.length);
|
||||
String contentRef = store.commit(committed);
|
||||
Assert.assertTrue(store.exists(contentRef));
|
||||
Assert.assertArrayEquals(bytes, store.readAllBytes(contentRef));
|
||||
}
|
||||
|
||||
/**
|
||||
* 相同内容按引用计数释放,归零后删除。
|
||||
*/
|
||||
@Test
|
||||
public void releaseDeletesOnlyAfterReferenceCountReachesZero() {
|
||||
InMemorySkillContentStore store = new InMemorySkillContentStore();
|
||||
byte[] bytes = "abc".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
String first = store.put(bytes);
|
||||
String second = store.put(bytes);
|
||||
store.release(first);
|
||||
|
||||
Assert.assertTrue(store.exists(second));
|
||||
store.release(second);
|
||||
Assert.assertFalse(store.exists(second));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.easyagents.skill.util;
|
||||
|
||||
import com.easyagents.skill.model.SkillResourceKind;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
@@ -15,8 +14,18 @@ public class SkillResourcesTest {
|
||||
@Test
|
||||
public void recognizeBothHtmlExtensionsAsText() {
|
||||
Assert.assertTrue(SkillResources.isText(
|
||||
"references/page.htm", SkillResourceKind.REFERENCE, "application/octet-stream"));
|
||||
"references/page.htm", "application/octet-stream"));
|
||||
Assert.assertTrue(SkillResources.isText(
|
||||
"references/page.html", SkillResourceKind.REFERENCE, "application/octet-stream"));
|
||||
"references/page.html", "application/octet-stream"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源目录只表达语义分类,不决定文本或二进制存储。
|
||||
*/
|
||||
@Test
|
||||
public void determineContentModeIndependentlyFromTopLevelDirectory() {
|
||||
Assert.assertTrue(SkillResources.isText("assets/template.md", "application/octet-stream"));
|
||||
Assert.assertTrue(SkillResources.isText("custom/data.csv", "application/octet-stream"));
|
||||
Assert.assertFalse(SkillResources.isText("scripts/helper.bin", "application/octet-stream"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,15 +45,15 @@ public class DefaultSkillValidatorTest {
|
||||
String content = "---\nname: nested-skill\ndescription: Handles nested metadata\n"
|
||||
+ "metadata:\n enabled: true\n retries: 3\n tags:\n - alpha\n - beta\n"
|
||||
+ "---\n# Nested\n";
|
||||
Skill skill = SkillFactory.create("repository-id", content);
|
||||
Skill skill = SkillFactory.create(content);
|
||||
|
||||
validator.validate(skill);
|
||||
|
||||
Assert.assertTrue(skill.getMetadata().get("metadata") instanceof java.util.Map);
|
||||
Assert.assertTrue(skill.getDocument().getFrontmatter().get("metadata") instanceof java.util.Map);
|
||||
}
|
||||
|
||||
/**
|
||||
* 结构化文档重新应用到聚合时同步 name、description 和 metadata。
|
||||
* 结构化文档更新后名称与描述从 frontmatter 派生。
|
||||
*/
|
||||
@Test
|
||||
public void applyEditedDocumentToAggregate() {
|
||||
@@ -64,7 +64,8 @@ public class DefaultSkillValidatorTest {
|
||||
skill.setDocument(document);
|
||||
|
||||
Assert.assertEquals("Updated description for the Skill", skill.getDescription());
|
||||
Assert.assertEquals("Updated description for the Skill", skill.getMetadata().get("description"));
|
||||
Assert.assertEquals("Updated description for the Skill",
|
||||
skill.getDocument().getFrontmatter().get("description"));
|
||||
validator.validate(skill);
|
||||
}
|
||||
|
||||
@@ -98,20 +99,12 @@ public class DefaultSkillValidatorTest {
|
||||
&& issue.getSeverity() == SkillValidationSeverity.ERROR));
|
||||
}
|
||||
|
||||
/**
|
||||
* 严格工厂入口执行正式标准名称校验。
|
||||
*/
|
||||
@Test(expected = SkillValidationException.class)
|
||||
public void strictFactoryRejectsLegacyUnderscoreName() {
|
||||
SkillFactory.createStrict("repository-id", skillMd("legacy_skill"));
|
||||
}
|
||||
|
||||
/**
|
||||
* AgentScope 嵌套 metadata 保真并标记标准兼容 warning。
|
||||
*/
|
||||
@Test
|
||||
public void nestedMetadataProducesCompatibilityWarning() {
|
||||
Skill skill = SkillFactory.create("id", "---\nname: metadata-skill\n"
|
||||
Skill skill = SkillFactory.create("---\nname: metadata-skill\n"
|
||||
+ "description: Nested metadata compatibility\nmetadata:\n provider:\n enabled: true\n"
|
||||
+ "---\n# Metadata\n");
|
||||
|
||||
@@ -127,7 +120,7 @@ public class DefaultSkillValidatorTest {
|
||||
*/
|
||||
@Test
|
||||
public void rejectInvalidOptionalStandardFields() {
|
||||
Skill skill = SkillFactory.create("id", "---\nname: optional-skill\n"
|
||||
Skill skill = SkillFactory.create("---\nname: optional-skill\n"
|
||||
+ "description: Invalid optional fields\nlicense: []\ncompatibility: ''\nallowed-tools:\n - Read\n"
|
||||
+ "---\n# Optional\n");
|
||||
|
||||
@@ -261,10 +254,10 @@ public class DefaultSkillValidatorTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* scripts 目录只接受严格 UTF-8 文本表示,二进制引用必须被拒绝。
|
||||
* 文本扩展名必须使用严格 UTF-8 文本表示,与所在目录无关。
|
||||
*/
|
||||
@Test
|
||||
public void rejectBinaryScriptResource() {
|
||||
public void rejectBinaryRepresentationForTextExtension() {
|
||||
Skill skill = validSkill("skill-a");
|
||||
SkillResource script = binaryResource("scripts/run.sh", "echo unsafe");
|
||||
script.setKind(SkillResourceKind.SCRIPT);
|
||||
@@ -273,23 +266,37 @@ public class DefaultSkillValidatorTest {
|
||||
SkillValidationReport report = validator.validateReport(skill);
|
||||
|
||||
Assert.assertTrue(report.getIssues().stream().anyMatch(issue ->
|
||||
"SCRIPT_TEXT_REQUIRED".equals(issue.getCode())
|
||||
"RESOURCE_CONTENT_MODE_MISMATCH".equals(issue.getCode())
|
||||
&& issue.getSeverity() == SkillValidationSeverity.ERROR));
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源的文本或二进制表示必须与统一路径和媒体类型判定一致。
|
||||
* assets 目录中的文本资源使用规范文本表示。
|
||||
*/
|
||||
@Test
|
||||
public void rejectNonCanonicalResourceContentMode() {
|
||||
public void acceptTextResourceInAssetsDirectory() {
|
||||
Skill skill = validSkill("skill-a");
|
||||
SkillResource textAsset = textResource("assets/readme.txt", SkillResourceKind.ASSET, "text asset");
|
||||
skill.setResources(java.util.List.of(textAsset));
|
||||
|
||||
SkillValidationReport report = validator.validateReport(skill);
|
||||
|
||||
Assert.assertTrue(report.getIssues().stream().anyMatch(issue ->
|
||||
"RESOURCE_CONTENT_MODE_MISMATCH".equals(issue.getCode())));
|
||||
Assert.assertFalse(report.hasErrors());
|
||||
}
|
||||
|
||||
/**
|
||||
* scripts 目录允许保存不透明二进制辅助文件。
|
||||
*/
|
||||
@Test
|
||||
public void acceptBinaryResourceInScriptsDirectory() {
|
||||
Skill skill = validSkill("skill-a");
|
||||
SkillResource binaryScript = binaryResource("scripts/helper.bin", "opaque");
|
||||
binaryScript.setKind(SkillResourceKind.SCRIPT);
|
||||
skill.setResources(java.util.List.of(binaryScript));
|
||||
|
||||
SkillValidationReport report = validator.validateReport(skill);
|
||||
|
||||
Assert.assertFalse(report.hasErrors());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -309,23 +316,6 @@ public class DefaultSkillValidatorTest {
|
||||
&& issue.getSeverity() == SkillValidationSeverity.WARNING));
|
||||
}
|
||||
|
||||
/**
|
||||
* 未识别脚本语言需要提示,但不能破坏外部标准 Skill 包的资源保真。
|
||||
*/
|
||||
@Test
|
||||
public void warnForUnrecognizedScriptLanguageExtension() {
|
||||
Skill skill = validSkill("skill-a");
|
||||
skill.setResources(java.util.List.of(
|
||||
textResource("scripts/run.txt", SkillResourceKind.SCRIPT, "echo ok")));
|
||||
|
||||
SkillValidationReport report = validator.validateReport(skill);
|
||||
|
||||
Assert.assertFalse(report.hasErrors());
|
||||
Assert.assertTrue(report.getIssues().stream().anyMatch(issue ->
|
||||
"SCRIPT_LANGUAGE_UNRECOGNIZED".equals(issue.getCode())
|
||||
&& issue.getSeverity() == SkillValidationSeverity.WARNING));
|
||||
}
|
||||
|
||||
/**
|
||||
* 结构化校验器执行资源单文件安全限额。
|
||||
*/
|
||||
@@ -361,19 +351,8 @@ public class DefaultSkillValidatorTest {
|
||||
Assert.assertFalse(report.hasErrors());
|
||||
}
|
||||
|
||||
/**
|
||||
* 元数据与 SKILL.md 不一致时失败。
|
||||
*/
|
||||
@Test(expected = SkillValidationException.class)
|
||||
public void rejectMetadataMismatch() {
|
||||
Skill skill = validSkill("skill-a");
|
||||
skill.getMetadata().put("extra", "value");
|
||||
|
||||
validator.validate(skill);
|
||||
}
|
||||
|
||||
private static Skill validSkill(String name) {
|
||||
return SkillFactory.create("repository-id", skillMd(name));
|
||||
return SkillFactory.create(skillMd(name));
|
||||
}
|
||||
|
||||
private static String skillMd(String name) {
|
||||
|
||||
Reference in New Issue
Block a user