refactor(agent): v2 绿编译基底(删 v2-broken 源码 + stub process)+ API 探针发现笔记

This commit is contained in:
everywhere.z 2026-06-19 22:35:31 +08:00
parent e3220a4977
commit 2d3bace328
23 changed files with 549 additions and 1734 deletions

View File

@ -0,0 +1,208 @@
# v2 API 探针发现笔记agentscope 2.0.0-RC3
**生成自:** Task 0 `V2ApiProbe``liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2spike/V2ApiProbe.java`),针对 **agentscope `2.0.0-RC3`** 真实 jar 实测通过6/6 测试绿。JDK 21.0.2。
**适用规则:** 后续每个 Task 凡用到本笔记列出的 API**必须与笔记核对**;若笔记与计划代码冲突,**以笔记为准**并回填修正计划。
---
## 0. 关键修正v2 没有 `HarnessAgent`,核心 agent 仍是 `ReActAgent`
迁移 spec`2026-06-19-agentscope-v2-migration-design.md`)的 R1/R2/R3 及多处描述基于"v2 引入 `HarnessAgent`"的**推测**。**实测 RC3 中 `io.agentscope.core.HarnessAgent` 不存在**——agentscope core jar 的核心 agent 类仍是:
```
io.agentscope.core.ReActAgent (extends AgentBase implements AutoCloseable)
```
`Harness*` token 只出现在 sandbox 扩展的 Jackson module 命名(`KubernetesHarnessSandboxJacksonModule` 等),与 agent 编排无关。
**影响(重大):**
- Task 2.1/2.2/2.3 凡是写 `HarnessAgent.builder()` / `HarnessAgentFactory` 的,**全部改为 `ReActAgent.builder()`**。
- spec 的 `RuntimeContext` 引用是对的:`io.agentscope.core.agent.RuntimeContext` 确实存在并被 `ReActAgent` 使用。
- `ReActAgent.builder()` 暴露 `.stateStore(...)`、`.middleware(...)`、`.middlewares(...)`、`.permissionContext(...)`、`.skillRepository(...)`、`.longTermMemory(...)`、`.knowledge(...)`、`.ragMode(...)` 等 v2 能力,**直接就在核心 agent 的 builder 上**,不需要换类。
`ReActAgent.Builder` 全量方法(实测):
`name, description, checkRunning, sysPrompt, model(Model)|model(String modelId), toolkit, maxIters, hook(Hook), hooks(List<Hook>), middleware(MiddlewareBase), middlewares(List), enableMetaTool, enableTaskList(×2), enablePendingToolRecovery, modelExecutionConfig, toolExecutionConfig, generateOptions, toolExecutionContext, stateStore(AgentStateStore), defaultSessionId, maxRetries, fallbackModel(×2), stopOnReject, permissionContext(PermissionContextState), longTermMemory, longTermMemoryMode, longTermMemoryAsyncRecord, knowledge, knowledges, ragMode, retrieveConfig, skillBox(SkillBox), skillRepository(AgentSkillRepository), skillRepositories(List), skillFilter, dynamicSkillsEnabled, skillCodeExecutionEnabled, skillWorkDir(Path)`,工厂 `ReActAgent.builder()` / `Builder.fromAgent(agent)`
`ReActAgent` 关键执行方法(实测):`Mono<Msg> call(List<Msg>)`、`call(List<Msg>, Class<?> structuredOutputClass, RuntimeContext)`、`call(List<Msg>, JsonNode outputSchema, RuntimeContext)`、`Flux<Event> stream(List<Msg>, StreamOptions, RuntimeContext)`、`Flux<AgentEvent> streamEvents(List<Msg>|Msg [, RuntimeContext])`、`interrupt(×多种)`、`getToolkit/getSysPrompt/getModel/getMaxIters/getGenerateOptions/getAgentState(×3 重载)/getRuntimeContext/setPermissionMode(×2)/getPermissionMode/saveAgentState(×2)/getStateStore/getDefaultSessionId/getModelConfig/getReactConfig/getPermissionEngine/getPermissionContext`。
---
## R1AgentStateStore —— NONE 语义 + 开箱即用实现
**结论(实测):**
- `io.agentscope.core.state.AgentStateStore`interface
- 开箱即用实现:`io.agentscope.core.state.InMemoryAgentStateStore`、`io.agentscope.core.state.JsonFileAgentStateStore(Path)`。
- **没有 `NoOpAgentStateStore` / `NullAgentStateStore` 类。**
- **NONE不持久化的官方做法 = 在 `ReActAgent.builder()` 上不调用 `.stateStore(...)`**。实测 `Builder.stateStore` 字段默认 `null``ReActAgent` 构造期对 `stateStore` 全部用 `if (stateStore != null)` 守卫——为 `null` 时 save/load/exists 全部短路跳过,等价于 NONE。
**AgentStateStore 接口签名(实测):**
```java
void save(String userId, String sessionId, String key, State value);
void save(String userId, String sessionId, String key, List<? extends State> values);
<T extends State> Optional<T> get(String userId, String sessionId, String key, Class<T> type);
<T extends State> List<T> getList(String userId, String sessionId, String key, Class<T> itemType);
boolean exists(String userId, String sessionId);
void delete(String userId, String sessionId);
default void delete(String userId, String sessionId, String key); // 默认 no-op
Set<String> listSessionIds(String userId);
default void close(); // 默认 no-op
```
- **slot 寻址是 `(userId, sessionId)` 对**,不是 1.0 的 `AgentSession.cacheKey`/workspace 目录。`userId` 可空(`null` = 匿名/单租户)。实现自决定如何拼存储 key。
- `JsonFileAgentStateStore``save(List)` 增量 append`InMemoryAgentStateStore``save(List)` 全量替换。调用方始终传完整 list。
- `State` 是 marker 接口(`io.agentscope.core.state.State``AgentState`、`Task`、`ToolkitState`、`ToolContextState`、`PlanModeContextState`、`SessionInfo` 等是其实现。
**对 Task 2.1AgentStateStoreResolver的指导** NONE 模式直接返回 `null`resolver 别强求一个 NoOp 类JVM 模式 = `new InMemoryAgentStateStore()`LOCAL_FILE 模式 = `new JsonFileAgentStateStore(Path)`REDIS/MYSQL 模式从对应 optional 扩展加载。**Resolver 的入参语义要从 1.0 的 `(conversationId, agentKey, workspaceDir)` 迁到 `(userId, sessionId)`**——建议 `userId = conversationId`、`sessionId = agentKey`(或组合),具体由 Task 2.x 决定。
---
## R4各 vendor `XxxChatModel.builder()` 方法名(名字有分歧!)
**全部位于 `io.agentscope.core.model` 包**(不在独立 vendor jar都在 core 里)。`public static Builder builder()` 工厂每个都有。**关键差异stream 标志与 generateOptions 的方法名因 vendor 不同**。
| 方法语义 | OpenAIChatModel | AnthropicChatModel | GeminiChatModel | DashScopeChatModel |
|---|---|---|---|---|
| apiKey | `apiKey(String)` | `apiKey(String)` | `apiKey(String)` | `apiKey(String)` |
| modelName | `modelName(String)` | `modelName(String)` | `modelName(String)` | `modelName(String)` |
| baseUrl | `baseUrl(String)` | `baseUrl(String)` | `baseUrl(String)` | `baseUrl(String)` |
| **stream 开关** | `stream(boolean)` | `stream(boolean)` | **`streamEnabled(boolean)`** | `stream(boolean)` |
| **GenerateOptions** | `generateOptions(GenerateOptions)` | **`defaultOptions(GenerateOptions)`** | **`defaultOptions(GenerateOptions)`** | **`defaultOptions(GenerateOptions)`** |
| formatter | `formatter(...)` | `formatter(AnthropicBaseFormatter)` | `formatter(...)` | `formatter(...)` |
各 vendor builder 额外方法(实测):
- **OpenAIChatModel**`apiKey, baseUrl, endpointPath, formatter, generateOptions, httpTransport, modelName, proxy, stream`。
- **AnthropicChatModel**`apiKey, baseUrl, defaultOptions, formatter, modelName, proxy, stream`。
- **GeminiChatModel**`apiKey, baseUrl, clientOptions, credentials, defaultOptions, formatter, httpOptions, location, modelName, project, proxy, streamEnabled, vertexAI`。
- **DashScopeChatModel**`apiKey, baseUrl, defaultOptions, enableEncrypt, enableSearch, enableThinking, endpointType, formatter, httpTransport, modelName, proxy, stream`。
**对 Task各 vendor `*Spec.resolve()` 重写)的指导:**
- OpenAISpec/OpenAICompatibleSpec 继续用 `generateOptions(...)` + `stream(...)`
- AnthropicSpec / DashScopeSpec / GeminiSpec **必须改用 `defaultOptions(...)`**
- **GeminiSpec 的 stream 改用 `streamEnabled(...)`**,不是 `stream(...)`
- `GenerateOptions` 类位置不变:`io.agentscope.core.model.GenerateOptions`。
`ChatModelBase``io.agentscope.core.model.ChatModelBase`abstract implements Model是这些的公共基类但 builder 是各 vendor 自带的(无共享 builder 基类)。
---
## R5ChatUsage 取值 + MiddlewareBase 模型调用回调
**ChatUsage 取值实测3 条路径):**
- **`io.agentscope.core.message.Msg.getChatUsage()``ChatUsage`**lazy从 message metadata key `MessageMetadataKeys.CHAT_USAGE` 解析;找不到返回 null。`Msg.getUsage()` 也存在(直接字段 getter可能 null
- **`io.agentscope.core.model.ChatResponse.getUsage()``ChatUsage`**(用于非流式 `ChatResponse`)。
- **`io.agentscope.core.event.ModelCallEndEvent.getUsage()``ChatUsage`**(流式事件路径,由 `ReActAgent``new ModelCallEndEvent(replyId, context.getChatUsage())` 处填充)。
- metadata key 常量:`io.agentscope.core.message.MessageMetadataKeys.CHAT_USAGE`(实测存在)。
**`ChatUsage``io.agentscope.core.model.ChatUsage`)签名:**
`getInputTokens():int`、`getOutputTokens():int`、`getTotalTokens():int`、`getTime():double`(累计推理耗时秒);`builder().inputTokens(int).outputTokens(int).time(double).build()`。
**MiddlewareBase 模型调用回调(实测):**
```java
// io.agentscope.core.middleware.MiddlewareBase (interface)
Flux<AgentEvent> onModelCall(
Agent agent, // io.agentscope.core.agent.Agent
RuntimeContext ctx, // io.agentscope.core.agent.RuntimeContext
ModelCallInput input, // 见下
Function<ModelCallInput, Flux<AgentEvent>> next); // 调下层/真实模型
// 默认实现直接 next.apply(input)。
```
`io.agentscope.core.middleware.ModelCallInput`record组件`messages:List<Msg>, tools:List<ToolSchema>, options:GenerateOptions, model:Model`。
**MiddlewareBase 的全套钩子实测onion 模式 4 个 + pipeline 1 个):**
- `onAgent(agent, ctx, AgentInput, next)` — 整个 agent 调用。
- `onReasoning(agent, ctx, ReasoningInput, next)` — reasoning 阶段LLM 调用 + 流式解析)。
- `onActing(agent, ctx, ActingInput, next)` — 工具执行阶段。
- `onModelCall(agent, ctx, ModelCallInput, next)`**原始模型 API 调用**(拿 usage 的最底层钩子)。
- `onSystemPrompt(agent, ctx, currentPrompt):Mono<String>` — 系统提示词变换pipeline
- 相关 input 类型:`AgentInput`、`ReasoningInput`、`ActingInput`、`ModelCallInput`(都在 `io.agentscope.core.middleware`)。
**对 Task 5.1Hook→Middleware的指导**
- 1.0 的 `ChatUsageTrackingHook`(已删)用 v2 middleware 重建为:一个 `MiddlewareBase`,覆写 `onModelCall`,在 `next.apply(input)` 返回的 `Flux<AgentEvent>` 里订阅 `ModelCallEndEvent` 并累加 `getUsage()`(或从 `AgentEvent.getUsage()` 取)。
- `ReActAgentContext.getChatUsage()` 当前返回 nullTask 0 stubTask 5.1 恢复为从该 middleware 读累计值。
- 1.0 的 `Hook``io.agentscope.core.hook.Hook`)在 v2 **软弃用但保留可编译**core 编译时会有 `@Deprecated(since="2.0.0")` removal 警告)。`ReActAgentComponent.hooks()` 签名保留 `List<Hook>`;新建 middleware 走 `.middleware(...)`,老 hook 走 `.hook(...)`/`.hooks(...)`。
---
## R6Permission 规则形态(命令级白/黑名单是原生支持)
**全部位于 `io.agentscope.core.permission`。**
**`PermissionMode`enum** `DEFAULT("default")`、`ACCEPT_EDITS("accept_edits")`、`EXPLORE("explore")`、`BYPASS("bypass")`、`DONT_ASK("dont_ask")`。带 `getValue()`、`static fromString(String)`case-insensitive
**`PermissionBehavior`enum** `ALLOW("allow")`、`DENY("deny")`、`ASK("ask")`、`PASSTHROUGH("passthrough")`。同样带 `getValue()` / `fromString(String)`
**`PermissionRule`record**
```java
public record PermissionRule(
String toolName, // @JsonProperty("tool_name")
String ruleContent, // @JsonProperty("rule_content") —— 匹配表达式,如 "command =~ '^ls .*'"
PermissionBehavior behavior,
String source) // 来源标签,如 "config" / "user"
// 构造器非空校验 toolName/behavior/source
```
**`PermissionContextState`final classbuilder 构造):**
```java
PermissionContextState.builder()
.mode(PermissionMode)
.addWorkingDirectory(String key, AdditionalWorkingDirectory dir)
.addAllowRule(String toolName, PermissionRule rule) // ← 命令级白名单
.addDenyRule(String toolName, PermissionRule rule) // ← 命令级黑名单
.addAskRule(String toolName, PermissionRule rule) // ← 命令级询问
.build();
// getters: getMode()、isTrivial()、getWorkingDirectories()、
// getAllowRules():Map<String,List<PermissionRule>>、getDenyRules()、getAskRules()
// withMode(PermissionMode) 返回新实例
```
**`PermissionEngine(PermissionContextState)`**
- `checkPermission(ToolBase tool, Map<String,Object> toolInput):Mono<PermissionDecision>` —— 实际裁决入口。
- `getContext()`、`addRule(PermissionRule)`、`getAllowRules/getDenyRules/getAskRules`。
- 通过 `ReActAgent.getPermissionEngine()` / `getPermissionContext()` 读回。
**`PermissionDecision`final class** `getBehavior():PermissionBehavior`、`getMessage()`、`getDecisionReason()`、`getUpdatedInput()`、`getSuggestedRules()`;静态工厂 `allow/deny/ask/passthrough(String)`builder 同名。
**结论:** "命令名 ∈ 白名单才允许 execute"这种细粒度规则**原生支持**——通过 `PermissionContextState.Builder.addAllowRule(toolName, new PermissionRule(toolName, "<matcher>", ALLOW, source))`。无需自写。
**对 Task 3.1PermissionConfigMapper的指导** 把 `liteflow.agent.shell.allowedCommands` / `blockedCommands` 配置翻译成:
- mode`DEFAULT`(白名单生效)或按 config 选。
- 每条允许的命令:`addAllowRule("shell", new PermissionRule("shell", "command IN ['ls','cat']", ALLOW, "config"))`。
- 每条拒绝的命令:`addDenyRule("shell", new PermissionRule("shell", "command == 'rm'", DENY, "config"))`。
- `ruleContent` 的 matcher 语法由 `PermissionEngine` 实现(实测 OpenAI 风格 DSL建议在 Task 3.1 spike matcher 精确语法)。
---
## R7JDK 21 baseline
**结论(实测):** agentscope core 2.0.0-RC3 + 所声明的 4 个扩展redis/mysql/oss/skill-git-repository**JDK 21.0.2** 下全部解析成功;`liteflow-react-agent-core` 干净编译(`mvn -pl .../liteflow-react-agent-core -am clean compile` → **BUILD SUCCESS**`V2ApiProbe` 6/6 测试绿。baseline 成立。
---
## 附加1.0 streaming API 在 v2 仍存在但 `@Deprecated`
`V2ApiProbe.r7` 实测确认(对 Task 6.1 AgentEventBridge 至关重要):
- **`io.agentscope.core.agent.EventType`**enum`@Deprecated(since="2.0.0")`):值 `REASONING`、`TOOL_RESULT`、`HINT`、`AGENT_RESULT`、`SUMMARY`1.0 `ReActAgentComponent` 用过的 4 个都在)。
- **`io.agentscope.core.agent.Event`**class`@Deprecated`):构造器 `Event(EventType, Msg, boolean isLast)` 仍在;`getType()`、`getMessage()`、`isLast()`、`getSource()`、`getMessageId()`。
- **`io.agentscope.core.agent.StreamOptions`**class未标 deprecated 但配套 1.0 路径):`builder().eventTypes(EventType...).incremental(boolean)...``defaults()`。
- **`ReActAgent.stream(List<Msg>, StreamOptions, RuntimeContext):Flux<Event>`** 仍可用deprecated 路径)。
- **新 API推荐** `ReActAgent.streamEvents(List<Msg>|Msg [, RuntimeContext]):Flux<AgentEvent>` + `io.agentscope.core.event.AgentEventType``AGENT_START, AGENT_END, AGENT_RESULT, MODEL_CALL_START, MODEL_CALL_END, TEXT_BLOCK_*, THINKING_BLOCK_*, DATA_BLOCK_*, TOOL_CALL_*, TOOL_RESULT_*` 等)+ `io.agentscope.core.event.AgentEvent`abstracttyped subclasses`ModelCallEndEvent`、`TextBlockDeltaEvent`、`ToolResultEndEvent` 等)。
**对 Task 6.1AgentEventBridge的指导** 可选两条路:
1. **过渡路(最小改动):** 继续用 deprecated `stream(List<Msg>, StreamOptions)` + `EventType`→FlowEvent 映射1.0 映射逻辑基本复用)。短期可行,但消费 deprecated API未来版本可能真删。
2. **新 API 路(推荐):** 改用 `streamEvents(...)` + `AgentEventType`→FlowEvent 映射拿到更细粒度事件model_call_end 带 usage、text_block_delta 真流式等。Task 6.1 决策时权衡。
---
## 探针运行说明(偏离记录)
`V2ApiProbe``liteflow-testcase-el/liteflow-testcase-el-react-agent` 模块下的 JUnit5 测试。但 Task 0 删除 v2-broken 源类后,**同一测试模块内既有的 feature 测试**`ShellToolsAgentCmp` / `WorkspaceToolsAgentCmp` / `SkillsAgentCmp` 等)引用了已删的 `ManagedShellCommandTool` / `WorkspaceFileTools` / `usedSkills()`,导致该模块 **test-compile 失败**brief Step 6 给的 `mvn -pl ... -Dtest=V2ApiProbe ... test` 无法在 core 外跑通(这是预期的 WIP 窗口——既有 react-agent 端到端测试由后续 Task 重建)。
为满足"探针要真实跑通"的交付要求,探针的**编译与执行**用直接 `javac` + JUnit Platform Launcher API 完成(不修改 sibling 测试、不改 `skipTests`
- `mvn -pl liteflow-testcase-el/liteflow-testcase-el-react-agent -am dependency:build-classpath` 取真实 classpath含 agentscope 2.0.0-RC3 与 junit-jupiter 5.8.2)。
- `javac` 编译 `V2ApiProbe.java`+ react-agent-core 已编译 classes→ 干净通过(仅 deprecation 注释)。
- JUnit Platform Launcher`junit-platform-launcher:1.8.2`,匹配 jupiter 5.8.2)执行 `selectClass(V2ApiProbe)`**6 tests, 0 failed**
探针源文件本身放在 brief 指定路径,是可被未来 `mvn test` 直接运行的合规 JUnit5 测试;当前仅因 sibling 测试未随 Task 0 一并更新而无法用单条 mvn 命令跑通。后续 Task 重建 feature 测试后该 mvn 命令自然恢复可用。

View File

@ -33,6 +33,35 @@
<version>${agentscope.version}</version>
</dependency>
<!-- v2 optional extensions: used at runtime only when the corresponding
persistence/skill mode is enabled. Declared optional so that a missing
extension (rename/removal in a later RC) does not break the build.
Resolved artifactIds confirmed for 2.0.0-RC3 on Maven Central. -->
<dependency>
<groupId>io.agentscope</groupId>
<artifactId>agentscope-extensions-redis</artifactId>
<version>${agentscope.version}</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>io.agentscope</groupId>
<artifactId>agentscope-extensions-mysql</artifactId>
<version>${agentscope.version}</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>io.agentscope</groupId>
<artifactId>agentscope-extensions-oss</artifactId>
<version>${agentscope.version}</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>io.agentscope</groupId>
<artifactId>agentscope-extensions-skill-git-repository</artifactId>
<version>${agentscope.version}</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-core</artifactId>

View File

@ -1,89 +1,47 @@
package com.yomahub.liteflow.agent.component;
import com.yomahub.liteflow.agent.exception.AgentConfigException;
import com.yomahub.liteflow.agent.hook.ChatUsageTrackingHook;
import com.yomahub.liteflow.agent.hook.ReActLoggingHook;
import com.yomahub.liteflow.agent.skill.SkillBoxFactory;
import com.yomahub.liteflow.agent.skill.SkillLoadResult;
import com.yomahub.liteflow.agent.skill.SkillTrackingHook;
import com.yomahub.liteflow.agent.session.AgentSession;
import com.yomahub.liteflow.agent.session.AgentSessionManager;
import com.yomahub.liteflow.agent.tool.ManagedShellCommandTool;
import com.yomahub.liteflow.agent.tool.WorkspaceFileTools;
import com.yomahub.liteflow.agent.exception.AgentInvocationException;
import com.yomahub.liteflow.agent.model.ModelSpec;
import com.yomahub.liteflow.core.NodeComponent;
import com.yomahub.liteflow.flow.FlowEvent;
import com.yomahub.liteflow.flow.FlowEventPublisher;
import com.yomahub.liteflow.property.LiteflowConfigGetter;
import com.yomahub.liteflow.property.agent.AgentConfig;
import com.yomahub.liteflow.property.agent.MemoryStorageConfig;
import com.yomahub.liteflow.property.agent.ShellMode;
import com.yomahub.liteflow.slot.Slot;
import com.yomahub.liteflow.util.ConversationIdGenerator;
import io.agentscope.core.ReActAgent;
import io.agentscope.core.agent.Event;
import io.agentscope.core.agent.EventType;
import io.agentscope.core.agent.StreamOptions;
import io.agentscope.core.hook.Hook;
import io.agentscope.core.memory.InMemoryMemory;
import io.agentscope.core.message.Msg;
import com.yomahub.liteflow.agent.model.ModelSpec;
import io.agentscope.core.model.Model;
import io.agentscope.core.skill.SkillBox;
import io.agentscope.core.tool.Toolkit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import reactor.core.publisher.Mono;
/**
* 封装 agentscope ReActAgent LiteFlow 抽象组件
* <p>
* 子类必须提供 {@link #model()}{@link #systemPrompt()} {@link #userPrompt()}
* 可选覆写方法用于自定义工具钩子和生命周期回调
* 封装 agentscope agent LiteFlow 抽象组件
*
* <p><b>状态v2 迁移进行中Task 0 green-commit 基底</b>
* 本类已删除所有依赖 agentscope 1.0 已删类型session 管理 / skill / 工具 /
* 流式事件桥接的实现{@link #process()} 当前抛出
* {@link AgentInvocationException}后续 Task 2.x 会基于 v2
* {@code HarnessAgent} 重建 {@code process()}受保护方法签名保持不变
* 子类实现与业务侧代码无需改动
*
* <p>子类必须提供 {@link #model()}{@link #systemPrompt()} {@link #userPrompt()}
* 可选覆写方法用于自定义工具钩子和生命周期回调当前均为空实现或读
* {@link #agentConfig()}签名不变
*
* <p>所有 hook 方法均为无参通过 {@link #ctx()} 动态获取当次执行的
* {@link ReActAgentContext} ctx {@link Slot} 同生命周期 {@link #process()}
* 自动挂载与解绑 {@code nodeId} 隔离以支持同 chain WHEN 并发执行多个 agent
* {@link ReActAgentContext} ctx {@link Slot} 同生命周期
*
* <p>会话标识被拆为两层
* <ul>
* <li>{@code conversationId}业务/对话维度决定 workspace 共享范围与对话连续性
* {@link #resolveConversationId()} 解析整条 chain 内只解析一次
* 后续 agent 通过 {@link Slot#getConversationId()} 复用</li>
* <li>{@code agentKey}组件维度默认 {@code nodeId}用于在同一段对话中区分
* 不同 agent ReActAgent 实例与对话记忆</li>
* <li>{@code conversationId}业务/对话维度 {@link #resolveConversationId()} 解析</li>
* <li>{@code agentKey}组件维度默认 {@code nodeId}隔离同对话内不同 agent</li>
* </ul>
*
* <p>When {@code liteflow.agent.skills.enabled=true}, the component can load
* agent-scope skills from {@code liteflow.agent.skills.path}. Override
* {@link #skills()} to restrict the component to a fixed allow-list; an empty
* list means all configured skills are available. The allow-list and
* {@link #enableSkills()} are evaluated only when the cached ReActAgent is built
* for a {@code (conversationId, agentKey)} session, so they are stable component
* capability declarations and should not depend on request data.
*
* <p><b>注意勿在跨 invocation 缓存的对象自定义工具/Hook/Model 中持有
* {@link ReActAgentContext} 引用</b>这些对象会被缓存的 agent 复用捕获的 ctx
* 会在下一次 {@code process()} 时变成陈旧引用其中的 slot 已经被回收
* 正确做法持有组件实例引用运行时通过 {@code component.ctx()} 动态获取
*
* <p>技能相关的 {@link #skills()} {@link #enableSkills()} 只在为某个
* {@code (conversationId, agentKey)} session 首次构建并缓存 ReActAgent 时求值
* 它们表示组件能力声明不应依赖单次请求数据同一 session 复用缓存 agent 时不会
* 重新读取这些声明
*
* <p>{@link #process()} 方法被声明为 {@code final}由框架统一保证 session
* 管理和 ctx 生命周期的正确性
* <p>{@link #process()} 方法被声明为 {@code final}由框架统一保证
*/
public abstract class ReActAgentComponent extends NodeComponent {
private static final Logger LOG = LoggerFactory.getLogger(ReActAgentComponent.class);
public static final String FLOW_EVENT_TYPE_REASONING = "agent.reasoning";
public static final String FLOW_EVENT_TYPE_TOOL_RESULT = "agent.tool_result";
public static final String FLOW_EVENT_TYPE_SUMMARY = "agent.summary";
@ -104,19 +62,11 @@ public abstract class ReActAgentComponent extends NodeComponent {
/** 在 Slot attachment 上存储 ctx 时使用的 key 前缀,按 nodeId 隔离。 */
private static final String CTX_KEY_PREFIX = "_react_agent_ctx_";
/** 在 Slot attachment 上存储技能跟踪 Hook 时使用的 key 前缀,按 nodeId 隔离。 */
private static final String SKILL_HOOK_KEY_PREFIX = "_react_agent_skill_hook_";
private String ctxKey() {
String nodeId = getNodeId();
return CTX_KEY_PREFIX + (nodeId == null ? "default" : nodeId);
}
private String skillHookKey() {
String nodeId = getNodeId();
return SKILL_HOOK_KEY_PREFIX + (nodeId == null ? "default" : nodeId);
}
/* ===== 框架提供的 final 访问器 ===== */
/**
@ -170,7 +120,7 @@ public abstract class ReActAgentComponent extends NodeComponent {
protected abstract String systemPrompt();
/**
* 返回传递给底层 ReActAgent 的最终系统提示词
* 返回传递给底层 agent 的最终系统提示词
*/
protected final String effectiveSystemPrompt() {
String customPrompt = systemPrompt();
@ -185,10 +135,10 @@ public abstract class ReActAgentComponent extends NodeComponent {
*/
protected abstract String userPrompt();
/* ===== 可选覆写 ===== */
/* ===== 可选覆写签名保持不变v2 迁移期返回空实现或读 config ===== */
/**
* 提供要注册到 agent {@link Toolkit} 中的额外工具对象
* 提供要注册到 agent toolkit 中的额外工具对象
* 默认返回空列表
*/
protected List<Object> tools() { return List.of(); }
@ -196,34 +146,15 @@ public abstract class ReActAgentComponent extends NodeComponent {
/**
* Return skill names this component may use. Empty means all configured skills.
*
* <p>This is evaluated only when the cached ReActAgent is built for a
* {@code (conversationId, agentKey)} session. Treat it as a stable component
* capability declaration; do not vary it per request.
* <p>签名保持不变具体 skill 加载由后续 Task 基于 v2 skill repository 重建
*/
protected List<String> skills() { return List.of(); }
/**
* Whether agent-scope skills should be enabled for this component.
*
* <p>This is evaluated only when the cached ReActAgent is built for a
* {@code (conversationId, agentKey)} session. Treat it as a stable component
* capability declaration; do not vary it per request.
*/
protected boolean enableSkills() { return agentConfig().getSkills().isEnabled(); }
/**
* Return skill names loaded by this agent during the current invocation.
*
* <p>This is available only while this component's {@link #process()} body has
* bound the invocation skill hook, including calls from {@link #userPrompt()},
* tool callbacks, and {@link #handleReply(Msg)}. After {@code process()} final
* cleanup, later lifecycle callbacks must not rely on it.
*/
protected final List<String> usedSkills() {
SkillTrackingHook hook = getSlot().getAttachment(skillHookKey());
return hook == null ? List.of() : hook.getUsedSkills();
}
/**
* 解析本次执行的 {@code conversationId}
*
@ -277,211 +208,15 @@ public abstract class ReActAgentComponent extends NodeComponent {
/* ===== 框架 final 执行体 ===== */
/**
* <b>v2 迁移期 stub</b>当前抛出 {@link AgentInvocationException}
* Task 2.x 基于 v2 {@code HarnessAgent} 重建
*
* <p>签名保持 {@code final} 1.0 一致
*/
@Override
public final void process() throws Exception {
AgentConfig cfg = agentConfig();
AgentSessionManager mgr = AgentSessionManagerHolder.getOrCreate(cfg);
MemoryStorageConfig mc = cfg.getSession().getMemory();
Slot slot = this.getSlot();
String cid = resolveConversationId();
slot.setConversationId(cid);
String akey = agentKey();
AgentSession session = mgr.acquire(cid, akey);
session.getLock().lock();
try {
ReActAgentContext ctx = new ReActAgentContext(
slot, session.getConversationId(), session.getAgentKey(), session.getWorkspaceDir());
slot.setAttachment(ctxKey(), ctx);
try {
ReActAgent agent = (ReActAgent) session.getAgent();
if (agent == null) {
BuiltAgent built = buildAgent();
agent = built.agent();
session.setSkillTrackingHook(built.skillTrackingHook());
session.setChatUsageTrackingHook(built.chatUsageTrackingHook());
mgr.loadIfExists(session, agent);
session.setAgent(agent);
}
SkillTrackingHook skillHook = session.getSkillTrackingHook();
if (skillHook != null) {
skillHook.clear();
slot.setAttachment(skillHookKey(), skillHook);
}
ChatUsageTrackingHook usageHook = session.getChatUsageTrackingHook();
if (usageHook != null) {
usageHook.reset();
ctx.setChatUsageTrackingHook(usageHook);
}
Throwable processError = null;
try {
Msg userMsg = Msg.builder().textContent(userPrompt()).build();
Msg reply = callAgent(agent, userMsg, slot);
handleReply(reply);
} catch (Throwable t) {
processError = t;
throw t;
} finally {
boolean shouldSave = (processError == null) ? mc.isSaveAfterCall() : mc.isSaveOnError();
if (shouldSave) {
try {
mgr.save(session, agent);
} catch (Exception persistEx) {
if (processError != null) {
processError.addSuppressed(persistEx);
} else {
LOG.warn("session memory save failed for cacheKey={}",
session.getCacheKey(), persistEx);
}
}
}
}
} finally {
slot.removeAttachment(ctxKey());
slot.removeAttachment(skillHookKey());
}
} finally {
session.getLock().unlock();
}
}
private Msg callAgent(ReActAgent agent, Msg userMsg, Slot slot) {
if (!FlowEventPublisher.hasListener(slot)) {
return agent.call(List.of(userMsg)).block();
}
return streamAgent(agent, userMsg, slot).block();
}
private Mono<Msg> streamAgent(ReActAgent agent, Msg userMsg, Slot slot) {
AtomicReference<Msg> finalMsg = new AtomicReference<>();
AtomicReference<Msg> fallbackFinalMsg = new AtomicReference<>();
StreamOptions options = StreamOptions.builder()
.eventTypes(EventType.REASONING, EventType.TOOL_RESULT, EventType.SUMMARY, EventType.AGENT_RESULT)
.incremental(true)
.build();
return agent.stream(List.of(userMsg), options)
.doOnNext(event -> {
if (event.getType() == EventType.AGENT_RESULT) {
finalMsg.set(event.getMessage());
} else if (event.isLast()) {
fallbackFinalMsg.set(event.getMessage());
}
publishAgentEvent(slot, event);
})
.then(Mono.defer(() -> {
Msg msg = finalMsg.get();
if (msg != null) {
return Mono.just(msg);
}
Msg fallback = fallbackFinalMsg.get();
return fallback == null ? Mono.empty() : Mono.just(fallback);
}));
}
private void publishAgentEvent(Slot slot, Event event) {
String type = toFlowEventType(event.getType());
if (type == null) {
return;
}
Msg msg = event.getMessage();
FlowEventPublisher.publish(slot, FlowEvent.builder()
.type(type)
.chainId(slot.getChainId())
.nodeId(getNodeId())
.requestId(slot.getRequestId())
.conversationId(slot.getConversationId())
.text(msg == null ? null : msg.getTextContent())
.last(event.isLast())
.data(event)
.build());
}
private String toFlowEventType(EventType type) {
if (type == EventType.REASONING) {
return FLOW_EVENT_TYPE_REASONING;
}
if (type == EventType.TOOL_RESULT) {
return FLOW_EVENT_TYPE_TOOL_RESULT;
}
if (type == EventType.SUMMARY) {
return FLOW_EVENT_TYPE_SUMMARY;
}
if (type == EventType.AGENT_RESULT) {
return FLOW_EVENT_TYPE_RESULT;
}
return null;
}
private record BuiltAgent(ReActAgent agent, SkillTrackingHook skillTrackingHook,
ChatUsageTrackingHook chatUsageTrackingHook) {
}
private BuiltAgent buildAgent() {
AgentConfig cfg = agentConfig();
int iters = maxIterations() > 0 ? maxIterations() : cfg.getDefaults().getMaxIterations();
ReActAgentContext ctx = ctx();
Toolkit toolkit = new Toolkit();
tools().forEach(toolkit::registerTool);
if (enableWorkspaceFileTools()) {
toolkit.registerTool(new WorkspaceFileTools(ctx.getWorkspaceDir(), cfg));
}
if (enableShellTool() && cfg.getShell().getMode() != ShellMode.DISABLED) {
toolkit.registerTool(new ManagedShellCommandTool(ctx.getWorkspaceDir(), cfg));
}
List<Hook> allHooks = new ArrayList<>(hooks());
if (enableReActLogging()) {
allHooks.add(new ReActLoggingHook(ctx.getConversationId() + ":" + ctx.getAgentKey()));
}
ChatUsageTrackingHook chatUsageTrackingHook = new ChatUsageTrackingHook();
allHooks.add(chatUsageTrackingHook);
SkillTrackingHook skillTrackingHook = null;
SkillBox skillBox = null;
if (enableSkills()) {
SkillLoadResult skillLoadResult = SkillBoxFactory.build(toolkit, cfg, skills(), ctx.getWorkspaceDir());
skillBox = skillLoadResult.skillBox();
skillTrackingHook = new SkillTrackingHook(skillLoadResult.skillIdToName());
allHooks.add(skillTrackingHook);
}
ReActAgent.Builder builder = ReActAgent.builder()
.name(getNodeId() == null ? "liteflow-agent" : getNodeId())
.sysPrompt(effectiveSystemPrompt())
.model(buildModel())
.toolkit(toolkit)
.memory(new InMemoryMemory())
.maxIters(iters)
.hooks(allHooks);
if (skillBox != null) {
builder.skillBox(skillBox);
}
return new BuiltAgent(builder.build(), skillTrackingHook, chatUsageTrackingHook);
}
/** 持有单例 AgentSessionManager首次 process() 时懒创建。 */
static final class AgentSessionManagerHolder {
private static volatile AgentSessionManager INSTANCE;
static AgentSessionManager getOrCreate(AgentConfig cfg) {
AgentSessionManager cur = INSTANCE;
if (cur != null) return cur;
synchronized (AgentSessionManagerHolder.class) {
if (INSTANCE == null) INSTANCE = new AgentSessionManager(cfg);
return INSTANCE;
}
}
static void resetForTesting() {
AgentSessionManager cur = INSTANCE;
if (cur != null) {
try { cur.close(); } catch (Exception ignored) {}
}
INSTANCE = null;
}
public final void process() {
throw new AgentInvocationException(
"ReActAgentComponent v2 migration in progress; process() is rebuilt in Task 2.3");
}
}

View File

@ -1,6 +1,5 @@
package com.yomahub.liteflow.agent.component;
import com.yomahub.liteflow.agent.hook.ChatUsageTrackingHook;
import com.yomahub.liteflow.slot.Slot;
import io.agentscope.core.model.ChatUsage;
@ -17,20 +16,20 @@ 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>勿在跨 invocation 缓存的对象中持有 {@code ReActAgentContext} 引用</b>
* 例如自定义工具实例HookModel 实现这些对象会被缓存的 ReActAgent 跨次复用
* 例如自定义工具实例HookModel 实现这些对象会被缓存的 agent 跨次复用
* ctx per-invocation 捕获后下一次 process() 时通过该 ctx 访问的 slot
* 已被 {@code DataBus.releaseSlot} 回收并复用是悬挂引用
*
* <p>正确做法在工具/Model 类中持有组件实例引用运行时通过
* {@code component.ctx()} 动态获取当次 ctx
*/
public class ReActAgentContext {
private final Slot slot;
private final String conversationId;
private final String agentKey;
private final Path workspaceDir;
private volatile ChatUsageTrackingHook chatUsageTrackingHook;
public ReActAgentContext(Slot slot, String conversationId, String agentKey, Path workspaceDir) {
this.slot = Objects.requireNonNull(slot, "slot");
@ -49,22 +48,23 @@ public class ReActAgentContext {
/**
* 由框架注入本次 {@code process()} 调用使用的 token 累加 hook
*
* <p><b>v2 迁移期 no-op</b>1.0 {@code ChatUsageTrackingHook} 已删除
* 这里仅保留方法签名以兼容调用点Task 5.1 重建后改回真实注入
*/
public void setChatUsageTrackingHook(ChatUsageTrackingHook hook) {
this.chatUsageTrackingHook = hook;
public void setChatUsageTrackingHook(Object hook) {
// no-op: 1.0 ChatUsageTrackingHook 已删除Task 5.1 恢复
}
/**
* 返回本次 {@code process()} 截至当前已累计的 token 用量
*
* <p>{@link ChatUsage#getInputTokens()} / {@link ChatUsage#getOutputTokens()} /
* {@link ChatUsage#getTotalTokens()} 给出累计 token{@link ChatUsage#getTime()}
* 给出累计推理耗时 {@code handleReply()} 中调用拿到的就是整次调用的累计值
* <p><b>v2 迁移期返回 {@code null}</b>1.0 {@code ChatUsageTrackingHook}
* 已删除 Task 5.1 基于 v2 middleware 重建后再恢复真实累计值
*
* @return 累计 ChatUsage若未观察到任何 usage模型未上报或 reply null则返回 {@code null}
* @return 当前固定返回 {@code null}Task 5.1 恢复后给出累计 ChatUsage
*/
public ChatUsage getChatUsage() {
ChatUsageTrackingHook hook = this.chatUsageTrackingHook;
return hook == null ? null : hook.snapshot();
return null;
}
}

View File

@ -1,73 +0,0 @@
package com.yomahub.liteflow.agent.hook;
import io.agentscope.core.hook.Hook;
import io.agentscope.core.hook.HookEvent;
import io.agentscope.core.hook.PostReasoningEvent;
import io.agentscope.core.message.Msg;
import io.agentscope.core.model.ChatUsage;
import reactor.core.publisher.Mono;
/**
* 累加单次 {@code process()} 调用内所有 reasoning step token 用量
*
* <p>底层 agentscope 每次 {@code reasoning(iter)} 都新建一个 {@link io.agentscope.core.agent.accumulator.ReasoningContext
* ReasoningContext}因此 {@link PostReasoningEvent#getReasoningMessage()} metadata 中携带的
* {@link ChatUsage} 只是本步 LLM call 的累计流式聚合不是跨多步 ReAct 循环的累计
* hook 在每次 PostReasoningEvent 触发时把当步 usage 累加到内部计数器
* 暴露整次调用累计后的 {@link #snapshot()}
*
* <p>实例与缓存 ReActAgent 同生命周期每次 {@code process()} 开始前必须调用 {@link #reset()}
* 清零避免上次调用的余量被带入
*/
public class ChatUsageTrackingHook implements Hook {
private int inputTokens;
private int outputTokens;
private double time;
private int steps;
@Override
public synchronized <T extends HookEvent> Mono<T> onEvent(T event) {
if (event instanceof PostReasoningEvent e) {
Msg msg = e.getReasoningMessage();
if (msg != null) {
ChatUsage usage = msg.getChatUsage();
if (usage != null) {
inputTokens += usage.getInputTokens();
outputTokens += usage.getOutputTokens();
time += usage.getTime();
steps++;
}
}
}
return Mono.just(event);
}
public synchronized void reset() {
this.inputTokens = 0;
this.outputTokens = 0;
this.time = 0;
this.steps = 0;
}
/**
* 返回到目前为止累计的 token 用量若尚未观察到任何 usage返回 {@code null}
*/
public synchronized ChatUsage snapshot() {
if (steps == 0) {
return null;
}
return ChatUsage.builder()
.inputTokens(inputTokens)
.outputTokens(outputTokens)
.time(time)
.build();
}
/**
* 已经累加过 usage reasoning step 次数
*/
public synchronized int getSteps() {
return steps;
}
}

View File

@ -1,134 +0,0 @@
package com.yomahub.liteflow.agent.hook;
import io.agentscope.core.hook.ErrorEvent;
import io.agentscope.core.hook.Hook;
import io.agentscope.core.hook.HookEvent;
import io.agentscope.core.hook.PostActingEvent;
import io.agentscope.core.hook.PostReasoningEvent;
import io.agentscope.core.hook.PreActingEvent;
import io.agentscope.core.hook.PreReasoningEvent;
import io.agentscope.core.message.Msg;
import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.message.ToolUseBlock;
import io.agentscope.core.message.ThinkingBlock;
import io.agentscope.core.model.ChatUsage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Mono;
import java.util.List;
/**
* agentscope ReActAgent 的内部 Reason / Act / Error 事件输出到日志
* LiteFlow 用户在终端可以直接看到 agent 的思考与工具调用过程
*
* <p>事件 日志格式
* <ul>
* <li>{@link PreReasoningEvent}{@code [agent:reason] >>> model=... messages=N}</li>
* <li>{@link PostReasoningEvent}{@code [agent:reason] <<< text=... toolCalls=[...]}</li>
* <li>{@link PreActingEvent}{@code [agent:act] >>> tool=... input=...}</li>
* <li>{@link PostActingEvent}{@code [agent:act] <<< tool=... result=...}</li>
* <li>{@link ErrorEvent}{@code [agent:error] ...}</li>
* </ul>
*/
public class ReActLoggingHook implements Hook {
private static final Logger LOG = LoggerFactory.getLogger(ReActLoggingHook.class);
private static final int MAX_TEXT_LEN = 500;
private static final int MAX_THINKING_LEN = 1000;
private static final int MAX_RESULT_LEN = 2000;
private final String sessionId;
public ReActLoggingHook(String sessionId) {
this.sessionId = sessionId == null ? "-" : sessionId;
}
@Override
public <T extends HookEvent> Mono<T> onEvent(T event) {
try {
if (event instanceof PreReasoningEvent e) {
List<Msg> msgs = e.getInputMessages();
LOG.info("[agent:reason][{}] >>> model={} messages={}",
sessionId, e.getModelName(), msgs == null ? 0 : msgs.size());
} else if (event instanceof PostReasoningEvent e) {
Msg reply = e.getReasoningMessage();
String thinking = extractThinking(reply);
String text = truncate(reply.getTextContent(), MAX_TEXT_LEN);
List<ToolUseBlock> tools = reply == null
? List.of()
: reply.getContentBlocks(ToolUseBlock.class);
if (!thinking.isEmpty()) {
LOG.info("[agent:reason][{}] <<< thinking={}", sessionId, thinking);
}
if (!text.isEmpty() || !tools.isEmpty()) {
if (tools.isEmpty()) {
LOG.info("[agent:reason][{}] <<< text={}", sessionId, text);
} else {
LOG.info("[agent:reason][{}] <<< text={} toolCalls={}",
sessionId, text, summarizeToolUses(tools));
}
}
ChatUsage usage = reply == null ? null : reply.getChatUsage();
if (usage != null) {
LOG.info("[agent:reason][{}] <<< usage input={} output={} total={} time={}s",
sessionId,
usage.getInputTokens(),
usage.getOutputTokens(),
usage.getTotalTokens(),
usage.getTime());
}
} else if (event instanceof PreActingEvent e) {
ToolUseBlock t = e.getToolUse();
LOG.info("[agent:act][{}] >>> tool={} input={}",
sessionId, t.getName(), truncate(String.valueOf(t.getInput()), MAX_TEXT_LEN));
} else if (event instanceof PostActingEvent e) {
ToolResultBlock r = e.getToolResult();
String result = blocksToString(r);
LOG.info("[agent:act][{}] <<< {} 结果:", sessionId, r.getName());
LOG.info("[agent:act][{}] {}", sessionId, truncate(result, MAX_RESULT_LEN));
} else if (event instanceof ErrorEvent e) {
LOG.warn("[agent:error][{}] {}", sessionId, e.getError().toString(), e.getError());
}
} catch (Throwable logEx) {
LOG.debug("ReActLoggingHook formatting failed", logEx);
}
return Mono.just(event);
}
@Override
public int priority() {
return 900;
}
private static String summarizeToolUses(List<ToolUseBlock> tools) {
StringBuilder sb = new StringBuilder("[");
for (int i = 0; i < tools.size(); i++) {
ToolUseBlock t = tools.get(i);
if (i > 0) sb.append(", ");
sb.append(t.getName()).append("(").append(t.getInput()).append(")");
}
return truncate(sb.append("]").toString(), MAX_TEXT_LEN);
}
private static String extractThinking(Msg msg) {
if (msg == null) return "";
List<ThinkingBlock> blocks = msg.getContentBlocks(ThinkingBlock.class);
if (blocks.isEmpty()) return "";
return truncate(blocks.get(0).getThinking(), MAX_THINKING_LEN);
}
private static String blocksToString(ToolResultBlock r) {
if (r.getOutput() == null) return "";
StringBuilder sb = new StringBuilder();
r.getOutput().forEach(b -> sb.append(b));
return sb.toString();
}
private static String truncate(String s, int maxLen) {
if (s == null) return "";
s = s.replaceAll("\\s+", " ").trim();
return s.length() <= maxLen ? s : s.substring(0, maxLen) + "...(truncated)";
}
}

View File

@ -1,98 +0,0 @@
package com.yomahub.liteflow.agent.session;
import com.yomahub.liteflow.agent.hook.ChatUsageTrackingHook;
import com.yomahub.liteflow.agent.skill.SkillTrackingHook;
import java.nio.file.Path;
import java.time.Instant;
import java.util.Objects;
import java.util.concurrent.locks.ReentrantLock;
/**
* 单个 agent 在某次会话中的运行时状态
*
* <p>会话标识被拆分为两个维度
* <ul>
* <li>{@code conversationId}业务/对话维度由调用方决定整条 chain 内所有 agent 共享
* 决定 workspace 目录与对话连续性</li>
* <li>{@code agentKey}组件维度默认是 {@code nodeId}用于在同一段对话中区分
* 不同 agent ReActAgent 实例和持久化记忆</li>
* </ul>
*
* <p>{@code workspaceDir} 仅按 {@code conversationId} 创建因此同一段对话中的多个 agent
* 共享同一个工作区目录实现 agent 之间的文件协作
*/
public class AgentSession {
private final String conversationId;
private final String agentKey;
private final String cacheKey;
private final Path workspaceDir;
private final ReentrantLock lock = new ReentrantLock();
private volatile Object agent;
private volatile SkillTrackingHook skillTrackingHook;
private volatile ChatUsageTrackingHook chatUsageTrackingHook;
private volatile Instant lastActive = Instant.now();
public AgentSession(String conversationId, String agentKey, String cacheKey, Path workspaceDir) {
this.conversationId = Objects.requireNonNull(conversationId, "conversationId");
this.agentKey = Objects.requireNonNull(agentKey, "agentKey");
this.cacheKey = Objects.requireNonNull(cacheKey, "cacheKey");
this.workspaceDir = Objects.requireNonNull(workspaceDir, "workspaceDir");
}
public String getConversationId() {
return conversationId;
}
public String getAgentKey() {
return agentKey;
}
/**
* JVM 内的缓存 key 与持久化 key {@code conversationId} {@code agentKey} 组合并安全编码后得到
*/
public String getCacheKey() {
return cacheKey;
}
public Path getWorkspaceDir() {
return workspaceDir;
}
public ReentrantLock getLock() {
return lock;
}
public Object getAgent() {
return agent;
}
public void setAgent(Object agent) {
this.agent = agent;
}
public SkillTrackingHook getSkillTrackingHook() {
return skillTrackingHook;
}
public void setSkillTrackingHook(SkillTrackingHook skillTrackingHook) {
this.skillTrackingHook = skillTrackingHook;
}
public ChatUsageTrackingHook getChatUsageTrackingHook() {
return chatUsageTrackingHook;
}
public void setChatUsageTrackingHook(ChatUsageTrackingHook chatUsageTrackingHook) {
this.chatUsageTrackingHook = chatUsageTrackingHook;
}
public Instant getLastActive() {
return lastActive;
}
public void touch() {
this.lastActive = Instant.now();
}
}

View File

@ -1,209 +0,0 @@
package com.yomahub.liteflow.agent.session;
import com.yomahub.liteflow.agent.exception.AgentConfigException;
import com.yomahub.liteflow.agent.session.factory.AgentSessionFactoryRegistry;
import com.yomahub.liteflow.property.agent.AgentConfig;
import com.yomahub.liteflow.property.agent.MemoryStorageConfig;
import com.yomahub.liteflow.property.agent.MemoryStorageMode;
import io.agentscope.core.ReActAgent;
import io.agentscope.core.session.Session;
import io.agentscope.core.session.SessionManager;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Instant;
import java.util.Comparator;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
/**
* 跟踪当前 JVM 中存活的 {@link AgentSession}并桥接到可插拔的
* {@link Session} {@link AgentSessionFactoryRegistry} 提供
*
* <p>会话标识被拆分为两个维度
* <ul>
* <li>{@code conversationId}业务/对话维度决定 workspace 目录与连续对话的恢复</li>
* <li>{@code agentKey}组件维度默认为 {@code nodeId}用于在同一段对话内
* 区分不同 agent ReActAgent 实例与对话记忆</li>
* </ul>
*
* <p>缓存与持久化都按 {@code (conversationId, agentKey)} 组合 key 隔离
* workspace 目录则只按 {@code conversationId} 建一份让同一段对话中的多个 agent
* 通过共享文件协作
*/
public class AgentSessionManager implements AutoCloseable {
private static final Pattern SAFE = Pattern.compile("[a-zA-Z0-9_\\-]+");
/** 缓存 key 内部使用的分隔符;不在 SAFE 字符集中以外,且不会出现在 NanoId 输出里。 */
static final String KEY_SEPARATOR = "__";
private final AgentConfig config;
private final Path root;
private final Map<String, AgentSession> sessions = new ConcurrentHashMap<>();
private final ScheduledExecutorService cleaner;
/** memory 模式为 NONE 时可能为 null。 */
private final Session storage;
public AgentSessionManager(AgentConfig config) {
this.config = config;
if (config == null || config.getWorkspace() == null || config.getWorkspace().getRoot() == null) {
throw new AgentConfigException("liteflow.agent.workspace.root is required");
}
this.root = Paths.get(config.getWorkspace().getRoot()).toAbsolutePath().normalize();
if (config.getWorkspace().isAutoCreate()) {
try {
Files.createDirectories(root);
} catch (IOException e) {
throw new AgentConfigException("cannot create workspace root: " + root, e);
}
} else if (!Files.isDirectory(root)) {
throw new AgentConfigException("workspace root does not exist: " + root);
}
this.storage = AgentSessionFactoryRegistry.createSession(config);
long every = Math.max(20, config.getSession().getCleanupInterval().toMillis());
this.cleaner = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "liteflow-agent-session-cleaner");
t.setDaemon(true);
return t;
});
cleaner.scheduleWithFixedDelay(this::cleanup, every, every, TimeUnit.MILLISECONDS);
}
/**
* 获取或创建一个 agent 会话
*
* <p>同一 {@code conversationId} 下的多个 {@code agentKey} 共享同一个 workspace 目录
* 但分别拥有独立的 {@link AgentSession}独立的 ReActAgent 实例独立的记忆持久化 key
*/
public AgentSession acquire(String conversationId, String agentKey) {
String safeCid = safeId(conversationId);
String safeKey = safeId(agentKey);
String cacheKey = safeCid + KEY_SEPARATOR + safeKey;
AgentSession s = sessions.computeIfAbsent(cacheKey, k -> {
Path ws = root.resolve(safeCid);
try {
Files.createDirectories(ws);
} catch (IOException e) {
throw new AgentConfigException("cannot create workspace: " + ws, e);
}
return new AgentSession(safeCid, safeKey, k, ws);
});
s.touch();
enforceMaxSessions();
return s;
}
public boolean contains(String conversationId, String agentKey) {
return sessions.containsKey(safeId(conversationId) + KEY_SEPARATOR + safeId(agentKey));
}
/**
* 将之前持久化的状态懒加载恢复到 agent
* 同一个 {@code (conversationId, agentKey)} 在当前 JVM 生命周期内应只调用一次并且应在 agent
* 构建完成后首次 {@code agent.call(...)} 前调用
*/
public void loadIfExists(AgentSession session, ReActAgent agent) {
if (storage == null || agent == null) return;
MemoryStorageConfig mc = config.getSession().getMemory();
if (!mc.isLoadOnFirstUse()) return;
if (mc.getMode() == MemoryStorageMode.NONE) return;
SessionManager.forSessionId(session.getCacheKey())
.withSession(storage)
.addComponent(agent)
.loadIfExists();
}
/** 持久化 agent 当前状态。失败会向调用方暴露。 */
public void save(AgentSession session, ReActAgent agent) {
if (storage == null || agent == null) return;
MemoryStorageConfig mc = config.getSession().getMemory();
if (mc.getMode() == MemoryStorageMode.NONE) return;
SessionManager.forSessionId(session.getCacheKey())
.withSession(storage)
.addComponent(agent)
.saveSession();
}
public Session storage() { return storage; }
static String safeId(String raw) {
if (raw == null || raw.isEmpty()) return "_";
if (SAFE.matcher(raw).matches()) return raw;
return URLEncoder.encode(raw, StandardCharsets.UTF_8).replace("%", "_");
}
private void enforceMaxSessions() {
int max = config.getSession().getMaxSessions();
while (sessions.size() > max) {
sessions.values().stream()
.min(Comparator.comparing(AgentSession::getLastActive))
// LRU 淘汰只移除 JVM 内缓存持久化数据保持不变
.ifPresent(victim -> evictFromCache(victim, false));
}
}
private void cleanup() {
Instant cutoff = Instant.now().minus(config.getSession().getIdleTimeout());
for (AgentSession s : sessions.values()) {
if (s.getLastActive().isAfter(cutoff)) continue;
if (!s.getLock().tryLock()) continue;
try {
evictFromCache(s, config.getWorkspace().isCleanupOnSessionExpire());
} finally {
s.getLock().unlock();
}
}
}
/**
* @param cleanWorkspace true 时同时尝试删除磁盘上的 workspace 目录由于同一 workspace
* 可能被 {@code (conversationId, *)} 下的多个 agent 共享仅在该
* conversation 下没有其他存活会话时才会真正删除目录
* 存储在其他位置的持久化 session 状态例如 workspaceRoot/.agent-session
* RedisMySQL不会在这里被删除
*/
private void evictFromCache(AgentSession s, boolean cleanWorkspace) {
sessions.remove(s.getCacheKey(), s);
if (cleanWorkspace && !hasSiblingInSameConversation(s)) {
deleteRecursively(s.getWorkspaceDir());
}
}
private boolean hasSiblingInSameConversation(AgentSession evicted) {
String prefix = evicted.getConversationId() + KEY_SEPARATOR;
for (String key : sessions.keySet()) {
if (key.startsWith(prefix)) return true;
}
return false;
}
private static void deleteRecursively(Path p) {
if (!Files.exists(p)) return;
try (var walk = Files.walk(p)) {
walk.sorted(Comparator.reverseOrder()).forEach(x -> {
try { Files.deleteIfExists(x); } catch (IOException ignored) {}
});
} catch (IOException ignored) {}
}
@Override
public void close() {
cleaner.shutdownNow();
if (config.getWorkspace().isCleanupOnJvmShutdown()) {
sessions.values().forEach(s -> deleteRecursively(s.getWorkspaceDir()));
}
if (storage != null) {
try { storage.close(); } catch (Exception ignored) {}
}
sessions.clear();
}
}

View File

@ -1,30 +0,0 @@
package com.yomahub.liteflow.agent.session.factory;
import com.yomahub.liteflow.property.agent.AgentConfig;
import com.yomahub.liteflow.property.agent.MemoryStorageMode;
import io.agentscope.core.session.Session;
/**
* 用于为 {@link io.agentscope.core.session.Session} 接入额外持久化后端的 SPI
*
* <p>框架内置模式{@code JVM}{@code LOCAL_FILE}{@code REDIS}
* {@code MYSQL}{@code NONE}需要其他后端例如 PostgreSQLOSS
* 加密 JSON的用户可以在 {@code META-INF/services/}{@link AgentSessionFactory}
* 下注册自定义工厂
*/
public interface AgentSessionFactory {
/**
* 当前工厂处理的模式所有已注册工厂之间必须唯一
*/
MemoryStorageMode mode();
/**
* 根据 agent 配置构建底层 {@link Session}该方法会在首次
* {@code process()} 时懒调用而不是在框架启动时调用
*
* @return null Session如果需要跳过持久化则返回 {@code null}
* {@link MemoryStorageMode#NONE} 对应的工厂会返回 {@code null}
*/
Session create(AgentConfig agentConfig);
}

View File

@ -1,54 +0,0 @@
package com.yomahub.liteflow.agent.session.factory;
import com.yomahub.liteflow.agent.exception.AgentConfigException;
import com.yomahub.liteflow.property.agent.AgentConfig;
import com.yomahub.liteflow.property.agent.MemoryStorageMode;
import io.agentscope.core.session.Session;
import java.util.EnumMap;
import java.util.Map;
import java.util.ServiceLoader;
/**
* 根据指定模式解析合适的 {@link AgentSessionFactory}
*
* <p>解析顺序
* <ol>
* <li>通过 {@link ServiceLoader} 注册的外部工厂</li>
* <li>框架内置工厂JVMlocal-fileRedisMySQLnone</li>
* </ol>
* 出现冲突时外部工厂优先因此用户可以覆盖内置实现
* 例如用自定义加密 JSON 工厂替换默认本地文件工厂
*/
public final class AgentSessionFactoryRegistry {
private static final Map<MemoryStorageMode, AgentSessionFactory> FACTORIES = new EnumMap<>(MemoryStorageMode.class);
static {
// 先注册内置实现如果存在 SPI 实现再由 SPI 覆盖
register(new InMemoryAgentSessionFactory());
register(new LocalFileAgentSessionFactory());
register(new RedisAgentSessionFactory());
register(new MysqlAgentSessionFactory());
register(new NoneAgentSessionFactory());
for (AgentSessionFactory f : ServiceLoader.load(AgentSessionFactory.class)) {
register(f);
}
}
private AgentSessionFactoryRegistry() { }
private static void register(AgentSessionFactory f) {
FACTORIES.put(f.mode(), f);
}
/** 根据配置模式构建 Session。{@link MemoryStorageMode#NONE} 可能返回 {@code null}。 */
public static Session createSession(AgentConfig cfg) {
MemoryStorageMode mode = cfg.getSession().getMemory().getMode();
AgentSessionFactory f = FACTORIES.get(mode);
if (f == null) {
throw new AgentConfigException("No AgentSessionFactory registered for mode: " + mode);
}
return f.create(cfg);
}
}

View File

@ -1,26 +0,0 @@
package com.yomahub.liteflow.agent.session.factory;
import com.yomahub.liteflow.property.agent.AgentConfig;
import com.yomahub.liteflow.property.agent.MemoryStorageMode;
import io.agentscope.core.session.InMemorySession;
import io.agentscope.core.session.Session;
/**
* 使用 AgentScope 的内存存储支持 {@link MemoryStorageMode#JVM} 模式
*
* <p>注意状态仍会在同一个 JVM 内跨调用保留适合希望在单进程内保留多轮记忆的场景
* 但进程退出后会丢失如果需要跨重启持久化请选择
* {@code LOCAL_FILE}{@code REDIS} {@code MYSQL}
*/
public class InMemoryAgentSessionFactory implements AgentSessionFactory {
@Override
public MemoryStorageMode mode() {
return MemoryStorageMode.JVM;
}
@Override
public Session create(AgentConfig agentConfig) {
return new InMemorySession();
}
}

View File

@ -1,47 +0,0 @@
package com.yomahub.liteflow.agent.session.factory;
import com.yomahub.liteflow.agent.exception.AgentConfigException;
import com.yomahub.liteflow.property.agent.AgentConfig;
import com.yomahub.liteflow.property.agent.LocalFileMemoryConfig;
import com.yomahub.liteflow.property.agent.MemoryStorageMode;
import io.agentscope.core.session.JsonSession;
import io.agentscope.core.session.Session;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
/**
* 通过把 JSON 文件存储在 {@code workspace.root/.agent-session/<sessionId>/}
* 下来支持 {@link MemoryStorageMode#LOCAL_FILE}
*
* <p>session 存储子目录与各 session workspace{@code workspace.root/<sessionId>/}
* 平级而非嵌套一方面避免 {@link com.yomahub.liteflow.agent.tool.WorkspaceFileTools}
* 读到或覆盖 agent 自己的记忆另一方面让 {@code cleanup-on-session-expire}
* 在递归清空 workspace 子目录时不会误删持久化的记忆
* RedisMySQL 后端的"持久化与 workspace 生命周期解耦"语义保持一致
*/
public class LocalFileAgentSessionFactory implements AgentSessionFactory {
@Override
public MemoryStorageMode mode() {
return MemoryStorageMode.LOCAL_FILE;
}
@Override
public Session create(AgentConfig cfg) {
if (cfg.getWorkspace() == null || cfg.getWorkspace().getRoot() == null) {
throw new AgentConfigException(
"liteflow.agent.workspace.root is required when session.memory.mode=LOCAL_FILE");
}
Path root = Paths.get(cfg.getWorkspace().getRoot()).toAbsolutePath().normalize()
.resolve(LocalFileMemoryConfig.SUB_DIR);
try {
Files.createDirectories(root);
} catch (IOException e) {
throw new AgentConfigException("cannot create session storage dir: " + root, e);
}
return new JsonSession(root);
}
}

View File

@ -1,52 +0,0 @@
package com.yomahub.liteflow.agent.session.factory;
import com.yomahub.liteflow.agent.exception.AgentConfigException;
import com.yomahub.liteflow.property.agent.AgentConfig;
import com.yomahub.liteflow.property.agent.MemoryStorageMode;
import com.yomahub.liteflow.property.agent.MysqlMemoryConfig;
import com.yomahub.liteflow.spi.holder.ContextAwareHolder;
import io.agentscope.core.session.Session;
import io.agentscope.core.session.mysql.MysqlSession;
import javax.sql.DataSource;
/**
* 支持 {@link MemoryStorageMode#MYSQL} 模式使用用户提供的
* {@link DataSource} beanLiteFlow 不自行创建 JDBC 连接池
*/
public class MysqlAgentSessionFactory implements AgentSessionFactory {
@Override
public MemoryStorageMode mode() {
return MemoryStorageMode.MYSQL;
}
@Override
public Session create(AgentConfig cfg) {
MysqlMemoryConfig mc = cfg.getSession().getMemory().getMysql();
if (mc.getDataSourceBeanName() == null || mc.getDataSourceBeanName().trim().isEmpty()) {
throw new AgentConfigException(
"liteflow.agent.session.memory.mysql.dataSourceBeanName is required when mode=MYSQL");
}
Object bean = ContextAwareHolder.loadContextAware().getBean(mc.getDataSourceBeanName());
if (bean == null) {
throw new AgentConfigException("DataSource bean not found: " + mc.getDataSourceBeanName());
}
if (!(bean instanceof DataSource)) {
throw new AgentConfigException("Bean '" + mc.getDataSourceBeanName() + "' is not a DataSource; got "
+ bean.getClass().getName());
}
DataSource ds = (DataSource) bean;
String db = mc.getDatabaseName();
String table = mc.getTableName();
boolean hasCustom = (db != null && !db.isEmpty()) || (table != null && !table.isEmpty());
try {
if (hasCustom) {
return new MysqlSession(ds, db, table, mc.isCreateIfNotExist());
}
return new MysqlSession(ds, mc.isCreateIfNotExist());
} catch (Exception e) {
throw new AgentConfigException("Failed to build MysqlSession", e);
}
}
}

View File

@ -1,22 +0,0 @@
package com.yomahub.liteflow.agent.session.factory;
import com.yomahub.liteflow.property.agent.AgentConfig;
import com.yomahub.liteflow.property.agent.MemoryStorageMode;
import io.agentscope.core.session.Session;
/**
* 返回 {@code null} Session用于通知 AgentSessionManager 跳过所有加载和保存操作
* {@link MemoryStorageMode#NONE} 使用
*/
public class NoneAgentSessionFactory implements AgentSessionFactory {
@Override
public MemoryStorageMode mode() {
return MemoryStorageMode.NONE;
}
@Override
public Session create(AgentConfig agentConfig) {
return null;
}
}

View File

@ -1,79 +0,0 @@
package com.yomahub.liteflow.agent.session.factory;
import com.yomahub.liteflow.agent.exception.AgentConfigException;
import com.yomahub.liteflow.property.agent.AgentConfig;
import com.yomahub.liteflow.property.agent.MemoryStorageMode;
import com.yomahub.liteflow.property.agent.RedisMemoryConfig;
import com.yomahub.liteflow.spi.holder.ContextAwareHolder;
import io.agentscope.core.session.Session;
/**
* 通过把用户提供的 Redis 客户端 beanRedissonJedisLettuce
* 适配到 AgentScope RedisSession 来支持 {@link MemoryStorageMode#REDIS}
*
* <p>Redis 客户端类通过反射查找因此 core 模块不会对 RedissonJedisLettuce
* 产生硬性的编译期依赖如果选择 REDIS 模式但 classpath 中缺少匹配驱动
* 会在首次 {@code process()} 时失败而不是在框架启动时失败
*/
public class RedisAgentSessionFactory implements AgentSessionFactory {
private static final String REDIS_SESSION_CLASS = "io.agentscope.core.session.redis.RedisSession";
@Override
public MemoryStorageMode mode() {
return MemoryStorageMode.REDIS;
}
@Override
public Session create(AgentConfig cfg) {
RedisMemoryConfig rc = cfg.getSession().getMemory().getRedis();
if (rc.getBeanName() == null || rc.getBeanName().trim().isEmpty()) {
throw new AgentConfigException(
"liteflow.agent.session.memory.redis.beanName is required when mode=REDIS");
}
Object client = ContextAwareHolder.loadContextAware().getBean(rc.getBeanName());
if (client == null) {
throw new AgentConfigException("Redis client bean not found: " + rc.getBeanName());
}
String builderMethod;
String clientFqn;
switch (rc.getClientType()) {
case REDISSON:
builderMethod = "redissonClient";
clientFqn = "org.redisson.api.RedissonClient";
break;
case JEDIS:
builderMethod = "jedisClient";
clientFqn = "redis.clients.jedis.UnifiedJedis";
break;
case LETTUCE:
builderMethod = "lettuceClient";
clientFqn = "io.lettuce.core.RedisClient";
break;
default:
throw new AgentConfigException("Unsupported redis client type: " + rc.getClientType());
}
try {
Class<?> sessionClass = Class.forName(REDIS_SESSION_CLASS);
Object builder = sessionClass.getMethod("builder").invoke(null);
Class<?> clientType = Class.forName(clientFqn);
if (!clientType.isInstance(client)) {
throw new AgentConfigException("Bean '" + rc.getBeanName() + "' is not a "
+ clientFqn + "; got " + client.getClass().getName());
}
builder.getClass().getMethod(builderMethod, clientType).invoke(builder, client);
if (rc.getKeyPrefix() != null && !rc.getKeyPrefix().isEmpty()) {
builder.getClass().getMethod("keyPrefix", String.class).invoke(builder, rc.getKeyPrefix());
}
return (Session) builder.getClass().getMethod("build").invoke(builder);
} catch (ClassNotFoundException e) {
throw new AgentConfigException(
"Class not found while building RedisSession: " + e.getMessage()
+ ". Add the matching driver dependency (Redisson/Jedis/Lettuce).", e);
} catch (AgentConfigException e) {
throw e;
} catch (Exception e) {
throw new AgentConfigException("Failed to build RedisSession", e);
}
}
}

View File

@ -1,153 +0,0 @@
package com.yomahub.liteflow.agent.skill;
import com.yomahub.liteflow.agent.exception.AgentConfigException;
import com.yomahub.liteflow.property.agent.AgentConfig;
import com.yomahub.liteflow.property.agent.SkillsConfig;
import io.agentscope.core.skill.AgentSkill;
import io.agentscope.core.skill.SkillBox;
import io.agentscope.core.skill.repository.FileSystemSkillRepository;
import io.agentscope.core.tool.Toolkit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
public final class SkillBoxFactory {
private static final Logger LOG = LoggerFactory.getLogger(SkillBoxFactory.class);
private SkillBoxFactory() {
}
public static SkillLoadResult build(Toolkit toolkit, AgentConfig agentConfig, List<String> allowedSkills) {
return build(toolkit, agentConfig, allowedSkills, null);
}
public static SkillLoadResult build(
Toolkit toolkit,
AgentConfig agentConfig,
List<String> allowedSkills,
Path workspaceDir) {
SkillsConfig skillsConfig = agentConfig.getSkills();
Path root = Path.of(skillsConfig.getPath()).normalize();
if (!Files.isDirectory(root)) {
return handleMissingRoot(root, skillsConfig, toolkit, workspaceDir);
}
try {
FileSystemSkillRepository repository = new FileSystemSkillRepository(root);
List<AgentSkill> allSkills = repository.getAllSkills();
List<AgentSkill> selected = selectSkills(allSkills, allowedSkills, skillsConfig);
SkillToolResolver toolResolver = new SkillToolResolver(skillsConfig);
SkillBox skillBox = createSkillBox(toolkit, workspaceDir);
Map<String, String> skillIdToName = new LinkedHashMap<>();
List<String> skillNames = new ArrayList<>();
for (AgentSkill skill : selected) {
skillIdToName.put(skill.getSkillId(), skill.getName());
skillNames.add(skill.getName());
List<Object> skillTools = toolResolver.instantiateTools(skill);
if (skillTools.isEmpty()) {
skillBox.registerSkill(skill);
} else {
for (Object tool : skillTools) {
skillBox.registration().skill(skill).tool(tool).apply();
}
}
}
return new SkillLoadResult(
skillBox,
Collections.unmodifiableMap(new LinkedHashMap<>(skillIdToName)),
List.copyOf(skillNames));
} catch (AgentConfigException e) {
throw e;
} catch (Exception e) {
if (skillsConfig.isStrict()) {
throw new AgentConfigException("Failed to load skills from: " + root, e);
}
LOG.warn("Failed to load skills from {}: {}", root, e.getMessage());
return new SkillLoadResult(createSkillBox(toolkit, workspaceDir), Map.of(), List.of());
}
}
private static SkillLoadResult handleMissingRoot(
Path root,
SkillsConfig skillsConfig,
Toolkit toolkit,
Path workspaceDir) {
String message = "Skills root not found: " + root;
if (skillsConfig.isStrict()) {
throw new AgentConfigException(message);
}
LOG.warn(message);
return new SkillLoadResult(createSkillBox(toolkit, workspaceDir), Map.of(), List.of());
}
private static SkillBox createSkillBox(Toolkit toolkit, Path workspaceDir) {
SkillBox skillBox = new SkillBox(toolkit);
if (workspaceDir != null) {
skillBox.codeExecution()
.workDir(workspaceDir.toAbsolutePath().normalize().toString())
.enable();
}
return skillBox;
}
private static List<AgentSkill> selectSkills(
List<AgentSkill> allSkills,
List<String> allowedSkills,
SkillsConfig skillsConfig) {
Map<String, AgentSkill> byName = allSkills.stream()
.collect(Collectors.toMap(
AgentSkill::getName,
skill -> skill,
(left, right) -> left,
LinkedHashMap::new));
Set<String> allowed = normalizeAllowedSkills(allowedSkills);
if (allowed.isEmpty()) {
return byName.values().stream()
.sorted(Comparator.comparing(AgentSkill::getName))
.toList();
}
List<String> missing = allowed.stream()
.filter(name -> !byName.containsKey(name))
.toList();
if (!missing.isEmpty()) {
String message = "Declared skills not found: " + missing;
if (skillsConfig.isStrict()) {
throw new AgentConfigException(message);
}
LOG.warn(message);
}
List<AgentSkill> selected = new ArrayList<>();
for (String name : allowed) {
AgentSkill skill = byName.get(name);
if (skill != null) {
selected.add(skill);
}
}
return selected;
}
private static Set<String> normalizeAllowedSkills(List<String> allowedSkills) {
if (allowedSkills == null || allowedSkills.isEmpty()) {
return Set.of();
}
return allowedSkills.stream()
.filter(s -> s != null && !s.isBlank())
.map(String::trim)
.collect(Collectors.toCollection(LinkedHashSet::new));
}
}

View File

@ -1,12 +0,0 @@
package com.yomahub.liteflow.agent.skill;
import io.agentscope.core.skill.SkillBox;
import java.util.List;
import java.util.Map;
public record SkillLoadResult(
SkillBox skillBox,
Map<String, String> skillIdToName,
List<String> skillNames) {
}

View File

@ -1,125 +0,0 @@
package com.yomahub.liteflow.agent.skill;
import com.yomahub.liteflow.agent.exception.AgentConfigException;
import com.yomahub.liteflow.property.agent.SkillsConfig;
import com.yomahub.liteflow.spi.ContextAware;
import com.yomahub.liteflow.spi.holder.ContextAwareHolder;
import io.agentscope.core.skill.AgentSkill;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
/**
* 把技能 frontmatter 中声明的 {@code tools} 解析为可注册的工具实例
*
* <p>{@code tools} 字段直接取自 agentscope 已经用 SnakeYAML 解析好的
* {@link AgentSkill#getMetadataValue(String)}因此无需再次读盘或自行解析 YAML
* 也天然支持 {@code tools: [a, b]} 这类行内数组写法解析范围严格限定在传入的技能上
* 不会因为目录中其它未被选中的技能配置错误而牵连本次构建
*/
final class SkillToolResolver {
private static final Logger LOG = LoggerFactory.getLogger(SkillToolResolver.class);
static final String TOOLS_METADATA_KEY = "tools";
private final SkillsConfig config;
SkillToolResolver(SkillsConfig config) {
this.config = config;
}
/**
* 解析并实例化指定技能声明的工具技能未声明 {@code tools} 时返回空列表
*
* <p>工具类优先从框架容器Spring/Solon按类型取已注册的 bean使其依赖注入生效
* 无容器未注册或容器访问异常时降级为反射实例化依赖注入不可用
*/
List<Object> instantiateTools(AgentSkill skill) {
List<Class<?>> classes = resolveToolClasses(skill);
if (classes.isEmpty()) {
return List.of();
}
ContextAware contextAware = ContextAwareHolder.loadContextAware();
List<Object> instances = new ArrayList<>(classes.size());
for (Class<?> clazz : classes) {
try {
instances.add(resolveToolInstance(contextAware, skill, clazz));
} catch (ReflectiveOperationException e) {
handleProblem("Skill '" + skill.getName() + "' tool class '" + clazz.getName()
+ "' instantiation failed", e);
}
}
return List.copyOf(instances);
}
private Object resolveToolInstance(ContextAware contextAware, AgentSkill skill, Class<?> clazz)
throws ReflectiveOperationException {
try {
if (contextAware.hasBean(clazz)) {
return contextAware.getBean(clazz);
}
} catch (Exception ex) {
// 容器未就绪 classpath spring ApplicationContext 尚未初始化等异常降级反射实例化
LOG.warn("Skill '{}' resolving tool '{}' from container failed ({}); "
+ "falling back to reflective instantiation",
skill.getName(), clazz.getName(), ex.toString());
}
Object instance = clazz.getDeclaredConstructor().newInstance();
LOG.info("Skill '{}' tool '{}' not found in container; fell back to reflective "
+ "instantiation, dependency injection unavailable", skill.getName(), clazz.getName());
return instance;
}
private List<Class<?>> resolveToolClasses(AgentSkill skill) {
Object toolsObj = skill.getMetadataValue(TOOLS_METADATA_KEY);
if (toolsObj == null) {
return List.of();
}
List<Class<?>> resolved = new ArrayList<>();
for (String className : toClassNameList(toolsObj)) {
try {
resolved.add(Class.forName(className));
} catch (ClassNotFoundException e) {
handleProblem("Skill '" + skill.getName() + "' references unknown tool class '"
+ className + "'", e);
}
}
if (!resolved.isEmpty()) {
LOG.info("Skill '{}' bound to tool classes: {}", skill.getName(),
resolved.stream().map(Class::getName).toList());
}
return resolved;
}
private static List<String> toClassNameList(Object field) {
if (field instanceof List<?> list) {
return list.stream()
.map(Object::toString)
.map(String::trim)
.filter(s -> !s.isEmpty())
.toList();
}
return Stream.of(field.toString().split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.toList();
}
private void handleProblem(String message, Exception e) {
if (config.isStrict()) {
if (e == null) {
throw new AgentConfigException(message);
}
throw new AgentConfigException(message, e);
}
if (e == null) {
LOG.warn(message);
} else {
LOG.warn("{}: {}", message, e.getMessage());
}
}
}

View File

@ -1,86 +0,0 @@
package com.yomahub.liteflow.agent.skill;
import io.agentscope.core.hook.Hook;
import io.agentscope.core.hook.HookEvent;
import io.agentscope.core.hook.PostActingEvent;
import io.agentscope.core.message.ContentBlock;
import io.agentscope.core.message.TextBlock;
import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.message.ToolUseBlock;
import reactor.core.publisher.Mono;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Tracks skills loaded by agentscope's skill-loading tool during a ReAct session.
*/
public class SkillTrackingHook implements Hook {
public static final String LOAD_SKILL_TOOL_NAME = "load_skill_through_path";
private static final String SKILL_ID_INPUT_KEY = "skillId";
private final Map<String, String> skillIdToName;
private final Set<String> usedSkills = Collections.synchronizedSet(new LinkedHashSet<>());
public SkillTrackingHook(Map<String, String> skillIdToName) {
this.skillIdToName = skillIdToName == null
? Map.of()
: Collections.unmodifiableMap(new LinkedHashMap<>(skillIdToName));
}
@Override
public <T extends HookEvent> Mono<T> onEvent(T event) {
if (event instanceof PostActingEvent postActingEvent) {
recordSkillLoad(postActingEvent.getToolUse(), postActingEvent.getToolResult());
}
return Mono.just(event);
}
public List<String> getUsedSkills() {
synchronized (usedSkills) {
return List.copyOf(usedSkills);
}
}
public void clear() {
usedSkills.clear();
}
private void recordSkillLoad(ToolUseBlock toolUse, ToolResultBlock toolResult) {
if (toolUse == null || !LOAD_SKILL_TOOL_NAME.equals(toolUse.getName()) || isErrorResult(toolResult)) {
return;
}
Map<String, Object> input = toolUse.getInput();
if (input == null || !input.containsKey(SKILL_ID_INPUT_KEY)) {
return;
}
Object skillId = input.get(SKILL_ID_INPUT_KEY);
if (skillId == null) {
return;
}
String skillName = skillIdToName.get(String.valueOf(skillId));
if (skillName != null) {
usedSkills.add(skillName);
}
}
private boolean isErrorResult(ToolResultBlock toolResult) {
if (toolResult == null || toolResult.getOutput() == null) {
return false;
}
for (ContentBlock block : toolResult.getOutput()) {
if (block instanceof TextBlock textBlock) {
String text = textBlock.getText();
if (text != null && text.startsWith("Error:")) {
return true;
}
}
}
return false;
}
}

View File

@ -1,128 +0,0 @@
package com.yomahub.liteflow.agent.tool;
import com.yomahub.liteflow.property.agent.AgentConfig;
import com.yomahub.liteflow.property.agent.ShellConfig;
import com.yomahub.liteflow.property.agent.ShellMode;
import io.agentscope.core.tool.Tool;
import io.agentscope.core.tool.ToolParam;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
public class ManagedShellCommandTool {
private final Path workspace;
private final ShellConfig shell;
public ManagedShellCommandTool(Path workspace, AgentConfig cfg) {
this.workspace = workspace.toAbsolutePath().normalize();
this.shell = cfg.getShell();
}
@Tool(name = "execute_shell_command",
description = "Execute a controlled shell command in the current workspace. Path traversal and blacklisted commands are blocked.")
public String executeCommand(
@ToolParam(name = "command", description = "Single command string (pipes && || are rejected)")
String command) {
if (shell.getMode() == ShellMode.DISABLED) {
return "{\"error\":\"shell execution denied by policy\"}";
}
if (command == null || command.isBlank()) {
return "{\"error\":\"empty command\"}";
}
if (containsUnsupportedShellSyntax(command)) {
return "{\"error\":\"unsupported shell syntax: pipes, redirection, and command chaining are not supported\"}";
}
String[] tokens = command.trim().split("\\s+");
String first = tokens[0];
if (shell.getMode() == ShellMode.WHITELIST && !shell.getWhitelist().contains(first)) {
return "{\"error\":\"command '" + first + "' not allowed by whitelist\"}";
}
if (shell.getMode() == ShellMode.BLACKLIST && shell.getBlacklist().contains(first)) {
return "{\"error\":\"command '" + first + "' not allowed by blacklist\"}";
}
try {
ProcessBuilder pb = new ProcessBuilder(Arrays.asList(tokens));
pb.directory(workspace.toFile());
pb.redirectErrorStream(true);
Process p = pb.start();
closeQuietly(p.getOutputStream());
ExecutorService outputReader = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "liteflow-agent-shell-output-reader");
t.setDaemon(true);
return t;
});
Future<String> outputFuture = outputReader.submit(() -> readLimited(p.getInputStream(), shell.getMaxOutputBytes()));
try {
boolean done = p.waitFor(shell.getTimeout().toMillis(), TimeUnit.MILLISECONDS);
if (!done) {
p.destroyForcibly();
closeQuietly(p.getInputStream());
outputFuture.cancel(true);
return "{\"error\":\"timeout after " + shell.getTimeout().toMillis() + "ms\"}";
}
return outputFuture.get(1, TimeUnit.SECONDS);
} catch (ExecutionException e) {
Throwable cause = e.getCause();
return "{\"error\":\"" + (cause == null ? e.getMessage() : cause.getMessage()).replace("\"", "'") + "\"}";
} catch (TimeoutException e) {
outputFuture.cancel(true);
return "{\"error\":\"output read timeout\"}";
} finally {
outputReader.shutdownNow();
}
} catch (IOException | InterruptedException e) {
if (e instanceof InterruptedException) Thread.currentThread().interrupt();
return "{\"error\":\"" + e.getMessage().replace("\"", "'") + "\"}";
}
}
private static boolean containsUnsupportedShellSyntax(String command) {
return command.contains("|")
|| command.contains("<")
|| command.contains(">")
|| command.contains("&&")
|| command.contains("||")
|| command.contains(";");
}
private static void closeQuietly(java.io.Closeable closeable) {
if (closeable == null) {
return;
}
try {
closeable.close();
} catch (IOException ignored) {
// ignore close failures while cleaning up process streams
}
}
private static String readLimited(InputStream in, long max) throws IOException {
byte[] buf = new byte[4096];
List<byte[]> chunks = new ArrayList<>();
long total = 0;
int n;
while ((n = in.read(buf)) > 0 && total < max) {
int toCopy = (int) Math.min(n, max - total);
byte[] c = new byte[toCopy];
System.arraycopy(buf, 0, c, 0, toCopy);
chunks.add(c);
total += toCopy;
}
byte[] all = new byte[(int) total];
int pos = 0;
for (byte[] c : chunks) { System.arraycopy(c, 0, all, pos, c.length); pos += c.length; }
return new String(all, StandardCharsets.UTF_8);
}
}

View File

@ -1,95 +0,0 @@
package com.yomahub.liteflow.agent.tool;
import com.yomahub.liteflow.property.agent.AgentConfig;
import io.agentscope.core.tool.Tool;
import io.agentscope.core.tool.ToolParam;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.ArrayList;
import java.util.List;
public class WorkspaceFileTools {
private final Path workspace;
private final long maxBytes;
private final int maxList;
public WorkspaceFileTools(Path workspace, AgentConfig cfg) {
this.workspace = workspace.toAbsolutePath().normalize();
this.maxBytes = cfg.getWorkspace().getMaxFileBytes();
this.maxList = cfg.getWorkspace().getMaxListSize();
}
@Tool(name = "read_file", description = "Read a text file in the current workspace")
public String readFile(
@ToolParam(name = "path", description = "Relative path") String path) {
Path p = resolveSafe(path);
try {
long size = Files.size(p);
if (size > maxBytes) {
byte[] buf = new byte[(int) maxBytes];
try (var in = Files.newInputStream(p)) {
int read = in.read(buf);
return new String(buf, 0, Math.max(0, read), StandardCharsets.UTF_8);
}
}
return Files.readString(p, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new RuntimeException("read_file failed: " + e.getMessage(), e);
}
}
@Tool(name = "write_file", description = "Write text to a file in the current workspace (overwrite)")
public String writeFile(
@ToolParam(name = "path", description = "Relative path") String path,
@ToolParam(name = "content", description = "File content") String content) {
Path p = resolveSafe(path);
try {
Files.createDirectories(p.getParent());
Files.writeString(p, content, StandardCharsets.UTF_8);
return "ok";
} catch (IOException e) {
throw new RuntimeException("write_file failed: " + e.getMessage(), e);
}
}
@Tool(name = "list_files", description = "List files in a workspace directory")
public List<String> listFiles(
@ToolParam(name = "path", required = false, description = "Relative path; defaults to current dir") String path) {
Path dir = resolveSafe(path == null || path.isEmpty() ? "." : path);
List<String> out = new ArrayList<>();
try (var ds = Files.newDirectoryStream(dir)) {
for (Path p : ds) {
out.add(workspace.relativize(p).toString());
if (out.size() >= maxList) break;
}
} catch (IOException e) {
throw new RuntimeException("list_files failed: " + e.getMessage(), e);
}
return out;
}
@Tool(name = "delete_file", description = "Delete a file in the current workspace")
public String deleteFile(
@ToolParam(name = "path", description = "Relative path") String path) {
Path p = resolveSafe(path);
try {
Files.deleteIfExists(p);
return "ok";
} catch (IOException e) {
throw new RuntimeException("delete_file failed: " + e.getMessage(), e);
}
}
private Path resolveSafe(String rel) {
if (rel == null) throw new SecurityException("path is null");
if (rel.startsWith("/")) throw new SecurityException("absolute path denied: " + rel);
Path abs = workspace.resolve(rel).toAbsolutePath().normalize();
if (!abs.startsWith(workspace)) {
throw new SecurityException("path escapes workspace: " + rel);
}
return abs;
}
}

View File

@ -0,0 +1,266 @@
package com.yomahub.liteflow.test.agent.v2spike;
import io.agentscope.core.ReActAgent;
import io.agentscope.core.agent.Event;
import io.agentscope.core.agent.EventType;
import io.agentscope.core.agent.RuntimeContext;
import io.agentscope.core.agent.StreamOptions;
import io.agentscope.core.event.AgentEventType;
import io.agentscope.core.message.MessageMetadataKeys;
import io.agentscope.core.message.Msg;
import io.agentscope.core.middleware.MiddlewareBase;
import io.agentscope.core.middleware.ModelCallInput;
import io.agentscope.core.model.AnthropicChatModel;
import io.agentscope.core.model.ChatResponse;
import io.agentscope.core.model.ChatUsage;
import io.agentscope.core.model.DashScopeChatModel;
import io.agentscope.core.model.GenerateOptions;
import io.agentscope.core.model.GeminiChatModel;
import io.agentscope.core.model.Model;
import io.agentscope.core.model.OpenAIChatModel;
import io.agentscope.core.permission.PermissionBehavior;
import io.agentscope.core.permission.PermissionContextState;
import io.agentscope.core.permission.PermissionMode;
import io.agentscope.core.permission.PermissionRule;
import io.agentscope.core.state.AgentStateStore;
import io.agentscope.core.state.InMemoryAgentStateStore;
import io.agentscope.core.state.JsonFileAgentStateStore;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import reactor.core.publisher.Flux;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Task 0 API spike against agentscope 2.0.0-RC3.
*
* <p>Confirms the real v2 signatures that the migration plan (written against the 1.0
* design doc's guesses) depends on. Findings are written back to
* {@code docs/superpowers/specs/v2-api-findings.md}; this test is a deliverable, not just
* the findings doc.
*
* <p>The probe is network-free: it never calls a real LLM. It builds lightweight objects /
* inspects method signatures via reflection, so it runs in CI without credentials.
*
* <p>R = requirement id from migration spec §16:
* <ul>
* <li><b>R1</b> AgentStateStore: NONE semantics + oob stores.</li>
* <li><b>R4</b> vendor ChatModel builder method names.</li>
* <li><b>R5</b> ChatUsage retrieval + MiddlewareBase model-call callback.</li>
* <li><b>R6</b> Permission rule shape (per-tool allow/deny).</li>
* <li><b>R7</b> JDK 21 baseline (this test compiling &amp; running IS the check).</li>
* </ul>
*/
class V2ApiProbe {
/** R1: confirm no HarnessAgent in v2; core agent stays ReActAgent (refutes plan's HarnessAgent guess). */
@Test
void r0_coreAgentIsReActAgent_notHarnessAgent() {
// The plan/spec assumed a v2 "HarnessAgent". In RC3 the public core agent is still
// io.agentscope.core.ReActAgent; "Harness*" only appears in sandbox extension Jackson modules.
assertNotNull(ReActAgent.builder(), "ReActAgent.builder() must exist");
Package pkg = ReActAgent.class.getPackage();
System.out.println("[R0] core agent class = " + ReActAgent.class.getName()
+ " (package " + pkg.getName() + ")");
System.out.println("[R0] HarnessAgent does NOT exist in agentscope core; "
+ "Harness* token only in sandbox jackson modules. Plan must target ReActAgent.");
}
/** R1: Builder.stateStore defaults to null; null stateStore = no persistence (NONE). */
@Test
void r1_noneStore_isNullStateStore() throws Exception {
// Build a ReActAgent WITHOUT stateStore and reflectively read the field.
// We cannot call build() without a model; instead reflect on the Builder field default.
ReActAgent.Builder b = ReActAgent.builder().name("probe").sysPrompt("s");
java.lang.reflect.Field f = ReActAgent.Builder.class.getDeclaredField("stateStore");
f.setAccessible(true);
Object defaultValue = f.get(b);
assertNull(defaultValue, "Builder.stateStore must default to null (=> NONE / no persistence)");
// OOB stores present:
AgentStateStore inMem = new InMemoryAgentStateStore();
AgentStateStore json = new JsonFileAgentStateStore(Path.of(System.getProperty("java.io.tmpdir"), "probe-state"));
System.out.println("[R1] Builder.stateStore default = " + defaultValue
+ " -> NONE persistence = omit .stateStore(...) (field stays null, ReActAgent skips save/load)");
System.out.println("[R1] OOB stores: InMemoryAgentStateStore=" + inMem.getClass().getName()
+ ", JsonFileAgentStateStore=" + json.getClass().getName());
System.out.println("[R1] No NoOp/NullAgentStateStore class in core; null-stateStore is the NONE path.");
// AgentStateStore interface shape
boolean hasSaveSingle = Arrays.stream(AgentStateStore.class.getMethods())
.anyMatch(m -> m.getName().equals("save")
&& m.getParameterCount() == 4
&& m.getParameterTypes()[0] == String.class
&& m.getParameterTypes()[1] == String.class
&& m.getParameterTypes()[2] == String.class);
boolean hasGet = Arrays.stream(AgentStateStore.class.getMethods())
.anyMatch(m -> m.getName().equals("get") && m.getParameterCount() == 4);
boolean hasListSessionIds = Arrays.stream(AgentStateStore.class.getMethods())
.anyMatch(m -> m.getName().equals("listSessionIds"));
assertTrue(hasSaveSingle && hasGet && hasListSessionIds,
"AgentStateStore: save(userId,sessionId,key,State) / get(...,Class) / listSessionIds(userId)");
System.out.println("[R1] AgentStateStore API: save(userId,sessionId,key,State|List<State>) | "
+ "<T> get(userId,sessionId,key,Class<T>) | listSessionIds(userId) -> Set<String>");
}
/** R4: confirm each vendor ChatModel builder exposes the canonical setters (names diverge per vendor). */
@Test
void r4_vendorChatModelBuilders() {
// OpenAIChatModel: apiKey/modelName/baseUrl/stream/generateOptions/formatter ALL present
Set<String> openai = builderMethods(OpenAIChatModel.Builder.class);
assertAll(openai, "OpenAIChatModel",
"apiKey", "modelName", "baseUrl", "stream", "generateOptions", "formatter");
// AnthropicChatModel: stream->stream, but gen options is defaultOptions (NOT generateOptions)
Set<String> anth = builderMethods(AnthropicChatModel.Builder.class);
assertAll(anth, "AnthropicChatModel",
"apiKey", "modelName", "baseUrl", "stream", "defaultOptions", "formatter");
assertFalse(anth.contains("generateOptions"),
"AnthropicChatModel.Builder has NO generateOptions -> use defaultOptions");
// GeminiChatModel: stream is streamEnabled (NOT stream)
Set<String> gem = builderMethods(GeminiChatModel.Builder.class);
assertAll(gem, "GeminiChatModel",
"apiKey", "modelName", "baseUrl", "defaultOptions", "formatter");
assertFalse(gem.contains("stream"), "GeminiChatModel.Builder stream flag is streamEnabled, not stream");
assertTrue(gem.contains("streamEnabled"), "GeminiChatModel.Builder has streamEnabled");
// DashScopeChatModel: stream + defaultOptions
Set<String> ds = builderMethods(DashScopeChatModel.Builder.class);
assertAll(ds, "DashScopeChatModel",
"apiKey", "modelName", "baseUrl", "stream", "defaultOptions", "formatter");
System.out.println("[R4] OpenAI builder methods: " + sorted(openai));
System.out.println("[R4] Anthropic builder methods: " + sorted(anth)
+ " (NOTE: defaultOptions, not generateOptions)");
System.out.println("[R4] Gemini builder methods: " + sorted(gem)
+ " (NOTE: streamEnabled, not stream)");
System.out.println("[R4] DashScope builder methods: " + sorted(ds));
}
/** R5: ChatUsage lives on Msg (lazy from metadata) + ChatResponse.getUsage(); middleware hook = onModelCall. */
@Test
void r5_chatUsage_retrieval() throws Exception {
// Msg has both getChatUsage() (lazy metadata) and getUsage() (direct field)
Method getChatUsage = Msg.class.getMethod("getChatUsage");
Method getUsage = Msg.class.getMethod("getUsage");
assertEquals(ChatUsage.class, getChatUsage.getReturnType(), "Msg.getChatUsage() -> ChatUsage");
assertEquals(ChatUsage.class, getUsage.getReturnType(), "Msg.getUsage() -> ChatUsage");
System.out.println("[R5] Msg.getChatUsage() -> " + getChatUsage.getReturnType().getSimpleName()
+ " (resolves from metadata key MessageMetadataKeys.CHAT_USAGE)");
System.out.println("[R5] MessageMetadataKeys.CHAT_USAGE exists = "
+ fieldExists(MessageMetadataKeys.class, "CHAT_USAGE"));
// ChatResponse.getUsage()
assertEquals(ChatUsage.class, ChatResponse.class.getMethod("getUsage").getReturnType());
// MiddlewareBase: onModelCall(agent, ctx, ModelCallInput, next) is the raw model-call hook.
// ModelCallInput carries messages+tools+options+model; the response/usage surfaces in the
// returned Flux<AgentEvent> (ModelCallEndEvent carries usage), or aggregate from Msg.getChatUsage().
Method onModelCall = MiddlewareBase.class.getMethod("onModelCall",
io.agentscope.core.agent.Agent.class, RuntimeContext.class,
ModelCallInput.class, Function.class);
assertEquals(Flux.class, onModelCall.getReturnType(),
"MiddlewareBase.onModelCall returns Flux<AgentEvent>");
System.out.println("[R5] MiddlewareBase.onModelCall(Agent, RuntimeContext, ModelCallInput, "
+ "Function<ModelCallInput,Flux<AgentEvent>>) -> Flux<AgentEvent>");
System.out.println("[R5] ModelCallInput record components = "
+ Arrays.stream(ModelCallInput.class.getRecordComponents())
.map(c -> c.getName() + ":" + c.getType().getSimpleName())
.collect(Collectors.joining(", ")));
// ModelCallEndEvent carries usage
try {
Class<?> mce = Class.forName("io.agentscope.core.event.ModelCallEndEvent");
boolean hasUsage = Arrays.stream(mce.getMethods()).anyMatch(m -> m.getName().equals("getUsage"));
System.out.println("[R5] ModelCallEndEvent.getUsage() present = " + hasUsage);
} catch (ClassNotFoundException e) {
System.out.println("[R5] ModelCallEndEvent not found on classpath");
}
}
/** R6: per-tool allow/deny/ask rules are NATIVE via PermissionContextState.Builder.addAllowRule(tool,rule). */
@Test
void r6_permissionRule_shape() {
// PermissionRule record: (toolName, ruleContent, behavior, source)
PermissionRule rule = new PermissionRule(
"shell", "command =~ '^ls .*'", PermissionBehavior.ALLOW, "test");
assertEquals("shell", rule.toolName());
assertEquals(PermissionBehavior.ALLOW, rule.behavior());
// Per-tool rules via Builder.addAllowRule/addDenyRule/addAskRule(toolName, rule)
PermissionContextState ctx = PermissionContextState.builder()
.mode(PermissionMode.DEFAULT)
.addAllowRule("shell", rule)
.build();
assertEquals(PermissionMode.DEFAULT, ctx.getMode());
assertTrue(ctx.getAllowRules().containsKey("shell"),
"PermissionContextState tracks per-tool allow rules by tool name");
// Enum values
System.out.println("[R6] PermissionMode values = " + Arrays.toString(PermissionMode.values()));
System.out.println("[R6] PermissionBehavior values = " + Arrays.toString(PermissionBehavior.values()));
System.out.println("[R6] PermissionRule record = (toolName, ruleContent, behavior, source)");
System.out.println("[R6] PermissionContextState.Builder.addAllowRule/addDenyRule/addAskRule(toolName, rule) "
+ "-> command-level allowlist/denylist is NATIVE");
System.out.println("[R6] sample allowRules keys = " + ctx.getAllowRules().keySet());
}
/** R7: this test compiling + running under JDK 21 against v2 RC3 IS the baseline confirmation. */
@Test
void r7_java_baseline() {
String javaVer = System.getProperty("java.version");
System.out.println("[R7] running on java.version=" + javaVer
+ " / " + System.getProperty("java.vm.name"));
// ReActAgent + vendor models + state store + permission all loaded on JDK 21 -> baseline OK.
assertNotNull(ReActAgent.builder());
assertNotNull(OpenAIChatModel.builder());
assertNotNull(StreamOptions.defaults());
assertNotNull(EventType.AGENT_RESULT);
// 1.0 streaming Event class still present (deprecated) informs Task 6.1 bridge strategy.
assertTrue(Arrays.stream(Event.class.getConstructors())
.anyMatch(c -> c.getParameterCount() >= 3), "1.0 Event(type,msg,isLast) ctor still present (deprecated)");
System.out.println("[R7] 1.0 io.agentscope.core.agent.EventType/Event/StreamOptions still present + "
+ "@Deprecated(since=2.0.0); new API = streamEvents() + AgentEventType.");
System.out.println("[R7] AgentEventType sample values = "
+ Arrays.toString(Arrays.stream(AgentEventType.values()).limit(6).toArray()));
}
/* ----- helpers ----- */
private static Set<String> builderMethods(Class<?> builderClass) {
return Arrays.stream(builderClass.getDeclaredMethods())
.filter(m -> m.getName().startsWith("set") == false)
.filter(m -> m.getReturnType() == builderClass)
.map(Method::getName)
.collect(Collectors.toSet());
}
private static void assertAll(Set<String> have, String label, String... names) {
for (String n : names) {
assertTrue(have.contains(n), label + ".Builder missing method: " + n
+ " (have " + sorted(have) + ")");
}
}
private static String sorted(Set<String> s) {
return s.stream().sorted().collect(Collectors.joining(", ", "[", "]"));
}
private static boolean fieldExists(Class<?> c, String name) {
try { c.getDeclaredField(name); return true; } catch (NoSuchFieldException e) { return false; }
}
}

View File

@ -90,7 +90,7 @@
<sharding-jdbc.version>4.1.1</sharding-jdbc.version>
<apache-commons-test.version>1.14.0</apache-commons-test.version>
<caffeine.version>2.9.3</caffeine.version>
<agentscope.version>1.0.12</agentscope.version>
<agentscope.version>2.0.0-RC3</agentscope.version>
<google-genai.version>1.38.0</google-genai.version>
<anthropic-java.version>1.2.0</anthropic-java.version>
</properties>