feat(agent): 重写 process() 接入 ReActAgent 单例+RuntimeContext (RC3 非流式)

Task 2.3:把 Task 0 的 process() stub 替换为真实非流式实现。

- ReActAgentComponent.process():agentConfig() → ReactAgentFactory.getOrCreate
  → 解析 cid/akey 写回 slot → RuntimeContext(userId=cid, sessionId=akey)
  → 挂 ReActAgentContext(含 rc) 到 slot → agent.call(List.of(new UserMessage(userPrompt())), rc).block()
  → handleReply(reply) → finally 摘除 ctx。新增 runtimeContext()/workspaceRoot()。
  cfg 一致性:全程复用 agentConfig(),保证 stateStore 与 model 同一份配置。
- ReActAgentContext:新增 RuntimeContext 字段 + getter/setter。
- LiveTestSupport.resetAgentSessionManager():改反射已删的 AgentSessionManagerHolder
  为直接调 ReactAgentFactory.resetForTesting()(修复运行期 CNPE)。
- ProcessIntegrationTest:Spring Boot + CannedReplyModel(确定性 mock,无真实 LLM/凭据/不 skip),
  THEN(agent, recordReply) 端到端跑通,断言 success/回复流转/调用次数/conversationId。

不做流式(留 Task 6.1);不接 middleware/permission/skill。
业务侧受保护方法签名不变。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
everywhere.z 2026-06-20 00:48:15 +08:00
parent c73b261658
commit 01cd0ddc32
9 changed files with 371 additions and 26 deletions

View File

@ -1,29 +1,32 @@
package com.yomahub.liteflow.agent.component;
import com.yomahub.liteflow.agent.exception.AgentConfigException;
import com.yomahub.liteflow.agent.exception.AgentInvocationException;
import com.yomahub.liteflow.agent.model.ModelSpec;
import com.yomahub.liteflow.core.NodeComponent;
import com.yomahub.liteflow.property.LiteflowConfigGetter;
import com.yomahub.liteflow.property.agent.AgentConfig;
import com.yomahub.liteflow.slot.Slot;
import com.yomahub.liteflow.util.ConversationIdGenerator;
import io.agentscope.core.ReActAgent;
import io.agentscope.core.agent.RuntimeContext;
import io.agentscope.core.hook.Hook;
import io.agentscope.core.message.Msg;
import io.agentscope.core.message.UserMessage;
import io.agentscope.core.model.Model;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.Map;
/**
* 封装 agentscope agent LiteFlow 抽象组件
*
* <p><b>状态v2 迁移进行中Task 0 green-commit 基底</b>
* 本类已删除所有依赖 agentscope 1.0 已删类型session 管理 / skill / 工具 /
* 流式事件桥接的实现{@link #process()} 当前抛出
* {@link AgentInvocationException}后续 Task 2.x 会基于 v2
* {@code HarnessAgent} 重建 {@code process()}受保护方法签名保持不变
* 子类实现与业务侧代码无需改动
* <p><b>状态v2RC3非流式实现</b>
* {@link #process()} 通过 {@link ReactAgentFactory#getOrCreate} 取按组件类缓存的无状态
* {@code ReActAgent} 单例 {@code (conversationId, agentKey)} 构造
* {@code RuntimeContext(userId, sessionId)} {@code agent.call(List&lt;Msg&gt;, RuntimeContext).block()}
* 拿到回复后交给 {@link #handleReply(Msg)} 写回 slot流式{@code streamEvents}留给 Task 6.1
*
* <p>子类必须提供 {@link #model()}{@link #systemPrompt()} {@link #userPrompt()}
* 可选覆写方法用于自定义工具钩子和生命周期回调当前均为空实现或读
@ -34,8 +37,10 @@ import java.util.Map;
*
* <p>会话标识被拆为两层
* <ul>
* <li>{@code conversationId}业务/对话维度 {@link #resolveConversationId()} 解析</li>
* <li>{@code agentKey}组件维度默认 {@code nodeId}隔离同对话内不同 agent</li>
* <li>{@code conversationId}业务/对话维度 {@link #resolveConversationId()} 解析
* 映射为 {@code RuntimeContext.userId}</li>
* <li>{@code agentKey}组件维度默认 {@code nodeId}隔离同对话内不同 agent
* 映射为 {@code RuntimeContext.sessionId}</li>
* </ul>
*
* <p>{@link #process()} 方法被声明为 {@code final}由框架统一保证
@ -209,14 +214,72 @@ public abstract class ReActAgentComponent extends NodeComponent {
/* ===== 框架 final 执行体 ===== */
/**
* <b>v2 迁移期 stub</b>当前抛出 {@link AgentInvocationException}
* Task 2.x 基于 v2 {@code HarnessAgent} 重建
* 端到端非流式执行RC3
*
* <p>流程
* <ol>
* <li>取全局 {@link #agentConfig()} {@code buildModel()} 读取的是同一份配置
* 保证 stateStore model 配置一致Task 2.2 review cfg 一致性要求</li>
* <li> {@link ReactAgentFactory#getOrCreate} 取按组件类缓存的无状态
* {@link ReActAgent} 单例</li>
* <li>解析 {@code conversationId} / {@code agentKey}写回 slot并构造对应的
* {@link RuntimeContext}{@code userId=conversationId, sessionId=agentKey}</li>
* <li> {@link ReActAgentContext} runtimeContext挂到 slot attachment
* 使 {@link #ctx()} {@code agent.call(...)} 触发的工具回调内可用</li>
* <li> {@code agent.call(List.of(new UserMessage(userPrompt())), rc).block()}
* 阻塞拿回复 {@link #handleReply(Msg)} 处理</li>
* <li>{@code finally} 中摘除 ctx避免跨 invocation 悬挂引用</li>
* </ol>
*
* <p><b>非流式</b>本方法始终用 {@code call(...)}流式{@code streamEvents}桥接
* Task 6.1 单独实现不在本方法范围内
*
* <p>签名保持 {@code final} 1.0 一致
*/
@Override
public final void process() {
throw new AgentInvocationException(
"ReActAgentComponent v2 migration in progress; process() is rebuilt in Task 2.3");
AgentConfig cfg = agentConfig();
ReActAgent agent = ReactAgentFactory.getOrCreate(this, cfg);
Slot slot = getSlot();
String cid = resolveConversationId();
slot.setConversationId(cid);
String akey = agentKey();
RuntimeContext rc = runtimeContext(cid, akey);
ReActAgentContext ctx = new ReActAgentContext(slot, cid, akey, workspaceRoot(cfg, cid));
ctx.setRuntimeContext(rc);
slot.setAttachment(ctxKey(), ctx);
try {
Msg reply = agent.call(List.of(new UserMessage(userPrompt())), rc).block();
handleReply(reply);
} finally {
slot.removeAttachment(ctxKey());
}
}
/**
* 构造本次调用的 {@link RuntimeContext}标识映射spec §4.1
* {@code userId = conversationId}{@code sessionId = agentKey}
*
* <p>声明为 {@code protected} 便于子类在需要时覆写例如注入额外 typed extras
*/
protected RuntimeContext runtimeContext(String conversationId, String agentKey) {
return RuntimeContext.builder().userId(conversationId).sessionId(agentKey).build();
}
/**
* 返回本次调用使用的 workspace 根目录仅用于 {@link ReActAgentContext#getWorkspaceDir()}
* 暴露给业务/未来工具RC3 核心 {@code ReActAgent} {@code .workspace()}
* 此值<b></b>传给 agent builder
*
* <p>RC3-core 暂用 cfg 配置的单根目录workspace 用户分桶由后续 GA/HarnessAgent 处理
*/
protected Path workspaceRoot(AgentConfig cfg, String conversationId) {
String root = cfg.getWorkspace().getRoot();
if (root == null || root.isBlank()) {
return Paths.get(System.getProperty("java.io.tmpdir"), "liteflow-agent-workspace");
}
return Paths.get(root);
}
}

View File

@ -1,6 +1,7 @@
package com.yomahub.liteflow.agent.component;
import com.yomahub.liteflow.slot.Slot;
import io.agentscope.core.agent.RuntimeContext;
import io.agentscope.core.model.ChatUsage;
import java.nio.file.Path;
@ -30,6 +31,7 @@ public class ReActAgentContext {
private final String conversationId;
private final String agentKey;
private final Path workspaceDir;
private RuntimeContext runtimeContext;
public ReActAgentContext(Slot slot, String conversationId, String agentKey, Path workspaceDir) {
this.slot = Objects.requireNonNull(slot, "slot");
@ -46,6 +48,22 @@ public class ReActAgentContext {
public Path getWorkspaceDir() { return workspaceDir; }
/**
* 本次 {@code process()} 调用注入给底层 {@code ReActAgent.call(...)}
* {@link RuntimeContext} {@link ReActAgentComponent#process()} 在构造 ctx
* {@code agent.call(...)} 前设置
*
* <p>标识映射{@code runtimeContext.userId = conversationId}
* {@code runtimeContext.sessionId = agentKey}spec §4.1
*
* @return 本次调用使用的 {@link RuntimeContext} {@code process()} 生命周期外为 {@code null}
*/
public RuntimeContext getRuntimeContext() { return runtimeContext; }
public void setRuntimeContext(RuntimeContext runtimeContext) {
this.runtimeContext = runtimeContext;
}
/**
* 由框架注入本次 {@code process()} 调用使用的 token 累加 hook
*

View File

@ -1,5 +1,6 @@
package com.yomahub.liteflow.test.agent.support;
import com.yomahub.liteflow.agent.component.ReactAgentFactory;
import com.yomahub.liteflow.agent.model.ModelSpec;
import com.yomahub.liteflow.agent.openai.OpenAICompatible;
import com.yomahub.liteflow.property.LiteflowConfig;
@ -7,18 +8,17 @@ import com.yomahub.liteflow.property.agent.AgentConfig;
import com.yomahub.liteflow.property.agent.PlatformCredential;
import org.junit.jupiter.api.Assumptions;
import java.lang.reflect.Method;
/**
* 整个模块唯一共享的凭据/skip/重置插管
*
* <p>按用户约定不同 package 之间只共享这一层凭据解析 key skipSessionManager 重置
* <p>按用户约定不同 package 之间只共享这一层凭据解析 key skipagent 运行时缓存重置
* 其余 agent 组件辅助节点探针flow xmlapplication.properties 一律每个 package 各自冗余
*
* <p>提供
* <ul>
* <li>{@link #resetAgentSessionManager()}反射重置 ReActAgentComponent 内部单例 SessionManager
* 避免跨测试类的全局静态状态互相污染</li>
* <li>{@link #resetAgentSessionManager()}重置 {@link ReactAgentFactory} 的进程内
* {@code ReActAgent} 单例缓存1.0 反射 {@code AgentSessionManagerHolder} 已随 Task 0
* 删除会运行期 CNPE避免跨测试类的全局静态状态互相污染</li>
* <li>{@link #compatibleCustomModel()}功能测试统一用的 OpenAI 兼容自定义模型描述符</li>
* <li>各平台的装凭据或 skip方法把真实 apikey/baseUrl 从环境变量装入 AgentConfig
* 缺失即 {@code Assumptions.assumeTrue} 跳过当前测试</li>
@ -36,15 +36,17 @@ public final class LiveTestSupport {
}
/**
* 强行重置 ReActAgentComponent 内部缓存的单例 SessionManager
* 否则跨 test class JVM 静态状态复用时前一个测试遗留的 Session 会污染当前断言
* 强行重置 {@link ReactAgentFactory} 内部缓存的 {@code ReActAgent} 单例表
* 否则跨 test class JVM 静态状态复用时前一个测试遗留的 agent绑定旧 model/stateStore
* 会污染当前断言
*
* <p>方法名保留 {@code resetAgentSessionManager} 以兼容现有调用点
* {@code BaseAgentLiveTest.resetAgentRuntime}RC3 会话状态已不在独立 SessionManager
* 而由 {@code AgentStateStore} {@code (userId, sessionId)} 寻址承担重置单例缓存即可
* 让下一个测试用全新的 cfg 重新构建 agent
*/
public static void resetAgentSessionManager() throws Exception {
Class<?> holder = Class.forName(
"com.yomahub.liteflow.agent.component.ReActAgentComponent$AgentSessionManagerHolder");
Method reset = holder.getDeclaredMethod("resetForTesting");
reset.setAccessible(true);
reset.invoke(null);
public static void resetAgentSessionManager() {
ReactAgentFactory.resetForTesting();
}
/**

View File

@ -0,0 +1,51 @@
package com.yomahub.liteflow.test.agent.v2;
import io.agentscope.core.message.ContentBlock;
import io.agentscope.core.message.Msg;
import io.agentscope.core.message.TextBlock;
import io.agentscope.core.model.ChatResponse;
import io.agentscope.core.model.GenerateOptions;
import io.agentscope.core.model.ToolSchema;
import reactor.core.publisher.Flux;
import java.util.List;
/**
* 确定性的罐头回复{@link io.agentscope.core.model.Model} 实现专供 v2
* {@code process()} 集成测试在<b>无网络无真实 LLM</b> 的前提下端到端跑通
*
* <p>{@link #stream(List, List, GenerateOptions)} 无论输入如何固定 emit 一个
* {@link ChatResponse} {@link TextBlock} + {@code finishReason="stop"}
* ReActAgent 在第一轮拿到带 stop 的文本回复即收敛{@code call(...).block()} 返回该回复
* 使 {@code ReActAgentComponent.process()} 能完整走通factory agent call handleReply
* 链路而无需任何凭据
*
* <p>实现 {@link io.agentscope.core.model.Model} 全部抽象方法{@code stream} + {@code getModelName}
* {@code supportsNativeStructuredOutput} default无状态线程安全
*/
final class CannedReplyModel implements io.agentscope.core.model.Model {
static final String CANNED_REPLY = "[canned-reply] hello from mock model";
private final String modelName;
CannedReplyModel(String modelName) {
this.modelName = modelName;
}
@Override
public Flux<ChatResponse> stream(
List<Msg> messages, List<ToolSchema> tools, GenerateOptions options) {
ChatResponse resp = ChatResponse.builder()
.id("canned-" + System.nanoTime())
.content(List.<ContentBlock>of(TextBlock.builder().text(CANNED_REPLY).build()))
.finishReason("stop")
.build();
return Flux.just(resp);
}
@Override
public String getModelName() {
return modelName;
}
}

View File

@ -0,0 +1,80 @@
package com.yomahub.liteflow.test.agent.v2;
import com.yomahub.liteflow.agent.component.ReActAgentComponent;
import com.yomahub.liteflow.agent.model.ModelSpec;
import com.yomahub.liteflow.property.agent.AgentConfig;
import io.agentscope.core.model.Model;
import org.springframework.stereotype.Component;
import java.util.concurrent.atomic.AtomicInteger;
/**
* {@code ProcessIntegrationTest} 用的最小 ReActAgentComponent 子类
* <ul>
* <li>{@link #buildModel()} escape hatch 返回 {@link CannedReplyModel}绕开真实 LLM
* {@code model()} 仍需提供 buildModel() 覆写后不再走 {@code ModelSpec.resolve}</li>
* <li>关闭 shell / workspace 工具最小化 toolkit</li>
* <li>记录 userPrompt/handleReply 调用次数供断言</li>
* </ul>
*/
@Component("processAgent")
public class ProcessAgentCmp extends ReActAgentComponent {
public static final AtomicInteger USER_PROMPT_COUNT = new AtomicInteger();
public static final AtomicInteger HANDLE_REPLY_COUNT = new AtomicInteger();
public static void reset() {
USER_PROMPT_COUNT.set(0);
HANDLE_REPLY_COUNT.set(0);
}
/** 仅满足抽象方法签名buildModel() 被覆写后这里不会被调用。 */
@Override
@SuppressWarnings("rawtypes")
protected ModelSpec model() {
return new ModelSpec() {
@Override
public Model resolve(AgentConfig c) {
return new CannedReplyModel("canned");
}
};
}
@Override
protected Model buildModel() {
return new CannedReplyModel("canned");
}
@Override
protected String systemPrompt() {
return "test system prompt";
}
@Override
protected String userPrompt() {
USER_PROMPT_COUNT.incrementAndGet();
Object reqData = getSlot().getChainReqData(getSlot().getChainId());
return reqData == null ? "hi" : reqData.toString();
}
@Override
protected boolean enableShellTool() {
return false;
}
@Override
protected boolean enableWorkspaceFileTools() {
return false;
}
@Override
protected boolean enableReActLogging() {
return false;
}
@Override
protected void handleReply(io.agentscope.core.message.Msg reply) {
HANDLE_REPLY_COUNT.incrementAndGet();
super.handleReply(reply);
}
}

View File

@ -0,0 +1,92 @@
package com.yomahub.liteflow.test.agent.v2;
import com.yomahub.liteflow.agent.component.ReActAgentComponent;
import com.yomahub.liteflow.flow.LiteflowResponse;
import com.yomahub.liteflow.property.LiteflowConfig;
import com.yomahub.liteflow.test.agent.support.LiveTestSupport;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.test.context.TestPropertySource;
import javax.annotation.Resource;
/**
* Task 2.3 端到端集成测试验证重写后的 {@link ReActAgentComponent#process()}
* Spring Boot 上下文 + 最简 EL chain 下能完整跑通factory 取无状态 ReActAgent 单例
* (conversationId, agentKey) 构造 RuntimeContext agent.call(UserMessage, rc).block()
* handleReply 写回 slot.responseData链路
*
* <p><b>无真实 LLM</b>{@link ProcessAgentCmp} 覆写 {@code buildModel()} 返回
* {@link CannedReplyModel}确定性罐头回复整个测试不需要任何 apikey/baseUrl
* 不会因缺失凭据被 {@code Assumptions.assumeTrue} 跳过
*
* <p>断言
* <ul>
* <li>{@code response.isSuccess()} trueprocess() 不再抛 stub 异常</li>
* <li>下游 {@code processRecordReply} 节点拿到非空回复 CANNED_REPLY证明 responseData
* 流转正常</li>
* <li>{@code userPrompt()} / {@code handleReply(...)} 各被调用一次</li>
* <li>{@code response.getConversationId()} 非空process() 写回 slot 后透出</li>
* </ul>
*/
@TestPropertySource("classpath:/feature/process/application.properties")
@SpringBootTest(classes = ProcessIntegrationTest.class)
@EnableAutoConfiguration
@ComponentScan("com.yomahub.liteflow.test.agent.v2")
public class ProcessIntegrationTest {
@Resource
private com.yomahub.liteflow.core.FlowExecutor flowExecutor;
@Resource
private LiteflowConfig liteflowConfig;
@BeforeEach
public void resetRuntime() {
// 清空 ReactAgentFactory 进程内单例缓存保证本类用本测试的 cfg 重新构建 agent
LiveTestSupport.resetAgentSessionManager();
ProcessAgentCmp.reset();
}
@Test
public void testProcessRunsEndToEndWithMockModel() {
String prompt = "ping";
LiteflowResponse response = flowExecutor.execute2Resp("processAgentChain", prompt);
Assertions.assertTrue(response.isSuccess(),
"chain failed: " + (response.getCause() == null
? "<no cause>"
: toString(response.getCause())));
// 回复通过默认 handleReply 写入 responseData并由 processRecordReply 复制到 output
Object reply = response.getSlot().getOutput(ProcessRecordReplyCmp.NODE_ID);
Assertions.assertNotNull(reply, "agent reply must reach processRecordReply node");
Assertions.assertEquals(CannedReplyModel.CANNED_REPLY, reply.toString(),
"downstream node must observe the canned reply");
// userPrompt / handleReply 各调用一次证明 process() 真正调用了 agent 并处理回复
Assertions.assertEquals(1, ProcessAgentCmp.USER_PROMPT_COUNT.get());
Assertions.assertEquals(1, ProcessAgentCmp.HANDLE_REPLY_COUNT.get());
// conversationId process() 写回 slot 后经 LiteflowResponse 透出
Assertions.assertNotNull(response.getConversationId(),
"conversationId must be propagated to LiteflowResponse");
}
private static String toString(Throwable t) {
StringBuilder sb = new StringBuilder();
Throwable cur = t;
while (cur != null) {
sb.append(cur.getClass().getSimpleName()).append(": ").append(cur.getMessage());
cur = cur.getCause();
if (cur != null) {
sb.append(" || caused by: ");
}
}
return sb.toString();
}
}

View File

@ -0,0 +1,22 @@
package com.yomahub.liteflow.test.agent.v2;
import com.yomahub.liteflow.core.NodeComponent;
import org.springframework.stereotype.Component;
/**
* Agent 写入 slot responseData 复制到本节点 output集成测试侧通过
* {@code slot.getOutput(NODE_ID)} 拿到回复验证回复确实流转到下游普通节点
*/
@Component("processRecordReply")
public class ProcessRecordReplyCmp extends NodeComponent {
public static final String NODE_ID = "processRecordReply";
@Override
public void process() {
Object reply = getSlot().getResponseData();
if (reply != null) {
getSlot().setOutput(NODE_ID, reply);
}
}
}

View File

@ -0,0 +1,11 @@
liteflow.rule-source=feature/process/flow.el.xml
liteflow.print-banner=false
# 最小 agent 配置workspace 指向 tmpCannedReplyModel 不实际写盘memory=NONE
# shell=disabledskills 关闭。本测试不接触真实 LLM。
liteflow.agent.workspace.root=target/wk/v2_process_test
liteflow.agent.workspace.auto-create=true
liteflow.agent.shell.mode=disabled
liteflow.agent.defaults.max-iterations=3
liteflow.agent.logging.react-enabled=false
liteflow.agent.skills.enabled=false

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<flow>
<chain name="processAgentChain">
THEN(processAgent, processRecordReply);
</chain>
</flow>