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:
everywhere.z 2026-06-20 11:18:12 +08:00
parent a37df41628
commit 6159af3a23
4 changed files with 239 additions and 3 deletions

View File

@ -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;

View File

@ -46,6 +46,18 @@ import java.util.function.Function;
* 用户在 {@code cmp.skills()} 写的是<b> name</b>findings R-skill (c) {@code usedSkills()}
* 也返回裸 namerepo {@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 {

View File

@ -77,8 +77,10 @@ import java.util.List;
* </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 恢复
* <b>不做</b>self-learning skill loopGA技能使用跟踪{@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}/

View File

@ -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 里有 skillname="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 无法映射时兜底记原始 skillIdrepo 为 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;
}
}