推荐改用 {@link #middlewares()}(v2 洋葱模型,{@link MiddlewareBase})。
+ * 本方法保留以兼容 1.0 业务子类,自 2.16.0 标记弃用、计划移除。
+ *
+ * @deprecated 改用 {@link #middlewares()};Hook 体系在 v2 软弃用,未来版本移除。
+ */
+ @Deprecated(since = "2.16.0", forRemoval = true)
protected List 默认返回空列表。返回的 middleware 实例会随 agent 单例缓存——不要在
+ * middleware 里持有 per-invocation 状态(如累加器、已用技能集),那些应通过
+ * {@link ChatUsageMiddleware#bind()} / {@link SkillTrackingMiddleware#bind()} 等
+ * per-invocation 绑定机制管理。
+ *
+ * @since 2.16.0
+ */
+ protected List 由 {@link SkillTrackingMiddleware} 在 {@code onActing} 钩子里跟踪
+ * {@code load_skill_through_path} 工具调用得到(findings R-skill-track)。
+ * 必须在 {@code process()} 生命周期内调用;未观察到任何技能加载时返回空列表。
+ *
+ * @since 2.16.0
+ */
+ protected final List 状态:v2 迁移期。对 1.0 {@code ChatUsageTrackingHook}(已删除)的依赖临时移除,
- * {@link #getChatUsage()} 返回 {@code null};Task 5.1 会基于 v2 middleware 恢复。
- * {@link #setChatUsageTrackingHook} 暂为 no-op,签名保留以兼容历史调用点。
+ * 状态:v2(RC3)。{@link #getChatUsage()} 由 {@link ChatUsageMiddleware}
+ * (v2 middleware,替代 1.0 {@code ChatUsageTrackingHook})在 per-invocation 累加器上提供,
+ * 累加器由 {@link ReActAgentComponent#process()} 在入口 bind、出口 unbind。
*
* 勿在跨 invocation 缓存的对象中持有 {@code ReActAgentContext} 引用
* (例如自定义工具实例、Hook、Model 实现)。这些对象会被缓存的 agent 跨次复用,
@@ -64,25 +65,20 @@ public class ReActAgentContext {
this.runtimeContext = runtimeContext;
}
- /**
- * 由框架注入:本次 {@code process()} 调用使用的 token 累加 hook。
- *
- * v2 迁移期 no-op。1.0 的 {@code ChatUsageTrackingHook} 已删除,
- * 这里仅保留方法签名以兼容调用点;Task 5.1 重建后改回真实注入。
- */
- public void setChatUsageTrackingHook(Object hook) {
- // no-op: 1.0 ChatUsageTrackingHook 已删除;Task 5.1 恢复
- }
-
/**
* 返回本次 {@code process()} 截至当前已累计的 token 用量。
*
- * v2 迁移期返回 {@code null}。1.0 的 {@code ChatUsageTrackingHook}
- * 已删除,待 Task 5.1 基于 v2 middleware 重建后再恢复真实累计值。
+ * 由 {@link ChatUsageMiddleware}(v2 RC3 middleware,替代 1.0
+ * {@code ChatUsageTrackingHook})在 {@code onModelCall} 钩子里累加每次模型调用的
+ * {@code ModelCallEndEvent.getUsage()}(findings R5)。累加器是 per-invocation 的
+ * ThreadLocal,由 {@link ReActAgentComponent#process()} 在入口 {@code bind}、
+ * 出口 {@code unbind}。
*
- * @return 当前固定返回 {@code null}(Task 5.1 恢复后给出累计 ChatUsage)
+ * 必须在 {@code process()} 生命周期内调用(典型时机:{@code handleReply},
+ * 此时所有 reasoning step 已完成)。在生命周期外、或本次调用未观察到任何 usage
+ * (模型/网关未上报)时返回 {@code null}。
*/
public ChatUsage getChatUsage() {
- return null;
+ return ChatUsageMiddleware.snapshot();
}
}
diff --git a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReactAgentFactory.java b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReactAgentFactory.java
index 555107edc..40a666cc7 100644
--- a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReactAgentFactory.java
+++ b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReactAgentFactory.java
@@ -1,6 +1,9 @@
package com.yomahub.liteflow.agent.component;
import com.yomahub.liteflow.agent.exception.AgentConfigException;
+import com.yomahub.liteflow.agent.middleware.ChatUsageMiddleware;
+import com.yomahub.liteflow.agent.middleware.LoggingMiddleware;
+import com.yomahub.liteflow.agent.middleware.SkillTrackingMiddleware;
import com.yomahub.liteflow.agent.permission.PermissionConfigMapper;
import com.yomahub.liteflow.agent.skill.SkillRepositoryResolver;
import com.yomahub.liteflow.agent.state.AgentStateStoreResolver;
@@ -9,13 +12,16 @@ import com.yomahub.liteflow.agent.tool.WorkspaceFileTools;
import com.yomahub.liteflow.property.agent.AgentConfig;
import com.yomahub.liteflow.property.agent.ShellMode;
import io.agentscope.core.ReActAgent;
+import io.agentscope.core.middleware.MiddlewareBase;
import io.agentscope.core.model.Model;
import io.agentscope.core.permission.PermissionContextState;
+import io.agentscope.core.skill.repository.AgentSkillRepository;
import io.agentscope.core.state.AgentStateStore;
import io.agentscope.core.tool.Toolkit;
import java.nio.file.Path;
import java.nio.file.Paths;
+import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
@@ -164,7 +170,36 @@ public final class ReactAgentFactory {
// 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);
+ // resolver 返回注册的 repo(未启用/构造失败为 null),供 SkillTrackingMiddleware
+ // 建立 skillId→name 映射(findings R-skill-track)。
+ AgentSkillRepository skillRepo = SkillRepositoryResolver.configure(
+ builder, cmp.enableSkills(), cmp.skills(), name, cfg);
+
+ // Task 5.1:v2 middleware(替代 1.0 三个 Hook)。
+ // 顺序:Logging(最外层,包裹整个 reasoning/acting)→ ChatUsage(onModelCall 累加)→
+ // SkillTracking(onActing 跟踪 load_skill)。之后追加业务侧 cmp.middlewares()。
+ List RC3 把 token 用量挂在每次模型调用的 {@link ModelCallEndEvent#getUsage()} 上(由
+ * {@code ReActAgent} 在 {@code new ModelCallEndEvent(replyId, context.getChatUsage())}
+ * 处填充,见 findings R5)。本 middleware 覆写 {@link #onModelCall},在
+ * {@code next.apply(input)} 返回的 {@code Flux 之所以用 ThreadLocal 而非 RuntimeContext:RC3-core 下 {@code process()} 用
+ * {@code .block()} 同步执行整条 ReAct 循环,模型调用在同一线程上完成;middleware
+ * 在该线程上被调用。子类若未来引入异步流式(Task 6.1),需相应把累加器改成随
+ * reactor {@code Context} 传播——RC3-core 不在此范围内。
+ *
+ * 静态访问:累加器本身是 ThreadLocal,与具体 middleware 实例无关;故
+ * {@link com.yomahub.liteflow.agent.component.ReActAgentContext#getChatUsage()}
+ * 可直接读,无需持有 middleware 引用。
+ */
+ public static ChatUsage snapshot() {
+ Accumulator acc = BOUND.get();
+ return acc == null ? null : acc.snapshot();
+ }
+
+ @Override
+ public Flux 覆写 {@link MiddlewareBase#onReasoning} 与 {@link MiddlewareBase#onActing},
+ * 在进入时打印 {@code >>>} 概要,在下游事件流的关键事件上打印 {@code <<<} 概要。
+ * 日志格式对齐 1.0 {@code ReActLoggingHook}:
+ * {@code sessionId} 取自 {@link RuntimeContext#getSessionId()}(= agentKey),
+ * 便于在日志中区分同对话内不同 agent。无 ctx 时记 {@code "-"}。
+ *
+ * 由 {@link ReActAgentComponent#enableReActLogging()} 控制——为 false 时 factory 不注册本 middleware。
+ */
+public class LoggingMiddleware implements MiddlewareBase {
+
+ private static final Logger LOG = LoggerFactory.getLogger(LoggingMiddleware.class);
+ private static final int MAX_LEN = 500;
+
+ @Override
+ public Flux (对比 1.0:旧的 {@code SkillTrackingHook} 监听 {@code PostActingEvent},取
+ * {@code toolUse.getName()/getInput()},逻辑等价——只是 v2 把这层从 hook 挪到了 middleware。)
+ *
+ * 静态访问:已用集合本身是 ThreadLocal,与具体 middleware 实例无关;故
+ * {@link com.yomahub.liteflow.agent.component.ReActAgentComponent#usedSkills()}
+ * 可直接读,无需持有 middleware 引用。
+ */
+ public static List v2 迁移期占位(Task 2.2c):原 1.0 的 {@code usedSkills()} 已从
- * {@link com.yomahub.liteflow.agent.component.ReActAgentComponent} 移除,待 Task 4.1
- * 重建 v2 skillRepository 后恢复组件级已用 skills 的读取语义。当前 {@code handleReply}
- * 把 {@code USED_SKILLS_SNAPSHOT} 填成空 List 占位,维持 {@link SkillsFeatureTest} 编译。
+ * {@code usedSkills()} 由 {@link com.yomahub.liteflow.agent.middleware.SkillTrackingMiddleware}
+ * 在 {@code onActing} 钩子里跟踪 {@code load_skill_through_path} 工具调用得到
+ * (findings R-skill-track,Task 5.1 重建)。{@code handleReply} 里读一次快照供
+ * {@link SkillsFeatureTest} 断言。
*/
@Component("skillsAgent")
public class SkillsAgentCmp extends ReActAgentComponent {
@@ -86,10 +86,9 @@ public class SkillsAgentCmp extends ReActAgentComponent {
@Override
protected void handleReply(Msg reply) {
- // TODO(Task 4.1): 1.0 usedSkills() 已从 ReActAgentComponent 移除,待 v2 skillRepository
- // 重建后恢复组件级已用 skills 读取:
- // USED_SKILLS_SNAPSHOT.set(usedSkills());
- USED_SKILLS_SNAPSHOT.set(List.of());
+ // usedSkills() 由 SkillTrackingMiddleware 在 onActing 跟踪 load_skill_through_path
+ // 得到(Task 5.1)。在 handleReply 读一次快照——此时所有 reasoning/acting step 已完成。
+ USED_SKILLS_SNAPSHOT.set(usedSkills());
super.handleReply(reply);
}
}
diff --git a/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2/ChatUsageMiddlewareTest.java b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2/ChatUsageMiddlewareTest.java
new file mode 100644
index 000000000..c966d117e
--- /dev/null
+++ b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2/ChatUsageMiddlewareTest.java
@@ -0,0 +1,108 @@
+package com.yomahub.liteflow.test.agent.v2;
+
+import com.yomahub.liteflow.agent.middleware.ChatUsageMiddleware;
+import io.agentscope.core.event.AgentEvent;
+import io.agentscope.core.event.ModelCallEndEvent;
+import io.agentscope.core.middleware.ModelCallInput;
+import io.agentscope.core.model.ChatUsage;
+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.List;
+import java.util.function.Function;
+
+/**
+ * Task 5.1 单元测试:验证 {@link ChatUsageMiddleware} 在单次 invocation 内累加多步
+ * {@link ModelCallEndEvent#getUsage()} 的 token 用量。
+ *
+ * 纯单元测试:不构建 ReActAgent、不起 Spring、不调 LLM。直接构造
+ * {@link ChatUsageMiddleware},覆写 {@code onModelCall},用一个发出两个
+ * {@link ModelCallEndEvent}(各带不同 {@link ChatUsage})的 mock {@code next},
+ * 断言 {@link ChatUsageMiddleware#snapshot()}(通过 bind 的 invocation 累加器)= 两者之和。
+ *
+ * 关键点(findings R5):
+ * per-invocation 绑定
+ * {@code ReActAgent} 被 {@code ReactAgentFactory} 按 cmp 子类缓存为单例、跨
+ * {@code process()} 复用,不能把累加器做成实例字段直接累加——否则会把上次
+ * 调用的余量带入下一次。故累加器用 {@link ThreadLocal} 持有,{@link #bind()} 在
+ * {@code process()} 入口调用(push 新累加器),{@link #unbind()} 在出口 {@code finally}
+ * 调用(pop 并丢弃)。
+ *
+ * 读累计值
+ * {@link com.yomahub.liteflow.agent.component.ReActAgentContext#getChatUsage()} 通过
+ * {@link #snapshot()} 读当前线程绑定的累加器;未 {@code bind} 或尚未观察到任何
+ * usage 时返回 {@code null}。
+ */
+public class ChatUsageMiddleware implements MiddlewareBase {
+
+ /** per-invocation 累加器栈:bind push、unbind pop(栈结构支持嵌套,虽当前 process() 不嵌套)。 */
+ private static final ThreadLocal
+ *
+ *
+ * 探测结论(R-skill-track)
+ * RC3 暴露"技能被使用"的最干净可观测信号是 {@link MiddlewareBase#onActing} 收到的
+ * {@link ActingInput#toolCalls()}:技能加载由 {@code DynamicSkillMiddleware} 注册的
+ * {@code load_skill_through_path} 工具承担(findings R-skill + RC3 源码
+ * {@code SkillToolFactory.createSkillAccessToolAgentTool},工具名常量
+ * {@code load_skill_through_path}),其输入 schema 含 {@code skillId}(= 技能的
+ * {@code name + "_" + source},见 {@code AgentSkill.getSkillId()})。当 ReActAgent 选择
+ * 调用该工具时,{@code onActing} 的 {@code toolCalls} 里就会出现一个
+ * {@link ToolUseBlock},其 {@code getName()=="load_skill_through_path"} 且
+ * {@code getInput().get("skillId")} 为本次加载的技能 id。
+ *
+ * per-invocation 绑定
+ * {@code ReActAgent} 单例、跨 {@code process()} 复用;本 middleware 同样单例。故已用技能集
+ * 用 {@link ThreadLocal} 持有,{@link #bind()} 在 {@code process()} 入口 push、
+ * {@link #unbind()} 在出口 pop。{@link #usedSkills()} 读当前线程绑定的集合(未 bind 返回空)。
+ *
+ * skillId → name 映射
+ * 构造期从注入的 {@link AgentSkillRepository}(若有)遍历一次,建 {@code skillId → name} 表。
+ * 用户在 {@code cmp.skills()} 写的是裸 name(findings R-skill (c)),故 {@code usedSkills()}
+ * 也返回裸 name。repo 为 {@code null}(技能未启用)时映射为空,{@code load_skill_through_path}
+ * 即便被调用也只能记 skillId 原样(兜底)。
+ */
+public class SkillTrackingMiddleware implements MiddlewareBase {
+
+ /** RC3 技能加载工具的固定名({@code SkillToolFactory.createSkillAccessToolAgentTool})。 */
+ public static final String LOAD_SKILL_TOOL_NAME = "load_skill_through_path";
+ private static final String SKILL_ID_INPUT_KEY = "skillId";
+
+ /** skillId → 裸 name 映射(不可变;构造期一次性建立)。 */
+ private final Map
+ *
+ */
+class ChatUsageMiddlewareTest {
+
+ private final ChatUsageMiddleware middleware = new ChatUsageMiddleware();
+
+ @AfterEach
+ void clean() {
+ // 兜底解绑,避免 ThreadLocal 跨用例泄漏(即便测试中途断言失败)。
+ ChatUsageMiddleware.unbind();
+ }
+
+ @Test
+ void accumulatesUsageAcrossMultipleModelCallEndEvents() {
+ ChatUsage step1 = ChatUsage.builder().inputTokens(100).outputTokens(50).time(0.5).build();
+ ChatUsage step2 = ChatUsage.builder().inputTokens(200).outputTokens(80).time(1.2).build();
+
+ // bind 一个 per-invocation 累加器(模拟 process() 入口)。
+ ChatUsageMiddleware.bind();
+ try {
+ // 两次 onModelCall(模拟 ReAct 循环里的多步 reasoning)。
+ runModelCall(middleware, step1);
+ runModelCall(middleware, step2);
+
+ ChatUsage snapshot = ChatUsageMiddleware.snapshot();
+ Assertions.assertNotNull(snapshot, "累加至少一次 usage 后 snapshot 不应为 null");
+ Assertions.assertEquals(300, snapshot.getInputTokens(), "inputTokens 应为两步之和");
+ Assertions.assertEquals(130, snapshot.getOutputTokens(), "outputTokens 应为两步之和");
+ Assertions.assertEquals(430, snapshot.getTotalTokens(), "totalTokens 应为两步之和");
+ Assertions.assertEquals(1.7, snapshot.getTime(), 1e-9, "time 应为两步之和");
+ } finally {
+ ChatUsageMiddleware.unbind();
+ }
+ }
+
+ @Test
+ void snapshotNullWhenNoUsageObserved() {
+ ChatUsageMiddleware.bind();
+ try {
+ Assertions.assertNull(ChatUsageMiddleware.snapshot(),
+ "未观察到任何 ModelCallEndEvent 时 snapshot 应为 null");
+ } finally {
+ ChatUsageMiddleware.unbind();
+ }
+ }
+
+ @Test
+ void snapshotNullWhenNotBound() {
+ // 不 bind 直接读:单例跨 invocation 不应残留上次调用的累计。
+ Assertions.assertNull(ChatUsageMiddleware.snapshot(),
+ "未 bind 时 snapshot 应为 null(单例不能跨 invocation 累加)");
+ }
+
+ @Test
+ void unbindClearsAccumulator() {
+ ChatUsage step = ChatUsage.builder().inputTokens(10).outputTokens(5).time(0.1).build();
+ ChatUsageMiddleware.bind();
+ runModelCall(middleware, step);
+ Assertions.assertNotNull(ChatUsageMiddleware.snapshot());
+ ChatUsageMiddleware.unbind();
+
+ // unbind 后再读 → null(per-invocation 累加器已被摘除)。
+ Assertions.assertNull(ChatUsageMiddleware.snapshot(),
+ "unbind 后 snapshot 应为 null");
+ }
+
+ /* ----- helpers ----- */
+
+ /** 调 middleware.onModelCall,next 发出单个带 usage 的 ModelCallEndEvent,然后 block 完成订阅。 */
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ private static void runModelCall(ChatUsageMiddleware mw, ChatUsage usage) {
+ Function