mirror of https://gitee.com/dromara/liteFlow
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:
parent
85f679ad4a
commit
96159b9499
|
|
@ -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-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<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 模式下返回 false,allow-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` 字段 3548–3564 + build() 4268–4276):
|
||||
- **`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() 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` 语义在 <b>resolver 层</b>实现,分两段:
|
||||
- **构造期异常**(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 明确推迟)。
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}(ShellConfig→v2 命令级规则,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(),
|
||||
// 把这两个值透传给 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);
|
||||
|
|
|
|||
|
|
@ -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()} 返回空 List。baseDir 直接是技能根,每个技能是
|
||||
* {@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 → 记录告警,不注册 repo(middleware 不装,等价禁用)。</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 loop(GA);{@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 会累加多个 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<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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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()} 实测 4268–4276)。该中间件在每次 {@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=true,allow-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;
|
||||
|
||||
/* ===== 场景 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<String> 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(...)} 收到的<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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue