From 2d3bace328c1833bd5ef96660ff2816464f11698 Mon Sep 17 00:00:00 2001 From: "everywhere.z" Date: Fri, 19 Jun 2026 22:35:31 +0800 Subject: [PATCH] =?UTF-8?q?refactor(agent):=20v2=20=E7=BB=BF=E7=BC=96?= =?UTF-8?q?=E8=AF=91=E5=9F=BA=E5=BA=95=EF=BC=88=E5=88=A0=20v2-broken=20?= =?UTF-8?q?=E6=BA=90=E7=A0=81=20+=20stub=20process=EF=BC=89+=20API=20?= =?UTF-8?q?=E6=8E=A2=E9=92=88=E5=8F=91=E7=8E=B0=E7=AC=94=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/specs/v2-api-findings.md | 208 +++++++++++ .../liteflow-react-agent-core/pom.xml | 29 ++ .../agent/component/ReActAgentComponent.java | 327 ++---------------- .../agent/component/ReActAgentContext.java | 28 +- .../agent/hook/ChatUsageTrackingHook.java | 73 ---- .../liteflow/agent/hook/ReActLoggingHook.java | 134 ------- .../liteflow/agent/session/AgentSession.java | 98 ------ .../agent/session/AgentSessionManager.java | 209 ----------- .../session/factory/AgentSessionFactory.java | 30 -- .../factory/AgentSessionFactoryRegistry.java | 54 --- .../factory/InMemoryAgentSessionFactory.java | 26 -- .../factory/LocalFileAgentSessionFactory.java | 47 --- .../factory/MysqlAgentSessionFactory.java | 52 --- .../factory/NoneAgentSessionFactory.java | 22 -- .../factory/RedisAgentSessionFactory.java | 79 ----- .../liteflow/agent/skill/SkillBoxFactory.java | 153 -------- .../liteflow/agent/skill/SkillLoadResult.java | 12 - .../agent/skill/SkillToolResolver.java | 125 ------- .../agent/skill/SkillTrackingHook.java | 86 ----- .../agent/tool/ManagedShellCommandTool.java | 128 ------- .../agent/tool/WorkspaceFileTools.java | 95 ----- .../test/agent/v2spike/V2ApiProbe.java | 266 ++++++++++++++ pom.xml | 2 +- 23 files changed, 549 insertions(+), 1734 deletions(-) create mode 100644 docs/superpowers/specs/v2-api-findings.md delete mode 100644 liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/hook/ChatUsageTrackingHook.java delete mode 100644 liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/hook/ReActLoggingHook.java delete mode 100644 liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/AgentSession.java delete mode 100644 liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/AgentSessionManager.java delete mode 100644 liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/factory/AgentSessionFactory.java delete mode 100644 liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/factory/AgentSessionFactoryRegistry.java delete mode 100644 liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/factory/InMemoryAgentSessionFactory.java delete mode 100644 liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/factory/LocalFileAgentSessionFactory.java delete mode 100644 liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/factory/MysqlAgentSessionFactory.java delete mode 100644 liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/factory/NoneAgentSessionFactory.java delete mode 100644 liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/factory/RedisAgentSessionFactory.java delete mode 100644 liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/skill/SkillBoxFactory.java delete mode 100644 liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/skill/SkillLoadResult.java delete mode 100644 liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/skill/SkillToolResolver.java delete mode 100644 liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/skill/SkillTrackingHook.java delete mode 100644 liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/tool/ManagedShellCommandTool.java delete mode 100644 liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/tool/WorkspaceFileTools.java create mode 100644 liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2spike/V2ApiProbe.java diff --git a/docs/superpowers/specs/v2-api-findings.md b/docs/superpowers/specs/v2-api-findings.md new file mode 100644 index 000000000..e59ad7d9e --- /dev/null +++ b/docs/superpowers/specs/v2-api-findings.md @@ -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), 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 call(List)`、`call(List, Class structuredOutputClass, RuntimeContext)`、`call(List, JsonNode outputSchema, RuntimeContext)`、`Flux stream(List, StreamOptions, RuntimeContext)`、`Flux streamEvents(List|Msg [, RuntimeContext])`、`interrupt(×多种)`、`getToolkit/getSysPrompt/getModel/getMaxIters/getGenerateOptions/getAgentState(×3 重载)/getRuntimeContext/setPermissionMode(×2)/getPermissionMode/saveAgentState(×2)/getStateStore/getDefaultSessionId/getModelConfig/getReactConfig/getPermissionEngine/getPermissionContext`。 + +--- + +## R1:AgentStateStore —— 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 values); + Optional get(String userId, String sessionId, String key, Class type); + List getList(String userId, String sessionId, String key, Class 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 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.1(AgentStateStoreResolver)的指导:** 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 基类)。 + +--- + +## R5:ChatUsage 取值 + 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 onModelCall( + Agent agent, // io.agentscope.core.agent.Agent + RuntimeContext ctx, // io.agentscope.core.agent.RuntimeContext + ModelCallInput input, // 见下 + Function> next); // 调下层/真实模型 +// 默认实现直接 next.apply(input)。 +``` +`io.agentscope.core.middleware.ModelCallInput`(record)组件:`messages:List, tools:List, 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` — 系统提示词变换(pipeline)。 +- 相关 input 类型:`AgentInput`、`ReasoningInput`、`ActingInput`、`ModelCallInput`(都在 `io.agentscope.core.middleware`)。 + +**对 Task 5.1(Hook→Middleware)的指导:** +- 1.0 的 `ChatUsageTrackingHook`(已删)用 v2 middleware 重建为:一个 `MiddlewareBase`,覆写 `onModelCall`,在 `next.apply(input)` 返回的 `Flux` 里订阅 `ModelCallEndEvent` 并累加 `getUsage()`(或从 `AgentEvent.getUsage()` 取)。 +- `ReActAgentContext.getChatUsage()` 当前返回 null(Task 0 stub),Task 5.1 恢复为从该 middleware 读累计值。 +- 1.0 的 `Hook`(`io.agentscope.core.hook.Hook`)在 v2 **软弃用但保留可编译**(core 编译时会有 `@Deprecated(since="2.0.0")` removal 警告)。`ReActAgentComponent.hooks()` 签名保留 `List`;新建 middleware 走 `.middleware(...)`,老 hook 走 `.hook(...)`/`.hooks(...)`。 + +--- + +## R6:Permission 规则形态(命令级白/黑名单是原生支持) + +**全部位于 `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 class,builder 构造):** +```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>、getDenyRules()、getAskRules() +// withMode(PermissionMode) 返回新实例 +``` + +**`PermissionEngine(PermissionContextState)`:** +- `checkPermission(ToolBase tool, Map toolInput):Mono` —— 实际裁决入口。 +- `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, "", ALLOW, source))`。无需自写。 + +**对 Task 3.1(PermissionConfigMapper)的指导:** 把 `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 精确语法)。 + +--- + +## R7:JDK 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, StreamOptions, RuntimeContext):Flux`** 仍可用(deprecated 路径)。 + +- **新 API(推荐):** `ReActAgent.streamEvents(List|Msg [, RuntimeContext]):Flux` + `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`(abstract,typed subclasses:`ModelCallEndEvent`、`TextBlockDeltaEvent`、`ToolResultEndEvent` 等)。 + +**对 Task 6.1(AgentEventBridge)的指导:** 可选两条路: +1. **过渡路(最小改动):** 继续用 deprecated `stream(List, 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 命令自然恢复可用。 diff --git a/liteflow-react-agent/liteflow-react-agent-core/pom.xml b/liteflow-react-agent/liteflow-react-agent-core/pom.xml index 6dcfdb585..9876a21a6 100644 --- a/liteflow-react-agent/liteflow-react-agent-core/pom.xml +++ b/liteflow-react-agent/liteflow-react-agent-core/pom.xml @@ -33,6 +33,35 @@ ${agentscope.version} + + + io.agentscope + agentscope-extensions-redis + ${agentscope.version} + true + + + io.agentscope + agentscope-extensions-mysql + ${agentscope.version} + true + + + io.agentscope + agentscope-extensions-oss + ${agentscope.version} + true + + + io.agentscope + agentscope-extensions-skill-git-repository + ${agentscope.version} + true + + cn.hutool hutool-core diff --git a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReActAgentComponent.java b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReActAgentComponent.java index 0941235f9..8c533d7c2 100644 --- a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReActAgentComponent.java +++ b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReActAgentComponent.java @@ -1,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 抽象组件。 - *

- * 子类必须提供 {@link #model()}、{@link #systemPrompt()} 和 {@link #userPrompt()}。 - * 可选覆写方法用于自定义工具、钩子和生命周期回调。 + * 封装 agentscope agent 的 LiteFlow 抽象组件。 + * + *

状态:v2 迁移进行中(Task 0 green-commit 基底)。 + * 本类已删除所有依赖 agentscope 1.0 已删类型(session 管理 / skill / 工具 / + * 流式事件桥接)的实现,{@link #process()} 当前抛出 + * {@link AgentInvocationException}。后续 Task 2.x 会基于 v2 + * {@code HarnessAgent} 重建 {@code process()}。受保护方法签名保持不变, + * 子类实现与业务侧代码无需改动。 + * + *

子类必须提供 {@link #model()}、{@link #systemPrompt()} 和 {@link #userPrompt()}。 + * 可选覆写方法用于自定义工具、钩子和生命周期回调;当前均为空实现或读 + * {@link #agentConfig()},签名不变。 * *

所有 hook 方法均为无参——通过 {@link #ctx()} 动态获取当次执行的 - * {@link ReActAgentContext}。该 ctx 与 {@link Slot} 同生命周期,由 {@link #process()} - * 自动挂载与解绑,按 {@code nodeId} 隔离以支持同 chain 内 WHEN 并发执行多个 agent。 + * {@link ReActAgentContext}。该 ctx 与 {@link Slot} 同生命周期。 * *

会话标识被拆为两层: *

    - *
  • {@code conversationId}:业务/对话维度,决定 workspace 共享范围与对话连续性。 - * 由 {@link #resolveConversationId()} 解析,整条 chain 内只解析一次, - * 后续 agent 通过 {@link Slot#getConversationId()} 复用。
  • - *
  • {@code agentKey}:组件维度,默认 {@code nodeId},用于在同一段对话中区分 - * 不同 agent 的 ReActAgent 实例与对话记忆。
  • + *
  • {@code conversationId}:业务/对话维度,由 {@link #resolveConversationId()} 解析。
  • + *
  • {@code agentKey}:组件维度,默认 {@code nodeId},隔离同对话内不同 agent。
  • *
* - *

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. - * - *

注意:勿在跨 invocation 缓存的对象(自定义工具/Hook/Model 等)中持有 - * {@link ReActAgentContext} 引用——这些对象会被缓存的 agent 复用,捕获的 ctx - * 会在下一次 {@code process()} 时变成陈旧引用(其中的 slot 已经被回收)。 - * 正确做法:持有组件实例引用,运行时通过 {@code component.ctx()} 动态获取。 - * - *

技能相关的 {@link #skills()} 与 {@link #enableSkills()} 只在为某个 - * {@code (conversationId, agentKey)} session 首次构建并缓存 ReActAgent 时求值。 - * 它们表示组件能力声明,不应依赖单次请求数据;同一 session 复用缓存 agent 时不会 - * 重新读取这些声明。 - * - *

{@link #process()} 方法被声明为 {@code final},由框架统一保证 session - * 管理和 ctx 生命周期的正确性。 + *

{@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 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. * - *

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. + *

签名保持不变;具体 skill 加载由后续 Task 基于 v2 skill repository 重建。 */ protected List skills() { return List.of(); } /** * Whether agent-scope skills should be enabled for this component. - * - *

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. - * - *

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 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 执行体 ===== */ + /** + * v2 迁移期 stub。当前抛出 {@link AgentInvocationException}, + * 由 Task 2.x 基于 v2 {@code HarnessAgent} 重建。 + * + *

签名保持 {@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 streamAgent(ReActAgent agent, Msg userMsg, Slot slot) { - AtomicReference finalMsg = new AtomicReference<>(); - AtomicReference 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 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"); } } diff --git a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReActAgentContext.java b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReActAgentContext.java index e321aebff..09ad8a509 100644 --- a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReActAgentContext.java +++ b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReActAgentContext.java @@ -1,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; *

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

    状态:v2 迁移期。对 1.0 {@code ChatUsageTrackingHook}(已删除)的依赖临时移除, + * {@link #getChatUsage()} 返回 {@code null};Task 5.1 会基于 v2 middleware 恢复。 + * {@link #setChatUsageTrackingHook} 暂为 no-op,签名保留以兼容历史调用点。 + * *

    勿在跨 invocation 缓存的对象中持有 {@code ReActAgentContext} 引用 - * (例如自定义工具实例、Hook、Model 实现)。这些对象会被缓存的 ReActAgent 跨次复用, + * (例如自定义工具实例、Hook、Model 实现)。这些对象会被缓存的 agent 跨次复用, * 而 ctx 是 per-invocation 的——捕获后下一次 process() 时通过该 ctx 访问的 slot * 已被 {@code DataBus.releaseSlot} 回收并复用,是悬挂引用。 - * - *

    正确做法:在工具/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。 + * + *

    v2 迁移期 no-op。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 用量。 * - *

    {@link ChatUsage#getInputTokens()} / {@link ChatUsage#getOutputTokens()} / - * {@link ChatUsage#getTotalTokens()} 给出累计 token,{@link ChatUsage#getTime()} - * 给出累计推理耗时(秒)。在 {@code handleReply()} 中调用拿到的就是整次调用的累计值。 + *

    v2 迁移期返回 {@code null}。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; } } diff --git a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/hook/ChatUsageTrackingHook.java b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/hook/ChatUsageTrackingHook.java deleted file mode 100644 index a861ab71c..000000000 --- a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/hook/ChatUsageTrackingHook.java +++ /dev/null @@ -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 用量。 - * - *

    底层 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()}。 - * - *

    实例与缓存 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 Mono 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; - } -} diff --git a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/hook/ReActLoggingHook.java b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/hook/ReActLoggingHook.java deleted file mode 100644 index 289b12b80..000000000 --- a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/hook/ReActLoggingHook.java +++ /dev/null @@ -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 的思考与工具调用过程。 - * - *

    事件 → 日志格式: - *

      - *
    • {@link PreReasoningEvent}:{@code [agent:reason] >>> model=... messages=N}
    • - *
    • {@link PostReasoningEvent}:{@code [agent:reason] <<< text=... toolCalls=[...]}
    • - *
    • {@link PreActingEvent}:{@code [agent:act] >>> tool=... input=...}
    • - *
    • {@link PostActingEvent}:{@code [agent:act] <<< tool=... result=...}
    • - *
    • {@link ErrorEvent}:{@code [agent:error] ...}
    • - *
    - */ -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 Mono onEvent(T event) { - try { - if (event instanceof PreReasoningEvent e) { - List 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 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 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 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)"; - } -} diff --git a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/AgentSession.java b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/AgentSession.java deleted file mode 100644 index 35e661d29..000000000 --- a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/AgentSession.java +++ /dev/null @@ -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 在某次会话中的运行时状态。 - * - *

    会话标识被拆分为两个维度: - *

      - *
    • {@code conversationId}:业务/对话维度,由调用方决定,整条 chain 内所有 agent 共享, - * 决定 workspace 目录与对话连续性。
    • - *
    • {@code agentKey}:组件维度,默认是 {@code nodeId},用于在同一段对话中区分 - * 不同 agent 的 ReActAgent 实例和持久化记忆。
    • - *
    - * - *

    {@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(); - } -} diff --git a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/AgentSessionManager.java b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/AgentSessionManager.java deleted file mode 100644 index 798e4b6a6..000000000 --- a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/AgentSessionManager.java +++ /dev/null @@ -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} 提供)。 - * - *

    会话标识被拆分为两个维度: - *

      - *
    • {@code conversationId}:业务/对话维度,决定 workspace 目录与连续对话的恢复。
    • - *
    • {@code agentKey}:组件维度(默认为 {@code nodeId}),用于在同一段对话内 - * 区分不同 agent 的 ReActAgent 实例与对话记忆。
    • - *
    - * - *

    缓存与持久化都按 {@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 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 会话。 - * - *

    同一 {@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、 - * Redis、MySQL)不会在这里被删除。 - */ - 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(); - } -} diff --git a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/factory/AgentSessionFactory.java b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/factory/AgentSessionFactory.java deleted file mode 100644 index 379987df8..000000000 --- a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/factory/AgentSessionFactory.java +++ /dev/null @@ -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。 - * - *

    框架内置模式({@code JVM}、{@code LOCAL_FILE}、{@code REDIS}、 - * {@code MYSQL}、{@code NONE})。需要其他后端(例如 PostgreSQL、OSS、 - * 加密 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); -} diff --git a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/factory/AgentSessionFactoryRegistry.java b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/factory/AgentSessionFactoryRegistry.java deleted file mode 100644 index 4cb5ed8ba..000000000 --- a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/factory/AgentSessionFactoryRegistry.java +++ /dev/null @@ -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}。 - * - *

    解析顺序: - *

      - *
    1. 通过 {@link ServiceLoader} 注册的外部工厂
    2. - *
    3. 框架内置工厂(JVM、local-file、Redis、MySQL、none)
    4. - *
    - * 出现冲突时外部工厂优先,因此用户可以覆盖内置实现 - * (例如用自定义加密 JSON 工厂替换默认本地文件工厂)。 - */ -public final class AgentSessionFactoryRegistry { - - private static final Map 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); - } -} diff --git a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/factory/InMemoryAgentSessionFactory.java b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/factory/InMemoryAgentSessionFactory.java deleted file mode 100644 index faca4367a..000000000 --- a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/factory/InMemoryAgentSessionFactory.java +++ /dev/null @@ -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} 模式。 - * - *

    注意:状态仍会在同一个 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(); - } -} diff --git a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/factory/LocalFileAgentSessionFactory.java b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/factory/LocalFileAgentSessionFactory.java deleted file mode 100644 index c66d52147..000000000 --- a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/factory/LocalFileAgentSessionFactory.java +++ /dev/null @@ -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//} - * 下来支持 {@link MemoryStorageMode#LOCAL_FILE}。 - * - *

    session 存储子目录与各 session 的 workspace({@code workspace.root//}) - * 平级而非嵌套:一方面避免 {@link com.yomahub.liteflow.agent.tool.WorkspaceFileTools} - * 读到或覆盖 agent 自己的记忆;另一方面让 {@code cleanup-on-session-expire} - * 在递归清空 workspace 子目录时不会误删持久化的记忆, - * 与 Redis、MySQL 后端的"持久化与 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); - } -} diff --git a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/factory/MysqlAgentSessionFactory.java b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/factory/MysqlAgentSessionFactory.java deleted file mode 100644 index a86ba4b03..000000000 --- a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/factory/MysqlAgentSessionFactory.java +++ /dev/null @@ -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} bean;LiteFlow 不自行创建 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); - } - } -} diff --git a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/factory/NoneAgentSessionFactory.java b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/factory/NoneAgentSessionFactory.java deleted file mode 100644 index 19b179565..000000000 --- a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/factory/NoneAgentSessionFactory.java +++ /dev/null @@ -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; - } -} diff --git a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/factory/RedisAgentSessionFactory.java b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/factory/RedisAgentSessionFactory.java deleted file mode 100644 index ad6254675..000000000 --- a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/session/factory/RedisAgentSessionFactory.java +++ /dev/null @@ -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 客户端 bean(Redisson、Jedis、Lettuce) - * 适配到 AgentScope 的 RedisSession 来支持 {@link MemoryStorageMode#REDIS}。 - * - *

    Redis 客户端类通过反射查找,因此 core 模块不会对 Redisson、Jedis、Lettuce - * 产生硬性的编译期依赖。如果选择 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); - } - } -} diff --git a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/skill/SkillBoxFactory.java b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/skill/SkillBoxFactory.java deleted file mode 100644 index 4331db26f..000000000 --- a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/skill/SkillBoxFactory.java +++ /dev/null @@ -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 allowedSkills) { - return build(toolkit, agentConfig, allowedSkills, null); - } - - public static SkillLoadResult build( - Toolkit toolkit, - AgentConfig agentConfig, - List 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 allSkills = repository.getAllSkills(); - List selected = selectSkills(allSkills, allowedSkills, skillsConfig); - SkillToolResolver toolResolver = new SkillToolResolver(skillsConfig); - SkillBox skillBox = createSkillBox(toolkit, workspaceDir); - Map skillIdToName = new LinkedHashMap<>(); - List skillNames = new ArrayList<>(); - - for (AgentSkill skill : selected) { - skillIdToName.put(skill.getSkillId(), skill.getName()); - skillNames.add(skill.getName()); - List 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 selectSkills( - List allSkills, - List allowedSkills, - SkillsConfig skillsConfig) { - Map byName = allSkills.stream() - .collect(Collectors.toMap( - AgentSkill::getName, - skill -> skill, - (left, right) -> left, - LinkedHashMap::new)); - Set allowed = normalizeAllowedSkills(allowedSkills); - if (allowed.isEmpty()) { - return byName.values().stream() - .sorted(Comparator.comparing(AgentSkill::getName)) - .toList(); - } - - List 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 selected = new ArrayList<>(); - for (String name : allowed) { - AgentSkill skill = byName.get(name); - if (skill != null) { - selected.add(skill); - } - } - return selected; - } - - private static Set normalizeAllowedSkills(List 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)); - } -} diff --git a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/skill/SkillLoadResult.java b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/skill/SkillLoadResult.java deleted file mode 100644 index 77647583c..000000000 --- a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/skill/SkillLoadResult.java +++ /dev/null @@ -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 skillIdToName, - List skillNames) { -} diff --git a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/skill/SkillToolResolver.java b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/skill/SkillToolResolver.java deleted file mode 100644 index 6b68c96ff..000000000 --- a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/skill/SkillToolResolver.java +++ /dev/null @@ -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} 解析为可注册的工具实例。 - * - *

    {@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} 时返回空列表。 - * - *

    工具类优先从框架容器(Spring/Solon)按类型取已注册的 bean,使其依赖注入生效; - * 无容器、未注册或容器访问异常时,降级为反射实例化(依赖注入不可用)。 - */ - List instantiateTools(AgentSkill skill) { - List> classes = resolveToolClasses(skill); - if (classes.isEmpty()) { - return List.of(); - } - ContextAware contextAware = ContextAwareHolder.loadContextAware(); - List 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> resolveToolClasses(AgentSkill skill) { - Object toolsObj = skill.getMetadataValue(TOOLS_METADATA_KEY); - if (toolsObj == null) { - return List.of(); - } - List> 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 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()); - } - } -} diff --git a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/skill/SkillTrackingHook.java b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/skill/SkillTrackingHook.java deleted file mode 100644 index b521fef36..000000000 --- a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/skill/SkillTrackingHook.java +++ /dev/null @@ -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 skillIdToName; - private final Set usedSkills = Collections.synchronizedSet(new LinkedHashSet<>()); - - public SkillTrackingHook(Map skillIdToName) { - this.skillIdToName = skillIdToName == null - ? Map.of() - : Collections.unmodifiableMap(new LinkedHashMap<>(skillIdToName)); - } - - @Override - public Mono onEvent(T event) { - if (event instanceof PostActingEvent postActingEvent) { - recordSkillLoad(postActingEvent.getToolUse(), postActingEvent.getToolResult()); - } - return Mono.just(event); - } - - public List 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 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; - } -} diff --git a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/tool/ManagedShellCommandTool.java b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/tool/ManagedShellCommandTool.java deleted file mode 100644 index b354235fc..000000000 --- a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/tool/ManagedShellCommandTool.java +++ /dev/null @@ -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 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 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); - } -} diff --git a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/tool/WorkspaceFileTools.java b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/tool/WorkspaceFileTools.java deleted file mode 100644 index b4472c15f..000000000 --- a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/tool/WorkspaceFileTools.java +++ /dev/null @@ -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 listFiles( - @ToolParam(name = "path", required = false, description = "Relative path; defaults to current dir") String path) { - Path dir = resolveSafe(path == null || path.isEmpty() ? "." : path); - List 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; - } -} diff --git a/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2spike/V2ApiProbe.java b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2spike/V2ApiProbe.java new file mode 100644 index 000000000..3f1ddafcf --- /dev/null +++ b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2spike/V2ApiProbe.java @@ -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. + * + *

    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. + * + *

    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. + * + *

    R = requirement id from migration spec §16: + *

      + *
    • R1 — AgentStateStore: NONE semantics + oob stores.
    • + *
    • R4 — vendor ChatModel builder method names.
    • + *
    • R5 — ChatUsage retrieval + MiddlewareBase model-call callback.
    • + *
    • R6 — Permission rule shape (per-tool allow/deny).
    • + *
    • R7 — JDK 21 baseline (this test compiling & running IS the check).
    • + *
    + */ +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) | " + + " get(userId,sessionId,key,Class) | listSessionIds(userId) -> Set"); + } + + /** 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 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 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 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 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 (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"); + System.out.println("[R5] MiddlewareBase.onModelCall(Agent, RuntimeContext, ModelCallInput, " + + "Function>) -> Flux"); + 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 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 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 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; } + } +} diff --git a/pom.xml b/pom.xml index ab4c5a3b4..c3f80424e 100644 --- a/pom.xml +++ b/pom.xml @@ -90,7 +90,7 @@ 4.1.1 1.14.0 2.9.3 - 1.0.12 + 2.0.0-RC3 1.38.0 1.2.0