mirror of https://gitee.com/dromara/liteFlow
fix(react-agent): 补 SkillTrackingMiddleware 单测 + 修正 middleware/resolver 注释
Task 5.1 review findings 修复(不改行为逻辑,仅加测试 + 改注释/javadoc): - Important-1: 新增 SkillTrackingMiddlewareTest(6 用例,纯单元、不起 Spring/不调 LLM)。 原 e2e 只断言 USED_SKILLS_SNAPSHOT != null 且兼容 mock 模型从不发 load_skill, 导致 onActing 的"识别 load_skill → 取 skillId → 映射 name → 记 usedSkills" 记录分支在生产中从未被执行。用例覆盖正常映射/多次 load 保序/repo=null 兜底/ 非 load_skill 工具忽略/未 bind 短路/unbind 清空。 - Important-2: SkillTrackingMiddleware 类 javadoc 补"执行前记录"取舍说明 (失败的工具调用也会被记录,与 1.0 PostActing+isErrorResult 过滤不同, 因 RC3 onActing 是执行前信号,无可用的执行后钩子)。 - Minor-3: SkillRepositoryResolver 类 javadoc 删除过时的"依赖 Task 5.1 / stub" 注释(Task 5.1 已合并),改为陈述 usedSkills 跟踪已落地。 - Minor-5: ChatUsageMiddleware.Accumulator 重写误导注释——方法特意 synchronized 因 add 在 doOnNext 的模型流线程(与 bind 的 HTTP 线程不同)上触发, ThreadLocal 仅做 per-invocation 隔离,不保证单线程访问。 验证: SkillTrackingMiddlewareTest/ChatUsageMiddlewareTest/ProcessIntegrationTest → Tests run: 11, Failures: 0, Errors: 0, Skipped: 0;core compile SUCCESS。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
a37df41628
commit
6159af3a23
|
|
@ -98,7 +98,15 @@ public class ChatUsageMiddleware implements MiddlewareBase {
|
|||
});
|
||||
}
|
||||
|
||||
/* ----- 累加器(线程不安全,仅由持有它的 ThreadLocal 保证单线程访问)----- */
|
||||
/* ----- 累加器({@code add}/{@code snapshot} 特意 synchronized)-----
|
||||
* 旧注释写"线程不安全、仅由 ThreadLocal 保证单线程访问"具有误导性:实际上
|
||||
* {@link #add} 在 {@code onModelCall} 的 {@code doOnNext} 回调里被调用,该回调
|
||||
* 运行在<b>模型流所在的 reactor 调度线程</b>上——它通常与 {@code process()} 调用
|
||||
* {@code bind()} 的 HTTP 线程<b>不同</b>(流可能被 publishOn 切换线程)。因此两个方法
|
||||
* 特意加 {@code synchronized}:写端(流线程的 add)与读端(HTTP 线程的 snapshot,
|
||||
* 即 {@code ctx.getChatUsage()})之间保证可见性与互斥。ThreadLocal 仍提供
|
||||
* per-invocation 隔离(单例 agent 跨 invocation 不串),但<b>不</b>保证单线程访问。
|
||||
*/
|
||||
|
||||
private static final class Accumulator {
|
||||
private int inputTokens;
|
||||
|
|
|
|||
|
|
@ -46,6 +46,18 @@ import java.util.function.Function;
|
|||
* 用户在 {@code cmp.skills()} 写的是<b>裸 name</b>(findings R-skill (c)),故 {@code usedSkills()}
|
||||
* 也返回裸 name。repo 为 {@code null}(技能未启用)时映射为空,{@code load_skill_through_path}
|
||||
* 即便被调用也只能记 skillId 原样(兜底)。
|
||||
*
|
||||
* <h2>已知取舍:记录时机是"执行前"(onActing 入口)</h2>
|
||||
* 本 middleware 在 {@code load_skill_through_path} 工具<b>执行之前</b>记录({@code onActing}
|
||||
* 是执行前信号),故<b>无法区分 load 成功/失败</b>——失败的工具调用也会被记入
|
||||
* {@code usedSkills()}。这与 1.0 的 {@code SkillTrackingHook} 不同:后者监听
|
||||
* {@code PostActingEvent}(执行后),用 {@code isErrorResult} 过滤掉 {@code "Error:"} 结果。
|
||||
*
|
||||
* <p>RC3 的 {@code onActing} 没有干净的"执行后"钩子可拿 toolCalls({@code ToolCallEndEvent}
|
||||
* 无 input、{@code ToolResultEndEvent} 在执行后但同样无 input、{@code DynamicSkillMiddleware}
|
||||
* 不发自定义事件——findings R-skill-track 已逐一确认),故该取舍属<b>可接受</b>:load_skill
|
||||
* 失败是异常路径,统计意义不大;如未来需要严格过滤,可改成在 {@code PostActingEvent} 重建
|
||||
* load_skill 调用图谱(需 RC3 暴露对应信号)。
|
||||
*/
|
||||
public class SkillTrackingMiddleware implements MiddlewareBase {
|
||||
|
||||
|
|
|
|||
|
|
@ -77,8 +77,10 @@ import java.util.List;
|
|||
* </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 恢复。
|
||||
* <b>不做</b>:self-learning skill loop(GA)。技能使用跟踪({@code usedSkills()})已由
|
||||
* Task 5.1 的 {@link com.yomahub.liteflow.agent.middleware.SkillTrackingMiddleware} 落地——
|
||||
* 该 middleware 消费本类 {@link #configure} 返回的 {@link AgentSkillRepository} 建立
|
||||
* {@code skillId → name} 映射;{@code SkillsAgentCmp} 的 {@code usedSkills} 已恢复真实读取。
|
||||
*
|
||||
* <h2>不臆造 API</h2>
|
||||
* 全部 v2 类({@link AgentSkillRepository}/{@link FileSystemSkillRepository}/{@link SkillFilter}/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,214 @@
|
|||
package com.yomahub.liteflow.test.agent.v2;
|
||||
|
||||
import com.yomahub.liteflow.agent.middleware.SkillTrackingMiddleware;
|
||||
import io.agentscope.core.event.AgentEvent;
|
||||
import io.agentscope.core.middleware.ActingInput;
|
||||
import io.agentscope.core.message.ToolUseBlock;
|
||||
import io.agentscope.core.skill.AgentSkill;
|
||||
import io.agentscope.core.skill.repository.AgentSkillRepository;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Task 5.1 修复轮(review Important-1)单元测试:验证 {@link SkillTrackingMiddleware}
|
||||
* 在 {@code onActing} 收到 {@code load_skill_through_path} 工具调用时,能从其 input
|
||||
* 取 {@code skillId}、映射成裸 name 记入当前线程的已用技能集。
|
||||
*
|
||||
* <p><b>覆盖缺口背景</b>:原 e2e({@code SkillsFeatureTest})只断言
|
||||
* {@code USED_SKILLS_SNAPSHOT.get() != null},且兼容 mock 模型从不发出
|
||||
* {@code load_skill_through_path} 工具调用,故 {@code onActing} 的记录分支
|
||||
* 在生产中从未被执行过。本用例直接构造含 {@code load_skill} 的 {@link ActingInput}
|
||||
* 来驱动该分支。
|
||||
*
|
||||
* <p><b>纯单元测试</b>:不构建 ReActAgent、不起 Spring、不调 LLM。{@code next}
|
||||
* 用一个发空 Flux 的 mock;{@link AgentSkillRepository} 用 Mockito stub。
|
||||
*
|
||||
* <p>关键 API(实测 RC3 源码 / 反射核对):
|
||||
* <ul>
|
||||
* <li>{@link ActingInput#toolCalls()} 返回 {@code List<ToolUseBlock>};record 构造器
|
||||
* 仅一个参数 {@code (List<ToolUseBlock>)}。</li>
|
||||
* <li>{@link ToolUseBlock#getName()}/{@link ToolUseBlock#getInput()}:
|
||||
* input 是 {@code Map<String,Object>}。{@code load_skill_through_path} 的
|
||||
* input schema 含 {@code skillId}(= 技能的 {@code name + "_" + source},
|
||||
* 见 {@link AgentSkill#getSkillId()})。</li>
|
||||
* <li>{@code SkillTrackingMiddleware} 构造期遍历
|
||||
* {@link AgentSkillRepository#getAllSkills()} 建 {@code skillId → 裸 name} 表。</li>
|
||||
* </ul>
|
||||
*/
|
||||
class SkillTrackingMiddlewareTest {
|
||||
|
||||
/** onActing 记录的是执行前的 toolCalls,与 next 是否真正执行无关——next 透传即可。 */
|
||||
private static final Function<ActingInput, Flux<AgentEvent>> NOOP_NEXT =
|
||||
input -> Flux.empty();
|
||||
|
||||
private SkillTrackingMiddleware middleware;
|
||||
|
||||
@AfterEach
|
||||
void clean() {
|
||||
// 兜底解绑,避免 ThreadLocal 跨用例泄漏(即便中途断言失败)。
|
||||
SkillTrackingMiddleware.unbind();
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordsMappedSkillNameWhenLoadSkillThroughPathCalled() {
|
||||
// repo 里有 skill:name="research", source="demo-src" → skillId="research_demo-src"
|
||||
AgentSkillRepository repo = mockRepo(List.of(
|
||||
stubSkill("research", "demo-src"),
|
||||
stubSkill("summarize", "demo-src")));
|
||||
middleware = new SkillTrackingMiddleware(repo);
|
||||
|
||||
SkillTrackingMiddleware.bind();
|
||||
try {
|
||||
// onActing 收到一个 load_skill_through_path 调用,input.skillId = "research_demo-src"
|
||||
ToolUseBlock loadSkill = new ToolUseBlock(
|
||||
"call-1",
|
||||
SkillTrackingMiddleware.LOAD_SKILL_TOOL_NAME,
|
||||
Map.of("skillId", "research_demo-src"));
|
||||
runActing(middleware, new ActingInput(List.of(loadSkill)));
|
||||
|
||||
List<String> used = SkillTrackingMiddleware.usedSkills();
|
||||
Assertions.assertEquals(List.of("research"), used,
|
||||
"load_skill_through_path(input.skillId='research_demo-src') 应映射成裸 name 'research'");
|
||||
} finally {
|
||||
SkillTrackingMiddleware.unbind();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesInsertionOrderAcrossMultipleSkillLoads() {
|
||||
AgentSkillRepository repo = mockRepo(List.of(
|
||||
stubSkill("alpha", "src"),
|
||||
stubSkill("beta", "src")));
|
||||
middleware = new SkillTrackingMiddleware(repo);
|
||||
|
||||
SkillTrackingMiddleware.bind();
|
||||
try {
|
||||
ToolUseBlock first = new ToolUseBlock(
|
||||
"c1", SkillTrackingMiddleware.LOAD_SKILL_TOOL_NAME, Map.of("skillId", "beta_src"));
|
||||
ToolUseBlock second = new ToolUseBlock(
|
||||
"c2", SkillTrackingMiddleware.LOAD_SKILL_TOOL_NAME, Map.of("skillId", "alpha_src"));
|
||||
runActing(middleware, new ActingInput(List.of(first, second)));
|
||||
|
||||
// 按出现顺序去重保序
|
||||
Assertions.assertEquals(List.of("beta", "alpha"), SkillTrackingMiddleware.usedSkills(),
|
||||
"已用技能应按首次出现顺序保留");
|
||||
} finally {
|
||||
SkillTrackingMiddleware.unbind();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToRawSkillIdWhenMappingMisses() {
|
||||
// repo 为 null(技能未启用 / 映射为空):兜底记 skillId 原样
|
||||
middleware = new SkillTrackingMiddleware(null);
|
||||
|
||||
SkillTrackingMiddleware.bind();
|
||||
try {
|
||||
ToolUseBlock loadSkill = new ToolUseBlock(
|
||||
"call-x",
|
||||
SkillTrackingMiddleware.LOAD_SKILL_TOOL_NAME,
|
||||
Map.of("skillId", "orphan_unknown-src"));
|
||||
runActing(middleware, new ActingInput(List.of(loadSkill)));
|
||||
|
||||
List<String> used = SkillTrackingMiddleware.usedSkills();
|
||||
Assertions.assertEquals(List.of("orphan_unknown-src"), used,
|
||||
"skillId 无法映射时兜底记原始 skillId(repo 为 null 的场景)");
|
||||
} finally {
|
||||
SkillTrackingMiddleware.unbind();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresNonLoadSkillToolsAndLoadsWithoutSkillId() {
|
||||
AgentSkillRepository repo = mockRepo(List.of(stubSkill("research", "src")));
|
||||
middleware = new SkillTrackingMiddleware(repo);
|
||||
|
||||
SkillTrackingMiddleware.bind();
|
||||
try {
|
||||
// (a) 普通(非 load_skill)工具调用 —— 不应记录
|
||||
ToolUseBlock otherTool = new ToolUseBlock("c-a", "search_web", Map.of("q", "liteflow"));
|
||||
// (b) load_skill 但 input 无 skillId —— 不应记录
|
||||
ToolUseBlock loadNoId = new ToolUseBlock(
|
||||
"c-b", SkillTrackingMiddleware.LOAD_SKILL_TOOL_NAME, new HashMap<>());
|
||||
// (c) load_skill 但 input 为 null —— 不应记录
|
||||
ToolUseBlock loadNullInput = new ToolUseBlock(
|
||||
"c-c", SkillTrackingMiddleware.LOAD_SKILL_TOOL_NAME, null);
|
||||
runActing(middleware, new ActingInput(List.of(otherTool, loadNoId, loadNullInput)));
|
||||
|
||||
Assertions.assertTrue(SkillTrackingMiddleware.usedSkills().isEmpty(),
|
||||
"非 load_skill 工具 / load_skill 但缺 skillId 都不应记录");
|
||||
} finally {
|
||||
SkillTrackingMiddleware.unbind();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void noOpWhenNotBound() {
|
||||
AgentSkillRepository repo = mockRepo(List.of(stubSkill("research", "src")));
|
||||
middleware = new SkillTrackingMiddleware(repo);
|
||||
|
||||
// 不 bind 直接读:单例跨 invocation 不应残留上次调用的集合。
|
||||
Assertions.assertTrue(SkillTrackingMiddleware.usedSkills().isEmpty(),
|
||||
"未 bind 时 usedSkills() 应为空(单例不能跨 invocation 累加)");
|
||||
|
||||
// 即便未 bind 时 onActing 被调用,BOUND.get()==null 分支也应安全短路(不 NPE)。
|
||||
ToolUseBlock loadSkill = new ToolUseBlock(
|
||||
"c-nb", SkillTrackingMiddleware.LOAD_SKILL_TOOL_NAME, Map.of("skillId", "research_src"));
|
||||
Flux<AgentEvent> flux = middleware.onActing(null, null,
|
||||
new ActingInput(List.of(loadSkill)), NOOP_NEXT);
|
||||
flux.collectList().block();
|
||||
|
||||
Assertions.assertTrue(SkillTrackingMiddleware.usedSkills().isEmpty(),
|
||||
"未 bind 时 onActing 应短路、不记录");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unbindClearsRecordedSkills() {
|
||||
AgentSkillRepository repo = mockRepo(List.of(stubSkill("research", "src")));
|
||||
middleware = new SkillTrackingMiddleware(repo);
|
||||
|
||||
SkillTrackingMiddleware.bind();
|
||||
ToolUseBlock loadSkill = new ToolUseBlock(
|
||||
"c1", SkillTrackingMiddleware.LOAD_SKILL_TOOL_NAME, Map.of("skillId", "research_src"));
|
||||
runActing(middleware, new ActingInput(List.of(loadSkill)));
|
||||
Assertions.assertEquals(List.of("research"), SkillTrackingMiddleware.usedSkills());
|
||||
|
||||
SkillTrackingMiddleware.unbind();
|
||||
Assertions.assertTrue(SkillTrackingMiddleware.usedSkills().isEmpty(),
|
||||
"unbind 后 usedSkills() 应为空(per-invocation 集合已被摘除)");
|
||||
}
|
||||
|
||||
/* ----- helpers ----- */
|
||||
|
||||
/** 调 middleware.onActing 并同步消费完整个流(触发记录副作用)。 */
|
||||
private static void runActing(SkillTrackingMiddleware mw, ActingInput input) {
|
||||
Flux<AgentEvent> flux = mw.onActing(null, null, input, NOOP_NEXT);
|
||||
flux.collectList().block();
|
||||
}
|
||||
|
||||
/** 构造一个 stub {@link AgentSkill}(name + source → skillId = name_source)。 */
|
||||
private static AgentSkill stubSkill(String name, String source) {
|
||||
AgentSkill skill = mock(AgentSkill.class);
|
||||
when(skill.getName()).thenReturn(name);
|
||||
when(skill.getSource()).thenReturn(source);
|
||||
when(skill.getSkillId()).thenReturn(name + "_" + source);
|
||||
return skill;
|
||||
}
|
||||
|
||||
/** 构造一个 stub {@link AgentSkillRepository},{@code getAllSkills()} 返回给定列表。 */
|
||||
private static AgentSkillRepository mockRepo(List<AgentSkill> skills) {
|
||||
AgentSkillRepository repo = mock(AgentSkillRepository.class);
|
||||
when(repo.getAllSkills()).thenReturn(skills);
|
||||
return repo;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue