From a37df41628874af47a7f97c01048a637362aa004 Mon Sep 17 00:00:00 2001 From: "everywhere.z" Date: Sat, 20 Jun 2026 11:07:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent):=20Hook=E2=86=92Middleware=EF=BC=88?= =?UTF-8?q?Logging/ChatUsage/SkillTracking=EF=BC=89+=20=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=20ctx.getChatUsage()/usedSkills()=20+=20=E6=97=A7=20Hook=20?= =?UTF-8?q?=E7=BB=8F=20builder.hooks()=20=E6=A1=A5=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 5.1:用 v2 RC3 的 MiddlewareBase 重建 Task 0 删除的三个 1.0 Hook 能力。 新增 middleware 包(com.yomahub.liteflow.agent.middleware): - LoggingMiddleware:onReasoning/onActing 打印 reason/act/error 日志(替代 ReActLoggingHook,格式对齐 1.0),由 cmp.enableReActLogging() 控制。 - ChatUsageMiddleware:onModelCall 订阅 ModelCallEndEvent.getUsage() 累加(替代 ChatUsageTrackingHook)。per-invocation ThreadLocal 累加器,process() 入口 bind、 出口 unbind;snapshot() 静态读累计值。 - SkillTrackingMiddleware:onActing 跟踪 load_skill_through_path 工具调用(替代 SkillTrackingHook)。构造期从注入的 AgentSkillRepository 建 skillId→name 映射, per-invocation ThreadLocal 集合,usedSkills() 静态读。 探针结论(findings R-skill-track,已追加):"技能被使用"的最干净可观测信号是 onActing 的 ActingInput.toolCalls()——load_skill_through_path 工具名固定、 input.skillId 即 AgentSkill.getSkillId()。未降级,usedSkills() 真实落地。 接入与恢复: - ReActAgentComponent:process() 入口 ChatUsageMiddleware.bind() + SkillTrackingMiddleware.bind(),出口 finally unbind;新增 protected middlewares()(默认空);hooks() 标 @Deprecated(since=2.16.0, forRemoval=true), 指引改用 middlewares();新增 protected final usedSkills()。 - ReActAgentContext:getChatUsage() 恢复为 ChatUsageMiddleware.snapshot(); 删除 stub setChatUsageTrackingHook;class javadoc 从"迁移期"改为"v2(RC3)"。 - ReactAgentFactory.build:组装 [LoggingMiddleware(if enabled), ChatUsageMiddleware, SkillTrackingMiddleware(repo), ...cmp.middlewares()] 逐个 builder.middleware(), 再 builder.hooks(cmp.hooks()) 直接桥接旧 Hook(RC3 Hook 软弃用但 builder.hooks 仍存在,无需 adapter)。 - SkillRepositoryResolver.configure 签名 void→AgentSkillRepository(返回注册的 repo 供 SkillTrackingMiddleware 建映射)。 un-stub:SkillsAgentCmp.handleReply 的 USED_SKILLS_SNAPSHOT 从 List.of() stub 改回 usedSkills();ChatUsageAgentCmp/HookAgentCmp 无需改源(随核心恢复自动生效)。 校验:core compile + test-compile SUCCESS; -Dtest=ChatUsageMiddlewareTest,ProcessIntegrationTest,ShellPermissionBehaviorTest, SkillLoadingTest → 14 用例全绿;ReactAgentFactoryTest 2 用例无回归。 Co-Authored-By: Claude Fable 5 --- docs/superpowers/specs/v2-api-findings.md | 51 ++++++ .../agent/component/ReActAgentComponent.java | 48 ++++++ .../agent/component/ReActAgentContext.java | 30 ++-- .../agent/component/ReactAgentFactory.java | 37 ++++- .../agent/middleware/ChatUsageMiddleware.java | 127 +++++++++++++++ .../agent/middleware/LoggingMiddleware.java | 108 +++++++++++++ .../middleware/SkillTrackingMiddleware.java | 151 ++++++++++++++++++ .../agent/skill/SkillRepositoryResolver.java | 16 +- .../agent/feature/skills/SkillsAgentCmp.java | 15 +- .../agent/v2/ChatUsageMiddlewareTest.java | 108 +++++++++++++ 10 files changed, 659 insertions(+), 32 deletions(-) create mode 100644 liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/middleware/ChatUsageMiddleware.java create mode 100644 liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/middleware/LoggingMiddleware.java create mode 100644 liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/middleware/SkillTrackingMiddleware.java create mode 100644 liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2/ChatUsageMiddlewareTest.java diff --git a/docs/superpowers/specs/v2-api-findings.md b/docs/superpowers/specs/v2-api-findings.md index 2a0624c97..0ecda8476 100644 --- a/docs/superpowers/specs/v2-api-findings.md +++ b/docs/superpowers/specs/v2-api-findings.md @@ -508,3 +508,54 @@ Task 4.1 的 `strict` 语义在 resolver 层实现,分两段: - 接入点:`ReactAgentFactory.build`,在 `.permissionContext(...)` 之后、`.build()` 之前调 `SkillRepositoryResolver.configure(builder, this, cfg)`。 - **不**碰 `usedSkills()` 跟踪(依赖 Task 5.1 SkillTrackingMiddleware,brief 明确推迟)。 + +--- + +## R-skill-track:技能"被使用"的可观测信号(Task 5.1 探针确认) + +**来源:** 对 `agentscope-2.0.0-RC3-sources.jar` 中 `io/agentscope/core/skill/SkillToolFactory.java`、`DynamicSkillMiddleware.java`、`io/agentscope/core/middleware/ActingInput.java`、`io/agentscope/core/event/ToolCallEndEvent.java`、`io/agentscope/core/skill/AgentSkill.java`、`ReActAgent.java`(中间件链 build 段)通读(JDK 21)。结论已用于 Task 5.1 `SkillTrackingMiddleware`。 + +**结论:RC3 暴露"技能被使用"的最干净可观测信号是 `MiddlewareBase.onActing` 收到的 `ActingInput.toolCalls()`。** + +### (a) 技能加载工具的固定名 + 入参 + +技能加载由 `DynamicSkillMiddleware`(在 `ReActAgent.build()` 段 4268–4276 自动安装,见 R-skill (e))注册的内置工具承担: +- 工具名固定为 **`load_skill_through_path`**(`SkillToolFactory.createSkillAccessToolAgentTool()` 的 `getName()`,RC3 全 jar 唯一来源)。 +- 入参 schema(`getParameters()`)含两个 key: + - **`skillId`**(string):技能唯一 id,= `AgentSkill.getSkillId()` = `name + "_" + source`(如 `research_filesystem-tmpXXX_skills`)。注意不是裸 name。 + - **`path`**(string):技能内资源路径,`SKILL.md` 表示加载技能说明。 + +### (b) 信号来源对比(为何选 onActing) + +| 信号源 | 可行性 | 评估 | +|---|---|---| +| **`MiddlewareBase.onActing` 的 `ActingInput.toolCalls()`** | ✅ 最佳 | `ActingInput` 是 record `{List toolCalls}`。`ToolUseBlock.getName()` 返回工具名、`getInput():Map` 含 `skillId`。与 1.0 `SkillTrackingHook` 监听 `PostActingEvent` 取 `toolUse.getName()/getInput()` 逻辑等价——v2 把这层从 hook 挪到了 middleware。在工具真正被调用前即可记录(onActing 在 acting 阶段入口)。 | +| `onModelCall` 下游 Flux 里的 `ToolCallEndEvent` | 可行但绕 | `ToolCallEndEvent` 带 `toolCallName` 但不带 input(只有 `replyId/toolCallId/toolCallName`),拿不到 `skillId`。需额外用 `toolCallId` 回查,不直接。 | +| `ToolResultEndEvent` | 不合适 | 工具执行之后才发,且只带 `toolCallName`,无 input;失败的工具调用也可能不发。 | +| `DynamicSkillMiddleware` 自发事件 | 无 | RC3 源码里 `DynamicSkillMiddleware` 只在 `onSystemPrompt` 钩子里加载技能清单/注册工具,不发任何"技能被加载"事件。 | +| `AgentEvent.metadata` | 无约定 | 无技能相关约定 key。 | + +### (c) skillId → name 映射的建立 + +用户在 `cmp.skills()` 写的是裸 name(如 `"research"`),而 `load_skill_through_path` 的 input `skillId` 是 `name + "_" + source`。故 `SkillTrackingMiddleware` 构造期需从注入的 `AgentSkillRepository`(`ReactAgentFactory` 调 `SkillRepositoryResolver.configure` 拿到,Task 5.1 让该 configure 返回注册的 repo)遍历一次 `repo.getAllSkills()`,建 `skillId → name` 不可变表;命中则记裸 name,未命中兜底记 skillId 原样。repo 为 null(技能未启用)时映射为空,middleware 仍可注册(onActing 见非 load_skill 工具自然跳过)。 + +### (d) 对 Task 5.1(SkillTrackingMiddleware)的指导(已采用) + +- 覆写 `onAgent/onReasoning/onActing/onModelCall` 中只覆写 **`onActing`**: + ```java + Flux onActing(Agent, RuntimeContext, ActingInput input, Function next) { + Set used = BOUND.get(); // per-invocation ThreadLocal + if (used != null && input.toolCalls() != null) { + for (ToolUseBlock t : input.toolCalls()) { + if (t != null && "load_skill_through_path".equals(t.getName())) { + Object id = t.getInput() == null ? null : t.getInput().get("skillId"); + if (id != null) used.add(skillIdToName.getOrDefault(String.valueOf(id), String.valueOf(id))); + } + } + } + return next.apply(input); + } + ``` +- per-invocation 绑定同 `ChatUsageMiddleware`:`bind()`/`unbind()` 操作 ThreadLocal `Set`,`process()` 入口 bind、出口 finally unbind。`usedSkills()` 静态读当前线程集合(未 bind 返回空 List)。 +- 工具名常量 `LOAD_SKILL_TOOL_NAME = "load_skill_through_path"`、入参 key `skillId`(与 1.0 `SkillTrackingHook` 一致)。 + diff --git a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReActAgentComponent.java b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReActAgentComponent.java index e64dd0d9b..9f22d95fe 100644 --- a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReActAgentComponent.java +++ b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReActAgentComponent.java @@ -1,6 +1,8 @@ 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.SkillTrackingMiddleware; import com.yomahub.liteflow.agent.model.ModelSpec; import com.yomahub.liteflow.core.NodeComponent; import com.yomahub.liteflow.property.LiteflowConfigGetter; @@ -12,6 +14,7 @@ import io.agentscope.core.agent.RuntimeContext; import io.agentscope.core.hook.Hook; import io.agentscope.core.message.Msg; import io.agentscope.core.message.UserMessage; +import io.agentscope.core.middleware.MiddlewareBase; import io.agentscope.core.model.Model; import java.nio.file.Path; @@ -198,8 +201,47 @@ public abstract class ReActAgentComponent extends NodeComponent { protected int maxIterations() { return -1; } protected boolean enableShellTool() { return true; } protected boolean enableWorkspaceFileTools() { return true; } + + /** + * 业务侧遗留 hooks 接入点。RC3 的 {@code io.agentscope.core.hook.Hook} 软弃用但仍可编译, + * {@code ReActAgent.builder().hooks(List)} 仍存在——{@link ReactAgentFactory#build} + * 把这里返回的 hooks 经 {@code builder.hooks(...)} 直接桥接,无需 adapter。 + * + *

推荐改用 {@link #middlewares()}(v2 洋葱模型,{@link MiddlewareBase})。 + * 本方法保留以兼容 1.0 业务子类,自 2.16.0 标记弃用、计划移除。 + * + * @deprecated 改用 {@link #middlewares()};Hook 体系在 v2 软弃用,未来版本移除。 + */ + @Deprecated(since = "2.16.0", forRemoval = true) protected List hooks() { return List.of(); } + /** + * 业务侧自定义 middleware 接入点(v2 RC3 推荐)。{@link ReactAgentFactory#build} 会在 + * 框架内置 middleware({@code ChatUsageMiddleware}/{@code SkillTrackingMiddleware}/ + * 可选 {@code LoggingMiddleware})之后追加这里返回的 middleware。 + * + *

默认返回空列表。返回的 middleware 实例会随 agent 单例缓存——不要在 + * middleware 里持有 per-invocation 状态(如累加器、已用技能集),那些应通过 + * {@link ChatUsageMiddleware#bind()} / {@link SkillTrackingMiddleware#bind()} 等 + * per-invocation 绑定机制管理。 + * + * @since 2.16.0 + */ + protected List middlewares() { return List.of(); } + + /** + * 返回本次 {@code process()} 截至当前已用到的技能名列表(裸 name,保持插入顺序)。 + * + *

由 {@link SkillTrackingMiddleware} 在 {@code onActing} 钩子里跟踪 + * {@code load_skill_through_path} 工具调用得到(findings R-skill-track)。 + * 必须在 {@code process()} 生命周期内调用;未观察到任何技能加载时返回空列表。 + * + * @since 2.16.0 + */ + protected final List usedSkills() { + return SkillTrackingMiddleware.usedSkills(); + } + protected boolean enableReActLogging() { return agentConfig().getLogging().isReactEnabled(); } @@ -250,10 +292,16 @@ public abstract class ReActAgentComponent extends NodeComponent { ReActAgentContext ctx = new ReActAgentContext(slot, cid, akey, workspaceRoot(cfg, cid)); ctx.setRuntimeContext(rc); slot.setAttachment(ctxKey(), ctx); + // per-invocation 绑定 ChatUsage / Skill 累加器——agent 是单例、跨 process() 复用, + // 不能跨 invocation 累加。在入口 bind、出口 finally unbind(见 findings R5)。 + ChatUsageMiddleware.bind(); + SkillTrackingMiddleware.bind(); try { Msg reply = agent.call(List.of(new UserMessage(userPrompt())), rc).block(); handleReply(reply); } finally { + ChatUsageMiddleware.unbind(); + SkillTrackingMiddleware.unbind(); slot.removeAttachment(ctxKey()); } } diff --git a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReActAgentContext.java b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReActAgentContext.java index 535e7f5af..c19d91a55 100644 --- a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReActAgentContext.java +++ b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReActAgentContext.java @@ -1,5 +1,6 @@ package com.yomahub.liteflow.agent.component; +import com.yomahub.liteflow.agent.middleware.ChatUsageMiddleware; import com.yomahub.liteflow.slot.Slot; import io.agentscope.core.agent.RuntimeContext; import io.agentscope.core.model.ChatUsage; @@ -17,9 +18,9 @@ import java.util.Objects; *

  • {@link #getWorkspaceDir()}:按 conversationId 创建,同一段对话中的多个 agent 共享。
  • * * - *

    状态: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 middlewares = new ArrayList<>(); + if (cmp.enableReActLogging()) { + middlewares.add(new LoggingMiddleware()); + } + middlewares.add(new ChatUsageMiddleware()); + middlewares.add(new SkillTrackingMiddleware(skillRepo)); + List extra = cmp.middlewares(); + if (extra != null) { + for (MiddlewareBase mw : extra) { + if (mw != null) { + middlewares.add(mw); + } + } + } + for (MiddlewareBase mw : middlewares) { + builder.middleware(mw); + } + + // 旧 Hook 桥接:RC3 的 io.agentscope.core.hook.Hook 软弃用但 ReActAgent.builder() + // 仍提供 .hooks(List)(findings R5)。业务侧 cmp.hooks() 经此直接接入,无需 adapter。 + builder.hooks(cmp.hooks()); + return builder.build(); } catch (Exception e) { throw new AgentConfigException( 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 new file mode 100644 index 000000000..707745f20 --- /dev/null +++ b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/middleware/ChatUsageMiddleware.java @@ -0,0 +1,127 @@ +package com.yomahub.liteflow.agent.middleware; + +import io.agentscope.core.agent.Agent; +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.event.AgentEvent; +import io.agentscope.core.event.ModelCallEndEvent; +import io.agentscope.core.middleware.MiddlewareBase; +import io.agentscope.core.middleware.ModelCallInput; +import io.agentscope.core.model.ChatUsage; +import reactor.core.publisher.Flux; + +import java.util.function.Function; + +/** + * 累加单次 {@code process()} 调用内所有模型调用的 token 用量(替代 1.0 + * {@code ChatUsageTrackingHook})。 + * + *

    RC3 把 token 用量挂在每次模型调用的 {@link ModelCallEndEvent#getUsage()} 上(由 + * {@code ReActAgent} 在 {@code new ModelCallEndEvent(replyId, context.getChatUsage())} + * 处填充,见 findings R5)。本 middleware 覆写 {@link #onModelCall},在 + * {@code next.apply(input)} 返回的 {@code Flux} 里订阅 + * {@link ModelCallEndEvent} 并把当步 usage 累加到一个 per-invocation 累加器。 + * + *

    per-invocation 绑定

    + * {@code ReActAgent} 被 {@code ReactAgentFactory} 按 cmp 子类缓存为单例、跨 + * {@code process()} 复用,不能把累加器做成实例字段直接累加——否则会把上次 + * 调用的余量带入下一次。故累加器用 {@link ThreadLocal} 持有,{@link #bind()} 在 + * {@code process()} 入口调用(push 新累加器),{@link #unbind()} 在出口 {@code finally} + * 调用(pop 并丢弃)。 + * + *

    之所以用 ThreadLocal 而非 RuntimeContext:RC3-core 下 {@code process()} 用 + * {@code .block()} 同步执行整条 ReAct 循环,模型调用在同一线程上完成;middleware + * 在该线程上被调用。子类若未来引入异步流式(Task 6.1),需相应把累加器改成随 + * reactor {@code Context} 传播——RC3-core 不在此范围内。 + * + *

    读累计值

    + * {@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 BOUND = new ThreadLocal<>(); + + public ChatUsageMiddleware() { + } + + /** + * 在当前线程绑定一个新的 per-invocation 累加器。必须在 {@code process()} 入口调用, + * 出口 {@link #unbind()} 清零。 + */ + public static void bind() { + BOUND.set(new Accumulator()); + } + + /** + * 摘除当前线程绑定的累加器。必须在 {@code process()} 出口({@code finally})调用, + * 避免单例 middleware 跨 invocation 累加。 + */ + public static void unbind() { + BOUND.remove(); + } + + /** + * 返回当前线程累加器截至当前累计的 token 用量;未 bind 或未观察到任何 usage 时 + * 返回 {@code null}。 + * + *

    静态访问:累加器本身是 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 onModelCall( + Agent agent, + RuntimeContext ctx, + ModelCallInput input, + Function> next) { + Flux downstream = next.apply(input); + Accumulator acc = BOUND.get(); + if (acc == null) { + // 未 bind(例如被独立使用、或 process() 外触发的模型调用)——不累加,透传。 + return downstream; + } + // 订阅下游流:每个 ModelCallEndEvent 累加到当前线程的累加器;不影响事件本身。 + return downstream.doOnNext(event -> { + if (event instanceof ModelCallEndEvent end) { + ChatUsage usage = end.getUsage(); + if (usage != null) { + acc.add(usage); + } + } + }); + } + + /* ----- 累加器(线程不安全,仅由持有它的 ThreadLocal 保证单线程访问)----- */ + + private static final class Accumulator { + private int inputTokens; + private int outputTokens; + private double time; + private int steps; + + synchronized void add(ChatUsage usage) { + this.inputTokens += usage.getInputTokens(); + this.outputTokens += usage.getOutputTokens(); + this.time += usage.getTime(); + this.steps++; + } + + synchronized ChatUsage snapshot() { + if (steps == 0) { + return null; + } + return ChatUsage.builder() + .inputTokens(inputTokens) + .outputTokens(outputTokens) + .time(time) + .build(); + } + } +} diff --git a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/middleware/LoggingMiddleware.java b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/middleware/LoggingMiddleware.java new file mode 100644 index 000000000..51ad510af --- /dev/null +++ b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/middleware/LoggingMiddleware.java @@ -0,0 +1,108 @@ +package com.yomahub.liteflow.agent.middleware; + +import io.agentscope.core.agent.Agent; +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.event.AgentEvent; +import io.agentscope.core.event.ModelCallEndEvent; +import io.agentscope.core.event.ToolCallEndEvent; +import io.agentscope.core.event.ToolResultEndEvent; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.middleware.ActingInput; +import io.agentscope.core.middleware.MiddlewareBase; +import io.agentscope.core.middleware.ReasoningInput; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; + +import java.util.List; +import java.util.function.Function; + +/** + * 把 ReActAgent 的 reasoning / acting 阶段输出到日志(替代 1.0 {@code ReActLoggingHook})。 + * + *

    覆写 {@link MiddlewareBase#onReasoning} 与 {@link MiddlewareBase#onActing}, + * 在进入时打印 {@code >>>} 概要,在下游事件流的关键事件上打印 {@code <<<} 概要。 + * 日志格式对齐 1.0 {@code ReActLoggingHook}: + *

      + *
    • reasoning 入口:{@code [agent:reason] >>> model= messages=}
    • + *
    • reasoning 出口(ModelCallEndEvent):{@code [agent:reason] <<< usage=...}
    • + *
    • acting 入口:{@code [agent:act] >>> tool= input=<...>}
    • + *
    • acting 出口(ToolResultEndEvent):{@code [agent:act] <<< tool=}
    • + *
    • 异常:{@code [agent:error] ...}
    • + *
    + * + *

    {@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 onReasoning( + Agent agent, + RuntimeContext ctx, + ReasoningInput input, + Function> next) { + String sid = sessionId(ctx); + // ReasoningInput 不含 model 实例(model 在 onModelCall 的 ModelCallInput 里), + // 故这里只记消息数;model 名字由 ModelCallEndEvent 的后续日志间接体现。 + int msgCount = input == null || input.messages() == null ? 0 : input.messages().size(); + LOG.info("[agent:reason][{}] >>> messages={}", sid, msgCount); + + return next.apply(input) + .doOnNext(event -> { + if (event instanceof ModelCallEndEvent end) { + LOG.info("[agent:reason][{}] <<< usage={}", sid, end.getUsage()); + } + }) + .doOnError(e -> LOG.warn("[agent:error][{}] {}", sid, e.toString(), e)); + } + + @Override + public Flux onActing( + Agent agent, + RuntimeContext ctx, + ActingInput input, + Function> next) { + String sid = sessionId(ctx); + List toolCalls = input == null ? null : input.toolCalls(); + if (toolCalls != null) { + for (ToolUseBlock t : toolCalls) { + if (t == null) { + continue; + } + LOG.info("[agent:act][{}] >>> tool={} input={}", + sid, t.getName(), truncate(String.valueOf(t.getInput()))); + } + } + return next.apply(input) + .doOnNext(event -> { + if (event instanceof ToolResultEndEvent end) { + LOG.info("[agent:act][{}] <<< tool={}", sid, end.getToolCallName()); + } else if (event instanceof ToolCallEndEvent end) { + LOG.info("[agent:act][{}] <<< toolCall={}", sid, end.getToolCallName()); + } + }) + .doOnError(e -> LOG.warn("[agent:error][{}] {}", sid, e.toString(), e)); + } + + private static String sessionId(RuntimeContext ctx) { + try { + return ctx == null || ctx.getSessionId() == null ? "-" : ctx.getSessionId(); + } catch (Throwable t) { + return "-"; + } + } + + private static String truncate(String s) { + if (s == null) { + return ""; + } + s = s.replaceAll("\\s+", " ").trim(); + return s.length() <= MAX_LEN ? s : s.substring(0, MAX_LEN) + "...(truncated)"; + } +} 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 new file mode 100644 index 000000000..54d197e42 --- /dev/null +++ b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/middleware/SkillTrackingMiddleware.java @@ -0,0 +1,151 @@ +package com.yomahub.liteflow.agent.middleware; + +import io.agentscope.core.agent.Agent; +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.event.AgentEvent; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.middleware.ActingInput; +import io.agentscope.core.middleware.MiddlewareBase; +import io.agentscope.core.skill.AgentSkill; +import io.agentscope.core.skill.repository.AgentSkillRepository; +import reactor.core.publisher.Flux; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; + +/** + * 跟踪本次 {@code process()} 调用内被用到的技能(替代 1.0 {@code SkillTrackingHook}, + * 恢复 {@code ReActAgentComponent.usedSkills()})。 + * + *

    探测结论(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。 + * + *

    (对比 1.0:旧的 {@code SkillTrackingHook} 监听 {@code PostActingEvent},取 + * {@code toolUse.getName()/getInput()},逻辑等价——只是 v2 把这层从 hook 挪到了 middleware。) + * + *

    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 skillIdToName; + + /** per-invocation 已用技能名集合,按 bind/unbind 生命周期隔离。 */ + private static final ThreadLocal> BOUND = new ThreadLocal<>(); + + public SkillTrackingMiddleware(AgentSkillRepository repository) { + this.skillIdToName = buildSkillIdToName(repository); + } + + /** + * 在当前线程绑定一个新的空"已用技能"集合。{@code process()} 入口调用。 + */ + public static void bind() { + BOUND.set(Collections.synchronizedSet(new LinkedHashSet<>())); + } + + /** + * 摘除当前线程绑定的集合。{@code process()} 出口({@code finally})调用。 + */ + public static void unbind() { + BOUND.remove(); + } + + /** + * 返回当前线程已用技能名列表(保持插入顺序)。未 bind 时返回空列表。 + * + *

    静态访问:已用集合本身是 ThreadLocal,与具体 middleware 实例无关;故 + * {@link com.yomahub.liteflow.agent.component.ReActAgentComponent#usedSkills()} + * 可直接读,无需持有 middleware 引用。 + */ + public static List usedSkills() { + Set set = BOUND.get(); + if (set == null) { + return List.of(); + } + synchronized (set) { + return List.copyOf(set); + } + } + + @Override + public Flux onActing( + Agent agent, + RuntimeContext ctx, + ActingInput input, + Function> next) { + Set used = BOUND.get(); + if (used != null && input != null && input.toolCalls() != null) { + for (ToolUseBlock t : input.toolCalls()) { + recordIfSkillLoad(t, used); + } + } + return next.apply(input); + } + + private void recordIfSkillLoad(ToolUseBlock toolUse, Set used) { + if (toolUse == null || !LOAD_SKILL_TOOL_NAME.equals(toolUse.getName())) { + return; + } + Map in = toolUse.getInput(); + if (in == null) { + return; + } + Object skillId = in.get(SKILL_ID_INPUT_KEY); + if (skillId == null) { + return; + } + String id = String.valueOf(skillId); + String name = skillIdToName.get(id); + synchronized (used) { + used.add(name != null ? name : id); + } + } + + private static Map buildSkillIdToName(AgentSkillRepository repository) { + if (repository == null) { + return Map.of(); + } + Map map = new LinkedHashMap<>(); + try { + List all = repository.getAllSkills(); + if (all != null) { + for (AgentSkill s : all) { + if (s == null) { + continue; + } + map.put(s.getSkillId(), s.getName()); + } + } + } catch (Throwable ignored) { + // repo 读失败不影响主流程;映射留空,usedSkills() 兜底记 skillId。 + } + return Collections.unmodifiableMap(map); + } +} 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 1462ec93d..0be010c04 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 @@ -107,10 +107,13 @@ public final class SkillRepositoryResolver { * @param allowList 组件级 allow-list(来自 {@code cmp.skills()}),空/null = 全部可见 * @param componentLabel 组件标签(用于日志/异常消息,通常是 {@code cmp.getClass().getSimpleName()}) * @param cfg agent 配置(读 {@link AgentConfig#getSkills()}) + * @return 注册到 builder 的 {@link AgentSkillRepository};未启用或构造失败时返回 {@code null}。 + * 调用方({@code ReactAgentFactory})可把它传给 {@code SkillTrackingMiddleware} + * 以建立 skillId→name 映射(Task 5.1)。 * @throws AgentConfigException enabled=true 且 strict=true 时技能路径不可用/无可解析技能 */ - public static void configure(ReActAgent.Builder builder, boolean enabled, - List allowList, String componentLabel, AgentConfig cfg) { + public static AgentSkillRepository configure(ReActAgent.Builder builder, boolean enabled, + List allowList, String componentLabel, AgentConfig cfg) { if (builder == null) { throw new AgentConfigException("ReActAgent.Builder is null; cannot configure skills"); } @@ -118,7 +121,7 @@ public final class SkillRepositoryResolver { if (!enabled) { // 显式关闭:即便后续代码塞了 repo(目前无此路径),middleware 也不会装。 builder.dynamicSkillsEnabled(false); - return; + return null; } if (cfg == null || cfg.getSkills() == null) { // enabled 但无配置:保守按 strict 抛,避免静默装上空 middleware。 @@ -148,7 +151,7 @@ public final class SkillRepositoryResolver { LOG.warn("[SkillRepositoryResolver] strict=false, skipping invalid skill path {}: {}", skillsPath, e.getMessage()); builder.dynamicSkillsEnabled(true); - return; + return null; } catch (Exception e) { if (strict) { throw new AgentConfigException( @@ -159,7 +162,7 @@ public final class SkillRepositoryResolver { LOG.warn("[SkillRepositoryResolver] strict=false, skipping skill repository init for {}: {}", skillsPath, e.getMessage()); builder.dynamicSkillsEnabled(true); - return; + return null; } // 构造成功但空(无 SKILL.md 子目录)。 @@ -177,7 +180,7 @@ public final class SkillRepositoryResolver { LOG.warn("[SkillRepositoryResolver] strict=false, getAllSkillNames failed for {}: {}", skillsPath, e.getMessage()); builder.dynamicSkillsEnabled(true); - return; + return null; } if (skillNames == null || skillNames.isEmpty()) { if (strict) { @@ -228,6 +231,7 @@ public final class SkillRepositoryResolver { + "for component {} (no allow-list, all skills visible)", skillsPath, skillNames == null ? 0 : skillNames.size(), label); } + return repo; } /** diff --git a/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/feature/skills/SkillsAgentCmp.java b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/feature/skills/SkillsAgentCmp.java index 2d1059185..ac27ee900 100644 --- a/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/feature/skills/SkillsAgentCmp.java +++ b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/feature/skills/SkillsAgentCmp.java @@ -14,10 +14,10 @@ import java.util.concurrent.atomic.AtomicReference; * 验证 skills.enabled=true 时 {@code load_skill_through_path} 工具会被注册到 Agent。 * 是否真的 load 取决于模型是否选择调用工具;本测试只关注工具集与组件级 skills() 过滤。 * - *

    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): + *

      + *
    • {@code onModelCall(agent, ctx, input, next)} 的 {@code next.apply(input)} 返回 + * {@code Flux},从其中订阅 {@link ModelCallEndEvent} 取 usage。
    • + *
    • agent 是单例、跨 invocation 复用,故累加器必须 per-invocation 绑定 + * ({@code bind}/{@code unbind}),不能跨次调用累加。
    • + *
    + */ +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> next = input -> Flux.just( + new ModelCallEndEvent("reply-" + System.nanoTime(), usage)); + Flux flux = mw.onModelCall(null, null, new ModelCallInput( + List.of(), List.of(), null, null), next); + flux.collectList().block(); // 同步消费完整个流,触发累加副作用 + } +}