mirror of https://gitee.com/dromara/liteFlow
refactor(agent): v2 绿编译基底(删 v2-broken 源码 + stub process)+ API 探针发现笔记
This commit is contained in:
parent
e3220a4977
commit
2d3bace328
|
|
@ -0,0 +1,208 @@
|
|||
# v2 API 探针发现笔记(agentscope 2.0.0-RC3)
|
||||
|
||||
**生成自:** Task 0 `V2ApiProbe`(`liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2spike/V2ApiProbe.java`),针对 **agentscope `2.0.0-RC3`** 真实 jar 实测通过(6/6 测试绿)。JDK 21.0.2。
|
||||
|
||||
**适用规则:** 后续每个 Task 凡用到本笔记列出的 API,**必须与笔记核对**;若笔记与计划代码冲突,**以笔记为准**并回填修正计划。
|
||||
|
||||
---
|
||||
|
||||
## 0. 关键修正:v2 没有 `HarnessAgent`,核心 agent 仍是 `ReActAgent`
|
||||
|
||||
迁移 spec(`2026-06-19-agentscope-v2-migration-design.md`)的 R1/R2/R3 及多处描述基于"v2 引入 `HarnessAgent`"的**推测**。**实测 RC3 中 `io.agentscope.core.HarnessAgent` 不存在**——agentscope core jar 的核心 agent 类仍是:
|
||||
|
||||
```
|
||||
io.agentscope.core.ReActAgent (extends AgentBase implements AutoCloseable)
|
||||
```
|
||||
|
||||
`Harness*` token 只出现在 sandbox 扩展的 Jackson module 命名(`KubernetesHarnessSandboxJacksonModule` 等),与 agent 编排无关。
|
||||
|
||||
**影响(重大):**
|
||||
- Task 2.1/2.2/2.3 凡是写 `HarnessAgent.builder()` / `HarnessAgentFactory` 的,**全部改为 `ReActAgent.builder()`**。
|
||||
- spec 的 `RuntimeContext` 引用是对的:`io.agentscope.core.agent.RuntimeContext` 确实存在并被 `ReActAgent` 使用。
|
||||
- `ReActAgent.builder()` 暴露 `.stateStore(...)`、`.middleware(...)`、`.middlewares(...)`、`.permissionContext(...)`、`.skillRepository(...)`、`.longTermMemory(...)`、`.knowledge(...)`、`.ragMode(...)` 等 v2 能力,**直接就在核心 agent 的 builder 上**,不需要换类。
|
||||
|
||||
`ReActAgent.Builder` 全量方法(实测):
|
||||
`name, description, checkRunning, sysPrompt, model(Model)|model(String modelId), toolkit, maxIters, hook(Hook), hooks(List<Hook>), middleware(MiddlewareBase), middlewares(List), enableMetaTool, enableTaskList(×2), enablePendingToolRecovery, modelExecutionConfig, toolExecutionConfig, generateOptions, toolExecutionContext, stateStore(AgentStateStore), defaultSessionId, maxRetries, fallbackModel(×2), stopOnReject, permissionContext(PermissionContextState), longTermMemory, longTermMemoryMode, longTermMemoryAsyncRecord, knowledge, knowledges, ragMode, retrieveConfig, skillBox(SkillBox), skillRepository(AgentSkillRepository), skillRepositories(List), skillFilter, dynamicSkillsEnabled, skillCodeExecutionEnabled, skillWorkDir(Path)`,工厂 `ReActAgent.builder()` / `Builder.fromAgent(agent)`。
|
||||
|
||||
`ReActAgent` 关键执行方法(实测):`Mono<Msg> call(List<Msg>)`、`call(List<Msg>, Class<?> structuredOutputClass, RuntimeContext)`、`call(List<Msg>, JsonNode outputSchema, RuntimeContext)`、`Flux<Event> stream(List<Msg>, StreamOptions, RuntimeContext)`、`Flux<AgentEvent> streamEvents(List<Msg>|Msg [, RuntimeContext])`、`interrupt(×多种)`、`getToolkit/getSysPrompt/getModel/getMaxIters/getGenerateOptions/getAgentState(×3 重载)/getRuntimeContext/setPermissionMode(×2)/getPermissionMode/saveAgentState(×2)/getStateStore/getDefaultSessionId/getModelConfig/getReactConfig/getPermissionEngine/getPermissionContext`。
|
||||
|
||||
---
|
||||
|
||||
## 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<? extends State> values);
|
||||
<T extends State> Optional<T> get(String userId, String sessionId, String key, Class<T> type);
|
||||
<T extends State> List<T> getList(String userId, String sessionId, String key, Class<T> itemType);
|
||||
boolean exists(String userId, String sessionId);
|
||||
void delete(String userId, String sessionId);
|
||||
default void delete(String userId, String sessionId, String key); // 默认 no-op
|
||||
Set<String> listSessionIds(String userId);
|
||||
default void close(); // 默认 no-op
|
||||
```
|
||||
|
||||
- **slot 寻址是 `(userId, sessionId)` 对**,不是 1.0 的 `AgentSession.cacheKey`/workspace 目录。`userId` 可空(`null` = 匿名/单租户)。实现自决定如何拼存储 key。
|
||||
- `JsonFileAgentStateStore`:`save(List)` 增量 append;`InMemoryAgentStateStore`:`save(List)` 全量替换。调用方始终传完整 list。
|
||||
- `State` 是 marker 接口(`io.agentscope.core.state.State`);`AgentState`、`Task`、`ToolkitState`、`ToolContextState`、`PlanModeContextState`、`SessionInfo` 等是其实现。
|
||||
|
||||
**对 Task 2.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<AgentEvent> onModelCall(
|
||||
Agent agent, // io.agentscope.core.agent.Agent
|
||||
RuntimeContext ctx, // io.agentscope.core.agent.RuntimeContext
|
||||
ModelCallInput input, // 见下
|
||||
Function<ModelCallInput, Flux<AgentEvent>> next); // 调下层/真实模型
|
||||
// 默认实现直接 next.apply(input)。
|
||||
```
|
||||
`io.agentscope.core.middleware.ModelCallInput`(record)组件:`messages:List<Msg>, tools:List<ToolSchema>, options:GenerateOptions, model:Model`。
|
||||
|
||||
**MiddlewareBase 的全套钩子(实测,onion 模式 4 个 + pipeline 1 个):**
|
||||
- `onAgent(agent, ctx, AgentInput, next)` — 整个 agent 调用。
|
||||
- `onReasoning(agent, ctx, ReasoningInput, next)` — reasoning 阶段(LLM 调用 + 流式解析)。
|
||||
- `onActing(agent, ctx, ActingInput, next)` — 工具执行阶段。
|
||||
- `onModelCall(agent, ctx, ModelCallInput, next)` — **原始模型 API 调用**(拿 usage 的最底层钩子)。
|
||||
- `onSystemPrompt(agent, ctx, currentPrompt):Mono<String>` — 系统提示词变换(pipeline)。
|
||||
- 相关 input 类型:`AgentInput`、`ReasoningInput`、`ActingInput`、`ModelCallInput`(都在 `io.agentscope.core.middleware`)。
|
||||
|
||||
**对 Task 5.1(Hook→Middleware)的指导:**
|
||||
- 1.0 的 `ChatUsageTrackingHook`(已删)用 v2 middleware 重建为:一个 `MiddlewareBase`,覆写 `onModelCall`,在 `next.apply(input)` 返回的 `Flux<AgentEvent>` 里订阅 `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<Hook>`;新建 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<String,List<PermissionRule>>、getDenyRules()、getAskRules()
|
||||
// withMode(PermissionMode) 返回新实例
|
||||
```
|
||||
|
||||
**`PermissionEngine(PermissionContextState)`:**
|
||||
- `checkPermission(ToolBase tool, Map<String,Object> toolInput):Mono<PermissionDecision>` —— 实际裁决入口。
|
||||
- `getContext()`、`addRule(PermissionRule)`、`getAllowRules/getDenyRules/getAskRules`。
|
||||
- 通过 `ReActAgent.getPermissionEngine()` / `getPermissionContext()` 读回。
|
||||
|
||||
**`PermissionDecision`(final class):** `getBehavior():PermissionBehavior`、`getMessage()`、`getDecisionReason()`、`getUpdatedInput()`、`getSuggestedRules()`;静态工厂 `allow/deny/ask/passthrough(String)`;builder 同名。
|
||||
|
||||
**结论:** "命令名 ∈ 白名单才允许 execute"这种细粒度规则**原生支持**——通过 `PermissionContextState.Builder.addAllowRule(toolName, new PermissionRule(toolName, "<matcher>", ALLOW, source))`。无需自写。
|
||||
|
||||
**对 Task 3.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<Msg>, StreamOptions, RuntimeContext):Flux<Event>`** 仍可用(deprecated 路径)。
|
||||
|
||||
- **新 API(推荐):** `ReActAgent.streamEvents(List<Msg>|Msg [, RuntimeContext]):Flux<AgentEvent>` + `io.agentscope.core.event.AgentEventType`(`AGENT_START, AGENT_END, AGENT_RESULT, MODEL_CALL_START, MODEL_CALL_END, TEXT_BLOCK_*, THINKING_BLOCK_*, DATA_BLOCK_*, TOOL_CALL_*, TOOL_RESULT_*` 等)+ `io.agentscope.core.event.AgentEvent`(abstract,typed subclasses:`ModelCallEndEvent`、`TextBlockDeltaEvent`、`ToolResultEndEvent` 等)。
|
||||
|
||||
**对 Task 6.1(AgentEventBridge)的指导:** 可选两条路:
|
||||
1. **过渡路(最小改动):** 继续用 deprecated `stream(List<Msg>, StreamOptions)` + `EventType`→FlowEvent 映射(1.0 映射逻辑基本复用)。短期可行,但消费 deprecated API,未来版本可能真删。
|
||||
2. **新 API 路(推荐):** 改用 `streamEvents(...)` + `AgentEventType`→FlowEvent 映射,拿到更细粒度事件(model_call_end 带 usage、text_block_delta 真流式等)。Task 6.1 决策时权衡。
|
||||
|
||||
---
|
||||
|
||||
## 探针运行说明(偏离记录)
|
||||
|
||||
`V2ApiProbe` 是 `liteflow-testcase-el/liteflow-testcase-el-react-agent` 模块下的 JUnit5 测试。但 Task 0 删除 v2-broken 源类后,**同一测试模块内既有的 feature 测试**(`ShellToolsAgentCmp` / `WorkspaceToolsAgentCmp` / `SkillsAgentCmp` 等)引用了已删的 `ManagedShellCommandTool` / `WorkspaceFileTools` / `usedSkills()`,导致该模块 **test-compile 失败**,brief Step 6 给的 `mvn -pl ... -Dtest=V2ApiProbe ... test` 无法在 core 外跑通(这是预期的 WIP 窗口——既有 react-agent 端到端测试由后续 Task 重建)。
|
||||
|
||||
为满足"探针要真实跑通"的交付要求,探针的**编译与执行**用直接 `javac` + JUnit Platform Launcher API 完成(不修改 sibling 测试、不改 `skipTests`):
|
||||
- `mvn -pl liteflow-testcase-el/liteflow-testcase-el-react-agent -am dependency:build-classpath` 取真实 classpath(含 agentscope 2.0.0-RC3 与 junit-jupiter 5.8.2)。
|
||||
- `javac` 编译 `V2ApiProbe.java`(+ react-agent-core 已编译 classes)→ 干净通过(仅 deprecation 注释)。
|
||||
- JUnit Platform Launcher(`junit-platform-launcher:1.8.2`,匹配 jupiter 5.8.2)执行 `selectClass(V2ApiProbe)` → **6 tests, 0 failed**。
|
||||
|
||||
探针源文件本身放在 brief 指定路径,是可被未来 `mvn test` 直接运行的合规 JUnit5 测试;当前仅因 sibling 测试未随 Task 0 一并更新而无法用单条 mvn 命令跑通。后续 Task 重建 feature 测试后该 mvn 命令自然恢复可用。
|
||||
|
|
@ -33,6 +33,35 @@
|
|||
<version>${agentscope.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- v2 optional extensions: used at runtime only when the corresponding
|
||||
persistence/skill mode is enabled. Declared optional so that a missing
|
||||
extension (rename/removal in a later RC) does not break the build.
|
||||
Resolved artifactIds confirmed for 2.0.0-RC3 on Maven Central. -->
|
||||
<dependency>
|
||||
<groupId>io.agentscope</groupId>
|
||||
<artifactId>agentscope-extensions-redis</artifactId>
|
||||
<version>${agentscope.version}</version>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.agentscope</groupId>
|
||||
<artifactId>agentscope-extensions-mysql</artifactId>
|
||||
<version>${agentscope.version}</version>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.agentscope</groupId>
|
||||
<artifactId>agentscope-extensions-oss</artifactId>
|
||||
<version>${agentscope.version}</version>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.agentscope</groupId>
|
||||
<artifactId>agentscope-extensions-skill-git-repository</artifactId>
|
||||
<version>${agentscope.version}</version>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>cn.hutool</groupId>
|
||||
<artifactId>hutool-core</artifactId>
|
||||
|
|
|
|||
|
|
@ -1,89 +1,47 @@
|
|||
package com.yomahub.liteflow.agent.component;
|
||||
|
||||
import com.yomahub.liteflow.agent.exception.AgentConfigException;
|
||||
import com.yomahub.liteflow.agent.hook.ChatUsageTrackingHook;
|
||||
import com.yomahub.liteflow.agent.hook.ReActLoggingHook;
|
||||
import com.yomahub.liteflow.agent.skill.SkillBoxFactory;
|
||||
import com.yomahub.liteflow.agent.skill.SkillLoadResult;
|
||||
import com.yomahub.liteflow.agent.skill.SkillTrackingHook;
|
||||
import com.yomahub.liteflow.agent.session.AgentSession;
|
||||
import com.yomahub.liteflow.agent.session.AgentSessionManager;
|
||||
import com.yomahub.liteflow.agent.tool.ManagedShellCommandTool;
|
||||
import com.yomahub.liteflow.agent.tool.WorkspaceFileTools;
|
||||
import com.yomahub.liteflow.agent.exception.AgentInvocationException;
|
||||
import com.yomahub.liteflow.agent.model.ModelSpec;
|
||||
import com.yomahub.liteflow.core.NodeComponent;
|
||||
import com.yomahub.liteflow.flow.FlowEvent;
|
||||
import com.yomahub.liteflow.flow.FlowEventPublisher;
|
||||
import com.yomahub.liteflow.property.LiteflowConfigGetter;
|
||||
import com.yomahub.liteflow.property.agent.AgentConfig;
|
||||
import com.yomahub.liteflow.property.agent.MemoryStorageConfig;
|
||||
import com.yomahub.liteflow.property.agent.ShellMode;
|
||||
import com.yomahub.liteflow.slot.Slot;
|
||||
import com.yomahub.liteflow.util.ConversationIdGenerator;
|
||||
import io.agentscope.core.ReActAgent;
|
||||
import io.agentscope.core.agent.Event;
|
||||
import io.agentscope.core.agent.EventType;
|
||||
import io.agentscope.core.agent.StreamOptions;
|
||||
import io.agentscope.core.hook.Hook;
|
||||
import io.agentscope.core.memory.InMemoryMemory;
|
||||
import io.agentscope.core.message.Msg;
|
||||
import com.yomahub.liteflow.agent.model.ModelSpec;
|
||||
import io.agentscope.core.model.Model;
|
||||
import io.agentscope.core.skill.SkillBox;
|
||||
import io.agentscope.core.tool.Toolkit;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* 封装 agentscope ReActAgent 的 LiteFlow 抽象组件。
|
||||
* <p>
|
||||
* 子类必须提供 {@link #model()}、{@link #systemPrompt()} 和 {@link #userPrompt()}。
|
||||
* 可选覆写方法用于自定义工具、钩子和生命周期回调。
|
||||
* 封装 agentscope agent 的 LiteFlow 抽象组件。
|
||||
*
|
||||
* <p><b>状态:v2 迁移进行中(Task 0 green-commit 基底)。</b>
|
||||
* 本类已删除所有依赖 agentscope 1.0 已删类型(session 管理 / skill / 工具 /
|
||||
* 流式事件桥接)的实现,{@link #process()} 当前抛出
|
||||
* {@link AgentInvocationException}。后续 Task 2.x 会基于 v2
|
||||
* {@code HarnessAgent} 重建 {@code process()}。受保护方法签名保持不变,
|
||||
* 子类实现与业务侧代码无需改动。
|
||||
*
|
||||
* <p>子类必须提供 {@link #model()}、{@link #systemPrompt()} 和 {@link #userPrompt()}。
|
||||
* 可选覆写方法用于自定义工具、钩子和生命周期回调;当前均为空实现或读
|
||||
* {@link #agentConfig()},签名不变。
|
||||
*
|
||||
* <p>所有 hook 方法均为无参——通过 {@link #ctx()} 动态获取当次执行的
|
||||
* {@link ReActAgentContext}。该 ctx 与 {@link Slot} 同生命周期,由 {@link #process()}
|
||||
* 自动挂载与解绑,按 {@code nodeId} 隔离以支持同 chain 内 WHEN 并发执行多个 agent。
|
||||
* {@link ReActAgentContext}。该 ctx 与 {@link Slot} 同生命周期。
|
||||
*
|
||||
* <p>会话标识被拆为两层:
|
||||
* <ul>
|
||||
* <li>{@code conversationId}:业务/对话维度,决定 workspace 共享范围与对话连续性。
|
||||
* 由 {@link #resolveConversationId()} 解析,整条 chain 内只解析一次,
|
||||
* 后续 agent 通过 {@link Slot#getConversationId()} 复用。</li>
|
||||
* <li>{@code agentKey}:组件维度,默认 {@code nodeId},用于在同一段对话中区分
|
||||
* 不同 agent 的 ReActAgent 实例与对话记忆。</li>
|
||||
* <li>{@code conversationId}:业务/对话维度,由 {@link #resolveConversationId()} 解析。</li>
|
||||
* <li>{@code agentKey}:组件维度,默认 {@code nodeId},隔离同对话内不同 agent。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>When {@code liteflow.agent.skills.enabled=true}, the component can load
|
||||
* agent-scope skills from {@code liteflow.agent.skills.path}. Override
|
||||
* {@link #skills()} to restrict the component to a fixed allow-list; an empty
|
||||
* list means all configured skills are available. The allow-list and
|
||||
* {@link #enableSkills()} are evaluated only when the cached ReActAgent is built
|
||||
* for a {@code (conversationId, agentKey)} session, so they are stable component
|
||||
* capability declarations and should not depend on request data.
|
||||
*
|
||||
* <p><b>注意:勿在跨 invocation 缓存的对象(自定义工具/Hook/Model 等)中持有
|
||||
* {@link ReActAgentContext} 引用</b>——这些对象会被缓存的 agent 复用,捕获的 ctx
|
||||
* 会在下一次 {@code process()} 时变成陈旧引用(其中的 slot 已经被回收)。
|
||||
* 正确做法:持有组件实例引用,运行时通过 {@code component.ctx()} 动态获取。
|
||||
*
|
||||
* <p>技能相关的 {@link #skills()} 与 {@link #enableSkills()} 只在为某个
|
||||
* {@code (conversationId, agentKey)} session 首次构建并缓存 ReActAgent 时求值。
|
||||
* 它们表示组件能力声明,不应依赖单次请求数据;同一 session 复用缓存 agent 时不会
|
||||
* 重新读取这些声明。
|
||||
*
|
||||
* <p>{@link #process()} 方法被声明为 {@code final},由框架统一保证 session
|
||||
* 管理和 ctx 生命周期的正确性。
|
||||
* <p>{@link #process()} 方法被声明为 {@code final},由框架统一保证。
|
||||
*/
|
||||
public abstract class ReActAgentComponent extends NodeComponent {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ReActAgentComponent.class);
|
||||
|
||||
public static final String FLOW_EVENT_TYPE_REASONING = "agent.reasoning";
|
||||
public static final String FLOW_EVENT_TYPE_TOOL_RESULT = "agent.tool_result";
|
||||
public static final String FLOW_EVENT_TYPE_SUMMARY = "agent.summary";
|
||||
|
|
@ -104,19 +62,11 @@ public abstract class ReActAgentComponent extends NodeComponent {
|
|||
/** 在 Slot attachment 上存储 ctx 时使用的 key 前缀,按 nodeId 隔离。 */
|
||||
private static final String CTX_KEY_PREFIX = "_react_agent_ctx_";
|
||||
|
||||
/** 在 Slot attachment 上存储技能跟踪 Hook 时使用的 key 前缀,按 nodeId 隔离。 */
|
||||
private static final String SKILL_HOOK_KEY_PREFIX = "_react_agent_skill_hook_";
|
||||
|
||||
private String ctxKey() {
|
||||
String nodeId = getNodeId();
|
||||
return CTX_KEY_PREFIX + (nodeId == null ? "default" : nodeId);
|
||||
}
|
||||
|
||||
private String skillHookKey() {
|
||||
String nodeId = getNodeId();
|
||||
return SKILL_HOOK_KEY_PREFIX + (nodeId == null ? "default" : nodeId);
|
||||
}
|
||||
|
||||
/* ===== 框架提供的 final 访问器 ===== */
|
||||
|
||||
/**
|
||||
|
|
@ -170,7 +120,7 @@ public abstract class ReActAgentComponent extends NodeComponent {
|
|||
protected abstract String systemPrompt();
|
||||
|
||||
/**
|
||||
* 返回传递给底层 ReActAgent 的最终系统提示词。
|
||||
* 返回传递给底层 agent 的最终系统提示词。
|
||||
*/
|
||||
protected final String effectiveSystemPrompt() {
|
||||
String customPrompt = systemPrompt();
|
||||
|
|
@ -185,10 +135,10 @@ public abstract class ReActAgentComponent extends NodeComponent {
|
|||
*/
|
||||
protected abstract String userPrompt();
|
||||
|
||||
/* ===== 可选覆写 ===== */
|
||||
/* ===== 可选覆写(签名保持不变;v2 迁移期返回空实现或读 config) ===== */
|
||||
|
||||
/**
|
||||
* 提供要注册到 agent {@link Toolkit} 中的额外工具对象。
|
||||
* 提供要注册到 agent toolkit 中的额外工具对象。
|
||||
* 默认返回空列表。
|
||||
*/
|
||||
protected List<Object> tools() { return List.of(); }
|
||||
|
|
@ -196,34 +146,15 @@ public abstract class ReActAgentComponent extends NodeComponent {
|
|||
/**
|
||||
* Return skill names this component may use. Empty means all configured skills.
|
||||
*
|
||||
* <p>This is evaluated only when the cached ReActAgent is built for a
|
||||
* {@code (conversationId, agentKey)} session. Treat it as a stable component
|
||||
* capability declaration; do not vary it per request.
|
||||
* <p>签名保持不变;具体 skill 加载由后续 Task 基于 v2 skill repository 重建。
|
||||
*/
|
||||
protected List<String> skills() { return List.of(); }
|
||||
|
||||
/**
|
||||
* Whether agent-scope skills should be enabled for this component.
|
||||
*
|
||||
* <p>This is evaluated only when the cached ReActAgent is built for a
|
||||
* {@code (conversationId, agentKey)} session. Treat it as a stable component
|
||||
* capability declaration; do not vary it per request.
|
||||
*/
|
||||
protected boolean enableSkills() { return agentConfig().getSkills().isEnabled(); }
|
||||
|
||||
/**
|
||||
* Return skill names loaded by this agent during the current invocation.
|
||||
*
|
||||
* <p>This is available only while this component's {@link #process()} body has
|
||||
* bound the invocation skill hook, including calls from {@link #userPrompt()},
|
||||
* tool callbacks, and {@link #handleReply(Msg)}. After {@code process()} final
|
||||
* cleanup, later lifecycle callbacks must not rely on it.
|
||||
*/
|
||||
protected final List<String> usedSkills() {
|
||||
SkillTrackingHook hook = getSlot().getAttachment(skillHookKey());
|
||||
return hook == null ? List.of() : hook.getUsedSkills();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析本次执行的 {@code conversationId}。
|
||||
*
|
||||
|
|
@ -277,211 +208,15 @@ public abstract class ReActAgentComponent extends NodeComponent {
|
|||
|
||||
/* ===== 框架 final 执行体 ===== */
|
||||
|
||||
/**
|
||||
* <b>v2 迁移期 stub。</b>当前抛出 {@link AgentInvocationException},
|
||||
* 由 Task 2.x 基于 v2 {@code HarnessAgent} 重建。
|
||||
*
|
||||
* <p>签名保持 {@code final},与 1.0 一致。
|
||||
*/
|
||||
@Override
|
||||
public final void process() throws Exception {
|
||||
AgentConfig cfg = agentConfig();
|
||||
AgentSessionManager mgr = AgentSessionManagerHolder.getOrCreate(cfg);
|
||||
MemoryStorageConfig mc = cfg.getSession().getMemory();
|
||||
Slot slot = this.getSlot();
|
||||
|
||||
String cid = resolveConversationId();
|
||||
slot.setConversationId(cid);
|
||||
|
||||
String akey = agentKey();
|
||||
AgentSession session = mgr.acquire(cid, akey);
|
||||
session.getLock().lock();
|
||||
try {
|
||||
ReActAgentContext ctx = new ReActAgentContext(
|
||||
slot, session.getConversationId(), session.getAgentKey(), session.getWorkspaceDir());
|
||||
slot.setAttachment(ctxKey(), ctx);
|
||||
try {
|
||||
ReActAgent agent = (ReActAgent) session.getAgent();
|
||||
if (agent == null) {
|
||||
BuiltAgent built = buildAgent();
|
||||
agent = built.agent();
|
||||
session.setSkillTrackingHook(built.skillTrackingHook());
|
||||
session.setChatUsageTrackingHook(built.chatUsageTrackingHook());
|
||||
mgr.loadIfExists(session, agent);
|
||||
session.setAgent(agent);
|
||||
}
|
||||
SkillTrackingHook skillHook = session.getSkillTrackingHook();
|
||||
if (skillHook != null) {
|
||||
skillHook.clear();
|
||||
slot.setAttachment(skillHookKey(), skillHook);
|
||||
}
|
||||
ChatUsageTrackingHook usageHook = session.getChatUsageTrackingHook();
|
||||
if (usageHook != null) {
|
||||
usageHook.reset();
|
||||
ctx.setChatUsageTrackingHook(usageHook);
|
||||
}
|
||||
Throwable processError = null;
|
||||
try {
|
||||
Msg userMsg = Msg.builder().textContent(userPrompt()).build();
|
||||
Msg reply = callAgent(agent, userMsg, slot);
|
||||
handleReply(reply);
|
||||
} catch (Throwable t) {
|
||||
processError = t;
|
||||
throw t;
|
||||
} finally {
|
||||
boolean shouldSave = (processError == null) ? mc.isSaveAfterCall() : mc.isSaveOnError();
|
||||
if (shouldSave) {
|
||||
try {
|
||||
mgr.save(session, agent);
|
||||
} catch (Exception persistEx) {
|
||||
if (processError != null) {
|
||||
processError.addSuppressed(persistEx);
|
||||
} else {
|
||||
LOG.warn("session memory save failed for cacheKey={}",
|
||||
session.getCacheKey(), persistEx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
slot.removeAttachment(ctxKey());
|
||||
slot.removeAttachment(skillHookKey());
|
||||
}
|
||||
} finally {
|
||||
session.getLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private Msg callAgent(ReActAgent agent, Msg userMsg, Slot slot) {
|
||||
if (!FlowEventPublisher.hasListener(slot)) {
|
||||
return agent.call(List.of(userMsg)).block();
|
||||
}
|
||||
return streamAgent(agent, userMsg, slot).block();
|
||||
}
|
||||
|
||||
private Mono<Msg> streamAgent(ReActAgent agent, Msg userMsg, Slot slot) {
|
||||
AtomicReference<Msg> finalMsg = new AtomicReference<>();
|
||||
AtomicReference<Msg> fallbackFinalMsg = new AtomicReference<>();
|
||||
StreamOptions options = StreamOptions.builder()
|
||||
.eventTypes(EventType.REASONING, EventType.TOOL_RESULT, EventType.SUMMARY, EventType.AGENT_RESULT)
|
||||
.incremental(true)
|
||||
.build();
|
||||
|
||||
return agent.stream(List.of(userMsg), options)
|
||||
.doOnNext(event -> {
|
||||
if (event.getType() == EventType.AGENT_RESULT) {
|
||||
finalMsg.set(event.getMessage());
|
||||
} else if (event.isLast()) {
|
||||
fallbackFinalMsg.set(event.getMessage());
|
||||
}
|
||||
publishAgentEvent(slot, event);
|
||||
})
|
||||
.then(Mono.defer(() -> {
|
||||
Msg msg = finalMsg.get();
|
||||
if (msg != null) {
|
||||
return Mono.just(msg);
|
||||
}
|
||||
Msg fallback = fallbackFinalMsg.get();
|
||||
return fallback == null ? Mono.empty() : Mono.just(fallback);
|
||||
}));
|
||||
}
|
||||
|
||||
private void publishAgentEvent(Slot slot, Event event) {
|
||||
String type = toFlowEventType(event.getType());
|
||||
if (type == null) {
|
||||
return;
|
||||
}
|
||||
Msg msg = event.getMessage();
|
||||
FlowEventPublisher.publish(slot, FlowEvent.builder()
|
||||
.type(type)
|
||||
.chainId(slot.getChainId())
|
||||
.nodeId(getNodeId())
|
||||
.requestId(slot.getRequestId())
|
||||
.conversationId(slot.getConversationId())
|
||||
.text(msg == null ? null : msg.getTextContent())
|
||||
.last(event.isLast())
|
||||
.data(event)
|
||||
.build());
|
||||
}
|
||||
|
||||
private String toFlowEventType(EventType type) {
|
||||
if (type == EventType.REASONING) {
|
||||
return FLOW_EVENT_TYPE_REASONING;
|
||||
}
|
||||
if (type == EventType.TOOL_RESULT) {
|
||||
return FLOW_EVENT_TYPE_TOOL_RESULT;
|
||||
}
|
||||
if (type == EventType.SUMMARY) {
|
||||
return FLOW_EVENT_TYPE_SUMMARY;
|
||||
}
|
||||
if (type == EventType.AGENT_RESULT) {
|
||||
return FLOW_EVENT_TYPE_RESULT;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private record BuiltAgent(ReActAgent agent, SkillTrackingHook skillTrackingHook,
|
||||
ChatUsageTrackingHook chatUsageTrackingHook) {
|
||||
}
|
||||
|
||||
private BuiltAgent buildAgent() {
|
||||
AgentConfig cfg = agentConfig();
|
||||
int iters = maxIterations() > 0 ? maxIterations() : cfg.getDefaults().getMaxIterations();
|
||||
ReActAgentContext ctx = ctx();
|
||||
|
||||
Toolkit toolkit = new Toolkit();
|
||||
tools().forEach(toolkit::registerTool);
|
||||
if (enableWorkspaceFileTools()) {
|
||||
toolkit.registerTool(new WorkspaceFileTools(ctx.getWorkspaceDir(), cfg));
|
||||
}
|
||||
if (enableShellTool() && cfg.getShell().getMode() != ShellMode.DISABLED) {
|
||||
toolkit.registerTool(new ManagedShellCommandTool(ctx.getWorkspaceDir(), cfg));
|
||||
}
|
||||
|
||||
List<Hook> allHooks = new ArrayList<>(hooks());
|
||||
if (enableReActLogging()) {
|
||||
allHooks.add(new ReActLoggingHook(ctx.getConversationId() + ":" + ctx.getAgentKey()));
|
||||
}
|
||||
|
||||
ChatUsageTrackingHook chatUsageTrackingHook = new ChatUsageTrackingHook();
|
||||
allHooks.add(chatUsageTrackingHook);
|
||||
|
||||
SkillTrackingHook skillTrackingHook = null;
|
||||
SkillBox skillBox = null;
|
||||
if (enableSkills()) {
|
||||
SkillLoadResult skillLoadResult = SkillBoxFactory.build(toolkit, cfg, skills(), ctx.getWorkspaceDir());
|
||||
skillBox = skillLoadResult.skillBox();
|
||||
skillTrackingHook = new SkillTrackingHook(skillLoadResult.skillIdToName());
|
||||
allHooks.add(skillTrackingHook);
|
||||
}
|
||||
|
||||
ReActAgent.Builder builder = ReActAgent.builder()
|
||||
.name(getNodeId() == null ? "liteflow-agent" : getNodeId())
|
||||
.sysPrompt(effectiveSystemPrompt())
|
||||
.model(buildModel())
|
||||
.toolkit(toolkit)
|
||||
.memory(new InMemoryMemory())
|
||||
.maxIters(iters)
|
||||
.hooks(allHooks);
|
||||
|
||||
if (skillBox != null) {
|
||||
builder.skillBox(skillBox);
|
||||
}
|
||||
|
||||
return new BuiltAgent(builder.build(), skillTrackingHook, chatUsageTrackingHook);
|
||||
}
|
||||
|
||||
/** 持有单例 AgentSessionManager;首次 process() 时懒创建。 */
|
||||
static final class AgentSessionManagerHolder {
|
||||
private static volatile AgentSessionManager INSTANCE;
|
||||
static AgentSessionManager getOrCreate(AgentConfig cfg) {
|
||||
AgentSessionManager cur = INSTANCE;
|
||||
if (cur != null) return cur;
|
||||
synchronized (AgentSessionManagerHolder.class) {
|
||||
if (INSTANCE == null) INSTANCE = new AgentSessionManager(cfg);
|
||||
return INSTANCE;
|
||||
}
|
||||
}
|
||||
static void resetForTesting() {
|
||||
AgentSessionManager cur = INSTANCE;
|
||||
if (cur != null) {
|
||||
try { cur.close(); } catch (Exception ignored) {}
|
||||
}
|
||||
INSTANCE = null;
|
||||
}
|
||||
public final void process() {
|
||||
throw new AgentInvocationException(
|
||||
"ReActAgentComponent v2 migration in progress; process() is rebuilt in Task 2.3");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
package com.yomahub.liteflow.agent.component;
|
||||
|
||||
import com.yomahub.liteflow.agent.hook.ChatUsageTrackingHook;
|
||||
import com.yomahub.liteflow.slot.Slot;
|
||||
import io.agentscope.core.model.ChatUsage;
|
||||
|
||||
|
|
@ -17,20 +16,20 @@ import java.util.Objects;
|
|||
* <li>{@link #getWorkspaceDir()}:按 conversationId 创建,同一段对话中的多个 agent 共享。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>状态:v2 迁移期。</b>对 1.0 {@code ChatUsageTrackingHook}(已删除)的依赖临时移除,
|
||||
* {@link #getChatUsage()} 返回 {@code null};Task 5.1 会基于 v2 middleware 恢复。
|
||||
* {@link #setChatUsageTrackingHook} 暂为 no-op,签名保留以兼容历史调用点。
|
||||
*
|
||||
* <p><b>勿在跨 invocation 缓存的对象中持有 {@code ReActAgentContext} 引用</b>
|
||||
* (例如自定义工具实例、Hook、Model 实现)。这些对象会被缓存的 ReActAgent 跨次复用,
|
||||
* (例如自定义工具实例、Hook、Model 实现)。这些对象会被缓存的 agent 跨次复用,
|
||||
* 而 ctx 是 per-invocation 的——捕获后下一次 process() 时通过该 ctx 访问的 slot
|
||||
* 已被 {@code DataBus.releaseSlot} 回收并复用,是悬挂引用。
|
||||
*
|
||||
* <p>正确做法:在工具/Model 类中持有组件实例引用,运行时通过
|
||||
* {@code component.ctx()} 动态获取当次 ctx。
|
||||
*/
|
||||
public class ReActAgentContext {
|
||||
private final Slot slot;
|
||||
private final String conversationId;
|
||||
private final String agentKey;
|
||||
private final Path workspaceDir;
|
||||
private volatile ChatUsageTrackingHook chatUsageTrackingHook;
|
||||
|
||||
public ReActAgentContext(Slot slot, String conversationId, String agentKey, Path workspaceDir) {
|
||||
this.slot = Objects.requireNonNull(slot, "slot");
|
||||
|
|
@ -49,22 +48,23 @@ public class ReActAgentContext {
|
|||
|
||||
/**
|
||||
* 由框架注入:本次 {@code process()} 调用使用的 token 累加 hook。
|
||||
*
|
||||
* <p><b>v2 迁移期 no-op。</b>1.0 的 {@code ChatUsageTrackingHook} 已删除,
|
||||
* 这里仅保留方法签名以兼容调用点;Task 5.1 重建后改回真实注入。
|
||||
*/
|
||||
public void setChatUsageTrackingHook(ChatUsageTrackingHook hook) {
|
||||
this.chatUsageTrackingHook = hook;
|
||||
public void setChatUsageTrackingHook(Object hook) {
|
||||
// no-op: 1.0 ChatUsageTrackingHook 已删除;Task 5.1 恢复
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回本次 {@code process()} 截至当前已累计的 token 用量。
|
||||
*
|
||||
* <p>{@link ChatUsage#getInputTokens()} / {@link ChatUsage#getOutputTokens()} /
|
||||
* {@link ChatUsage#getTotalTokens()} 给出累计 token,{@link ChatUsage#getTime()}
|
||||
* 给出累计推理耗时(秒)。在 {@code handleReply()} 中调用拿到的就是整次调用的累计值。
|
||||
* <p><b>v2 迁移期返回 {@code null}。</b>1.0 的 {@code ChatUsageTrackingHook}
|
||||
* 已删除,待 Task 5.1 基于 v2 middleware 重建后再恢复真实累计值。
|
||||
*
|
||||
* @return 累计 ChatUsage;若未观察到任何 usage(模型未上报或 reply 为 null)则返回 {@code null}
|
||||
* @return 当前固定返回 {@code null}(Task 5.1 恢复后给出累计 ChatUsage)
|
||||
*/
|
||||
public ChatUsage getChatUsage() {
|
||||
ChatUsageTrackingHook hook = this.chatUsageTrackingHook;
|
||||
return hook == null ? null : hook.snapshot();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,73 +0,0 @@
|
|||
package com.yomahub.liteflow.agent.hook;
|
||||
|
||||
import io.agentscope.core.hook.Hook;
|
||||
import io.agentscope.core.hook.HookEvent;
|
||||
import io.agentscope.core.hook.PostReasoningEvent;
|
||||
import io.agentscope.core.message.Msg;
|
||||
import io.agentscope.core.model.ChatUsage;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* 累加单次 {@code process()} 调用内所有 reasoning step 的 token 用量。
|
||||
*
|
||||
* <p>底层 agentscope 每次 {@code reasoning(iter)} 都新建一个 {@link io.agentscope.core.agent.accumulator.ReasoningContext
|
||||
* ReasoningContext},因此 {@link PostReasoningEvent#getReasoningMessage()} 的 metadata 中携带的
|
||||
* {@link ChatUsage} 只是本步 LLM call 的累计(流式聚合),不是跨多步 ReAct 循环的累计。
|
||||
* 本 hook 在每次 PostReasoningEvent 触发时把当步 usage 累加到内部计数器,
|
||||
* 暴露整次调用累计后的 {@link #snapshot()}。
|
||||
*
|
||||
* <p>实例与缓存 ReActAgent 同生命周期;每次 {@code process()} 开始前必须调用 {@link #reset()}
|
||||
* 清零,避免上次调用的余量被带入。
|
||||
*/
|
||||
public class ChatUsageTrackingHook implements Hook {
|
||||
|
||||
private int inputTokens;
|
||||
private int outputTokens;
|
||||
private double time;
|
||||
private int steps;
|
||||
|
||||
@Override
|
||||
public synchronized <T extends HookEvent> Mono<T> onEvent(T event) {
|
||||
if (event instanceof PostReasoningEvent e) {
|
||||
Msg msg = e.getReasoningMessage();
|
||||
if (msg != null) {
|
||||
ChatUsage usage = msg.getChatUsage();
|
||||
if (usage != null) {
|
||||
inputTokens += usage.getInputTokens();
|
||||
outputTokens += usage.getOutputTokens();
|
||||
time += usage.getTime();
|
||||
steps++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Mono.just(event);
|
||||
}
|
||||
|
||||
public synchronized void reset() {
|
||||
this.inputTokens = 0;
|
||||
this.outputTokens = 0;
|
||||
this.time = 0;
|
||||
this.steps = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回到目前为止累计的 token 用量;若尚未观察到任何 usage,返回 {@code null}。
|
||||
*/
|
||||
public synchronized ChatUsage snapshot() {
|
||||
if (steps == 0) {
|
||||
return null;
|
||||
}
|
||||
return ChatUsage.builder()
|
||||
.inputTokens(inputTokens)
|
||||
.outputTokens(outputTokens)
|
||||
.time(time)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 已经累加过 usage 的 reasoning step 次数。
|
||||
*/
|
||||
public synchronized int getSteps() {
|
||||
return steps;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
package com.yomahub.liteflow.agent.hook;
|
||||
|
||||
import io.agentscope.core.hook.ErrorEvent;
|
||||
import io.agentscope.core.hook.Hook;
|
||||
import io.agentscope.core.hook.HookEvent;
|
||||
import io.agentscope.core.hook.PostActingEvent;
|
||||
import io.agentscope.core.hook.PostReasoningEvent;
|
||||
import io.agentscope.core.hook.PreActingEvent;
|
||||
import io.agentscope.core.hook.PreReasoningEvent;
|
||||
import io.agentscope.core.message.Msg;
|
||||
import io.agentscope.core.message.ToolResultBlock;
|
||||
import io.agentscope.core.message.ToolUseBlock;
|
||||
import io.agentscope.core.message.ThinkingBlock;
|
||||
import io.agentscope.core.model.ChatUsage;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 把 agentscope ReActAgent 的内部 Reason / Act / Error 事件输出到日志,
|
||||
* 让 LiteFlow 用户在终端可以直接看到 agent 的思考与工具调用过程。
|
||||
*
|
||||
* <p>事件 → 日志格式:
|
||||
* <ul>
|
||||
* <li>{@link PreReasoningEvent}:{@code [agent:reason] >>> model=... messages=N}</li>
|
||||
* <li>{@link PostReasoningEvent}:{@code [agent:reason] <<< text=... toolCalls=[...]}</li>
|
||||
* <li>{@link PreActingEvent}:{@code [agent:act] >>> tool=... input=...}</li>
|
||||
* <li>{@link PostActingEvent}:{@code [agent:act] <<< tool=... result=...}</li>
|
||||
* <li>{@link ErrorEvent}:{@code [agent:error] ...}</li>
|
||||
* </ul>
|
||||
*/
|
||||
public class ReActLoggingHook implements Hook {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ReActLoggingHook.class);
|
||||
private static final int MAX_TEXT_LEN = 500;
|
||||
private static final int MAX_THINKING_LEN = 1000;
|
||||
private static final int MAX_RESULT_LEN = 2000;
|
||||
|
||||
private final String sessionId;
|
||||
|
||||
public ReActLoggingHook(String sessionId) {
|
||||
this.sessionId = sessionId == null ? "-" : sessionId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T extends HookEvent> Mono<T> onEvent(T event) {
|
||||
try {
|
||||
if (event instanceof PreReasoningEvent e) {
|
||||
List<Msg> msgs = e.getInputMessages();
|
||||
LOG.info("[agent:reason][{}] >>> model={} messages={}",
|
||||
sessionId, e.getModelName(), msgs == null ? 0 : msgs.size());
|
||||
} else if (event instanceof PostReasoningEvent e) {
|
||||
Msg reply = e.getReasoningMessage();
|
||||
String thinking = extractThinking(reply);
|
||||
String text = truncate(reply.getTextContent(), MAX_TEXT_LEN);
|
||||
List<ToolUseBlock> tools = reply == null
|
||||
? List.of()
|
||||
: reply.getContentBlocks(ToolUseBlock.class);
|
||||
|
||||
if (!thinking.isEmpty()) {
|
||||
LOG.info("[agent:reason][{}] <<< thinking={}", sessionId, thinking);
|
||||
}
|
||||
if (!text.isEmpty() || !tools.isEmpty()) {
|
||||
if (tools.isEmpty()) {
|
||||
LOG.info("[agent:reason][{}] <<< text={}", sessionId, text);
|
||||
} else {
|
||||
LOG.info("[agent:reason][{}] <<< text={} toolCalls={}",
|
||||
sessionId, text, summarizeToolUses(tools));
|
||||
}
|
||||
}
|
||||
ChatUsage usage = reply == null ? null : reply.getChatUsage();
|
||||
if (usage != null) {
|
||||
LOG.info("[agent:reason][{}] <<< usage input={} output={} total={} time={}s",
|
||||
sessionId,
|
||||
usage.getInputTokens(),
|
||||
usage.getOutputTokens(),
|
||||
usage.getTotalTokens(),
|
||||
usage.getTime());
|
||||
}
|
||||
} else if (event instanceof PreActingEvent e) {
|
||||
ToolUseBlock t = e.getToolUse();
|
||||
LOG.info("[agent:act][{}] >>> tool={} input={}",
|
||||
sessionId, t.getName(), truncate(String.valueOf(t.getInput()), MAX_TEXT_LEN));
|
||||
} else if (event instanceof PostActingEvent e) {
|
||||
ToolResultBlock r = e.getToolResult();
|
||||
String result = blocksToString(r);
|
||||
LOG.info("[agent:act][{}] <<< {} 结果:", sessionId, r.getName());
|
||||
LOG.info("[agent:act][{}] {}", sessionId, truncate(result, MAX_RESULT_LEN));
|
||||
} else if (event instanceof ErrorEvent e) {
|
||||
LOG.warn("[agent:error][{}] {}", sessionId, e.getError().toString(), e.getError());
|
||||
}
|
||||
} catch (Throwable logEx) {
|
||||
LOG.debug("ReActLoggingHook formatting failed", logEx);
|
||||
}
|
||||
return Mono.just(event);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int priority() {
|
||||
return 900;
|
||||
}
|
||||
|
||||
private static String summarizeToolUses(List<ToolUseBlock> tools) {
|
||||
StringBuilder sb = new StringBuilder("[");
|
||||
for (int i = 0; i < tools.size(); i++) {
|
||||
ToolUseBlock t = tools.get(i);
|
||||
if (i > 0) sb.append(", ");
|
||||
sb.append(t.getName()).append("(").append(t.getInput()).append(")");
|
||||
}
|
||||
return truncate(sb.append("]").toString(), MAX_TEXT_LEN);
|
||||
}
|
||||
|
||||
private static String extractThinking(Msg msg) {
|
||||
if (msg == null) return "";
|
||||
List<ThinkingBlock> blocks = msg.getContentBlocks(ThinkingBlock.class);
|
||||
if (blocks.isEmpty()) return "";
|
||||
return truncate(blocks.get(0).getThinking(), MAX_THINKING_LEN);
|
||||
}
|
||||
|
||||
private static String blocksToString(ToolResultBlock r) {
|
||||
if (r.getOutput() == null) return "";
|
||||
StringBuilder sb = new StringBuilder();
|
||||
r.getOutput().forEach(b -> sb.append(b));
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String truncate(String s, int maxLen) {
|
||||
if (s == null) return "";
|
||||
s = s.replaceAll("\\s+", " ").trim();
|
||||
return s.length() <= maxLen ? s : s.substring(0, maxLen) + "...(truncated)";
|
||||
}
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
package com.yomahub.liteflow.agent.session;
|
||||
|
||||
import com.yomahub.liteflow.agent.hook.ChatUsageTrackingHook;
|
||||
import com.yomahub.liteflow.agent.skill.SkillTrackingHook;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
/**
|
||||
* 单个 agent 在某次会话中的运行时状态。
|
||||
*
|
||||
* <p>会话标识被拆分为两个维度:
|
||||
* <ul>
|
||||
* <li>{@code conversationId}:业务/对话维度,由调用方决定,整条 chain 内所有 agent 共享,
|
||||
* 决定 workspace 目录与对话连续性。</li>
|
||||
* <li>{@code agentKey}:组件维度,默认是 {@code nodeId},用于在同一段对话中区分
|
||||
* 不同 agent 的 ReActAgent 实例和持久化记忆。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>{@code workspaceDir} 仅按 {@code conversationId} 创建,因此同一段对话中的多个 agent
|
||||
* 共享同一个工作区目录(实现 agent 之间的文件协作)。
|
||||
*/
|
||||
public class AgentSession {
|
||||
|
||||
private final String conversationId;
|
||||
private final String agentKey;
|
||||
private final String cacheKey;
|
||||
private final Path workspaceDir;
|
||||
private final ReentrantLock lock = new ReentrantLock();
|
||||
private volatile Object agent;
|
||||
private volatile SkillTrackingHook skillTrackingHook;
|
||||
private volatile ChatUsageTrackingHook chatUsageTrackingHook;
|
||||
private volatile Instant lastActive = Instant.now();
|
||||
|
||||
public AgentSession(String conversationId, String agentKey, String cacheKey, Path workspaceDir) {
|
||||
this.conversationId = Objects.requireNonNull(conversationId, "conversationId");
|
||||
this.agentKey = Objects.requireNonNull(agentKey, "agentKey");
|
||||
this.cacheKey = Objects.requireNonNull(cacheKey, "cacheKey");
|
||||
this.workspaceDir = Objects.requireNonNull(workspaceDir, "workspaceDir");
|
||||
}
|
||||
|
||||
public String getConversationId() {
|
||||
return conversationId;
|
||||
}
|
||||
|
||||
public String getAgentKey() {
|
||||
return agentKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* JVM 内的缓存 key 与持久化 key,由 {@code conversationId} 与 {@code agentKey} 组合并安全编码后得到。
|
||||
*/
|
||||
public String getCacheKey() {
|
||||
return cacheKey;
|
||||
}
|
||||
|
||||
public Path getWorkspaceDir() {
|
||||
return workspaceDir;
|
||||
}
|
||||
|
||||
public ReentrantLock getLock() {
|
||||
return lock;
|
||||
}
|
||||
|
||||
public Object getAgent() {
|
||||
return agent;
|
||||
}
|
||||
|
||||
public void setAgent(Object agent) {
|
||||
this.agent = agent;
|
||||
}
|
||||
|
||||
public SkillTrackingHook getSkillTrackingHook() {
|
||||
return skillTrackingHook;
|
||||
}
|
||||
|
||||
public void setSkillTrackingHook(SkillTrackingHook skillTrackingHook) {
|
||||
this.skillTrackingHook = skillTrackingHook;
|
||||
}
|
||||
|
||||
public ChatUsageTrackingHook getChatUsageTrackingHook() {
|
||||
return chatUsageTrackingHook;
|
||||
}
|
||||
|
||||
public void setChatUsageTrackingHook(ChatUsageTrackingHook chatUsageTrackingHook) {
|
||||
this.chatUsageTrackingHook = chatUsageTrackingHook;
|
||||
}
|
||||
|
||||
public Instant getLastActive() {
|
||||
return lastActive;
|
||||
}
|
||||
|
||||
public void touch() {
|
||||
this.lastActive = Instant.now();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
package com.yomahub.liteflow.agent.session;
|
||||
|
||||
import com.yomahub.liteflow.agent.exception.AgentConfigException;
|
||||
import com.yomahub.liteflow.agent.session.factory.AgentSessionFactoryRegistry;
|
||||
import com.yomahub.liteflow.property.agent.AgentConfig;
|
||||
import com.yomahub.liteflow.property.agent.MemoryStorageConfig;
|
||||
import com.yomahub.liteflow.property.agent.MemoryStorageMode;
|
||||
import io.agentscope.core.ReActAgent;
|
||||
import io.agentscope.core.session.Session;
|
||||
import io.agentscope.core.session.SessionManager;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.Instant;
|
||||
import java.util.Comparator;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 跟踪当前 JVM 中存活的 {@link AgentSession},并桥接到可插拔的
|
||||
* {@link Session}(由 {@link AgentSessionFactoryRegistry} 提供)。
|
||||
*
|
||||
* <p>会话标识被拆分为两个维度:
|
||||
* <ul>
|
||||
* <li>{@code conversationId}:业务/对话维度,决定 workspace 目录与连续对话的恢复。</li>
|
||||
* <li>{@code agentKey}:组件维度(默认为 {@code nodeId}),用于在同一段对话内
|
||||
* 区分不同 agent 的 ReActAgent 实例与对话记忆。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>缓存与持久化都按 {@code (conversationId, agentKey)} 组合 key 隔离;
|
||||
* workspace 目录则只按 {@code conversationId} 建一份,让同一段对话中的多个 agent
|
||||
* 通过共享文件协作。
|
||||
*/
|
||||
public class AgentSessionManager implements AutoCloseable {
|
||||
|
||||
private static final Pattern SAFE = Pattern.compile("[a-zA-Z0-9_\\-]+");
|
||||
|
||||
/** 缓存 key 内部使用的分隔符;不在 SAFE 字符集中以外,且不会出现在 NanoId 输出里。 */
|
||||
static final String KEY_SEPARATOR = "__";
|
||||
|
||||
private final AgentConfig config;
|
||||
private final Path root;
|
||||
private final Map<String, AgentSession> sessions = new ConcurrentHashMap<>();
|
||||
private final ScheduledExecutorService cleaner;
|
||||
/** memory 模式为 NONE 时可能为 null。 */
|
||||
private final Session storage;
|
||||
|
||||
public AgentSessionManager(AgentConfig config) {
|
||||
this.config = config;
|
||||
if (config == null || config.getWorkspace() == null || config.getWorkspace().getRoot() == null) {
|
||||
throw new AgentConfigException("liteflow.agent.workspace.root is required");
|
||||
}
|
||||
this.root = Paths.get(config.getWorkspace().getRoot()).toAbsolutePath().normalize();
|
||||
if (config.getWorkspace().isAutoCreate()) {
|
||||
try {
|
||||
Files.createDirectories(root);
|
||||
} catch (IOException e) {
|
||||
throw new AgentConfigException("cannot create workspace root: " + root, e);
|
||||
}
|
||||
} else if (!Files.isDirectory(root)) {
|
||||
throw new AgentConfigException("workspace root does not exist: " + root);
|
||||
}
|
||||
this.storage = AgentSessionFactoryRegistry.createSession(config);
|
||||
long every = Math.max(20, config.getSession().getCleanupInterval().toMillis());
|
||||
this.cleaner = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "liteflow-agent-session-cleaner");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
cleaner.scheduleWithFixedDelay(this::cleanup, every, every, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取(或创建)一个 agent 会话。
|
||||
*
|
||||
* <p>同一 {@code conversationId} 下的多个 {@code agentKey} 共享同一个 workspace 目录,
|
||||
* 但分别拥有独立的 {@link AgentSession}(独立的 ReActAgent 实例、独立的记忆持久化 key)。
|
||||
*/
|
||||
public AgentSession acquire(String conversationId, String agentKey) {
|
||||
String safeCid = safeId(conversationId);
|
||||
String safeKey = safeId(agentKey);
|
||||
String cacheKey = safeCid + KEY_SEPARATOR + safeKey;
|
||||
AgentSession s = sessions.computeIfAbsent(cacheKey, k -> {
|
||||
Path ws = root.resolve(safeCid);
|
||||
try {
|
||||
Files.createDirectories(ws);
|
||||
} catch (IOException e) {
|
||||
throw new AgentConfigException("cannot create workspace: " + ws, e);
|
||||
}
|
||||
return new AgentSession(safeCid, safeKey, k, ws);
|
||||
});
|
||||
s.touch();
|
||||
enforceMaxSessions();
|
||||
return s;
|
||||
}
|
||||
|
||||
public boolean contains(String conversationId, String agentKey) {
|
||||
return sessions.containsKey(safeId(conversationId) + KEY_SEPARATOR + safeId(agentKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将之前持久化的状态懒加载恢复到 agent 中。
|
||||
* 同一个 {@code (conversationId, agentKey)} 在当前 JVM 生命周期内应只调用一次,并且应在 agent
|
||||
* 构建完成后、首次 {@code agent.call(...)} 前调用。
|
||||
*/
|
||||
public void loadIfExists(AgentSession session, ReActAgent agent) {
|
||||
if (storage == null || agent == null) return;
|
||||
MemoryStorageConfig mc = config.getSession().getMemory();
|
||||
if (!mc.isLoadOnFirstUse()) return;
|
||||
if (mc.getMode() == MemoryStorageMode.NONE) return;
|
||||
SessionManager.forSessionId(session.getCacheKey())
|
||||
.withSession(storage)
|
||||
.addComponent(agent)
|
||||
.loadIfExists();
|
||||
}
|
||||
|
||||
/** 持久化 agent 当前状态。失败会向调用方暴露。 */
|
||||
public void save(AgentSession session, ReActAgent agent) {
|
||||
if (storage == null || agent == null) return;
|
||||
MemoryStorageConfig mc = config.getSession().getMemory();
|
||||
if (mc.getMode() == MemoryStorageMode.NONE) return;
|
||||
SessionManager.forSessionId(session.getCacheKey())
|
||||
.withSession(storage)
|
||||
.addComponent(agent)
|
||||
.saveSession();
|
||||
}
|
||||
|
||||
public Session storage() { return storage; }
|
||||
|
||||
static String safeId(String raw) {
|
||||
if (raw == null || raw.isEmpty()) return "_";
|
||||
if (SAFE.matcher(raw).matches()) return raw;
|
||||
return URLEncoder.encode(raw, StandardCharsets.UTF_8).replace("%", "_");
|
||||
}
|
||||
|
||||
private void enforceMaxSessions() {
|
||||
int max = config.getSession().getMaxSessions();
|
||||
while (sessions.size() > max) {
|
||||
sessions.values().stream()
|
||||
.min(Comparator.comparing(AgentSession::getLastActive))
|
||||
// LRU 淘汰只移除 JVM 内缓存;持久化数据保持不变。
|
||||
.ifPresent(victim -> evictFromCache(victim, false));
|
||||
}
|
||||
}
|
||||
|
||||
private void cleanup() {
|
||||
Instant cutoff = Instant.now().minus(config.getSession().getIdleTimeout());
|
||||
for (AgentSession s : sessions.values()) {
|
||||
if (s.getLastActive().isAfter(cutoff)) continue;
|
||||
if (!s.getLock().tryLock()) continue;
|
||||
try {
|
||||
evictFromCache(s, config.getWorkspace().isCleanupOnSessionExpire());
|
||||
} finally {
|
||||
s.getLock().unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param cleanWorkspace 为 true 时同时尝试删除磁盘上的 workspace 目录。由于同一 workspace
|
||||
* 可能被 {@code (conversationId, *)} 下的多个 agent 共享,仅在该
|
||||
* conversation 下没有其他存活会话时才会真正删除目录。
|
||||
* 存储在其他位置的持久化 session 状态(例如 workspaceRoot/.agent-session、
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
package com.yomahub.liteflow.agent.session.factory;
|
||||
|
||||
import com.yomahub.liteflow.property.agent.AgentConfig;
|
||||
import com.yomahub.liteflow.property.agent.MemoryStorageMode;
|
||||
import io.agentscope.core.session.Session;
|
||||
|
||||
/**
|
||||
* 用于为 {@link io.agentscope.core.session.Session} 接入额外持久化后端的 SPI。
|
||||
*
|
||||
* <p>框架内置模式({@code JVM}、{@code LOCAL_FILE}、{@code REDIS}、
|
||||
* {@code MYSQL}、{@code NONE})。需要其他后端(例如 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);
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
package com.yomahub.liteflow.agent.session.factory;
|
||||
|
||||
import com.yomahub.liteflow.agent.exception.AgentConfigException;
|
||||
import com.yomahub.liteflow.property.agent.AgentConfig;
|
||||
import com.yomahub.liteflow.property.agent.MemoryStorageMode;
|
||||
import io.agentscope.core.session.Session;
|
||||
|
||||
import java.util.EnumMap;
|
||||
import java.util.Map;
|
||||
import java.util.ServiceLoader;
|
||||
|
||||
/**
|
||||
* 根据指定模式解析合适的 {@link AgentSessionFactory}。
|
||||
*
|
||||
* <p>解析顺序:
|
||||
* <ol>
|
||||
* <li>通过 {@link ServiceLoader} 注册的外部工厂</li>
|
||||
* <li>框架内置工厂(JVM、local-file、Redis、MySQL、none)</li>
|
||||
* </ol>
|
||||
* 出现冲突时外部工厂优先,因此用户可以覆盖内置实现
|
||||
* (例如用自定义加密 JSON 工厂替换默认本地文件工厂)。
|
||||
*/
|
||||
public final class AgentSessionFactoryRegistry {
|
||||
|
||||
private static final Map<MemoryStorageMode, AgentSessionFactory> FACTORIES = new EnumMap<>(MemoryStorageMode.class);
|
||||
|
||||
static {
|
||||
// 先注册内置实现;如果存在 SPI 实现,再由 SPI 覆盖。
|
||||
register(new InMemoryAgentSessionFactory());
|
||||
register(new LocalFileAgentSessionFactory());
|
||||
register(new RedisAgentSessionFactory());
|
||||
register(new MysqlAgentSessionFactory());
|
||||
register(new NoneAgentSessionFactory());
|
||||
for (AgentSessionFactory f : ServiceLoader.load(AgentSessionFactory.class)) {
|
||||
register(f);
|
||||
}
|
||||
}
|
||||
|
||||
private AgentSessionFactoryRegistry() { }
|
||||
|
||||
private static void register(AgentSessionFactory f) {
|
||||
FACTORIES.put(f.mode(), f);
|
||||
}
|
||||
|
||||
/** 根据配置模式构建 Session。{@link MemoryStorageMode#NONE} 可能返回 {@code null}。 */
|
||||
public static Session createSession(AgentConfig cfg) {
|
||||
MemoryStorageMode mode = cfg.getSession().getMemory().getMode();
|
||||
AgentSessionFactory f = FACTORIES.get(mode);
|
||||
if (f == null) {
|
||||
throw new AgentConfigException("No AgentSessionFactory registered for mode: " + mode);
|
||||
}
|
||||
return f.create(cfg);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
package com.yomahub.liteflow.agent.session.factory;
|
||||
|
||||
import com.yomahub.liteflow.property.agent.AgentConfig;
|
||||
import com.yomahub.liteflow.property.agent.MemoryStorageMode;
|
||||
import io.agentscope.core.session.InMemorySession;
|
||||
import io.agentscope.core.session.Session;
|
||||
|
||||
/**
|
||||
* 使用 AgentScope 的内存存储支持 {@link MemoryStorageMode#JVM} 模式。
|
||||
*
|
||||
* <p>注意:状态仍会在同一个 JVM 内跨调用保留(适合希望在单进程内保留多轮记忆的场景),
|
||||
* 但进程退出后会丢失。如果需要跨重启持久化,请选择
|
||||
* {@code LOCAL_FILE}、{@code REDIS} 或 {@code MYSQL}。
|
||||
*/
|
||||
public class InMemoryAgentSessionFactory implements AgentSessionFactory {
|
||||
|
||||
@Override
|
||||
public MemoryStorageMode mode() {
|
||||
return MemoryStorageMode.JVM;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Session create(AgentConfig agentConfig) {
|
||||
return new InMemorySession();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
package com.yomahub.liteflow.agent.session.factory;
|
||||
|
||||
import com.yomahub.liteflow.agent.exception.AgentConfigException;
|
||||
import com.yomahub.liteflow.property.agent.AgentConfig;
|
||||
import com.yomahub.liteflow.property.agent.LocalFileMemoryConfig;
|
||||
import com.yomahub.liteflow.property.agent.MemoryStorageMode;
|
||||
import io.agentscope.core.session.JsonSession;
|
||||
import io.agentscope.core.session.Session;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
/**
|
||||
* 通过把 JSON 文件存储在 {@code workspace.root/.agent-session/<sessionId>/}
|
||||
* 下来支持 {@link MemoryStorageMode#LOCAL_FILE}。
|
||||
*
|
||||
* <p>session 存储子目录与各 session 的 workspace({@code workspace.root/<sessionId>/})
|
||||
* 平级而非嵌套:一方面避免 {@link com.yomahub.liteflow.agent.tool.WorkspaceFileTools}
|
||||
* 读到或覆盖 agent 自己的记忆;另一方面让 {@code cleanup-on-session-expire}
|
||||
* 在递归清空 workspace 子目录时不会误删持久化的记忆,
|
||||
* 与 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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}。
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
package com.yomahub.liteflow.agent.skill;
|
||||
|
||||
import com.yomahub.liteflow.agent.exception.AgentConfigException;
|
||||
import com.yomahub.liteflow.property.agent.AgentConfig;
|
||||
import com.yomahub.liteflow.property.agent.SkillsConfig;
|
||||
import io.agentscope.core.skill.AgentSkill;
|
||||
import io.agentscope.core.skill.SkillBox;
|
||||
import io.agentscope.core.skill.repository.FileSystemSkillRepository;
|
||||
import io.agentscope.core.tool.Toolkit;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public final class SkillBoxFactory {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(SkillBoxFactory.class);
|
||||
|
||||
private SkillBoxFactory() {
|
||||
}
|
||||
|
||||
public static SkillLoadResult build(Toolkit toolkit, AgentConfig agentConfig, List<String> allowedSkills) {
|
||||
return build(toolkit, agentConfig, allowedSkills, null);
|
||||
}
|
||||
|
||||
public static SkillLoadResult build(
|
||||
Toolkit toolkit,
|
||||
AgentConfig agentConfig,
|
||||
List<String> allowedSkills,
|
||||
Path workspaceDir) {
|
||||
SkillsConfig skillsConfig = agentConfig.getSkills();
|
||||
Path root = Path.of(skillsConfig.getPath()).normalize();
|
||||
if (!Files.isDirectory(root)) {
|
||||
return handleMissingRoot(root, skillsConfig, toolkit, workspaceDir);
|
||||
}
|
||||
|
||||
try {
|
||||
FileSystemSkillRepository repository = new FileSystemSkillRepository(root);
|
||||
List<AgentSkill> allSkills = repository.getAllSkills();
|
||||
List<AgentSkill> selected = selectSkills(allSkills, allowedSkills, skillsConfig);
|
||||
SkillToolResolver toolResolver = new SkillToolResolver(skillsConfig);
|
||||
SkillBox skillBox = createSkillBox(toolkit, workspaceDir);
|
||||
Map<String, String> skillIdToName = new LinkedHashMap<>();
|
||||
List<String> skillNames = new ArrayList<>();
|
||||
|
||||
for (AgentSkill skill : selected) {
|
||||
skillIdToName.put(skill.getSkillId(), skill.getName());
|
||||
skillNames.add(skill.getName());
|
||||
List<Object> skillTools = toolResolver.instantiateTools(skill);
|
||||
if (skillTools.isEmpty()) {
|
||||
skillBox.registerSkill(skill);
|
||||
} else {
|
||||
for (Object tool : skillTools) {
|
||||
skillBox.registration().skill(skill).tool(tool).apply();
|
||||
}
|
||||
}
|
||||
}
|
||||
return new SkillLoadResult(
|
||||
skillBox,
|
||||
Collections.unmodifiableMap(new LinkedHashMap<>(skillIdToName)),
|
||||
List.copyOf(skillNames));
|
||||
} catch (AgentConfigException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
if (skillsConfig.isStrict()) {
|
||||
throw new AgentConfigException("Failed to load skills from: " + root, e);
|
||||
}
|
||||
LOG.warn("Failed to load skills from {}: {}", root, e.getMessage());
|
||||
return new SkillLoadResult(createSkillBox(toolkit, workspaceDir), Map.of(), List.of());
|
||||
}
|
||||
}
|
||||
|
||||
private static SkillLoadResult handleMissingRoot(
|
||||
Path root,
|
||||
SkillsConfig skillsConfig,
|
||||
Toolkit toolkit,
|
||||
Path workspaceDir) {
|
||||
String message = "Skills root not found: " + root;
|
||||
if (skillsConfig.isStrict()) {
|
||||
throw new AgentConfigException(message);
|
||||
}
|
||||
LOG.warn(message);
|
||||
return new SkillLoadResult(createSkillBox(toolkit, workspaceDir), Map.of(), List.of());
|
||||
}
|
||||
|
||||
private static SkillBox createSkillBox(Toolkit toolkit, Path workspaceDir) {
|
||||
SkillBox skillBox = new SkillBox(toolkit);
|
||||
if (workspaceDir != null) {
|
||||
skillBox.codeExecution()
|
||||
.workDir(workspaceDir.toAbsolutePath().normalize().toString())
|
||||
.enable();
|
||||
}
|
||||
return skillBox;
|
||||
}
|
||||
|
||||
private static List<AgentSkill> selectSkills(
|
||||
List<AgentSkill> allSkills,
|
||||
List<String> allowedSkills,
|
||||
SkillsConfig skillsConfig) {
|
||||
Map<String, AgentSkill> byName = allSkills.stream()
|
||||
.collect(Collectors.toMap(
|
||||
AgentSkill::getName,
|
||||
skill -> skill,
|
||||
(left, right) -> left,
|
||||
LinkedHashMap::new));
|
||||
Set<String> allowed = normalizeAllowedSkills(allowedSkills);
|
||||
if (allowed.isEmpty()) {
|
||||
return byName.values().stream()
|
||||
.sorted(Comparator.comparing(AgentSkill::getName))
|
||||
.toList();
|
||||
}
|
||||
|
||||
List<String> missing = allowed.stream()
|
||||
.filter(name -> !byName.containsKey(name))
|
||||
.toList();
|
||||
if (!missing.isEmpty()) {
|
||||
String message = "Declared skills not found: " + missing;
|
||||
if (skillsConfig.isStrict()) {
|
||||
throw new AgentConfigException(message);
|
||||
}
|
||||
LOG.warn(message);
|
||||
}
|
||||
|
||||
List<AgentSkill> selected = new ArrayList<>();
|
||||
for (String name : allowed) {
|
||||
AgentSkill skill = byName.get(name);
|
||||
if (skill != null) {
|
||||
selected.add(skill);
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
private static Set<String> normalizeAllowedSkills(List<String> allowedSkills) {
|
||||
if (allowedSkills == null || allowedSkills.isEmpty()) {
|
||||
return Set.of();
|
||||
}
|
||||
return allowedSkills.stream()
|
||||
.filter(s -> s != null && !s.isBlank())
|
||||
.map(String::trim)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
package com.yomahub.liteflow.agent.skill;
|
||||
|
||||
import io.agentscope.core.skill.SkillBox;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public record SkillLoadResult(
|
||||
SkillBox skillBox,
|
||||
Map<String, String> skillIdToName,
|
||||
List<String> skillNames) {
|
||||
}
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
package com.yomahub.liteflow.agent.skill;
|
||||
|
||||
import com.yomahub.liteflow.agent.exception.AgentConfigException;
|
||||
import com.yomahub.liteflow.property.agent.SkillsConfig;
|
||||
import com.yomahub.liteflow.spi.ContextAware;
|
||||
import com.yomahub.liteflow.spi.holder.ContextAwareHolder;
|
||||
import io.agentscope.core.skill.AgentSkill;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* 把技能 frontmatter 中声明的 {@code tools} 解析为可注册的工具实例。
|
||||
*
|
||||
* <p>{@code tools} 字段直接取自 agentscope 已经用 SnakeYAML 解析好的
|
||||
* {@link AgentSkill#getMetadataValue(String)},因此无需再次读盘或自行解析 YAML,
|
||||
* 也天然支持 {@code tools: [a, b]} 这类行内数组写法。解析范围严格限定在传入的技能上,
|
||||
* 不会因为目录中其它(未被选中的)技能配置错误而牵连本次构建。
|
||||
*/
|
||||
final class SkillToolResolver {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(SkillToolResolver.class);
|
||||
|
||||
static final String TOOLS_METADATA_KEY = "tools";
|
||||
|
||||
private final SkillsConfig config;
|
||||
|
||||
SkillToolResolver(SkillsConfig config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析并实例化指定技能声明的工具。技能未声明 {@code tools} 时返回空列表。
|
||||
*
|
||||
* <p>工具类优先从框架容器(Spring/Solon)按类型取已注册的 bean,使其依赖注入生效;
|
||||
* 无容器、未注册或容器访问异常时,降级为反射实例化(依赖注入不可用)。
|
||||
*/
|
||||
List<Object> instantiateTools(AgentSkill skill) {
|
||||
List<Class<?>> classes = resolveToolClasses(skill);
|
||||
if (classes.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
ContextAware contextAware = ContextAwareHolder.loadContextAware();
|
||||
List<Object> instances = new ArrayList<>(classes.size());
|
||||
for (Class<?> clazz : classes) {
|
||||
try {
|
||||
instances.add(resolveToolInstance(contextAware, skill, clazz));
|
||||
} catch (ReflectiveOperationException e) {
|
||||
handleProblem("Skill '" + skill.getName() + "' tool class '" + clazz.getName()
|
||||
+ "' instantiation failed", e);
|
||||
}
|
||||
}
|
||||
return List.copyOf(instances);
|
||||
}
|
||||
|
||||
private Object resolveToolInstance(ContextAware contextAware, AgentSkill skill, Class<?> clazz)
|
||||
throws ReflectiveOperationException {
|
||||
try {
|
||||
if (contextAware.hasBean(clazz)) {
|
||||
return contextAware.getBean(clazz);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
// 容器未就绪(如 classpath 含 spring 但 ApplicationContext 尚未初始化)等异常:降级反射实例化
|
||||
LOG.warn("Skill '{}' resolving tool '{}' from container failed ({}); "
|
||||
+ "falling back to reflective instantiation",
|
||||
skill.getName(), clazz.getName(), ex.toString());
|
||||
}
|
||||
Object instance = clazz.getDeclaredConstructor().newInstance();
|
||||
LOG.info("Skill '{}' tool '{}' not found in container; fell back to reflective "
|
||||
+ "instantiation, dependency injection unavailable", skill.getName(), clazz.getName());
|
||||
return instance;
|
||||
}
|
||||
|
||||
private List<Class<?>> resolveToolClasses(AgentSkill skill) {
|
||||
Object toolsObj = skill.getMetadataValue(TOOLS_METADATA_KEY);
|
||||
if (toolsObj == null) {
|
||||
return List.of();
|
||||
}
|
||||
List<Class<?>> resolved = new ArrayList<>();
|
||||
for (String className : toClassNameList(toolsObj)) {
|
||||
try {
|
||||
resolved.add(Class.forName(className));
|
||||
} catch (ClassNotFoundException e) {
|
||||
handleProblem("Skill '" + skill.getName() + "' references unknown tool class '"
|
||||
+ className + "'", e);
|
||||
}
|
||||
}
|
||||
if (!resolved.isEmpty()) {
|
||||
LOG.info("Skill '{}' bound to tool classes: {}", skill.getName(),
|
||||
resolved.stream().map(Class::getName).toList());
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private static List<String> toClassNameList(Object field) {
|
||||
if (field instanceof List<?> list) {
|
||||
return list.stream()
|
||||
.map(Object::toString)
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.toList();
|
||||
}
|
||||
return Stream.of(field.toString().split(","))
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.toList();
|
||||
}
|
||||
|
||||
private void handleProblem(String message, Exception e) {
|
||||
if (config.isStrict()) {
|
||||
if (e == null) {
|
||||
throw new AgentConfigException(message);
|
||||
}
|
||||
throw new AgentConfigException(message, e);
|
||||
}
|
||||
if (e == null) {
|
||||
LOG.warn(message);
|
||||
} else {
|
||||
LOG.warn("{}: {}", message, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
package com.yomahub.liteflow.agent.skill;
|
||||
|
||||
import io.agentscope.core.hook.Hook;
|
||||
import io.agentscope.core.hook.HookEvent;
|
||||
import io.agentscope.core.hook.PostActingEvent;
|
||||
import io.agentscope.core.message.ContentBlock;
|
||||
import io.agentscope.core.message.TextBlock;
|
||||
import io.agentscope.core.message.ToolResultBlock;
|
||||
import io.agentscope.core.message.ToolUseBlock;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Tracks skills loaded by agentscope's skill-loading tool during a ReAct session.
|
||||
*/
|
||||
public class SkillTrackingHook implements Hook {
|
||||
|
||||
public static final String LOAD_SKILL_TOOL_NAME = "load_skill_through_path";
|
||||
private static final String SKILL_ID_INPUT_KEY = "skillId";
|
||||
|
||||
private final Map<String, String> skillIdToName;
|
||||
private final Set<String> usedSkills = Collections.synchronizedSet(new LinkedHashSet<>());
|
||||
|
||||
public SkillTrackingHook(Map<String, String> skillIdToName) {
|
||||
this.skillIdToName = skillIdToName == null
|
||||
? Map.of()
|
||||
: Collections.unmodifiableMap(new LinkedHashMap<>(skillIdToName));
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T extends HookEvent> Mono<T> onEvent(T event) {
|
||||
if (event instanceof PostActingEvent postActingEvent) {
|
||||
recordSkillLoad(postActingEvent.getToolUse(), postActingEvent.getToolResult());
|
||||
}
|
||||
return Mono.just(event);
|
||||
}
|
||||
|
||||
public List<String> getUsedSkills() {
|
||||
synchronized (usedSkills) {
|
||||
return List.copyOf(usedSkills);
|
||||
}
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
usedSkills.clear();
|
||||
}
|
||||
|
||||
private void recordSkillLoad(ToolUseBlock toolUse, ToolResultBlock toolResult) {
|
||||
if (toolUse == null || !LOAD_SKILL_TOOL_NAME.equals(toolUse.getName()) || isErrorResult(toolResult)) {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> input = toolUse.getInput();
|
||||
if (input == null || !input.containsKey(SKILL_ID_INPUT_KEY)) {
|
||||
return;
|
||||
}
|
||||
Object skillId = input.get(SKILL_ID_INPUT_KEY);
|
||||
if (skillId == null) {
|
||||
return;
|
||||
}
|
||||
String skillName = skillIdToName.get(String.valueOf(skillId));
|
||||
if (skillName != null) {
|
||||
usedSkills.add(skillName);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isErrorResult(ToolResultBlock toolResult) {
|
||||
if (toolResult == null || toolResult.getOutput() == null) {
|
||||
return false;
|
||||
}
|
||||
for (ContentBlock block : toolResult.getOutput()) {
|
||||
if (block instanceof TextBlock textBlock) {
|
||||
String text = textBlock.getText();
|
||||
if (text != null && text.startsWith("Error:")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
package com.yomahub.liteflow.agent.tool;
|
||||
|
||||
import com.yomahub.liteflow.property.agent.AgentConfig;
|
||||
import com.yomahub.liteflow.property.agent.ShellConfig;
|
||||
import com.yomahub.liteflow.property.agent.ShellMode;
|
||||
import io.agentscope.core.tool.Tool;
|
||||
import io.agentscope.core.tool.ToolParam;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
public class ManagedShellCommandTool {
|
||||
|
||||
private final Path workspace;
|
||||
private final ShellConfig shell;
|
||||
|
||||
public ManagedShellCommandTool(Path workspace, AgentConfig cfg) {
|
||||
this.workspace = workspace.toAbsolutePath().normalize();
|
||||
this.shell = cfg.getShell();
|
||||
}
|
||||
|
||||
@Tool(name = "execute_shell_command",
|
||||
description = "Execute a controlled shell command in the current workspace. Path traversal and blacklisted commands are blocked.")
|
||||
public String executeCommand(
|
||||
@ToolParam(name = "command", description = "Single command string (pipes && || are rejected)")
|
||||
String command) {
|
||||
if (shell.getMode() == ShellMode.DISABLED) {
|
||||
return "{\"error\":\"shell execution denied by policy\"}";
|
||||
}
|
||||
if (command == null || command.isBlank()) {
|
||||
return "{\"error\":\"empty command\"}";
|
||||
}
|
||||
if (containsUnsupportedShellSyntax(command)) {
|
||||
return "{\"error\":\"unsupported shell syntax: pipes, redirection, and command chaining are not supported\"}";
|
||||
}
|
||||
String[] tokens = command.trim().split("\\s+");
|
||||
String first = tokens[0];
|
||||
if (shell.getMode() == ShellMode.WHITELIST && !shell.getWhitelist().contains(first)) {
|
||||
return "{\"error\":\"command '" + first + "' not allowed by whitelist\"}";
|
||||
}
|
||||
if (shell.getMode() == ShellMode.BLACKLIST && shell.getBlacklist().contains(first)) {
|
||||
return "{\"error\":\"command '" + first + "' not allowed by blacklist\"}";
|
||||
}
|
||||
try {
|
||||
ProcessBuilder pb = new ProcessBuilder(Arrays.asList(tokens));
|
||||
pb.directory(workspace.toFile());
|
||||
pb.redirectErrorStream(true);
|
||||
Process p = pb.start();
|
||||
closeQuietly(p.getOutputStream());
|
||||
ExecutorService outputReader = Executors.newSingleThreadExecutor(r -> {
|
||||
Thread t = new Thread(r, "liteflow-agent-shell-output-reader");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
Future<String> outputFuture = outputReader.submit(() -> readLimited(p.getInputStream(), shell.getMaxOutputBytes()));
|
||||
try {
|
||||
boolean done = p.waitFor(shell.getTimeout().toMillis(), TimeUnit.MILLISECONDS);
|
||||
if (!done) {
|
||||
p.destroyForcibly();
|
||||
closeQuietly(p.getInputStream());
|
||||
outputFuture.cancel(true);
|
||||
return "{\"error\":\"timeout after " + shell.getTimeout().toMillis() + "ms\"}";
|
||||
}
|
||||
return outputFuture.get(1, TimeUnit.SECONDS);
|
||||
} catch (ExecutionException e) {
|
||||
Throwable cause = e.getCause();
|
||||
return "{\"error\":\"" + (cause == null ? e.getMessage() : cause.getMessage()).replace("\"", "'") + "\"}";
|
||||
} catch (TimeoutException e) {
|
||||
outputFuture.cancel(true);
|
||||
return "{\"error\":\"output read timeout\"}";
|
||||
} finally {
|
||||
outputReader.shutdownNow();
|
||||
}
|
||||
} catch (IOException | InterruptedException e) {
|
||||
if (e instanceof InterruptedException) Thread.currentThread().interrupt();
|
||||
return "{\"error\":\"" + e.getMessage().replace("\"", "'") + "\"}";
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean containsUnsupportedShellSyntax(String command) {
|
||||
return command.contains("|")
|
||||
|| command.contains("<")
|
||||
|| command.contains(">")
|
||||
|| command.contains("&&")
|
||||
|| command.contains("||")
|
||||
|| command.contains(";");
|
||||
}
|
||||
|
||||
private static void closeQuietly(java.io.Closeable closeable) {
|
||||
if (closeable == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
closeable.close();
|
||||
} catch (IOException ignored) {
|
||||
// ignore close failures while cleaning up process streams
|
||||
}
|
||||
}
|
||||
|
||||
private static String readLimited(InputStream in, long max) throws IOException {
|
||||
byte[] buf = new byte[4096];
|
||||
List<byte[]> chunks = new ArrayList<>();
|
||||
long total = 0;
|
||||
int n;
|
||||
while ((n = in.read(buf)) > 0 && total < max) {
|
||||
int toCopy = (int) Math.min(n, max - total);
|
||||
byte[] c = new byte[toCopy];
|
||||
System.arraycopy(buf, 0, c, 0, toCopy);
|
||||
chunks.add(c);
|
||||
total += toCopy;
|
||||
}
|
||||
byte[] all = new byte[(int) total];
|
||||
int pos = 0;
|
||||
for (byte[] c : chunks) { System.arraycopy(c, 0, all, pos, c.length); pos += c.length; }
|
||||
return new String(all, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
package com.yomahub.liteflow.agent.tool;
|
||||
|
||||
import com.yomahub.liteflow.property.agent.AgentConfig;
|
||||
import io.agentscope.core.tool.Tool;
|
||||
import io.agentscope.core.tool.ToolParam;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class WorkspaceFileTools {
|
||||
|
||||
private final Path workspace;
|
||||
private final long maxBytes;
|
||||
private final int maxList;
|
||||
|
||||
public WorkspaceFileTools(Path workspace, AgentConfig cfg) {
|
||||
this.workspace = workspace.toAbsolutePath().normalize();
|
||||
this.maxBytes = cfg.getWorkspace().getMaxFileBytes();
|
||||
this.maxList = cfg.getWorkspace().getMaxListSize();
|
||||
}
|
||||
|
||||
@Tool(name = "read_file", description = "Read a text file in the current workspace")
|
||||
public String readFile(
|
||||
@ToolParam(name = "path", description = "Relative path") String path) {
|
||||
Path p = resolveSafe(path);
|
||||
try {
|
||||
long size = Files.size(p);
|
||||
if (size > maxBytes) {
|
||||
byte[] buf = new byte[(int) maxBytes];
|
||||
try (var in = Files.newInputStream(p)) {
|
||||
int read = in.read(buf);
|
||||
return new String(buf, 0, Math.max(0, read), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
return Files.readString(p, StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("read_file failed: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Tool(name = "write_file", description = "Write text to a file in the current workspace (overwrite)")
|
||||
public String writeFile(
|
||||
@ToolParam(name = "path", description = "Relative path") String path,
|
||||
@ToolParam(name = "content", description = "File content") String content) {
|
||||
Path p = resolveSafe(path);
|
||||
try {
|
||||
Files.createDirectories(p.getParent());
|
||||
Files.writeString(p, content, StandardCharsets.UTF_8);
|
||||
return "ok";
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("write_file failed: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Tool(name = "list_files", description = "List files in a workspace directory")
|
||||
public List<String> listFiles(
|
||||
@ToolParam(name = "path", required = false, description = "Relative path; defaults to current dir") String path) {
|
||||
Path dir = resolveSafe(path == null || path.isEmpty() ? "." : path);
|
||||
List<String> out = new ArrayList<>();
|
||||
try (var ds = Files.newDirectoryStream(dir)) {
|
||||
for (Path p : ds) {
|
||||
out.add(workspace.relativize(p).toString());
|
||||
if (out.size() >= maxList) break;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("list_files failed: " + e.getMessage(), e);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@Tool(name = "delete_file", description = "Delete a file in the current workspace")
|
||||
public String deleteFile(
|
||||
@ToolParam(name = "path", description = "Relative path") String path) {
|
||||
Path p = resolveSafe(path);
|
||||
try {
|
||||
Files.deleteIfExists(p);
|
||||
return "ok";
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("delete_file failed: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private Path resolveSafe(String rel) {
|
||||
if (rel == null) throw new SecurityException("path is null");
|
||||
if (rel.startsWith("/")) throw new SecurityException("absolute path denied: " + rel);
|
||||
Path abs = workspace.resolve(rel).toAbsolutePath().normalize();
|
||||
if (!abs.startsWith(workspace)) {
|
||||
throw new SecurityException("path escapes workspace: " + rel);
|
||||
}
|
||||
return abs;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,266 @@
|
|||
package com.yomahub.liteflow.test.agent.v2spike;
|
||||
|
||||
import io.agentscope.core.ReActAgent;
|
||||
import io.agentscope.core.agent.Event;
|
||||
import io.agentscope.core.agent.EventType;
|
||||
import io.agentscope.core.agent.RuntimeContext;
|
||||
import io.agentscope.core.agent.StreamOptions;
|
||||
import io.agentscope.core.event.AgentEventType;
|
||||
import io.agentscope.core.message.MessageMetadataKeys;
|
||||
import io.agentscope.core.message.Msg;
|
||||
import io.agentscope.core.middleware.MiddlewareBase;
|
||||
import io.agentscope.core.middleware.ModelCallInput;
|
||||
import io.agentscope.core.model.AnthropicChatModel;
|
||||
import io.agentscope.core.model.ChatResponse;
|
||||
import io.agentscope.core.model.ChatUsage;
|
||||
import io.agentscope.core.model.DashScopeChatModel;
|
||||
import io.agentscope.core.model.GenerateOptions;
|
||||
import io.agentscope.core.model.GeminiChatModel;
|
||||
import io.agentscope.core.model.Model;
|
||||
import io.agentscope.core.model.OpenAIChatModel;
|
||||
import io.agentscope.core.permission.PermissionBehavior;
|
||||
import io.agentscope.core.permission.PermissionContextState;
|
||||
import io.agentscope.core.permission.PermissionMode;
|
||||
import io.agentscope.core.permission.PermissionRule;
|
||||
import io.agentscope.core.state.AgentStateStore;
|
||||
import io.agentscope.core.state.InMemoryAgentStateStore;
|
||||
import io.agentscope.core.state.JsonFileAgentStateStore;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Task 0 API spike against agentscope 2.0.0-RC3.
|
||||
*
|
||||
* <p>Confirms the real v2 signatures that the migration plan (written against the 1.0
|
||||
* design doc's guesses) depends on. Findings are written back to
|
||||
* {@code docs/superpowers/specs/v2-api-findings.md}; this test is a deliverable, not just
|
||||
* the findings doc.
|
||||
*
|
||||
* <p>The probe is network-free: it never calls a real LLM. It builds lightweight objects /
|
||||
* inspects method signatures via reflection, so it runs in CI without credentials.
|
||||
*
|
||||
* <p>R = requirement id from migration spec §16:
|
||||
* <ul>
|
||||
* <li><b>R1</b> — AgentStateStore: NONE semantics + oob stores.</li>
|
||||
* <li><b>R4</b> — vendor ChatModel builder method names.</li>
|
||||
* <li><b>R5</b> — ChatUsage retrieval + MiddlewareBase model-call callback.</li>
|
||||
* <li><b>R6</b> — Permission rule shape (per-tool allow/deny).</li>
|
||||
* <li><b>R7</b> — JDK 21 baseline (this test compiling & running IS the check).</li>
|
||||
* </ul>
|
||||
*/
|
||||
class V2ApiProbe {
|
||||
|
||||
/** R1: confirm no HarnessAgent in v2; core agent stays ReActAgent (refutes plan's HarnessAgent guess). */
|
||||
@Test
|
||||
void r0_coreAgentIsReActAgent_notHarnessAgent() {
|
||||
// The plan/spec assumed a v2 "HarnessAgent". In RC3 the public core agent is still
|
||||
// io.agentscope.core.ReActAgent; "Harness*" only appears in sandbox extension Jackson modules.
|
||||
assertNotNull(ReActAgent.builder(), "ReActAgent.builder() must exist");
|
||||
Package pkg = ReActAgent.class.getPackage();
|
||||
System.out.println("[R0] core agent class = " + ReActAgent.class.getName()
|
||||
+ " (package " + pkg.getName() + ")");
|
||||
System.out.println("[R0] HarnessAgent does NOT exist in agentscope core; "
|
||||
+ "Harness* token only in sandbox jackson modules. Plan must target ReActAgent.");
|
||||
}
|
||||
|
||||
/** R1: Builder.stateStore defaults to null; null stateStore = no persistence (NONE). */
|
||||
@Test
|
||||
void r1_noneStore_isNullStateStore() throws Exception {
|
||||
// Build a ReActAgent WITHOUT stateStore and reflectively read the field.
|
||||
// We cannot call build() without a model; instead reflect on the Builder field default.
|
||||
ReActAgent.Builder b = ReActAgent.builder().name("probe").sysPrompt("s");
|
||||
java.lang.reflect.Field f = ReActAgent.Builder.class.getDeclaredField("stateStore");
|
||||
f.setAccessible(true);
|
||||
Object defaultValue = f.get(b);
|
||||
assertNull(defaultValue, "Builder.stateStore must default to null (=> NONE / no persistence)");
|
||||
|
||||
// OOB stores present:
|
||||
AgentStateStore inMem = new InMemoryAgentStateStore();
|
||||
AgentStateStore json = new JsonFileAgentStateStore(Path.of(System.getProperty("java.io.tmpdir"), "probe-state"));
|
||||
System.out.println("[R1] Builder.stateStore default = " + defaultValue
|
||||
+ " -> NONE persistence = omit .stateStore(...) (field stays null, ReActAgent skips save/load)");
|
||||
System.out.println("[R1] OOB stores: InMemoryAgentStateStore=" + inMem.getClass().getName()
|
||||
+ ", JsonFileAgentStateStore=" + json.getClass().getName());
|
||||
System.out.println("[R1] No NoOp/NullAgentStateStore class in core; null-stateStore is the NONE path.");
|
||||
|
||||
// AgentStateStore interface shape
|
||||
boolean hasSaveSingle = Arrays.stream(AgentStateStore.class.getMethods())
|
||||
.anyMatch(m -> m.getName().equals("save")
|
||||
&& m.getParameterCount() == 4
|
||||
&& m.getParameterTypes()[0] == String.class
|
||||
&& m.getParameterTypes()[1] == String.class
|
||||
&& m.getParameterTypes()[2] == String.class);
|
||||
boolean hasGet = Arrays.stream(AgentStateStore.class.getMethods())
|
||||
.anyMatch(m -> m.getName().equals("get") && m.getParameterCount() == 4);
|
||||
boolean hasListSessionIds = Arrays.stream(AgentStateStore.class.getMethods())
|
||||
.anyMatch(m -> m.getName().equals("listSessionIds"));
|
||||
assertTrue(hasSaveSingle && hasGet && hasListSessionIds,
|
||||
"AgentStateStore: save(userId,sessionId,key,State) / get(...,Class) / listSessionIds(userId)");
|
||||
System.out.println("[R1] AgentStateStore API: save(userId,sessionId,key,State|List<State>) | "
|
||||
+ "<T> get(userId,sessionId,key,Class<T>) | listSessionIds(userId) -> Set<String>");
|
||||
}
|
||||
|
||||
/** R4: confirm each vendor ChatModel builder exposes the canonical setters (names diverge per vendor). */
|
||||
@Test
|
||||
void r4_vendorChatModelBuilders() {
|
||||
// OpenAIChatModel: apiKey/modelName/baseUrl/stream/generateOptions/formatter ALL present
|
||||
Set<String> openai = builderMethods(OpenAIChatModel.Builder.class);
|
||||
assertAll(openai, "OpenAIChatModel",
|
||||
"apiKey", "modelName", "baseUrl", "stream", "generateOptions", "formatter");
|
||||
|
||||
// AnthropicChatModel: stream->stream, but gen options is defaultOptions (NOT generateOptions)
|
||||
Set<String> anth = builderMethods(AnthropicChatModel.Builder.class);
|
||||
assertAll(anth, "AnthropicChatModel",
|
||||
"apiKey", "modelName", "baseUrl", "stream", "defaultOptions", "formatter");
|
||||
assertFalse(anth.contains("generateOptions"),
|
||||
"AnthropicChatModel.Builder has NO generateOptions -> use defaultOptions");
|
||||
|
||||
// GeminiChatModel: stream is streamEnabled (NOT stream)
|
||||
Set<String> gem = builderMethods(GeminiChatModel.Builder.class);
|
||||
assertAll(gem, "GeminiChatModel",
|
||||
"apiKey", "modelName", "baseUrl", "defaultOptions", "formatter");
|
||||
assertFalse(gem.contains("stream"), "GeminiChatModel.Builder stream flag is streamEnabled, not stream");
|
||||
assertTrue(gem.contains("streamEnabled"), "GeminiChatModel.Builder has streamEnabled");
|
||||
|
||||
// DashScopeChatModel: stream + defaultOptions
|
||||
Set<String> ds = builderMethods(DashScopeChatModel.Builder.class);
|
||||
assertAll(ds, "DashScopeChatModel",
|
||||
"apiKey", "modelName", "baseUrl", "stream", "defaultOptions", "formatter");
|
||||
|
||||
System.out.println("[R4] OpenAI builder methods: " + sorted(openai));
|
||||
System.out.println("[R4] Anthropic builder methods: " + sorted(anth)
|
||||
+ " (NOTE: defaultOptions, not generateOptions)");
|
||||
System.out.println("[R4] Gemini builder methods: " + sorted(gem)
|
||||
+ " (NOTE: streamEnabled, not stream)");
|
||||
System.out.println("[R4] DashScope builder methods: " + sorted(ds));
|
||||
}
|
||||
|
||||
/** R5: ChatUsage lives on Msg (lazy from metadata) + ChatResponse.getUsage(); middleware hook = onModelCall. */
|
||||
@Test
|
||||
void r5_chatUsage_retrieval() throws Exception {
|
||||
// Msg has both getChatUsage() (lazy metadata) and getUsage() (direct field)
|
||||
Method getChatUsage = Msg.class.getMethod("getChatUsage");
|
||||
Method getUsage = Msg.class.getMethod("getUsage");
|
||||
assertEquals(ChatUsage.class, getChatUsage.getReturnType(), "Msg.getChatUsage() -> ChatUsage");
|
||||
assertEquals(ChatUsage.class, getUsage.getReturnType(), "Msg.getUsage() -> ChatUsage");
|
||||
System.out.println("[R5] Msg.getChatUsage() -> " + getChatUsage.getReturnType().getSimpleName()
|
||||
+ " (resolves from metadata key MessageMetadataKeys.CHAT_USAGE)");
|
||||
System.out.println("[R5] MessageMetadataKeys.CHAT_USAGE exists = "
|
||||
+ fieldExists(MessageMetadataKeys.class, "CHAT_USAGE"));
|
||||
|
||||
// ChatResponse.getUsage()
|
||||
assertEquals(ChatUsage.class, ChatResponse.class.getMethod("getUsage").getReturnType());
|
||||
|
||||
// MiddlewareBase: onModelCall(agent, ctx, ModelCallInput, next) is the raw model-call hook.
|
||||
// ModelCallInput carries messages+tools+options+model; the response/usage surfaces in the
|
||||
// returned Flux<AgentEvent> (ModelCallEndEvent carries usage), or aggregate from Msg.getChatUsage().
|
||||
Method onModelCall = MiddlewareBase.class.getMethod("onModelCall",
|
||||
io.agentscope.core.agent.Agent.class, RuntimeContext.class,
|
||||
ModelCallInput.class, Function.class);
|
||||
assertEquals(Flux.class, onModelCall.getReturnType(),
|
||||
"MiddlewareBase.onModelCall returns Flux<AgentEvent>");
|
||||
System.out.println("[R5] MiddlewareBase.onModelCall(Agent, RuntimeContext, ModelCallInput, "
|
||||
+ "Function<ModelCallInput,Flux<AgentEvent>>) -> Flux<AgentEvent>");
|
||||
System.out.println("[R5] ModelCallInput record components = "
|
||||
+ Arrays.stream(ModelCallInput.class.getRecordComponents())
|
||||
.map(c -> c.getName() + ":" + c.getType().getSimpleName())
|
||||
.collect(Collectors.joining(", ")));
|
||||
|
||||
// ModelCallEndEvent carries usage
|
||||
try {
|
||||
Class<?> mce = Class.forName("io.agentscope.core.event.ModelCallEndEvent");
|
||||
boolean hasUsage = Arrays.stream(mce.getMethods()).anyMatch(m -> m.getName().equals("getUsage"));
|
||||
System.out.println("[R5] ModelCallEndEvent.getUsage() present = " + hasUsage);
|
||||
} catch (ClassNotFoundException e) {
|
||||
System.out.println("[R5] ModelCallEndEvent not found on classpath");
|
||||
}
|
||||
}
|
||||
|
||||
/** R6: per-tool allow/deny/ask rules are NATIVE via PermissionContextState.Builder.addAllowRule(tool,rule). */
|
||||
@Test
|
||||
void r6_permissionRule_shape() {
|
||||
// PermissionRule record: (toolName, ruleContent, behavior, source)
|
||||
PermissionRule rule = new PermissionRule(
|
||||
"shell", "command =~ '^ls .*'", PermissionBehavior.ALLOW, "test");
|
||||
assertEquals("shell", rule.toolName());
|
||||
assertEquals(PermissionBehavior.ALLOW, rule.behavior());
|
||||
|
||||
// Per-tool rules via Builder.addAllowRule/addDenyRule/addAskRule(toolName, rule)
|
||||
PermissionContextState ctx = PermissionContextState.builder()
|
||||
.mode(PermissionMode.DEFAULT)
|
||||
.addAllowRule("shell", rule)
|
||||
.build();
|
||||
assertEquals(PermissionMode.DEFAULT, ctx.getMode());
|
||||
assertTrue(ctx.getAllowRules().containsKey("shell"),
|
||||
"PermissionContextState tracks per-tool allow rules by tool name");
|
||||
|
||||
// Enum values
|
||||
System.out.println("[R6] PermissionMode values = " + Arrays.toString(PermissionMode.values()));
|
||||
System.out.println("[R6] PermissionBehavior values = " + Arrays.toString(PermissionBehavior.values()));
|
||||
System.out.println("[R6] PermissionRule record = (toolName, ruleContent, behavior, source)");
|
||||
System.out.println("[R6] PermissionContextState.Builder.addAllowRule/addDenyRule/addAskRule(toolName, rule) "
|
||||
+ "-> command-level allowlist/denylist is NATIVE");
|
||||
System.out.println("[R6] sample allowRules keys = " + ctx.getAllowRules().keySet());
|
||||
}
|
||||
|
||||
/** R7: this test compiling + running under JDK 21 against v2 RC3 IS the baseline confirmation. */
|
||||
@Test
|
||||
void r7_java_baseline() {
|
||||
String javaVer = System.getProperty("java.version");
|
||||
System.out.println("[R7] running on java.version=" + javaVer
|
||||
+ " / " + System.getProperty("java.vm.name"));
|
||||
// ReActAgent + vendor models + state store + permission all loaded on JDK 21 -> baseline OK.
|
||||
assertNotNull(ReActAgent.builder());
|
||||
assertNotNull(OpenAIChatModel.builder());
|
||||
assertNotNull(StreamOptions.defaults());
|
||||
assertNotNull(EventType.AGENT_RESULT);
|
||||
// 1.0 streaming Event class still present (deprecated) — informs Task 6.1 bridge strategy.
|
||||
assertTrue(Arrays.stream(Event.class.getConstructors())
|
||||
.anyMatch(c -> c.getParameterCount() >= 3), "1.0 Event(type,msg,isLast) ctor still present (deprecated)");
|
||||
System.out.println("[R7] 1.0 io.agentscope.core.agent.EventType/Event/StreamOptions still present + "
|
||||
+ "@Deprecated(since=2.0.0); new API = streamEvents() + AgentEventType.");
|
||||
System.out.println("[R7] AgentEventType sample values = "
|
||||
+ Arrays.toString(Arrays.stream(AgentEventType.values()).limit(6).toArray()));
|
||||
}
|
||||
|
||||
/* ----- helpers ----- */
|
||||
|
||||
private static Set<String> builderMethods(Class<?> builderClass) {
|
||||
return Arrays.stream(builderClass.getDeclaredMethods())
|
||||
.filter(m -> m.getName().startsWith("set") == false)
|
||||
.filter(m -> m.getReturnType() == builderClass)
|
||||
.map(Method::getName)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private static void assertAll(Set<String> have, String label, String... names) {
|
||||
for (String n : names) {
|
||||
assertTrue(have.contains(n), label + ".Builder missing method: " + n
|
||||
+ " (have " + sorted(have) + ")");
|
||||
}
|
||||
}
|
||||
|
||||
private static String sorted(Set<String> s) {
|
||||
return s.stream().sorted().collect(Collectors.joining(", ", "[", "]"));
|
||||
}
|
||||
|
||||
private static boolean fieldExists(Class<?> c, String name) {
|
||||
try { c.getDeclaredField(name); return true; } catch (NoSuchFieldException e) { return false; }
|
||||
}
|
||||
}
|
||||
2
pom.xml
2
pom.xml
|
|
@ -90,7 +90,7 @@
|
|||
<sharding-jdbc.version>4.1.1</sharding-jdbc.version>
|
||||
<apache-commons-test.version>1.14.0</apache-commons-test.version>
|
||||
<caffeine.version>2.9.3</caffeine.version>
|
||||
<agentscope.version>1.0.12</agentscope.version>
|
||||
<agentscope.version>2.0.0-RC3</agentscope.version>
|
||||
<google-genai.version>1.38.0</google-genai.version>
|
||||
<anthropic-java.version>1.2.0</anthropic-java.version>
|
||||
</properties>
|
||||
|
|
|
|||
Loading…
Reference in New Issue