diff --git a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/middleware/ChatUsageMiddleware.java b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/middleware/ChatUsageMiddleware.java index 707745f20..f771c7417 100644 --- a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/middleware/ChatUsageMiddleware.java +++ b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/middleware/ChatUsageMiddleware.java @@ -98,7 +98,15 @@ public class ChatUsageMiddleware implements MiddlewareBase { }); } - /* ----- 累加器(线程不安全,仅由持有它的 ThreadLocal 保证单线程访问)----- */ + /* ----- 累加器({@code add}/{@code snapshot} 特意 synchronized)----- + * 旧注释写"线程不安全、仅由 ThreadLocal 保证单线程访问"具有误导性:实际上 + * {@link #add} 在 {@code onModelCall} 的 {@code doOnNext} 回调里被调用,该回调 + * 运行在模型流所在的 reactor 调度线程上——它通常与 {@code process()} 调用 + * {@code bind()} 的 HTTP 线程不同(流可能被 publishOn 切换线程)。因此两个方法 + * 特意加 {@code synchronized}:写端(流线程的 add)与读端(HTTP 线程的 snapshot, + * 即 {@code ctx.getChatUsage()})之间保证可见性与互斥。ThreadLocal 仍提供 + * per-invocation 隔离(单例 agent 跨 invocation 不串),但保证单线程访问。 + */ private static final class Accumulator { private int inputTokens; diff --git a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/middleware/SkillTrackingMiddleware.java b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/middleware/SkillTrackingMiddleware.java index 54d197e42..7df262354 100644 --- a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/middleware/SkillTrackingMiddleware.java +++ b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/middleware/SkillTrackingMiddleware.java @@ -46,6 +46,18 @@ import java.util.function.Function; * 用户在 {@code cmp.skills()} 写的是裸 name(findings R-skill (c)),故 {@code usedSkills()} * 也返回裸 name。repo 为 {@code null}(技能未启用)时映射为空,{@code load_skill_through_path} * 即便被调用也只能记 skillId 原样(兜底)。 + * + *

已知取舍:记录时机是"执行前"(onActing 入口)

+ * 本 middleware 在 {@code load_skill_through_path} 工具执行之前记录({@code onActing} + * 是执行前信号),故无法区分 load 成功/失败——失败的工具调用也会被记入 + * {@code usedSkills()}。这与 1.0 的 {@code SkillTrackingHook} 不同:后者监听 + * {@code PostActingEvent}(执行后),用 {@code isErrorResult} 过滤掉 {@code "Error:"} 结果。 + * + *

RC3 的 {@code onActing} 没有干净的"执行后"钩子可拿 toolCalls({@code ToolCallEndEvent} + * 无 input、{@code ToolResultEndEvent} 在执行后但同样无 input、{@code DynamicSkillMiddleware} + * 不发自定义事件——findings R-skill-track 已逐一确认),故该取舍属可接受:load_skill + * 失败是异常路径,统计意义不大;如未来需要严格过滤,可改成在 {@code PostActingEvent} 重建 + * load_skill 调用图谱(需 RC3 暴露对应信号)。 */ public class SkillTrackingMiddleware implements MiddlewareBase { 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 index 0be010c04..ce88460bd 100644 --- 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 @@ -77,8 +77,10 @@ import java.util.List; * * *

范围边界(brief Task 4.1)

- * 不做:self-learning skill loop(GA);{@code usedSkills()} 跟踪(依赖 Task 5.1 - * SkillTrackingMiddleware)。{@code SkillsAgentCmp} 的 {@code usedSkills} stub 由 Task 5.1 恢复。 + * 不做: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} 已恢复真实读取。 * *

不臆造 API

* 全部 v2 类({@link AgentSkillRepository}/{@link FileSystemSkillRepository}/{@link SkillFilter}/ diff --git a/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2/SkillTrackingMiddlewareTest.java b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2/SkillTrackingMiddlewareTest.java new file mode 100644 index 000000000..d42fdfd48 --- /dev/null +++ b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2/SkillTrackingMiddlewareTest.java @@ -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 记入当前线程的已用技能集。 + * + *

覆盖缺口背景:原 e2e({@code SkillsFeatureTest})只断言 + * {@code USED_SKILLS_SNAPSHOT.get() != null},且兼容 mock 模型从不发出 + * {@code load_skill_through_path} 工具调用,故 {@code onActing} 的记录分支 + * 在生产中从未被执行过。本用例直接构造含 {@code load_skill} 的 {@link ActingInput} + * 来驱动该分支。 + * + *

纯单元测试:不构建 ReActAgent、不起 Spring、不调 LLM。{@code next} + * 用一个发空 Flux 的 mock;{@link AgentSkillRepository} 用 Mockito stub。 + * + *

关键 API(实测 RC3 源码 / 反射核对): + *

+ */ +class SkillTrackingMiddlewareTest { + + /** onActing 记录的是执行前的 toolCalls,与 next 是否真正执行无关——next 透传即可。 */ + private static final Function> 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 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 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 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 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 skills) { + AgentSkillRepository repo = mock(AgentSkillRepository.class); + when(repo.getAllSkills()).thenReturn(skills); + return repo; + } +}