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 源码 / 反射核对):
+ *
+ * - {@link ActingInput#toolCalls()} 返回 {@code List};record 构造器
+ * 仅一个参数 {@code (List)}。
+ * - {@link ToolUseBlock#getName()}/{@link ToolUseBlock#getInput()}:
+ * input 是 {@code Map}。{@code load_skill_through_path} 的
+ * input schema 含 {@code skillId}(= 技能的 {@code name + "_" + source},
+ * 见 {@link AgentSkill#getSkillId()})。
+ * - {@code SkillTrackingMiddleware} 构造期遍历
+ * {@link AgentSkillRepository#getAllSkills()} 建 {@code skillId → 裸 name} 表。
+ *
+ */
+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;
+ }
+}