feat(agent): SkillRepositoryResolver 把 skills.* 映射到 v2 skillRepository/skillFilter

Task 4.1:新增 SkillRepositoryResolver,把 liteflow.agent.skills.*(enabled/path/strict)
+ 组件级 cmp.skills() allow-list 翻译成 agentscope v2 的 skillRepository/skillFilter/
dynamicSkillsEnabled,替代 Task 0 删除的 1.0 SkillBoxFactory(基于已弃用 SkillBox)。

核心映射(探针结论回填 findings §R-skill,实测 RC3 源码):
- enabled=true → new FileSystemSkillRepository(path) + builder.skillRepository(repo)
  + dynamicSkillsEnabled(true);v2 ReActAgent.build() 自动装 DynamicSkillMiddleware。
- enabled=false → dynamicSkillsEnabled(false),不注册。
- allow-list 非空 → 先把裸技能名解析成真实 skillId(name + "_" + source,RC3 filter
  匹配 skillId 而非 name)再 SkillFilter.only(skillIds);空 = SkillFilter.all()。
- strict:path 不存在/非目录(构造抛)或无 SKILL.md(空 names)或 allow-list 全不命中
  → strict 抛 AgentConfigException,非 strict 告警 + 照常/不注册。
- skillWorkDir 与 skillRepository 区别:前者是技能文件上传目录,后者是技能来源;
  "目录里有 SKILL.md" 用 skillRepository(FileSystemSkillRepository(path))。

接入 ReactAgentFactory.build(permissionContext 之后、build 之前)。因
ReActAgentComponent.enableSkills()/skills() 是 protected,resolver 在 skill 包无法
直接访问,故由同 component 包的 ReactAgentFactory 读出 enabled/allowList 透传。

测试 SkillLoadingTest(6 场景,PromptCapturingModel 捕获系统提示词,不依赖真实 LLM):
enabled 注入全部 / allow-list 过滤 / disabled 不注入 / strict 抛(×2) / 非 strict 告警。
校验:SkillLoadingTest+ProcessIntegrationTest+ShellPermissionBehaviorTest 10/10 PASS。

范围外(推迟):self-learning skill loop(GA)、usedSkills() 跟踪(Task 5.1
SkillTrackingMiddleware);SkillsAgentCmp 的 usedSkills stub 保留 TODO(5.1)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
everywhere.z 2026-06-20 10:48:07 +08:00
parent 85f679ad4a
commit 96159b9499
4 changed files with 681 additions and 5 deletions

View File

@ -362,3 +362,149 @@ public boolean matchRule(String ruleContent, Map<String, Object> toolInput)
### `ToolCallParam` 取参路径callAsync 实现用)
`ToolCallParam.getInput():Map<String,Object>`unmodifiable即工具参数`getToolUseBlock()` 含 call id/name`getAgent()`/`getRuntimeContext()` 可空。子类 `callAsync` 实现典型:从 `param.getInput().get("command")` 取参、执行、返回 `Mono.just(new ToolResultBlock(...))`
---
## R-skillSkill repository / SkillFilter / DynamicSkillMiddleware 自动安装Task 4.1 探针确认)
**来源:** 对 `agentscope-2.0.0-RC3-sources.jar``io/agentscope/core/skill/**`、`io/agentscope/core/ReActAgent.java`build() 段 42004280、`io/agentscope/core/skill/util/SkillFileSystemHelper.java` 全量源码通读JDK 21直接读 RC3 源码,比反射更权威)。结论已用于 Task 4.1 `SkillRepositoryResolver`
### (a) `AgentSkillRepository` 接口 + 开箱即用实现
`io.agentscope.core.skill.repository.AgentSkillRepository extends AutoCloseable`(接口,方法签名):
```java
AgentSkill getSkill(String name);
List<String> getAllSkillNames();
List<AgentSkill> getAllSkills();
boolean save(List<AgentSkill>, boolean force);
boolean delete(String name);
boolean skillExists(String name);
AgentSkillRepositoryInfo getRepositoryInfo(); // (type, location, writable)
String getSource();
void setWriteable(boolean);
boolean isWriteable();
default void close();
```
**开箱即用实现(全部 implements AgentSkillRepository**
- **`io.agentscope.core.skill.repository.FileSystemSkillRepository`**(最适合"目录里有 `SKILL.md` 的本地技能"
```java
FileSystemSkillRepository(Path baseDir);
FileSystemSkillRepository(Path baseDir, boolean lazy);
FileSystemSkillRepository(Path baseDir, boolean lazy, String source);
FileSystemSkillRepository(Path baseDir, boolean lazy, String source, boolean writeable);
// baseDir 即技能根目录;每个技能是 baseDir 下的一个<b>子目录</b>,内含 SKILL.md。
```
- **`ClasspathSkillRepository(String resourcePath) throws IOException`**(从 classpath 资源目录加载;构造会抛受检 `IOException`,用反射/try-catch 包裹)。
- (扩展 jar本任务不涉及`GitSkillRepository` / `NacosSkillRepository` / `MysqlSkillRepository` / `PostgresSkillRepository`
### (b) SKILL.md 格式 + name 解析
- **文件名固定为 `SKILL.md`**`FileSystemSkillRepository.SKILL_FILE_NAME` / `SkillFileSystemHelper.SKILL_FILE_NAME` 私有常量,大小写敏感)。
- **目录布局**`<baseDir>/<skillDir>/SKILL.md`+ 可选资源文件)。**`baseDir` 直接是技能根**,不要在下面再套一层 `skills/`
- **YAML frontmatter**:开头 `---` ... `---` 块,至少含 `name:`(技能名,字符串,非空)与 `description:`。技能的 `name` **取自 frontmatter 的 `name` 字段,不是目录名**`SkillFileSystemHelper.readSkillName` 用 `MarkdownSkillParser.parse` 解析 frontmatter`metadata.get("name")`)。
```markdown
---
name: research
description: Research skill ...
---
# Research Skill
...
```
- `FileSystemSkillRepository.getAllSkills()`:惰性/非惰性地遍历 `baseDir` 子目录,对每个含 `SKILL.md` 的子目录解析出一个 `AgentSkill`,返回 `List<AgentSkill>`。**目录不存在或无 SKILL.md → 返回空 List不抛**`SkillFileSystemHelper` 内部对 `Files.list` 失败抛 RuntimeException但 repo 层会吞)。
### (c) `SkillFilter` —— allow-list 的原生表达
`io.agentscope.core.skill.SkillFilter`final class私有构造全部 static factory
```java
static SkillFilter all(); // 不过滤(默认)
static SkillFilter none(); // 全禁用
static SkillFilter only(String... names); // allow-list仅这些 name 可见
static SkillFilter except(String... names); // 黑名单
static SkillFilter enable(String... names);
static SkillFilter disable(String... names);
boolean isAllowed(String name);
boolean isOverlay();
SkillFilter overlay(SkillFilter runtimeOverlay); // 与 RuntimeContext 上的 overlay 合并
```
内部 `Mode` 枚举:`ALL / NONE / WHITELIST / BLACKLIST / OVERLAY_ENABLE / OVERLAY_DISABLE`。
**⚠️ 重大坑(实测 RC3 源码filter 匹配的是 `skillId`,不是裸 `name`。**
`AgentSkillPromptProvider.getSkillSystemPrompt(filter)` 的过滤循环里调的是
`filter.isAllowed(registered.getSkillId())`,而 `AgentSkill.getSkillId()` = `name + "_" + source`
(如 `"research_filesystem-tmpXXX_skills"`)。`FileSystemSkillRepository` 的 `source` =
`"filesystem-" + parent_baseDir``buildDefaultSourceSuffix`)。
故用户在 `cmp.skills()` 里写裸技能名(`"research"`)时,**直接 `SkillFilter.only("research")`
会永不命中**——`isAllowed("research_filesystem-...")` 在 WHITELIST 模式下返回 falseallow-list
反而把所有技能都滤掉。**Task 4.1 resolver 必须先把裸 name 解析成 repo 里真实的 skillId 列表**
(遍历 `repo.getAllSkills()`,对 `skill.getName() ∈ allowList` 的项收集 `skill.getSkillId()`
`SkillFilter.only(skillIds)`。这是 allow-list 正确工作的<b>唯一</b>方式。
空 allow-list = 不过滤 = 不调 `.skillFilter(...)`builder 默认 `SkillFilter.all()`)。
### (d) `skillWorkDir` vs `skillRepository` —— 区别
两者**完全不同**,不可互换(实测 `ReActAgent.Builder` 字段 35483564 + build() 42684276
- **`skillRepository(AgentSkillRepository)` / `skillRepositories(List)`**:技能<b>来源</b>(数据源)——告诉 DynamicSkillMiddleware 去<b>哪里</b>加载技能定义。**这就是"目录里有 SKILL.md 的本地技能"要用的**`builder.skillRepository(new FileSystemSkillRepository(path))`。
- **`skillWorkDir(Path)`**DynamicSkillMiddleware 在加载技能时把技能附带的支持文件resources<b>上传/解压</b>到的稳定工作目录(`SkillBox.uploadSkillFiles()` 落地点)。`null`(默认)= middleware 内部 `mkdtemp` 临时目录、JVM 退出自动清理。**它不是技能来源,不影响"加载到哪些技能"。**
Task 4.1 的 `skills.path`(指向含 `<name>/SKILL.md` 的目录)→ **`skillRepository(new FileSystemSkillRepository(Paths.get(path)))`****不**用 `skillWorkDir`
### (e) `DynamicSkillMiddleware` 自动安装条件(实测 build() 42684276
```java
if (!skillRepositories.isEmpty() && dynamicSkillsEnabled) {
middlewares.add(new DynamicSkillMiddleware(
List.copyOf(skillRepositories),
agentToolkit,
skillFilter != null ? skillFilter : SkillFilter.all(),
skillCodeExecutionEnabled,
skillWorkDir));
}
```
- **只要注册了 ≥1 个 skillRepository 且 `dynamicSkillsEnabled` 为 true默认 true`DynamicSkillMiddleware` 就被自动安装**——无需手动 `.middleware(...)`
- 它在每个 `call()``onSystemPrompt` 钩子里:调 `repo.getAllSkills()` → 按 `SkillFilter` 过滤 → 重建 `SkillBox` → 在 toolkit 上注册 `load_skill_through_path` 工具 + 把技能清单注入系统提示词。
- `skillRepository(...)` 调多次会累加(`skillRepositories` 是 List同名 skill 后注册的覆盖前者);`skillRepositories(List)` 会先 clear 再 add。
### (f) strict 语义SkillsConfig.strict默认 true
`FileSystemSkillRepository`**constructor 对入参强校验**(实测源码):
```java
// public FileSystemSkillRepository(Path baseDir, boolean writeable, String source, boolean lazy)
if (baseDir == null) throw new IllegalArgumentException("Base directory cannot be null");
this.baseDir = baseDir.toAbsolutePath().normalize();
if (!Files.exists(this.baseDir)) throw new IllegalArgumentException("Base directory does not exist: " + this.baseDir);
if (!Files.isDirectory(this.baseDir)) throw new IllegalArgumentException("Base directory is not a directory: " + this.baseDir);
```
**"path 不存在/不是目录"在构造期就抛 `IllegalArgumentException`,根本走不到 `getAllSkills()`**。而 **空目录(存在但无 `SKILL.md` 子目录)构造成功,`getAllSkillNames()`/`getAllSkills()` 返回空 List、不抛**
Task 4.1 的 `strict` 语义在 <b>resolver 层</b>实现,分两段:
- **构造期异常**path 不存在/非目录):
- `strict=true` → 捕获 `IllegalArgumentException`,包装成 `AgentConfigException` 抛出(快速失败,对齐 1.0 `SkillBoxFactory`)。
- `strict=false` → 捕获、记录告警、不注册 repomiddleware 不会安装,等价禁用)。
- **构造成功但空**`repo.getAllSkillNames().isEmpty()`
- `strict=true` → 抛 `AgentConfigException`"skills.enabled=true 但 path 下无可解析技能")。
- `strict=false` → 记录告警,照常注册 repoDynamicSkillMiddleware 运行期见空 `getAllSkills()` 自然短路:`currentSkillBox=null`、不注入提示词段、不注册 `load_skill_through_path`)。
### (g) enabled=false 的正确处理
`ReActAgent.Builder.dynamicSkillsEnabled` **默认 true**,即便不注册 skillRepository 也无副作用(无 repo → middleware 不安装)。但为对齐"`skills.enabled=false` 显式关闭技能"语义Task 4.1 resolver 在 enabled=false 时显式调 `builder.dynamicSkillsEnabled(false)`——这样即便用户/其他代码后续塞了 repo目前无此路径也不会意外装上 middleware。不注册 skillRepository、不设 skillFilter。
### 对 Task 4.1SkillRepositoryResolver的指导已采用
- `configure(Builder, cmp, cfg)` 签名:
- `enabled = cmp.enableSkills()`(默认读 `cfg.getSkills().isEnabled()`,可被子类覆写)。
- **enabled=true**
1. `Path p = Paths.get(cfg.getSkills().getPath())`(默认 `./skills`)。
2. `AgentSkillRepository repo = new FileSystemSkillRepository(p)`
3. `if (strict && repo.getAllSkillNames().isEmpty()) throw new AgentConfigException(...)`
4. `builder.skillRepository(repo)` + `builder.dynamicSkillsEnabled(true)`
5. allow-list 非空 → 先 `resolveAllowListSkillIds(repo, allowList)`(遍历
`repo.getAllSkills()` 把裸 name 映射成 `name + "_" + source` 的真实 skillId
(c) 的 filter-vs-skillId 坑),再 `builder.skillFilter(SkillFilter.only(skillIds))`
allow-list 里没有一个 name 命中 → strict 抛、非 strict 告警 + 仍注册runtime 全 deny
- **enabled=false**`builder.dynamicSkillsEnabled(false)`,不注册、不设 filter。
- 接入点:`ReactAgentFactory.build`,在 `.permissionContext(...)` 之后、`.build()` 之前调 `SkillRepositoryResolver.configure(builder, this, cfg)`
- **不**碰 `usedSkills()` 跟踪(依赖 Task 5.1 SkillTrackingMiddlewarebrief 明确推迟)。

View File

@ -2,6 +2,7 @@ package com.yomahub.liteflow.agent.component;
import com.yomahub.liteflow.agent.exception.AgentConfigException;
import com.yomahub.liteflow.agent.permission.PermissionConfigMapper;
import com.yomahub.liteflow.agent.skill.SkillRepositoryResolver;
import com.yomahub.liteflow.agent.state.AgentStateStoreResolver;
import com.yomahub.liteflow.agent.tool.ManagedShellCommandTool;
import com.yomahub.liteflow.agent.tool.WorkspaceFileTools;
@ -52,12 +53,16 @@ import java.util.concurrent.ConcurrentHashMap;
* <li>{@code maxIters} {@code cmp.maxIterations() > 0 ? it : cfg.defaults.maxIterations}</li>
* <li>{@code stateStore} {@link AgentStateStoreResolver#resolve(AgentConfig)}可能返回 null=NONE合法</li>
* <li>{@code permissionContext} {@link PermissionConfigMapper#map(AgentConfig)}ShellConfigv2 命令级规则Task 3.1</li>
* <li>{@code skillRepository}/{@code skillFilter}/{@code dynamicSkillsEnabled}
* {@link SkillRepositoryResolver#configure(ReActAgent.Builder, boolean, java.util.List, String, AgentConfig)}
* SkillsConfig + {@code cmp.skills()} allow-list v2 技能仓库/过滤Task 4.1
* 本类同处 {@code component} 可读 {@code cmp} {@code protected} 钩子
* {@code enableSkills()}/{@code skills()}把这两个值透传给 resolver</li>
* </ul>
*
* <p><b>不设留给后续 Task / RC3 </b>
* {@code .filesystem()/.workspace()/.compaction()/.memory(MemoryConfig)}RC3 HarnessAgent 这些方法
* {@code .middleware(...)Task 5.1
* {@code .skillRepository(...)Task 4.1本类不引用这些协作者
* {@code .middleware(...)Task 5.1本类不引用这些协作者
*
* <h2>线程安全</h2>
* 缓存用 {@link ConcurrentHashMap#computeIfAbsent}构建闭包至多执行一次/
@ -148,15 +153,19 @@ public final class ReactAgentFactory {
PermissionContextState permissionContext = PermissionConfigMapper.map(cfg);
try {
return ReActAgent.builder()
ReActAgent.Builder builder = ReActAgent.builder()
.name(name)
.sysPrompt(sysPrompt)
.model(model)
.toolkit(toolkit)
.maxIters(maxIters)
.stateStore(stateStore) // null 合法 = NONE 语义findings R1
.permissionContext(permissionContext)
.build();
.permissionContext(permissionContext);
// SkillsConfig + cmp.skills() allow-list v2 skillRepository/skillFilter/dynamicSkillsEnabled
// findings R-skill本类同 component 可读 cmp protected enableSkills()/skills()
// 把这两个值透传给 resolverresolver skill 无法直接访问 protected 钩子
SkillRepositoryResolver.configure(builder, cmp.enableSkills(), cmp.skills(), name, cfg);
return builder.build();
} catch (Exception e) {
throw new AgentConfigException(
"Failed to build ReActAgent for component " + name + ": " + e.getMessage(), e);

View File

@ -0,0 +1,266 @@
package com.yomahub.liteflow.agent.skill;
import com.yomahub.liteflow.agent.exception.AgentConfigException;
import com.yomahub.liteflow.log.LFLog;
import com.yomahub.liteflow.log.LFLoggerManager;
import com.yomahub.liteflow.property.agent.AgentConfig;
import com.yomahub.liteflow.property.agent.SkillsConfig;
import io.agentscope.core.ReActAgent;
import io.agentscope.core.skill.SkillFilter;
import io.agentscope.core.skill.repository.AgentSkillRepository;
import io.agentscope.core.skill.repository.FileSystemSkillRepository;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
/**
* liteflow {@code liteflow.agent.skills.*} 配置 + 组件级 {@code cmp.skills()} allow-list
* 翻译成 agentscope v2 {@code skillRepository}/{@code skillFilter}/{@code dynamicSkillsEnabled}
* 替代 Task 0 删除的 1.0 {@code SkillBoxFactory}/{@code SkillToolResolver}基于已弃用
* {@code SkillBox}
*
* <p>这是技能层迁移的第一块Task 4.1调用方 {@code ReactAgentFactory.build}
* {@code component} 可读 {@link com.yomahub.liteflow.agent.component.ReActAgentComponent}
* {@code protected} 钩子 {@code enableSkills()}/{@code skills()}在设好
* model/toolkit/stateStore/permission 之后{@code .build()} 之前调一次本类的
* {@link #configure(ReActAgent.Builder, boolean, List, String, AgentConfig)}把这两个
* {@code protected} 值作为<b>显式入参</b>传入本类处在 {@code skill} 不在 {@code component}
* 无法直接访问 {@code protected} 钩子故采用工厂读钩子 透传给 resolver的分层
*
* <h2>配置映射findings R-skill实测 RC3 源码</h2>
* <ul>
* <li><b>{@code enabled=true}</b>工厂侧由 {@code cmp.enableSkills()} 解析默认读
* {@link SkillsConfig#isEnabled()}可被子类覆写
* <ol>
* <li>{@code Path p = Paths.get(skills.path)}默认 {@code ./skills}</li>
* <li>{@code AgentSkillRepository repo = new FileSystemSkillRepository(p)}
* {@code FileSystemSkillRepository} 构造器对 path 强校验null/不存在/非目录
* {@link IllegalArgumentException}空目录 {@code SKILL.md} 子目录构造成功
* {@code getAllSkillNames()} 返回空 ListbaseDir 直接是技能根每个技能是
* {@code <baseDir>/<skillDir>/SKILL.md}技能名取自 YAML frontmatter {@code name:}
* 字段<b>非目录名</b></li>
* <li>strict 校验见下</li>
* <li>{@code builder.skillRepository(repo)} + {@code builder.dynamicSkillsEnabled(true)}
* v2 {@code ReActAgent.build()} {@code !skillRepositories.isEmpty() && dynamicSkillsEnabled}
* <b>自动</b>安装 {@code DynamicSkillMiddleware}无需手动 {@code .middleware(...)}
* 它在每个 {@code call()} {@code onSystemPrompt} 钩子里加载技能 filter 过滤
* 把技能清单注入系统提示词并注册 {@code load_skill_through_path} 工具</li>
* <li>{@code allowList} 非空工厂侧由 {@code cmp.skills()} 解析
* {@code builder.skillFilter(SkillFilter.only(...))} allow-list 不调
* builder 默认 {@code SkillFilter.all()}</li>
* </ol>
* </li>
* <li><b>{@code enabled=false}</b>{@code builder.dynamicSkillsEnabled(false)}不注册
* skillRepository不设 skillFilter即便 {@code dynamicSkillsEnabled} 默认就是 true
* repo v2 也不会装 middleware这里仍显式置 false 以对齐"明确关闭技能"语义
* 防御性地避免后续代码目前无此路径意外塞 repo</li>
* </ul>
*
* <h2>strict 语义{@link SkillsConfig#isStrict()}默认 true</h2>
* 对齐 1.0 {@code SkillBoxFactory} 的快速失败行为<b>resolver </b>实现
* <ul>
* <li><b>构造期异常</b>path 不存在/非目录{@link FileSystemSkillRepository}
* {@link IllegalArgumentException}
* <ul>
* <li>strict=true 包装成 {@link AgentConfigException} 抛出</li>
* <li>strict=false 记录告警不注册 repomiddleware 不装等价禁用</li>
* </ul>
* </li>
* <li><b>构造成功但空</b>{@code repo.getAllSkillNames().isEmpty()}
* <ul>
* <li>strict=true {@link AgentConfigException}"skills.enabled=true 但 path 下无可解析技能"</li>
* <li>strict=false 记录告警照常注册 repo运行期 DynamicSkillMiddleware 见空
* {@code getAllSkills()} 自然短路不注入提示词段不注册 load 工具</li>
* </ul>
* </li>
* </ul>
*
* <h2>范围边界brief Task 4.1</h2>
* <b>不做</b>self-learning skill loopGA{@code usedSkills()} 跟踪依赖 Task 5.1
* SkillTrackingMiddleware{@code SkillsAgentCmp} {@code usedSkills} stub Task 5.1 恢复
*
* <h2>不臆造 API</h2>
* 全部 v2 {@link AgentSkillRepository}/{@link FileSystemSkillRepository}/{@link SkillFilter}/
* {@code DynamicSkillMiddleware}均经 findings R-skill 实测确认
*/
public final class SkillRepositoryResolver {
private static final LFLog LOG = LFLoggerManager.getLogger(SkillRepositoryResolver.class);
private SkillRepositoryResolver() {
}
/**
* 把技能仓库/过滤/动态开关配置到 {@code builder}
*
* <p>{@code enabled} {@code allowList} 由调用方{@code ReactAgentFactory}
* {@link com.yomahub.liteflow.agent.component.ReActAgentComponent} 同包从其
* {@code protected} 钩子 {@code enableSkills()}/{@code skills()} 读取后透传本类不在
* {@code component} 无法直接访问这两个 {@code protected} 方法
*
* <p>幂等假设每个 {@link ReActAgent.Builder} 仅被配置一次 {@code ReactAgentFactory.build}
* 单点调用重复调同一 builder 会累加多个 repositoryv2 语义不在本类职责内
*
* @param builder 待配置的 {@link ReActAgent.Builder}已设好 model/toolkit
* @param enabled 是否启用技能来自 {@code cmp.enableSkills()}
* @param allowList 组件级 allow-list来自 {@code cmp.skills()}/null = 全部可见
* @param componentLabel 组件标签用于日志/异常消息通常是 {@code cmp.getClass().getSimpleName()}
* @param cfg agent 配置 {@link AgentConfig#getSkills()}
* @throws AgentConfigException enabled=true strict=true 时技能路径不可用/无可解析技能
*/
public static void configure(ReActAgent.Builder builder, boolean enabled,
List<String> allowList, String componentLabel, AgentConfig cfg) {
if (builder == null) {
throw new AgentConfigException("ReActAgent.Builder is null; cannot configure skills");
}
String label = componentLabel == null || componentLabel.isBlank() ? "<agent>" : componentLabel;
if (!enabled) {
// 显式关闭即便后续代码塞了 repo目前无此路径middleware 也不会装
builder.dynamicSkillsEnabled(false);
return;
}
if (cfg == null || cfg.getSkills() == null) {
// enabled 但无配置保守按 strict 避免静默装上空 middleware
throw new AgentConfigException(
"skills enabled for component " + label
+ " but AgentConfig.skills is null; configure liteflow.agent.skills.*");
}
SkillsConfig skills = cfg.getSkills();
boolean strict = skills.isStrict();
String pathStr = skills.getPath();
if (pathStr == null || pathStr.isBlank()) {
pathStr = "./skills";
}
Path skillsPath = Paths.get(pathStr);
// 构造 FileSystemSkillRepository构造器强校验 path
AgentSkillRepository repo;
try {
repo = new FileSystemSkillRepository(skillsPath);
} catch (IllegalArgumentException e) {
if (strict) {
throw new AgentConfigException(
"skills.enabled=true but skill path is invalid for component "
+ label + ": " + e.getMessage(),
e);
}
LOG.warn("[SkillRepositoryResolver] strict=false, skipping invalid skill path {}: {}",
skillsPath, e.getMessage());
builder.dynamicSkillsEnabled(true);
return;
} catch (Exception e) {
if (strict) {
throw new AgentConfigException(
"skills.enabled=true but failed to build FileSystemSkillRepository for component "
+ label + ": " + e.getMessage(),
e);
}
LOG.warn("[SkillRepositoryResolver] strict=false, skipping skill repository init for {}: {}",
skillsPath, e.getMessage());
builder.dynamicSkillsEnabled(true);
return;
}
// 构造成功但空 SKILL.md 子目录
List<String> skillNames;
try {
skillNames = repo.getAllSkillNames();
} catch (Exception e) {
// getAllSkillNames 理论不抛空目录返回空 List防御性处理
if (strict) {
throw new AgentConfigException(
"skills.enabled=true but failed to list skills for component "
+ label + ": " + e.getMessage(),
e);
}
LOG.warn("[SkillRepositoryResolver] strict=false, getAllSkillNames failed for {}: {}",
skillsPath, e.getMessage());
builder.dynamicSkillsEnabled(true);
return;
}
if (skillNames == null || skillNames.isEmpty()) {
if (strict) {
throw new AgentConfigException(
"skills.enabled=true but no parseable skills found under path "
+ skillsPath + " for component " + label
+ " (expected <name>/SKILL.md with YAML frontmatter 'name:')");
}
LOG.warn("[SkillRepositoryResolver] strict=false, no skills found under {}; "
+ "DynamicSkillMiddleware will no-op at runtime", skillsPath);
}
// 注册 repo + 开启动态加载v2 build() 自动装 DynamicSkillMiddleware
builder.skillRepository(repo);
builder.dynamicSkillsEnabled(true);
// allow-list 非空 SkillFilter.only(...)
// 注意findings R-skill实测 RC3 源码DynamicSkillMiddleware/SkillBox
// AgentSkill.getSkillId()= name + "_" + source
// "research_filesystem-tmpXXX_skills"而非裸 name filter 匹配
// AgentSkillPromptProvider.getSkillSystemPrompt(filter)
// filter.isAllowed(skillId)用户在 cmp.skills() 里写的是裸技能名
// "research" resolver 必须先从 repo 解析出真实 skillId 列表再构造
// SkillFilter.only(skillIds)否则裸名永不命中allow-list 会滤掉一切
if (allowList != null && !allowList.isEmpty()) {
List<String> resolvedSkillIds = resolveAllowListSkillIds(repo, allowList, skillsPath, label);
if (resolvedSkillIds.isEmpty()) {
// allow-list 里没有一个 name 能在 repo 找到此时 SkillFilter.only([]) 等于
// "禁止全部"strict 下按快速失败 strict 记告警 + 仍注册middleware 会因
// filter deny 而不注入任何技能等价禁用技能加载
if (strict) {
throw new AgentConfigException(
"skills.enabled=true with allow-list " + allowList
+ " but none matched any skill in " + skillsPath
+ " for component " + label
+ " (available skill names: " + skillNames + ")");
}
LOG.warn("[SkillRepositoryResolver] strict=false, allow-list {} matched no skills in {}; "
+ "all skills will be filtered out at runtime", allowList, skillsPath);
}
builder.skillFilter(SkillFilter.only(resolvedSkillIds.toArray(new String[0])));
LOG.info("[SkillRepositoryResolver] registered skill repository {} ({} skills) "
+ "for component {} with allow-list {} (resolved to skillIds {})",
skillsPath, skillNames == null ? 0 : skillNames.size(), label, allowList,
resolvedSkillIds);
} else {
LOG.info("[SkillRepositoryResolver] registered skill repository {} ({} skills) "
+ "for component {} (no allow-list, all skills visible)",
skillsPath, skillNames == null ? 0 : skillNames.size(), label);
}
}
/**
* 把用户写的裸技能名 allow-list 映射成 repo 里真实的 {@code skillId}{@code name + "_" + source}
* 遍历 {@code repo.getAllSkills()}对每个 {@link io.agentscope.core.skill.AgentSkill}
* 若其 {@code getName()} allow-list 则收集其 {@code getSkillId()}找不到的名字被忽略
* strict "全找不到"由调用方在 resolvedSkillIds 为空时处理
*/
private static List<String> resolveAllowListSkillIds(AgentSkillRepository repo,
List<String> allowList,
Path skillsPath, String label) {
List<io.agentscope.core.skill.AgentSkill> all;
try {
all = repo.getAllSkills();
} catch (Exception e) {
LOG.warn("[SkillRepositoryResolver] getAllSkills failed while resolving allow-list for {}: {}",
skillsPath, e.getMessage());
return List.of();
}
if (all == null || all.isEmpty()) {
return List.of();
}
java.util.Set<String> allowed = new java.util.HashSet<>(allowList);
List<String> skillIds = new java.util.ArrayList<>();
for (io.agentscope.core.skill.AgentSkill skill : all) {
if (skill == null || skill.getName() == null) {
continue;
}
if (allowed.contains(skill.getName())) {
skillIds.add(skill.getSkillId());
}
}
return skillIds;
}
}

View File

@ -0,0 +1,255 @@
package com.yomahub.liteflow.test.agent.v2;
import com.yomahub.liteflow.agent.exception.AgentConfigException;
import com.yomahub.liteflow.agent.skill.SkillRepositoryResolver;
import com.yomahub.liteflow.property.agent.AgentConfig;
import io.agentscope.core.ReActAgent;
import io.agentscope.core.agent.RuntimeContext;
import io.agentscope.core.message.ContentBlock;
import io.agentscope.core.message.Msg;
import io.agentscope.core.message.TextBlock;
import io.agentscope.core.message.UserMessage;
import io.agentscope.core.model.ChatResponse;
import io.agentscope.core.model.GenerateOptions;
import io.agentscope.core.model.Model;
import io.agentscope.core.model.ToolSchema;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import reactor.core.publisher.Flux;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Task 4.1 SkillRepositoryResolver 端到端行为测试
*
* <p>验证 {@link SkillRepositoryResolver#configure(ReActAgent.Builder, boolean, List, String, AgentConfig)}
* {@code skills.enabled=true} + {@code skills.path} {@code <name>/SKILL.md}正确映射到
* v2 {@code skillRepository} + {@code dynamicSkillsEnabled(true)}并在 allow-list 非空时
* 套用 {@code SkillFilter.only(...)}
*
* <p><b>断言路径</b>{@code configure(...)} {@code builder.build()} 得到的 {@link ReActAgent}
* 已由 v2 自动装上 {@code DynamicSkillMiddleware}findings R-skill注册 1 repo + dynamicSkillsEnabled
* 即自动安装{@code ReActAgent.build()} 实测 42684276该中间件在每次 {@code call()}
* {@code onSystemPrompt} 钩子里加载 repo 技能并按 {@code SkillFilter} 过滤把技能清单注入系统提示词
* 本测试用一个 {@link PromptCapturingModel}捕获模型收到的系统提示词+ {@code agent.call(...).block()}
* 触发该钩子再断言提示词里包含/不含各技能名这是<b>不依赖真实 LLM</b> 的稳定可观测点
*
* <p>三组场景
* <ol>
* <li><b>enabled=true allow-list</b>两个技能research / demo都出现在提示词</li>
* <li><b>enabled=trueallow-list=[research]</b> research 出现demo 不出现</li>
* <li><b>enabled=false</b>提示词不含任何技能名{@code dynamicSkillsEnabled(false)}middleware 不装</li>
* </ol>
*
* <p>另含一组 <b>strict 语义</b>测试strict=true + path 不存在 {@link AgentConfigException}
* strict=true + path strict=false + 不存在 不抛不注册build 仍成功提示词无技能
*
* <p><b>不构建 Spring 上下文不调真实 LLM</b>直接构造 {@link AgentConfig} + 手建
* {@link ReActAgent.Builder}最小 name/sysPrompt/toolkit + 捕获 model{@code enabled}/
* {@code allowList} 由测试直接传给 resolver在生产路径这两个值由 {@code ReactAgentFactory}
* {@code component} {@code ReActAgentComponent} {@code protected} 钩子
* {@code enableSkills()}/{@code skills()} 读取后透传本测试绕开 cmp 直接验证 resolver 行为
*/
class SkillLoadingTest {
@TempDir
Path tmp;
/* ===== 场景 1enabled=true空 allow-list → 两个技能都可见 ===== */
@Test
void enabledWithSkillsDir_injectsAllSkillsIntoSystemPrompt() {
Path skillsDir = writeSkills("research", "demo");
AgentConfig cfg = skillsCfg(skillsDir, true, true);
ReActAgent.Builder builder = baseBuilder();
PromptCapturingModel model = new PromptCapturingModel();
builder.model(model);
assertDoesNotThrow(() -> configure(builder, true, Collections.emptyList(), cfg));
ReActAgent agent = builder.build();
agent.call(List.of(new UserMessage("hi")), RuntimeContext.empty()).block();
String sysPrompt = model.capturedSystemPrompt.get();
assertTrue(sysPrompt != null && sysPrompt.contains("research"),
"system prompt should mention 'research' skill; got: " + sysPrompt);
assertTrue(sysPrompt.contains("demo"),
"system prompt should mention 'demo' skill; got: " + sysPrompt);
}
/* ===== 场景 2enabled=trueallow-list=[research] → 仅 research 可见 ===== */
@Test
void enabledWithAllowList_filtersOutNonListedSkill() {
Path skillsDir = writeSkills("research", "demo");
AgentConfig cfg = skillsCfg(skillsDir, true, true);
ReActAgent.Builder builder = baseBuilder();
PromptCapturingModel model = new PromptCapturingModel();
builder.model(model);
configure(builder, true, Collections.singletonList("research"), cfg);
ReActAgent agent = builder.build();
agent.call(List.of(new UserMessage("hi")), RuntimeContext.empty()).block();
String sysPrompt = model.capturedSystemPrompt.get();
assertTrue(sysPrompt.contains("research"),
"allow-listed 'research' must remain visible; got: " + sysPrompt);
assertFalse(sysPrompt.contains("demo"),
"non-allow-listed 'demo' must be filtered out; got: " + sysPrompt);
}
/* ===== 场景 3enabled=false → 无任何技能注入 ===== */
@Test
void disabled_doesNotInjectSkills() {
Path skillsDir = writeSkills("research", "demo");
AgentConfig cfg = skillsCfg(skillsDir, false, true);
ReActAgent.Builder builder = baseBuilder();
PromptCapturingModel model = new PromptCapturingModel();
builder.model(model);
configure(builder, false, Collections.emptyList(), cfg);
ReActAgent agent = builder.build();
agent.call(List.of(new UserMessage("hi")), RuntimeContext.empty()).block();
String sysPrompt = model.capturedSystemPrompt.get();
assertFalse(sysPrompt.contains("research"),
"skills disabled → no skill injected; got: " + sysPrompt);
assertFalse(sysPrompt.contains("demo"),
"skills disabled → no skill injected; got: " + sysPrompt);
}
/* ===== strict 语义 ===== */
@Test
void strict_pathDoesNotExist_throws() {
AgentConfig cfg = skillsCfg(tmp.resolve("does-not-exist"), true, true);
assertThrows(AgentConfigException.class,
() -> configure(baseBuilder(), true, Collections.emptyList(), cfg),
"strict=true + missing path should fail fast");
}
@Test
void strict_emptySkillsDir_throws() throws Exception {
Path emptyDir = tmp.resolve("empty-skills");
Files.createDirectories(emptyDir);
AgentConfig cfg = skillsCfg(emptyDir, true, true);
assertThrows(AgentConfigException.class,
() -> configure(baseBuilder(), true, Collections.emptyList(), cfg),
"strict=true + no parseable skills should fail fast");
}
@Test
void nonStrict_missingPath_doesNotThrowAndDoesNotRegister() {
AgentConfig cfg = skillsCfg(tmp.resolve("does-not-exist"), true, false);
ReActAgent.Builder builder = baseBuilder();
PromptCapturingModel model = new PromptCapturingModel();
builder.model(model);
// strict=false + missing path resolver warns, does not register, build still OK
assertDoesNotThrow(() -> configure(builder, true, Collections.emptyList(), cfg));
ReActAgent agent = builder.build();
agent.call(List.of(new UserMessage("hi")), RuntimeContext.empty()).block();
String sysPrompt = model.capturedSystemPrompt.get();
assertFalse(sysPrompt.contains("research") || sysPrompt.contains("demo"),
"strict=false + missing path → no skills; got: " + sysPrompt);
}
/* ===== helpers ===== */
/**
* Thin wrapper over {@link SkillRepositoryResolver#configure}固定 componentLabel
* 让测试调用更紧凑生产路径由 {@code ReactAgentFactory} 传真实 label
*/
private static void configure(ReActAgent.Builder builder, boolean enabled,
List<String> allowList, AgentConfig cfg) {
SkillRepositoryResolver.configure(builder, enabled, allowList, "SkillLoadingTest", cfg);
}
/** 在 tmp 下建一个 skills 根目录,每个 name 一个子目录 + SKILL.mdYAML frontmatter。 */
private Path writeSkills(String... names) {
try {
Path root = tmp.resolve("skills");
Files.createDirectories(root);
for (String name : names) {
Path skillDir = root.resolve(name);
Files.createDirectories(skillDir);
String content = "---\n" +
"name: " + name + "\n" +
"description: " + name + " skill for SkillLoadingTest\n" +
"---\n\n" +
"# " + name + " Skill\n\n" +
"Use this skill when the request is about " + name + ".\n";
Files.write(skillDir.resolve("SKILL.md"), content.getBytes(StandardCharsets.UTF_8));
}
return root;
} catch (Exception e) {
throw new IllegalStateException("failed to write skill fixtures", e);
}
}
private static AgentConfig skillsCfg(Path skillsPath, boolean enabled, boolean strict) {
AgentConfig cfg = new AgentConfig();
cfg.getSkills().setEnabled(enabled);
cfg.getSkills().setPath(skillsPath.toAbsolutePath().toString());
cfg.getSkills().setStrict(strict);
return cfg;
}
/** 最小可构建的 buildername + sysPrompt + 空 toolkit。model 由各测试单独塞。 */
private static ReActAgent.Builder baseBuilder() {
return ReActAgent.builder()
.name("skill-loading-test")
.sysPrompt("base test system prompt")
.toolkit(new io.agentscope.core.tool.Toolkit());
}
/**
* 捕获模型记录每次 {@code stream(...)} 收到的<b>第一条消息</b>系统提示词文本
* 同时固定 emit 一个 stop 回复让 ReActAgent 一轮收敛无状态确定性
*/
private static final class PromptCapturingModel implements Model {
final AtomicReference<String> capturedSystemPrompt = new AtomicReference<>();
@Override
public Flux<ChatResponse> stream(List<Msg> messages, List<ToolSchema> tools, GenerateOptions options) {
if (messages != null && !messages.isEmpty()) {
Msg first = messages.get(0);
String text = first.getTextContent();
if (text != null) {
capturedSystemPrompt.set(text);
}
}
ChatResponse resp = ChatResponse.builder()
.id("captured-" + System.nanoTime())
.content(List.<ContentBlock>of(TextBlock.builder().text("[captured-ok]").build()))
.finishReason("stop")
.build();
return Flux.just(resp);
}
@Override
public String getModelName() {
return "prompt-capturing-mock";
}
}
}