mirror of https://gitee.com/dromara/liteFlow
feat(agent): ReactAgentFactory 构建并按组件子类缓存无状态 ReActAgent 单例
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
f0616e8fb8
commit
9534357c69
|
|
@ -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 为 null、sysPrompt 空、或构建期任意异常时
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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*} 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)。
|
||||
*
|
||||
* <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());
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue