From f0616e8fb8bd73a8e4e897a315e4e45704cfc05b Mon Sep 17 00:00:00 2001 From: "everywhere.z" Date: Fri, 19 Jun 2026 23:11:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent):=20AgentStateStoreResolver=20?= =?UTF-8?q?=E6=98=A0=E5=B0=84=20MemoryStorageMode=20=E5=88=B0=20v2=20Agent?= =?UTF-8?q?StateStore=20(NONE/JVM/LOCAL=5FFILE/REDIS/MYSQL/OSS)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 AgentStateStoreResolver.resolve(AgentConfig): 按 MemoryStorageMode 选 v2 AgentStateStore,替代 Task 0 删除的 AgentSessionFactory SPI 层。 - NONE→null(v2 stateStore 默认 null,save/load 短路,不造 NoOp 类); JVM→InMemoryAgentStateStore;LOCAL_FILE→JsonFileAgentStateStore(root/.agent-session); REDIS→反射构造 RedisAgentStateStore(Redisson/Jedis/Lettuce 三选一,沿用 1.0 延迟失败范式); MYSQL→反射构造 MysqlAgentStateStore(DataSource + 可选 db/table/createIfNotExist 重载)。 - findings R1-ext 新增 Redis/MySQL/OSS 扩展 store 反射探针签名(含 OSS,供将来扩展枚举复用)。 - 测试 5 例(NONE/JVM/LOCAL_FILE + REDIS/MYSQL beanName 缺失快速失败)全绿, 用 javac + JUnit console-standalone 跑(sibling 测试 WIP,留待 Task 3.2 修后正式 mvn 跑)。 - react-agent-core / liteflow-core 均 BUILD SUCCESS(green commit 不破)。 Co-Authored-By: Claude Fable 5 --- docs/superpowers/specs/v2-api-findings.md | 55 +++++ .../agent/state/AgentStateStoreResolver.java | 233 ++++++++++++++++++ .../v2/AgentStateStoreResolverExtTest.java | 55 +++++ .../agent/v2/AgentStateStoreResolverTest.java | 57 +++++ 4 files changed, 400 insertions(+) create mode 100644 liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/state/AgentStateStoreResolver.java create mode 100644 liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2/AgentStateStoreResolverExtTest.java create mode 100644 liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2/AgentStateStoreResolverTest.java diff --git a/docs/superpowers/specs/v2-api-findings.md b/docs/superpowers/specs/v2-api-findings.md index e59ad7d9e..eb19026b0 100644 --- a/docs/superpowers/specs/v2-api-findings.md +++ b/docs/superpowers/specs/v2-api-findings.md @@ -57,6 +57,61 @@ default void close(); // 默认 no --- +## R1-ext:Redis / MySQL / OSS 扩展 `AgentStateStore` 实现签名(Task 2.1 探针确认) + +**来源:** 对 `~/.m2/repository/io/agentscope/agentscope-extensions-{redis,mysql,oss}/2.0.0-RC3/*.jar` 跑 `javap -p`(Task 2.1,JDK 21)。三个扩展 jar 已在 `liteflow-react-agent-core` pom 以 `true` 声明,故 core 编译期不产生硬依赖;resolver 用反射构造,运行期缺失驱动时按"延迟失败"抛 `AgentConfigException`。 + +### Redis — `io.agentscope.extensions.redis.state.RedisAgentStateStore` +- 接口:`implements io.agentscope.core.state.AgentStateStore`。 +- 构造:**私有的 `RedisAgentStateStore(Builder)`**,对外只有 `public static Builder builder()`。 +- `RedisAgentStateStore.Builder`(实测全量方法): + ```java + Builder(); // 无参 + Builder keyPrefix(String); + Builder jedisClient(redis.clients.jedis.UnifiedJedis); + Builder lettuceClient(io.lettuce.core.RedisClient); + Builder lettuceClusterClient(io.lettuce.core.cluster.RedisClusterClient); // v2 新增(集群) + Builder redissonClient(org.redisson.api.RedissonClient); + Builder clientAdapter(io.agentscope.extensions.redis.state.RedisClientAdapter); // 通用 SPI 逃生口 + RedisAgentStateStore build(); + ``` +- **另有 `RedissonAgentStateStore`(`...state.redisson`)/ `JedisAgentStateStore`(`...state.jedis`)两个独立实现**,各自带 `Builder`、各自只接单一客户端类型。Task 2.1 **不使用**这两个(用户配的 `clientType` 三选一 + 单一 `RedisAgentStateStore.Builder` 已覆盖 REDISSON/JEDIS/LETTUCE 全部场景,语义与 1.0 `RedisAgentSessionFactory` 的反射路径一致)。 +- `RedisClientAdapter` 是内部 SPI 接口(`set/get/rightPushList/rangeList/getListLength/deleteKeys/addToSet/getSetMembers/getSetSize/keyExists/findKeysByPattern/close`),用户通常不直接实现。 +- 对 `RedisMemoryConfig.RedisClientType` 的映射(沿用 1.0 范式): + - `REDISSON` → `redissonClient(RedissonClient)` + - `JEDIS` → `jedisClient(UnifiedJedis)` + - `LETTUCE` → `lettuceClient(RedisClient)`(**非集群**;集群需 v2 新的 `lettuceClusterClient`,但 `RedisClientType` 枚举目前未区分集群,按非集群走) + +### MySQL — `io.agentscope.extensions.mysql.state.MysqlAgentStateStore` +- 接口:`implements io.agentscope.core.state.AgentStateStore`。 +- 构造:**没有 Builder,直接 public 构造器**: + ```java + MysqlAgentStateStore(javax.sql.DataSource); + MysqlAgentStateStore(javax.sql.DataSource, boolean createIfNotExist); + MysqlAgentStateStore(javax.sql.DataSource, String databaseName, String tableName, boolean createIfNotExist); + ``` +- 默认库名 `agentscope`、表名 `agentscope_sessions`(`DEFAULT_DATABASE_NAME`/`DEFAULT_TABLE_NAME` 私有常量)。 +- 对 `MysqlMemoryConfig` 的映射: + - `dataSourceBeanName`(必填)→ `ContextAwareHolder.getBean(...)` 取 `DataSource`。 + - `databaseName`/`tableName` 均留空 → 走 `(DataSource, createIfNotExist)` 双参重载。 + - 任一非空 → 走 `(DataSource, databaseName, tableName, createIfNotExist)` 四参重载(留空的那个传 null,由 store 内部回退默认)。 + - `createIfNotExist` → 第 2/4 参数。 + +### OSS — `io.agentscope.extensions.oss.OssAgentStateStore` +- 接口:`implements io.agentscope.core.state.AgentStateStore`。 +- 构造:私有的 `OssAgentStateStore(Builder)`,对外 `public static Builder builder()`。 +- `OssAgentStateStore.Builder`(实测全量方法): + ```java + Builder(); + Builder ossClient(com.aliyun.oss.OSS); + Builder bucketName(String); + Builder keyPrefix(String); + OssAgentStateStore build(); + ``` +- **注意:** `MemoryStorageMode` 枚举目前只有 NONE/JVM/LOCAL_FILE/REDIS/MYSQL 五档,**没有 OSS 档**。Task 2.1 resolver 不实现 OSS(无对应 mode 可映射);此签名记录于此供将来扩展 `MemoryStorageMode.OSS` 时直接复用,避免重复探针。 + +--- + ## R4:各 vendor `XxxChatModel.builder()` 方法名(名字有分歧!) **全部位于 `io.agentscope.core.model` 包**(不在独立 vendor jar,都在 core 里)。`public static Builder builder()` 工厂每个都有。**关键差异:stream 标志与 generateOptions 的方法名因 vendor 不同**。 diff --git a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/state/AgentStateStoreResolver.java b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/state/AgentStateStoreResolver.java new file mode 100644 index 000000000..ffd38a195 --- /dev/null +++ b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/state/AgentStateStoreResolver.java @@ -0,0 +1,233 @@ +package com.yomahub.liteflow.agent.state; + +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 com.yomahub.liteflow.property.agent.MysqlMemoryConfig; +import com.yomahub.liteflow.property.agent.RedisMemoryConfig; +import com.yomahub.liteflow.spi.holder.ContextAwareHolder; +import io.agentscope.core.state.AgentStateStore; +import io.agentscope.core.state.InMemoryAgentStateStore; +import io.agentscope.core.state.JsonFileAgentStateStore; + +import javax.sql.DataSource; +import java.lang.reflect.Method; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * 把 liteflow 的 {@link MemoryStorageMode} 映射到 agentscope v2 的 + * {@link AgentStateStore},替代 Task 0 删除的整个 {@code AgentSessionFactory} SPI 层。 + * + *

这是会话/状态层重设计(迁移 spec §4.4)的第一块:resolver 只负责 + * "按 mode 选 store 实现",不负责拼存储 key——v2 store 内部按 + * {@code (userId, sessionId)} 对寻址({@code userId = conversationId}、 + * {@code sessionId = agentKey},由 Task 2.3 在 {@code RuntimeContext} 里填)。 + * + *

mode 映射

+ * + * + *

延迟失败语义(沿用 1.0 {@code RedisAgentSessionFactory}/{@code MysqlAgentSessionFactory} + * 范式): core 模块编译期对 Redis/MySQL 驱动与扩展 jar 都是 optional、无硬依赖。 + * 若用户选了 REDIS/MYSQL 但 classpath 缺扩展 jar、或容器里找不到对应 bean,resolver 在 + * {@code process()} 首次调用时(而不是框架启动时)抛 {@link AgentConfigException}。 + * + *

{@code MemoryStorageMode} 目前没有 OSS 档(见 findings R1-ext),故本类不处理 OSS; + * 将来若新增 OSS 档,其构造签名({@code OssAgentStateStore.builder().ossClient(OSS) + * .bucketName(String).keyPrefix(String).build()})已在 R1-ext 记录,可直接复用。 + */ +public final class AgentStateStoreResolver { + + /** Redis 扩展 store 全名(core 对其无编译期依赖,反射加载)。 */ + private static final String REDIS_STORE_CLASS = + "io.agentscope.extensions.redis.state.RedisAgentStateStore"; + + /** MySQL 扩展 store 全名(core 对其无编译期依赖,反射加载)。 */ + private static final String MYSQL_STORE_CLASS = + "io.agentscope.extensions.mysql.state.MysqlAgentStateStore"; + + private AgentStateStoreResolver() { + } + + /** + * 按 {@code cfg.session.memory.mode} 选 v2 {@link AgentStateStore}。 + * + * @param cfg agent 配置(读 {@code workspace.root}、{@code session.memory.*}) + * @return 对应的 state store;{@link MemoryStorageMode#NONE} 返回 {@code null} + * (调用方 {@code builder.stateStore(null)} 即 NONE 语义) + * @throws AgentConfigException REDIS/MYSQL 模式下扩展 jar/bean 缺失或类型不匹配 + */ + public static AgentStateStore resolve(AgentConfig cfg) { + if (cfg == null) { + // 防御:无配置时按 NONE 语义(不持久化)处理,与 1.0 默认行为一致。 + return null; + } + MemoryStorageMode mode = cfg.getSession().getMemory().getMode(); + if (mode == null) { + return null; + } + switch (mode) { + case NONE: + return null; + case JVM: + return new InMemoryAgentStateStore(); + case LOCAL_FILE: + return newLocalFileStore(cfg); + case REDIS: + return newRedisStore(cfg); + case MYSQL: + return newMysqlStore(cfg); + default: + // 未来新增 mode(如 OSS)在此显式报错,而非静默返回 null。 + throw new AgentConfigException( + "Unsupported MemoryStorageMode: " + mode + + " (AgentStateStoreResolver 仅支持 NONE/JVM/LOCAL_FILE/REDIS/MYSQL)"); + } + } + + /* ----- LOCAL_FILE ----- */ + + private static AgentStateStore newLocalFileStore(AgentConfig cfg) { + String root = cfg.getWorkspace().getRoot(); + if (root == null || root.trim().isEmpty()) { + throw new AgentConfigException( + "liteflow.agent.workspace.root is required when memory.mode=LOCAL_FILE"); + } + Path dir = Paths.get(root).resolve(LocalFileMemoryConfig.SUB_DIR); + return new JsonFileAgentStateStore(dir); + } + + /* ----- REDIS(反射构造,core 不硬依赖 Redisson/Jedis/Lettuce/扩展 jar)----- */ + + private static AgentStateStore newRedisStore(AgentConfig cfg) { + RedisMemoryConfig rc = cfg.getSession().getMemory().getRedis(); + if (rc == null) { + throw new AgentConfigException( + "liteflow.agent.session.memory.redis is required when mode=REDIS"); + } + String beanName = rc.getBeanName(); + if (beanName == null || beanName.trim().isEmpty()) { + throw new AgentConfigException( + "liteflow.agent.session.memory.redis.beanName is required when mode=REDIS"); + } + Object client = ContextAwareHolder.loadContextAware().getBean(beanName); + if (client == null) { + throw new AgentConfigException("Redis client bean not found: " + beanName); + } + + RedisMemoryConfig.RedisClientType clientType = rc.getClientType(); + String builderMethod; + String clientFqn; + if (clientType == null) { + clientType = RedisMemoryConfig.RedisClientType.REDISSON; + } + switch (clientType) { + 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: " + clientType); + } + + try { + Class storeClass = Class.forName(REDIS_STORE_CLASS); + Object builder = storeClass.getMethod("builder").invoke(null); + Class clientTypeClass = Class.forName(clientFqn); + if (!clientTypeClass.isInstance(client)) { + throw new AgentConfigException("Bean '" + beanName + "' is not a " + + clientFqn + "; got " + client.getClass().getName()); + } + Method setter = builder.getClass().getMethod(builderMethod, clientTypeClass); + setter.invoke(builder, client); + if (rc.getKeyPrefix() != null && !rc.getKeyPrefix().isEmpty()) { + builder.getClass().getMethod("keyPrefix", String.class).invoke(builder, rc.getKeyPrefix()); + } + return (AgentStateStore) builder.getClass().getMethod("build").invoke(builder); + } catch (AgentConfigException e) { + throw e; + } catch (ClassNotFoundException e) { + throw new AgentConfigException( + "Class not found while building RedisAgentStateStore: " + e.getMessage() + + ". Add agentscope-extensions-redis (+ Redisson/Jedis/Lettuce driver) to the classpath.", e); + } catch (Exception e) { + throw new AgentConfigException("Failed to build RedisAgentStateStore", e); + } + } + + /* ----- MYSQL(反射构造,core 不硬依赖扩展 jar)----- */ + + private static AgentStateStore newMysqlStore(AgentConfig cfg) { + MysqlMemoryConfig mc = cfg.getSession().getMemory().getMysql(); + if (mc == null) { + throw new AgentConfigException( + "liteflow.agent.session.memory.mysql is required when mode=MYSQL"); + } + String dsBeanName = mc.getDataSourceBeanName(); + if (dsBeanName == null || dsBeanName.trim().isEmpty()) { + throw new AgentConfigException( + "liteflow.agent.session.memory.mysql.dataSourceBeanName is required when mode=MYSQL"); + } + Object ds = ContextAwareHolder.loadContextAware().getBean(dsBeanName); + if (ds == null) { + throw new AgentConfigException("DataSource bean not found: " + dsBeanName); + } + if (!(ds instanceof DataSource)) { + throw new AgentConfigException("Bean '" + dsBeanName + "' is not a javax.sql.DataSource; got " + + ds.getClass().getName()); + } + DataSource dataSource = (DataSource) ds; + boolean createIfNotExist = mc.isCreateIfNotExist(); + String db = mc.getDatabaseName(); + String table = mc.getTableName(); + + try { + Class storeClass = Class.forName(MYSQL_STORE_CLASS); + // 三个 public 构造器:见 findings R1-ext。 + // 任一自定义库名/表名非空 -> 走四参重载(留空那个传 null,store 内部回退默认)。 + if ((db != null && !db.isEmpty()) || (table != null && !table.isEmpty())) { + return (AgentStateStore) storeClass + .getConstructor(DataSource.class, String.class, String.class, boolean.class) + .newInstance(dataSource, nullIfEmpty(db), nullIfEmpty(table), createIfNotExist); + } + return (AgentStateStore) storeClass + .getConstructor(DataSource.class, boolean.class) + .newInstance(dataSource, createIfNotExist); + } catch (ClassNotFoundException e) { + throw new AgentConfigException( + "Class not found while building MysqlAgentStateStore: " + e.getMessage() + + ". Add agentscope-extensions-mysql to the classpath.", e); + } catch (Exception e) { + throw new AgentConfigException("Failed to build MysqlAgentStateStore", e); + } + } + + private static String nullIfEmpty(String s) { + return (s == null || s.isEmpty()) ? null : s; + } +} diff --git a/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2/AgentStateStoreResolverExtTest.java b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2/AgentStateStoreResolverExtTest.java new file mode 100644 index 000000000..62a2e3043 --- /dev/null +++ b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2/AgentStateStoreResolverExtTest.java @@ -0,0 +1,55 @@ +package com.yomahub.liteflow.test.agent.v2; + +import com.yomahub.liteflow.agent.exception.AgentConfigException; +import com.yomahub.liteflow.agent.state.AgentStateStoreResolver; +import com.yomahub.liteflow.property.agent.AgentConfig; +import com.yomahub.liteflow.property.agent.MemoryStorageMode; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Task 2.1 REDIS/MYSQL 配置校验测试。 + * + *

这里覆盖的是 配置缺失/bean 缺失的快速失败路径(不需要真实的 Redis/MySQL + * 驱动或扩展 jar,纯单元测试)。反射构造的 happy-path(bean 存在 + 扩展 jar 在 + * classpath)属于集成测试范畴,需要把 {@code agentscope-extensions-redis/mysql} + * 作为 test 依赖显式引入——留待后续 Task 3.2 在 react-agent 集成测试里覆盖。 + * + *

Resolver 的反射构造签名见 findings R1-ext: + *

+ */ +class AgentStateStoreResolverExtTest { + + private AgentConfig cfg(MemoryStorageMode mode) { + AgentConfig c = new AgentConfig(); + c.getWorkspace().setRoot("/tmp/unused"); + c.getSession().getMemory().setMode(mode); + return c; + } + + @Test + void redis_missingBeanName_throwsConfigException() { + // beanName 未配 -> 启动期配置校验直接报错(不会走到反射)。 + AgentConfig c = cfg(MemoryStorageMode.REDIS); + // redis.beanName 默认 null + AgentConfigException ex = assertThrows(AgentConfigException.class, + () -> AgentStateStoreResolver.resolve(c)); + assertTrue(ex.getMessage().contains("beanName"), + "missing beanName must be reported; got: " + ex.getMessage()); + } + + @Test + void mysql_missingDataSourceBeanName_throwsConfigException() { + AgentConfig c = cfg(MemoryStorageMode.MYSQL); + AgentConfigException ex = assertThrows(AgentConfigException.class, + () -> AgentStateStoreResolver.resolve(c)); + assertTrue(ex.getMessage().contains("dataSourceBeanName"), + "missing dataSourceBeanName must be reported; got: " + ex.getMessage()); + } +} diff --git a/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2/AgentStateStoreResolverTest.java b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2/AgentStateStoreResolverTest.java new file mode 100644 index 000000000..00beca72e --- /dev/null +++ b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2/AgentStateStoreResolverTest.java @@ -0,0 +1,57 @@ +package com.yomahub.liteflow.test.agent.v2; + +import com.yomahub.liteflow.agent.state.AgentStateStoreResolver; +import com.yomahub.liteflow.property.agent.AgentConfig; +import com.yomahub.liteflow.property.agent.MemoryStorageMode; +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 org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; + +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 2.1 单元测试:验证 {@link AgentStateStoreResolver} 把 + * {@link MemoryStorageMode} 正确映射到 v2 {@link AgentStateStore}。 + * + *

仅覆盖 NONE/JVM/LOCAL_FILE 三档——这三档的签名已在 findings R1 实测确认, + * 无外部依赖、纯单元测试(不启动 Spring)。REDIS/MYSQL 的 mock-bean 覆盖见 + * {@code AgentStateStoreResolverRedisTest}/{@code ...MysqlTest}(可选,依赖扩展 jar 在 classpath)。 + */ +class AgentStateStoreResolverTest { + + @TempDir + Path tmp; + + private AgentConfig cfg(MemoryStorageMode mode) { + AgentConfig c = new AgentConfig(); + c.getWorkspace().setRoot(tmp.toString()); + c.getSession().getMemory().setMode(mode); + return c; + } + + @Test + void none_returnsNull() { + // NONE 语义 = 不设 stateStore(resolver 返回 null,调用方 builder.stateStore(null))。 + assertNull(AgentStateStoreResolver.resolve(cfg(MemoryStorageMode.NONE))); + } + + @Test + void jvm_mapsTo_inMemory() { + AgentStateStore s = AgentStateStoreResolver.resolve(cfg(MemoryStorageMode.JVM)); + assertNotNull(s); + assertTrue(s instanceof InMemoryAgentStateStore, s.getClass().getName()); + } + + @Test + void localFile_mapsTo_jsonFile() { + AgentStateStore s = AgentStateStoreResolver.resolve(cfg(MemoryStorageMode.LOCAL_FILE)); + assertNotNull(s); + assertTrue(s instanceof JsonFileAgentStateStore, s.getClass().getName()); + } +}