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 以 `
这是会话/状态层重设计(迁移 spec §4.4)的第一块:resolver 只负责 + * "按 mode 选 store 实现",不负责拼存储 key——v2 store 内部按 + * {@code (userId, sessionId)} 对寻址({@code userId = conversationId}、 + * {@code sessionId = agentKey},由 Task 2.3 在 {@code RuntimeContext} 里填)。 + * + *
延迟失败语义(沿用 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: + *
仅覆盖 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()); + } +}