diff --git a/docs/superpowers/specs/v2-api-findings.md b/docs/superpowers/specs/v2-api-findings.md index a30167254..2a0624c97 100644 --- a/docs/superpowers/specs/v2-api-findings.md +++ b/docs/superpowers/specs/v2-api-findings.md @@ -362,3 +362,149 @@ public boolean matchRule(String ruleContent, Map toolInput) ### `ToolCallParam` 取参路径(callAsync 实现用) `ToolCallParam.getInput():Map`(unmodifiable)即工具参数;`getToolUseBlock()` 含 call id/name;`getAgent()`/`getRuntimeContext()` 可空。子类 `callAsync` 实现典型:从 `param.getInput().get("command")` 取参、执行、返回 `Mono.just(new ToolResultBlock(...))`。 + +--- + +## R-skill:Skill repository / SkillFilter / DynamicSkillMiddleware 自动安装(Task 4.1 探针确认) + +**来源:** 对 `agentscope-2.0.0-RC3-sources.jar` 中 `io/agentscope/core/skill/**`、`io/agentscope/core/ReActAgent.java`(build() 段 4200–4280)、`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 getAllSkillNames(); +List getAllSkills(); +boolean save(List, 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 下的一个子目录,内含 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` 私有常量,大小写敏感)。 +- **目录布局**:`//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`。**目录不存在或无 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 模式下返回 false,allow-list +反而把所有技能都滤掉。**Task 4.1 resolver 必须先把裸 name 解析成 repo 里真实的 skillId 列表** +(遍历 `repo.getAllSkills()`,对 `skill.getName() ∈ allowList` 的项收集 `skill.getSkillId()`), +再 `SkillFilter.only(skillIds)`。这是 allow-list 正确工作的唯一方式。 + +空 allow-list = 不过滤 = 不调 `.skillFilter(...)`(builder 默认 `SkillFilter.all()`)。 + +### (d) `skillWorkDir` vs `skillRepository` —— 区别 + +两者**完全不同**,不可互换(实测 `ReActAgent.Builder` 字段 3548–3564 + build() 4268–4276): +- **`skillRepository(AgentSkillRepository)` / `skillRepositories(List)`**:技能来源(数据源)——告诉 DynamicSkillMiddleware 去哪里加载技能定义。**这就是"目录里有 SKILL.md 的本地技能"要用的**:`builder.skillRepository(new FileSystemSkillRepository(path))`。 +- **`skillWorkDir(Path)`**:DynamicSkillMiddleware 在加载技能时,把技能附带的支持文件(resources)上传/解压到的稳定工作目录(`SkillBox.uploadSkillFiles()` 落地点)。`null`(默认)= middleware 内部 `mkdtemp` 临时目录、JVM 退出自动清理。**它不是技能来源,不影响"加载到哪些技能"。** + +Task 4.1 的 `skills.path`(指向含 `/SKILL.md` 的目录)→ **`skillRepository(new FileSystemSkillRepository(Paths.get(path)))`**,**不**用 `skillWorkDir`。 + +### (e) `DynamicSkillMiddleware` 自动安装条件(实测 build() 4268–4276) + +```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` 语义在 resolver 层实现,分两段: +- **构造期异常**(path 不存在/非目录): + - `strict=true` → 捕获 `IllegalArgumentException`,包装成 `AgentConfigException` 抛出(快速失败,对齐 1.0 `SkillBoxFactory`)。 + - `strict=false` → 捕获、记录告警、不注册 repo(middleware 不会安装,等价禁用)。 +- **构造成功但空**(`repo.getAllSkillNames().isEmpty()`): + - `strict=true` → 抛 `AgentConfigException`("skills.enabled=true 但 path 下无可解析技能")。 + - `strict=false` → 记录告警,照常注册 repo(DynamicSkillMiddleware 运行期见空 `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.1(SkillRepositoryResolver)的指导(已采用) + +- `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 SkillTrackingMiddleware,brief 明确推迟)。 + diff --git a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReactAgentFactory.java b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReactAgentFactory.java index 7e1ec7100..555107edc 100644 --- a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReactAgentFactory.java +++ b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReactAgentFactory.java @@ -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; *
  • {@code maxIters} — {@code cmp.maxIterations() > 0 ? it : cfg.defaults.maxIterations}。
  • *
  • {@code stateStore} — {@link AgentStateStoreResolver#resolve(AgentConfig)}(可能返回 null=NONE,合法)。
  • *
  • {@code permissionContext} — {@link PermissionConfigMapper#map(AgentConfig)}(ShellConfig→v2 命令级规则,Task 3.1)。
  • + *
  • {@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。
  • * * *

    不设(留给后续 Task / RC3 无): * {@code .filesystem()/.workspace()/.compaction()/.memory(MemoryConfig)}(RC3 无 HarnessAgent 这些方法)、 - * {@code .middleware(...)(Task 5.1)、 - * {@code .skillRepository(...)(Task 4.1)。本类不引用这些协作者。 + * {@code .middleware(...)(Task 5.1)。本类不引用这些协作者。 * *

    线程安全

    * 缓存用 {@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(), + // 把这两个值透传给 resolver(resolver 在 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); diff --git a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/skill/SkillRepositoryResolver.java b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/skill/SkillRepositoryResolver.java new file mode 100644 index 000000000..1462ec93d --- /dev/null +++ b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/skill/SkillRepositoryResolver.java @@ -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})。 + * + *

    这是技能层迁移的第一块(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} 值作为显式入参传入——本类处在 {@code skill} 包(不在 {@code component} + * 包),无法直接访问 {@code protected} 钩子,故采用「工厂读钩子 → 透传给 resolver」的分层。 + * + *

    配置映射(findings R-skill,实测 RC3 源码)

    + *
      + *
    • {@code enabled=true}(工厂侧由 {@code cmp.enableSkills()} 解析,默认读 + * {@link SkillsConfig#isEnabled()},可被子类覆写): + *
        + *
      1. {@code Path p = Paths.get(skills.path)}(默认 {@code ./skills})。
      2. + *
      3. {@code AgentSkillRepository repo = new FileSystemSkillRepository(p)}。 + * {@code FileSystemSkillRepository} 构造器对 path 强校验——null/不存在/非目录 + * 抛 {@link IllegalArgumentException};空目录(无 {@code SKILL.md} 子目录)构造成功、 + * {@code getAllSkillNames()} 返回空 List。baseDir 直接是技能根,每个技能是 + * {@code //SKILL.md},技能名取自 YAML frontmatter 的 {@code name:} + * 字段(非目录名)。
      4. + *
      5. strict 校验(见下)。
      6. + *
      7. {@code builder.skillRepository(repo)} + {@code builder.dynamicSkillsEnabled(true)}。 + * v2 {@code ReActAgent.build()} 在 {@code !skillRepositories.isEmpty() && dynamicSkillsEnabled} + * 时自动安装 {@code DynamicSkillMiddleware}(无需手动 {@code .middleware(...)})—— + * 它在每个 {@code call()} 的 {@code onSystemPrompt} 钩子里加载技能、按 filter 过滤、 + * 把技能清单注入系统提示词并注册 {@code load_skill_through_path} 工具。
      8. + *
      9. {@code allowList} 非空(工厂侧由 {@code cmp.skills()} 解析)→ + * {@code builder.skillFilter(SkillFilter.only(...))};空 allow-list 不调 + * (builder 默认 {@code SkillFilter.all()})。
      10. + *
      + *
    • + *
    • {@code enabled=false}:{@code builder.dynamicSkillsEnabled(false)},不注册 + * skillRepository、不设 skillFilter。即便 {@code dynamicSkillsEnabled} 默认就是 true 且 + * 无 repo 时 v2 也不会装 middleware,这里仍显式置 false 以对齐"明确关闭技能"语义, + * 防御性地避免后续代码(目前无此路径)意外塞 repo。
    • + *
    + * + *

    strict 语义({@link SkillsConfig#isStrict()},默认 true)

    + * 对齐 1.0 {@code SkillBoxFactory} 的快速失败行为,在resolver 层实现: + *
      + *
    • 构造期异常(path 不存在/非目录,{@link FileSystemSkillRepository} 抛 + * {@link IllegalArgumentException}): + *
        + *
      • strict=true → 包装成 {@link AgentConfigException} 抛出。
      • + *
      • strict=false → 记录告警,不注册 repo(middleware 不装,等价禁用)。
      • + *
      + *
    • + *
    • 构造成功但空({@code repo.getAllSkillNames().isEmpty()}): + *
        + *
      • strict=true → 抛 {@link AgentConfigException}("skills.enabled=true 但 path 下无可解析技能")。
      • + *
      • strict=false → 记录告警,照常注册 repo(运行期 DynamicSkillMiddleware 见空 + * {@code getAllSkills()} 自然短路:不注入提示词段、不注册 load 工具)。
      • + *
      + *
    • + *
    + * + *

    范围边界(brief Task 4.1)

    + * 不做:self-learning skill loop(GA);{@code usedSkills()} 跟踪(依赖 Task 5.1 + * SkillTrackingMiddleware)。{@code SkillsAgentCmp} 的 {@code usedSkills} stub 由 Task 5.1 恢复。 + * + *

    不臆造 API

    + * 全部 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} 上。 + * + *

    {@code enabled} 与 {@code allowList} 由调用方({@code ReactAgentFactory},与 + * {@link com.yomahub.liteflow.agent.component.ReActAgentComponent} 同包)从其 + * {@code protected} 钩子 {@code enableSkills()}/{@code skills()} 读取后透传;本类不在 + * {@code component} 包,无法直接访问这两个 {@code protected} 方法。 + * + *

    幂等假设:每个 {@link ReActAgent.Builder} 仅被配置一次(由 {@code ReactAgentFactory.build} + * 单点调用)。重复调同一 builder 会累加多个 repository(v2 语义),不在本类职责内。 + * + * @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 allowList, String componentLabel, AgentConfig cfg) { + if (builder == null) { + throw new AgentConfigException("ReActAgent.Builder is null; cannot configure skills"); + } + String label = componentLabel == null || componentLabel.isBlank() ? "" : 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 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 /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 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 resolveAllowListSkillIds(AgentSkillRepository repo, + List allowList, + Path skillsPath, String label) { + List 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 allowed = new java.util.HashSet<>(allowList); + List 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; + } +} + diff --git a/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2/SkillLoadingTest.java b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2/SkillLoadingTest.java new file mode 100644 index 000000000..130638f22 --- /dev/null +++ b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2/SkillLoadingTest.java @@ -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 端到端行为测试。 + * + *

    验证 {@link SkillRepositoryResolver#configure(ReActAgent.Builder, boolean, List, String, AgentConfig)} + * 把 {@code skills.enabled=true} + {@code skills.path}(含 {@code /SKILL.md})正确映射到 + * v2 {@code skillRepository} + {@code dynamicSkillsEnabled(true)},并在 allow-list 非空时 + * 套用 {@code SkillFilter.only(...)}。 + * + *

    断言路径:{@code configure(...)} 后 {@code builder.build()} 得到的 {@link ReActAgent} + * 已由 v2 自动装上 {@code DynamicSkillMiddleware}(findings R-skill:注册 ≥1 repo + dynamicSkillsEnabled + * 即自动安装,{@code ReActAgent.build()} 实测 4268–4276)。该中间件在每次 {@code call()} 的 + * {@code onSystemPrompt} 钩子里加载 repo 技能并按 {@code SkillFilter} 过滤,把技能清单注入系统提示词。 + * 本测试用一个 {@link PromptCapturingModel}(捕获模型收到的系统提示词)+ {@code agent.call(...).block()} + * 触发该钩子,再断言提示词里包含/不含各技能名——这是不依赖真实 LLM 的稳定可观测点。 + * + *

    三组场景: + *

      + *
    1. enabled=true,空 allow-list:两个技能(research / demo)都出现在提示词。
    2. + *
    3. enabled=true,allow-list=[research]:仅 research 出现,demo 不出现。
    4. + *
    5. enabled=false:提示词不含任何技能名({@code dynamicSkillsEnabled(false)},middleware 不装)。
    6. + *
    + * + *

    另含一组 strict 语义测试:strict=true + path 不存在 → 抛 {@link AgentConfigException}; + * strict=true + 空 path → 抛;strict=false + 不存在 → 不抛、不注册(build 仍成功、提示词无技能)。 + * + *

    不构建 Spring 上下文、不调真实 LLM:直接构造 {@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; + + /* ===== 场景 1:enabled=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); + } + + /* ===== 场景 2:enabled=true,allow-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); + } + + /* ===== 场景 3:enabled=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 allowList, AgentConfig cfg) { + SkillRepositoryResolver.configure(builder, enabled, allowList, "SkillLoadingTest", cfg); + } + + /** 在 tmp 下建一个 skills 根目录,每个 name 一个子目录 + SKILL.md(YAML 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; + } + + /** 最小可构建的 builder:name + 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(...)} 收到的第一条消息(系统提示词)文本, + * 同时固定 emit 一个 stop 回复让 ReActAgent 一轮收敛。无状态、确定性。 + */ + private static final class PromptCapturingModel implements Model { + final AtomicReference capturedSystemPrompt = new AtomicReference<>(); + + @Override + public Flux stream(List messages, List 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.of(TextBlock.builder().text("[captured-ok]").build())) + .finishReason("stop") + .build(); + return Flux.just(resp); + } + + @Override + public String getModelName() { + return "prompt-capturing-mock"; + } + } +} +