feat(agent): ReactAgentFactory 构建并按组件子类缓存无状态 ReActAgent 单例

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
everywhere.z 2026-06-20 00:02:19 +08:00
parent f0616e8fb8
commit 9534357c69
3 changed files with 274 additions and 0 deletions

View File

@ -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} 子类构建并缓存<b>一个无状态</b>
* {@link ReActAgent} 单例所有 {@code (conversationId, agentKey)} 调用复用同一实例
* agent 本身无状态会话状态由 {@code RuntimeContext} 在每次 {@code call()} 时注入
* 持久化由 {@link AgentStateStore}{@link AgentStateStoreResolver} cfg 承担
*
* <p>这是会话/状态层重设计迁移 spec §4.4的第二块
* Task 2.1 给了 stateStore 解析本类给 agent 构建Task 2.3 {@code process()} 接上
*
* <h2>为什么放在 {@code component} </h2>
* 本类需要读 {@link ReActAgentComponent} {@code protected} 钩子方法
* {@code effectiveSystemPrompt()}{@code buildModel()}{@code tools()}
* {@code maxIterations()}这些是<b>业务子类契约</b>签名受
* "业务侧受保护方法签名不变" 约束不得改修饰符Java {@code protected} 允许同包访问
* 故本类与 {@link ReActAgentComponent} 同处 {@code component} 1.0 也是在
* {@code ReActAgentComponent} 内部直接构建 agent同类内访问本类把该逻辑抽成独立
* factory 以便 Task 2.3 {@code process()} 复用与单测
*
* <h2>缓存键</h2>
* {@code cmp.getClass()}组件子类的具体 {@link Class}为键同一种组件类型在整条
* chain 内只有一个 agent 实例这与 1.0 "每个 agentKey 一个 session"的隔离语义一致
* 默认 {@code agentKey == nodeId}而同类型组件 nodeId 相同
*
* <h2>构建范围RC3 最小可用</h2>
* 只设本任务已具备的字段
* <ul>
* <li>{@code name} 组件类简单名便于日志/调试</li>
* <li>{@code sysPrompt} {@code cmp.effectiveSystemPrompt()}框架统一提示词 + 子类自定义</li>
* <li>{@code model} {@code cmp.buildModel()}{@code ModelSpec.resolve(agentConfig())}</li>
* <li>{@code toolkit} {@code new Toolkit()} + 遍历 {@code cmp.tools()} 逐个
* {@code registerTool(Object)}v2 反射注册 {@code @Tool} 方法</li>
* <li>{@code maxIters} {@code cmp.maxIterations() > 0 ? it : cfg.defaults.maxIterations}</li>
* <li>{@code stateStore} {@link AgentStateStoreResolver#resolve(AgentConfig)}可能返回 null=NONE合法</li>
* </ul>
*
* <p><b>不设留给后续 Task / RC3 </b>
* {@code .filesystem()/.workspace()/.compaction()/.memory(MemoryConfig)}RC3 HarnessAgent 这些方法
* {@code .middleware(...)Task 5.1.permissionContext(...)Task 3.1
* {@code .skillRepository(...)Task 4.1本类不引用这些协作者
*
* <h2>线程安全</h2>
* 缓存用 {@link ConcurrentHashMap#computeIfAbsent}构建闭包至多执行一次/
* 单例 {@link ReActAgent} 自身线程安全v2 设计为可跨会话复用
*/
public final class ReactAgentFactory {
/** 按组件子类缓存的单例 agent。 */
private static final ConcurrentHashMap<Class<?>, 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 nullsysPrompt 或构建期任意异常时
*/
public static ReActAgent getOrCreate(ReActAgentComponent cmp, AgentConfig cfg) {
return CACHE.computeIfAbsent(cmp.getClass(), k -> build(cmp, cfg));
}
/**
* 清空缓存仅测试用避免跨用例串扰
*
* <p>设为 {@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<Object> 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);
}
}
}

View File

@ -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}避免真实网络调用
*
* <p>命名沿用迁移 spec {@code Harness*} tokenRC3 实测 v2 没有 HarnessAgent
* 已统一改为 {@code ReActAgent}但测试 helper 类名保留 {@code HarnessFixture}
* 以减少跨 task 改动 findings §0
*/
final class HarnessFixture {
private HarnessFixture() {
}
/**
* 返回一个 workspace.root=tmpmemory.mode=NONE 的最小 {@link AgentConfig}
* {@code ReactAgentFactory} 构建最小 ReActAgentstateStore 解析为 null=NONE
*
* <p>每次调用创建一个独立临时目录本任务 NONE 模式不实际写盘但保留 root
* 以便后续 Task 复用此 fixture LOCAL_FILE 等模式不用 JUnit {@code @TempDir}
* 静态 helper 类的 static 字段无法被 JUnit 注入
*
* <p><b>全局注入</b> {@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());
// NONEAgentStateStoreResolver.resolve(cfg) 返回 nullbuilder.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);
}
}
}

View File

@ -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 返回同一实例
* 不同子类各自独立
*
* <p>不发起任何真实 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");
}
}