From 9534357c69d14d23c978f6ef9a837d80f411881c Mon Sep 17 00:00:00 2001 From: "everywhere.z" Date: Sat, 20 Jun 2026 00:02:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent):=20ReactAgentFactory=20=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E5=B9=B6=E6=8C=89=E7=BB=84=E4=BB=B6=E5=AD=90=E7=B1=BB?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E6=97=A0=E7=8A=B6=E6=80=81=20ReActAgent=20?= =?UTF-8?q?=E5=8D=95=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../agent/component/ReactAgentFactory.java | 137 ++++++++++++++++++ .../test/agent/v2/HarnessFixture.java | 68 +++++++++ .../test/agent/v2/ReactAgentFactoryTest.java | 69 +++++++++ 3 files changed, 274 insertions(+) create mode 100644 liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReactAgentFactory.java create mode 100644 liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2/HarnessFixture.java create mode 100644 liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2/ReactAgentFactoryTest.java diff --git a/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReactAgentFactory.java b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReactAgentFactory.java new file mode 100644 index 000000000..598c3fe83 --- /dev/null +++ b/liteflow-react-agent/liteflow-react-agent-core/src/main/java/com/yomahub/liteflow/agent/component/ReactAgentFactory.java @@ -0,0 +1,137 @@ +package com.yomahub.liteflow.agent.component; + +import com.yomahub.liteflow.agent.exception.AgentConfigException; +import com.yomahub.liteflow.agent.state.AgentStateStoreResolver; +import com.yomahub.liteflow.property.agent.AgentConfig; +import io.agentscope.core.ReActAgent; +import io.agentscope.core.model.Model; +import io.agentscope.core.state.AgentStateStore; +import io.agentscope.core.tool.Toolkit; + +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 按 {@link ReActAgentComponent} 子类构建并缓存一个无状态 + * {@link ReActAgent} 单例。所有 {@code (conversationId, agentKey)} 调用复用同一实例—— + * agent 本身无状态,会话状态由 {@code RuntimeContext} 在每次 {@code call()} 时注入, + * 持久化由 {@link AgentStateStore}({@link AgentStateStoreResolver} 按 cfg 选)承担。 + * + *

这是会话/状态层重设计(迁移 spec §4.4)的第二块: + * Task 2.1 给了 stateStore 解析,本类给 agent 构建,Task 2.3 把 {@code process()} 接上。 + * + *

为什么放在 {@code component} 包

+ * 本类需要读 {@link ReActAgentComponent} 的 {@code protected} 钩子方法 + * ({@code effectiveSystemPrompt()}、{@code buildModel()}、{@code tools()}、 + * {@code maxIterations()})。这些是业务子类契约,签名受 + * "业务侧受保护方法签名不变" 约束不得改修饰符;Java {@code protected} 允许同包访问, + * 故本类与 {@link ReActAgentComponent} 同处 {@code component} 包。1.0 也是在 + * {@code ReActAgentComponent} 内部直接构建 agent(同类内访问),本类把该逻辑抽成独立 + * factory 以便 Task 2.3 的 {@code process()} 复用与单测。 + * + *

缓存键

+ * 以 {@code cmp.getClass()}(组件子类的具体 {@link Class})为键——同一种组件类型在整条 + * chain 内只有一个 agent 实例。这与 1.0 "每个 agentKey 一个 session"的隔离语义一致 + * (默认 {@code agentKey == nodeId},而同类型组件 nodeId 相同)。 + * + *

构建范围(RC3 最小可用)

+ * 只设本任务已具备的字段: + * + * + *

不设(留给后续 Task / RC3 无): + * {@code .filesystem()/.workspace()/.compaction()/.memory(MemoryConfig)}(RC3 无 HarnessAgent 这些方法)、 + * {@code .middleware(...)(Task 5.1)、.permissionContext(...)(Task 3.1)、 + * {@code .skillRepository(...)(Task 4.1)。本类不引用这些协作者。 + * + *

线程安全

+ * 缓存用 {@link ConcurrentHashMap#computeIfAbsent},构建闭包至多执行一次/类; + * 单例 {@link ReActAgent} 自身线程安全(v2 设计为可跨会话复用)。 + */ +public final class ReactAgentFactory { + + /** 按组件子类缓存的单例 agent。 */ + private static final ConcurrentHashMap, ReActAgent> CACHE = new ConcurrentHashMap<>(); + + private ReactAgentFactory() { + } + + /** + * 按 {@code cmp.getClass()} 取(或首次构建)无状态 {@link ReActAgent} 单例。 + * + * @param cmp 业务组件实例(仅读其 {@code protected} 钩子方法 + class,不持有实例引用) + * @param cfg agent 配置(读 {@code defaults.maxIterations} + 透传给 stateStore resolver) + * @return 与该组件类绑定的 {@link ReActAgent} 单例 + * @throws AgentConfigException 当 model 为 null、sysPrompt 空、或构建期任意异常时 + */ + public static ReActAgent getOrCreate(ReActAgentComponent cmp, AgentConfig cfg) { + return CACHE.computeIfAbsent(cmp.getClass(), k -> build(cmp, cfg)); + } + + /** + * 清空缓存(仅测试用,避免跨用例串扰)。 + * + *

设为 {@code public}(而非 brief 字面的"包级可见"):测试位于 + * {@code com.yomahub.liteflow.test.agent.v2},与工厂不同包,包级私有会让测试无法调用。 + * 该方法只会清空进程内缓存,生产路径从不调用,无副作用风险。 + */ + public static void resetForTesting() { + CACHE.clear(); + } + + /* ----- 构建(同包,可访问 ReActAgentComponent 的 protected 钩子)----- */ + + private static ReActAgent build(ReActAgentComponent cmp, AgentConfig cfg) { + String name = cmp.getClass().getSimpleName(); + String sysPrompt = cmp.effectiveSystemPrompt(); + if (sysPrompt == null || sysPrompt.isBlank()) { + throw new AgentConfigException( + "ReActAgent system prompt is empty for component " + name + + "; systemPrompt() must return non-blank text"); + } + Model model = cmp.buildModel(); + if (model == null) { + throw new AgentConfigException( + "ReActAgent model is null for component " + name + + "; model().resolve(agentConfig()) returned null"); + } + + Toolkit toolkit = new Toolkit(); + List tools = cmp.tools(); + if (tools != null) { + for (Object tool : tools) { + if (tool != null) { + toolkit.registerTool(tool); + } + } + } + + int maxIters = cmp.maxIterations(); + if (maxIters <= 0) { + maxIters = cfg.getDefaults().getMaxIterations(); + } + + AgentStateStore stateStore = AgentStateStoreResolver.resolve(cfg); + + try { + return ReActAgent.builder() + .name(name) + .sysPrompt(sysPrompt) + .model(model) + .toolkit(toolkit) + .maxIters(maxIters) + .stateStore(stateStore) // null 合法 = NONE 语义(findings R1) + .build(); + } catch (Exception e) { + throw new AgentConfigException( + "Failed to build ReActAgent for component " + name + ": " + e.getMessage(), e); + } + } +} diff --git a/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2/HarnessFixture.java b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2/HarnessFixture.java new file mode 100644 index 000000000..cd2cb7bf4 --- /dev/null +++ b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2/HarnessFixture.java @@ -0,0 +1,68 @@ +package com.yomahub.liteflow.test.agent.v2; + +import com.yomahub.liteflow.property.LiteflowConfig; +import com.yomahub.liteflow.property.LiteflowConfigGetter; +import com.yomahub.liteflow.property.agent.AgentConfig; +import io.agentscope.core.model.Model; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Task 2.2 测试夹具:构造最小可构建的 {@link AgentConfig}(workspace.root 指向 tmp)+ + * 一个 mock {@link Model}(避免真实网络调用)。 + * + *

命名沿用迁移 spec 的 {@code Harness*} token(RC3 实测 v2 没有 HarnessAgent, + * 已统一改为 {@code ReActAgent},但测试 helper 类名保留 {@code HarnessFixture} + * 以减少跨 task 改动——见 findings §0)。 + */ +final class HarnessFixture { + + private HarnessFixture() { + } + + /** + * 返回一个 workspace.root=tmp、memory.mode=NONE 的最小 {@link AgentConfig}。 + * 够 {@code ReactAgentFactory} 构建最小 ReActAgent(stateStore 解析为 null=NONE)。 + * + *

每次调用创建一个独立临时目录(本任务 NONE 模式不实际写盘,但保留 root + * 以便后续 Task 复用此 fixture 跑 LOCAL_FILE 等模式)。不用 JUnit {@code @TempDir}: + * 静态 helper 类的 static 字段无法被 JUnit 注入。 + * + *

全局注入: {@code ReActAgentComponent.buildModel()} 内部走 + * {@code agentConfig()} → {@link LiteflowConfigGetter#get()}(非 Spring 环境下从 + * {@code ContextAwareHolder} 取 bean 会 NPE)。本方法把 cfg 塞进一个 + * {@link LiteflowConfig} 并通过 {@link LiteflowConfigGetter#setLiteflowConfig} 注入, + * 使裸 JUnit 运行(无 Spring 上下文)也能让 {@code agentConfig()} 返回同一个 cfg。 + */ + static AgentConfig minimalConfig() { + AgentConfig c = new AgentConfig(); + c.getWorkspace().setRoot(newTempDir().toString()); + // NONE:AgentStateStoreResolver.resolve(cfg) 返回 null,builder.stateStore(null) 合法。 + c.getSession().getMemory().setMode( + com.yomahub.liteflow.property.agent.MemoryStorageMode.NONE); + + LiteflowConfig lf = new LiteflowConfig(); + lf.setAgent(c); + LiteflowConfigGetter.setLiteflowConfig(lf); + return c; + } + + /** + * 返回一个 Mockito mock 的 {@link Model}(默认 answer,零网络调用)。 + * factory 在构建 ReActAgent 时只调用 {@code builder.model(...)} 塞进去, + * 不会真正发起推理,故 mock 不需要桩任何方法。 + */ + static Model stubModel() { + return org.mockito.Mockito.mock(Model.class); + } + + private static Path newTempDir() { + try { + return Files.createTempDirectory("react-agent-factory-test-"); + } catch (IOException e) { + throw new IllegalStateException("Failed to create temp dir for test fixture", e); + } + } +} diff --git a/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2/ReactAgentFactoryTest.java b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2/ReactAgentFactoryTest.java new file mode 100644 index 000000000..969353640 --- /dev/null +++ b/liteflow-testcase-el/liteflow-testcase-el-react-agent/src/test/java/com/yomahub/liteflow/test/agent/v2/ReactAgentFactoryTest.java @@ -0,0 +1,69 @@ +package com.yomahub.liteflow.test.agent.v2; + +import com.yomahub.liteflow.agent.component.ReActAgentComponent; +import com.yomahub.liteflow.agent.component.ReactAgentFactory; +import com.yomahub.liteflow.agent.model.ModelSpec; +import com.yomahub.liteflow.property.agent.AgentConfig; +import io.agentscope.core.ReActAgent; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; + +/** + * Task 2.2 单元测试:验证 {@link ReactAgentFactory} 按 {@code cmp.getClass()} + * 构建并缓存无状态 {@link ReActAgent} 单例——同一组件子类多次 getOrCreate 返回同一实例, + * 不同子类各自独立。 + * + *

不发起任何真实 LLM 调用:{@link HarnessFixture#stubModel()} 返回 Mockito mock 的 + * {@link io.agentscope.core.model.Model},factory 只把它塞进 builder,不触发推理。 + */ +class ReactAgentFactoryTest { + + /** 最小可构建组件:提供 model()/systemPrompt()/userPrompt() 三个抽象方法。 */ + static class StubCmp extends ReActAgentComponent { + @Override + protected ModelSpec model() { + // ModelSpec 是带递归 SELF 泛型的抽象类(非函数接口),匿名 <> 无法推断 SELF, + // 故用裸类型 ModelSpec 的匿名子类实现 resolve()。 + return new ModelSpec() { + @Override + public io.agentscope.core.model.Model resolve(AgentConfig c) { + return HarnessFixture.stubModel(); + } + }; + } + + @Override + protected String systemPrompt() { + return "x"; + } + + @Override + protected String userPrompt() { + return "y"; + } + } + + /** 第二个组件子类,用于验证"不同 class → 不同 agent"。 */ + static class StubCmp2 extends StubCmp { + } + + @Test + void sameComponentClass_returnsSameSingleton() { + ReactAgentFactory.resetForTesting(); + AgentConfig cfg = HarnessFixture.minimalConfig(); + ReActAgent a1 = ReactAgentFactory.getOrCreate(new StubCmp(), cfg); + ReActAgent a2 = ReactAgentFactory.getOrCreate(new StubCmp(), cfg); + assertSame(a1, a2, "同一组件子类必须复用同一 ReActAgent 单例"); + } + + @Test + void differentClasses_getDistinctAgents() { + ReactAgentFactory.resetForTesting(); + AgentConfig cfg = HarnessFixture.minimalConfig(); + ReActAgent a1 = ReactAgentFactory.getOrCreate(new StubCmp(), cfg); + ReActAgent a2 = ReactAgentFactory.getOrCreate(new StubCmp2(), cfg); + assertNotSame(a1, a2, "不同组件子类必须各自构建独立 agent"); + } +}