feat(agent): Hook→Middleware(Logging/ChatUsage/SkillTracking)+ 恢复 ctx.getChatUsage()/usedSkills() + 旧 Hook 经 builder.hooks() 桥接

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 <noreply@anthropic.com>
This commit is contained in:
everywhere.z 2026-06-20 11:07:45 +08:00
parent 96159b9499
commit a37df41628
10 changed files with 659 additions and 32 deletions

View File

@ -508,3 +508,54 @@ Task 4.1 的 `strict` 语义在 <b>resolver 层</b>实现,分两段:
- 接入点:`ReactAgentFactory.build`,在 `.permissionContext(...)` 之后、`.build()` 之前调 `SkillRepositoryResolver.configure(builder, this, cfg)`
- **不**碰 `usedSkills()` 跟踪(依赖 Task 5.1 SkillTrackingMiddlewarebrief 明确推迟)。
---
## 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()` 段 42684276 自动安装,见 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`)。注意<b>不是</b>裸 name。
- **`path`**string技能内资源路径`SKILL.md` 表示加载技能说明。
### (b) 信号来源对比(为何选 onActing
| 信号源 | 可行性 | 评估 |
|---|---|---|
| **`MiddlewareBase.onActing``ActingInput.toolCalls()`** | ✅ 最佳 | `ActingInput` 是 record `{List<ToolUseBlock> toolCalls}`。`ToolUseBlock.getName()` 返回工具名、`getInput():Map<String,Object>` 含 `skillId`。与 1.0 `SkillTrackingHook` 监听 `PostActingEvent``toolUse.getName()/getInput()` 逻辑<b>等价</b>——v2 把这层从 hook 挪到了 middleware。在工具真正被调用前即可记录onActing 在 acting 阶段入口)。 |
| `onModelCall` 下游 Flux 里的 `ToolCallEndEvent` | 可行但绕 | `ToolCallEndEvent``toolCallName`<b>不带 input</b>(只有 `replyId/toolCallId/toolCallName`),拿不到 `skillId`。需额外用 `toolCallId` 回查,不直接。 |
| `ToolResultEndEvent` | 不合适 | 工具执行<b>之后</b>才发,且只带 `toolCallName`,无 input失败的工具调用也可能不发。 |
| `DynamicSkillMiddleware` 自发事件 | 无 | RC3 源码里 `DynamicSkillMiddleware` 只在 `onSystemPrompt` 钩子里加载技能清单/注册工具,<b>不发任何"技能被加载"事件</b>。 |
| `AgentEvent.metadata` | 无约定 | 无技能相关约定 key。 |
### (c) skillId → name 映射的建立
用户在 `cmp.skills()` 写的是<b>裸 name</b>(如 `"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.1SkillTrackingMiddleware的指导已采用
- 覆写 `onAgent/onReasoning/onActing/onModelCall` 中只覆写 **`onActing`**
```java
Flux<AgentEvent> onActing(Agent, RuntimeContext, ActingInput input, Function next) {
Set<String> 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<String>``process()` 入口 bind、出口 finally unbind。`usedSkills()` 静态读当前线程集合(未 bind 返回空 List
- 工具名常量 `LOAD_SKILL_TOOL_NAME = "load_skill_through_path"`、入参 key `skillId`(与 1.0 `SkillTrackingHook` 一致)。

View File

@ -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<Hook>)} 仍存在{@link ReactAgentFactory#build}
* 把这里返回的 hooks {@code builder.hooks(...)} 直接桥接无需 adapter
*
* <p><b>推荐改用 {@link #middlewares()}</b>v2 洋葱模型{@link MiddlewareBase}
* 本方法保留以兼容 1.0 业务子类 2.16.0 标记弃用计划移除
*
* @deprecated 改用 {@link #middlewares()}Hook 体系在 v2 软弃用未来版本移除
*/
@Deprecated(since = "2.16.0", forRemoval = true)
protected List<Hook> hooks() { return List.of(); }
/**
* 业务侧自定义 middleware 接入点v2 RC3 推荐{@link ReactAgentFactory#build} 会在
* 框架内置 middleware{@code ChatUsageMiddleware}/{@code SkillTrackingMiddleware}/
* 可选 {@code LoggingMiddleware}之后追加这里返回的 middleware
*
* <p>默认返回空列表返回的 middleware 实例会随 agent 单例缓存<b>不要</b>
* middleware 里持有 per-invocation 状态如累加器已用技能集那些应通过
* {@link ChatUsageMiddleware#bind()} / {@link SkillTrackingMiddleware#bind()}
* per-invocation 绑定机制管理
*
* @since 2.16.0
*/
protected List<MiddlewareBase> middlewares() { return List.of(); }
/**
* 返回本次 {@code process()} 截至当前已用到的技能名列表 name保持插入顺序
*
* <p> {@link SkillTrackingMiddleware} {@code onActing} 钩子里跟踪
* {@code load_skill_through_path} 工具调用得到findings R-skill-track
* 必须在 {@code process()} 生命周期内调用未观察到任何技能加载时返回空列表
*
* @since 2.16.0
*/
protected final List<String> 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());
}
}

View File

@ -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;
* <li>{@link #getWorkspaceDir()} conversationId 创建同一段对话中的多个 agent 共享</li>
* </ul>
*
* <p><b>状态v2 迁移期</b> 1.0 {@code ChatUsageTrackingHook}已删除的依赖临时移除
* {@link #getChatUsage()} 返回 {@code null}Task 5.1 会基于 v2 middleware 恢复
* {@link #setChatUsageTrackingHook} 暂为 no-op签名保留以兼容历史调用点
* <p><b>状态v2RC3</b>{@link #getChatUsage()} {@link ChatUsageMiddleware}
* v2 middleware替代 1.0 {@code ChatUsageTrackingHook} per-invocation 累加器上提供
* 累加器由 {@link ReActAgentComponent#process()} 在入口 bind出口 unbind
*
* <p><b>勿在跨 invocation 缓存的对象中持有 {@code ReActAgentContext} 引用</b>
* 例如自定义工具实例HookModel 实现这些对象会被缓存的 agent 跨次复用
@ -64,25 +65,20 @@ public class ReActAgentContext {
this.runtimeContext = runtimeContext;
}
/**
* 由框架注入本次 {@code process()} 调用使用的 token 累加 hook
*
* <p><b>v2 迁移期 no-op</b>1.0 {@code ChatUsageTrackingHook} 已删除
* 这里仅保留方法签名以兼容调用点Task 5.1 重建后改回真实注入
*/
public void setChatUsageTrackingHook(Object hook) {
// no-op: 1.0 ChatUsageTrackingHook 已删除Task 5.1 恢复
}
/**
* 返回本次 {@code process()} 截至当前已累计的 token 用量
*
* <p><b>v2 迁移期返回 {@code null}</b>1.0 {@code ChatUsageTrackingHook}
* 已删除 Task 5.1 基于 v2 middleware 重建后再恢复真实累计值
* <p> {@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
* <p>必须在 {@code process()} 生命周期内调用典型时机{@code handleReply}
* 此时所有 reasoning step 已完成在生命周期外或本次调用未观察到任何 usage
* 模型/网关未上报时返回 {@code null}
*/
public ChatUsage getChatUsage() {
return null;
return ChatUsageMiddleware.snapshot();
}
}

View File

@ -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()
// 把这两个值透传给 resolverresolver skill 无法直接访问 protected 钩子
SkillRepositoryResolver.configure(builder, cmp.enableSkills(), cmp.skills(), name, cfg);
// resolver 返回注册的 repo未启用/构造失败为 null SkillTrackingMiddleware
// 建立 skillIdname 映射findings R-skill-track
AgentSkillRepository skillRepo = SkillRepositoryResolver.configure(
builder, cmp.enableSkills(), cmp.skills(), name, cfg);
// Task 5.1v2 middleware替代 1.0 三个 Hook
// 顺序Logging最外层包裹整个 reasoning/acting ChatUsageonModelCall 累加
// SkillTrackingonActing 跟踪 load_skill之后追加业务侧 cmp.middlewares()
List<MiddlewareBase> middlewares = new ArrayList<>();
if (cmp.enableReActLogging()) {
middlewares.add(new LoggingMiddleware());
}
middlewares.add(new ChatUsageMiddleware());
middlewares.add(new SkillTrackingMiddleware(skillRepo));
List<MiddlewareBase> 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<Hook>)findings R5业务侧 cmp.hooks() 经此直接接入无需 adapter
builder.hooks(cmp.hooks());
return builder.build();
} catch (Exception e) {
throw new AgentConfigException(

View File

@ -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}
*
* <p>RC3 token 用量挂在每次模型调用的 {@link ModelCallEndEvent#getUsage()}
* {@code ReActAgent} {@code new ModelCallEndEvent(replyId, context.getChatUsage())}
* 处填充 findings R5 middleware 覆写 {@link #onModelCall}
* {@code next.apply(input)} 返回的 {@code Flux<AgentEvent>} 里订阅
* {@link ModelCallEndEvent} 并把当步 usage 累加到一个 per-invocation 累加器
*
* <h2>per-invocation 绑定</h2>
* {@code ReActAgent} {@code ReactAgentFactory} cmp 子类缓存为单例
* {@code process()} 复用<b>不能</b>把累加器做成实例字段直接累加否则会把上次
* 调用的余量带入下一次故累加器用 {@link ThreadLocal} 持有{@link #bind()}
* {@code process()} 入口调用push 新累加器{@link #unbind()} 在出口 {@code finally}
* 调用pop 并丢弃
*
* <p>之所以用 ThreadLocal 而非 RuntimeContextRC3-core {@code process()}
* {@code .block()} 同步执行整条 ReAct 循环模型调用在同一线程上完成middleware
* 在该线程上被调用子类若未来引入异步流式Task 6.1需相应把累加器改成随
* reactor {@code Context} 传播RC3-core 不在此范围内
*
* <h2>读累计值</h2>
* {@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<Accumulator> 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}
*
* <p>静态访问累加器本身是 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<AgentEvent> onModelCall(
Agent agent,
RuntimeContext ctx,
ModelCallInput input,
Function<ModelCallInput, Flux<AgentEvent>> next) {
Flux<AgentEvent> 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();
}
}
}

View File

@ -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}
*
* <p>覆写 {@link MiddlewareBase#onReasoning} {@link MiddlewareBase#onActing}
* 在进入时打印 {@code >>>} 概要在下游事件流的关键事件上打印 {@code <<<} 概要
* 日志格式对齐 1.0 {@code ReActLoggingHook}
* <ul>
* <li>reasoning 入口{@code [agent:reason] >>> model=<name> messages=<n>}</li>
* <li>reasoning 出口ModelCallEndEvent{@code [agent:reason] <<< usage=...}</li>
* <li>acting 入口{@code [agent:act] >>> tool=<name> input=<...>}</li>
* <li>acting 出口ToolResultEndEvent{@code [agent:act] <<< tool=<name>}</li>
* <li>异常{@code [agent:error] ...}</li>
* </ul>
*
* <p>{@code sessionId} 取自 {@link RuntimeContext#getSessionId()}= agentKey
* 便于在日志中区分同对话内不同 agent ctx 时记 {@code "-"}
*
* <p> {@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<AgentEvent> onReasoning(
Agent agent,
RuntimeContext ctx,
ReasoningInput input,
Function<ReasoningInput, Flux<AgentEvent>> 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<AgentEvent> onActing(
Agent agent,
RuntimeContext ctx,
ActingInput input,
Function<ActingInput, Flux<AgentEvent>> next) {
String sid = sessionId(ctx);
List<ToolUseBlock> 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)";
}
}

View File

@ -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()}
*
* <h2>探测结论R-skill-track</h2>
* 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
*
* <p>对比 1.0旧的 {@code SkillTrackingHook} 监听 {@code PostActingEvent}
* {@code toolUse.getName()/getInput()}逻辑等价只是 v2 把这层从 hook 挪到了 middleware
*
* <h2>per-invocation 绑定</h2>
* {@code ReActAgent} 单例 {@code process()} 复用 middleware 同样单例故已用技能集
* {@link ThreadLocal} 持有{@link #bind()} {@code process()} 入口 push
* {@link #unbind()} 在出口 pop{@link #usedSkills()} 读当前线程绑定的集合 bind 返回空
*
* <h2>skillId name 映射</h2>
* 构造期从注入的 {@link AgentSkillRepository}若有遍历一次 {@code skillId name}
* 用户在 {@code cmp.skills()} 写的是<b> name</b>findings R-skill (c) {@code usedSkills()}
* 也返回裸 namerepo {@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<String, String> skillIdToName;
/** per-invocation 已用技能名集合,按 bind/unbind 生命周期隔离。 */
private static final ThreadLocal<Set<String>> 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 时返回空列表
*
* <p>静态访问已用集合本身是 ThreadLocal与具体 middleware 实例无关
* {@link com.yomahub.liteflow.agent.component.ReActAgentComponent#usedSkills()}
* 可直接读无需持有 middleware 引用
*/
public static List<String> usedSkills() {
Set<String> set = BOUND.get();
if (set == null) {
return List.of();
}
synchronized (set) {
return List.copyOf(set);
}
}
@Override
public Flux<AgentEvent> onActing(
Agent agent,
RuntimeContext ctx,
ActingInput input,
Function<ActingInput, Flux<AgentEvent>> next) {
Set<String> 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<String> used) {
if (toolUse == null || !LOAD_SKILL_TOOL_NAME.equals(toolUse.getName())) {
return;
}
Map<String, Object> 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<String, String> buildSkillIdToName(AgentSkillRepository repository) {
if (repository == null) {
return Map.of();
}
Map<String, String> map = new LinkedHashMap<>();
try {
List<AgentSkill> 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);
}
}

View File

@ -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}
* 以建立 skillIdname 映射Task 5.1
* @throws AgentConfigException enabled=true strict=true 时技能路径不可用/无可解析技能
*/
public static void configure(ReActAgent.Builder builder, boolean enabled,
List<String> allowList, String componentLabel, AgentConfig cfg) {
public static AgentSkillRepository configure(ReActAgent.Builder builder, boolean enabled,
List<String> 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;
}
/**

View File

@ -14,10 +14,10 @@ import java.util.concurrent.atomic.AtomicReference;
* 验证 skills.enabled=true {@code load_skill_through_path} 工具会被注册到 Agent
* 是否真的 load 取决于模型是否选择调用工具本测试只关注工具集与组件级 skills() 过滤
*
* <p><b>v2 迁移期占位Task 2.2c</b> 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} 编译
* <p>{@code usedSkills()} {@link com.yomahub.liteflow.agent.middleware.SkillTrackingMiddleware}
* {@code onActing} 钩子里跟踪 {@code load_skill_through_path} 工具调用得到
* findings R-skill-trackTask 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);
}
}

View File

@ -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 用量
*
* <p><b>纯单元测试</b>不构建 ReActAgent不起 Spring不调 LLM直接构造
* {@link ChatUsageMiddleware}覆写 {@code onModelCall}用一个发出两个
* {@link ModelCallEndEvent}各带不同 {@link ChatUsage} mock {@code next}
* 断言 {@link ChatUsageMiddleware#snapshot()}通过 bind invocation 累加器= 两者之和
*
* <p>关键点findings R5
* <ul>
* <li>{@code onModelCall(agent, ctx, input, next)} {@code next.apply(input)} 返回
* {@code Flux<AgentEvent>}从其中订阅 {@link ModelCallEndEvent} usage</li>
* <li>agent 是单例 invocation 复用故累加器必须 per-invocation 绑定
* {@code bind}/{@code unbind}不能跨次调用累加</li>
* </ul>
*/
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 后再读 nullper-invocation 累加器已被摘除
Assertions.assertNull(ChatUsageMiddleware.snapshot(),
"unbind 后 snapshot 应为 null");
}
/* ----- helpers ----- */
/** 调 middleware.onModelCallnext 发出单个带 usage 的 ModelCallEndEvent然后 block 完成订阅。 */
@SuppressWarnings({"rawtypes", "unchecked"})
private static void runModelCall(ChatUsageMiddleware mw, ChatUsage usage) {
Function<ModelCallInput, Flux<AgentEvent>> next = input -> Flux.<AgentEvent>just(
new ModelCallEndEvent("reply-" + System.nanoTime(), usage));
Flux<AgentEvent> flux = mw.onModelCall(null, null, new ModelCallInput(
List.of(), List.of(), null, null), next);
flux.collectList().block(); // 同步消费完整个流触发累加副作用
}
}