diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/AgentInitRequest.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/AgentInitRequest.java new file mode 100644 index 0000000..ac2c7aa --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/AgentInitRequest.java @@ -0,0 +1,203 @@ +package com.easyagents.agent.runtime; + +import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetriever; +import com.easyagents.agent.runtime.persistence.conversation.AgentConversationRecorder; +import com.easyagents.agent.runtime.persistence.conversation.noop.NoopAgentConversationRecorder; +import com.easyagents.agent.runtime.persistence.session.AgentSessionStore; +import com.easyagents.agent.runtime.persistence.session.noop.NoopAgentSessionStore; +import com.easyagents.agent.runtime.tool.AgentToolInvoker; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 智能体初始化创建请求参数 + */ +public class AgentInitRequest { + + /** + * 会话ID,用于从会话存储中加载和保存当前智能体的上下文状态。 + */ + private String sessionId; + + /** + * 智能体定义,包含模型、系统提示词、工具、知识库、记忆策略等静态配置。 + */ + private AgentDefinition agentDefinition; + + /** + * 会话状态存储实现,可以实现此接口以管理 session + */ + private AgentSessionStore sessionStore = NoopAgentSessionStore.INSTANCE; + + /** + * 运行时上下文,传递租户、用户、链路等调用环境信息。 + */ + private AgentRuntimeContext runtimeContext = new AgentRuntimeContext(); + + /** + * 工具集合。 + */ + private Map toolInvokers = new LinkedHashMap<>(); + + /** + * 知识库集合,实现AgentKnowledgeRetriever接口以进行知识检索动作。 + */ + private Map knowledgeRetrievers = new LinkedHashMap<>(); + + /** + * 对话事件记录器,用于记录运行时事件流。 + */ + private AgentConversationRecorder conversationRecorder = NoopAgentConversationRecorder.INSTANCE; + + /** + * 初始化元数据,用于传递业务侧扩展信息。 + */ + private Map metadata = new LinkedHashMap<>(); + + /** + * 获取会话ID。 + * + * @return 会话ID + */ + public String getSessionId() { + return sessionId; + } + + /** + * 设置会话ID。 + * + * @param sessionId 会话ID + */ + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + /** + * 获取智能体定义。 + * + * @return 智能体定义 + */ + public AgentDefinition getAgentDefinition() { + return agentDefinition; + } + + /** + * 设置智能体定义。 + * + * @param agentDefinition 智能体定义 + */ + public void setAgentDefinition(AgentDefinition agentDefinition) { + this.agentDefinition = agentDefinition; + } + + /** + * 获取会话存储。 + * + * @return 会话存储 + */ + public AgentSessionStore getSessionStore() { + return sessionStore; + } + + /** + * 设置会话存储。 + * + * @param sessionStore 会话存储 + */ + public void setSessionStore(AgentSessionStore sessionStore) { + this.sessionStore = sessionStore == null ? NoopAgentSessionStore.INSTANCE : sessionStore; + } + + /** + * 获取运行时上下文。 + * + * @return 运行时上下文 + */ + public AgentRuntimeContext getRuntimeContext() { + return runtimeContext; + } + + /** + * 设置运行时上下文。 + * + * @param runtimeContext 运行时上下文 + */ + public void setRuntimeContext(AgentRuntimeContext runtimeContext) { + this.runtimeContext = runtimeContext == null ? new AgentRuntimeContext() : runtimeContext; + } + + /** + * 获取工具调用器。 + * + * @return 工具调用器 + */ + public Map getToolInvokers() { + return toolInvokers; + } + + /** + * 设置工具调用器。 + * + * @param toolInvokers 工具调用器 + */ + public void setToolInvokers(Map toolInvokers) { + this.toolInvokers = toolInvokers == null ? new LinkedHashMap<>() : toolInvokers; + } + + /** + * 获取知识库检索器。 + * + * @return 知识库检索器 + */ + public Map getKnowledgeRetrievers() { + return knowledgeRetrievers; + } + + /** + * 设置知识库检索器。 + * + * @param knowledgeRetrievers 知识库检索器 + */ + public void setKnowledgeRetrievers(Map knowledgeRetrievers) { + this.knowledgeRetrievers = knowledgeRetrievers == null ? new LinkedHashMap<>() : knowledgeRetrievers; + } + + /** + * 获取会话记录器。 + * + * @return 会话记录器 + */ + public AgentConversationRecorder getConversationRecorder() { + return conversationRecorder; + } + + /** + * 设置会话记录器。 + * + * @param conversationRecorder 会话记录器 + */ + public void setConversationRecorder(AgentConversationRecorder conversationRecorder) { + this.conversationRecorder = conversationRecorder == null + ? NoopAgentConversationRecorder.INSTANCE + : conversationRecorder; + } + + /** + * 获取元数据。 + * + * @return 元数据 + */ + public Map getMetadata() { + return metadata; + } + + /** + * 设置元数据。 + * + * @param metadata 元数据 + */ + public void setMetadata(Map metadata) { + this.metadata = metadata == null ? new LinkedHashMap<>() : metadata; + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/hitl/AgentToolApprovalResponse.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/AgentResumeRequest.java similarity index 70% rename from easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/hitl/AgentToolApprovalResponse.java rename to easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/AgentResumeRequest.java index fadf97d..0bd311f 100644 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/hitl/AgentToolApprovalResponse.java +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/AgentResumeRequest.java @@ -1,16 +1,33 @@ -package com.easyagents.agent.runtime.hitl; +package com.easyagents.agent.runtime; + +import com.easyagents.agent.runtime.hitl.AgentResumeToken; import java.util.LinkedHashMap; import java.util.Map; /** - * 工具执行审批响应。 + * 智能体挂起运行恢复请求。 */ -public class AgentToolApprovalResponse { +public class AgentResumeRequest { + /** + * 挂起运行的恢复令牌。 + */ private AgentResumeToken resumeToken; + + /** + * 是否批准继续执行。 + */ private boolean approved; + + /** + * 拒绝继续执行时的原因。 + */ private String rejectReason; + + /** + * 调用方传入的恢复元数据。 + */ private Map metadata = new LinkedHashMap<>(); /** @@ -32,7 +49,7 @@ public class AgentToolApprovalResponse { } /** - * 返回是否批准执行工具。 + * 返回是否批准继续执行。 * * @return 批准时为 true */ @@ -41,7 +58,7 @@ public class AgentToolApprovalResponse { } /** - * 设置是否批准执行工具。 + * 设置是否批准继续执行。 * * @param approved 批准标记 */ @@ -68,18 +85,18 @@ public class AgentToolApprovalResponse { } /** - * 获取审批元数据。 + * 获取恢复元数据。 * - * @return 审批元数据 + * @return 恢复元数据 */ public Map getMetadata() { return metadata; } /** - * 设置审批元数据。 + * 设置恢复元数据。 * - * @param metadata 审批元数据 + * @param metadata 恢复元数据 */ public void setMetadata(Map metadata) { this.metadata = metadata == null ? new LinkedHashMap<>() : metadata; diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/AgentRunHandle.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/AgentRunHandle.java deleted file mode 100644 index b10903e..0000000 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/AgentRunHandle.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.easyagents.agent.runtime; - -import com.easyagents.agent.runtime.event.AgentRuntimeEvent; -import com.easyagents.agent.runtime.hitl.AgentToolApprovalResponse; -import reactor.core.publisher.Flux; - -/** - * 可取消的单次智能体运行句柄。 - */ -public interface AgentRunHandle { - - /** - * 获取本次运行的事件流。 - * - * @return 事件流 - */ - Flux stream(); - - /** - * 取消本次运行。 - */ - void cancel(); - - /** - * 提交工具审批结果。 - * - * @param response 审批响应 - */ - void submitToolApproval(AgentToolApprovalResponse response); -} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/AgentRuntime.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/AgentRuntime.java index d0497ab..22c4d7b 100644 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/AgentRuntime.java +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/AgentRuntime.java @@ -1,26 +1,34 @@ package com.easyagents.agent.runtime; import com.easyagents.agent.runtime.event.AgentRuntimeEvent; +import com.easyagents.agent.runtime.message.AgentMessage; import reactor.core.publisher.Flux; /** - * 执行声明式 ReAct 智能体并流式输出运行事件。 + * 有状态智能体运行器。 */ public interface AgentRuntime { /** - * 启动单次智能体运行并返回可取消句柄。 + * 初始化智能体运行器。 * - * @param request 包含定义、输入、记忆和适配器的运行请求 - * @return 本次运行句柄 + * @param request 初始化请求 */ - AgentRunHandle start(AgentRunRequest request); + void init(AgentInitRequest request); /** - * 启动单次智能体运行。 + * 发送用户消息并流式输出运行事件。 * - * @param request 包含定义、输入、记忆和适配器的运行请求 - * @return 本次运行的事件流 + * @param userMessage 用户消息 + * @return 运行事件流 */ - Flux stream(AgentRunRequest request); + Flux stream(AgentMessage userMessage); + + /** + * 恢复一次已挂起的运行。 + * + * @param request 恢复请求 + * @return 运行事件流 + */ + Flux resume(AgentResumeRequest request); } diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/AgentRunRequest.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/AgentRuntimeExecutionContext.java similarity index 79% rename from easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/AgentRunRequest.java rename to easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/AgentRuntimeExecutionContext.java index f0ed6cb..eeaead7 100644 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/AgentRunRequest.java +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/AgentRuntimeExecutionContext.java @@ -3,32 +3,83 @@ package com.easyagents.agent.runtime; import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetriever; import com.easyagents.agent.runtime.memory.AgentMemorySnapshot; import com.easyagents.agent.runtime.message.AgentMessage; -import com.easyagents.agent.runtime.persistence.AgentConversationRecorder; -import com.easyagents.agent.runtime.persistence.AgentSessionStore; -import com.easyagents.agent.runtime.persistence.noop.NoopAgentConversationRecorder; -import com.easyagents.agent.runtime.persistence.noop.NoopAgentSessionStore; +import com.easyagents.agent.runtime.persistence.conversation.AgentConversationRecorder; +import com.easyagents.agent.runtime.persistence.conversation.noop.NoopAgentConversationRecorder; +import com.easyagents.agent.runtime.persistence.session.AgentSessionStore; +import com.easyagents.agent.runtime.persistence.session.noop.NoopAgentSessionStore; import com.easyagents.agent.runtime.tool.AgentToolInvoker; import java.util.LinkedHashMap; import java.util.Map; /** - * 一次智能体运行请求。 + * 单轮智能体运行的内部执行上下文。 */ -public class AgentRunRequest { +public class AgentRuntimeExecutionContext { + /** + * 本轮运行请求ID。 + */ private String requestId; + + /** + * 本轮运行链路ID。 + */ private String traceId; + + /** + * 当前会话ID。 + */ private String sessionId; + + /** + * 智能体定义。 + */ private AgentDefinition agentDefinition; + + /** + * 业务运行时上下文。 + */ private AgentRuntimeContext runtimeContext = new AgentRuntimeContext(); + + /** + * 本轮用户消息。 + */ private AgentMessage userMessage; + + /** + * 本轮初始记忆快照。 + */ private AgentMemorySnapshot memorySnapshot = new AgentMemorySnapshot(); + + /** + * 按工具名称索引的工具调用器。 + */ private Map toolInvokers = new LinkedHashMap<>(); + + /** + * 按知识库ID索引的检索器。 + */ private Map knowledgeRetrievers = new LinkedHashMap<>(); + + /** + * 会话状态存储。 + */ private AgentSessionStore sessionStore = NoopAgentSessionStore.INSTANCE; + + /** + * 对话事件记录器。 + */ private AgentConversationRecorder conversationRecorder = NoopAgentConversationRecorder.INSTANCE; + + /** + * 运行元数据。 + */ private Map metadata = new LinkedHashMap<>(); + + /** + * 运行取消原因。 + */ private String cancelReason; /** @@ -158,7 +209,7 @@ public class AgentRunRequest { } /** - * 获取tool invokers by tool name。 + * 获取工具调用器。 * * @return 工具调用器 */ @@ -176,7 +227,7 @@ public class AgentRunRequest { } /** - * 获取knowledge retrievers by knowledge id。 + * 获取知识库检索器。 * * @return 知识库检索器 */ @@ -212,36 +263,38 @@ public class AgentRunRequest { } /** - * 获取会话记录器。 + * 获取对话记录器。 * - * @return 会话记录器 + * @return 对话记录器 */ public AgentConversationRecorder getConversationRecorder() { return conversationRecorder; } /** - * 设置会话记录器。 + * 设置对话记录器。 * - * @param conversationRecorder 会话记录器 + * @param conversationRecorder 对话记录器 */ public void setConversationRecorder(AgentConversationRecorder conversationRecorder) { - this.conversationRecorder = conversationRecorder == null ? NoopAgentConversationRecorder.INSTANCE : conversationRecorder; + this.conversationRecorder = conversationRecorder == null + ? NoopAgentConversationRecorder.INSTANCE + : conversationRecorder; } /** - * 获取元数据。 + * 获取运行元数据。 * - * @return 元数据 + * @return 运行元数据 */ public Map getMetadata() { return metadata; } /** - * 设置元数据。 + * 设置运行元数据。 * - * @param metadata 元数据 + * @param metadata 运行元数据 */ public void setMetadata(Map metadata) { this.metadata = metadata == null ? new LinkedHashMap<>() : metadata; diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeAutoContextCompressionModel.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeAutoContextCompressionModel.java new file mode 100644 index 0000000..1f1fd2d --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeAutoContextCompressionModel.java @@ -0,0 +1,157 @@ +package com.easyagents.agent.runtime.agentscope; + +import io.agentscope.core.message.*; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.Model; +import io.agentscope.core.model.ToolSchema; +import reactor.core.publisher.Flux; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * 面向 AutoContext 摘要调用的模型包装器。 + */ +class AgentScopeAutoContextCompressionModel implements Model { + + private final Model delegate; + + /** + * 创建 AutoContext 摘要模型包装器。 + * + * @param delegate 实际调用的模型 + */ + AgentScopeAutoContextCompressionModel(Model delegate) { + this.delegate = delegate; + } + + /** + * 对 AutoContext 压缩摘要输入做协议安全化后转发给实际模型。 + * + * @param messages AgentScope 消息 + * @param tools 工具声明 + * @param options 生成参数 + * @return 模型响应流 + */ + @Override + public Flux stream(List messages, List tools, GenerateOptions options) { + return delegate.stream(sanitizeCompressionMessages(messages), tools, options); + } + + /** + * 获取模型名称。 + * + * @return 模型名称 + */ + @Override + public String getModelName() { + return delegate.getModelName(); + } + + /** + * 将 AutoContext 摘要材料中的工具协议消息转换为普通文本消息。 + * + * @param messages 原始摘要输入 + * @return 协议安全的摘要输入 + */ + List sanitizeCompressionMessages(List messages) { + if (messages == null || messages.isEmpty()) { + return messages; + } + List sanitized = new ArrayList<>(messages.size()); + for (Msg message : messages) { + if (message == null) { + continue; + } + if (requiresPlainText(message)) { + sanitized.add(toPlainTextUserMessage(message)); + } else { + sanitized.add(message); + } + } + return sanitized; + } + + private boolean requiresPlainText(Msg message) { + return message.getRole() == MsgRole.TOOL + || message.hasContentBlocks(ToolUseBlock.class) + || message.hasContentBlocks(ToolResultBlock.class); + } + + private Msg toPlainTextUserMessage(Msg message) { + return Msg.builder() + .id(message.getId()) + .role(MsgRole.USER) + .name("context") + .content(TextBlock.builder().text(renderMessage(message)).build()) + .metadata(message.getMetadata()) + .timestamp(message.getTimestamp()) + .build(); + } + + private String renderMessage(Msg message) { + StringBuilder builder = new StringBuilder(); + builder.append("Context message role=").append(message.getRole()); + if (message.getName() != null && !message.getName().isBlank()) { + builder.append(", name=").append(message.getName()); + } + builder.append('\n'); + for (ContentBlock block : message.getContent()) { + appendBlock(builder, block); + } + return builder.toString().trim(); + } + + private void appendBlock(StringBuilder builder, ContentBlock block) { + if (block instanceof TextBlock textBlock) { + appendSection(builder, "text", textBlock.getText()); + return; + } + if (block instanceof ThinkingBlock thinkingBlock) { + appendSection(builder, "thinking", thinkingBlock.getThinking()); + return; + } + if (block instanceof ToolUseBlock toolUseBlock) { + appendSection(builder, "tool_use.id", toolUseBlock.getId()); + appendSection(builder, "tool_use.name", toolUseBlock.getName()); + appendSection(builder, "tool_use.input", renderMap(toolUseBlock.getInput())); + return; + } + if (block instanceof ToolResultBlock toolResultBlock) { + appendSection(builder, "tool_result.id", toolResultBlock.getId()); + appendSection(builder, "tool_result.name", toolResultBlock.getName()); + appendSection(builder, "tool_result.output", renderBlocks(toolResultBlock.getOutput())); + return; + } + appendSection(builder, "content", String.valueOf(block)); + } + + private String renderBlocks(List blocks) { + if (blocks == null || blocks.isEmpty()) { + return ""; + } + StringBuilder builder = new StringBuilder(); + for (ContentBlock block : blocks) { + if (block instanceof TextBlock textBlock) { + builder.append(textBlock.getText()); + } else { + builder.append(String.valueOf(block)); + } + builder.append('\n'); + } + return builder.toString().trim(); + } + + private String renderMap(Map map) { + return map == null ? "{}" : map.toString(); + } + + private void appendSection(StringBuilder builder, String label, String value) { + if (value == null || value.isBlank()) { + return; + } + builder.append(label).append(": ").append(value).append('\n'); + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeEventHook.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeEventHook.java deleted file mode 100644 index 4b77656..0000000 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeEventHook.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.easyagents.agent.runtime.agentscope; - -import com.easyagents.agent.runtime.AgentRunRequest; -import io.agentscope.core.hook.Hook; -import io.agentscope.core.hook.HookEvent; -import reactor.core.publisher.Mono; - -/** - * 从 AgentScope Hook 回调发射不在主事件流中的运行时事件。 - */ -public class AgentScopeEventHook implements Hook { - - private final AgentRunRequest request; - - /** - * 创建 Hook。 - * - * @param request 运行请求 - */ - public AgentScopeEventHook(AgentRunRequest request) { - this.request = request; - } - - @Override - public Mono onEvent(T event) { - return Mono.just(event); - } -} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeKnowledgeAdapter.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeKnowledgeAdapter.java index 97b2720..59e5c99 100644 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeKnowledgeAdapter.java +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeKnowledgeAdapter.java @@ -1,9 +1,8 @@ package com.easyagents.agent.runtime.agentscope; -import com.easyagents.agent.runtime.AgentRunRequest; import com.easyagents.agent.runtime.AgentRuntimeException; -import com.easyagents.agent.runtime.event.AgentRuntimeEvent; -import com.easyagents.agent.runtime.event.AgentRuntimeEventType; +import com.easyagents.agent.runtime.AgentRuntimeExecutionContext; +import com.easyagents.agent.runtime.event.*; import com.easyagents.agent.runtime.knowledge.*; import io.agentscope.core.message.TextBlock; import io.agentscope.core.rag.Knowledge; @@ -26,8 +25,8 @@ public class AgentScopeKnowledgeAdapter { * @param request 运行请求 * @return 聚合 Knowledge;未配置知识库时返回 null */ - public Knowledge createAggregateKnowledge(AgentRunRequest request) { - return createAggregateKnowledge(request, null); + public Knowledge createAggregateKnowledge(AgentRuntimeExecutionContext request) { + return createAggregateKnowledge(request, (Sinks.Many) null); } /** @@ -37,11 +36,31 @@ public class AgentScopeKnowledgeAdapter { * @param eventSink 事件 sink * @return 聚合 Knowledge;未配置知识库时返回 null */ - public Knowledge createAggregateKnowledge(AgentRunRequest request, Sinks.Many eventSink) { + public Knowledge createAggregateKnowledge(AgentRuntimeExecutionContext request, Sinks.Many eventSink) { + return createAggregateKnowledge(request, fixedHolder(request, eventSink)); + } + + /** + * 创建可读取当前运行轮次事件出口的聚合 Knowledge。 + * + * @param request 运行时级上下文 + * @param turnContextHolder 当前运行轮次上下文持有器 + * @return 聚合 Knowledge;未配置知识库时返回 null + */ + public Knowledge createAggregateKnowledge(AgentRuntimeExecutionContext request, + AgentRuntimeTurnContextHolder turnContextHolder) { if (request.getAgentDefinition().getKnowledgeSpecs().isEmpty()) { return null; } - return new AggregateKnowledge(request, eventSink); + return new AggregateKnowledge(request, turnContextHolder); + } + + private AgentRuntimeTurnContextHolder fixedHolder(AgentRuntimeExecutionContext request, + Sinks.Many eventSink) { + AgentRuntimeTurnContextHolder holder = new AgentRuntimeTurnContextHolder(); + AgentRuntimeEventBridge bridge = new AgentRuntimeEventBridge(request, holder); + holder.set(new AgentRuntimeTurnContext(null, eventSink, bridge)); + return holder; } /** @@ -71,9 +90,9 @@ public class AgentScopeKnowledgeAdapter { payload.put("documentMetadata", document.getMetadata()); payload.putAll(document.getMetadata()); DocumentMetadata metadata = DocumentMetadata.builder() - .content(TextBlock.builder().text(document.getContent()).build()) - .docId(document.getDocumentId()) - .chunkId(document.getChunkId()) + .content(TextBlock.builder().text(safeContent(document)).build()) + .docId(safeDocumentId(document)) + .chunkId(safeChunkId(document)) .payload(payload) .build(); Document converted = new Document(metadata); @@ -81,17 +100,59 @@ public class AgentScopeKnowledgeAdapter { return converted; } + /** + * 获取 AgentScope 要求的非空文档 ID。 + * + * @param document 知识文档 + * @return 非空文档 ID + */ + private String safeDocumentId(AgentKnowledgeDocument document) { + if (document.getDocumentId() != null && !document.getDocumentId().isBlank()) { + return document.getDocumentId(); + } + if (document.getChunkId() != null && !document.getChunkId().isBlank()) { + return document.getChunkId(); + } + return "knowledge-document"; + } + + /** + * 获取 AgentScope 要求的非空分片 ID。 + * + * @param document 知识文档 + * @return 非空分片 ID + */ + private String safeChunkId(AgentKnowledgeDocument document) { + if (document.getChunkId() != null && !document.getChunkId().isBlank()) { + return document.getChunkId(); + } + if (document.getDocumentId() != null && !document.getDocumentId().isBlank()) { + return document.getDocumentId(); + } + return "0"; + } + + /** + * 获取 AgentScope 要求的非空文档内容。 + * + * @param document 知识文档 + * @return 文档内容 + */ + private String safeContent(AgentKnowledgeDocument document) { + return document.getContent() == null ? "" : document.getContent(); + } + /** * 将检索调用分发到多个知识源的聚合 Knowledge 实现。 */ private class AggregateKnowledge implements Knowledge { - private final AgentRunRequest request; - private final Sinks.Many eventSink; + private final AgentRuntimeExecutionContext request; + private final AgentRuntimeTurnContextHolder turnContextHolder; - private AggregateKnowledge(AgentRunRequest request, Sinks.Many eventSink) { + private AggregateKnowledge(AgentRuntimeExecutionContext request, AgentRuntimeTurnContextHolder turnContextHolder) { this.request = request; - this.eventSink = eventSink; + this.turnContextHolder = turnContextHolder; } /** @@ -139,9 +200,10 @@ public class AgentScopeKnowledgeAdapter { retrievalRequest.setLimit(spec.getLimit()); retrievalRequest.setScoreThreshold(Math.max(spec.getScoreThreshold(), globalThreshold)); retrievalRequest.setKnowledgeSpec(spec); - retrievalRequest.setRuntimeContext(request.getRuntimeContext()); - retrievalRequest.getMetadata().put("traceId", request.getTraceId()); - retrievalRequest.getMetadata().put("sessionId", request.getSessionId()); + AgentRuntimeExecutionContext currentRequest = currentRequest(); + retrievalRequest.setRuntimeContext(currentRequest.getRuntimeContext()); + retrievalRequest.getMetadata().put("traceId", currentRequest.getTraceId()); + retrievalRequest.getMetadata().put("sessionId", currentRequest.getSessionId()); AgentKnowledgeRetrievalResult result = retriever.retrieve(retrievalRequest); if (result == null || result.getDocuments() == null) { emitKnowledgeRetrievalEvent(query, spec, retrievalRequest, new ArrayList<>()); @@ -181,7 +243,11 @@ public class AgentScopeKnowledgeAdapter { } /** - * 发射知识库检索事件,供聊天界面展示检索过程。 + * 发射知识库检索旁路事件,供聊天界面展示检索过程。 + * + *

知识库检索本身属于 AgentScope RAG 主线路,返回的 Document 会继续进入 + * AgentScope 的上下文注入流程;这里发出的 {@code KNOWLEDGE_RETRIEVAL} + * 只是旁路告知调用方,不会回写 memory,也不会参与模型消息序列。

* * @param query 查询 * @param spec 知识库声明 @@ -192,32 +258,35 @@ public class AgentScopeKnowledgeAdapter { AgentKnowledgeSpec spec, AgentKnowledgeRetrievalRequest retrievalRequest, List documents) { - if (eventSink == null) { - return; - } - AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL); - event.setTraceId(request.getTraceId()); - event.setSessionId(request.getSessionId()); - event.setAgentId(request.getAgentDefinition().getAgentId()); - event.getMetadata().put("requestId", request.getRequestId()); + AgentRuntimeEvent event = currentEventBridge().event(AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL); event.getPayload().put("query", query); event.getPayload().put("knowledgeId", spec.getKnowledgeId()); event.getPayload().put("knowledgeName", spec.getName()); + event.getPayload().put("knowledgeType", spec.getMetadata().get("knowledgeType")); + event.getPayload().put("faqCollection", spec.getMetadata().get("faqCollection")); event.getPayload().put("limit", retrievalRequest.getLimit()); event.getPayload().put("scoreThreshold", retrievalRequest.getScoreThreshold()); event.getPayload().put("documentCount", documents == null ? 0 : documents.size()); event.getPayload().put("documents", documentSummaries(documents)); - Sinks.EmitResult result = eventSink.tryEmitNext(event); - if (result.isFailure()) { - throw new AgentRuntimeException("Failed to emit knowledge retrieval event: " + result); + currentEventBridge().emit(event); + } + + private AgentRuntimeExecutionContext currentRequest() { + return turnContextHolder == null ? request : turnContextHolder.executionContext(request); + } + + private AgentRuntimeEventBridge currentEventBridge() { + if (turnContextHolder != null && turnContextHolder.eventBridge().isPresent()) { + return turnContextHolder.eventBridge().get(); } + return new AgentRuntimeEventBridge(request, turnContextHolder); } /** - * 构建用于事件展示的文档摘要,避免把全文内容放入事件。 + * 构建用于事件展示的命中片段,保留前端引注需要的原始 chunk 内容。 * * @param documents 检索文档 - * @return 文档摘要列表 + * @return 命中片段列表 */ private List> documentSummaries(List documents) { List> summaries = new ArrayList<>(); @@ -229,6 +298,7 @@ public class AgentScopeKnowledgeAdapter { summary.put("documentId", document.getDocumentId()); summary.put("documentName", document.getDocumentName()); summary.put("chunkId", document.getChunkId()); + summary.put("chunkContent", document.getContent()); summary.put("score", document.getScore()); summary.put("sourceUri", document.getSourceUri()); summary.put("metadata", document.getMetadata()); diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeMemoryAdapter.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeMemoryAdapter.java index 6563d9d..cba5cd9 100644 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeMemoryAdapter.java +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeMemoryAdapter.java @@ -28,16 +28,35 @@ public class AgentScopeMemoryAdapter { * @return AgentScope 记忆 */ public Memory createMemory(AgentMemorySnapshot snapshot, AgentMemoryPolicy policy, Model model) { + return createMemoryResult(snapshot, policy, model).getMemory(); + } + + /** + * 创建记忆并返回 AutoContext 配置快照。 + * + *

有状态 runtime 需要把同一份 {@link AutoContextConfig} 交给 AutoContext干预器, + * 用于判断是否进入压缩流程并发出旁路事件。非 AutoContext 记忆不会返回配置。

+ * + * @param snapshot 记忆快照 + * @param policy 记忆策略 + * @param model 用于自动压缩的模型 + * @return 记忆构建结果 + */ + public AgentScopeMemoryBuildResult createMemoryResult(AgentMemorySnapshot snapshot, + AgentMemoryPolicy policy, + Model model) { AgentMemoryPolicy safePolicy = policy == null ? AgentMemoryPolicy.autoContext() : policy; Memory memory; + AutoContextConfig autoContextConfig = null; if (safePolicy.getType() == com.easyagents.agent.runtime.memory.AgentMemoryType.AUTO_CONTEXT && safePolicy.getCompressionParameter().isEnabled()) { - memory = new AutoContextMemory(toAutoContextConfig(safePolicy.getCompressionParameter()), model); + autoContextConfig = toAutoContextConfig(safePolicy.getCompressionParameter()); + memory = new AutoContextMemory(autoContextConfig, new AgentScopeAutoContextCompressionModel(model)); } else { memory = new InMemoryMemory(); } attachMessages(memory, snapshot, safePolicy.getMaxAttachedMessageCount()); - return memory; + return new AgentScopeMemoryBuildResult(memory, autoContextConfig); } /** @@ -48,13 +67,14 @@ public class AgentScopeMemoryAdapter { */ public AutoContextConfig toAutoContextConfig(AgentMemoryCompressionParameter parameter) { AgentMemoryCompressionParameter safeParameter = parameter == null ? new AgentMemoryCompressionParameter() : parameter; + Integer compressionThreshold = safeParameter.getMinCompressionTokenThreshold(); return AutoContextConfig.builder() - .msgThreshold(safeParameter.getMsgThreshold()) + .msgThreshold(safeParameter.getMsgThreshold() == null ? Integer.MAX_VALUE : safeParameter.getMsgThreshold()) .lastKeep(safeParameter.getLastKeep()) - .tokenRatio(safeParameter.getTokenRatio()) - .maxToken(safeParameter.getMaxToken()) + .tokenRatio(1.0D) + .maxToken(compressionThreshold == null ? Long.MAX_VALUE : compressionThreshold) .largePayloadThreshold(safeParameter.getLargePayloadThreshold()) - .minCompressionTokenThreshold(safeParameter.getMinCompressionTokenThreshold()) + .minCompressionTokenThreshold(compressionThreshold == null ? Integer.MAX_VALUE : compressionThreshold) .currentRoundCompressionRatio(safeParameter.getCurrentRoundCompressionRatio()) .minConsecutiveToolMessages(safeParameter.getMinConsecutiveToolMessages()) .build(); diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeMemoryBuildResult.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeMemoryBuildResult.java new file mode 100644 index 0000000..64c2484 --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeMemoryBuildResult.java @@ -0,0 +1,46 @@ +package com.easyagents.agent.runtime.agentscope; + +import io.agentscope.core.memory.Memory; +import io.agentscope.core.memory.autocontext.AutoContextConfig; + +/** + * AgentScope 记忆构建结果。 + * + *

AutoContext 的压缩入口判断必须使用创建 {@code AutoContextMemory} 时的同一份 + * {@link AutoContextConfig}。该结果对象将记忆实例和配置快照一起返回,避免运行时 + * 干预器重新推导配置导致事件触发条件与 AgentScope 主线路不一致。

+ */ +public class AgentScopeMemoryBuildResult { + + private final Memory memory; + private final AutoContextConfig autoContextConfig; + + /** + * 创建记忆构建结果。 + * + * @param memory AgentScope 记忆实例 + * @param autoContextConfig AutoContext 配置,非 AutoContext 记忆时为 null + */ + public AgentScopeMemoryBuildResult(Memory memory, AutoContextConfig autoContextConfig) { + this.memory = memory; + this.autoContextConfig = autoContextConfig; + } + + /** + * 获取 AgentScope 记忆实例。 + * + * @return AgentScope 记忆实例 + */ + public Memory getMemory() { + return memory; + } + + /** + * 获取 AutoContext 配置。 + * + * @return AutoContext 配置,非 AutoContext 记忆时为 null + */ + public AutoContextConfig getAutoContextConfig() { + return autoContextConfig; + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeReActRuntime.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeReActRuntime.java index d07d425..bf38e2d 100644 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeReActRuntime.java +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeReActRuntime.java @@ -1,16 +1,21 @@ package com.easyagents.agent.runtime.agentscope; import com.easyagents.agent.runtime.*; -import com.easyagents.agent.runtime.event.AgentRuntimeEvent; -import com.easyagents.agent.runtime.event.AgentRuntimeEventType; +import com.easyagents.agent.runtime.event.*; +import com.easyagents.agent.runtime.event.interceptor.AutoContextInterceptor; +import com.easyagents.agent.runtime.event.interceptor.ToolHitlInterceptor; +import com.easyagents.agent.runtime.event.observer.AgentRuntimeErrorObserver; +import com.easyagents.agent.runtime.event.observer.ReasoningLifecycleObserver; +import com.easyagents.agent.runtime.event.observer.SkillExecutionObserver; +import com.easyagents.agent.runtime.event.observer.ToolExecutionObserver; import com.easyagents.agent.runtime.hitl.AgentToolApprovalCoordinator; import com.easyagents.agent.runtime.hitl.AgentToolApprovalRejectedException; -import com.easyagents.agent.runtime.hitl.AgentToolApprovalResponse; import com.easyagents.agent.runtime.knowledge.AgentKnowledgeSpec; -import com.easyagents.agent.runtime.message.AgentKnowledgeReference; -import com.easyagents.agent.runtime.message.AgentMessage; +import com.easyagents.agent.runtime.knowledge.citation.AgentKnowledgeCitationMatcher; +import com.easyagents.agent.runtime.knowledge.citation.HeuristicKnowledgeCitationMatcher; +import com.easyagents.agent.runtime.message.*; +import com.easyagents.agent.runtime.persistence.session.noop.NoopAgentSessionStore; import com.easyagents.agent.runtime.skill.AgentSkillBinding; -import com.easyagents.agent.runtime.skill.AgentSkillLoadCall; import com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext; import com.easyagents.agent.runtime.tool.AgentToolInvoker; import com.easyagents.agent.runtime.tool.AgentToolSpec; @@ -19,29 +24,28 @@ import io.agentscope.core.agent.Event; import io.agentscope.core.agent.EventType; import io.agentscope.core.agent.StreamOptions; import io.agentscope.core.memory.Memory; -import io.agentscope.core.message.Msg; -import io.agentscope.core.message.ToolResultBlock; -import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.memory.autocontext.AutoContextMemory; +import io.agentscope.core.message.*; import io.agentscope.core.model.Model; import io.agentscope.core.rag.Knowledge; import io.agentscope.core.rag.RAGMode; import io.agentscope.core.rag.model.RetrieveConfig; import io.agentscope.core.session.Session; import io.agentscope.core.skill.SkillBox; +import io.agentscope.core.state.SessionKey; import io.agentscope.core.tool.AgentTool; import io.agentscope.core.tool.Toolkit; import reactor.core.publisher.Flux; import reactor.core.publisher.Sinks; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; +import java.util.*; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; /** - * 基于 AgentScope 的 ReAct 运行时。 + * 基于 AgentScope 的有状态 ReAct 运行时。 */ public class AgentScopeReActRuntime implements AgentRuntime { @@ -51,6 +55,17 @@ public class AgentScopeReActRuntime implements AgentRuntime { private final AgentScopeMemoryAdapter memoryAdapter; private final AgentScopeSkillAdapter skillAdapter; private final AgentScopeMessageAdapter messageAdapter; + private final AgentKnowledgeCitationMatcher citationMatcher = new HeuristicKnowledgeCitationMatcher(); + private final AtomicBoolean initialized = new AtomicBoolean(false); + private final AtomicBoolean running = new AtomicBoolean(false); + private AgentInitRequest initRequest; + private AgentRuntimeExecutionContext runtimeContext; + private AgentSkillRuntimeContext skillContext; + private AgentToolApprovalCoordinator approvalCoordinator; + private AgentRuntimeTurnContextHolder turnContextHolder; + private Session session; + private SessionKey sessionKey; + private ReActAgent agent; /** * 使用默认适配器创建运行时。 @@ -68,6 +83,7 @@ public class AgentScopeReActRuntime implements AgentRuntime { * @param knowledgeAdapter 知识库适配器 * @param memoryAdapter 记忆适配器 * @param skillAdapter Skill 适配器 + * @param messageAdapter 消息适配器 */ public AgentScopeReActRuntime(AgentScopeModelFactory modelFactory, AgentScopeToolAdapter toolAdapter, @@ -83,131 +99,880 @@ public class AgentScopeReActRuntime implements AgentRuntime { this.messageAdapter = messageAdapter; } + /** + * 初始化有状态运行时。 + * + * @param request 初始化请求 + */ @Override - public Flux stream(AgentRunRequest request) { + public void init(AgentInitRequest request) { + validateInitRequest(request); + if (!initialized.compareAndSet(false, true)) { + throw new AgentRuntimeException("Agent runtime has already been initialized."); + } + this.initRequest = request; + this.runtimeContext = createRuntimeContext(request); + this.skillContext = AgentSkillRuntimeContext.from(request.getAgentDefinition().getSkillBoxSpec()); + this.approvalCoordinator = AgentToolApprovalCoordinator.enabled(); + this.turnContextHolder = new AgentRuntimeTurnContextHolder(); + this.session = new AgentScopeSessionAdapter(request.getSessionStore()); + this.sessionKey = AgentScopeSessionAdapter.sessionKey(request.getSessionId()); + this.agent = buildAgent(runtimeContext); + this.agent.loadIfExists(session, sessionKey); + } + + /** + *

发送用户消息并流式输出运行事件

+ *

核心线路有两条,agent 交互的主线路与事件监察的旁线路 + *

    + *
  • 主线路:agent 运行时事件,对接 agentscope 内部状态处理,只会进行极为克制的干扰
  • + *
  • 旁线路:监察 agent 交互时的各类状态,提供给调用方和前端做状态展示,例如工具调用事件,审批事件等等
  • + *
+ *

+ * @param userMessage 用户消息 + * @return 运行事件流 + */ + @Override + public Flux stream(AgentMessage userMessage) { + validateStreamRequest(userMessage); + return Flux.defer(() -> runAgentStream(createTurnExecutionContext(userMessage), () -> buildInput(userMessage))); + } + + /** + * 恢复一次已挂起的运行。 + * + * @param request 恢复请求 + * @return 运行事件流 + */ + @Override + public Flux resume(AgentResumeRequest request) { + validateResumeRequest(request); return Flux.defer(() -> { - try { - return start(request).stream(); - } catch (Throwable error) { - return Flux.just(failed(request, error)); + if (!running.compareAndSet(false, true)) { + return Flux.error(new AgentRuntimeException("Agent runtime is already streaming.")); } + AgentRuntimeExecutionContext executionContext = createResumeExecutionContext(request); + try { + approvalCoordinator.consume(request); + } catch (RuntimeException error) { + running.set(false); + throw error; + } + // 审批拒绝 + if (!request.isApproved()) { + executionContext.setCancelReason(request.getRejectReason()); + return Flux.defer(() -> { + saveSession(); + return Flux.just(started(executionContext), cancelled(executionContext)); + }).doOnNext(event -> executionContext.getConversationRecorder().record(executionContext, event)) + .doFinally(signalType -> cleanupTurn()); + } + return runAgentStreamAfterLock(executionContext, List::of); }); } - @Override - public AgentRunHandle start(AgentRunRequest request) { - validate(request); - AgentToolApprovalCoordinator approvalCoordinator = AgentToolApprovalCoordinator.enabled(); + /** + * 执行一轮 AgentScope 主线路并合并旁路事件。 + * + * @param executionContext 本轮执行上下文 + * @param inputSupplier AgentScope 主线路输入供应器 + * @return 运行事件流 + */ + private Flux runAgentStream(AgentRuntimeExecutionContext executionContext, + Supplier> inputSupplier) { + return Flux.defer(() -> { + // 单个实例禁止并发 stream/resume,避免两轮对话同时读写 memory、session 和 turn context。 + if (!running.compareAndSet(false, true)) { + return Flux.error(new AgentRuntimeException("Agent runtime is already streaming.")); + } + return runAgentStreamAfterLock(executionContext, inputSupplier); + }); + } + + private Flux runAgentStreamAfterLock(AgentRuntimeExecutionContext executionContext, + Supplier> inputSupplier) { + // 旁线路事件监听。Tool、Knowledge、AutoContext、Skill observer 都通过 bridge 写入这里。 Sinks.Many sideEvents = Sinks.many().unicast().onBackpressureBuffer(); - AgentSkillRuntimeContext skillContext = AgentSkillRuntimeContext.from(request.getAgentDefinition().getSkillBoxSpec()); + AgentRuntimeEventBridge eventBridge = new AgentRuntimeEventBridge(runtimeContext, turnContextHolder); + // 将本轮上下文挂到 holder 后,adapter / observer 在 AgentScope 回调线程里也能拿到当前 requestId。 + turnContextHolder.set(new AgentRuntimeTurnContext(executionContext, sideEvents, eventBridge)); + // agent 输出的完整纯文本消息,用于知识库引注等。 StringBuilder finalText = new StringBuilder(); + // agent 输出的完整结构化消息。 AtomicReference finalMessage = new AtomicReference<>(); + // HITL 暂停事件。被设置后,本轮以 SUSPENDED 挂起而不是 COMPLETED 结束。 + AtomicReference suspendedEvent = new AtomicReference<>(); + // 本轮 HITL 待审批项来自旁路交互事件,最终会合并进 SUSPENDED 挂起事件。 + List> pendingApprovals = new CopyOnWriteArrayList<>(); + // 知识库引注。 Map knowledgeReferences = new LinkedHashMap<>(); + // 流式输出归一化,防止出现累计快照的重复输出。 + StreamDeltaNormalizer deltaNormalizer = new StreamDeltaNormalizer(); + // 取消输出标记。 AtomicBoolean cancelled = new AtomicBoolean(false); - ReActAgent agent = buildAgent(request, sideEvents, approvalCoordinator, skillContext); - loadSessionIfNeeded(request, agent); - List input = buildInput(request); - StreamOptions streamOptions = StreamOptions.builder() + // 旁线路监察事件流式输出。 + Flux sideEventFlux = sideEvents.asFlux() + .doOnNext(event -> updateKnowledgeReferences(knowledgeReferences, event)) + .doOnNext(event -> updatePendingApprovals(pendingApprovals, event)); + // 主线路 agent 交互。resume 场景会传入空列表,让 AgentScope 从 pending tool 继续执行。 + Flux mainEventFlux = agent.stream(inputSupplier.get(), streamOptions()) + .timeout(executionContext.getAgentDefinition().getExecutionOptions().getTimeout()) + // 保存输出完毕的完整消息 + .doOnNext(event -> captureFinalAgentResult(finalText, finalMessage, event)) + // 映射主线路事件:推理增量、文本增量和 HITL 暂停信号,并执行消息归一化。 + .flatMapIterable(event -> mapStreamEvent(executionContext, event)) + .map(deltaNormalizer::normalize) + .doOnNext(event -> updateSuspendedEvent(suspendedEvent, event)) + .doOnNext(event -> updateFinalText(finalText, event)) + .doOnNext(event -> updateFinalMessage(finalMessage, event)) + // 输出完毕后持久化 memory/session/toolkit 等状态。 + .doOnComplete(this::saveSession) + // 主线路结束后关闭旁路监听事件,让 merge 流可以自然完成。 + .doFinally(signalType -> sideEvents.tryEmitComplete()) + // 本次输出完毕。 + .concatWith(Flux.defer(() -> { + AgentRuntimeEvent suspended = suspendedEvent.get(); + if (suspended != null) { + // 触发 hitl 审批事件,暂时挂起。 + suspended.getPayload().put("pendingApprovals", pendingApprovals); + return Flux.just(suspended); + } + return Flux.just(completed(executionContext, finalText.toString(), + finalMessage.get(), knowledgeReferences)); + })); + // STARTED 先发出;随后主线路和旁线路并行输出。 + return Flux.concat(Flux.just(started(executionContext)), Flux.merge(sideEventFlux, mainEventFlux)) + .onErrorResume(error -> errorEventFlux(executionContext, error)) + // 输出所有事件统一交给调用方存储事件记录,默认是空实现即不记录。 + .doOnNext(event -> executionContext.getConversationRecorder().record(executionContext, event)) + // 处理中断请求 + .doOnCancel(() -> cancelInternal(executionContext, sideEvents, cancelled)) + // 释放运行锁并清掉 turn context。 + .doFinally(signalType -> cleanupTurn()); + } + + /** + * 校验流式运行的必要条件。 + * + * @param userMessage 用户消息 + */ + private void validateStreamRequest(AgentMessage userMessage) { + if (!initialized.get() || agent == null) { + throw new AgentRuntimeException("Agent runtime has not been initialized."); + } + if (userMessage == null) { + throw new AgentRuntimeException("Agent user message is required."); + } + if (userMessage.getContentBlocks() == null || userMessage.getContentBlocks().isEmpty()) { + throw new AgentRuntimeException("Agent user message content is required."); + } + } + + /** + * 校验恢复请求的必要条件。 + * + * @param request 恢复请求 + */ + private void validateResumeRequest(AgentResumeRequest request) { + if (!initialized.get() || agent == null) { + throw new AgentRuntimeException("Agent runtime has not been initialized."); + } + if (request == null) { + throw new AgentRuntimeException("Agent resume request is required."); + } + if (request.getResumeToken() == null + || request.getResumeToken().getValue() == null + || request.getResumeToken().getValue().isBlank()) { + throw new AgentRuntimeException("Agent resume token is required."); + } + } + + /** + * 创建单轮运行上下文。 + * + * @param userMessage 用户消息 + * @return 单轮运行上下文 + */ + private AgentRuntimeExecutionContext createTurnExecutionContext(AgentMessage userMessage) { + AgentRuntimeExecutionContext context = new AgentRuntimeExecutionContext(); + context.setRequestId(UUID.randomUUID().toString()); + context.setTraceId(runtimeContext.getTraceId()); + context.setSessionId(runtimeContext.getSessionId()); + context.setAgentDefinition(runtimeContext.getAgentDefinition()); + context.setRuntimeContext(runtimeContext.getRuntimeContext()); + context.setUserMessage(userMessage); + context.setToolInvokers(runtimeContext.getToolInvokers()); + context.setKnowledgeRetrievers(runtimeContext.getKnowledgeRetrievers()); + context.setSessionStore(runtimeContext.getSessionStore()); + context.setConversationRecorder(runtimeContext.getConversationRecorder()); + context.setMetadata(runtimeContext.getMetadata()); + return context; + } + + /** + * 创建恢复运行上下文。 + * + * @param request 恢复请求 + * @return 恢复运行上下文 + */ + private AgentRuntimeExecutionContext createResumeExecutionContext(AgentResumeRequest request) { + AgentRuntimeExecutionContext context = new AgentRuntimeExecutionContext(); + context.setRequestId(UUID.randomUUID().toString()); + context.setTraceId(runtimeContext.getTraceId()); + context.setSessionId(runtimeContext.getSessionId()); + context.setAgentDefinition(runtimeContext.getAgentDefinition()); + context.setRuntimeContext(runtimeContext.getRuntimeContext()); + context.setToolInvokers(runtimeContext.getToolInvokers()); + context.setKnowledgeRetrievers(runtimeContext.getKnowledgeRetrievers()); + context.setSessionStore(runtimeContext.getSessionStore()); + context.setConversationRecorder(runtimeContext.getConversationRecorder()); + Map metadata = new LinkedHashMap<>(runtimeContext.getMetadata()); + metadata.putAll(request.getMetadata()); + metadata.put("resume", true); + metadata.put("resumeToken", request.getResumeToken().getValue()); + context.setMetadata(metadata); + return context; + } + + /** + * 构建传入 AgentScope 主线路的用户输入。 + * + *

这里只传当前用户消息。历史上下文、AutoContext 压缩和 session 状态均由 + * AgentScope memory/session 主线路负责管理。

+ * + * @param userMessage 用户消息 + * @return AgentScope 输入消息 + */ + private List buildInput(AgentMessage userMessage) { + Msg message = messageAdapter.toMsg(userMessage); + return message == null ? List.of() : List.of(message); + } + + /** + * 构建 AgentScope 流式选项。 + * + * @return 流式选项 + */ + private StreamOptions streamOptions() { + return StreamOptions.builder() .eventTypes(EventType.ALL) .incremental(true) - .includeReasoningChunk(request.getAgentDefinition().getExecutionOptions().isReasoningEnabled()) - .includeReasoningResult(true) + .includeReasoningChunk(runtimeContext.getAgentDefinition().getExecutionOptions().isReasoningEnabled()) + .includeReasoningResult(false) .includeActingChunk(true) .includeSummaryChunk(true) .includeSummaryResult(true) .build(); - Flux mappedAgentEvents = agent.stream(input, streamOptions) - .timeout(request.getAgentDefinition().getExecutionOptions().getTimeout()) - .flatMapIterable(event -> mapEvent(request, event, skillContext)) - .doOnNext(event -> updateFinalText(finalText, event)) - .doOnNext(event -> updateFinalMessage(finalMessage, event)) - .doOnNext(event -> updateKnowledgeReferences(knowledgeReferences, event)) - .doOnComplete(() -> saveSessionIfNeeded(request, agent)) - .doFinally(signalType -> sideEvents.tryEmitComplete()) - .concatWith(Flux.defer(() -> Flux.just(completed(request, finalText.toString(), finalMessage.get(), knowledgeReferences)))); - Flux stream = Flux.concat(Flux.just(started(request)), Flux.merge(sideEvents.asFlux(), mappedAgentEvents)) - .doOnNext(event -> request.getConversationRecorder().record(request, event)) - .onErrorResume(error -> { - if (error instanceof AgentToolApprovalRejectedException approvalRejectedException) { - // 工具执行被拒绝 - request.setCancelReason(approvalRejectedException.getRejectReason()); - saveSessionIfNeeded(request, agent); - if (cancelled.compareAndSet(false, true)) { - return Flux.just(cancelled(request)) - .doOnNext(event -> request.getConversationRecorder().record(request, event)); - } - return Flux.empty(); - } - return Flux.just(failed(request, error)) - .doOnNext(event -> request.getConversationRecorder().record(request, event)); - }); - return new AgentRunHandle() { - private final AtomicBoolean cancelled = new AtomicBoolean(false); - - @Override - public Flux stream() { - return stream.doOnCancel(() -> cancelInternal(agent, approvalCoordinator, sideEvents, request, cancelled)); - } - - @Override - public void cancel() { - cancelInternal(agent, approvalCoordinator, sideEvents, request, cancelled); - } - - @Override - public void submitToolApproval(AgentToolApprovalResponse response) { - approvalCoordinator.submit(response); - } - }; } /** - * 为单次请求构建 ReActAgent。 + * 将 AgentScope 主线路事件映射为 Easy-Agents 运行时事件。 * - * @param request 运行请求 - * @param sideEvents 旁路事件 sink - * @return ReActAgent 实例 + *

这里仅处理 AgentScope {@code agent.stream(...)} 主线路中需要展示给调用方的 + * 模型内容。工具、知识库、AutoContext 和 Skill 状态都由旁路 bridge 通过 + * observer/interceptor 发出,不参与这里的 AgentScope 主输出映射。

+ * + * @param context 本轮运行上下文 + * @param event AgentScope 主线路事件 + * @return 运行时事件列表 */ - public ReActAgent buildAgent(AgentRunRequest request, Sinks.Many sideEvents) { - return buildAgent(request, sideEvents, AgentToolApprovalCoordinator.disabled()); + private List mapStreamEvent(AgentRuntimeExecutionContext context, Event event) { + if (event == null) { + return List.of(); + } + if (event.getType() == EventType.REASONING) { + return mapReasoningEvent(context, event); + } + if (event.getType() == EventType.SUMMARY) { + return mapSummaryEvent(context, event); + } + if (event.getType() == EventType.AGENT_RESULT) { + return mapAgentResultEvent(context, event); + } + // HINT 属于 AgentScope 注入给模型的上下文提示,不作为聊天主输出展示。 + return List.of(); } /** - * 为单次请求构建 ReActAgent。 + * 映射 AgentScope reasoning chunk。 * - * @param request 运行请求 - * @param sideEvents 旁路事件 sink - * @param approvalCoordinator 审批协调器 - * @return ReActAgent 实例 + * @param context 本轮运行上下文 + * @param event AgentScope reasoning 事件 + * @return 运行时事件列表 */ - public ReActAgent buildAgent(AgentRunRequest request, - Sinks.Many sideEvents, - AgentToolApprovalCoordinator approvalCoordinator) { - return buildAgent(request, sideEvents, approvalCoordinator, - AgentSkillRuntimeContext.from(request.getAgentDefinition().getSkillBoxSpec())); + private List mapReasoningEvent(AgentRuntimeExecutionContext context, Event event) { + if (event.isLast() || event.getMessage() == null || event.getMessage().getContent() == null) { + return List.of(); + } + List events = new ArrayList<>(); + List blocks = event.getMessage().getContent(); + for (int blockIndex = 0; blockIndex < blocks.size(); blockIndex++) { + ContentBlock block = blocks.get(blockIndex); + if (block instanceof ThinkingBlock thinkingBlock) { + AgentRuntimeEvent runtimeEvent = base(context, AgentRuntimeEventType.REASONING_DELTA); + runtimeEvent.setMessageId(event.getMessageId()); + runtimeEvent.getPayload().put("reasoning", thinkingBlock.getThinking()); + runtimeEvent.getPayload().put("last", event.isLast()); + runtimeEvent.getMetadata().put("source", "AGENTSCOPE_STREAM"); + runtimeEvent.getMetadata().put("blockIndex", blockIndex); + runtimeEvent.getMetadata().putAll(nullToEmpty(thinkingBlock.getMetadata())); + events.add(runtimeEvent); + continue; + } + if (block instanceof TextBlock textBlock) { + AgentRuntimeEvent runtimeEvent = base(context, AgentRuntimeEventType.MESSAGE_DELTA); + runtimeEvent.setMessageId(event.getMessageId()); + runtimeEvent.getPayload().put("text", textBlock.getText()); + runtimeEvent.getPayload().put("last", event.isLast()); + runtimeEvent.getMetadata().put("source", "AGENTSCOPE_STREAM"); + runtimeEvent.getMetadata().put("blockIndex", blockIndex); + events.add(runtimeEvent); + continue; + } + } + return events; } /** - * 为单次请求构建 ReActAgent。 + * 映射 AgentScope summary chunk。 * - * @param request 运行请求 - * @param sideEvents 旁路事件 sink - * @param approvalCoordinator 审批协调器 - * @param skillContext Skill 运行时上下文 - * @return ReActAgent 实例 + * @param context 本轮运行上下文 + * @param event AgentScope summary 事件 + * @return 运行时事件列表 */ - public ReActAgent buildAgent(AgentRunRequest request, - Sinks.Many sideEvents, - AgentToolApprovalCoordinator approvalCoordinator, - AgentSkillRuntimeContext skillContext) { + private List mapSummaryEvent(AgentRuntimeExecutionContext context, Event event) { + if (event.getMessage() == null || event.getMessage().getContent() == null) { + return List.of(); + } + List events = new ArrayList<>(); + List blocks = event.getMessage().getContent(); + for (int blockIndex = 0; blockIndex < blocks.size(); blockIndex++) { + ContentBlock block = blocks.get(blockIndex); + if (block instanceof TextBlock textBlock) { + AgentRuntimeEvent runtimeEvent = base(context, AgentRuntimeEventType.MESSAGE_DELTA); + runtimeEvent.setMessageId(event.getMessageId()); + runtimeEvent.getPayload().put("text", textBlock.getText()); + runtimeEvent.getPayload().put("last", event.isLast()); + runtimeEvent.getPayload().put("summary", true); + runtimeEvent.getMetadata().put("source", "AGENTSCOPE_STREAM"); + runtimeEvent.getMetadata().put("blockIndex", blockIndex); + runtimeEvent.getMetadata().put("streamType", "SUMMARY"); + events.add(runtimeEvent); + } + } + return events; + } + + /** + * 映射 AgentScope 最终结果事件。 + * + *

普通最终结果只用于后端收口,不作为 {@link AgentRuntimeEventType#MESSAGE_DELTA} + * 下发给前端。HITL 暂停是例外:AgentScope 用最终结果事件携带 + * {@link GenerateReason#REASONING_STOP_REQUESTED},这里需要转换为 Easy-Agents + * 的 {@link AgentRuntimeEventType#SUSPENDED}。

+ * + * @param context 本轮运行上下文 + * @param event AgentScope 最终结果事件 + * @return 运行时事件列表 + */ + private List mapAgentResultEvent(AgentRuntimeExecutionContext context, Event event) { + if (event.getMessage() != null + && event.getMessage().getGenerateReason() == GenerateReason.REASONING_STOP_REQUESTED) { + return List.of(suspended(context, event)); + } + return List.of(); + } + + /** + * 生成暂停事件。 + * + *

该事件表示 AgentScope 主线路被 HITL interceptor 主动暂停并挂起,等待外部审批后恢复。

+ * + * @param context 本轮运行上下文 + * @param sourceEvent AgentScope 结果事件 + * @return 暂停事件 + */ + private AgentRuntimeEvent suspended(AgentRuntimeExecutionContext context, Event sourceEvent) { + AgentRuntimeEvent event = base(context, AgentRuntimeEventType.SUSPENDED); + event.setMessageId(sourceEvent.getMessageId()); + if (sourceEvent.getMessage() != null) { + event.setMessage(messageAdapter.toAgentMessage(sourceEvent.getMessage())); + } + event.getPayload().put("reason", context.getMetadata().getOrDefault("hitlSuspendReason", "TOOL_APPROVAL_REQUIRED")); + Object pendingApprovals = context.getMetadata().get("hitlPendingApprovals"); + event.getPayload().put("pendingApprovals", pendingApprovals instanceof List list ? list : List.of()); + event.getMetadata().put("source", "AGENTSCOPE_STREAM"); + event.getMetadata().put("generateReason", sourceEvent.getMessage() == null + ? GenerateReason.REASONING_STOP_REQUESTED.name() + : sourceEvent.getMessage().getGenerateReason().name()); + return event; + } + + /** + * 生成开始事件。 + * + * @param context 本轮运行上下文 + * @return 开始事件 + */ + private AgentRuntimeEvent started(AgentRuntimeExecutionContext context) { + AgentRuntimeEvent event = base(context, AgentRuntimeEventType.STARTED); + event.getPayload().put("requestId", context.getRequestId()); + if (Boolean.TRUE.equals(context.getMetadata().get("resume"))) { + event.getPayload().put("resume", true); + event.getPayload().put("resumeToken", context.getMetadata().get("resumeToken")); + } + return event; + } + + /** + * 生成完成事件。 + * + * @param context 本轮运行上下文 + * @param text 最终文本 + * @param message 最终结构化消息 + * @param knowledgeReferences 本轮知识候选 + * @return 完成事件 + */ + private AgentRuntimeEvent completed(AgentRuntimeExecutionContext context, + String text, + AgentMessage message, + Map knowledgeReferences) { + String finalText = text == null ? "" : text; + AgentMessage finalMessage = message == null ? AgentMessage.text(AgentMessageRole.ASSISTANT, finalText) : message; + List matchedReferences = citationMatcher.match(finalText, knowledgeReferences.values()); + finalMessage.setKnowledgeReferences(matchedReferences); + AgentRuntimeEvent event = base(context, AgentRuntimeEventType.COMPLETED); + event.getPayload().put("text", finalText); + event.setMessage(finalMessage); + return event; + } + + /** + * 生成失败事件。 + * + * @param context 本轮运行上下文 + * @param error 运行异常 + * @return 失败事件 + */ + private AgentRuntimeEvent failed(AgentRuntimeExecutionContext context, Throwable error) { + AgentRuntimeEvent event = base(context, AgentRuntimeEventType.FAILED); + event.getPayload().put("message", error == null ? "Agent runtime failed." : error.getMessage()); + event.getPayload().put("errorType", error == null ? null : error.getClass().getName()); + return event; + } + + /** + * 生成取消事件。 + * + * @param context 本轮运行上下文 + * @return 取消事件 + */ + private AgentRuntimeEvent cancelled(AgentRuntimeExecutionContext context) { + AgentRuntimeEvent event = base(context, AgentRuntimeEventType.CANCELLED); + event.getPayload().put("reason", context.getCancelReason()); + if (Boolean.TRUE.equals(context.getMetadata().get("resume"))) { + event.getPayload().put("resume", true); + event.getPayload().put("resumeToken", context.getMetadata().get("resumeToken")); + } + return event; + } + + /** + * 将异常转换为运行时事件流。 + * + * @param context 本轮运行上下文 + * @param error 异常 + * @return 错误收口事件流 + */ + private Flux errorEventFlux(AgentRuntimeExecutionContext context, Throwable error) { + if (error instanceof AgentToolApprovalRejectedException approvalRejectedException) { + context.setCancelReason(approvalRejectedException.getRejectReason()); + saveSession(); + return Flux.just(cancelled(context)); + } + return Flux.just(failed(context, error)); + } + + /** + * 保存 AgentScope 会话状态。 + */ + private void saveSession() { + agent.saveTo(session, sessionKey); + } + + /** + * 响应 Reactor 取消信号。 + * + *

取消本身是调用方终止订阅行为,因此当前订阅者通常无法再收到 {@code CANCELLED} + * 事件。这里的职责是中断 AgentScope 主线路、释放等待中的审批请求,并完成旁路 sink。

+ * + * @param context 本轮运行上下文 + * @param sideEvents 旁路事件 sink + * @param cancelled 取消去重标记 + */ + private void cancelInternal(AgentRuntimeExecutionContext context, + Sinks.Many sideEvents, + AtomicBoolean cancelled) { + if (!cancelled.compareAndSet(false, true)) { + return; + } + context.setCancelReason("cancelled"); + approvalCoordinator.cancelAll(context.getCancelReason()); + agent.interrupt(); + sideEvents.tryEmitComplete(); + } + + /** + * 清理本轮状态。 + */ + private void cleanupTurn() { + turnContextHolder.clear(); + running.set(false); + } + + /** + * 从 AgentScope 最终结果中捕获后端收口需要的完整消息。 + * + *

这是后端内部状态收集

+ * + * @param finalText 最终文本 + * @param finalMessage 最终结构化消息引用 + * @param event AgentScope 主线路事件 + */ + private void captureFinalAgentResult(StringBuilder finalText, + AtomicReference finalMessage, + Event event) { + if (event == null + || event.getType() != EventType.AGENT_RESULT + || event.getMessage() == null + || event.getMessage().getGenerateReason() == GenerateReason.REASONING_STOP_REQUESTED) { + return; + } + AgentMessage message = messageAdapter.toAgentMessage(event.getMessage()); + finalMessage.set(message); + String messageText = textContent(message); + if (!messageText.isBlank()) { + finalText.setLength(0); + finalText.append(messageText); + } + } + + /** + * 从消息增量事件中收集最终助手文本。 + * + * @param finalText 最终文本 + * @param event 运行时事件 + */ + private void updateFinalText(StringBuilder finalText, AgentRuntimeEvent event) { + if (event.getEventType() != AgentRuntimeEventType.MESSAGE_DELTA) { + return; + } + Object text = event.getPayload().get("text"); + if (text instanceof String textValue && !textValue.isEmpty()) { + finalText.append(textValue); + } + if (event.getMessage() != null && Boolean.TRUE.equals(event.getPayload().get("last"))) { + String messageText = textContent(event.getMessage()); + if (!messageText.isBlank()) { + finalText.setLength(0); + finalText.append(messageText); + } + } + } + + /** + * 从最终增量中收集结构化助手消息。 + * + * @param finalMessage 最终消息引用 + * @param event 运行时事件 + */ + private void updateFinalMessage(AtomicReference finalMessage, AgentRuntimeEvent event) { + if (event.getEventType() == AgentRuntimeEventType.MESSAGE_DELTA + && Boolean.TRUE.equals(event.getPayload().get("last")) + && event.getMessage() != null) { + finalMessage.set(event.getMessage()); + } + } + + /** + * 记录本轮是否已被 HITL 暂停。 + * + * @param suspendedEvent 暂停事件引用 + * @param event 运行时事件 + */ + private void updateSuspendedEvent(AtomicReference suspendedEvent, AgentRuntimeEvent event) { + if (event.getEventType() == AgentRuntimeEventType.SUSPENDED) { + suspendedEvent.set(event); + } + } + + /** + * 从工具审批旁路事件中收集本轮待审批项。 + * + * @param pendingApprovals 待审批项集合 + * @param event 运行时事件 + */ + private void updatePendingApprovals(List> pendingApprovals, AgentRuntimeEvent event) { + if (event == null || event.getEventType() != AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED) { + return; + } + Map approval = new LinkedHashMap<>(); + approval.put("resumeToken", event.getPayload().get("resumeToken")); + approval.put("toolCallId", event.getPayload().get("toolCallId")); + approval.put("toolName", event.getPayload().get("toolName")); + approval.put("toolInput", event.getPayload().get("toolInput")); + approval.put("expiresAt", event.getPayload().get("expiresAt")); + approval.put("approvalPrompt", event.getPayload().get("approvalPrompt")); + pendingApprovals.add(approval); + } + + /** + * 从知识库旁路事件中收集本轮候选引用。 + * + *

知识库检索仍属于 AgentScope RAG 主线路;这里仅读取旁路事件 payload, + * 为最终消息展示引用做保守匹配,不影响 AgentScope memory/session。

+ * + * @param knowledgeReferences 候选引用集合 + * @param event 运行时事件 + */ + @SuppressWarnings("unchecked") + private void updateKnowledgeReferences(Map knowledgeReferences, + AgentRuntimeEvent event) { + if (event == null || event.getEventType() != AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL) { + return; + } + Object documentsObject = event.getPayload().get("documents"); + if (!(documentsObject instanceof List documents) || documents.isEmpty()) { + return; + } + Object knowledgeId = event.getPayload().get("knowledgeId"); + Object knowledgeName = event.getPayload().get("knowledgeName"); + Object knowledgeType = event.getPayload().get("knowledgeType"); + Object faqCollection = event.getPayload().get("faqCollection"); + for (Object documentObject : documents) { + if (!(documentObject instanceof Map documentMap)) { + continue; + } + AgentKnowledgeReference reference = new AgentKnowledgeReference(); + reference.setKnowledgeId(stringValue(knowledgeId)); + reference.setKnowledgeName(stringValue(knowledgeName)); + reference.setDocumentId(stringValue(documentMap.get("documentId"))); + reference.setDocumentName(stringValue(documentMap.get("documentName"))); + reference.setChunkId(stringValue(documentMap.get("chunkId"))); + reference.setChunkContent(stringValue(documentMap.get("chunkContent"))); + reference.setSourceUri(stringValue(documentMap.get("sourceUri"))); + reference.setScore(documentMap.get("score") instanceof Number score ? score.doubleValue() : null); + Object metadata = documentMap.get("metadata"); + Map referenceMetadata = metadata instanceof Map map + ? new LinkedHashMap<>((Map) map) + : new LinkedHashMap<>(); + if (knowledgeType != null) { + referenceMetadata.putIfAbsent("knowledgeType", knowledgeType); + } + if (faqCollection != null) { + referenceMetadata.putIfAbsent("faqCollection", faqCollection); + } + reference.setMetadata(referenceMetadata); + knowledgeReferences.putIfAbsent(knowledgeReferenceKey(reference), reference); + } + } + + /** + * 构建候选知识引用去重键。 + * + * @param reference 知识引用 + * @return 去重键 + */ + private String knowledgeReferenceKey(AgentKnowledgeReference reference) { + return String.join(":", + stringValue(reference.getKnowledgeId()), + stringValue(reference.getDocumentId()), + stringValue(reference.getChunkId())); + } + + /** + * 提取结构化消息中的文本正文。 + * + * @param message 结构化消息 + * @return 文本正文 + */ + private String textContent(AgentMessage message) { + if (message == null || message.getContentBlocks() == null) { + return ""; + } + StringBuilder builder = new StringBuilder(); + for (AgentContentBlock block : message.getContentBlocks()) { + if (block instanceof AgentTextBlock textBlock && textBlock.getText() != null) { + builder.append(textBlock.getText()); + } + } + return builder.toString(); + } + + /** + * 构建携带公共身份信息的运行时事件。 + * + * @param context 本轮运行上下文 + * @param type 事件类型 + * @return 运行时事件 + */ + private AgentRuntimeEvent base(AgentRuntimeExecutionContext context, AgentRuntimeEventType type) { + AgentRuntimeEvent event = AgentRuntimeEvent.of(type); + event.setTraceId(context.getTraceId()); + event.setSessionId(context.getSessionId()); + event.setAgentId(context.getAgentDefinition().getAgentId()); + event.getMetadata().put("requestId", context.getRequestId()); + event.getMetadata().putAll(nullToEmpty(context.getMetadata())); + return event; + } + + /** + * 空安全 Map。 + * + * @param map 原 Map + * @return 非空 Map + */ + private Map nullToEmpty(Map map) { + return map == null ? new LinkedHashMap<>() : map; + } + + /** + * 将对象转换为字符串。 + * + * @param value 值 + * @return 字符串 + */ + private String stringValue(Object value) { + return value == null ? null : String.valueOf(value); + } + + /** + * 将 AgentScope 可能输出的累计快照归一化为增量。 + * + *

主线路 mapper 要尽量保持 AgentScope 原始顺序,但不同模型或底层适配器可能 + * 输出累计文本。该归一化器只修正同一 message/block 的文本增量,不触碰旁路事件。

+ */ + private static final class StreamDeltaNormalizer { + + private final Map previousValues = new LinkedHashMap<>(); + + /** + * 归一化流式事件。 + * + * @param event 原事件 + * @return 归一化后的事件 + */ + private AgentRuntimeEvent normalize(AgentRuntimeEvent event) { + if (event == null || event.getEventType() == null) { + return event; + } + if (event.getEventType() == AgentRuntimeEventType.REASONING_DELTA) { + normalizePayloadText(event, "reasoning"); + } + if (event.getEventType() == AgentRuntimeEventType.MESSAGE_DELTA) { + normalizePayloadText(event, "text"); + } + return event; + } + + private void normalizePayloadText(AgentRuntimeEvent event, String payloadKey) { + Object currentValue = event.getPayload().get(payloadKey); + if (!(currentValue instanceof String currentText) || currentText.isEmpty()) { + return; + } + String key = streamKey(event, payloadKey); + String previousText = previousValues.get(key); + previousValues.put(key, currentText); + if (previousText != null && !previousText.isEmpty() && currentText.startsWith(previousText)) { + event.getPayload().put(payloadKey, currentText.substring(previousText.length())); + } + } + + private String streamKey(AgentRuntimeEvent event, String payloadKey) { + Object blockIndex = event.getMetadata().get("blockIndex"); + return String.join(":", + event.getMessageId() == null || event.getMessageId().isBlank() ? "default-message" : event.getMessageId(), + event.getEventType().name(), + payloadKey, + blockIndex == null ? "0" : String.valueOf(blockIndex)); + } + } + + /** + * 校验初始化请求的必要字段。 + * + * @param request 初始化请求 + */ + private void validateInitRequest(AgentInitRequest request) { + if (request == null) { + throw new AgentRuntimeException("Agent runtime init request is required."); + } + if (request.getSessionId() == null || request.getSessionId().isBlank()) { + throw new AgentRuntimeException("Agent runtime session id is required."); + } + if (request.getSessionStore() == null || request.getSessionStore() == NoopAgentSessionStore.INSTANCE) { + throw new AgentRuntimeException("Agent runtime session store is required."); + } AgentDefinition definition = request.getAgentDefinition(); + if (definition == null) { + throw new AgentRuntimeException("Agent definition is required."); + } + if (definition.getModelSpec() == null) { + throw new AgentRuntimeException("Agent model spec is required."); + } + } + + /** + * 从初始化请求创建运行时级上下文。 + * + * @param request 初始化请求 + * @return 运行时级上下文 + */ + private AgentRuntimeExecutionContext createRuntimeContext(AgentInitRequest request) { + AgentRuntimeExecutionContext context = new AgentRuntimeExecutionContext(); + context.setSessionId(request.getSessionId()); + context.setTraceId(request.getRuntimeContext().getTraceId()); + context.setAgentDefinition(request.getAgentDefinition()); + context.setRuntimeContext(request.getRuntimeContext()); + context.setToolInvokers(request.getToolInvokers()); + context.setKnowledgeRetrievers(request.getKnowledgeRetrievers()); + context.setSessionStore(request.getSessionStore()); + context.setConversationRecorder(request.getConversationRecorder()); + context.setMetadata(request.getMetadata()); + return context; + } + + /** + * 构建并配置 AgentScope ReActAgent。 + * + * @param context 运行时级上下文 + * @return AgentScope ReActAgent + */ + private ReActAgent buildAgent(AgentRuntimeExecutionContext context) { + AgentDefinition definition = context.getAgentDefinition(); Model model = modelFactory.create(definition.getModelSpec(), definition.getGenerationOptions()); Toolkit toolkit = new Toolkit(); - Map> skillTools = buildToolkit(request, toolkit, sideEvents, approvalCoordinator, skillContext); - Memory memory = memoryAdapter.createMemory(request.getMemorySnapshot(), definition.getMemoryPolicy(), model); - Knowledge knowledge = knowledgeAdapter.createAggregateKnowledge(request, sideEvents); + Map> skillTools = buildToolkit(context, toolkit); + AgentScopeMemoryBuildResult memoryResult = memoryAdapter.createMemoryResult(null, definition.getMemoryPolicy(), model); + Memory memory = memoryResult.getMemory(); + Knowledge knowledge = knowledgeAdapter.createAggregateKnowledge(context, turnContextHolder); SkillBox skillBox = skillAdapter.createSkillBox(definition.getSkillBoxSpec(), toolkit, skillTools); - // 构建ReActAgent + // AutoContextInterceptor 是官方 AutoContextHook 的替代实现。这里仍只注册统一 runtime hook, + // 避免官方 hook 与 Easy-Agents interceptor 同时触发压缩和 inputMessages 改写。 + AgentRuntimeEventBridge eventBridge = new AgentRuntimeEventBridge(context, turnContextHolder); + List interceptors = new ArrayList<>(); + if (memory instanceof AutoContextMemory) { + interceptors.add(new AutoContextInterceptor(eventBridge, memoryResult.getAutoContextConfig())); + } + interceptors.add(new ToolHitlInterceptor(eventBridge, approvalCoordinator, definition.getToolSpecs())); + // 注册旁路事件监听器与主线路干预器。观察器只发旁路事件,不修改 AgentScope HookEvent。 + List observers = new ArrayList<>(); + observers.add(new SkillExecutionObserver(eventBridge, skillContext, skillBox)); + observers.add(new ToolExecutionObserver(eventBridge, skillContext)); + observers.add(new ReasoningLifecycleObserver(eventBridge)); + observers.add(new AgentRuntimeErrorObserver(eventBridge)); + AgentRuntimeObservationManager observationManager = + new AgentRuntimeObservationManager(interceptors, observers); ReActAgent.Builder builder = ReActAgent.builder() .name(definition.getAgentName()) .description(definition.getDescription()) @@ -217,7 +982,8 @@ public class AgentScopeReActRuntime implements AgentRuntime { .memory(memory) .maxIters(definition.getExecutionOptions().getMaxIters()) .generateOptions(modelFactory.toGenerateOptions(definition.getModelSpec(), definition.getGenerationOptions())) - .hook(new AgentScopeEventHook(request)) + .hook(new AgentScopeRuntimeHook(observationManager)) + .enablePendingToolRecovery(true) .statePersistence(AgentScopeSessionAdapter.toStatePersistence(definition.getPersistencePolicy())); if (knowledge != null) { builder.knowledge(knowledge) @@ -230,51 +996,24 @@ public class AgentScopeReActRuntime implements AgentRuntime { return builder.build(); } - private Toolkit buildToolkit(AgentRunRequest request, Sinks.Many sideEvents) { - return buildToolkit(request, sideEvents, AgentToolApprovalCoordinator.disabled()); - } - /** - * 构建工具箱。 + * 构建 AgentScope Toolkit,并返回按 Skill ID 分组的工具。 * - * @param request 运行请求 - * @param sideEvents 旁路事件 sink - * @param approvalCoordinator 审批协调器 - * @return 工具箱 - */ - private Toolkit buildToolkit(AgentRunRequest request, - Sinks.Many sideEvents, - AgentToolApprovalCoordinator approvalCoordinator) { - Toolkit toolkit = new Toolkit(); - buildToolkit(request, toolkit, sideEvents, approvalCoordinator, - AgentSkillRuntimeContext.from(request.getAgentDefinition().getSkillBoxSpec())); - return toolkit; - } - - /** - * 构建工具箱并返回 Skill 绑定工具。 - * - * @param request 运行请求 - * @param toolkit 工具箱 - * @param sideEvents 旁路事件 sink - * @param approvalCoordinator 审批协调器 - * @param skillContext Skill 运行时上下文 + * @param context 运行时级上下文 + * @param toolkit AgentScope Toolkit * @return 按 Skill ID 分组的工具 */ - private Map> buildToolkit(AgentRunRequest request, - Toolkit toolkit, - Sinks.Many sideEvents, - AgentToolApprovalCoordinator approvalCoordinator, - AgentSkillRuntimeContext skillContext) { + private Map> buildToolkit(AgentRuntimeExecutionContext context, + Toolkit toolkit) { Map> skillTools = new LinkedHashMap<>(); - if (!request.getAgentDefinition().getExecutionOptions().isToolCallingEnabled()) { + if (!context.getAgentDefinition().getExecutionOptions().isToolCallingEnabled()) { return skillTools; } - for (AgentToolSpec toolSpec : request.getAgentDefinition().getToolSpecs()) { - AgentToolInvoker invoker = request.getToolInvokers().get(toolSpec.getName()); + for (AgentToolSpec toolSpec : context.getAgentDefinition().getToolSpecs()) { + AgentToolInvoker invoker = context.getToolInvokers().get(toolSpec.getName()); AgentSkillBinding skillBinding = skillContext.getToolBinding(toolSpec.getName()); - AgentTool agentTool = toolAdapter.adapt(toolSpec, invoker, request, approvalCoordinator, sideEvents, - skillContext, skillBinding); + AgentTool agentTool = toolAdapter.adapt(toolSpec, invoker, context, approvalCoordinator, turnContextHolder, + skillContext, skillBinding, false, false, false); if (skillBinding == null) { toolkit.registerAgentTool(agentTool); } else { @@ -284,182 +1023,6 @@ public class AgentScopeReActRuntime implements AgentRuntime { return skillTools; } - /** - * 构建当前轮的 AgentScope 输入消息。 - * - * @param request 运行请求 - * @return 输入消息 - */ - private List buildInput(AgentRunRequest request) { - List input = new ArrayList<>(); - AgentMessage userMessage = request.getUserMessage(); - if (userMessage != null) { - input.add(messageAdapter.toMsg(userMessage)); - } - return input; - } - - /** - * 将单个 AgentScope 事件映射为运行时事件。 - * - * @param request 运行请求 - * @param event AgentScope 事件 - * @return 运行时事件 - */ - private List mapEvent(AgentRunRequest request, Event event) { - return mapEvent(request, event, AgentSkillRuntimeContext.from(request.getAgentDefinition().getSkillBoxSpec())); - } - - /** - * 将单个 AgentScope 事件映射为运行时事件。 - * - * @param request 运行请求 - * @param event AgentScope 事件 - * @param skillContext Skill 运行时上下文 - * @return 运行时事件 - */ - private List mapEvent(AgentRunRequest request, Event event, AgentSkillRuntimeContext skillContext) { - List events = new ArrayList<>(); - if (event == null) { - return events; - } - if (event.getType() == EventType.REASONING) { - events.addAll(mapSkillCalls(request, event, skillContext)); - AgentRuntimeEvent runtimeEvent = base(request, AgentRuntimeEventType.REASONING_DELTA); - runtimeEvent.setMessageId(event.getMessageId()); - runtimeEvent.getPayload().put("text", event.getMessage() == null ? null : event.getMessage().getTextContent()); - runtimeEvent.getPayload().put("last", event.isLast()); - events.add(runtimeEvent); - return events; - } - if (event.getType() == EventType.TOOL_RESULT) { - events.addAll(mapSkillResults(request, event, skillContext)); - return events; - } - if (event.getType() == EventType.AGENT_RESULT) { - AgentRuntimeEvent runtimeEvent = base(request, AgentRuntimeEventType.MESSAGE_DELTA); - runtimeEvent.setMessageId(event.getMessageId()); - runtimeEvent.getPayload().put("text", event.getMessage() == null ? null : event.getMessage().getTextContent()); - runtimeEvent.getPayload().put("last", event.isLast()); - if (event.isLast() && event.getMessage() != null) { - runtimeEvent.setMessage(messageAdapter.toAgentMessage(event.getMessage())); - } - events.add(runtimeEvent); - return events; - } - return events; - } - - /** - * 从推理消息中识别 Skill 加载调用。 - * - * @param request 运行请求 - * @param event AgentScope 事件 - * @param skillContext Skill 运行时上下文 - * @return Skill 调用事件 - */ - private List mapSkillCalls(AgentRunRequest request, - Event event, - AgentSkillRuntimeContext skillContext) { - List events = new ArrayList<>(); - if (event.getMessage() == null) { - return events; - } - for (ToolUseBlock block : event.getMessage().getContentBlocks(ToolUseBlock.class)) { - if (!skillContext.isSkillLoadTool(block.getName())) { - continue; - } - AgentSkillLoadCall call = skillContext.rememberLoadCall(block.getId(), block.getInput()); - if (!skillContext.markLoadCallEmitted(block.getId())) { - continue; - } - AgentRuntimeEvent runtimeEvent = base(request, AgentRuntimeEventType.SKILL_CALL); - runtimeEvent.setMessageId(event.getMessageId()); - runtimeEvent.setToolCallId(block.getId()); - appendSkillLoadPayload(runtimeEvent, call); - runtimeEvent.getPayload().put("toolName", block.getName()); - runtimeEvent.getPayload().put("input", block.getInput()); - runtimeEvent.getPayload().put("status", "RUNNING"); - events.add(runtimeEvent); - } - return events; - } - - /** - * 从工具结果中识别 Skill 加载结果。 - * - * @param request 运行请求 - * @param event AgentScope 事件 - * @param skillContext Skill 运行时上下文 - * @return Skill 结果事件 - */ - private List mapSkillResults(AgentRunRequest request, - Event event, - AgentSkillRuntimeContext skillContext) { - List events = new ArrayList<>(); - if (event.getMessage() == null) { - return events; - } - for (ToolResultBlock block : event.getMessage().getContentBlocks(ToolResultBlock.class)) { - if (!skillContext.isSkillLoadTool(block.getName())) { - continue; - } - AgentSkillLoadCall call = skillContext.removeLoadCall(block.getId()); - AgentRuntimeEvent runtimeEvent = base(request, skillResultType(block)); - if (runtimeEvent.getEventType() == AgentRuntimeEventType.SKILL_RESULT && call != null) { - skillContext.activateSkill(call.getSkillId()); - } - runtimeEvent.setMessageId(event.getMessageId()); - runtimeEvent.setToolCallId(block.getId()); - appendSkillLoadPayload(runtimeEvent, call); - runtimeEvent.getPayload().put("toolName", block.getName()); - runtimeEvent.getPayload().put("text", block.toString()); - runtimeEvent.getPayload().put("status", runtimeEvent.getEventType() == AgentRuntimeEventType.SKILL_RESULT ? "SUCCESS" : "FAILED"); - runtimeEvent.getPayload().put("success", runtimeEvent.getEventType() == AgentRuntimeEventType.SKILL_RESULT); - runtimeEvent.getPayload().put("suspended", block.isSuspended()); - runtimeEvent.getMetadata().putAll(block.getMetadata() == null ? new LinkedHashMap<>() : block.getMetadata()); - events.add(runtimeEvent); - } - return events; - } - - /** - * 判断 Skill 结果事件类型。 - * - * @param block 工具结果块 - * @return Skill 事件类型 - */ - private AgentRuntimeEventType skillResultType(ToolResultBlock block) { - Object success = block.getMetadata() == null ? null : block.getMetadata().get("success"); - if (Boolean.FALSE.equals(success)) { - return AgentRuntimeEventType.SKILL_FAILED; - } - String text = block.toString(); - if (text != null && text.toLowerCase().contains("error")) { - return AgentRuntimeEventType.SKILL_FAILED; - } - return AgentRuntimeEventType.SKILL_RESULT; - } - - /** - * 追加 Skill 加载事件载荷。 - * - * @param event 运行时事件 - * @param call Skill 加载调用 - */ - private void appendSkillLoadPayload(AgentRuntimeEvent event, AgentSkillLoadCall call) { - if (call == null) { - return; - } - event.getPayload().put("skillId", call.getSkillId()); - event.getPayload().put("skillName", call.getSkillName()); - event.getPayload().put("skillBoxId", call.getSkillBoxId()); - event.getPayload().put("path", call.getPath()); - event.getMetadata().put("skillId", call.getSkillId()); - event.getMetadata().put("skillName", call.getSkillName()); - event.getMetadata().put("skillBoxId", call.getSkillBoxId()); - } - /** * 构建聚合知识库的默认检索配置。 * @@ -482,251 +1045,16 @@ public class AgentScopeReActRuntime implements AgentRuntime { .build(); } - /** - * 从消息增量事件中收集最终助手文本。 - * - * @param finalText 最终文本引用 - * @param event 运行时事件 - */ - private void updateFinalText(StringBuilder finalText, AgentRuntimeEvent event) { - if (event.getEventType() == AgentRuntimeEventType.MESSAGE_DELTA) { - Object text = event.getPayload().get("text"); - if (text != null) { - String chunk = String.valueOf(text); - if (Boolean.TRUE.equals(event.getPayload().get("last")) && finalText.length() > chunk.length()) { - finalText.setLength(0); - } - finalText.append(chunk); - } - } + public AgentInitRequest getInitRequest() { + return initRequest; } /** - * 从最终消息事件中收集结构化助手消息。 + * 获取当前已构建的 AgentScope ReActAgent。 * - * @param finalMessage 最终消息引用 - * @param event 运行时事件 + * @return AgentScope ReActAgent */ - private void updateFinalMessage(AtomicReference finalMessage, AgentRuntimeEvent event) { - if (event.getEventType() == AgentRuntimeEventType.MESSAGE_DELTA - && Boolean.TRUE.equals(event.getPayload().get("last")) - && event.getMessage() != null) { - finalMessage.set(event.getMessage()); - } - } - - /** - * 校验请求的最小必要字段。 - * - * @param request 运行请求 - */ - private void validate(AgentRunRequest request) { - if (request == null) { - throw new AgentRuntimeException("Agent run request is required."); - } - if (request.getAgentDefinition() == null) { - throw new AgentRuntimeException("Agent definition is required."); - } - if (request.getAgentDefinition().getModelSpec() == null) { - throw new AgentRuntimeException("Agent model spec is required."); - } - } - - /** - * 启用时加载 AgentScope 会话状态。 - * - * @param request 运行请求 - * @param agent AgentScope 智能体 - */ - private void loadSessionIfNeeded(AgentRunRequest request, ReActAgent agent) { - if (request.getAgentDefinition().getPersistencePolicy().isEnabled() - && request.getAgentDefinition().getPersistencePolicy().isSessionManaged() - && request.getSessionId() != null) { - Session session = new AgentScopeSessionAdapter(request.getSessionStore()); - agent.loadIfExists(session, AgentScopeSessionAdapter.sessionKey(request.getSessionId())); - } - } - - /** - * 启用时保存 AgentScope 会话状态。 - * - * @param request 运行请求 - * @param agent AgentScope 智能体 - */ - private void saveSessionIfNeeded(AgentRunRequest request, ReActAgent agent) { - if (request.getAgentDefinition().getPersistencePolicy().isEnabled() - && request.getAgentDefinition().getPersistencePolicy().isSessionManaged() - && request.getSessionId() != null) { - Session session = new AgentScopeSessionAdapter(request.getSessionStore()); - agent.saveTo(session, AgentScopeSessionAdapter.sessionKey(request.getSessionId())); - } - } - - /** - * 构建开始事件。 - * - * @param request 运行请求 - * @return 开始事件 - */ - private AgentRuntimeEvent started(AgentRunRequest request) { - AgentRuntimeEvent event = base(request, AgentRuntimeEventType.STARTED); - event.getPayload().put("requestId", request.getRequestId()); - return event; - } - - /** - * 构建完成事件。 - * - * @param request 运行请求 - * @param text 最终文本 - * @return 完成事件 - */ - private AgentRuntimeEvent completed(AgentRunRequest request, String text, AgentMessage message) { - AgentRuntimeEvent event = base(request, AgentRuntimeEventType.COMPLETED); - event.getPayload().put("text", text); - event.setMessage(message); - return event; - } - - /** - * 构建完成事件,并附带知识库引用。 - * - * @param request 运行请求 - * @param text 最终文本 - * @param message 最终消息 - * @param knowledgeReferences 知识库引用 - * @return 完成事件 - */ - private AgentRuntimeEvent completed(AgentRunRequest request, - String text, - AgentMessage message, - Map knowledgeReferences) { - if (message != null && knowledgeReferences != null && !knowledgeReferences.isEmpty()) { - message.setKnowledgeReferences(new ArrayList<>(knowledgeReferences.values())); - } - return completed(request, text, message); - } - - /** - * 构建失败事件。 - * - * @param request 运行请求 - * @param error 错误 - * @return 失败事件 - */ - private AgentRuntimeEvent failed(AgentRunRequest request, Throwable error) { - AgentRuntimeEvent event = request == null || request.getAgentDefinition() == null - ? AgentRuntimeEvent.of(AgentRuntimeEventType.FAILED) - : base(request, AgentRuntimeEventType.FAILED); - event.getPayload().put("message", error.getMessage()); - event.getPayload().put("errorType", error.getClass().getName()); - return event; - } - - /** - * 构建取消事件。 - * - * @param request 运行请求 - * @return 取消事件 - */ - private AgentRuntimeEvent cancelled(AgentRunRequest request) { - AgentRuntimeEvent event = base(request, AgentRuntimeEventType.CANCELLED); - event.getPayload().put("reason", request.getCancelReason()); - return event; - } - - /** - * 取消本次运行并避免重复发出取消事件。 - * - * @param agent AgentScope 智能体 - * @param sideEvents 旁路事件 sink - * @param request 运行请求 - * @param cancelled 取消标记 - */ - private void cancelInternal(ReActAgent agent, - AgentToolApprovalCoordinator approvalCoordinator, - Sinks.Many sideEvents, - AgentRunRequest request, - java.util.concurrent.atomic.AtomicBoolean cancelled) { - if (!cancelled.compareAndSet(false, true)) { - return; - } - approvalCoordinator.cancelAll(request.getCancelReason() == null ? "运行已取消" : request.getCancelReason()); - saveSessionIfNeeded(request, agent); - agent.interrupt(); - sideEvents.tryEmitNext(cancelled(request)); - } - - /** - * 收集知识库引用,供最终消息 metadata 使用。 - * - * @param knowledgeReferences 知识库引用 - * @param event 运行时事件 - */ - @SuppressWarnings("unchecked") - private void updateKnowledgeReferences(Map knowledgeReferences, AgentRuntimeEvent event) { - if (event == null || event.getEventType() != AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL) { - return; - } - Object documentsObject = event.getPayload().get("documents"); - if (!(documentsObject instanceof List documents) || documents.isEmpty()) { - return; - } - Object knowledgeId = event.getPayload().get("knowledgeId"); - Object knowledgeName = event.getPayload().get("knowledgeName"); - for (Object documentObject : documents) { - if (!(documentObject instanceof Map documentMap)) { - continue; - } - AgentKnowledgeReference reference = new AgentKnowledgeReference(); - reference.setKnowledgeId(stringValue(knowledgeId)); - reference.setKnowledgeName(stringValue(knowledgeName)); - reference.setDocumentId(stringValue(documentMap.get("documentId"))); - reference.setDocumentName(stringValue(documentMap.get("documentName"))); - reference.setChunkId(stringValue(documentMap.get("chunkId"))); - reference.setSourceUri(stringValue(documentMap.get("sourceUri"))); - reference.setScore(documentMap.get("score") instanceof Number score ? score.doubleValue() : null); - Object metadata = documentMap.get("metadata"); - reference.setMetadata(metadata instanceof Map map ? new LinkedHashMap<>((Map) map) : new LinkedHashMap<>()); - String key = String.valueOf(knowledgeId) + "|" + String.valueOf(documentMap.get("documentId")) + "|" + String.valueOf(documentMap.get("chunkId")); - knowledgeReferences.putIfAbsent(key, reference); - } - } - - /** - * 将对象转换为字符串。 - * - * @param value 值 - * @return 字符串值 - */ - private String stringValue(Object value) { - return value == null ? null : String.valueOf(value); - } - - /** - * 构建携带通用标识的运行时事件。 - * - * @param request 运行请求 - * @param type 事件类型 - * @return 事件 - */ - private AgentRuntimeEvent base(AgentRunRequest request, AgentRuntimeEventType type) { - AgentRuntimeEvent event = AgentRuntimeEvent.of(type); - event.setTraceId(request.getTraceId()); - event.setSessionId(request.getSessionId()); - event.setAgentId(request.getAgentDefinition().getAgentId()); - event.getMetadata().put("requestId", request.getRequestId()); - event.getMetadata().putAll(nullToEmpty(request.getMetadata())); - return event; - } - - /** - * 当来源元数据为空时返回空 Map。 - * - * @param map 来源 Map - * @return 非空 Map - */ - private Map nullToEmpty(Map map) { - return map == null ? new LinkedHashMap<>() : map; + ReActAgent getAgent() { + return agent; } } diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeRuntimeHook.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeRuntimeHook.java new file mode 100644 index 0000000..2abf641 --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeRuntimeHook.java @@ -0,0 +1,37 @@ +package com.easyagents.agent.runtime.agentscope; + +import com.easyagents.agent.runtime.event.AgentRuntimeObservationManager; +import io.agentscope.core.hook.Hook; +import io.agentscope.core.hook.HookEvent; +import reactor.core.publisher.Mono; + +/** + * AgentScope Hook 的统一入口。 + */ +public class AgentScopeRuntimeHook implements Hook { + + private final AgentRuntimeObservationManager observationManager; + + /** + * 创建统一 Hook 入口。 + * + * @param observationManager 观察和干预调度器 + */ + public AgentScopeRuntimeHook(AgentRuntimeObservationManager observationManager) { + this.observationManager = observationManager == null + ? AgentRuntimeObservationManager.empty() + : observationManager; + } + + /** + * 处理 AgentScope Hook 事件。 + * + * @param event Hook 事件 + * @param Hook 事件类型 + * @return 处理后的 Hook 事件 + */ + @Override + public Mono onEvent(T event) { + return observationManager.handle(event); + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeSessionAdapter.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeSessionAdapter.java index 5d56385..e1b3302 100644 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeSessionAdapter.java +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeSessionAdapter.java @@ -1,14 +1,12 @@ package com.easyagents.agent.runtime.agentscope; import com.easyagents.agent.runtime.persistence.AgentPersistencePolicy; -import com.easyagents.agent.runtime.persistence.AgentRuntimeState; -import com.easyagents.agent.runtime.persistence.AgentSessionStore; +import com.easyagents.agent.runtime.persistence.session.AgentSessionStore; import io.agentscope.core.session.Session; import io.agentscope.core.state.SessionKey; import io.agentscope.core.state.State; import io.agentscope.core.state.StatePersistence; -import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.Set; @@ -32,7 +30,7 @@ public class AgentScopeSessionAdapter implements Session { @Override public void save(SessionKey sessionKey, String name, State state) { - sessionStore.save(toKey(sessionKey), name, AgentRuntimeState.of(name, state)); + sessionStore.save(toKey(sessionKey), name, state); } /** @@ -44,13 +42,7 @@ public class AgentScopeSessionAdapter implements Session { */ @Override public void save(SessionKey sessionKey, String name, List states) { - List converted = new ArrayList<>(); - if (states != null) { - for (State state : states) { - converted.add(AgentRuntimeState.of(name, state)); - } - } - sessionStore.saveList(toKey(sessionKey), name, converted); + sessionStore.saveList(toKey(sessionKey), name, states); } /** @@ -64,10 +56,7 @@ public class AgentScopeSessionAdapter implements Session { */ @Override public Optional get(SessionKey sessionKey, String name, Class clazz) { - return sessionStore.get(toKey(sessionKey), name) - .map(AgentRuntimeState::getValue) - .filter(clazz::isInstance) - .map(clazz::cast); + return sessionStore.get(toKey(sessionKey), name, clazz); } /** @@ -81,11 +70,7 @@ public class AgentScopeSessionAdapter implements Session { */ @Override public List getList(SessionKey sessionKey, String name, Class clazz) { - return sessionStore.getList(toKey(sessionKey), name).stream() - .map(AgentRuntimeState::getValue) - .filter(clazz::isInstance) - .map(clazz::cast) - .collect(Collectors.toList()); + return sessionStore.getList(toKey(sessionKey), name, clazz); } /** @@ -129,7 +114,7 @@ public class AgentScopeSessionAdapter implements Session { */ public static StatePersistence toStatePersistence(AgentPersistencePolicy policy) { if (policy == null || !policy.isEnabled()) { - return StatePersistence.none(); + return StatePersistence.memoryOnly(); } return StatePersistence.builder() .memoryManaged(policy.isMemoryManaged()) @@ -154,24 +139,17 @@ public class AgentScopeSessionAdapter implements Session { } /** - * 运行时支撑的 AgentScope 会话键。 - */ - private static class RuntimeSessionKey implements SessionKey { - - private final String value; - - private RuntimeSessionKey(String value) { - this.value = value; - } - + * 运行时支撑的 AgentScope 会话键。 + */ + private record RuntimeSessionKey(String value) implements SessionKey { /** - * 将键转换为稳定标识符。 + * 将键转换为稳定标识符。 * * @return 标识符 */ @Override public String toIdentifier() { - return value; - } + return value; + } } } diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeSkillAdapter.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeSkillAdapter.java index 80ba50d..0a2e9bb 100644 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeSkillAdapter.java +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeSkillAdapter.java @@ -1,5 +1,6 @@ package com.easyagents.agent.runtime.agentscope; +import com.easyagents.agent.runtime.AgentRuntimeException; import com.easyagents.agent.runtime.skill.AgentSkillBoxSpec; import com.easyagents.agent.runtime.skill.AgentSkillCompiler; import com.easyagents.agent.runtime.skill.AgentSkillSpec; @@ -8,7 +9,6 @@ import io.agentscope.core.skill.SkillBox; import io.agentscope.core.tool.AgentTool; import io.agentscope.core.tool.Toolkit; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -19,18 +19,14 @@ public class AgentScopeSkillAdapter implements AgentSkillCompiler { @Override public AgentSkill compile(AgentSkillSpec skillSpec) { - Map metadata = new LinkedHashMap<>(skillSpec.getMetadata()); - metadata.put("skillId", skillSpec.getSkillId()); - metadata.put("name", skillSpec.getName()); - metadata.put("description", skillSpec.getDescription()); - return AgentSkill.builder() - .name(skillSpec.getName()) - .description(skillSpec.getDescription()) - .skillContent(skillSpec.getSkillContent()) - .metadata(metadata) - .resources(skillSpec.getResources()) - .source(skillSpec.getSource()) - .build(); + validateSkillSpec(skillSpec); + return new EasyAgentsAgentSkill( + skillSpec.getSkillId(), + skillSpec.getName(), + skillSpec.getDescription(), + skillSpec.getSkillContent(), + skillSpec.getResources(), + skillSpec.getSource()); } /** @@ -105,4 +101,71 @@ public class AgentScopeSkillAdapter implements AgentSkillCompiler { skillBox.syncToolGroupStates(); return skillBox; } + + /** + * 校验 Skill 声明是否具备 AgentScope 注册和模型提示所需的必要信息。 + * + * @param skillSpec Skill 声明 + * @throws AgentRuntimeException Skill 声明为空或核心字段缺失时抛出 + */ + private void validateSkillSpec(AgentSkillSpec skillSpec) { + if (skillSpec == null) { + throw new AgentRuntimeException("Agent skill spec is required."); + } + if (skillSpec.getSkillId() == null || skillSpec.getSkillId().isBlank()) { + throw new AgentRuntimeException("Agent skill id is required."); + } + if (skillSpec.getName() == null || skillSpec.getName().isBlank()) { + throw new AgentRuntimeException("Agent skill name is required: " + skillSpec.getSkillId()); + } + if (skillSpec.getDescription() == null || skillSpec.getDescription().isBlank()) { + throw new AgentRuntimeException("Agent skill description is required: " + skillSpec.getSkillId()); + } + if (skillSpec.getSkillContent() == null || skillSpec.getSkillContent().isBlank()) { + throw new AgentRuntimeException("Agent skill content is required: " + skillSpec.getSkillId()); + } + } + + /** + * 保持 Easy-Agents Skill ID 与 AgentScope Skill ID 完全一致。 + * + *

AgentScope 默认用 {@code name + "_" + source} 生成 Skill ID,而 Easy-Agents + * 的工具绑定、旁路事件和调用层配置都以 {@link AgentSkillSpec#getSkillId()} 为准。 + * 如果不覆盖这里,模型在 prompt 中看到的 skill-id 会和 Easy 侧绑定 key 不一致, + * 后续 Skill 状态监听也无法做到精准归属。

+ */ + private static class EasyAgentsAgentSkill extends AgentSkill { + + private final String skillId; + + /** + * 创建 ID 对齐的 AgentScope Skill。 + * + * @param skillId Easy-Agents Skill ID + * @param name Skill 展示名称 + * @param description Skill 描述 + * @param skillContent Skill 内容 + * @param resources Skill 资源 + * @param source Skill 来源 + */ + EasyAgentsAgentSkill(String skillId, + String name, + String description, + String skillContent, + Map resources, + String source) { + super(name, description, skillContent, resources, source); + this.skillId = skillId; + } + + /** + * 获取 Easy-Agents 声明的 Skill ID。 + * + * @return Easy-Agents Skill ID + */ + @Override + public String getSkillId() { + return skillId; + } + } } diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeToolAdapter.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeToolAdapter.java index 475969a..c586ea2 100644 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeToolAdapter.java +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeToolAdapter.java @@ -1,9 +1,8 @@ package com.easyagents.agent.runtime.agentscope; -import com.easyagents.agent.runtime.AgentRunRequest; import com.easyagents.agent.runtime.AgentRuntimeException; -import com.easyagents.agent.runtime.event.AgentRuntimeEvent; -import com.easyagents.agent.runtime.event.AgentRuntimeEventType; +import com.easyagents.agent.runtime.AgentRuntimeExecutionContext; +import com.easyagents.agent.runtime.event.*; import com.easyagents.agent.runtime.hitl.AgentPendingState; import com.easyagents.agent.runtime.hitl.AgentToolApprovalCoordinator; import com.easyagents.agent.runtime.hitl.AgentToolApprovalRejectedException; @@ -39,8 +38,9 @@ public class AgentScopeToolAdapter { * @param request 运行请求 * @return AgentScope 工具 */ - public AgentTool adapt(AgentToolSpec toolSpec, AgentToolInvoker invoker, AgentRunRequest request) { - return adapt(toolSpec, invoker, request, AgentToolApprovalCoordinator.disabled(), null); + public AgentTool adapt(AgentToolSpec toolSpec, AgentToolInvoker invoker, AgentRuntimeExecutionContext request) { + return adapt(toolSpec, invoker, request, AgentToolApprovalCoordinator.disabled(), + (Sinks.Many) null); } /** @@ -54,9 +54,10 @@ public class AgentScopeToolAdapter { */ public AgentTool adapt(AgentToolSpec toolSpec, AgentToolInvoker invoker, - AgentRunRequest request, + AgentRuntimeExecutionContext request, Sinks.Many eventSink) { - return adapt(toolSpec, invoker, request, AgentToolApprovalCoordinator.disabled(), eventSink); + return adapt(toolSpec, invoker, request, AgentToolApprovalCoordinator.disabled(), fixedHolder(request, eventSink), + null, null); } /** @@ -71,10 +72,10 @@ public class AgentScopeToolAdapter { */ public AgentTool adapt(AgentToolSpec toolSpec, AgentToolInvoker invoker, - AgentRunRequest request, + AgentRuntimeExecutionContext request, AgentToolApprovalCoordinator approvalCoordinator, Sinks.Many eventSink) { - return adapt(toolSpec, invoker, request, approvalCoordinator, eventSink, null); + return adapt(toolSpec, invoker, request, approvalCoordinator, fixedHolder(request, eventSink), null, null); } /** @@ -90,11 +91,11 @@ public class AgentScopeToolAdapter { */ public AgentTool adapt(AgentToolSpec toolSpec, AgentToolInvoker invoker, - AgentRunRequest request, + AgentRuntimeExecutionContext request, AgentToolApprovalCoordinator approvalCoordinator, Sinks.Many eventSink, AgentSkillBinding skillBinding) { - return adapt(toolSpec, invoker, request, approvalCoordinator, eventSink, null, skillBinding); + return adapt(toolSpec, invoker, request, approvalCoordinator, fixedHolder(request, eventSink), null, skillBinding); } /** @@ -111,27 +112,153 @@ public class AgentScopeToolAdapter { */ public AgentTool adapt(AgentToolSpec toolSpec, AgentToolInvoker invoker, - AgentRunRequest request, + AgentRuntimeExecutionContext request, AgentToolApprovalCoordinator approvalCoordinator, Sinks.Many eventSink, AgentSkillRuntimeContext skillContext, AgentSkillBinding skillBinding) { + return adapt(toolSpec, invoker, request, approvalCoordinator, fixedHolder(request, eventSink), skillContext, skillBinding); + } + + /** + * 将运行时工具声明和调用器转换为可读取当前运行轮次的 AgentScope AgentTool。 + * + * @param toolSpec 工具声明 + * @param invoker 工具调用器 + * @param request 运行时级上下文 + * @param approvalCoordinator 审批协调器 + * @param turnContextHolder 当前运行轮次上下文持有器 + * @param skillContext Skill 运行时上下文 + * @param skillBinding Skill 静态绑定关系 + * @return AgentScope 工具 + */ + public AgentTool adapt(AgentToolSpec toolSpec, + AgentToolInvoker invoker, + AgentRuntimeExecutionContext request, + AgentToolApprovalCoordinator approvalCoordinator, + AgentRuntimeTurnContextHolder turnContextHolder, + AgentSkillRuntimeContext skillContext, + AgentSkillBinding skillBinding) { + return adapt(toolSpec, invoker, request, approvalCoordinator, turnContextHolder, skillContext, skillBinding, true); + } + + /** + * 将运行时工具声明和调用器转换为可读取当前运行轮次的 AgentScope AgentTool。 + * + * @param toolSpec 工具声明 + * @param invoker 工具调用器 + * @param request 运行时级上下文 + * @param approvalCoordinator 审批协调器 + * @param turnContextHolder 当前运行轮次上下文持有器 + * @param skillContext Skill 运行时上下文 + * @param skillBinding Skill 静态绑定关系 + * @param emitNormalToolResult 是否由 adapter 发出普通工具结果旁路事件 + * @return AgentScope 工具 + */ + public AgentTool adapt(AgentToolSpec toolSpec, + AgentToolInvoker invoker, + AgentRuntimeExecutionContext request, + AgentToolApprovalCoordinator approvalCoordinator, + AgentRuntimeTurnContextHolder turnContextHolder, + AgentSkillRuntimeContext skillContext, + AgentSkillBinding skillBinding, + boolean emitNormalToolResult) { if (toolSpec == null || toolSpec.getName() == null) { throw new AgentRuntimeException("Agent tool spec and name are required."); } if (invoker == null) { throw new AgentRuntimeException("Agent tool invoker is required: " + toolSpec.getName()); } - return new RuntimeAgentTool(toolSpec, invoker, request, approvalCoordinator, eventSink, skillContext, skillBinding); + return new RuntimeAgentTool(toolSpec, invoker, request, approvalCoordinator, turnContextHolder, + skillContext, skillBinding, emitNormalToolResult, true, true); + } + + /** + * 将运行时工具声明和调用器转换为可读取当前运行轮次的 AgentScope AgentTool。 + * + * @param toolSpec 工具声明 + * @param invoker 工具调用器 + * @param request 运行时级上下文 + * @param approvalCoordinator 审批协调器 + * @param turnContextHolder 当前运行轮次上下文持有器 + * @param skillContext Skill 运行时上下文 + * @param skillBinding Skill 静态绑定关系 + * @param emitNormalToolResult 是否由 adapter 发出普通工具结果旁路事件 + * @param emitSkillStep 是否由 adapter 发出 Skill 步骤旁路事件 + * @return AgentScope 工具 + */ + public AgentTool adapt(AgentToolSpec toolSpec, + AgentToolInvoker invoker, + AgentRuntimeExecutionContext request, + AgentToolApprovalCoordinator approvalCoordinator, + AgentRuntimeTurnContextHolder turnContextHolder, + AgentSkillRuntimeContext skillContext, + AgentSkillBinding skillBinding, + boolean emitNormalToolResult, + boolean emitSkillStep) { + if (toolSpec == null || toolSpec.getName() == null) { + throw new AgentRuntimeException("Agent tool spec and name are required."); + } + if (invoker == null) { + throw new AgentRuntimeException("Agent tool invoker is required: " + toolSpec.getName()); + } + return new RuntimeAgentTool(toolSpec, invoker, request, approvalCoordinator, turnContextHolder, + skillContext, skillBinding, emitNormalToolResult, emitSkillStep, true); + } + + /** + * 将运行时工具声明和调用器转换为可读取当前运行轮次的 AgentScope AgentTool。 + * + * @param toolSpec 工具声明 + * @param invoker 工具调用器 + * @param request 运行时级上下文 + * @param approvalCoordinator 审批协调器 + * @param turnContextHolder 当前运行轮次上下文持有器 + * @param skillContext Skill 运行时上下文 + * @param skillBinding Skill 静态绑定关系 + * @param emitNormalToolResult 是否由 adapter 发出普通工具结果旁路事件 + * @param emitSkillStep 是否由 adapter 发出 Skill 步骤旁路事件 + * @param handleApprovalInTool 是否在工具执行阶段处理审批 + * @return AgentScope 工具 + */ + public AgentTool adapt(AgentToolSpec toolSpec, + AgentToolInvoker invoker, + AgentRuntimeExecutionContext request, + AgentToolApprovalCoordinator approvalCoordinator, + AgentRuntimeTurnContextHolder turnContextHolder, + AgentSkillRuntimeContext skillContext, + AgentSkillBinding skillBinding, + boolean emitNormalToolResult, + boolean emitSkillStep, + boolean handleApprovalInTool) { + if (toolSpec == null || toolSpec.getName() == null) { + throw new AgentRuntimeException("Agent tool spec and name are required."); + } + if (invoker == null) { + throw new AgentRuntimeException("Agent tool invoker is required: " + toolSpec.getName()); + } + return new RuntimeAgentTool(toolSpec, invoker, request, approvalCoordinator, turnContextHolder, + skillContext, skillBinding, emitNormalToolResult, emitSkillStep, handleApprovalInTool); + } + + private AgentRuntimeTurnContextHolder fixedHolder(AgentRuntimeExecutionContext request, + Sinks.Many eventSink) { + AgentRuntimeTurnContextHolder holder = new AgentRuntimeTurnContextHolder(); + AgentRuntimeEventBridge bridge = new AgentRuntimeEventBridge(request, holder); + holder.set(new AgentRuntimeTurnContext(null, eventSink, bridge)); + return holder; } private record RuntimeAgentTool(AgentToolSpec toolSpec, AgentToolInvoker invoker, - AgentRunRequest request, + AgentRuntimeExecutionContext request, AgentToolApprovalCoordinator approvalCoordinator, - Sinks.Many eventSink, + AgentRuntimeTurnContextHolder turnContextHolder, AgentSkillRuntimeContext skillContext, - AgentSkillBinding skillBinding) implements AgentTool { + AgentSkillBinding skillBinding, + boolean emitNormalToolResult, + boolean emitSkillStep, + boolean handleApprovalInTool) implements AgentTool { /** * 获取工具名称。 @@ -181,11 +308,14 @@ public class AgentScopeToolAdapter { */ @Override public Mono callAsync(ToolCallParam param) { - emit(toolCallEvent(param == null ? null : param.getToolUseBlock())); + AgentRuntimeEvent startEvent = toolExecutionStartEvent(param == null ? null : param.getToolUseBlock()); + if (startEvent != null) { + emit(startEvent); + } Map input = param == null || param.getInput() == null ? new LinkedHashMap<>() : new LinkedHashMap<>(param.getInput()); - if (toolSpec.isApprovalRequired()) { + if (handleApprovalInTool && toolSpec.isApprovalRequired()) { if (approvalCoordinator == null) { throw new AgentRuntimeException("Agent tool approval coordinator is required: " + toolSpec.getName()); } @@ -224,12 +354,13 @@ public class AgentScopeToolAdapter { * @return 工具上下文 */ private AgentToolContext buildContext(ToolCallParam param) { + AgentRuntimeExecutionContext currentRequest = currentRequest(); AgentToolContext context = new AgentToolContext(); - context.setRequestId(request.getRequestId()); - context.setTraceId(request.getTraceId()); - context.setSessionId(request.getSessionId()); - context.setAgentId(request.getAgentDefinition().getAgentId()); - context.setRuntimeContext(request.getRuntimeContext()); + context.setRequestId(currentRequest.getRequestId()); + context.setTraceId(currentRequest.getTraceId()); + context.setSessionId(currentRequest.getSessionId()); + context.setAgentId(currentRequest.getAgentDefinition().getAgentId()); + context.setRuntimeContext(currentRequest.getRuntimeContext()); if (param != null && param.getToolUseBlock() != null) { context.setToolCallId(param.getToolUseBlock().getId()); } @@ -273,35 +404,39 @@ public class AgentScopeToolAdapter { AgentToolContext context = buildContext(param); AgentToolResult result = invoker.invoke(input, context); ToolResultBlock block = toToolResultBlock(param, result); - emit(toolResultEvent(block)); + // 有状态 runtime 中,普通工具结果由 AgentScope 原生 PostActingEvent + // 旁路观察器统一发出;旧 sink 辅助路径没有统一 hook,因此仍允许 adapter 兼容发射。 + if (emitNormalToolResult || (emitSkillStep && activeSkillBinding() != null)) { + emit(toolResultEvent(block)); + } return block; } /** - * 存在 sink 时发射一条事件。 + * 通过旁路事件桥发射事件。 + * + *

这里发射的是 Easy-Agents 对调用方的监察/交互事件,不是 AgentScope + * 主线路消息。主线路的 tool_call/tool_result 顺序仍由 AgentScope 自己维护。

* * @param event 事件 */ private void emit(AgentRuntimeEvent event) { - if (eventSink != null && event != null) { - Sinks.EmitResult result = eventSink.tryEmitNext(event); - if (result.isFailure()) { - throw new AgentRuntimeException("Failed to emit agent runtime event: " + result); - } - } + currentEventBridge().emit(event); } /** - * 构建工具调用事件。 + * 构建工具执行开始事件。 + * 普通工具调用由 AgentScope stream 的 ToolUseBlock 表达,这里仅为已激活 Skill 发射步骤事件。 * * @param block 工具使用块 - * @return 运行时事件 + * @return Skill 步骤事件,普通工具返回 null */ - private AgentRuntimeEvent toolCallEvent(ToolUseBlock block) { + private AgentRuntimeEvent toolExecutionStartEvent(ToolUseBlock block) { AgentSkillBinding activeBinding = activeSkillBinding(); - AgentRuntimeEvent event = baseEvent(activeBinding == null - ? AgentRuntimeEventType.TOOL_CALL - : AgentRuntimeEventType.SKILL_STEP); + if (!emitSkillStep || activeBinding == null) { + return null; + } + AgentRuntimeEvent event = baseEvent(AgentRuntimeEventType.SKILL_STEP); if (block != null) { event.setToolCallId(block.getId()); event.getPayload().put("name", block.getName()); @@ -359,6 +494,7 @@ public class AgentScopeToolAdapter { * @return 工具审批事件 */ private AgentRuntimeEvent toolApprovalRequiredEvent(ToolUseBlock toolUseBlock, AgentPendingState pendingState) { + AgentRuntimeExecutionContext currentRequest = currentRequest(); AgentRuntimeEvent event = baseEvent(AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED); if (pendingState != null && pendingState.getToolCallId() != null) { event.setToolCallId(pendingState.getToolCallId()); @@ -367,8 +503,8 @@ public class AgentScopeToolAdapter { payload.put("resumeToken", pendingState == null || pendingState.getResumeToken() == null ? UUID.randomUUID().toString() : pendingState.getResumeToken().getValue()); - payload.put("sessionId", request.getSessionId()); - payload.put("agentId", request.getAgentDefinition().getAgentId()); + payload.put("sessionId", currentRequest.getSessionId()); + payload.put("agentId", currentRequest.getAgentDefinition().getAgentId()); payload.put("approvalPrompt", approvalPrompt(pendingState == null ? null : pendingState.getApprovalPrompt())); payload.put("approvalMetadata", pendingState == null ? new LinkedHashMap<>() : pendingState.getMetadata()); payload.put("toolInput", pendingState == null ? new LinkedHashMap<>() : pendingState.getToolInput()); @@ -404,11 +540,8 @@ public class AgentScopeToolAdapter { * @return 运行时事件 */ private AgentRuntimeEvent baseEvent(AgentRuntimeEventType type) { - AgentRuntimeEvent event = AgentRuntimeEvent.of(type); - event.setTraceId(request.getTraceId()); - event.setSessionId(request.getSessionId()); - event.setAgentId(request.getAgentDefinition().getAgentId()); - event.getMetadata().put("requestId", request.getRequestId()); + AgentRuntimeExecutionContext currentRequest = currentRequest(); + AgentRuntimeEvent event = currentEventBridge().event(type); event.getMetadata().put("toolCategory", toolSpec.getCategory().name()); event.getMetadata().put("visibility", toolSpec.getVisibility().name()); appendSkillPayload(event.getMetadata(), activeSkillBinding()); @@ -455,5 +588,16 @@ public class AgentScopeToolAdapter { Object success = event.getMetadata().get("success"); return !(success instanceof Boolean) || Boolean.TRUE.equals(success); } + + private AgentRuntimeExecutionContext currentRequest() { + return turnContextHolder == null ? request : turnContextHolder.executionContext(request); + } + + private AgentRuntimeEventBridge currentEventBridge() { + if (turnContextHolder != null && turnContextHolder.eventBridge().isPresent()) { + return turnContextHolder.eventBridge().get(); + } + return new AgentRuntimeEventBridge(request, turnContextHolder); + } } } diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/AgentRuntimeEventBridge.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/AgentRuntimeEventBridge.java new file mode 100644 index 0000000..d377a65 --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/AgentRuntimeEventBridge.java @@ -0,0 +1,130 @@ +package com.easyagents.agent.runtime.event; + +import com.easyagents.agent.runtime.AgentRuntimeException; +import com.easyagents.agent.runtime.AgentRuntimeExecutionContext; +import reactor.core.publisher.Sinks; + +import java.util.Map; +import java.util.Optional; + +/** + * AgentScope 运行时的旁路事件桥。 + * + *

该类只负责 Easy-Agents 自己的旁路监察事件,不负责 AgentScope 主线路事件。 + * 主线路事件来自 {@code agent.stream(...)},会进入模型会话、工具结果和最终输出的正常顺序; + * 旁路事件只给调用方观察运行过程,例如知识库检索、自动上下文压缩、工具审批和 Skill 步骤。

+ * + *

旁路事件不会写入 AgentScope memory/session,也不会修改 AgentScope {@code Msg}。仅作观察与展示使用

+ */ +public class AgentRuntimeEventBridge { + + private final AgentRuntimeExecutionContext fallbackContext; + private final AgentRuntimeTurnContextHolder turnContextHolder; + + /** + * 创建旁路事件桥。 + * + * @param fallbackContext 运行时级上下文,当前轮次未设置时作为身份信息来源 + * @param turnContextHolder 当前运行轮次上下文持有器 + */ + public AgentRuntimeEventBridge(AgentRuntimeExecutionContext fallbackContext, + AgentRuntimeTurnContextHolder turnContextHolder) { + this.fallbackContext = fallbackContext; + this.turnContextHolder = turnContextHolder; + } + + /** + * 创建固定 sink 的旁路事件桥,主要用于旧测试和旧辅助构造路径。 + * + * @param fallbackContext 运行时级上下文 + * @param eventSink 旁路事件 sink + * @return 旁路事件桥 + */ + public static AgentRuntimeEventBridge fixed(AgentRuntimeExecutionContext fallbackContext, + Sinks.Many eventSink) { + AgentRuntimeTurnContextHolder holder = new AgentRuntimeTurnContextHolder(); + AgentRuntimeEventBridge bridge = new AgentRuntimeEventBridge(fallbackContext, holder); + holder.set(new AgentRuntimeTurnContext(null, eventSink, bridge)); + return bridge; + } + + /** + * 创建指定类型的旁路事件,并自动补齐公共运行身份信息。 + * + * @param type 旁路事件类型 + * @return 已补齐公共字段的运行时事件 + */ + public AgentRuntimeEvent event(AgentRuntimeEventType type) { + AgentRuntimeEvent event = AgentRuntimeEvent.of(type); + enrich(event); + return event; + } + + /** + * 发射一条旁路事件。 + * + *

当前没有运行轮次或没有旁路 sink 时直接忽略。这允许 adapter 在非流式测试、 + * 初始化阶段或主线路未建立旁路订阅时保持无副作用。

+ * + * @param event 旁路事件 + */ + public void emit(AgentRuntimeEvent event) { + if (event == null) { + return; + } + Optional> sink = eventSink(); + if (sink.isEmpty()) { + return; + } + enrich(event); + Sinks.EmitResult result = sink.get().tryEmitNext(event); + if (result.isFailure()) { + throw new AgentRuntimeException("Failed to emit agent runtime side event: " + result); + } + } + + /** + * 获取当前轮次合并后的执行上下文。 + * + * @return 当前执行上下文 + */ + public AgentRuntimeExecutionContext executionContext() { + return turnContextHolder == null ? fallbackContext : turnContextHolder.executionContext(fallbackContext); + } + + /** + * 补齐旁路事件的公共身份信息。 + * + * @param event 旁路事件 + */ + public void enrich(AgentRuntimeEvent event) { + if (event == null) { + return; + } + AgentRuntimeExecutionContext context = executionContext(); + if (context == null) { + return; + } + if (event.getTraceId() == null || event.getTraceId().isBlank()) { + event.setTraceId(context.getTraceId()); + } + if (event.getSessionId() == null || event.getSessionId().isBlank()) { + event.setSessionId(context.getSessionId()); + } + if ((event.getAgentId() == null || event.getAgentId().isBlank()) + && context.getAgentDefinition() != null) { + event.setAgentId(context.getAgentDefinition().getAgentId()); + } + if (context.getRequestId() != null && !context.getRequestId().isBlank()) { + event.getMetadata().putIfAbsent("requestId", context.getRequestId()); + } + Map metadata = context.getMetadata(); + if (metadata != null && !metadata.isEmpty()) { + metadata.forEach(event.getMetadata()::putIfAbsent); + } + } + + private Optional> eventSink() { + return turnContextHolder == null ? Optional.empty() : turnContextHolder.eventSink(); + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/AgentRuntimeEventType.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/AgentRuntimeEventType.java index 0b6e20e..2a5bc1a 100644 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/AgentRuntimeEventType.java +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/AgentRuntimeEventType.java @@ -14,6 +14,16 @@ public enum AgentRuntimeEventType { */ REASONING_DELTA, + /** + * 智能体开始一次模型推理。 + */ + REASONING_STARTED, + + /** + * 智能体完成一次模型推理。 + */ + REASONING_COMPLETED, + /** * 流式输出内容,用于流式展示聊天内容。 */ @@ -34,6 +44,16 @@ public enum AgentRuntimeEventType { */ KNOWLEDGE_RETRIEVAL, + /** + * 自动上下文压缩已开始。 + */ + MEMORY_COMPRESSION_STARTED, + + /** + * 自动上下文压缩已完成。 + */ + MEMORY_COMPRESSION_COMPLETED, + /** * 工具执行前需要人工审批。 */ @@ -64,6 +84,11 @@ public enum AgentRuntimeEventType { */ COMPLETED, + /** + * 智能体运行已暂停,等待外部输入后继续。 + */ + SUSPENDED, + /** * 智能体运行失败。 */ diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/AgentRuntimeInterceptor.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/AgentRuntimeInterceptor.java new file mode 100644 index 0000000..65234f2 --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/AgentRuntimeInterceptor.java @@ -0,0 +1,34 @@ +package com.easyagents.agent.runtime.event; + +import io.agentscope.core.hook.HookEvent; +import reactor.core.publisher.Mono; + +/** + * AgentScope Hook 事件的主线路干预器。对接 AgentScope 的原生生命周期hook + * + *

干预器允许修改 AgentScope HookEvent,因此会影响主线路执行。 + * 典型场景包括 AutoContext 在推理前改写输入消息,或后续 HITL 在推理后调用 + * {@code stopAgent()} 暂停工具执行。普通运行状态通知应使用 {@link AgentRuntimeObserver}。

+ *

警告:本干预器会影响到主线路agent 交互,谨慎使用 + *

+ */ +public interface AgentRuntimeInterceptor { + + /** + * 处理并返回可能被修改的 AgentScope Hook 事件。 + * + * @param event AgentScope Hook 事件 + * @param Hook 事件类型 + * @return 处理后的 Hook 事件 + */ + Mono intercept(T event); + + /** + * 获取干预器执行优先级。 + * + * @return 优先级,数值越小越先执行 + */ + default int priority() { + return 100; + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/AgentRuntimeObservationManager.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/AgentRuntimeObservationManager.java new file mode 100644 index 0000000..77d76a8 --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/AgentRuntimeObservationManager.java @@ -0,0 +1,72 @@ +package com.easyagents.agent.runtime.event; + +import io.agentscope.core.hook.HookEvent; +import reactor.core.publisher.Mono; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * AgentScope Hook 事件的统一观察和干预调度器。 + * + *

处理顺序固定为:先执行干预器,再执行观察器。干预器属于主线路能力, + * 可以修改 AgentScope HookEvent;观察器属于旁线路能力,只能查看事件并通过 + * {@link AgentRuntimeEventBridge} 发射对外监察事件。

+ */ +public class AgentRuntimeObservationManager { + + private final List interceptors; + private final List observers; + + /** + * 创建观察调度器。 + * + * @param interceptors 主线路干预器 + * @param observers 旁路观察器 + */ + public AgentRuntimeObservationManager(List interceptors, + List observers) { + this.interceptors = sortInterceptors(interceptors); + this.observers = sortObservers(observers); + } + + /** + * 创建空观察调度器。 + * + * @return 空调度器 + */ + public static AgentRuntimeObservationManager empty() { + return new AgentRuntimeObservationManager(List.of(), List.of()); + } + + /** + * 处理 AgentScope Hook 事件。 + * + * @param event Hook 事件 + * @param Hook 事件类型 + * @return 处理后的 Hook 事件 + */ + public Mono handle(T event) { + Mono chain = Mono.just(event); + for (AgentRuntimeInterceptor interceptor : interceptors) { + chain = chain.flatMap(interceptor::intercept); + } + for (AgentRuntimeObserver observer : observers) { + chain = chain.flatMap(current -> observer.observe(current).thenReturn(current)); + } + return chain; + } + + private List sortInterceptors(List source) { + List sorted = new ArrayList<>(source == null ? List.of() : source); + sorted.sort(Comparator.comparingInt(AgentRuntimeInterceptor::priority)); + return List.copyOf(sorted); + } + + private List sortObservers(List source) { + List sorted = new ArrayList<>(source == null ? List.of() : source); + sorted.sort(Comparator.comparingInt(AgentRuntimeObserver::priority)); + return List.copyOf(sorted); + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/AgentRuntimeObserver.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/AgentRuntimeObserver.java new file mode 100644 index 0000000..4158583 --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/AgentRuntimeObserver.java @@ -0,0 +1,31 @@ +package com.easyagents.agent.runtime.event; + +import io.agentscope.core.hook.HookEvent; +import reactor.core.publisher.Mono; + +/** + * AgentScope Hook 事件的旁路观察器。对接 AgentScope 的原生生命周期hook + * + *

观察器只做监察和旁路事件发射,不允许修改 AgentScope HookEvent。 + * 如果能力需要影响主线路,例如修改输入消息、暂停 agent 或替换工具结果,应实现 + * {@link AgentRuntimeInterceptor}。

+ */ +public interface AgentRuntimeObserver { + + /** + * 观察 AgentScope Hook 事件。 + * + * @param event AgentScope Hook 事件 + * @return 完成信号 + */ + Mono observe(HookEvent event); + + /** + * 获取观察器执行优先级。 + * + * @return 优先级,数值越小越先执行 + */ + default int priority() { + return 100; + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/AgentRuntimeTurnContext.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/AgentRuntimeTurnContext.java new file mode 100644 index 0000000..de79974 --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/AgentRuntimeTurnContext.java @@ -0,0 +1,137 @@ +package com.easyagents.agent.runtime.event; + +import com.easyagents.agent.runtime.AgentRuntimeExecutionContext; +import reactor.core.publisher.Sinks; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * AgentScope 运行时的单轮执行上下文。 + */ +public class AgentRuntimeTurnContext { + + /** + * 本轮运行上下文。 + */ + private final AgentRuntimeExecutionContext executionContext; + + /** + * 本轮旁路事件 sink。 + * + *

该字段只承载旁线路监察事件,不承载 AgentScope 主线路 stream 事件。

+ */ + private final Sinks.Many eventSink; + + /** + * 本轮旁路事件桥。 + * + *

adapter 和 observer 应优先通过 bridge 发射旁路事件,避免各处重复拼接 + * trace/session/request 等公共字段。

+ */ + private final AgentRuntimeEventBridge eventBridge; + + /** + * 创建单轮执行上下文。 + * + * @param executionContext 本轮运行上下文 + * @param eventSink 本轮旁路事件 sink + */ + public AgentRuntimeTurnContext(AgentRuntimeExecutionContext executionContext, + Sinks.Many eventSink) { + this(executionContext, eventSink, null); + } + + /** + * 创建单轮执行上下文。 + * + * @param executionContext 本轮运行上下文 + * @param eventSink 本轮旁路事件 sink + * @param eventBridge 本轮旁路事件桥 + */ + public AgentRuntimeTurnContext(AgentRuntimeExecutionContext executionContext, + Sinks.Many eventSink, + AgentRuntimeEventBridge eventBridge) { + this.executionContext = executionContext; + this.eventSink = eventSink; + this.eventBridge = eventBridge; + } + + /** + * 获取本轮运行上下文。 + * + * @return 本轮运行上下文 + */ + public AgentRuntimeExecutionContext getExecutionContext() { + return executionContext; + } + + /** + * 获取本轮旁路事件 sink。 + * + * @return 本轮旁路事件 sink + */ + public Sinks.Many getEventSink() { + return eventSink; + } + + /** + * 获取本轮旁路事件桥。 + * + * @return 本轮旁路事件桥 + */ + public AgentRuntimeEventBridge getEventBridge() { + return eventBridge; + } + + /** + * 构建以当前轮信息覆盖运行时信息后的上下文。 + * + * @param fallback 运行时级上下文 + * @return 当前轮上下文,未设置时返回运行时级上下文 + */ + public AgentRuntimeExecutionContext mergeWith(AgentRuntimeExecutionContext fallback) { + if (executionContext == null) { + return fallback; + } + AgentRuntimeExecutionContext merged = new AgentRuntimeExecutionContext(); + merged.setRequestId(firstNonBlank(executionContext.getRequestId(), fallback == null ? null : fallback.getRequestId())); + merged.setTraceId(firstNonBlank(executionContext.getTraceId(), fallback == null ? null : fallback.getTraceId())); + merged.setSessionId(firstNonBlank(executionContext.getSessionId(), fallback == null ? null : fallback.getSessionId())); + merged.setAgentDefinition(executionContext.getAgentDefinition() == null && fallback != null + ? fallback.getAgentDefinition() + : executionContext.getAgentDefinition()); + merged.setRuntimeContext(executionContext.getRuntimeContext() == null && fallback != null + ? fallback.getRuntimeContext() + : executionContext.getRuntimeContext()); + merged.setUserMessage(executionContext.getUserMessage()); + merged.setMemorySnapshot(executionContext.getMemorySnapshot()); + merged.setToolInvokers(fallback == null ? executionContext.getToolInvokers() : fallback.getToolInvokers()); + merged.setKnowledgeRetrievers(fallback == null + ? executionContext.getKnowledgeRetrievers() + : fallback.getKnowledgeRetrievers()); + merged.setSessionStore(fallback == null ? executionContext.getSessionStore() : fallback.getSessionStore()); + merged.setConversationRecorder(fallback == null + ? executionContext.getConversationRecorder() + : fallback.getConversationRecorder()); + merged.setMetadata(mergedMetadata(fallback, executionContext)); + merged.setCancelReason(executionContext.getCancelReason()); + return merged; + } + + private Map mergedMetadata(AgentRuntimeExecutionContext fallback, + AgentRuntimeExecutionContext current) { + Map metadata = new LinkedHashMap<>(); + if (fallback != null && fallback.getMetadata() != null) { + metadata.putAll(fallback.getMetadata()); + } + if (current != null && current.getMetadata() != null) { + metadata.putAll(current.getMetadata()); + } + return metadata; + } + + private String firstNonBlank(String first, String second) { + return first == null || first.isBlank() ? second : first; + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/AgentRuntimeTurnContextHolder.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/AgentRuntimeTurnContextHolder.java new file mode 100644 index 0000000..5ede50b --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/AgentRuntimeTurnContextHolder.java @@ -0,0 +1,74 @@ +package com.easyagents.agent.runtime.event; + +import com.easyagents.agent.runtime.AgentRuntimeExecutionContext; +import reactor.core.publisher.Sinks; + +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +/** + * 保存当前运行轮次上下文。 + */ +public class AgentRuntimeTurnContextHolder { + + private final AtomicReference current = new AtomicReference<>(); + + /** + * 设置当前运行轮次上下文。 + * + * @param context 当前轮次上下文 + */ + public void set(AgentRuntimeTurnContext context) { + current.set(context); + } + + /** + * 清理当前运行轮次上下文。 + */ + public void clear() { + current.set(null); + } + + /** + * 获取当前运行轮次上下文。 + * + * @return 当前轮次上下文 + */ + public Optional current() { + return Optional.ofNullable(current.get()); + } + + /** + * 获取当前轮次事件 sink。 + * + * @return 当前轮次旁路事件 sink + */ + public Optional> eventSink() { + return current() + .map(AgentRuntimeTurnContext::getEventSink) + .filter(sink -> sink != null); + } + + /** + * 获取当前轮次旁路事件桥。 + * + * @return 当前轮次旁路事件桥 + */ + public Optional eventBridge() { + return current() + .map(AgentRuntimeTurnContext::getEventBridge) + .filter(bridge -> bridge != null); + } + + /** + * 合并当前轮次上下文和运行时级上下文。 + * + * @param fallback 运行时级上下文 + * @return 合并后的上下文 + */ + public AgentRuntimeExecutionContext executionContext(AgentRuntimeExecutionContext fallback) { + return current() + .map(context -> context.mergeWith(fallback)) + .orElse(fallback); + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/interceptor/AutoContextInterceptor.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/interceptor/AutoContextInterceptor.java new file mode 100644 index 0000000..5261fe3 --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/interceptor/AutoContextInterceptor.java @@ -0,0 +1,357 @@ +package com.easyagents.agent.runtime.event.interceptor; + +import com.easyagents.agent.runtime.AgentRuntimeException; +import com.easyagents.agent.runtime.event.AgentRuntimeEvent; +import com.easyagents.agent.runtime.event.AgentRuntimeEventBridge; +import com.easyagents.agent.runtime.event.AgentRuntimeEventType; +import com.easyagents.agent.runtime.event.AgentRuntimeInterceptor; +import io.agentscope.core.ReActAgent; +import io.agentscope.core.agent.Agent; +import io.agentscope.core.hook.HookEvent; +import io.agentscope.core.hook.PreCallEvent; +import io.agentscope.core.hook.PreReasoningEvent; +import io.agentscope.core.memory.Memory; +import io.agentscope.core.memory.autocontext.*; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.plan.PlanNotebook; +import io.agentscope.core.tool.Toolkit; +import reactor.core.publisher.Mono; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * AutoContext 主线路干预器。 + * + *

特殊约束:本干预器是对 AgentScope 官方 + * {@code AutoContextHook} 的替代实现,不能与官方 {@code AutoContextHook} 同时注册。 + * 两者同时存在会导致 {@link AutoContextMemory#compressIfNeeded()}、上下文重写以及 + * {@link ContextOffloadTool} 注册被重复执行。

+ * + *

本类承担两类职责。第一类是主线路干预:在 {@link PreCallEvent} 中注册 + * AutoContext 工具能力,在 {@link PreReasoningEvent} 中触发记忆压缩并改写 + * LLM 输入消息。第二类是旁路通知:通过 {@link AgentRuntimeEventBridge} 发出 + * {@link AgentRuntimeEventType#MEMORY_COMPRESSION_STARTED} 和 + * {@link AgentRuntimeEventType#MEMORY_COMPRESSION_COMPLETED},这些事件只用于调用方展示, + * 不写入 AgentScope memory/session,也不参与 LLM 会话协议。

+ */ +public class AutoContextInterceptor implements AgentRuntimeInterceptor { + + private static final String STATUS_KEY = "memory-compression"; + private static final String AUTO_CONTEXT_SYSTEM_INSTRUCTION = + "You may see compressed messages containing .\n" + + "- Use the UUID to call context_reload if you need full details.\n" + + "- NEVER mention, quote, or refer to UUIDs, offload tags, or internal metadata in your response."; + + private final AgentRuntimeEventBridge eventBridge; + private final AutoContextConfig autoContextConfig; + private final AtomicBoolean registered = new AtomicBoolean(false); + + /** + * 创建 AutoContext 主线路干预器。 + * + *

传入的 {@link AutoContextConfig} 必须是创建目标 {@link AutoContextMemory} + * 时使用的同一份配置。这样压缩开始事件的触发条件才能与 AgentScope + * {@code compressIfNeeded()} 的入口判断保持一致。

+ * + * @param eventBridge 旁路事件桥 + * @param autoContextConfig AutoContext 配置 + */ + public AutoContextInterceptor(AgentRuntimeEventBridge eventBridge, + AutoContextConfig autoContextConfig) { + this.eventBridge = eventBridge; + this.autoContextConfig = autoContextConfig; + } + + /** + * 处理 AutoContext 相关 AgentScope Hook 事件。 + * + * @param event AgentScope Hook 事件 + * @param Hook 事件类型 + * @return 处理后的 Hook 事件 + */ + @Override + public Mono intercept(T event) { + if (event instanceof PreCallEvent preCallEvent) { + @SuppressWarnings("unchecked") + Mono result = (Mono) handlePreCall(preCallEvent); + return result; + } + if (event instanceof PreReasoningEvent preReasoningEvent) { + @SuppressWarnings("unchecked") + Mono result = (Mono) handlePreReasoning(preReasoningEvent); + return result; + } + return Mono.just(event); + } + + /** + * 获取干预器优先级。 + * + * @return 优先级,保持与官方 AutoContextHook 一致 + */ + @Override + public int priority() { + return 0; + } + + /** + * 判断 AutoContext 工具集成是否已经注册。 + * + * @return 已注册时返回 true + */ + public boolean isRegistered() { + return registered.get(); + } + + /** + * 处理调用前事件,注册 AutoContext 的上下文重载工具和计划本。 + * + * @param event 调用前事件 + * @return 原事件 + */ + private Mono handlePreCall(PreCallEvent event) { + if (registered.get()) { + return Mono.just(event); + } + Agent agent = event.getAgent(); + if (!(agent instanceof ReActAgent reActAgent)) { + return Mono.just(event); + } + Memory memory = reActAgent.getMemory(); + if (!(memory instanceof AutoContextMemory autoContextMemory)) { + return Mono.just(event); + } + if (!registered.compareAndSet(false, true)) { + return Mono.just(event); + } + try { + Toolkit toolkit = reActAgent.getToolkit(); + if (toolkit != null) { + toolkit.registerTool(new ContextOffloadTool(autoContextMemory)); + } + PlanNotebook planNotebook = reActAgent.getPlanNotebook(); + if (planNotebook != null) { + autoContextMemory.attachPlanNote(planNotebook); + } + } catch (Exception e) { + registered.set(false); + throw new AgentRuntimeException("Failed to register AutoContext integration.", e); + } + return Mono.just(event); + } + + /** + * 处理推理前事件,触发 AutoContext 压缩并改写 LLM 输入消息。 + * + *

这是主线路干预逻辑:{@code compressIfNeeded()} 和 {@code setInputMessages(...)} + * 会影响 AgentScope 本次 reasoning 输入。压缩开始/完成事件则是旁路通知,只发给调用方。

+ * + * @param event 推理前事件 + * @return 改写输入消息后的事件 + */ + private Mono handlePreReasoning(PreReasoningEvent event) { + Agent agent = event.getAgent(); + if (!(agent instanceof ReActAgent reActAgent)) { + return Mono.just(event); + } + Memory memory = reActAgent.getMemory(); + if (!(memory instanceof AutoContextMemory autoContextMemory)) { + return Mono.just(event); + } + // 判断是否达到压缩条件 + CompressionCheck compressionCheck = compressionCheck(autoContextMemory.getMessages()); + int beforeEventCount = compressionEventCount(autoContextMemory); + if (compressionCheck.thresholdReached()) { + boolean compressed = autoContextMemory.compressIfNeeded(); + List newEvents = newCompressionEvents(autoContextMemory, beforeEventCount); + if (compressed && !newEvents.isEmpty()) { + emitCompressionStarted(compressionCheck); + emitCompressionCompleted(compressionCheck, newEvents); + } + } + // 压缩完毕后自动进行当前的会话 + event.setInputMessages(buildInputMessages(event, autoContextMemory)); + return Mono.just(event); + } + + /** + * 计算当前记忆是否达到 AutoContext 官方压缩入口条件。 + * + * @param messages 当前工作记忆消息 + * @return 压缩入口检查结果 + */ + private CompressionCheck compressionCheck(List messages) { + List safeMessages = messages == null ? List.of() : messages; + int messageCount = safeMessages.size(); + int tokenCount = TokenCounterUtil.calculateToken(safeMessages); + int thresholdMessageCount = autoContextConfig == null ? Integer.MAX_VALUE : autoContextConfig.getMsgThreshold(); + long thresholdTokenCount = autoContextConfig == null + ? Long.MAX_VALUE + : (long) (autoContextConfig.getMaxToken() * autoContextConfig.getTokenRatio()); + boolean messageThresholdReached = messageCount >= thresholdMessageCount; + boolean tokenThresholdReached = tokenCount >= thresholdTokenCount; + return new CompressionCheck(messageCount, tokenCount, thresholdMessageCount, thresholdTokenCount, + messageThresholdReached, tokenThresholdReached); + } + + /** + * 获取当前压缩事件数量。 + * + * @param autoContextMemory AutoContext 记忆 + * @return 压缩事件数量 + */ + private int compressionEventCount(AutoContextMemory autoContextMemory) { + List events = autoContextMemory.getCompressionEvents(); + return events == null ? 0 : events.size(); + } + + /** + * 获取本次压缩新增的 AgentScope 压缩事件。 + * + * @param autoContextMemory AutoContext 记忆 + * @param beforeEventCount 压缩前事件数量 + * @return 新增压缩事件 + */ + private List newCompressionEvents(AutoContextMemory autoContextMemory, int beforeEventCount) { + List events = autoContextMemory.getCompressionEvents(); + if (events == null || events.size() <= beforeEventCount) { + return List.of(); + } + return new ArrayList<>(events.subList(beforeEventCount, events.size())); + } + + /** + * 构建 AutoContext 压缩后传给 LLM 的输入消息。 + * + * @param event 推理前事件 + * @param autoContextMemory AutoContext 记忆 + * @return 更新后的输入消息 + */ + private List buildInputMessages(PreReasoningEvent event, AutoContextMemory autoContextMemory) { + List originalInputMessages = event.getInputMessages(); + List newInputMessages = new ArrayList<>(); + if (!originalInputMessages.isEmpty() && originalInputMessages.get(0).getRole() == MsgRole.SYSTEM) { + Msg originalSystemMsg = originalInputMessages.get(0); + String originalSystemText = originalSystemMsg.getTextContent(); + String newSystemText = originalSystemText != null + ? originalSystemText + "\n\n" + AUTO_CONTEXT_SYSTEM_INSTRUCTION + : AUTO_CONTEXT_SYSTEM_INSTRUCTION; + newInputMessages.add(Msg.builder() + .role(MsgRole.SYSTEM) + .name(originalSystemMsg.getName()) + .content(TextBlock.builder().text(newSystemText).build()) + .metadata(originalSystemMsg.getMetadata()) + .build()); + } else { + newInputMessages.add(Msg.builder() + .role(MsgRole.SYSTEM) + .name("system") + .content(TextBlock.builder().text(AUTO_CONTEXT_SYSTEM_INSTRUCTION).build()) + .build()); + } + newInputMessages.addAll(autoContextMemory.getMessages()); + return newInputMessages; + } + + /** + * 发射上下文压缩开始旁路事件。 + * + * @param compressionCheck 压缩入口检查结果 + */ + private void emitCompressionStarted(CompressionCheck compressionCheck) { + AgentRuntimeEvent event = eventBridge.event(AgentRuntimeEventType.MEMORY_COMPRESSION_STARTED); + event.getPayload().put("statusKey", STATUS_KEY); + event.getPayload().put("phase", "started"); + event.getPayload().put("status", "running"); + event.getPayload().put("label", "正在整理上下文"); + putCompressionCheckPayload(event, compressionCheck); + eventBridge.emit(event); + } + + /** + * 发射上下文压缩完成旁路事件。 + * + * @param compressionCheck 压缩入口检查结果 + * @param events 新增压缩事件 + */ + private void emitCompressionCompleted(CompressionCheck compressionCheck, + List events) { + AgentRuntimeEvent event = eventBridge.event(AgentRuntimeEventType.MEMORY_COMPRESSION_COMPLETED); + event.getPayload().put("statusKey", STATUS_KEY); + event.getPayload().put("phase", "completed"); + event.getPayload().put("status", "done"); + event.getPayload().put("label", "已整理上下文"); + event.getPayload().put("compressed", true); + event.getPayload().put("eventCount", events == null ? 0 : events.size()); + event.getPayload().put("events", toPayloadEvents(events)); + putCompressionCheckPayload(event, compressionCheck); + eventBridge.emit(event); + } + + /** + * 填充压缩入口判断相关载荷。 + * + * @param event 运行时事件 + * @param compressionCheck 压缩入口检查结果 + */ + private void putCompressionCheckPayload(AgentRuntimeEvent event, CompressionCheck compressionCheck) { + event.getPayload().put("messageCount", compressionCheck.messageCount()); + event.getPayload().put("tokenCount", compressionCheck.tokenCount()); + event.getPayload().put("thresholdMessageCount", compressionCheck.thresholdMessageCount()); + event.getPayload().put("thresholdTokenCount", compressionCheck.thresholdTokenCount()); + event.getPayload().put("messageThresholdReached", compressionCheck.messageThresholdReached()); + event.getPayload().put("tokenThresholdReached", compressionCheck.tokenThresholdReached()); + } + + /** + * 将 AgentScope 压缩事件转换为旁路事件载荷。 + * + * @param events AgentScope 压缩事件 + * @return 压缩事件载荷 + */ + private List> toPayloadEvents(List events) { + if (events == null || events.isEmpty()) { + return List.of(); + } + List> payloadEvents = new ArrayList<>(); + for (CompressionEvent event : events) { + Map payload = new LinkedHashMap<>(); + payload.put("eventType", event.getEventType()); + payload.put("timestamp", event.getTimestamp()); + payload.put("compressedMessageCount", event.getCompressedMessageCount()); + payload.put("previousMessageId", event.getPreviousMessageId()); + payload.put("nextMessageId", event.getNextMessageId()); + payload.put("compressedMessageId", event.getCompressedMessageId()); + payload.put("tokenBefore", event.getTokenBefore()); + payload.put("tokenAfter", event.getTokenAfter()); + payload.put("tokenReduction", event.getTokenReduction()); + payload.put("inputToken", event.getCompressInputToken()); + payload.put("outputToken", event.getCompressOutputToken()); + payloadEvents.add(payload); + } + return payloadEvents; + } + + private record CompressionCheck(int messageCount, + int tokenCount, + int thresholdMessageCount, + long thresholdTokenCount, + boolean messageThresholdReached, + boolean tokenThresholdReached) { + + /** + * 判断是否达到 AutoContext 官方压缩入口条件。 + * + * @return 达到任一阈值时返回 true + */ + private boolean thresholdReached() { + return messageThresholdReached || tokenThresholdReached; + } + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/interceptor/ToolHitlInterceptor.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/interceptor/ToolHitlInterceptor.java new file mode 100644 index 0000000..f13c747 --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/interceptor/ToolHitlInterceptor.java @@ -0,0 +1,198 @@ +package com.easyagents.agent.runtime.event.interceptor; + +import com.easyagents.agent.runtime.AgentRuntimeExecutionContext; +import com.easyagents.agent.runtime.event.AgentRuntimeEvent; +import com.easyagents.agent.runtime.event.AgentRuntimeEventBridge; +import com.easyagents.agent.runtime.event.AgentRuntimeEventType; +import com.easyagents.agent.runtime.event.AgentRuntimeInterceptor; +import com.easyagents.agent.runtime.hitl.AgentPendingState; +import com.easyagents.agent.runtime.hitl.AgentToolApprovalCoordinator; +import com.easyagents.agent.runtime.hitl.AgentToolApprovalRequest; +import com.easyagents.agent.runtime.tool.AgentToolSpec; +import io.agentscope.core.hook.HookEvent; +import io.agentscope.core.hook.PostReasoningEvent; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.ToolUseBlock; +import reactor.core.publisher.Mono; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 工具 HITL 主线路干预器。 + * + *

本 interceptor 专门处理“工具执行前人工审批”。监听 AgentScope 原生 + * {@link PostReasoningEvent}

+ * + *

这里包含两类动作: + *

    + *
  • 主线路干预:发现待审批工具后调用 {@link PostReasoningEvent#stopAgent()}, + * 让 AgentScope 返回当前带 ToolUseBlock 的消息并暂停工具执行。
  • + *
  • 旁路交互事件:通过 {@link AgentRuntimeEventBridge} 发出 + * {@link AgentRuntimeEventType#TOOL_APPROVAL_REQUIRED},通知调用方展示审批交互。
  • + *
+ * + *

注意:本 interceptor 不执行工具、不写入 AgentScope memory/session,也不实现恢复。 + * 后续 resume 流程应基于 AgentScope pending tool 状态继续调用 agent stream/call。

+ */ +public class ToolHitlInterceptor implements AgentRuntimeInterceptor { + + private final AgentRuntimeEventBridge eventBridge; + private final AgentToolApprovalCoordinator approvalCoordinator; + private final Map toolSpecs; + + /** + * 创建工具 HITL 干预器。 + * + * @param eventBridge 旁路事件桥 + * @param approvalCoordinator 工具审批协调器 + * @param toolSpecs 工具声明列表 + */ + public ToolHitlInterceptor(AgentRuntimeEventBridge eventBridge, + AgentToolApprovalCoordinator approvalCoordinator, + List toolSpecs) { + this.eventBridge = eventBridge; + this.approvalCoordinator = approvalCoordinator; + this.toolSpecs = (toolSpecs == null ? List.of() : toolSpecs).stream() + .filter(spec -> spec != null && spec.getName() != null && !spec.getName().isBlank()) + .collect(Collectors.toMap(AgentToolSpec::getName, Function.identity(), (left, right) -> left, + LinkedHashMap::new)); + } + + /** + * 处理 AgentScope Hook 事件。 + * + * @param event AgentScope Hook 事件 + * @param Hook 事件类型 + * @return 处理后的 Hook 事件 + */ + @Override + public Mono intercept(T event) { + if (event instanceof PostReasoningEvent postReasoningEvent) { + interceptPostReasoning(postReasoningEvent); + } + return Mono.just(event); + } + + /** + * 返回执行优先级。 + * + *

工具审批需要在普通观察器发出 reasoning completed 后保持事件已被标记暂停, + * 但不应早于 AutoContext 的 PreReasoning 干预。当前值用于主线路 reasoning 后检查。

+ * + * @return 优先级 + */ + @Override + public int priority() { + return 50; + } + + private void interceptPostReasoning(PostReasoningEvent event) { + Msg reasoningMessage = event.getReasoningMessage(); + if (reasoningMessage == null) { + return; + } + List approvalRequiredTools = approvalRequiredTools(reasoningMessage); + if (approvalRequiredTools.isEmpty()) { + return; + } + List> pendingApprovals = new ArrayList<>(); + for (ToolUseBlock toolUse : approvalRequiredTools) { + AgentToolSpec toolSpec = toolSpecs.get(toolUse.getName()); + AgentPendingState pendingState = registerPendingState(toolSpec, toolUse); + AgentRuntimeEvent approvalEvent = toolApprovalRequiredEvent(toolSpec, toolUse, pendingState); + pendingState.setEventId(approvalEvent.getEventId()); + pendingApprovals.add(pendingApprovalPayload(pendingState, toolUse)); + eventBridge.emit(approvalEvent); + } + AgentRuntimeExecutionContext context = eventBridge.executionContext(); + if (context != null) { + context.getMetadata().put("hitlSuspended", true); + context.getMetadata().put("hitlSuspendReason", "TOOL_APPROVAL_REQUIRED"); + context.getMetadata().put("hitlPendingApprovals", pendingApprovals); + } + event.stopAgent(); + } + + private List approvalRequiredTools(Msg reasoningMessage) { + List toolUses = reasoningMessage.getContentBlocks(ToolUseBlock.class); + if (toolUses == null || toolUses.isEmpty()) { + return List.of(); + } + return toolUses.stream() + .filter(toolUse -> { + AgentToolSpec toolSpec = toolUse == null ? null : toolSpecs.get(toolUse.getName()); + return toolSpec != null && toolSpec.isApprovalRequired(); + }) + .toList(); + } + + private AgentPendingState registerPendingState(AgentToolSpec toolSpec, ToolUseBlock toolUse) { + AgentRuntimeExecutionContext context = eventBridge.executionContext(); + AgentToolApprovalRequest approvalRequest = toolSpec.getApprovalRequest(); + Duration timeout = approvalRequest == null || approvalRequest.getTimeout() == null + ? Duration.ofMinutes(30) + : approvalRequest.getTimeout(); + Map metadata = approvalRequest == null + ? new LinkedHashMap<>() + : new LinkedHashMap<>(approvalRequest.getMetadata()); + metadata.put("phase", "POST_REASONING"); + metadata.put("source", "TOOL_HITL_INTERCEPTOR"); + metadata.putAll(toolUse.getMetadata() == null ? Map.of() : toolUse.getMetadata()); + return approvalCoordinator.register( + context == null ? null : context.getSessionId(), + context == null || context.getAgentDefinition() == null ? null : context.getAgentDefinition().getAgentId(), + toolUse.getId(), + toolUse.getName(), + approvalPrompt(approvalRequest), + toolUse.getInput(), + metadata, + Instant.now().plus(timeout)); + } + + private AgentRuntimeEvent toolApprovalRequiredEvent(AgentToolSpec toolSpec, + ToolUseBlock toolUse, + AgentPendingState pendingState) { + AgentRuntimeExecutionContext context = eventBridge.executionContext(); + AgentRuntimeEvent event = eventBridge.event(AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED); + event.setToolCallId(toolUse.getId()); + event.getPayload().putAll(pendingApprovalPayload(pendingState, toolUse)); + event.getPayload().put("sessionId", context == null ? null : context.getSessionId()); + event.getPayload().put("agentId", context == null || context.getAgentDefinition() == null + ? null + : context.getAgentDefinition().getAgentId()); + event.getPayload().put("approvalPrompt", approvalPrompt(toolSpec.getApprovalRequest())); + event.getPayload().put("approvalMetadata", pendingState.getMetadata()); + event.getPayload().put("toolDescription", toolSpec.getDescription()); + event.getMetadata().put("source", "TOOL_HITL_INTERCEPTOR"); + event.getMetadata().put("phase", "POST_REASONING"); + return event; + } + + private Map pendingApprovalPayload(AgentPendingState pendingState, ToolUseBlock toolUse) { + Map payload = new LinkedHashMap<>(); + payload.put("resumeToken", pendingState.getResumeToken().getValue()); + payload.put("toolCallId", pendingState.getToolCallId()); + payload.put("toolName", pendingState.getToolName()); + payload.put("toolInput", pendingState.getToolInput()); + payload.put("input", toolUse.getInput()); + payload.put("content", toolUse.getContent()); + payload.put("expiresAt", pendingState.getExpiresAt() == null ? null : pendingState.getExpiresAt().toString()); + return payload; + } + + private String approvalPrompt(AgentToolApprovalRequest approvalRequest) { + if (approvalRequest != null + && approvalRequest.getApprovalPrompt() != null + && !approvalRequest.getApprovalPrompt().isBlank()) { + return approvalRequest.getApprovalPrompt(); + } + return "是否批准执行该工具?"; + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/observer/AgentRuntimeErrorObserver.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/observer/AgentRuntimeErrorObserver.java new file mode 100644 index 0000000..dd4917d --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/observer/AgentRuntimeErrorObserver.java @@ -0,0 +1,62 @@ +package com.easyagents.agent.runtime.event.observer; + +import com.easyagents.agent.runtime.event.AgentRuntimeEvent; +import com.easyagents.agent.runtime.event.AgentRuntimeEventBridge; +import com.easyagents.agent.runtime.event.AgentRuntimeEventType; +import com.easyagents.agent.runtime.event.AgentRuntimeObserver; +import io.agentscope.core.agent.Agent; +import io.agentscope.core.hook.ErrorEvent; +import io.agentscope.core.hook.HookEvent; +import reactor.core.publisher.Mono; + +/** + * 监听 AgentScope 原生错误事件,并发射运行失败旁路事件。 + * + *

该观察器复用 {@link AgentRuntimeEventType#FAILED},但通过 payload 中的 + * {@code source=HOOK} 标识它来自 AgentScope 生命周期观察,不等同于 Easy-Agents + * runtime 外层流已经完成失败收口。

+ */ +public class AgentRuntimeErrorObserver implements AgentRuntimeObserver { + + private final AgentRuntimeEventBridge eventBridge; + + /** + * 创建运行错误观察器。 + * + * @param eventBridge 旁路事件桥 + */ + public AgentRuntimeErrorObserver(AgentRuntimeEventBridge eventBridge) { + this.eventBridge = eventBridge; + } + + /** + * 观察 AgentScope 错误事件。 + * + * @param event AgentScope Hook 事件 + * @return 完成信号 + */ + @Override + public Mono observe(HookEvent event) { + if (!(event instanceof ErrorEvent errorEvent)) { + return Mono.empty(); + } + Throwable error = errorEvent.getError(); + AgentRuntimeEvent runtimeEvent = eventBridge.event(AgentRuntimeEventType.FAILED); + runtimeEvent.getPayload().put("source", "HOOK"); + runtimeEvent.getPayload().put("phase", "ERROR"); + runtimeEvent.getPayload().put("stage", "AGENTSCOPE_HOOK"); + runtimeEvent.getPayload().put("errorType", error == null ? null : error.getClass().getName()); + runtimeEvent.getPayload().put("message", error == null ? "AgentScope hook error." : error.getMessage()); + appendAgent(runtimeEvent, errorEvent.getAgent()); + eventBridge.emit(runtimeEvent); + return Mono.empty(); + } + + private void appendAgent(AgentRuntimeEvent event, Agent agent) { + if (agent == null) { + return; + } + event.getPayload().put("agentName", agent.getName()); + event.getPayload().put("agentScopeAgentId", agent.getAgentId()); + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/observer/ReasoningLifecycleObserver.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/observer/ReasoningLifecycleObserver.java new file mode 100644 index 0000000..727340f --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/observer/ReasoningLifecycleObserver.java @@ -0,0 +1,77 @@ +package com.easyagents.agent.runtime.event.observer; + +import com.easyagents.agent.runtime.event.AgentRuntimeEvent; +import com.easyagents.agent.runtime.event.AgentRuntimeEventBridge; +import com.easyagents.agent.runtime.event.AgentRuntimeEventType; +import com.easyagents.agent.runtime.event.AgentRuntimeObserver; +import io.agentscope.core.hook.HookEvent; +import io.agentscope.core.hook.PostReasoningEvent; +import io.agentscope.core.hook.PreReasoningEvent; +import io.agentscope.core.message.Msg; +import reactor.core.publisher.Mono; + +/** + * 监听 AgentScope 推理生命周期,并发射思考状态旁路事件。 + * + *

{@link AgentRuntimeEventType#REASONING_DELTA} 表示主线路中的推理内容片段; + * 本观察器发射的 started/completed 事件只用于前端状态展示,不携带模型上下文, + * 也不会修改 AgentScope 的推理输入或输出。

+ */ +public class ReasoningLifecycleObserver implements AgentRuntimeObserver { + + private final AgentRuntimeEventBridge eventBridge; + + /** + * 创建推理生命周期观察器。 + * + * @param eventBridge 旁路事件桥 + */ + public ReasoningLifecycleObserver(AgentRuntimeEventBridge eventBridge) { + this.eventBridge = eventBridge; + } + + /** + * 观察推理开始和完成事件。 + * + * @param event AgentScope Hook 事件 + * @return 完成信号 + */ + @Override + public Mono observe(HookEvent event) { + if (event instanceof PreReasoningEvent preReasoningEvent) { + emitStarted(preReasoningEvent); + return Mono.empty(); + } + if (event instanceof PostReasoningEvent postReasoningEvent) { + emitCompleted(postReasoningEvent); + } + return Mono.empty(); + } + + private void emitStarted(PreReasoningEvent event) { + AgentRuntimeEvent runtimeEvent = eventBridge.event(AgentRuntimeEventType.REASONING_STARTED); + runtimeEvent.getPayload().put("modelName", event.getModelName()); + runtimeEvent.getPayload().put("messageCount", event.getInputMessages() == null ? 0 : event.getInputMessages().size()); + runtimeEvent.getPayload().put("source", "HOOK"); + runtimeEvent.getPayload().put("phase", "PRE_REASONING"); + eventBridge.emit(runtimeEvent); + } + + private void emitCompleted(PostReasoningEvent event) { + AgentRuntimeEvent runtimeEvent = eventBridge.event(AgentRuntimeEventType.REASONING_COMPLETED); + runtimeEvent.getPayload().put("modelName", event.getModelName()); + runtimeEvent.getPayload().put("stopRequested", event.isStopRequested()); + runtimeEvent.getPayload().put("gotoReasoningRequested", event.isGotoReasoningRequested()); + runtimeEvent.getPayload().put("text", reasoningText(event.getReasoningMessage())); + runtimeEvent.getPayload().put("source", "HOOK"); + runtimeEvent.getPayload().put("phase", "POST_REASONING"); + eventBridge.emit(runtimeEvent); + } + + private String reasoningText(Msg message) { + if (message == null || message.getTextContent() == null) { + return ""; + } + return message.getTextContent(); + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/observer/SkillExecutionObserver.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/observer/SkillExecutionObserver.java new file mode 100644 index 0000000..a2e9740 --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/observer/SkillExecutionObserver.java @@ -0,0 +1,300 @@ +package com.easyagents.agent.runtime.event.observer; + +import com.easyagents.agent.runtime.event.AgentRuntimeEvent; +import com.easyagents.agent.runtime.event.AgentRuntimeEventBridge; +import com.easyagents.agent.runtime.event.AgentRuntimeEventType; +import com.easyagents.agent.runtime.event.AgentRuntimeObserver; +import com.easyagents.agent.runtime.skill.AgentSkillBinding; +import com.easyagents.agent.runtime.skill.AgentSkillLoadCall; +import com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext; +import io.agentscope.core.hook.HookEvent; +import io.agentscope.core.hook.PostActingEvent; +import io.agentscope.core.hook.PreActingEvent; +import io.agentscope.core.message.ContentBlock; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.skill.SkillBox; +import io.agentscope.core.tool.Toolkit; +import reactor.core.publisher.Mono; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.StringJoiner; + +/** + * 监听 AgentScope 工具执行生命周期,并发射 Skill 旁路事件。 + * + *

该观察器只做 Easy-Agents 的旁路监察,不修改 AgentScope {@link HookEvent}。 + * Skill 加载工具 {@link AgentSkillRuntimeContext#LOAD_SKILL_TOOL_NAME} 本身仍是 + * AgentScope 主线路中的工具调用;本观察器只把它翻译成调用方可展示的 + * {@link AgentRuntimeEventType#SKILL_CALL}、{@link AgentRuntimeEventType#SKILL_RESULT} + * 和 {@link AgentRuntimeEventType#SKILL_FAILED}。已激活 Skill 内部的普通工具调用会 + * 被翻译成 {@link AgentRuntimeEventType#SKILL_STEP}。

+ * + *

Skill 是否激活以 AgentScope {@link SkillBox} 和 {@link Toolkit#getActiveGroups()} + * 为准,本地 {@link AgentSkillRuntimeContext} 只缓存旁路展示所需的归属状态。

+ */ +public class SkillExecutionObserver implements AgentRuntimeObserver { + + private final AgentRuntimeEventBridge eventBridge; + private final AgentSkillRuntimeContext skillContext; + private final SkillBox skillBox; + + /** + * 创建 Skill 执行观察器。 + * + * @param eventBridge 旁路事件桥 + * @param skillContext Skill 运行时上下文 + */ + public SkillExecutionObserver(AgentRuntimeEventBridge eventBridge, + AgentSkillRuntimeContext skillContext) { + this(eventBridge, skillContext, null); + } + + /** + * 创建 Skill 执行观察器。 + * + * @param eventBridge 旁路事件桥 + * @param skillContext Skill 运行时上下文 + * @param skillBox AgentScope SkillBox,作为 Skill 激活状态的权威来源 + */ + public SkillExecutionObserver(AgentRuntimeEventBridge eventBridge, + AgentSkillRuntimeContext skillContext, + SkillBox skillBox) { + this.eventBridge = eventBridge; + this.skillContext = skillContext; + this.skillBox = skillBox; + } + + /** + * 观察 Skill 加载和已激活 Skill 内部工具执行。 + * + * @param event AgentScope Hook 事件 + * @return 完成信号 + */ + @Override + public Mono observe(HookEvent event) { + if (skillContext == null) { + return Mono.empty(); + } + if (event instanceof PreActingEvent preActingEvent) { + observePreActing(preActingEvent); + return Mono.empty(); + } + if (event instanceof PostActingEvent postActingEvent) { + observePostActing(postActingEvent); + } + return Mono.empty(); + } + + private void observePreActing(PreActingEvent event) { + ToolUseBlock toolUse = event.getToolUse(); + if (toolUse == null) { + return; + } + syncSkillState(toolUse.getName(), event.getToolkit()); + if (skillContext.isSkillLoadTool(toolUse.getName())) { + emitSkillCall(event, toolUse); + return; + } + AgentSkillBinding activeBinding = skillContext.getActiveToolBinding(toolUse.getName()); + if (activeBinding != null) { + emitSkillStepCall(toolUse, activeBinding); + } + } + + private void observePostActing(PostActingEvent event) { + ToolResultBlock result = event.getToolResult(); + ToolUseBlock toolUse = event.getToolUse(); + String toolName = result == null ? toolName(toolUse) : result.getName(); + if (skillContext.isSkillLoadTool(toolName)) { + emitSkillResult(result, toolUse, event.getToolkit()); + return; + } + syncSkillState(toolName, event.getToolkit()); + AgentSkillBinding activeBinding = skillContext.getActiveToolBinding(toolName); + if (activeBinding != null) { + emitSkillStepResult(result, toolUse, activeBinding); + } + } + + private void emitSkillCall(PreActingEvent event, ToolUseBlock toolUse) { + AgentSkillLoadCall call = skillContext.rememberLoadCall(toolUse.getId(), toolUse.getInput()); + if (!skillContext.markLoadCallEmitted(toolUse.getId())) { + return; + } + AgentRuntimeEvent runtimeEvent = eventBridge.event(AgentRuntimeEventType.SKILL_CALL); + runtimeEvent.setToolCallId(toolUse.getId()); + runtimeEvent.getPayload().put("toolCallId", toolUse.getId()); + runtimeEvent.getPayload().put("toolName", toolUse.getName()); + runtimeEvent.getPayload().put("input", toolUse.getInput()); + runtimeEvent.getPayload().put("status", "RUNNING"); + runtimeEvent.getPayload().put("source", "HOOK"); + runtimeEvent.getPayload().put("phase", "PRE_ACTING"); + appendSkillLoadPayload(runtimeEvent, call); + runtimeEvent.getMetadata().putAll(nullToEmpty(toolUse.getMetadata())); + eventBridge.emit(runtimeEvent); + } + + private void emitSkillResult(ToolResultBlock result, ToolUseBlock toolUse, Toolkit toolkit) { + String toolCallId = result == null ? toolCallId(toolUse) : result.getId(); + AgentSkillLoadCall call = skillContext.removeLoadCall(toolCallId); + boolean active = syncSkillState(call, toolkit); + AgentRuntimeEvent runtimeEvent = eventBridge.event(skillResultType(result, active)); + runtimeEvent.setToolCallId(toolCallId); + runtimeEvent.getPayload().put("toolCallId", toolCallId); + runtimeEvent.getPayload().put("toolName", result == null ? toolName(toolUse) : result.getName()); + runtimeEvent.getPayload().put("text", resultText(result)); + runtimeEvent.getPayload().put("status", runtimeEvent.getEventType() == AgentRuntimeEventType.SKILL_RESULT + ? "SUCCESS" + : "FAILED"); + runtimeEvent.getPayload().put("success", runtimeEvent.getEventType() == AgentRuntimeEventType.SKILL_RESULT); + runtimeEvent.getPayload().put("suspended", result != null && result.isSuspended()); + runtimeEvent.getPayload().put("active", active); + runtimeEvent.getPayload().put("source", "HOOK"); + runtimeEvent.getPayload().put("phase", "POST_ACTING"); + if (result != null) { + runtimeEvent.getMetadata().putAll(nullToEmpty(result.getMetadata())); + } + appendSkillLoadPayload(runtimeEvent, call); + eventBridge.emit(runtimeEvent); + } + + private void emitSkillStepCall(ToolUseBlock toolUse, AgentSkillBinding binding) { + AgentRuntimeEvent runtimeEvent = eventBridge.event(AgentRuntimeEventType.SKILL_STEP); + runtimeEvent.setToolCallId(toolUse.getId()); + runtimeEvent.getPayload().put("toolCallId", toolUse.getId()); + runtimeEvent.getPayload().put("name", toolUse.getName()); + runtimeEvent.getPayload().put("toolName", toolUse.getName()); + runtimeEvent.getPayload().put("input", toolUse.getInput()); + runtimeEvent.getPayload().put("content", toolUse.getContent()); + runtimeEvent.getPayload().put("stepType", "TOOL_CALL"); + runtimeEvent.getPayload().put("stepName", toolUse.getName()); + runtimeEvent.getPayload().put("status", "RUNNING"); + runtimeEvent.getPayload().put("source", "HOOK"); + runtimeEvent.getPayload().put("phase", "PRE_ACTING"); + appendSkillPayload(runtimeEvent.getPayload(), binding); + appendSkillPayload(runtimeEvent.getMetadata(), binding); + runtimeEvent.getMetadata().putAll(nullToEmpty(toolUse.getMetadata())); + eventBridge.emit(runtimeEvent); + } + + private void emitSkillStepResult(ToolResultBlock result, + ToolUseBlock toolUse, + AgentSkillBinding binding) { + AgentRuntimeEvent runtimeEvent = eventBridge.event(AgentRuntimeEventType.SKILL_STEP); + String toolCallId = result == null ? toolCallId(toolUse) : result.getId(); + String toolName = result == null ? toolName(toolUse) : result.getName(); + runtimeEvent.setToolCallId(toolCallId); + runtimeEvent.getPayload().put("toolCallId", toolCallId); + runtimeEvent.getPayload().put("name", toolName); + runtimeEvent.getPayload().put("toolName", toolName); + runtimeEvent.getPayload().put("text", resultText(result)); + runtimeEvent.getPayload().put("suspended", result != null && result.isSuspended()); + runtimeEvent.getPayload().put("stepType", "TOOL_RESULT"); + runtimeEvent.getPayload().put("stepName", toolName); + runtimeEvent.getPayload().put("status", success(result) ? "SUCCESS" : "FAILED"); + runtimeEvent.getPayload().put("success", success(result)); + runtimeEvent.getPayload().put("source", "HOOK"); + runtimeEvent.getPayload().put("phase", "POST_ACTING"); + appendSkillPayload(runtimeEvent.getPayload(), binding); + appendSkillPayload(runtimeEvent.getMetadata(), binding); + if (result != null) { + runtimeEvent.getMetadata().putAll(nullToEmpty(result.getMetadata())); + } + eventBridge.emit(runtimeEvent); + } + + private AgentRuntimeEventType skillResultType(ToolResultBlock result, boolean active) { + return success(result) && active ? AgentRuntimeEventType.SKILL_RESULT : AgentRuntimeEventType.SKILL_FAILED; + } + + private boolean success(ToolResultBlock result) { + if (result == null) { + return false; + } + Object success = result.getMetadata() == null ? null : result.getMetadata().get("success"); + return !(success instanceof Boolean) || Boolean.TRUE.equals(success); + } + + private String resultText(ToolResultBlock result) { + if (result == null || result.getOutput() == null || result.getOutput().isEmpty()) { + return ""; + } + StringJoiner joiner = new StringJoiner("\n"); + for (ContentBlock output : result.getOutput()) { + if (output instanceof TextBlock textBlock && textBlock.getText() != null) { + joiner.add(textBlock.getText()); + } else if (output != null) { + joiner.add(output.toString()); + } + } + return joiner.toString(); + } + + private boolean syncSkillState(AgentSkillLoadCall call, Toolkit toolkit) { + if (call == null) { + return false; + } + boolean active = isAgentScopeSkillActive(call.getSkillId(), toolkit); + skillContext.syncSkillActive(call.getSkillId(), active); + return active; + } + + private void syncSkillState(String toolName, Toolkit toolkit) { + AgentSkillBinding binding = skillContext.getToolBinding(toolName); + if (binding == null) { + return; + } + skillContext.syncSkillActive(binding.getSkillId(), isAgentScopeSkillActive(binding.getSkillId(), toolkit)); + } + + private boolean isAgentScopeSkillActive(String skillId, Toolkit toolkit) { + if (skillId == null || skillId.isBlank()) { + return false; + } + if (skillBox != null && skillBox.isSkillActive(skillId)) { + return true; + } + return toolkit != null && toolkit.getActiveGroups().contains(skillToolGroupName(skillId)); + } + + private String skillToolGroupName(String skillId) { + return skillId + "_skill_tools"; + } + + private void appendSkillLoadPayload(AgentRuntimeEvent event, AgentSkillLoadCall call) { + if (call == null) { + return; + } + event.getPayload().put("skillId", call.getSkillId()); + event.getPayload().put("skillName", call.getSkillName()); + event.getPayload().put("skillBoxId", call.getSkillBoxId()); + event.getPayload().put("path", call.getPath()); + event.getMetadata().put("skillId", call.getSkillId()); + event.getMetadata().put("skillName", call.getSkillName()); + event.getMetadata().put("skillBoxId", call.getSkillBoxId()); + } + + private void appendSkillPayload(Map target, AgentSkillBinding binding) { + if (binding == null || target == null) { + return; + } + target.put("skillId", binding.getSkillId()); + target.put("skillName", binding.getSkillName()); + target.put("skillBoxId", binding.getSkillBoxId()); + } + + private String toolCallId(ToolUseBlock toolUse) { + return toolUse == null ? null : toolUse.getId(); + } + + private String toolName(ToolUseBlock toolUse) { + return toolUse == null ? null : toolUse.getName(); + } + + private Map nullToEmpty(Map map) { + return map == null ? new LinkedHashMap<>() : map; + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/observer/ToolExecutionObserver.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/observer/ToolExecutionObserver.java new file mode 100644 index 0000000..1d6b466 --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/event/observer/ToolExecutionObserver.java @@ -0,0 +1,154 @@ +package com.easyagents.agent.runtime.event.observer; + +import com.easyagents.agent.runtime.event.AgentRuntimeEvent; +import com.easyagents.agent.runtime.event.AgentRuntimeEventBridge; +import com.easyagents.agent.runtime.event.AgentRuntimeEventType; +import com.easyagents.agent.runtime.event.AgentRuntimeObserver; +import com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext; +import io.agentscope.core.hook.HookEvent; +import io.agentscope.core.hook.PostActingEvent; +import io.agentscope.core.hook.PreActingEvent; +import io.agentscope.core.message.ContentBlock; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.message.ToolUseBlock; +import reactor.core.publisher.Mono; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 监听 AgentScope 原生工具执行生命周期,并发射工具状态旁路事件。 + * + *

该观察器复用 {@link AgentRuntimeEventType#TOOL_CALL} 和 + * {@link AgentRuntimeEventType#TOOL_RESULT},用于 EasyFlow 展示工具开始与完成状态。 + * 它不修改 AgentScope HookEvent,也不写入模型上下文。

+ */ +public class ToolExecutionObserver implements AgentRuntimeObserver { + + private final AgentRuntimeEventBridge eventBridge; + private final AgentSkillRuntimeContext skillContext; + + /** + * 创建工具执行观察器。 + * + * @param eventBridge 旁路事件桥 + */ + public ToolExecutionObserver(AgentRuntimeEventBridge eventBridge) { + this(eventBridge, null); + } + + /** + * 创建工具执行观察器。 + * + * @param eventBridge 旁路事件桥 + * @param skillContext Skill 上下文,用于跳过由 SkillExecutionObserver 处理的工具 + */ + public ToolExecutionObserver(AgentRuntimeEventBridge eventBridge, + AgentSkillRuntimeContext skillContext) { + this.eventBridge = eventBridge; + this.skillContext = skillContext; + } + + /** + * 观察工具执行前后事件。 + * + * @param event AgentScope Hook 事件 + * @return 完成信号 + */ + @Override + public Mono observe(HookEvent event) { + if (event instanceof PreActingEvent preActingEvent) { + emitToolCall(preActingEvent); + return Mono.empty(); + } + if (event instanceof PostActingEvent postActingEvent) { + emitToolResult(postActingEvent); + } + return Mono.empty(); + } + + private void emitToolCall(PreActingEvent event) { + ToolUseBlock toolUse = event.getToolUse(); + if (toolUse == null) { + return; + } + if (isSkillTool(toolUse.getName())) { + return; + } + AgentRuntimeEvent runtimeEvent = eventBridge.event(AgentRuntimeEventType.TOOL_CALL); + runtimeEvent.setToolCallId(toolUse.getId()); + runtimeEvent.getPayload().put("toolCallId", toolUse.getId()); + runtimeEvent.getPayload().put("name", toolUse.getName()); + runtimeEvent.getPayload().put("toolName", toolUse.getName()); + runtimeEvent.getPayload().put("input", toolUse.getInput()); + runtimeEvent.getPayload().put("content", toolUse.getContent()); + runtimeEvent.getPayload().put("status", "RUNNING"); + runtimeEvent.getPayload().put("source", "HOOK"); + runtimeEvent.getPayload().put("phase", "PRE_ACTING"); + runtimeEvent.getMetadata().putAll(nullToEmpty(toolUse.getMetadata())); + eventBridge.emit(runtimeEvent); + } + + private void emitToolResult(PostActingEvent event) { + ToolResultBlock result = event.getToolResult(); + ToolUseBlock toolUse = event.getToolUse(); + if (result == null && toolUse == null) { + return; + } + String toolCallId = result == null ? toolUse.getId() : result.getId(); + String toolName = result == null ? toolUse.getName() : result.getName(); + if (isSkillTool(toolName)) { + return; + } + AgentRuntimeEvent runtimeEvent = eventBridge.event(AgentRuntimeEventType.TOOL_RESULT); + runtimeEvent.setToolCallId(toolCallId); + runtimeEvent.getPayload().put("toolCallId", toolCallId); + runtimeEvent.getPayload().put("name", toolName); + runtimeEvent.getPayload().put("toolName", toolName); + runtimeEvent.getPayload().put("text", resultText(result)); + runtimeEvent.getPayload().put("suspended", result != null && result.isSuspended()); + runtimeEvent.getPayload().put("status", success(result) ? "SUCCESS" : "FAILED"); + runtimeEvent.getPayload().put("success", success(result)); + runtimeEvent.getPayload().put("source", "HOOK"); + runtimeEvent.getPayload().put("phase", "POST_ACTING"); + if (result != null) { + runtimeEvent.getMetadata().putAll(nullToEmpty(result.getMetadata())); + } + eventBridge.emit(runtimeEvent); + } + + private boolean success(ToolResultBlock result) { + if (result == null) { + return false; + } + Object success = result.getMetadata() == null ? null : result.getMetadata().get("success"); + return !(success instanceof Boolean) || Boolean.TRUE.equals(success); + } + + private String resultText(ToolResultBlock result) { + if (result == null || result.getOutput() == null || result.getOutput().isEmpty()) { + return ""; + } + StringBuilder builder = new StringBuilder(); + for (ContentBlock block : result.getOutput()) { + if (block instanceof TextBlock textBlock) { + builder.append(textBlock.getText()); + } else { + builder.append(block); + } + } + return builder.toString(); + } + + private Map nullToEmpty(Map map) { + return map == null ? new LinkedHashMap<>() : map; + } + + private boolean isSkillTool(String toolName) { + if (skillContext == null) { + return false; + } + return skillContext.isSkillLoadTool(toolName) || skillContext.getActiveToolBinding(toolName) != null; + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/hitl/AgentToolApprovalCoordinator.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/hitl/AgentToolApprovalCoordinator.java index 7b1313b..340b527 100644 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/hitl/AgentToolApprovalCoordinator.java +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/hitl/AgentToolApprovalCoordinator.java @@ -1,5 +1,7 @@ package com.easyagents.agent.runtime.hitl; +import com.easyagents.agent.runtime.AgentResumeRequest; +import com.easyagents.agent.runtime.AgentRuntimeException; import reactor.core.publisher.Mono; import java.time.Instant; @@ -86,14 +88,14 @@ public class AgentToolApprovalCoordinator { * 等待指定审批令牌的响应。 * * @param resumeToken 恢复令牌 - * @return 审批响应 + * @return 恢复请求 */ - public Mono await(AgentResumeToken resumeToken) { + public Mono await(AgentResumeToken resumeToken) { if (!enabled) { - AgentToolApprovalResponse response = new AgentToolApprovalResponse(); - response.setResumeToken(resumeToken); - response.setApproved(true); - return Mono.just(response); + AgentResumeRequest request = new AgentResumeRequest(); + request.setResumeToken(resumeToken); + request.setApproved(true); + return Mono.just(request); } if (resumeToken == null || resumeToken.getValue() == null || resumeToken.getValue().isBlank()) { return Mono.error(new AgentToolApprovalRejectedException("缺少审批令牌。")); @@ -106,19 +108,31 @@ public class AgentToolApprovalCoordinator { } /** - * 提交审批响应。 + * 消费恢复请求对应的待审批状态。 * - * @param response 审批响应 + *

该方法用于有状态 runtime 的 HITL resume。第一版 pending state 仅保存在 + * 当前进程内存中,因此消费成功后会立即移除 token,避免重复恢复。

+ * + * @param request 恢复请求 + * @return 待审批状态 */ - public void submit(AgentToolApprovalResponse response) { - if (!enabled || response == null || response.getResumeToken() == null - || response.getResumeToken().getValue() == null) { - return; + public AgentPendingState consume(AgentResumeRequest request) { + if (request == null || request.getResumeToken() == null + || request.getResumeToken().getValue() == null + || request.getResumeToken().getValue().isBlank()) { + throw new AgentRuntimeException("Agent resume token is required."); } - PendingApproval pendingApproval = approvals.get(response.getResumeToken().getValue()); - if (pendingApproval != null) { - pendingApproval.future.complete(response); + if (!enabled) { + AgentPendingState state = new AgentPendingState(); + state.setResumeToken(request.getResumeToken()); + return state; } + PendingApproval pendingApproval = approvals.remove(request.getResumeToken().getValue()); + if (pendingApproval == null) { + throw new AgentRuntimeException("Agent resume token is invalid or expired."); + } + pendingApproval.future.complete(request); + return pendingApproval.state; } /** @@ -131,11 +145,11 @@ public class AgentToolApprovalCoordinator { return; } for (PendingApproval pendingApproval : approvals.values()) { - AgentToolApprovalResponse response = new AgentToolApprovalResponse(); - response.setResumeToken(pendingApproval.state.getResumeToken()); - response.setApproved(false); - response.setRejectReason(reason); - pendingApproval.future.complete(response); + AgentResumeRequest request = new AgentResumeRequest(); + request.setResumeToken(pendingApproval.state.getResumeToken()); + request.setApproved(false); + request.setRejectReason(reason); + pendingApproval.future.complete(request); } approvals.clear(); } @@ -151,9 +165,9 @@ public class AgentToolApprovalCoordinator { private static class PendingApproval { private final AgentPendingState state; - private final CompletableFuture future; + private final CompletableFuture future; - private PendingApproval(AgentPendingState state, CompletableFuture future) { + private PendingApproval(AgentPendingState state, CompletableFuture future) { this.state = Objects.requireNonNull(state, "state"); this.future = Objects.requireNonNull(future, "future"); } diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/knowledge/citation/AgentKnowledgeCitationMatcher.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/knowledge/citation/AgentKnowledgeCitationMatcher.java new file mode 100644 index 0000000..9e331b9 --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/knowledge/citation/AgentKnowledgeCitationMatcher.java @@ -0,0 +1,24 @@ +package com.easyagents.agent.runtime.knowledge.citation; + +import com.easyagents.agent.runtime.message.AgentKnowledgeReference; + +import java.util.Collection; +import java.util.List; + +/** + * 知识库引用匹配器。 + * + *

该接口只负责从最终答案和本轮检索候选中选择可展示的引用。实现不应修改答案文本, + * 也不应把引用写入 AgentScope memory/session。

+ */ +public interface AgentKnowledgeCitationMatcher { + + /** + * 匹配最终答案中可由候选知识片段支撑的引用。 + * + * @param answerText 最终答案文本 + * @param candidates 本轮检索候选引用 + * @return 匹配到的知识库引用 + */ + List match(String answerText, Collection candidates); +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/knowledge/citation/HeuristicKnowledgeCitationMatcher.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/knowledge/citation/HeuristicKnowledgeCitationMatcher.java new file mode 100644 index 0000000..e8df3a0 --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/knowledge/citation/HeuristicKnowledgeCitationMatcher.java @@ -0,0 +1,204 @@ +package com.easyagents.agent.runtime.knowledge.citation; + +import com.easyagents.agent.runtime.message.AgentKnowledgeReference; + +import java.util.*; + +/** + * 基于文本证据的启发式知识库引用匹配器。 + * + *

该实现用于模型没有显式输出引用 ID 的场景。它只能说明答案文本与某些检索片段 + * 存在较强文本支撑关系,不能证明 LLM 真实使用了该片段。因此匹配策略保持保守: + * 宁可少返回引用,也不为了“看起来有引用”而强行猜测。

+ */ +public class HeuristicKnowledgeCitationMatcher implements AgentKnowledgeCitationMatcher { + + private static final int MIN_NORMALIZED_ANSWER_LENGTH = 4; + private static final int MIN_CONTENT_LENGTH = 6; + private static final int MAX_REFERENCES = 5; + private static final double MIN_SUPPORT_SCORE = 0.42D; + + /** + * 根据最终答案和候选知识片段匹配引用。 + * + * @param answerText 最终答案文本 + * @param candidates 本轮检索候选引用 + * @return 高置信引用列表 + */ + @Override + public List match(String answerText, Collection candidates) { + if (candidates == null || candidates.isEmpty()) { + return List.of(); + } + String normalizedAnswer = normalize(answerText); + if (normalizedAnswer.length() < MIN_NORMALIZED_ANSWER_LENGTH) { + return List.of(); + } + List scoredReferences = new ArrayList<>(); + for (AgentKnowledgeReference candidate : candidates) { + double supportScore = supportScore(normalizedAnswer, normalize(candidate == null ? null : candidate.getChunkContent())); + if (supportScore >= MIN_SUPPORT_SCORE) { + scoredReferences.add(new ScoredKnowledgeReference(candidate, supportScore)); + } + } + scoredReferences.sort(Comparator.comparingDouble(ScoredKnowledgeReference::score).reversed()); + return scoredReferences.stream() + .limit(MAX_REFERENCES) + .map(ScoredKnowledgeReference::reference) + .toList(); + } + + /** + * 计算答案与候选片段之间的文本支撑分。 + * + * @param normalizedAnswer 归一化后的答案 + * @param normalizedContent 归一化后的候选片段 + * @return 支撑分 + */ + private double supportScore(String normalizedAnswer, String normalizedContent) { + if (normalizedAnswer.isBlank() + || normalizedContent.isBlank() + || normalizedContent.length() < MIN_CONTENT_LENGTH) { + return 0D; + } + if (normalizedContent.contains(normalizedAnswer)) { + return 1D; + } + int longestCommon = longestCommonSubstringLength(normalizedAnswer, normalizedContent); + double gramOverlap = gramOverlapRatio(charGrams(normalizedAnswer, 2), charGrams(normalizedContent, 2)); + double numericCoverage = numericCoverage(normalizedAnswer, normalizedContent); + + // 长连续文本片段比零散关键词更能说明答案来自该 chunk。 + if (longestCommon >= 8 && gramOverlap >= 0.12D) { + double numericBoost = numericCoverage >= 0.8D ? 0.12D : numericCoverage * 0.08D; + return Math.max(0.55D, gramOverlap + Math.min(0.35D, longestCommon / 60D) + numericBoost); + } + if (gramOverlap >= 0.28D && longestCommon >= 5) { + return gramOverlap + Math.min(0.25D, longestCommon / 80D); + } + return gramOverlap * 0.7D + Math.min(0.2D, longestCommon / 80D) + numericCoverage * 0.08D; + } + + /** + * 归一化用于引用匹配的文本。 + * + * @param text 原始文本 + * @return 低噪声文本 + */ + private String normalize(String text) { + if (text == null) { + return ""; + } + return text.toLowerCase() + .replace('至', '到') + .replaceAll("[^\\p{IsHan}a-z0-9]", ""); + } + + /** + * 生成字符 ngram。 + * + * @param text 文本 + * @param size ngram 长度 + * @return ngram 集合 + */ + private Set charGrams(String text, int size) { + Set grams = new HashSet<>(); + if (text == null || text.length() < size) { + return grams; + } + for (int i = 0; i <= text.length() - size; i++) { + grams.add(text.substring(i, i + size)); + } + return grams; + } + + /** + * 计算答案 ngram 被候选片段覆盖的比例。 + * + * @param answerGrams 答案 ngram + * @param contentGrams 候选片段 ngram + * @return 覆盖比例 + */ + private double gramOverlapRatio(Set answerGrams, Set contentGrams) { + if (answerGrams == null || answerGrams.isEmpty() || contentGrams == null || contentGrams.isEmpty()) { + return 0D; + } + int matched = 0; + for (String gram : answerGrams) { + if (contentGrams.contains(gram)) { + matched++; + } + } + return (double) matched / (double) answerGrams.size(); + } + + /** + * 计算答案中的数字片段被候选片段覆盖的比例。 + * + *

数字只能作为弱加权因素,不能单独造成高置信引用,避免日期、章节号等噪声误判。

+ * + * @param normalizedAnswer 归一化后的答案 + * @param normalizedContent 归一化后的候选片段 + * @return 数字覆盖比例 + */ + private double numericCoverage(String normalizedAnswer, String normalizedContent) { + List numbers = extractNumericTokens(normalizedAnswer); + if (numbers.isEmpty()) { + return 0D; + } + int matched = 0; + for (String number : numbers) { + if (normalizedContent.contains(number)) { + matched++; + } + } + return (double) matched / (double) numbers.size(); + } + + /** + * 提取数字片段。 + * + * @param text 文本 + * @return 数字片段 + */ + private List extractNumericTokens(String text) { + List numbers = new ArrayList<>(); + if (text == null || text.isBlank()) { + return numbers; + } + java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("\\d+").matcher(text); + while (matcher.find()) { + numbers.add(matcher.group()); + } + return numbers; + } + + /** + * 计算最长公共子串长度。 + * + * @param first 第一段文本 + * @param second 第二段文本 + * @return 最长公共子串长度 + */ + private int longestCommonSubstringLength(String first, String second) { + if (first == null || second == null || first.isEmpty() || second.isEmpty()) { + return 0; + } + int[] previous = new int[second.length() + 1]; + int max = 0; + for (int i = 1; i <= first.length(); i++) { + int[] current = new int[second.length() + 1]; + for (int j = 1; j <= second.length(); j++) { + if (first.charAt(i - 1) == second.charAt(j - 1)) { + current[j] = previous[j - 1] + 1; + max = Math.max(max, current[j]); + } + } + previous = current; + } + return max; + } + + private record ScoredKnowledgeReference(AgentKnowledgeReference reference, double score) { + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/memory/AgentMemoryCompressionParameter.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/memory/AgentMemoryCompressionParameter.java index d7cc12d..0a65aa4 100644 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/memory/AgentMemoryCompressionParameter.java +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/memory/AgentMemoryCompressionParameter.java @@ -6,12 +6,12 @@ package com.easyagents.agent.runtime.memory; public class AgentMemoryCompressionParameter { private boolean enabled = true; - private int msgThreshold = 20; + private Integer msgThreshold; private int lastKeep = 8; - private double tokenRatio = 0.7D; - private long maxToken = 12000L; + private Double tokenRatio; + private Long maxToken; private long largePayloadThreshold = 2048L; - private int minCompressionTokenThreshold = 1000; + private Integer minCompressionTokenThreshold; private double currentRoundCompressionRatio = 0.5D; private int minConsecutiveToolMessages = 4; @@ -38,7 +38,7 @@ public class AgentMemoryCompressionParameter { * * @return 消息阈值 */ - public int getMsgThreshold() { + public Integer getMsgThreshold() { return msgThreshold; } @@ -48,7 +48,7 @@ public class AgentMemoryCompressionParameter { * @param msgThreshold 消息阈值 */ public void setMsgThreshold(int msgThreshold) { - this.msgThreshold = msgThreshold <= 0 ? 20 : msgThreshold; + this.msgThreshold = msgThreshold <= 0 ? null : msgThreshold; } /** @@ -74,7 +74,7 @@ public class AgentMemoryCompressionParameter { * * @return Token 比例 */ - public double getTokenRatio() { + public Double getTokenRatio() { return tokenRatio; } @@ -84,7 +84,7 @@ public class AgentMemoryCompressionParameter { * @param tokenRatio Token 比例 */ public void setTokenRatio(double tokenRatio) { - this.tokenRatio = tokenRatio <= 0D ? 0.7D : tokenRatio; + this.tokenRatio = tokenRatio <= 0D ? null : tokenRatio; } /** @@ -92,7 +92,7 @@ public class AgentMemoryCompressionParameter { * * @return 最大 Token 数 */ - public long getMaxToken() { + public Long getMaxToken() { return maxToken; } @@ -102,7 +102,7 @@ public class AgentMemoryCompressionParameter { * @param maxToken 最大 Token 数 */ public void setMaxToken(long maxToken) { - this.maxToken = maxToken <= 0L ? 12000L : maxToken; + this.maxToken = maxToken <= 0L ? null : maxToken; } /** @@ -128,7 +128,7 @@ public class AgentMemoryCompressionParameter { * * @return 最小压缩 Token 阈值 */ - public int getMinCompressionTokenThreshold() { + public Integer getMinCompressionTokenThreshold() { return minCompressionTokenThreshold; } @@ -138,7 +138,7 @@ public class AgentMemoryCompressionParameter { * @param minCompressionTokenThreshold 最小压缩 Token 阈值 */ public void setMinCompressionTokenThreshold(int minCompressionTokenThreshold) { - this.minCompressionTokenThreshold = Math.max(0, minCompressionTokenThreshold); + this.minCompressionTokenThreshold = minCompressionTokenThreshold <= 0 ? null : minCompressionTokenThreshold; } /** diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/memory/AgentMemorySnapshot.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/memory/AgentMemorySnapshot.java index 6c03ade..ac3917a 100644 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/memory/AgentMemorySnapshot.java +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/memory/AgentMemorySnapshot.java @@ -31,7 +31,7 @@ public class AgentMemorySnapshot { } /** - * 添加one message。 + * 添加消息。 * * @param message 消息 */ diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/message/AgentKnowledgeReference.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/message/AgentKnowledgeReference.java index 081cc3a..ba26ed4 100644 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/message/AgentKnowledgeReference.java +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/message/AgentKnowledgeReference.java @@ -13,6 +13,7 @@ public class AgentKnowledgeReference { private String documentId; private String documentName; private String chunkId; + private String chunkContent; private String sourceUri; private Double score; private Map metadata = new LinkedHashMap<>(); @@ -107,6 +108,24 @@ public class AgentKnowledgeReference { this.chunkId = chunkId; } + /** + * 获取命中分片原文。 + * + * @return 命中分片原文 + */ + public String getChunkContent() { + return chunkContent; + } + + /** + * 设置命中分片原文。 + * + * @param chunkContent 命中分片原文 + */ + public void setChunkContent(String chunkContent) { + this.chunkContent = chunkContent; + } + /** * 获取来源 URI。 * diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/AgentRuntimeState.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/AgentRuntimeState.java deleted file mode 100644 index bea03c0..0000000 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/AgentRuntimeState.java +++ /dev/null @@ -1,86 +0,0 @@ -package com.easyagents.agent.runtime.persistence; - -import java.util.LinkedHashMap; -import java.util.Map; - -/** - * 运行时状态项。 - * - *

MVP 中 value 是 JVM 对象态,不是稳定的传输格式, - * 非内存 {@link AgentSessionStore} 实现必须显式完成序列化, - * 再写入外部存储。

- */ -public class AgentRuntimeState { - - private String name; - private Object value; - private Map metadata = new LinkedHashMap<>(); - - /** - * 创建a 状态项。 - * - * @param name 状态名称 - * @param value 状态值 - * @return 状态项 - */ - public static AgentRuntimeState of(String name, Object value) { - AgentRuntimeState state = new AgentRuntimeState(); - state.setName(name); - state.setValue(value); - return state; - } - - /** - * 获取状态名称。 - * - * @return 状态名称 - */ - public String getName() { - return name; - } - - /** - * 设置状态名称。 - * - * @param name 状态名称 - */ - public void setName(String name) { - this.name = name; - } - - /** - * 获取状态值。 - * - * @return 状态值 - */ - public Object getValue() { - return value; - } - - /** - * 设置状态值。 - * - * @param value 状态值 - */ - public void setValue(Object value) { - this.value = value; - } - - /** - * 获取元数据。 - * - * @return 元数据 - */ - public Map getMetadata() { - return metadata; - } - - /** - * 设置元数据。 - * - * @param metadata 元数据 - */ - public void setMetadata(Map metadata) { - this.metadata = metadata == null ? new LinkedHashMap<>() : metadata; - } -} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/AgentSessionStore.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/AgentSessionStore.java deleted file mode 100644 index f2a8d2a..0000000 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/AgentSessionStore.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.easyagents.agent.runtime.persistence; - -import java.util.List; -import java.util.Optional; -import java.util.Set; - -/** - * 用于 AgentScope 类运行时会话状态的存储 SPI。 - * - */ -public interface AgentSessionStore { - - /** - * 保存one state value。 - * - * @param sessionKey 会话键 - * @param name 状态名称 - * @param state 状态值 - */ - void save(String sessionKey, String name, AgentRuntimeState state); - - /** - * 保存a state list。 - * - * @param sessionKey 会话键 - * @param name 状态名称 - * @param states 状态列表 - */ - void saveList(String sessionKey, String name, List states); - - /** - * 获取one state value。 - * - * @param sessionKey 会话键 - * @param name 状态名称 - * @return 可选状态 - */ - Optional get(String sessionKey, String name); - - /** - * 获取a state list。 - * - * @param sessionKey 会话键 - * @param name 状态名称 - * @return 状态列表 - */ - List getList(String sessionKey, String name); - - /** - * 返回是否session key exists。 - * - * @param sessionKey 会话键 - * @return 存在时为 true - */ - boolean exists(String sessionKey); - - /** - * 删除a session key。 - * - * @param sessionKey 会话键 - */ - void delete(String sessionKey); - - /** - * 列出会话键列表。 - * - * @return 会话键列表 - */ - Set listSessionKeys(); -} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/AgentConversationRecorder.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/conversation/AgentConversationRecorder.java similarity index 57% rename from easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/AgentConversationRecorder.java rename to easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/conversation/AgentConversationRecorder.java index 2c93307..3cfae04 100644 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/AgentConversationRecorder.java +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/conversation/AgentConversationRecorder.java @@ -1,6 +1,6 @@ -package com.easyagents.agent.runtime.persistence; +package com.easyagents.agent.runtime.persistence.conversation; -import com.easyagents.agent.runtime.AgentRunRequest; +import com.easyagents.agent.runtime.AgentRuntimeExecutionContext; import com.easyagents.agent.runtime.event.AgentRuntimeEvent; /** @@ -14,5 +14,5 @@ public interface AgentConversationRecorder { * @param request 运行请求 * @param event 运行时事件 */ - void record(AgentRunRequest request, AgentRuntimeEvent event); + void record(AgentRuntimeExecutionContext request, AgentRuntimeEvent event); } diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/conversation/noop/NoopAgentConversationRecorder.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/conversation/noop/NoopAgentConversationRecorder.java new file mode 100644 index 0000000..36c9dcb --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/conversation/noop/NoopAgentConversationRecorder.java @@ -0,0 +1,16 @@ +package com.easyagents.agent.runtime.persistence.conversation.noop; + +import com.easyagents.agent.runtime.AgentRuntimeExecutionContext; +import com.easyagents.agent.runtime.event.AgentRuntimeEvent; +import com.easyagents.agent.runtime.persistence.conversation.AgentConversationRecorder; + +/** + * 空操作会话记录器。 + */ +public enum NoopAgentConversationRecorder implements AgentConversationRecorder { + INSTANCE; + + @Override + public void record(AgentRuntimeExecutionContext request, AgentRuntimeEvent event) { + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/json/AgentSessionStateCodec.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/json/AgentSessionStateCodec.java deleted file mode 100644 index c9ff425..0000000 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/json/AgentSessionStateCodec.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.easyagents.agent.runtime.persistence.json; - -import com.easyagents.agent.runtime.persistence.AgentRuntimeState; -import com.easyagents.agent.runtime.persistence.AgentSessionStoreException; -import io.agentscope.core.state.State; -import io.agentscope.core.util.JsonUtils; - -/** - * AgentScope 状态的 JSON 编解码器。 - */ -public class AgentSessionStateCodec { - - /** - * 将状态对象编码为可持久化记录。 - * - * @param state 状态对象 - * @return 可持久化记录 - */ - public SerializedAgentRuntimeState encode(AgentRuntimeState state) { - if (state == null || state.getValue() == null) { - return null; - } - Object value = state.getValue(); - if (!(value instanceof State)) { - throw new AgentSessionStoreException("Only AgentScope State values can be serialized: " - + value.getClass().getName()); - } - SerializedAgentRuntimeState serialized = new SerializedAgentRuntimeState(); - serialized.setName(state.getName()); - serialized.setStateClassName(value.getClass().getName()); - serialized.setStateJson(JsonUtils.getJsonCodec().toJson(value)); - serialized.setMetadata(state.getMetadata()); - return serialized; - } - - /** - * 将持久化记录解码为运行时状态。 - * - * @param serialized 可持久化记录 - * @return 运行时状态 - */ - @SuppressWarnings("unchecked") - public AgentRuntimeState decode(SerializedAgentRuntimeState serialized) { - if (serialized == null) { - return null; - } - try { - Class clazz = Class.forName(serialized.getStateClassName()); - if (!State.class.isAssignableFrom(clazz)) { - throw new AgentSessionStoreException("Serialized state class is not AgentScope State: " - + serialized.getStateClassName()); - } - State value = JsonUtils.getJsonCodec().fromJson(serialized.getStateJson(), (Class) clazz); - AgentRuntimeState state = AgentRuntimeState.of(serialized.getName(), value); - state.setMetadata(serialized.getMetadata()); - return state; - } catch (ClassNotFoundException e) { - throw new AgentSessionStoreException("Serialized state class not found: " - + serialized.getStateClassName(), e); - } - } -} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/json/AgentSessionStoreBackend.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/json/AgentSessionStoreBackend.java deleted file mode 100644 index 0cb27dd..0000000 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/json/AgentSessionStoreBackend.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.easyagents.agent.runtime.persistence.json; - -import java.util.List; -import java.util.Optional; -import java.util.Set; - -/** - * JSON 会话状态的底层键值存储后端。 - */ -public interface AgentSessionStoreBackend { - - /** - * 保存单个状态记录。 - * - * @param sessionKey 会话键 - * @param name 状态名称 - * @param state 状态记录 - */ - void save(String sessionKey, String name, SerializedAgentRuntimeState state); - - /** - * 保存状态记录列表。 - * - * @param sessionKey 会话键 - * @param name 状态名称 - * @param states 状态记录列表 - */ - void saveList(String sessionKey, String name, List states); - - /** - * 获取单个状态记录。 - * - * @param sessionKey 会话键 - * @param name 状态名称 - * @return 状态记录 - */ - Optional get(String sessionKey, String name); - - /** - * 获取状态记录列表。 - * - * @param sessionKey 会话键 - * @param name 状态名称 - * @return 状态记录列表 - */ - List getList(String sessionKey, String name); - - /** - * 判断会话是否存在。 - * - * @param sessionKey 会话键 - * @return 存在时为 true - */ - boolean exists(String sessionKey); - - /** - * 删除会话。 - * - * @param sessionKey 会话键 - */ - void delete(String sessionKey); - - /** - * 列出全部会话键。 - * - * @return 会话键集合 - */ - Set listSessionKeys(); -} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/json/FileAgentSessionStoreBackend.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/json/FileAgentSessionStoreBackend.java deleted file mode 100644 index 938c6b0..0000000 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/json/FileAgentSessionStoreBackend.java +++ /dev/null @@ -1,172 +0,0 @@ -package com.easyagents.agent.runtime.persistence.json; - -import com.easyagents.agent.runtime.persistence.AgentSessionStoreException; -import io.agentscope.core.util.JsonUtils; - -import java.io.BufferedReader; -import java.io.BufferedWriter; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardOpenOption; -import java.util.*; -import java.util.regex.Pattern; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -/** - * 基于本地文件的 JSON 会话状态后端。 - */ -public class FileAgentSessionStoreBackend implements AgentSessionStoreBackend { - - private static final Pattern SAFE_NAME = Pattern.compile("[A-Za-z0-9._-]+"); - private final Path rootDirectory; - - /** - * 创建文件后端。 - * - * @param rootDirectory 根目录 - */ - public FileAgentSessionStoreBackend(Path rootDirectory) { - if (rootDirectory == null) { - throw new AgentSessionStoreException("Agent session root directory is required."); - } - this.rootDirectory = rootDirectory; - try { - Files.createDirectories(rootDirectory); - } catch (IOException e) { - throw new AgentSessionStoreException("Failed to create agent session root directory: " + rootDirectory, e); - } - } - - @Override - public void save(String sessionKey, String name, SerializedAgentRuntimeState state) { - if (state == null) { - return; - } - Path path = statePath(sessionKey, name); - createParent(path); - try { - Files.writeString(path, JsonUtils.getJsonCodec().toPrettyJson(state), StandardCharsets.UTF_8, - StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); - } catch (IOException e) { - throw new AgentSessionStoreException("Failed to save agent session state: " + sessionKey + "/" + name, e); - } - } - - @Override - public void saveList(String sessionKey, String name, List states) { - Path path = listPath(sessionKey, name); - createParent(path); - try (BufferedWriter writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8, - StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)) { - if (states == null) { - return; - } - for (SerializedAgentRuntimeState state : states) { - writer.write(JsonUtils.getJsonCodec().toJson(state)); - writer.newLine(); - } - } catch (IOException e) { - throw new AgentSessionStoreException("Failed to save agent session state list: " + sessionKey + "/" + name, e); - } - } - - @Override - public Optional get(String sessionKey, String name) { - Path path = statePath(sessionKey, name); - if (!Files.exists(path)) { - return Optional.empty(); - } - try { - String json = Files.readString(path, StandardCharsets.UTF_8); - return Optional.of(JsonUtils.getJsonCodec().fromJson(json, SerializedAgentRuntimeState.class)); - } catch (IOException e) { - throw new AgentSessionStoreException("Failed to load agent session state: " + sessionKey + "/" + name, e); - } - } - - @Override - public List getList(String sessionKey, String name) { - Path path = listPath(sessionKey, name); - if (!Files.exists(path)) { - return List.of(); - } - List states = new ArrayList<>(); - try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { - String line; - while ((line = reader.readLine()) != null) { - if (!line.isBlank()) { - states.add(JsonUtils.getJsonCodec().fromJson(line, SerializedAgentRuntimeState.class)); - } - } - return states; - } catch (IOException e) { - throw new AgentSessionStoreException("Failed to load agent session state list: " + sessionKey + "/" + name, e); - } - } - - @Override - public boolean exists(String sessionKey) { - return Files.isDirectory(sessionDirectory(sessionKey)); - } - - @Override - public void delete(String sessionKey) { - Path directory = sessionDirectory(sessionKey); - if (!Files.exists(directory)) { - return; - } - try (Stream paths = Files.walk(directory)) { - List ordered = paths.sorted(Comparator.reverseOrder()).collect(Collectors.toList()); - for (Path path : ordered) { - Files.deleteIfExists(path); - } - } catch (IOException e) { - throw new AgentSessionStoreException("Failed to delete agent session: " + sessionKey, e); - } - } - - @Override - public Set listSessionKeys() { - if (!Files.exists(rootDirectory)) { - return Set.of(); - } - try (Stream paths = Files.list(rootDirectory)) { - return paths.filter(Files::isDirectory) - .map(path -> path.getFileName().toString()) - .collect(Collectors.toCollection(LinkedHashSet::new)); - } catch (IOException e) { - throw new AgentSessionStoreException("Failed to list agent sessions.", e); - } - } - - private Path statePath(String sessionKey, String name) { - return sessionDirectory(sessionKey).resolve(safeName(name) + ".json"); - } - - private Path listPath(String sessionKey, String name) { - return sessionDirectory(sessionKey).resolve(safeName(name) + ".jsonl"); - } - - private Path sessionDirectory(String sessionKey) { - return rootDirectory.resolve(safeName(sessionKey)); - } - - private String safeName(String value) { - if (value == null || value.isBlank() || !SAFE_NAME.matcher(value).matches() - || value.contains("..")) { - throw new AgentSessionStoreException("Unsafe agent session storage key: " + value); - } - return value; - } - - private void createParent(Path path) { - try { - Files.createDirectories(path.getParent()); - } catch (IOException e) { - throw new AgentSessionStoreException("Failed to create agent session directory: " + path.getParent(), e); - } - } -} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/json/JsonAgentSessionStore.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/json/JsonAgentSessionStore.java deleted file mode 100644 index fc13d20..0000000 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/json/JsonAgentSessionStore.java +++ /dev/null @@ -1,106 +0,0 @@ -package com.easyagents.agent.runtime.persistence.json; - -import com.easyagents.agent.runtime.persistence.AgentRuntimeState; -import com.easyagents.agent.runtime.persistence.AgentSessionStore; -import com.easyagents.agent.runtime.persistence.AgentSessionStoreException; - -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.Set; - -/** - * 基于 JSON 记录的默认非内存会话存储。 - */ -public class JsonAgentSessionStore implements AgentSessionStore { - - private final AgentSessionStoreBackend backend; - private final AgentSessionStateCodec codec; - - /** - * 创建文件型 JSON 会话存储。 - * - * @param rootDirectory 根目录 - */ - public JsonAgentSessionStore(Path rootDirectory) { - this(new FileAgentSessionStoreBackend(rootDirectory), new AgentSessionStateCodec()); - } - - /** - * 创建可替换后端的 JSON 会话存储。 - * - * @param backend 底层存储后端 - */ - public JsonAgentSessionStore(AgentSessionStoreBackend backend) { - this(backend, new AgentSessionStateCodec()); - } - - /** - * 创建可替换后端和编解码器的 JSON 会话存储。 - * - * @param backend 底层存储后端 - * @param codec 状态编解码器 - */ - public JsonAgentSessionStore(AgentSessionStoreBackend backend, AgentSessionStateCodec codec) { - if (backend == null) { - throw new AgentSessionStoreException("Agent session store backend is required."); - } - this.backend = backend; - this.codec = codec == null ? new AgentSessionStateCodec() : codec; - } - - @Override - public void save(String sessionKey, String name, AgentRuntimeState state) { - SerializedAgentRuntimeState serialized = codec.encode(state); - if (serialized != null) { - backend.save(sessionKey, name, serialized); - } - } - - @Override - public void saveList(String sessionKey, String name, List states) { - List serialized = new ArrayList<>(); - if (states != null) { - for (AgentRuntimeState state : states) { - SerializedAgentRuntimeState item = codec.encode(state); - if (item != null) { - serialized.add(item); - } - } - } - backend.saveList(sessionKey, name, serialized); - } - - @Override - public Optional get(String sessionKey, String name) { - return backend.get(sessionKey, name).map(codec::decode); - } - - @Override - public List getList(String sessionKey, String name) { - List states = new ArrayList<>(); - for (SerializedAgentRuntimeState item : backend.getList(sessionKey, name)) { - AgentRuntimeState state = codec.decode(item); - if (state != null) { - states.add(state); - } - } - return states; - } - - @Override - public boolean exists(String sessionKey) { - return backend.exists(sessionKey); - } - - @Override - public void delete(String sessionKey) { - backend.delete(sessionKey); - } - - @Override - public Set listSessionKeys() { - return backend.listSessionKeys(); - } -} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/json/SerializedAgentRuntimeState.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/json/SerializedAgentRuntimeState.java deleted file mode 100644 index 2cb01fc..0000000 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/json/SerializedAgentRuntimeState.java +++ /dev/null @@ -1,107 +0,0 @@ -package com.easyagents.agent.runtime.persistence.json; - -import java.time.Instant; -import java.util.LinkedHashMap; -import java.util.Map; - -/** - * 可跨进程持久化的 AgentScope 状态记录。 - */ -public class SerializedAgentRuntimeState { - - private String name; - private String stateClassName; - private String stateJson; - private Map metadata = new LinkedHashMap<>(); - private Instant createdAt = Instant.now(); - - /** - * 获取状态名称。 - * - * @return 状态名称 - */ - public String getName() { - return name; - } - - /** - * 设置状态名称。 - * - * @param name 状态名称 - */ - public void setName(String name) { - this.name = name; - } - - /** - * 获取状态类名。 - * - * @return 状态类名 - */ - public String getStateClassName() { - return stateClassName; - } - - /** - * 设置状态类名。 - * - * @param stateClassName 状态类名 - */ - public void setStateClassName(String stateClassName) { - this.stateClassName = stateClassName; - } - - /** - * 获取状态 JSON。 - * - * @return 状态 JSON - */ - public String getStateJson() { - return stateJson; - } - - /** - * 设置状态 JSON。 - * - * @param stateJson 状态 JSON - */ - public void setStateJson(String stateJson) { - this.stateJson = stateJson; - } - - /** - * 获取元数据。 - * - * @return 元数据 - */ - public Map getMetadata() { - return metadata; - } - - /** - * 设置元数据。 - * - * @param metadata 元数据 - */ - public void setMetadata(Map metadata) { - this.metadata = metadata == null ? new LinkedHashMap<>() : metadata; - } - - /** - * 获取创建时间。 - * - * @return 创建时间 - */ - public Instant getCreatedAt() { - return createdAt; - } - - /** - * 设置创建时间。 - * - * @param createdAt 创建时间 - */ - public void setCreatedAt(Instant createdAt) { - this.createdAt = createdAt == null ? Instant.now() : createdAt; - } -} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/memory/InMemoryAgentSessionStore.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/memory/InMemoryAgentSessionStore.java deleted file mode 100644 index 9470f89..0000000 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/memory/InMemoryAgentSessionStore.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.easyagents.agent.runtime.persistence.memory; - -import com.easyagents.agent.runtime.persistence.AgentRuntimeState; -import com.easyagents.agent.runtime.persistence.AgentSessionStore; - -import java.util.*; -import java.util.concurrent.ConcurrentHashMap; - -/** - * 用于测试和单节点 MVP 的内存会话存储。 - */ -public class InMemoryAgentSessionStore implements AgentSessionStore { - - private final Map>> states = new ConcurrentHashMap<>(); - - @Override - public void save(String sessionKey, String name, AgentRuntimeState state) { - if (sessionKey == null || name == null || state == null) { - return; - } - saveList(sessionKey, name, List.of(state)); - } - - @Override - public void saveList(String sessionKey, String name, List states) { - if (sessionKey == null || name == null) { - return; - } - this.states.computeIfAbsent(sessionKey, key -> new ConcurrentHashMap<>()) - .put(name, states == null ? new ArrayList<>() : new ArrayList<>(states)); - } - - @Override - public Optional get(String sessionKey, String name) { - List list = getList(sessionKey, name); - return list.isEmpty() ? Optional.empty() : Optional.ofNullable(list.get(0)); - } - - @Override - public List getList(String sessionKey, String name) { - Map> byName = states.getOrDefault(sessionKey, new LinkedHashMap<>()); - return new ArrayList<>(byName.getOrDefault(name, new ArrayList<>())); - } - - @Override - public boolean exists(String sessionKey) { - return states.containsKey(sessionKey); - } - - @Override - public void delete(String sessionKey) { - states.remove(sessionKey); - } - - @Override - public Set listSessionKeys() { - return new LinkedHashSet<>(states.keySet()); - } -} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/noop/NoopAgentConversationRecorder.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/noop/NoopAgentConversationRecorder.java deleted file mode 100644 index 433aba0..0000000 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/noop/NoopAgentConversationRecorder.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.easyagents.agent.runtime.persistence.noop; - -import com.easyagents.agent.runtime.AgentRunRequest; -import com.easyagents.agent.runtime.event.AgentRuntimeEvent; -import com.easyagents.agent.runtime.persistence.AgentConversationRecorder; - -/** - * 空操作会话记录器。 - */ -public enum NoopAgentConversationRecorder implements AgentConversationRecorder { - INSTANCE; - - @Override - public void record(AgentRunRequest request, AgentRuntimeEvent event) { - } -} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/session/AgentSessionStore.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/session/AgentSessionStore.java new file mode 100644 index 0000000..a992eaa --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/session/AgentSessionStore.java @@ -0,0 +1,79 @@ +package com.easyagents.agent.runtime.persistence.session; + +import io.agentscope.core.state.State; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +/** + * 智能体会话状态存储接口。 + *

+ * 该接口承接 AgentScope {@code Session} 的持久化读写能力,用于按会话键保存和恢复 + * Agent 运行过程中产生的状态,例如 memory、toolkit、plan notebook 或有状态工具数据。 + * 具体实现可以基于内存、Redis、MySQL 等存储介质。 + */ +public interface AgentSessionStore { + + /** + * 保存单个状态项。 + * + * @param sessionKey 会话键 + * @param name 状态名称 + * @param state 状态值 + */ + void save(String sessionKey, String name, State state); + + /** + * 保存状态列表。 + * + * @param sessionKey 会话键 + * @param name 状态名称 + * @param states 状态列表 + */ + void saveList(String sessionKey, String name, List states); + + /** + * 获取单个状态项。 + * + * @param sessionKey 会话键 + * @param name 状态名称 + * @param type 状态类型 + * @param 状态类型 + * @return 可选状态 + */ + Optional get(String sessionKey, String name, Class type); + + /** + * 获取状态列表。 + * + * @param sessionKey 会话键 + * @param name 状态名称 + * @param itemType 状态元素类型 + * @param 状态元素类型 + * @return 状态列表 + */ + List getList(String sessionKey, String name, Class itemType); + + /** + * 判断会话键是否存在。 + * + * @param sessionKey 会话键 + * @return 存在时为 true + */ + boolean exists(String sessionKey); + + /** + * 删除指定会话键下的全部状态。 + * + * @param sessionKey 会话键 + */ + void delete(String sessionKey); + + /** + * 列出当前存储中的会话键。 + * + * @return 会话键列表 + */ + Set listSessionKeys(); +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/AgentSessionStoreException.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/session/AgentSessionStoreException.java similarity index 91% rename from easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/AgentSessionStoreException.java rename to easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/session/AgentSessionStoreException.java index c743c8f..dd8dfb5 100644 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/AgentSessionStoreException.java +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/session/AgentSessionStoreException.java @@ -1,4 +1,4 @@ -package com.easyagents.agent.runtime.persistence; +package com.easyagents.agent.runtime.persistence.session; import com.easyagents.agent.runtime.AgentRuntimeException; diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/session/memory/InMemoryAgentSessionStore.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/session/memory/InMemoryAgentSessionStore.java new file mode 100644 index 0000000..87a0569 --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/session/memory/InMemoryAgentSessionStore.java @@ -0,0 +1,69 @@ +package com.easyagents.agent.runtime.persistence.session.memory; + +import com.easyagents.agent.runtime.persistence.session.AgentSessionStore; +import io.agentscope.core.state.State; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 用于测试和单节点 MVP 的内存会话存储。 + */ +public class InMemoryAgentSessionStore implements AgentSessionStore { + + private final Map>> states = new ConcurrentHashMap<>(); + + @Override + public void save(String sessionKey, String name, State state) { + if (sessionKey == null || name == null || state == null) { + return; + } + saveList(sessionKey, name, List.of(state)); + } + + @Override + public void saveList(String sessionKey, String name, List states) { + if (sessionKey == null || name == null) { + return; + } + this.states.computeIfAbsent(sessionKey, key -> new ConcurrentHashMap<>()) + .put(name, states == null ? new ArrayList<>() : new ArrayList<>(states)); + } + + @Override + public Optional get(String sessionKey, String name, Class type) { + List list = getList(sessionKey, name, type); + return list.isEmpty() ? Optional.empty() : Optional.of(list.get(0)); + } + + @Override + public List getList(String sessionKey, String name, Class itemType) { + Map> byName = states.getOrDefault(sessionKey, new LinkedHashMap<>()); + List values = byName.getOrDefault(name, new ArrayList<>()); + if (itemType == null) { + return new ArrayList<>(); + } + List typedValues = new ArrayList<>(); + for (State value : values) { + if (itemType.isInstance(value)) { + typedValues.add(itemType.cast(value)); + } + } + return typedValues; + } + + @Override + public boolean exists(String sessionKey) { + return states.containsKey(sessionKey); + } + + @Override + public void delete(String sessionKey) { + states.remove(sessionKey); + } + + @Override + public Set listSessionKeys() { + return new LinkedHashSet<>(states.keySet()); + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/noop/NoopAgentSessionStore.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/session/noop/NoopAgentSessionStore.java similarity index 54% rename from easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/noop/NoopAgentSessionStore.java rename to easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/session/noop/NoopAgentSessionStore.java index 36240e8..1205da8 100644 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/noop/NoopAgentSessionStore.java +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/persistence/session/noop/NoopAgentSessionStore.java @@ -1,7 +1,7 @@ -package com.easyagents.agent.runtime.persistence.noop; +package com.easyagents.agent.runtime.persistence.session.noop; -import com.easyagents.agent.runtime.persistence.AgentRuntimeState; -import com.easyagents.agent.runtime.persistence.AgentSessionStore; +import com.easyagents.agent.runtime.persistence.session.AgentSessionStore; +import io.agentscope.core.state.State; import java.util.Collections; import java.util.List; @@ -15,20 +15,20 @@ public enum NoopAgentSessionStore implements AgentSessionStore { INSTANCE; @Override - public void save(String sessionKey, String name, AgentRuntimeState state) { + public void save(String sessionKey, String name, State state) { } @Override - public void saveList(String sessionKey, String name, List states) { + public void saveList(String sessionKey, String name, List states) { } @Override - public Optional get(String sessionKey, String name) { + public Optional get(String sessionKey, String name, Class type) { return Optional.empty(); } @Override - public List getList(String sessionKey, String name) { + public List getList(String sessionKey, String name, Class itemType) { return Collections.emptyList(); } diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/skill/AgentSkillRuntimeContext.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/skill/AgentSkillRuntimeContext.java index ca63f24..9e76667 100644 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/skill/AgentSkillRuntimeContext.java +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/skill/AgentSkillRuntimeContext.java @@ -111,6 +111,26 @@ public class AgentSkillRuntimeContext { } } + /** + * 按 AgentScope 的真实 Skill 激活状态同步本地旁路上下文。 + * + *

该状态只用于 Easy-Agents 判断旁路展示事件归属,不参与 AgentScope + * memory/session,也不决定工具是否真的可被模型调用。

+ * + * @param skillId Skill ID + * @param active 是否激活 + */ + public void syncSkillActive(String skillId, boolean active) { + if (skillId == null || skillId.isBlank()) { + return; + } + if (active) { + activeSkills.put(skillId, true); + return; + } + activeSkills.remove(skillId); + } + /** * 判断 Skill 是否已激活。 * diff --git a/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/agentscope/AgentScopeAdapterTest.java b/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/agentscope/AgentScopeAdapterTest.java deleted file mode 100644 index ea713f1..0000000 --- a/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/agentscope/AgentScopeAdapterTest.java +++ /dev/null @@ -1,951 +0,0 @@ -package com.easyagents.agent.runtime.agentscope; - -import com.easyagents.agent.runtime.AgentDefinition; -import com.easyagents.agent.runtime.AgentRunRequest; -import com.easyagents.agent.runtime.event.AgentRuntimeEvent; -import com.easyagents.agent.runtime.event.AgentRuntimeEventType; -import com.easyagents.agent.runtime.hitl.AgentResumeToken; -import com.easyagents.agent.runtime.hitl.AgentToolApprovalCoordinator; -import com.easyagents.agent.runtime.hitl.AgentToolApprovalRequest; -import com.easyagents.agent.runtime.hitl.AgentToolApprovalResponse; -import com.easyagents.agent.runtime.knowledge.AgentKnowledgeDocument; -import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetrievalResult; -import com.easyagents.agent.runtime.knowledge.AgentKnowledgeSpec; -import com.easyagents.agent.runtime.memory.AgentMemoryCompressionParameter; -import com.easyagents.agent.runtime.memory.AgentMemoryPolicy; -import com.easyagents.agent.runtime.memory.AgentMemorySnapshot; -import com.easyagents.agent.runtime.message.*; -import com.easyagents.agent.runtime.model.AgentModelProviderType; -import com.easyagents.agent.runtime.model.AgentModelSpec; -import com.easyagents.agent.runtime.persistence.AgentPersistencePolicy; -import com.easyagents.agent.runtime.persistence.AgentRuntimeState; -import com.easyagents.agent.runtime.persistence.json.JsonAgentSessionStore; -import com.easyagents.agent.runtime.persistence.memory.InMemoryAgentSessionStore; -import com.easyagents.agent.runtime.skill.AgentSkillBoxSpec; -import com.easyagents.agent.runtime.skill.AgentSkillSpec; -import com.easyagents.agent.runtime.tool.AgentToolResult; -import com.easyagents.agent.runtime.tool.AgentToolSpec; -import io.agentscope.core.ReActAgent; -import io.agentscope.core.agent.Event; -import io.agentscope.core.agent.EventType; -import io.agentscope.core.hook.ErrorEvent; -import io.agentscope.core.hook.HookEvent; -import io.agentscope.core.hook.ReasoningChunkEvent; -import io.agentscope.core.memory.autocontext.AutoContextConfig; -import io.agentscope.core.message.*; -import io.agentscope.core.model.AnthropicChatModel; -import io.agentscope.core.model.GeminiChatModel; -import io.agentscope.core.model.OpenAIChatModel; -import io.agentscope.core.rag.Knowledge; -import io.agentscope.core.rag.model.Document; -import io.agentscope.core.rag.model.RetrieveConfig; -import io.agentscope.core.session.Session; -import io.agentscope.core.skill.SkillBox; -import io.agentscope.core.tool.AgentTool; -import io.agentscope.core.tool.ToolCallParam; -import io.agentscope.core.tool.Toolkit; -import org.junit.Assert; -import org.junit.Test; -import reactor.core.publisher.Sinks; - -import java.lang.reflect.Field; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; - -/** - * 在不发起真实模型网络调用的情况下测试 AgentScope 适配行为。 - */ -public class AgentScopeAdapterTest { - - @Test - public void shouldBuildReActAgentFromDefinition() { - AgentRunRequest request = request(); - AgentScopeReActRuntime runtime = fakeRuntime(); - - ReActAgent agent = runtime.buildAgent(request, Sinks.many().unicast().onBackpressureBuffer()); - - Assert.assertEquals("agent-name", agent.getName()); - Assert.assertEquals("system", agent.getSysPrompt()); - Assert.assertEquals(3, agent.getMaxIters()); - Assert.assertNotNull(agent.getMemory()); - Assert.assertNotNull(agent.getToolkit().getTool("echo")); - } - - @Test - public void shouldRegisterAndCallDynamicTool() { - AgentRunRequest request = request(); - AgentToolSpec toolSpec = request.getAgentDefinition().getToolSpecs().get(0); - AgentTool tool = new AgentScopeToolAdapter().adapt(toolSpec, request.getToolInvokers().get("echo"), request); - ToolUseBlock block = ToolUseBlock.builder() - .id("call-1") - .name("echo") - .input(Map.of("text", "hello")) - .build(); - - String result = ((TextBlock) tool.callAsync(ToolCallParam.builder() - .toolUseBlock(block) - .input(Map.of("text", "hello")) - .build()) - .block() - .getOutput() - .get(0)).getText(); - - Assert.assertTrue(result.contains("hello")); - } - - @Test - public void shouldEmitToolCallAndResultEventsFromToolkitCallback() throws Exception { - AgentRunRequest request = request(); - Sinks.Many sink = Sinks.many().unicast().onBackpressureBuffer(); - ReActAgent agent = fakeRuntime().buildAgent(request, sink); - CompletableFuture> eventsFuture = sink.asFlux().take(2).collectList().toFuture(); - ToolUseBlock block = ToolUseBlock.builder() - .id("call-2") - .name("echo") - .input(Map.of("text", "hello")) - .build(); - - agent.getToolkit().getTool("echo").callAsync(ToolCallParam.builder() - .toolUseBlock(block) - .input(Map.of("text", "hello")) - .build()) - .block(); - List events = eventsFuture.get(3, TimeUnit.SECONDS); - - Assert.assertTrue(events.stream().anyMatch(event -> event.getEventType() == AgentRuntimeEventType.TOOL_CALL)); - Assert.assertTrue(events.stream().anyMatch(event -> event.getEventType() == AgentRuntimeEventType.TOOL_RESULT)); - } - - @Test - public void shouldEmitNormalToolEventBeforeSkillActivated() throws Exception { - AgentRunRequest request = requestWithSkillBoundTool(); - Sinks.Many sink = Sinks.many().unicast().onBackpressureBuffer(); - ReActAgent agent = fakeRuntime().buildAgent(request, sink); - CompletableFuture> eventsFuture = sink.asFlux().take(2).collectList().toFuture(); - ToolUseBlock block = ToolUseBlock.builder() - .id("call-skill-tool") - .name("echo") - .input(Map.of("text", "hello")) - .build(); - - agent.getToolkit().getTool("echo").callAsync(ToolCallParam.builder() - .toolUseBlock(block) - .input(Map.of("text", "hello")) - .build()) - .block(); - List events = eventsFuture.get(3, TimeUnit.SECONDS); - - Assert.assertEquals(AgentRuntimeEventType.TOOL_CALL, events.get(0).getEventType()); - Assert.assertEquals(AgentRuntimeEventType.TOOL_RESULT, events.get(1).getEventType()); - Assert.assertFalse(events.get(0).getPayload().containsKey("skillId")); - } - - @Test - public void shouldEmitSkillStepAfterSkillActivated() throws Exception { - AgentRunRequest request = requestWithSkillBoundTool(); - com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext skillContext = - com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext.from(request.getAgentDefinition().getSkillBoxSpec()); - skillContext.activateSkill("skill-1"); - Sinks.Many sink = Sinks.many().unicast().onBackpressureBuffer(); - AgentTool tool = new AgentScopeToolAdapter().adapt(request.getAgentDefinition().getToolSpecs().get(0), - request.getToolInvokers().get("echo"), request, AgentToolApprovalCoordinator.disabled(), sink, - skillContext, skillContext.getToolBinding("echo")); - CompletableFuture> eventsFuture = sink.asFlux().take(2).collectList().toFuture(); - - tool.callAsync(ToolCallParam.builder() - .toolUseBlock(ToolUseBlock.builder() - .id("call-skill-tool") - .name("echo") - .input(Map.of("text", "hello")) - .build()) - .input(Map.of("text", "hello")) - .build()) - .block(); - List events = eventsFuture.get(3, TimeUnit.SECONDS); - - Assert.assertTrue(events.stream().allMatch(event -> event.getEventType() == AgentRuntimeEventType.SKILL_STEP)); - Assert.assertEquals("skill-1", events.get(0).getPayload().get("skillId")); - Assert.assertEquals("TOOL_CALL", events.get(0).getPayload().get("stepType")); - Assert.assertEquals("TOOL_RESULT", events.get(1).getPayload().get("stepType")); - } - - @Test - public void shouldEmitToolApprovalWhenToolRequiresApproval() throws Exception { - AgentRunRequest enabled = request(); - AgentToolApprovalRequest approvalRequest = new AgentToolApprovalRequest(); - approvalRequest.setApprovalPrompt("确认执行?"); - enabled.getAgentDefinition().getToolSpecs().get(0).setApprovalRequired(true); - enabled.getAgentDefinition().getToolSpecs().get(0).setApprovalRequest(approvalRequest); - AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled(); - Sinks.Many sink = Sinks.many().unicast().onBackpressureBuffer(); - AgentTool tool = new AgentScopeToolAdapter().adapt(enabled.getAgentDefinition().getToolSpecs().get(0), - enabled.getToolInvokers().get("echo"), enabled, coordinator, sink); - java.util.concurrent.CountDownLatch approvalLatch = new java.util.concurrent.CountDownLatch(1); - java.util.concurrent.CopyOnWriteArrayList events = new java.util.concurrent.CopyOnWriteArrayList<>(); - sink.asFlux().subscribe(event -> { - events.add(event); - if (event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED) { - approvalLatch.countDown(); - } - }); - tool.callAsync(ToolCallParam.builder() - .toolUseBlock(ToolUseBlock.builder() - .id("call-hitl") - .name("echo") - .input(Map.of("text", "hello")) - .build()) - .input(Map.of("text", "hello")) - .build()) - .subscribe(result -> { - }, error -> { - }); - Assert.assertTrue(approvalLatch.await(5, TimeUnit.SECONDS)); - AgentRuntimeEvent approval = events.stream() - .filter(event -> event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED) - .findFirst() - .orElseThrow(AssertionError::new); - Assert.assertEquals("确认执行?", approval.getPayload().get("approvalPrompt")); - Assert.assertNotNull(approval.getPayload().get("resumeToken")); - } - - @Test - public void shouldContinueToolExecutionAfterApproval() throws Exception { - AgentRunRequest request = request(); - AgentToolApprovalRequest approvalRequest = new AgentToolApprovalRequest(); - approvalRequest.setApprovalPrompt("确认执行?"); - request.getAgentDefinition().getToolSpecs().get(0).setApprovalRequired(true); - request.getAgentDefinition().getToolSpecs().get(0).setApprovalRequest(approvalRequest); - - AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled(); - Sinks.Many sink = Sinks.many().unicast().onBackpressureBuffer(); - AgentTool tool = new AgentScopeToolAdapter().adapt(request.getAgentDefinition().getToolSpecs().get(0), - request.getToolInvokers().get("echo"), request, coordinator, sink); - java.util.List events = new java.util.concurrent.CopyOnWriteArrayList<>(); - java.util.concurrent.CountDownLatch approvalLatch = new java.util.concurrent.CountDownLatch(1); - java.util.concurrent.atomic.AtomicReference errorRef = new java.util.concurrent.atomic.AtomicReference<>(); - sink.asFlux().subscribe(event -> { - events.add(event); - if (event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED) { - approvalLatch.countDown(); - } - }); - java.util.concurrent.CompletableFuture future = tool.callAsync( - ToolCallParam.builder() - .toolUseBlock(ToolUseBlock.builder() - .id("call-hitl") - .name("echo") - .input(Map.of("text", "hello")) - .build()) - .input(Map.of("text", "hello")) - .build()).toFuture(); - Assert.assertTrue(approvalLatch.await(5, TimeUnit.SECONDS)); - AgentRuntimeEvent approval = events.stream() - .filter(event -> event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED) - .findFirst() - .orElseThrow(AssertionError::new); - - AgentToolApprovalResponse response = new AgentToolApprovalResponse(); - AgentResumeToken token = AgentResumeToken.create(); - token.setValue(String.valueOf(approval.getPayload().get("resumeToken"))); - response.setResumeToken(token); - response.setApproved(true); - - coordinator.submit(response); - io.agentscope.core.message.ToolResultBlock resultBlock = future.get(5, TimeUnit.SECONDS); - Assert.assertTrue(events.stream().anyMatch(event -> event.getEventType() == AgentRuntimeEventType.TOOL_RESULT)); - Assert.assertNotNull(resultBlock); - } - - @Test - public void shouldCancelToolExecutionAfterRejection() throws Exception { - AgentRunRequest request = request(); - AgentToolApprovalRequest approvalRequest = new AgentToolApprovalRequest(); - approvalRequest.setApprovalPrompt("确认执行?"); - request.getAgentDefinition().getToolSpecs().get(0).setApprovalRequired(true); - request.getAgentDefinition().getToolSpecs().get(0).setApprovalRequest(approvalRequest); - - AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled(); - Sinks.Many sink = Sinks.many().unicast().onBackpressureBuffer(); - AgentTool tool = new AgentScopeToolAdapter().adapt(request.getAgentDefinition().getToolSpecs().get(0), - request.getToolInvokers().get("echo"), request, coordinator, sink); - java.util.List events = new java.util.concurrent.CopyOnWriteArrayList<>(); - sink.asFlux().subscribe(events::add); - java.util.concurrent.CompletableFuture future = tool.callAsync( - ToolCallParam.builder() - .toolUseBlock(ToolUseBlock.builder() - .id("call-hitl") - .name("echo") - .input(Map.of("text", "hello")) - .build()) - .input(Map.of("text", "hello")) - .build()).toFuture(); - AgentRuntimeEvent approval = null; - long deadline = System.currentTimeMillis() + 5000L; - while (System.currentTimeMillis() < deadline) { - approval = events.stream() - .filter(event -> event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED) - .findFirst() - .orElse(null); - if (approval != null) { - break; - } - Thread.sleep(50L); - } - if (approval == null) { - throw new AssertionError("未收到审批事件"); - } - - AgentToolApprovalResponse response = new AgentToolApprovalResponse(); - AgentResumeToken token = AgentResumeToken.create(); - token.setValue(String.valueOf(approval.getPayload().get("resumeToken"))); - response.setResumeToken(token); - response.setApproved(false); - response.setRejectReason("拒绝执行"); - - coordinator.submit(response); - try { - future.get(5, TimeUnit.SECONDS); - Assert.fail("拒绝后不应返回工具结果。"); - } catch (java.util.concurrent.ExecutionException exception) { - Assert.assertTrue(exception.getCause() instanceof com.easyagents.agent.runtime.hitl.AgentToolApprovalRejectedException); - } - Assert.assertFalse(events.stream().anyMatch(event -> event.getEventType() == AgentRuntimeEventType.TOOL_RESULT)); - } - - @Test - public void shouldNotDuplicateToolApprovalFromRuntimeEventMapper() throws Exception { - AgentRunRequest request = request(); - AgentScopeReActRuntime runtime = fakeRuntime(); - java.lang.reflect.Method method = AgentScopeReActRuntime.class.getDeclaredMethod( - "mapEvent", AgentRunRequest.class, Event.class); - method.setAccessible(true); - ToolResultBlock resultBlock = ToolResultBlock.builder() - .id("call-hitl") - .name("echo") - .output(TextBlock.builder().text("need confirm").build()) - .metadata(Map.of(ToolResultBlock.METADATA_SUSPENDED, true)) - .build(); - Msg message = Msg.builder() - .role(MsgRole.TOOL) - .content(resultBlock) - .build(); - Event event = new Event(EventType.TOOL_RESULT, message, true); - - @SuppressWarnings("unchecked") - List events = (List) method.invoke(runtime, request, event); - - Assert.assertTrue(events.isEmpty()); - } - - @Test - public void shouldMapSkillLoadEventsFromAgentScopeToolEvents() throws Exception { - AgentRunRequest request = requestWithSkillBoundTool(); - AgentScopeReActRuntime runtime = fakeRuntime(); - java.lang.reflect.Method method = AgentScopeReActRuntime.class.getDeclaredMethod( - "mapEvent", AgentRunRequest.class, Event.class, - com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext.class); - method.setAccessible(true); - com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext skillContext = - com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext.from(request.getAgentDefinition().getSkillBoxSpec()); - ToolUseBlock toolUseBlock = ToolUseBlock.builder() - .id("skill-call-1") - .name(com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext.LOAD_SKILL_TOOL_NAME) - .input(Map.of("skillId", "skill-1", "path", "SKILL.md")) - .build(); - Msg reasoning = Msg.builder() - .id("reasoning-msg") - .role(MsgRole.ASSISTANT) - .content(toolUseBlock) - .build(); - - @SuppressWarnings("unchecked") - List callEvents = (List) method.invoke( - runtime, request, new Event(EventType.REASONING, reasoning, false), skillContext); - - ToolResultBlock resultBlock = ToolResultBlock.of( - "skill-call-1", - com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext.LOAD_SKILL_TOOL_NAME, - TextBlock.builder().text("Successfully loaded skill: skill-1").build()); - Msg toolResult = Msg.builder() - .id("tool-msg") - .role(MsgRole.TOOL) - .content(resultBlock) - .build(); - @SuppressWarnings("unchecked") - List resultEvents = (List) method.invoke( - runtime, request, new Event(EventType.TOOL_RESULT, toolResult, true), skillContext); - - Assert.assertTrue(callEvents.stream().anyMatch(event -> event.getEventType() == AgentRuntimeEventType.SKILL_CALL)); - AgentRuntimeEvent resultEvent = resultEvents.get(0); - Assert.assertEquals(AgentRuntimeEventType.SKILL_RESULT, resultEvent.getEventType()); - Assert.assertEquals("skill-1", resultEvent.getPayload().get("skillId")); - Assert.assertEquals("SKILL.md", resultEvent.getPayload().get("path")); - Assert.assertTrue(skillContext.isSkillActive("skill-1")); - } - - @Test - public void shouldAttachStructuredMessageOnLastAgentResult() throws Exception { - AgentRunRequest request = request(); - AgentScopeReActRuntime runtime = fakeRuntime(); - java.lang.reflect.Method method = AgentScopeReActRuntime.class.getDeclaredMethod( - "mapEvent", AgentRunRequest.class, Event.class); - method.setAccessible(true); - Msg message = Msg.builder() - .id("assistant-msg") - .role(MsgRole.ASSISTANT) - .content(TextBlock.builder().text("final answer").build()) - .build(); - Event event = new Event(EventType.AGENT_RESULT, message, true); - - @SuppressWarnings("unchecked") - List events = (List) method.invoke(runtime, request, event); - - AgentRuntimeEvent delta = events.get(0); - Assert.assertEquals(AgentRuntimeEventType.MESSAGE_DELTA, delta.getEventType()); - Assert.assertNotNull(delta.getMessage()); - Assert.assertEquals("assistant-msg", delta.getMessage().getMessageId()); - } - - @Test - public void shouldNotEmitReasoningFromHook() { - AgentRunRequest request = request(); - AgentScopeEventHook hook = new AgentScopeEventHook(request); - ReActAgent agent = fakeRuntime().buildAgent(request, Sinks.many().unicast().onBackpressureBuffer()); - Msg chunk = Msg.builder() - .role(MsgRole.ASSISTANT) - .textContent("thinking") - .build(); - - hook.onEvent(new ReasoningChunkEvent(agent, "run-1", null, chunk, chunk)).block(); - Assert.assertNotNull(chunk); - } - - @Test - public void shouldAggregateKnowledgeAndPreserveMetadata() { - AgentRunRequest request = request(); - AgentKnowledgeSpec second = knowledge("kb-2", "KB2"); - second.getMetadata().put("permissionScope", "team"); - request.getAgentDefinition().getKnowledgeSpecs().add(second); - request.getKnowledgeRetrievers().put("kb-2", retrievalRequest -> { - AgentKnowledgeDocument document = doc("doc-2", "chunk-2", "content-2", 0.95D); - document.getMetadata().put("pageNo", 2); - return AgentKnowledgeRetrievalResult.of(List.of(document)); - }); - Knowledge knowledge = new AgentScopeKnowledgeAdapter().createAggregateKnowledge(request); - - List documents = knowledge.retrieve("query", RetrieveConfig.builder().limit(2).scoreThreshold(0D).build()).block(); - - Assert.assertEquals(2, documents.size()); - Map payload = documents.get(0).getPayload(); - Assert.assertTrue(payload.containsKey("knowledgeMetadata")); - Assert.assertTrue(payload.containsKey("documentMetadata")); - } - - @Test - public void shouldEmitKnowledgeRetrievalEvent() throws Exception { - AgentRunRequest request = request(); - Sinks.Many sink = Sinks.many().unicast().onBackpressureBuffer(); - Knowledge knowledge = new AgentScopeKnowledgeAdapter().createAggregateKnowledge(request, sink); - CompletableFuture> eventsFuture = sink.asFlux().take(1).collectList().toFuture(); - - knowledge.retrieve("query", RetrieveConfig.builder().limit(1).scoreThreshold(0.2D).build()).block(); - AgentRuntimeEvent event = eventsFuture.get(3, TimeUnit.SECONDS).get(0); - - Assert.assertEquals(AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL, event.getEventType()); - Assert.assertEquals("kb-1", event.getPayload().get("knowledgeId")); - Assert.assertEquals(1, event.getPayload().get("documentCount")); - @SuppressWarnings("unchecked") - List> documents = (List>) event.getPayload().get("documents"); - Assert.assertFalse(documents.get(0).containsKey("content")); - } - - @Test - public void shouldFailWhenKnowledgeRetrieverMissing() { - AgentRunRequest request = request(); - request.getKnowledgeRetrievers().clear(); - Knowledge knowledge = new AgentScopeKnowledgeAdapter().createAggregateKnowledge(request); - - try { - knowledge.retrieve("query", RetrieveConfig.builder().limit(2).scoreThreshold(0D).build()).block(); - Assert.fail("Expected missing knowledge retriever to fail."); - } catch (Exception exception) { - Assert.assertTrue(exception.getMessage().contains("Knowledge retriever is required")); - } - } - - @Test - public void shouldUseKnowledgeSpecLimitAsAggregateDefault() { - AgentRunRequest request = request(); - AgentKnowledgeSpec second = knowledge("kb-2", "KB2"); - second.setLimit(3); - request.getAgentDefinition().getKnowledgeSpecs().get(0).setLimit(2); - request.getAgentDefinition().getKnowledgeSpecs().add(second); - ReActAgent agent = fakeRuntime().buildAgent(request, Sinks.many().unicast().onBackpressureBuffer()); - - Assert.assertNotNull(agent); - } - - @Test - public void shouldUseMinimumKnowledgeScoreThresholdAsAggregateDefault() throws Exception { - AgentRunRequest request = request(); - request.getAgentDefinition().getKnowledgeSpecs().get(0).setScoreThreshold(0.4D); - AgentKnowledgeSpec second = knowledge("kb-2", "KB2"); - second.setScoreThreshold(0.2D); - request.getAgentDefinition().getKnowledgeSpecs().add(second); - AgentScopeReActRuntime runtime = fakeRuntime(); - java.lang.reflect.Method method = AgentScopeReActRuntime.class.getDeclaredMethod( - "defaultRetrieveConfig", AgentDefinition.class); - method.setAccessible(true); - - RetrieveConfig config = (RetrieveConfig) method.invoke(runtime, request.getAgentDefinition()); - - Assert.assertEquals(0.2D, config.getScoreThreshold(), 0.0001D); - } - - @Test - public void shouldRoundTripStructuredMessageBlocks() { - AgentMessage message = new AgentMessage(); - message.setMessageId("msg-1"); - message.setRole(AgentMessageRole.ASSISTANT); - message.getMetadata().put("scene", "chat"); - message.getContentBlocks().add(new AgentTextBlock("hello")); - message.getContentBlocks().add(new AgentThinkingBlock("thinking")); - AgentToolUseBlock toolUse = new AgentToolUseBlock("call-1", "echo", Map.of("text", "hi")); - toolUse.setContent("raw-call"); - message.getContentBlocks().add(toolUse); - AgentToolResultBlock toolResult = new AgentToolResultBlock("call-1", "echo"); - toolResult.setSuspended(true); - toolResult.getMetadata().put("status", "ok"); - toolResult.getOutput().add(new AgentTextBlock("result")); - message.getContentBlocks().add(toolResult); - AgentMediaBlock media = new AgentMediaBlock("image"); - media.setUrl("https://example.com/a.png"); - media.setMimeType("image/png"); - message.getContentBlocks().add(media); - - AgentScopeMessageAdapter adapter = new AgentScopeMessageAdapter(); - Msg msg = adapter.toMsg(message); - AgentMessage converted = adapter.toAgentMessage(msg); - - Assert.assertEquals("msg-1", converted.getMessageId()); - Assert.assertEquals(5, converted.getContentBlocks().size()); - Assert.assertEquals("hello", ((AgentTextBlock) converted.getContentBlocks().get(0)).getText()); - Assert.assertEquals("thinking", ((AgentThinkingBlock) converted.getContentBlocks().get(1)).getThinking()); - Assert.assertEquals("call-1", ((AgentToolUseBlock) converted.getContentBlocks().get(2)).getId()); - Assert.assertEquals("echo", ((AgentToolResultBlock) converted.getContentBlocks().get(3)).getName()); - Assert.assertEquals("image", ((AgentMediaBlock) converted.getContentBlocks().get(4)).getMediaKind()); - } - - @Test - public void shouldPreserveInvalidTimestampAsRawMetadata() { - Msg msg = Msg.builder() - .id("msg-invalid-time") - .role(MsgRole.USER) - .textContent("hello") - .timestamp("invalid-time") - .build(); - - AgentMessage converted = new AgentScopeMessageAdapter().toAgentMessage(msg); - - Assert.assertEquals("invalid-time", converted.getMetadata().get("rawTimestamp")); - } - - @Test - public void shouldConvertUnknownBlockToExplicitFallbackText() { - AgentUnknownBlock unknownBlock = new AgentUnknownBlock(); - unknownBlock.setSourceClassName("com.example.CustomBlock"); - unknownBlock.setSourceTypeName("custom"); - - io.agentscope.core.message.ContentBlock converted = new AgentScopeMessageAdapter().toContentBlock(unknownBlock); - - Assert.assertTrue(converted instanceof TextBlock); - Assert.assertTrue(((TextBlock) converted).getText().contains("unsupported content block")); - } - - @Test - public void shouldCreateAutoContextConfigFromPolicy() { - AgentMemoryCompressionParameter parameter = new AgentMemoryCompressionParameter(); - parameter.setMsgThreshold(9); - parameter.setLastKeep(4); - parameter.setMaxToken(1234L); - - AutoContextConfig config = new AgentScopeMemoryAdapter().toAutoContextConfig(parameter); - - Assert.assertEquals(9, config.getMsgThreshold()); - Assert.assertEquals(4, config.getLastKeep()); - Assert.assertEquals(1234L, config.getMaxToken()); - } - - @Test - public void shouldMapOllamaGenerationOptions() throws Exception { - AgentModelSpec modelSpec = new AgentModelSpec(); - modelSpec.setProviderType(AgentModelProviderType.OLLAMA); - modelSpec.setModelName("llama"); - com.easyagents.agent.runtime.model.AgentGenerationOptions options = new com.easyagents.agent.runtime.model.AgentGenerationOptions(); - options.setTemperature(0.2D); - options.setTopP(0.8D); - options.setTopK(20); - options.setMaxTokens(128); - - Object model = new AgentScopeModelFactory().create(modelSpec, options); - Field field = model.getClass().getDeclaredField("defaultOptions"); - field.setAccessible(true); - io.agentscope.core.model.ollama.OllamaOptions ollamaOptions = (io.agentscope.core.model.ollama.OllamaOptions) field.get(model); - - Assert.assertEquals(0.2D, ollamaOptions.getTemperature(), 0.0001D); - Assert.assertEquals(0.8D, ollamaOptions.getTopP(), 0.0001D); - Assert.assertEquals(Integer.valueOf(20), ollamaOptions.getTopK()); - Assert.assertEquals(Integer.valueOf(128), ollamaOptions.getMaxTokens()); - } - - @Test - public void shouldCreateNativeProviderModels() { - Assert.assertTrue(createModel(AgentModelProviderType.OPENAI) instanceof OpenAIChatModel); - Assert.assertTrue(createModel(AgentModelProviderType.ANTHROPIC) instanceof AnthropicChatModel); - Assert.assertTrue(createModel(AgentModelProviderType.GEMINI) instanceof GeminiChatModel); - } - - @Test - public void shouldCreateDomesticProvidersAsOpenAiCompatibleModels() { - Assert.assertTrue(createModel(AgentModelProviderType.GLM) instanceof OpenAIChatModel); - Assert.assertTrue(createModel(AgentModelProviderType.MINIMAX) instanceof OpenAIChatModel); - Assert.assertTrue(createModel(AgentModelProviderType.MOONSHOT) instanceof OpenAIChatModel); - Assert.assertTrue(createModel(AgentModelProviderType.ARK) instanceof OpenAIChatModel); - Assert.assertTrue(createModel(AgentModelProviderType.SILICONFLOW) instanceof OpenAIChatModel); - } - - @Test - public void shouldStoreSessionThroughAdapter() { - InMemoryAgentSessionStore store = new InMemoryAgentSessionStore(); - Session session = new AgentScopeSessionAdapter(store); - - session.save(AgentScopeSessionAdapter.sessionKey("s1"), "state", new TestState("v1")); - - Assert.assertTrue(store.exists("s1")); - Assert.assertEquals("v1", session.get(AgentScopeSessionAdapter.sessionKey("s1"), "state", TestState.class).get().value); - Assert.assertTrue(AgentScopeSessionAdapter.toStatePersistence(AgentPersistencePolicy.memoryOnly()).memoryManaged()); - } - - @Test - public void shouldPersistSessionStateAsJsonFiles() throws Exception { - Path directory = Files.createTempDirectory("easy-agents-session-"); - JsonAgentSessionStore store = new JsonAgentSessionStore(directory); - Session session = new AgentScopeSessionAdapter(store); - Msg first = Msg.builder() - .role(MsgRole.USER) - .content(TextBlock.builder().text("hello").build()) - .build(); - Msg second = Msg.builder() - .role(MsgRole.ASSISTANT) - .content(TextBlock.builder().text("world").build()) - .build(); - - session.save(AgentScopeSessionAdapter.sessionKey("s1"), "message", first); - session.save(AgentScopeSessionAdapter.sessionKey("s1"), "messages", List.of(first, second)); - - JsonAgentSessionStore reloadedStore = new JsonAgentSessionStore(directory); - Session reloadedSession = new AgentScopeSessionAdapter(reloadedStore); - Msg reloaded = reloadedSession.get(AgentScopeSessionAdapter.sessionKey("s1"), "message", Msg.class).orElseThrow(); - List messages = reloadedSession.getList(AgentScopeSessionAdapter.sessionKey("s1"), "messages", Msg.class); - - Assert.assertTrue(reloadedStore.exists("s1")); - Assert.assertEquals("hello", reloaded.getTextContent()); - Assert.assertEquals(2, messages.size()); - Assert.assertEquals("world", messages.get(1).getTextContent()); - } - - @Test(expected = RuntimeException.class) - public void shouldRejectUnsafeSessionKeysForFileStore() throws Exception { - JsonAgentSessionStore store = new JsonAgentSessionStore(Files.createTempDirectory("easy-agents-session-")); - - store.save("../unsafe", "state", AgentRuntimeState.of("state", new TestState("v1"))); - } - - @Test - public void shouldCreateSkillBox() { - AgentSkillSpec skill = new AgentSkillSpec(); - skill.setSkillId("skill-1"); - skill.setName("skill"); - skill.setDescription("desc"); - skill.setSkillContent("content"); - AgentSkillBoxSpec spec = new AgentSkillBoxSpec(); - spec.setSkillBoxId("box"); - spec.setSkills(List.of(skill)); - - SkillBox skillBox = new AgentScopeSkillAdapter().createSkillBox(spec, new Toolkit()); - - Assert.assertNotNull(skillBox); - Assert.assertFalse(skillBox.getAllSkillIds().isEmpty()); - } - - @Test - public void shouldExposeSkillBoundToolThroughAgentToolkit() { - AgentRunRequest request = requestWithSkillBoundTool(); - ReActAgent agent = fakeRuntime().buildAgent(request, Sinks.many().unicast().onBackpressureBuffer()); - - Assert.assertNotNull(agent.getToolkit().getTool("echo")); - } - - @Test - public void shouldAttachSkillMetadataToApprovalEvent() throws Exception { - AgentRunRequest request = requestWithSkillBoundTool(); - request.getAgentDefinition().getToolSpecs().get(0).setApprovalRequired(true); - AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled(); - Sinks.Many sink = Sinks.many().unicast().onBackpressureBuffer(); - com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext skillContext = activatedSkillContext(request); - AgentTool tool = new AgentScopeToolAdapter().adapt(request.getAgentDefinition().getToolSpecs().get(0), - request.getToolInvokers().get("echo"), request, coordinator, sink, - skillContext, skillContext.getToolBinding("echo")); - java.util.List events = new java.util.concurrent.CopyOnWriteArrayList<>(); - java.util.concurrent.CountDownLatch approvalLatch = new java.util.concurrent.CountDownLatch(1); - sink.asFlux().subscribe(event -> { - events.add(event); - if (event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED) { - approvalLatch.countDown(); - } - }); - - tool.callAsync(ToolCallParam.builder() - .toolUseBlock(ToolUseBlock.builder() - .id("call-skill-approval") - .name("echo") - .input(Map.of("text", "hello")) - .build()) - .input(Map.of("text", "hello")) - .build()) - .subscribe(result -> { - }, error -> { - }); - Assert.assertTrue(approvalLatch.await(5, TimeUnit.SECONDS)); - - AgentRuntimeEvent approval = events.stream() - .filter(event -> event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED) - .findFirst() - .orElseThrow(AssertionError::new); - Assert.assertEquals("skill-1", approval.getPayload().get("skillId")); - Assert.assertEquals("skill", approval.getPayload().get("skillName")); - } - - @Test - public void shouldEmitCompletedAndFailedEvents() { - AgentRunRequest request = request(); - - Assert.assertTrue(fakeRuntime().stream(request) - .collectList() - .block() - .stream() - .anyMatch(event -> event.getEventType() == AgentRuntimeEventType.COMPLETED)); - - AgentRunRequest failed = request(); - failed.getAgentDefinition().setModelSpec(null); - Assert.assertTrue(fakeRuntime().stream(failed) - .collectList() - .block() - .stream() - .anyMatch(event -> event.getEventType() == AgentRuntimeEventType.FAILED)); - } - - @Test - public void shouldEmitCancelledEventFromRunHandle() throws Exception { - AgentRunRequest request = request(); - request.setCancelReason("user stopped"); - AgentScopeReActRuntime runtime = fakeRuntime(); - java.lang.reflect.Method method = AgentScopeReActRuntime.class.getDeclaredMethod( - "cancelled", AgentRunRequest.class); - method.setAccessible(true); - AgentRuntimeEvent cancelledEvent = (AgentRuntimeEvent) method.invoke(runtime, request); - List events = List.of(cancelledEvent); - Assert.assertTrue(events.stream().anyMatch(runtimeEvent -> runtimeEvent.getEventType() == AgentRuntimeEventType.CANCELLED)); - } - - @Test - public void shouldNotEmitFailedFromHook() { - AgentRunRequest request = request(); - AgentScopeEventHook hook = new AgentScopeEventHook(request); - ReActAgent agent = fakeRuntime().buildAgent(request, Sinks.many().unicast().onBackpressureBuffer()); - ErrorEvent errorEvent = new ErrorEvent(agent, new RuntimeException("boom")); - - HookEvent returned = hook.onEvent(errorEvent).block(); - - Assert.assertSame(errorEvent, returned); - } - - @Test - public void shouldAccumulateCompletedTextFromMessageDeltas() throws Exception { - AgentRunRequest request = request(); - AgentScopeReActRuntime runtime = fakeRuntime(); - java.lang.reflect.Method method = AgentScopeReActRuntime.class.getDeclaredMethod( - "updateFinalText", StringBuilder.class, AgentRuntimeEvent.class); - method.setAccessible(true); - StringBuilder builder = new StringBuilder(); - AgentRuntimeEvent first = AgentRuntimeEvent.of(AgentRuntimeEventType.MESSAGE_DELTA); - first.getPayload().put("text", "hello "); - AgentRuntimeEvent second = AgentRuntimeEvent.of(AgentRuntimeEventType.MESSAGE_DELTA); - second.getPayload().put("text", "world"); - - method.invoke(runtime, builder, first); - method.invoke(runtime, builder, second); - - Assert.assertEquals("hello world", builder.toString()); - } - - @Test - public void shouldAttachStructuredMessageOnCompletedEvent() throws Exception { - AgentRunRequest request = request(); - AgentMessage message = AgentMessage.text(AgentMessageRole.ASSISTANT, "final answer"); - AgentScopeReActRuntime runtime = fakeRuntime(); - java.lang.reflect.Method method = AgentScopeReActRuntime.class.getDeclaredMethod( - "completed", AgentRunRequest.class, String.class, AgentMessage.class); - method.setAccessible(true); - - AgentRuntimeEvent event = (AgentRuntimeEvent) method.invoke(runtime, request, "final answer", message); - - Assert.assertEquals(AgentRuntimeEventType.COMPLETED, event.getEventType()); - Assert.assertEquals("final answer", event.getPayload().get("text")); - Assert.assertSame(message, event.getMessage()); - } - - @Test - public void shouldAttachKnowledgeReferencesToCompletedMessage() throws Exception { - AgentRunRequest request = request(); - AgentMessage message = AgentMessage.text(AgentMessageRole.ASSISTANT, "final answer"); - AgentScopeReActRuntime runtime = fakeRuntime(); - java.lang.reflect.Method method = AgentScopeReActRuntime.class.getDeclaredMethod( - "completed", AgentRunRequest.class, String.class, AgentMessage.class, Map.class); - method.setAccessible(true); - Map refs = new java.util.LinkedHashMap<>(); - AgentKnowledgeReference ref = new AgentKnowledgeReference(); - ref.setKnowledgeId("kb-1"); - ref.setKnowledgeName("KB1"); - ref.setDocumentId("doc-1"); - ref.setDocumentName("doc-1.md"); - ref.setChunkId("chunk-1"); - refs.put("kb-1|doc-1|chunk-1", ref); - - AgentRuntimeEvent event = (AgentRuntimeEvent) method.invoke(runtime, request, "final answer", message, refs); - - Assert.assertEquals(AgentRuntimeEventType.COMPLETED, event.getEventType()); - List knowledgeRefs = event.getMessage().getKnowledgeReferences(); - Assert.assertEquals(1, knowledgeRefs.size()); - Assert.assertEquals("doc-1.md", knowledgeRefs.get(0).getDocumentName()); - Assert.assertEquals("kb-1", knowledgeRefs.get(0).getKnowledgeId()); - } - - private AgentScopeReActRuntime fakeRuntime() { - return new AgentScopeReActRuntime(new FakeAgentScopeModelFactory(), new AgentScopeToolAdapter(), - new AgentScopeKnowledgeAdapter(), new AgentScopeMemoryAdapter(), new AgentScopeSkillAdapter(), - new AgentScopeMessageAdapter()); - } - - private List invokeToolAndCollect(AgentRunRequest request, int count) throws Exception { - Sinks.Many sink = Sinks.many().replay().all(); - ReActAgent agent = fakeRuntime().buildAgent(request, sink); - CompletableFuture> eventsFuture = sink.asFlux().take(count).collectList().toFuture(); - ToolUseBlock block = ToolUseBlock.builder() - .id("call-hitl") - .name("echo") - .input(Map.of("text", "hello")) - .build(); - agent.getToolkit().getTool("echo").callAsync(ToolCallParam.builder() - .toolUseBlock(block) - .input(Map.of("text", "hello")) - .build()) - .block(); - return eventsFuture.get(3, TimeUnit.SECONDS); - } - - private AgentRunRequest request() { - AgentModelSpec modelSpec = new AgentModelSpec(); - modelSpec.setModelName("fake-model"); - AgentToolSpec tool = new AgentToolSpec(); - tool.setName("echo"); - tool.setDescription("Echo text"); - tool.setParametersSchema(Map.of("type", "object", "properties", Map.of("text", Map.of("type", "string")))); - - AgentDefinition definition = new AgentDefinition(); - definition.setAgentId("agent-1"); - definition.setAgentName("agent-name"); - definition.setSystemPrompt("system"); - definition.setModelSpec(modelSpec); - definition.getExecutionOptions().setMaxIters(3); - definition.setToolSpecs(List.of(tool)); - definition.setKnowledgeSpecs(List.of(knowledge("kb-1", "KB1"))); - definition.setMemoryPolicy(AgentMemoryPolicy.inMemory()); - - AgentMemorySnapshot snapshot = new AgentMemorySnapshot(); - snapshot.addMessage(AgentMessage.text(AgentMessageRole.USER, "history")); - - AgentRunRequest request = new AgentRunRequest(); - request.setRequestId("req-1"); - request.setTraceId("trace-1"); - request.setSessionId("session-1"); - request.setAgentDefinition(definition); - request.setMemorySnapshot(snapshot); - request.setUserMessage(AgentMessage.text(AgentMessageRole.USER, "hello")); - request.getToolInvokers().put("echo", (arguments, context) -> AgentToolResult.success(String.valueOf(arguments.get("text")))); - request.getKnowledgeRetrievers().put("kb-1", retrievalRequest -> AgentKnowledgeRetrievalResult.of( - List.of(doc("doc-1", "chunk-1", "content-1", 0.8D)))); - return request; - } - - private AgentRunRequest requestWithSkillBoundTool() { - AgentRunRequest request = request(); - AgentSkillSpec skill = new AgentSkillSpec(); - skill.setSkillId("skill-1"); - skill.setName("skill"); - skill.setDescription("desc"); - skill.setSkillContent("content"); - AgentSkillBoxSpec skillBoxSpec = new AgentSkillBoxSpec(); - skillBoxSpec.setSkillBoxId("box"); - skillBoxSpec.setSkills(List.of(skill)); - skillBoxSpec.setToolBindings(Map.of("skill-1", List.of("echo"))); - request.getAgentDefinition().setSkillBoxSpec(skillBoxSpec); - return request; - } - - private com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext activatedSkillContext(AgentRunRequest request) { - com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext skillContext = - com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext.from(request.getAgentDefinition().getSkillBoxSpec()); - skillContext.activateSkill("skill-1"); - return skillContext; - } - - private Object createModel(AgentModelProviderType providerType) { - AgentModelSpec modelSpec = new AgentModelSpec(); - modelSpec.setProviderType(providerType); - modelSpec.setModelName("test-model"); - modelSpec.setApiKey("test-key"); - return new AgentScopeModelFactory().create(modelSpec, new com.easyagents.agent.runtime.model.AgentGenerationOptions()); - } - - private AgentKnowledgeSpec knowledge(String id, String name) { - AgentKnowledgeSpec spec = new AgentKnowledgeSpec(); - spec.setKnowledgeId(id); - spec.setName(name); - spec.getMetadata().put("publishVersion", "v1"); - return spec; - } - - private AgentKnowledgeDocument doc(String documentId, String chunkId, String content, double score) { - AgentKnowledgeDocument document = new AgentKnowledgeDocument(); - document.setDocumentId(documentId); - document.setDocumentName(documentId + ".md"); - document.setChunkId(chunkId); - document.setContent(content); - document.setScore(score); - document.getMetadata().put("sectionTitle", "section"); - return document; - } - - private static class TestState implements io.agentscope.core.state.State { - private final String value; - - private TestState(String value) { - this.value = value; - } - } -} diff --git a/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/agentscope/AgentScopeStatefulRuntimeTest.java b/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/agentscope/AgentScopeStatefulRuntimeTest.java new file mode 100644 index 0000000..ea81040 --- /dev/null +++ b/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/agentscope/AgentScopeStatefulRuntimeTest.java @@ -0,0 +1,1025 @@ +package com.easyagents.agent.runtime.agentscope; + +import com.easyagents.agent.runtime.*; +import com.easyagents.agent.runtime.event.*; +import com.easyagents.agent.runtime.event.interceptor.AutoContextInterceptor; +import com.easyagents.agent.runtime.event.interceptor.ToolHitlInterceptor; +import com.easyagents.agent.runtime.event.observer.AgentRuntimeErrorObserver; +import com.easyagents.agent.runtime.event.observer.ReasoningLifecycleObserver; +import com.easyagents.agent.runtime.event.observer.SkillExecutionObserver; +import com.easyagents.agent.runtime.event.observer.ToolExecutionObserver; +import com.easyagents.agent.runtime.hitl.AgentResumeToken; +import com.easyagents.agent.runtime.knowledge.AgentKnowledgeDocument; +import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetrievalResult; +import com.easyagents.agent.runtime.knowledge.AgentKnowledgeSpec; +import com.easyagents.agent.runtime.memory.AgentMemoryCompressionParameter; +import com.easyagents.agent.runtime.memory.AgentMemoryPolicy; +import com.easyagents.agent.runtime.memory.AgentMemorySnapshot; +import com.easyagents.agent.runtime.message.AgentMessage; +import com.easyagents.agent.runtime.message.AgentMessageRole; +import com.easyagents.agent.runtime.model.AgentModelSpec; +import com.easyagents.agent.runtime.persistence.session.memory.InMemoryAgentSessionStore; +import com.easyagents.agent.runtime.skill.AgentSkillBoxSpec; +import com.easyagents.agent.runtime.skill.AgentSkillRuntimeContext; +import com.easyagents.agent.runtime.skill.AgentSkillSpec; +import com.easyagents.agent.runtime.tool.AgentToolResult; +import com.easyagents.agent.runtime.tool.AgentToolSpec; +import io.agentscope.core.ReActAgent; +import io.agentscope.core.hook.*; +import io.agentscope.core.memory.autocontext.AutoContextHook; +import io.agentscope.core.memory.autocontext.AutoContextMemory; +import io.agentscope.core.message.*; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.Model; +import io.agentscope.core.model.ToolSchema; +import io.agentscope.core.skill.SkillBox; +import io.agentscope.core.tool.AgentTool; +import io.agentscope.core.tool.Toolkit; +import org.junit.Assert; +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Sinks; + +import java.lang.reflect.Field; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * 测试有状态 AgentScope 运行时。 + */ +public class AgentScopeStatefulRuntimeTest { + + @Test(expected = AgentRuntimeException.class) + public void shouldRejectStreamBeforeInit() { + AgentScopeReActRuntime runtime = fakeRuntime(); + + runtime.stream(AgentMessage.text(AgentMessageRole.USER, "hello")); + } + + @Test(expected = AgentRuntimeException.class) + public void shouldRejectEmptyStreamMessage() { + AgentScopeReActRuntime runtime = fakeRuntime(); + AgentMessage message = new AgentMessage(); + runtime.init(initRequest()); + + runtime.stream(message); + } + + @Test(expected = AgentRuntimeException.class) + public void shouldRejectDuplicateInit() { + AgentScopeReActRuntime runtime = fakeRuntime(); + AgentInitRequest request = initRequest(); + + runtime.init(request); + runtime.init(request); + } + + @Test(expected = AgentRuntimeException.class) + public void shouldRequireSessionStore() { + AgentInitRequest request = initRequest(); + request.setSessionStore(null); + + fakeRuntime().init(request); + } + + @Test + public void shouldRegisterUnifiedRuntimeHookForStatefulAutoContextRuntime() { + AgentInitRequest request = initRequest(); + request.getAgentDefinition().setMemoryPolicy(AgentMemoryPolicy.autoContext()); + AgentScopeReActRuntime runtime = fakeRuntime(); + + runtime.init(request); + + Assert.assertTrue(runtime.getAgent().getHooks().stream().anyMatch(this::isRuntimeHook)); + Assert.assertFalse(runtime.getAgent().getHooks().stream().anyMatch(AutoContextHook.class::isInstance)); + } + + @Test + public void shouldEmitSideEventWithRuntimeIdentityFromBridge() throws Exception { + AgentRuntimeExecutionContext context = new AgentRuntimeExecutionContext(); + AgentDefinition definition = new AgentDefinition(); + definition.setAgentId("agent-bridge"); + context.setAgentDefinition(definition); + context.setRequestId("request-bridge"); + context.setTraceId("trace-bridge"); + context.setSessionId("session-bridge"); + context.setMetadata(Map.of("tenantId", "tenant-1")); + Sinks.Many sink = Sinks.many().replay().all(); + AgentRuntimeEventBridge bridge = AgentRuntimeEventBridge.fixed(context, sink); + + bridge.emit(AgentRuntimeEvent.of(AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL)); + AgentRuntimeEvent event = sink.asFlux().take(1).collectList().toFuture().get(3, TimeUnit.SECONDS).get(0); + + Assert.assertEquals("trace-bridge", event.getTraceId()); + Assert.assertEquals("session-bridge", event.getSessionId()); + Assert.assertEquals("agent-bridge", event.getAgentId()); + Assert.assertEquals("request-bridge", event.getMetadata().get("requestId")); + Assert.assertEquals("tenant-1", event.getMetadata().get("tenantId")); + } + + @Test + public void shouldKeepAutoContextInterceptorInsideUnifiedRuntimeHook() throws Exception { + AgentInitRequest request = initRequest(); + request.getAgentDefinition().setMemoryPolicy(AgentMemoryPolicy.autoContext()); + AgentScopeReActRuntime runtime = fakeRuntime(); + + runtime.init(request); + AgentScopeRuntimeHook runtimeHook = runtime.getAgent().getHooks().stream() + .filter(AgentScopeRuntimeHook.class::isInstance) + .map(AgentScopeRuntimeHook.class::cast) + .findFirst() + .orElseThrow(); + Field managerField = AgentScopeRuntimeHook.class.getDeclaredField("observationManager"); + managerField.setAccessible(true); + AgentRuntimeObservationManager manager = (AgentRuntimeObservationManager) managerField.get(runtimeHook); + Field interceptorsField = AgentRuntimeObservationManager.class.getDeclaredField("interceptors"); + interceptorsField.setAccessible(true); + @SuppressWarnings("unchecked") + List interceptors = (List) interceptorsField.get(manager); + + Assert.assertTrue(interceptors.stream().anyMatch(AutoContextInterceptor.class::isInstance)); + } + + @Test + public void shouldKeepToolHitlInterceptorInsideUnifiedRuntimeHook() throws Exception { + AgentInitRequest request = initRequest(); + AgentToolSpec toolSpec = new AgentToolSpec(); + toolSpec.setName("search"); + toolSpec.setDescription("search"); + toolSpec.setApprovalRequired(true); + request.getAgentDefinition().setToolSpecs(List.of(toolSpec)); + request.setToolInvokers(Map.of("search", (arguments, context) -> AgentToolResult.success("tool result"))); + AgentScopeReActRuntime runtime = runtimeWithModel(List.of(ChatResponse.builder() + .id("tool-call-message") + .content(List.of(ToolUseBlock.builder() + .id("call-search") + .name("search") + .input(Map.of("q", "easyflow")) + .build())) + .finishReason("tool_calls") + .build())); + + runtime.init(request); + AgentScopeRuntimeHook runtimeHook = runtime.getAgent().getHooks().stream() + .filter(AgentScopeRuntimeHook.class::isInstance) + .map(AgentScopeRuntimeHook.class::cast) + .findFirst() + .orElseThrow(); + Field managerField = AgentScopeRuntimeHook.class.getDeclaredField("observationManager"); + managerField.setAccessible(true); + AgentRuntimeObservationManager manager = (AgentRuntimeObservationManager) managerField.get(runtimeHook); + Field interceptorsField = AgentRuntimeObservationManager.class.getDeclaredField("interceptors"); + interceptorsField.setAccessible(true); + @SuppressWarnings("unchecked") + List interceptors = (List) interceptorsField.get(manager); + + Assert.assertTrue(interceptors.stream().anyMatch(ToolHitlInterceptor.class::isInstance)); + } + + @Test + public void shouldEnablePendingToolRecoveryForRejectedHitlContinuation() { + AgentScopeReActRuntime runtime = fakeRuntime(); + + runtime.init(initRequest()); + + Assert.assertTrue(runtime.getAgent().getHooks().stream() + .anyMatch(PendingToolRecoveryHook.class::isInstance)); + } + + @Test + public void shouldEmitAutoContextCompressionEventsFromInterceptor() throws Exception { + AgentInitRequest request = initRequest(); + AgentMemoryCompressionParameter parameter = new AgentMemoryCompressionParameter(); + parameter.setMsgThreshold(3); + parameter.setLastKeep(1); + parameter.setLargePayloadThreshold(20); + AgentMemoryPolicy policy = AgentMemoryPolicy.autoContext(); + policy.setCompressionParameter(parameter); + policy.setMaxAttachedMessageCount(10); + request.getAgentDefinition().setMemoryPolicy(policy); + AgentScopeMemoryBuildResult memoryResult = new AgentScopeMemoryAdapter() + .createMemoryResult(compressionSnapshot(), policy, new FakeAgentScopeModelFactory() + .create(request.getAgentDefinition().getModelSpec(), request.getAgentDefinition().getGenerationOptions())); + ReActAgent agent = ReActAgent.builder() + .name("agent-name") + .sysPrompt("system") + .model(new FakeAgentScopeModelFactory() + .create(request.getAgentDefinition().getModelSpec(), request.getAgentDefinition().getGenerationOptions())) + .memory(memoryResult.getMemory()) + .toolkit(new Toolkit()) + .build(); + Sinks.Many sink = Sinks.many().replay().all(); + AutoContextInterceptor interceptor = new AutoContextInterceptor( + AgentRuntimeEventBridge.fixed(executionContext(), sink), memoryResult.getAutoContextConfig()); + CompletableFuture> eventsFuture = sink.asFlux().take(2).collectList().toFuture(); + PreReasoningEvent event = new PreReasoningEvent(agent, "fake-model", null, + List.of(Msg.builder() + .role(MsgRole.SYSTEM) + .name("system") + .content(TextBlock.builder().text("system").build()) + .build())); + + interceptor.intercept(event).block(); + List events = eventsFuture.get(3, TimeUnit.SECONDS); + + Assert.assertEquals(AgentRuntimeEventType.MEMORY_COMPRESSION_STARTED, events.get(0).getEventType()); + Assert.assertEquals(AgentRuntimeEventType.MEMORY_COMPRESSION_COMPLETED, events.get(1).getEventType()); + Assert.assertEquals("memory-compression", events.get(0).getPayload().get("statusKey")); + Assert.assertEquals(Boolean.TRUE, events.get(1).getPayload().get("compressed")); + Assert.assertTrue(((Number) events.get(1).getPayload().get("eventCount")).intValue() > 0); + @SuppressWarnings("unchecked") + List> compressionEvents = (List>) events.get(1).getPayload().get("events"); + Assert.assertFalse(compressionEvents.isEmpty()); + Assert.assertTrue(event.getInputMessages().get(0).getTextContent().contains("CONTEXT_OFFLOAD")); + Assert.assertTrue(((AutoContextMemory) agent.getMemory()).getMessages().stream() + .anyMatch(message -> message.getTextContent() != null + && message.getTextContent().contains("CONTEXT_OFFLOAD"))); + } + + @Test + public void shouldNotEmitAutoContextCompressionEventsWhenThresholdIsNotReached() { + AgentInitRequest request = initRequest(); + AgentMemoryCompressionParameter parameter = new AgentMemoryCompressionParameter(); + parameter.setMsgThreshold(10); + AgentMemoryPolicy policy = AgentMemoryPolicy.autoContext(); + policy.setCompressionParameter(parameter); + request.getAgentDefinition().setMemoryPolicy(policy); + AgentScopeMemoryBuildResult memoryResult = new AgentScopeMemoryAdapter() + .createMemoryResult(null, policy, new FakeAgentScopeModelFactory() + .create(request.getAgentDefinition().getModelSpec(), request.getAgentDefinition().getGenerationOptions())); + memoryResult.getMemory().addMessage(Msg.builder().role(MsgRole.USER).textContent("hello").build()); + ReActAgent agent = ReActAgent.builder() + .name("agent-name") + .sysPrompt("system") + .model(new FakeAgentScopeModelFactory() + .create(request.getAgentDefinition().getModelSpec(), request.getAgentDefinition().getGenerationOptions())) + .memory(memoryResult.getMemory()) + .toolkit(new Toolkit()) + .build(); + Sinks.Many sink = Sinks.many().replay().all(); + AutoContextInterceptor interceptor = new AutoContextInterceptor( + AgentRuntimeEventBridge.fixed(executionContext(), sink), memoryResult.getAutoContextConfig()); + PreReasoningEvent event = new PreReasoningEvent(agent, "fake-model", null, + List.of(Msg.builder().role(MsgRole.SYSTEM).textContent("system").build())); + + interceptor.intercept(event).block(); + + Assert.assertTrue(sink.asFlux().take(Duration.ofMillis(100)).collectList().block().isEmpty()); + Assert.assertTrue(event.getInputMessages().get(0).getTextContent().contains("CONTEXT_OFFLOAD")); + } + + @Test + public void shouldNotEmitAutoContextCompressionEventsWhenCompressionIsSkipped() { + AgentInitRequest request = initRequest(); + AgentMemoryCompressionParameter parameter = new AgentMemoryCompressionParameter(); + parameter.setMsgThreshold(1); + parameter.setLastKeep(1); + parameter.setMinCompressionTokenThreshold(300); + AgentMemoryPolicy policy = AgentMemoryPolicy.autoContext(); + policy.setCompressionParameter(parameter); + request.getAgentDefinition().setMemoryPolicy(policy); + AgentScopeMemoryBuildResult memoryResult = new AgentScopeMemoryAdapter() + .createMemoryResult(null, policy, new FakeAgentScopeModelFactory() + .create(request.getAgentDefinition().getModelSpec(), request.getAgentDefinition().getGenerationOptions())); + memoryResult.getMemory().addMessage(Msg.builder().role(MsgRole.USER).textContent("hi").build()); + ReActAgent agent = ReActAgent.builder() + .name("agent-name") + .sysPrompt("system") + .model(new FakeAgentScopeModelFactory() + .create(request.getAgentDefinition().getModelSpec(), request.getAgentDefinition().getGenerationOptions())) + .memory(memoryResult.getMemory()) + .toolkit(new Toolkit()) + .build(); + Sinks.Many sink = Sinks.many().replay().all(); + AutoContextInterceptor interceptor = new AutoContextInterceptor( + AgentRuntimeEventBridge.fixed(executionContext(), sink), memoryResult.getAutoContextConfig()); + PreReasoningEvent event = new PreReasoningEvent(agent, "fake-model", null, + List.of(Msg.builder().role(MsgRole.SYSTEM).textContent("system").build())); + + interceptor.intercept(event).block(); + + Assert.assertTrue(sink.asFlux().take(Duration.ofMillis(100)).collectList().block().isEmpty()); + Assert.assertTrue(event.getInputMessages().get(0).getTextContent().contains("CONTEXT_OFFLOAD")); + } + + @Test + public void shouldRegisterContextReloadToolFromAutoContextInterceptor() { + AgentMemoryPolicy policy = AgentMemoryPolicy.autoContext(); + AgentScopeMemoryBuildResult memoryResult = new AgentScopeMemoryAdapter() + .createMemoryResult(null, policy, new FakeAgentScopeModelFactory().create(new AgentModelSpec(), null)); + ReActAgent agent = ReActAgent.builder() + .name("agent-name") + .model(new FakeAgentScopeModelFactory().create(new AgentModelSpec(), null)) + .memory(memoryResult.getMemory()) + .toolkit(new Toolkit()) + .build(); + Sinks.Many sink = Sinks.many().replay().all(); + AutoContextInterceptor interceptor = new AutoContextInterceptor( + AgentRuntimeEventBridge.fixed(executionContext(), sink), memoryResult.getAutoContextConfig()); + + interceptor.intercept(new PreCallEvent(agent, List.of())).block(); + + Assert.assertTrue(interceptor.isRegistered()); + Assert.assertNotNull(agent.getToolkit().getTool("context_reload")); + } + + @Test + public void shouldEmitToolCallAndResultFromToolExecutionObserver() throws Exception { + AgentRuntimeExecutionContext context = executionContext(); + Sinks.Many sink = Sinks.many().replay().all(); + AgentRuntimeEventBridge bridge = AgentRuntimeEventBridge.fixed(context, sink); + ToolExecutionObserver observer = new ToolExecutionObserver(bridge); + ReActAgent agent = initializedAgent(); + Toolkit toolkit = agent.getToolkit(); + ToolUseBlock toolUse = ToolUseBlock.builder() + .id("call-1") + .name("search") + .input(Map.of("q", "easyflow")) + .build(); + ToolResultBlock toolResult = ToolResultBlock.of("call-1", "search", + TextBlock.builder().text("done").build(), Map.of("success", true)); + + observer.observe(new PreActingEvent(agent, toolkit, toolUse)).block(); + observer.observe(new PostActingEvent(agent, toolkit, toolUse, toolResult)).block(); + List events = sink.asFlux().take(2).collectList().toFuture().get(3, TimeUnit.SECONDS); + + Assert.assertEquals(AgentRuntimeEventType.TOOL_CALL, events.get(0).getEventType()); + Assert.assertEquals("RUNNING", events.get(0).getPayload().get("status")); + Assert.assertEquals("PRE_ACTING", events.get(0).getPayload().get("phase")); + Assert.assertEquals(AgentRuntimeEventType.TOOL_RESULT, events.get(1).getEventType()); + Assert.assertEquals("SUCCESS", events.get(1).getPayload().get("status")); + Assert.assertEquals("POST_ACTING", events.get(1).getPayload().get("phase")); + } + + @Test + public void shouldEmitSkillLifecycleEventsFromSkillExecutionObserver() throws Exception { + AgentRuntimeExecutionContext context = executionContext(); + Sinks.Many sink = Sinks.many().replay().all(); + AgentRuntimeEventBridge bridge = AgentRuntimeEventBridge.fixed(context, sink); + AgentSkillRuntimeContext skillContext = AgentSkillRuntimeContext.from(skillBoxSpec()); + Toolkit toolkit = new Toolkit(); + SkillBox skillBox = skillBox(toolkit); + SkillExecutionObserver observer = new SkillExecutionObserver(bridge, skillContext, skillBox); + ReActAgent agent = initializedAgent(); + toolkit.updateToolGroups(List.of("skill-1_skill_tools"), true); + ToolUseBlock loadSkill = ToolUseBlock.builder() + .id("skill-call-1") + .name(AgentSkillRuntimeContext.LOAD_SKILL_TOOL_NAME) + .input(Map.of("skillId", "skill-1", "path", "SKILL.md")) + .build(); + ToolResultBlock loadResult = ToolResultBlock.of("skill-call-1", + AgentSkillRuntimeContext.LOAD_SKILL_TOOL_NAME, + TextBlock.builder().text("Successfully loaded skill: skill-1").build(), Map.of("success", true)); + ToolUseBlock skillToolUse = ToolUseBlock.builder() + .id("skill-step-1") + .name("search") + .input(Map.of("q", "easyflow")) + .build(); + ToolResultBlock skillToolResult = ToolResultBlock.of("skill-step-1", "search", + TextBlock.builder().text("done").build(), Map.of("success", true)); + + observer.observe(new PreActingEvent(agent, toolkit, loadSkill)).block(); + observer.observe(new PostActingEvent(agent, toolkit, loadSkill, loadResult)).block(); + observer.observe(new PreActingEvent(agent, toolkit, skillToolUse)).block(); + observer.observe(new PostActingEvent(agent, toolkit, skillToolUse, skillToolResult)).block(); + List events = sink.asFlux().take(4).collectList().toFuture().get(3, TimeUnit.SECONDS); + + Assert.assertEquals(AgentRuntimeEventType.SKILL_CALL, events.get(0).getEventType()); + Assert.assertEquals(AgentRuntimeEventType.SKILL_RESULT, events.get(1).getEventType()); + Assert.assertTrue(skillContext.isSkillActive("skill-1")); + Assert.assertEquals(AgentRuntimeEventType.SKILL_STEP, events.get(2).getEventType()); + Assert.assertEquals("TOOL_CALL", events.get(2).getPayload().get("stepType")); + Assert.assertEquals(AgentRuntimeEventType.SKILL_STEP, events.get(3).getEventType()); + Assert.assertEquals("TOOL_RESULT", events.get(3).getPayload().get("stepType")); + Assert.assertEquals("skill-1", events.get(3).getPayload().get("skillId")); + } + + @Test + public void shouldUseAgentScopeSkillStateWhenSkillLoadReturnsErrorText() throws Exception { + AgentRuntimeExecutionContext context = executionContext(); + Sinks.Many sink = Sinks.many().replay().all(); + AgentRuntimeEventBridge bridge = AgentRuntimeEventBridge.fixed(context, sink); + AgentSkillRuntimeContext skillContext = AgentSkillRuntimeContext.from(skillBoxSpec()); + Toolkit toolkit = new Toolkit(); + SkillBox skillBox = skillBox(toolkit); + SkillExecutionObserver observer = new SkillExecutionObserver(bridge, skillContext, skillBox); + ReActAgent agent = initializedAgent(); + ToolUseBlock loadSkill = ToolUseBlock.builder() + .id("skill-call-error") + .name(AgentSkillRuntimeContext.LOAD_SKILL_TOOL_NAME) + .input(Map.of("skillId", "skill-1", "path", "missing.md")) + .build(); + + skillBox.getSkill("skill-1").getResource("missing.md"); + toolkit.updateToolGroups(List.of("skill-1_skill_tools"), true); + ToolResultBlock loadResult = ToolResultBlock.of("skill-call-error", + AgentSkillRuntimeContext.LOAD_SKILL_TOOL_NAME, + TextBlock.builder().text("Error: Resource not found").build(), Map.of("success", true)); + observer.observe(new PreActingEvent(agent, toolkit, loadSkill)).block(); + observer.observe(new PostActingEvent(agent, toolkit, loadSkill, loadResult)).block(); + List events = sink.asFlux().take(2).collectList().toFuture().get(3, TimeUnit.SECONDS); + + Assert.assertEquals(AgentRuntimeEventType.SKILL_RESULT, events.get(1).getEventType()); + Assert.assertEquals(Boolean.TRUE, events.get(1).getPayload().get("active")); + Assert.assertTrue(skillContext.isSkillActive("skill-1")); + } + + @Test + public void shouldEmitSkillStepFromRestoredToolkitActiveGroup() throws Exception { + AgentRuntimeExecutionContext context = executionContext(); + Sinks.Many sink = Sinks.many().replay().all(); + AgentRuntimeEventBridge bridge = AgentRuntimeEventBridge.fixed(context, sink); + AgentSkillRuntimeContext skillContext = AgentSkillRuntimeContext.from(skillBoxSpec()); + Toolkit toolkit = new Toolkit(); + skillBox(toolkit); + toolkit.updateToolGroups(List.of("skill-1_skill_tools"), true); + SkillExecutionObserver observer = new SkillExecutionObserver(bridge, skillContext, null); + ReActAgent agent = initializedAgent(); + ToolUseBlock skillToolUse = ToolUseBlock.builder() + .id("skill-step-restored") + .name("search") + .input(Map.of("q", "easyflow")) + .build(); + ToolResultBlock skillToolResult = ToolResultBlock.of("skill-step-restored", "search", + TextBlock.builder().text("done").build(), Map.of("success", true)); + + observer.observe(new PreActingEvent(agent, toolkit, skillToolUse)).block(); + observer.observe(new PostActingEvent(agent, toolkit, skillToolUse, skillToolResult)).block(); + List events = sink.asFlux().take(2).collectList().toFuture().get(3, TimeUnit.SECONDS); + + Assert.assertEquals(AgentRuntimeEventType.SKILL_STEP, events.get(0).getEventType()); + Assert.assertEquals("TOOL_CALL", events.get(0).getPayload().get("stepType")); + Assert.assertEquals(AgentRuntimeEventType.SKILL_STEP, events.get(1).getEventType()); + Assert.assertEquals("TOOL_RESULT", events.get(1).getPayload().get("stepType")); + Assert.assertTrue(skillContext.isSkillActive("skill-1")); + } + + @Test + public void shouldEmitReasoningLifecycleEvents() throws Exception { + AgentRuntimeExecutionContext context = executionContext(); + Sinks.Many sink = Sinks.many().replay().all(); + AgentRuntimeEventBridge bridge = AgentRuntimeEventBridge.fixed(context, sink); + ReasoningLifecycleObserver observer = new ReasoningLifecycleObserver(bridge); + ReActAgent agent = initializedAgent(); + Msg input = Msg.builder().role(MsgRole.USER).textContent("hello").build(); + Msg reasoning = Msg.builder().role(MsgRole.ASSISTANT).textContent("thinking done").build(); + + observer.observe(new PreReasoningEvent(agent, "fake-model", null, List.of(input))).block(); + observer.observe(new PostReasoningEvent(agent, "fake-model", null, reasoning)).block(); + List events = sink.asFlux().take(2).collectList().toFuture().get(3, TimeUnit.SECONDS); + + Assert.assertEquals(AgentRuntimeEventType.REASONING_STARTED, events.get(0).getEventType()); + Assert.assertEquals("PRE_REASONING", events.get(0).getPayload().get("phase")); + Assert.assertEquals(AgentRuntimeEventType.REASONING_COMPLETED, events.get(1).getEventType()); + Assert.assertEquals("POST_REASONING", events.get(1).getPayload().get("phase")); + Assert.assertEquals("thinking done", events.get(1).getPayload().get("text")); + } + + @Test + public void shouldEmitFailedFromRuntimeErrorObserver() throws Exception { + AgentRuntimeExecutionContext context = executionContext(); + Sinks.Many sink = Sinks.many().replay().all(); + AgentRuntimeEventBridge bridge = AgentRuntimeEventBridge.fixed(context, sink); + AgentRuntimeErrorObserver observer = new AgentRuntimeErrorObserver(bridge); + ReActAgent agent = initializedAgent(); + + observer.observe(new ErrorEvent(agent, new IllegalStateException("boom"))).block(); + AgentRuntimeEvent event = sink.asFlux().take(1).collectList().toFuture().get(3, TimeUnit.SECONDS).get(0); + + Assert.assertEquals(AgentRuntimeEventType.FAILED, event.getEventType()); + Assert.assertEquals("HOOK", event.getPayload().get("source")); + Assert.assertEquals("ERROR", event.getPayload().get("phase")); + Assert.assertEquals(IllegalStateException.class.getName(), event.getPayload().get("errorType")); + Assert.assertEquals("boom", event.getPayload().get("message")); + } + + @Test + public void shouldStreamStatefulStartedMessageAndCompletedEvents() { + InMemoryAgentSessionStore sessionStore = new InMemoryAgentSessionStore(); + AgentInitRequest request = initRequest(); + request.setSessionStore(sessionStore); + AgentScopeReActRuntime runtime = fakeRuntime(); + runtime.init(request); + + List events = runtime.stream(AgentMessage.text(AgentMessageRole.USER, "hello")) + .collectList() + .block(); + + Assert.assertFalse(events.isEmpty()); + Assert.assertEquals(AgentRuntimeEventType.STARTED, events.get(0).getEventType()); + Assert.assertTrue(events.stream().anyMatch(event -> event.getEventType() == AgentRuntimeEventType.MESSAGE_DELTA)); + String streamedText = events.stream() + .filter(event -> event.getEventType() == AgentRuntimeEventType.MESSAGE_DELTA) + .map(event -> String.valueOf(event.getPayload().getOrDefault("text", ""))) + .reduce("", String::concat); + Assert.assertEquals("fake answer", streamedText); + AgentRuntimeEvent completed = events.stream() + .filter(event -> event.getEventType() == AgentRuntimeEventType.COMPLETED) + .findFirst() + .orElseThrow(); + Assert.assertEquals("fake answer", completed.getPayload().get("text")); + Assert.assertEquals("trace-1", completed.getTraceId()); + Assert.assertEquals("session-1", completed.getSessionId()); + Assert.assertTrue(sessionStore.exists("session-1")); + } + + @Test + public void shouldAllowNextStreamAfterPreviousStreamCompleted() { + AgentScopeReActRuntime runtime = fakeRuntime(); + runtime.init(initRequest()); + + runtime.stream(AgentMessage.text(AgentMessageRole.USER, "first")).collectList().block(); + List events = runtime.stream(AgentMessage.text(AgentMessageRole.USER, "second")) + .collectList() + .block(); + + Assert.assertTrue(events.stream().anyMatch(event -> event.getEventType() == AgentRuntimeEventType.COMPLETED)); + } + + @Test + public void shouldRejectConcurrentStatefulStream() { + AgentScopeReActRuntime runtime = fakeRuntime(); + runtime.init(initRequest()); + + reactor.core.Disposable disposable = runtime.stream(AgentMessage.text(AgentMessageRole.USER, "first")) + .subscribe(); + try { + runtime.stream(AgentMessage.text(AgentMessageRole.USER, "second")).collectList().block(); + Assert.fail("Concurrent stream should be rejected."); + } catch (AgentRuntimeException expected) { + Assert.assertTrue(expected.getMessage().contains("already streaming")); + } finally { + disposable.dispose(); + } + } + + @Test + public void shouldNotDuplicateNormalToolEventsFromMainStream() { + AgentInitRequest request = initRequest(); + AgentToolSpec toolSpec = new AgentToolSpec(); + toolSpec.setName("search"); + toolSpec.setDescription("search"); + request.getAgentDefinition().setToolSpecs(List.of(toolSpec)); + AtomicBoolean invoked = new AtomicBoolean(false); + request.setToolInvokers(Map.of("search", (arguments, context) -> { + invoked.set(true); + return AgentToolResult.success("tool result"); + })); + AgentScopeReActRuntime runtime = runtimeWithModel(List.of( + ChatResponse.builder() + .id("tool-call-message") + .content(List.of(ToolUseBlock.builder() + .id("call-search") + .name("search") + .input(Map.of("q", "easyflow")) + .build())) + .finishReason("tool_calls") + .build(), + ChatResponse.builder() + .id("final-message") + .content(List.of(TextBlock.builder().text("done").build())) + .finishReason("stop") + .build())); + runtime.init(request); + + List events = runtime.stream(AgentMessage.text(AgentMessageRole.USER, "use tool")) + .collectList() + .block(); + + Assert.assertTrue(invoked.get()); + Assert.assertEquals(1, events.stream() + .filter(event -> event.getEventType() == AgentRuntimeEventType.TOOL_CALL) + .count()); + Assert.assertEquals(1, events.stream() + .filter(event -> event.getEventType() == AgentRuntimeEventType.TOOL_RESULT) + .count()); + } + + @Test + public void shouldSuspendBeforeApprovedToolExecutionFromToolHitlInterceptor() { + AgentInitRequest request = initRequest(); + AgentToolSpec toolSpec = new AgentToolSpec(); + toolSpec.setName("search"); + toolSpec.setDescription("search"); + toolSpec.setApprovalRequired(true); + toolSpec.getApprovalRequest().setApprovalPrompt("Approve search?"); + request.getAgentDefinition().setToolSpecs(List.of(toolSpec)); + AtomicBoolean invoked = new AtomicBoolean(false); + request.setToolInvokers(Map.of("search", (arguments, context) -> { + invoked.set(true); + return AgentToolResult.success("tool result"); + })); + AgentScopeReActRuntime runtime = runtimeWithModel(List.of(ChatResponse.builder() + .id("tool-call-message") + .content(List.of(ToolUseBlock.builder() + .id("call-search") + .name("search") + .input(Map.of("q", "easyflow")) + .build())) + .finishReason("tool_calls") + .build())); + runtime.init(request); + + List events = runtime.stream(AgentMessage.text(AgentMessageRole.USER, "use tool")) + .collectList() + .block(); + + Assert.assertFalse(invoked.get()); + Assert.assertTrue(events.stream() + .anyMatch(event -> event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED)); + AgentRuntimeEvent suspended = events.stream() + .filter(event -> event.getEventType() == AgentRuntimeEventType.SUSPENDED) + .findFirst() + .orElseThrow(); + Assert.assertEquals("TOOL_APPROVAL_REQUIRED", suspended.getPayload().get("reason")); + Assert.assertFalse(((List) suspended.getPayload().get("pendingApprovals")).isEmpty()); + Assert.assertFalse(events.stream().anyMatch(event -> event.getEventType() == AgentRuntimeEventType.COMPLETED)); + Assert.assertFalse(events.stream().anyMatch(event -> event.getEventType() == AgentRuntimeEventType.TOOL_RESULT)); + } + + @Test(expected = AgentRuntimeException.class) + public void shouldRejectResumeBeforeInit() { + AgentResumeRequest request = new AgentResumeRequest(); + AgentResumeToken token = new AgentResumeToken(); + token.setValue("resume-token"); + request.setResumeToken(token); + request.setApproved(true); + + fakeRuntime().resume(request).collectList().block(); + } + + @Test(expected = AgentRuntimeException.class) + public void shouldRejectResumeWithInvalidToken() { + AgentScopeReActRuntime runtime = fakeRuntime(); + runtime.init(initRequest()); + AgentResumeRequest request = new AgentResumeRequest(); + AgentResumeToken token = new AgentResumeToken(); + token.setValue("missing-token"); + request.setResumeToken(token); + request.setApproved(true); + + runtime.resume(request).collectList().block(); + } + + @Test + public void shouldResumeApprovedToolExecutionFromSuspendedRuntime() { + InMemoryAgentSessionStore sessionStore = new InMemoryAgentSessionStore(); + AgentInitRequest request = initRequest(); + request.setSessionStore(sessionStore); + AgentToolSpec toolSpec = new AgentToolSpec(); + toolSpec.setName("search"); + toolSpec.setDescription("search"); + toolSpec.setApprovalRequired(true); + request.getAgentDefinition().setToolSpecs(List.of(toolSpec)); + AtomicBoolean invoked = new AtomicBoolean(false); + request.setToolInvokers(Map.of("search", (arguments, context) -> { + invoked.set(true); + return AgentToolResult.success("tool result"); + })); + AgentScopeReActRuntime runtime = runtimeWithModel(List.of( + ChatResponse.builder() + .id("tool-call-message") + .content(List.of(ToolUseBlock.builder() + .id("call-search") + .name("search") + .input(Map.of("q", "easyflow")) + .build())) + .finishReason("tool_calls") + .build(), + ChatResponse.builder() + .id("final-message") + .content(List.of(TextBlock.builder().text("done").build())) + .finishReason("stop") + .build())); + runtime.init(request); + List suspendedEvents = runtime.stream(AgentMessage.text(AgentMessageRole.USER, "use tool")) + .collectList() + .block(); + AgentRuntimeEvent approval = suspendedEvents.stream() + .filter(event -> event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED) + .findFirst() + .orElseThrow(); + AgentResumeRequest resumeRequest = new AgentResumeRequest(); + AgentResumeToken token = new AgentResumeToken(); + token.setValue(String.valueOf(approval.getPayload().get("resumeToken"))); + resumeRequest.setResumeToken(token); + resumeRequest.setApproved(true); + + List resumeEvents = runtime.resume(resumeRequest) + .collectList() + .block(); + + Assert.assertTrue(invoked.get()); + Assert.assertEquals(AgentRuntimeEventType.STARTED, resumeEvents.get(0).getEventType()); + Assert.assertEquals(Boolean.TRUE, resumeEvents.get(0).getPayload().get("resume")); + Assert.assertTrue(resumeEvents.stream().anyMatch(event -> event.getEventType() == AgentRuntimeEventType.TOOL_RESULT)); + Assert.assertTrue(resumeEvents.stream().anyMatch(event -> event.getEventType() == AgentRuntimeEventType.MESSAGE_DELTA)); + Assert.assertTrue(resumeEvents.stream().anyMatch(event -> event.getEventType() == AgentRuntimeEventType.COMPLETED)); + Assert.assertFalse(resumeEvents.stream().anyMatch(event -> event.getEventType() == AgentRuntimeEventType.SUSPENDED)); + Assert.assertTrue(sessionStore.exists("session-1")); + } + + @Test + public void shouldCancelRejectedToolResumeWithoutExecutingTool() { + InMemoryAgentSessionStore sessionStore = new InMemoryAgentSessionStore(); + AgentInitRequest request = initRequest(); + request.setSessionStore(sessionStore); + AgentToolSpec toolSpec = new AgentToolSpec(); + toolSpec.setName("search"); + toolSpec.setDescription("search"); + toolSpec.setApprovalRequired(true); + request.getAgentDefinition().setToolSpecs(List.of(toolSpec)); + AtomicBoolean invoked = new AtomicBoolean(false); + request.setToolInvokers(Map.of("search", (arguments, context) -> { + invoked.set(true); + return AgentToolResult.success("tool result"); + })); + AgentScopeReActRuntime runtime = runtimeWithModel(List.of(ChatResponse.builder() + .id("tool-call-message") + .content(List.of(ToolUseBlock.builder() + .id("call-search") + .name("search") + .input(Map.of("q", "easyflow")) + .build())) + .finishReason("tool_calls") + .build())); + runtime.init(request); + List suspendedEvents = runtime.stream(AgentMessage.text(AgentMessageRole.USER, "use tool")) + .collectList() + .block(); + AgentRuntimeEvent approval = suspendedEvents.stream() + .filter(event -> event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED) + .findFirst() + .orElseThrow(); + AgentResumeRequest resumeRequest = new AgentResumeRequest(); + AgentResumeToken token = new AgentResumeToken(); + token.setValue(String.valueOf(approval.getPayload().get("resumeToken"))); + resumeRequest.setResumeToken(token); + resumeRequest.setApproved(false); + resumeRequest.setRejectReason("not allowed"); + + List resumeEvents = runtime.resume(resumeRequest) + .collectList() + .block(); + + Assert.assertFalse(invoked.get()); + AgentRuntimeEvent cancelled = resumeEvents.stream() + .filter(event -> event.getEventType() == AgentRuntimeEventType.CANCELLED) + .findFirst() + .orElseThrow(); + Assert.assertEquals("not allowed", cancelled.getPayload().get("reason")); + Assert.assertEquals(Boolean.TRUE, cancelled.getPayload().get("resume")); + Assert.assertTrue(sessionStore.exists("session-1")); + } + + @Test + public void shouldRejectStreamWhileResumeIsRunning() { + AgentInitRequest request = initRequest(); + AgentToolSpec toolSpec = new AgentToolSpec(); + toolSpec.setName("search"); + toolSpec.setDescription("search"); + toolSpec.setApprovalRequired(true); + request.getAgentDefinition().setToolSpecs(List.of(toolSpec)); + CompletableFuture toolStarted = new CompletableFuture<>(); + request.setToolInvokers(Map.of("search", (arguments, context) -> { + toolStarted.complete(null); + try { + Thread.sleep(1_000L); + } catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + } + return AgentToolResult.success("tool result"); + })); + AgentScopeReActRuntime runtime = runtimeWithModel(List.of( + ChatResponse.builder() + .id("tool-call-message") + .content(List.of(ToolUseBlock.builder() + .id("call-search") + .name("search") + .input(Map.of("q", "easyflow")) + .build())) + .finishReason("tool_calls") + .build(), + ChatResponse.builder() + .id("final-message") + .content(List.of(TextBlock.builder().text("done").build())) + .finishReason("stop") + .build())); + runtime.init(request); + List suspendedEvents = runtime.stream(AgentMessage.text(AgentMessageRole.USER, "use tool")) + .collectList() + .block(); + AgentRuntimeEvent approval = suspendedEvents.stream() + .filter(event -> event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED) + .findFirst() + .orElseThrow(); + AgentResumeRequest resumeRequest = new AgentResumeRequest(); + AgentResumeToken token = new AgentResumeToken(); + token.setValue(String.valueOf(approval.getPayload().get("resumeToken"))); + resumeRequest.setResumeToken(token); + resumeRequest.setApproved(true); + + reactor.core.Disposable disposable = runtime.resume(resumeRequest).subscribe(); + try { + toolStarted.get(3, TimeUnit.SECONDS); + runtime.stream(AgentMessage.text(AgentMessageRole.USER, "second")).collectList().block(); + Assert.fail("Concurrent stream should be rejected."); + } catch (AgentRuntimeException expected) { + Assert.assertTrue(expected.getMessage().contains("already streaming")); + } catch (Exception error) { + throw new AssertionError(error); + } finally { + disposable.dispose(); + } + } + + @Test + public void shouldAttachKnowledgeReferencesToCompletedMessage() { + AgentInitRequest request = initRequest(); + AgentKnowledgeSpec knowledgeSpec = new AgentKnowledgeSpec(); + knowledgeSpec.setKnowledgeId("knowledge-1"); + knowledgeSpec.setName("知识库"); + request.getAgentDefinition().setKnowledgeSpecs(List.of(knowledgeSpec)); + request.setKnowledgeRetrievers(Map.of("knowledge-1", retrievalRequest -> { + AgentKnowledgeDocument document = new AgentKnowledgeDocument(); + document.setDocumentId("doc-1"); + document.setDocumentName("说明文档"); + document.setChunkId("chunk-1"); + document.setContent("fake answer"); + document.setScore(0.9D); + return AgentKnowledgeRetrievalResult.of(List.of(document)); + })); + AgentScopeReActRuntime runtime = fakeRuntime(); + runtime.init(request); + + List events = runtime.stream(AgentMessage.text(AgentMessageRole.USER, "query knowledge")) + .collectList() + .block(); + + AgentRuntimeEvent completed = events.stream() + .filter(event -> event.getEventType() == AgentRuntimeEventType.COMPLETED) + .findFirst() + .orElseThrow(); + Assert.assertNotNull(completed.getMessage()); + if (completed.getMessage().getKnowledgeReferences().isEmpty()) { + /* + * 当前 runtime 将知识库注册为 AgentScope AGENTIC RAG,模型需要主动调用 + * retrieve_knowledge 才会产生 KNOWLEDGE_RETRIEVAL 旁路事件。fake model + * 不会调用该工具时,不应强行猜引用。 + */ + Assert.assertFalse(events.stream().anyMatch(event -> + event.getEventType() == AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL)); + return; + } + Assert.assertEquals("chunk-1", completed.getMessage().getKnowledgeReferences().get(0).getChunkId()); + } + + private AgentScopeReActRuntime fakeRuntime() { + return new AgentScopeReActRuntime(new FakeAgentScopeModelFactory(), new AgentScopeToolAdapter(), + new AgentScopeKnowledgeAdapter(), new AgentScopeMemoryAdapter(), new AgentScopeSkillAdapter(), + new AgentScopeMessageAdapter()); + } + + private AgentScopeReActRuntime runtimeWithModel(List responses) { + AgentScopeModelFactory modelFactory = new AgentScopeModelFactory() { + @Override + public Model create(AgentModelSpec modelSpec, + com.easyagents.agent.runtime.model.AgentGenerationOptions generationOptions) { + return new ScriptedModel(modelSpec == null ? "fake-model" : modelSpec.getModelName(), responses); + } + }; + return new AgentScopeReActRuntime(modelFactory, new AgentScopeToolAdapter(), + new AgentScopeKnowledgeAdapter(), new AgentScopeMemoryAdapter(), new AgentScopeSkillAdapter(), + new AgentScopeMessageAdapter()); + } + + private static class ScriptedModel implements Model { + + private final String modelName; + private final List responses; + + private ScriptedModel(String modelName, List responses) { + this.modelName = modelName; + this.responses = responses; + } + + @Override + public Flux stream(List messages, List toolSchemas, GenerateOptions options) { + boolean hasToolResult = messages.stream() + .flatMap(message -> message.getContentBlocks(ToolResultBlock.class).stream()) + .anyMatch(result -> "call-search".equals(result.getId())); + List selectedResponses = hasToolResult && responses.size() > 1 + ? responses.subList(1, responses.size()) + : responses.subList(0, 1); + return Flux.fromIterable(selectedResponses); + } + + @Override + public String getModelName() { + return modelName; + } + } + + private AgentInitRequest initRequest() { + AgentModelSpec modelSpec = new AgentModelSpec(); + modelSpec.setModelName("fake-model"); + AgentDefinition definition = new AgentDefinition(); + definition.setAgentId("agent-1"); + definition.setAgentName("agent-name"); + definition.setSystemPrompt("system"); + definition.setModelSpec(modelSpec); + definition.setMemoryPolicy(AgentMemoryPolicy.inMemory()); + + AgentInitRequest request = new AgentInitRequest(); + request.setSessionId("session-1"); + request.setSessionStore(new InMemoryAgentSessionStore()); + request.setAgentDefinition(definition); + AgentRuntimeContext runtimeContext = new AgentRuntimeContext(); + runtimeContext.setTraceId("trace-1"); + request.setRuntimeContext(runtimeContext); + return request; + } + + private AgentMemorySnapshot compressionSnapshot() { + AgentMemorySnapshot snapshot = new AgentMemorySnapshot(); + snapshot.addMessage(AgentMessage.text(AgentMessageRole.USER, + "large previous message " + "y".repeat(80))); + snapshot.addMessage(AgentMessage.text(AgentMessageRole.ASSISTANT, + "old assistant answer")); + snapshot.addMessage(AgentMessage.text(AgentMessageRole.USER, "latest user")); + return snapshot; + } + + private AgentSkillBoxSpec skillBoxSpec() { + AgentSkillSpec skill = new AgentSkillSpec(); + skill.setSkillId("skill-1"); + skill.setName("Search Skill"); + skill.setDescription("Search related information with the search tool."); + skill.setSkillContent("Use search."); + AgentSkillBoxSpec spec = new AgentSkillBoxSpec(); + spec.setSkillBoxId("box-1"); + spec.setSkills(List.of(skill)); + spec.setToolBindings(Map.of("skill-1", List.of("search"))); + return spec; + } + + private SkillBox skillBox(Toolkit toolkit) { + return new AgentScopeSkillAdapter().createSkillBox(skillBoxSpec(), toolkit, + Map.of("skill-1", List.of(new NoopAgentTool("search")))); + } + + private record NoopAgentTool(String name) implements AgentTool { + + @Override + public String getName() { + return name; + } + + @Override + public String getDescription() { + return name; + } + + @Override + public Map getParameters() { + return Map.of(); + } + + @Override + public reactor.core.publisher.Mono callAsync(io.agentscope.core.tool.ToolCallParam param) { + return reactor.core.publisher.Mono.just(ToolResultBlock.text("ok")); + } + } + + private AgentRuntimeExecutionContext executionContext() { + AgentRuntimeExecutionContext context = new AgentRuntimeExecutionContext(); + AgentDefinition definition = new AgentDefinition(); + AgentModelSpec modelSpec = new AgentModelSpec(); + modelSpec.setModelName("fake-model"); + definition.setAgentId("agent-1"); + definition.setAgentName("agent-name"); + definition.setSystemPrompt("system"); + definition.setModelSpec(modelSpec); + definition.setMemoryPolicy(AgentMemoryPolicy.inMemory()); + context.setAgentDefinition(definition); + context.setTraceId("trace-1"); + context.setSessionId("session-1"); + context.setRequestId("request-1"); + return context; + } + + private ReActAgent initializedAgent() { + AgentScopeReActRuntime runtime = fakeRuntime(); + runtime.init(initRequest()); + return runtime.getAgent(); + } + + private boolean isRuntimeHook(Hook hook) { + return hook instanceof AgentScopeRuntimeHook; + } +} diff --git a/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/knowledge/citation/HeuristicKnowledgeCitationMatcherTest.java b/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/knowledge/citation/HeuristicKnowledgeCitationMatcherTest.java new file mode 100644 index 0000000..408c96c --- /dev/null +++ b/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/knowledge/citation/HeuristicKnowledgeCitationMatcherTest.java @@ -0,0 +1,96 @@ +package com.easyagents.agent.runtime.knowledge.citation; + +import com.easyagents.agent.runtime.message.AgentKnowledgeReference; +import org.junit.Assert; +import org.junit.Test; + +import java.util.List; + +/** + * 测试启发式知识库引用匹配器。 + */ +public class HeuristicKnowledgeCitationMatcherTest { + + private final HeuristicKnowledgeCitationMatcher matcher = new HeuristicKnowledgeCitationMatcher(); + + /** + * 当答案与候选片段存在明确文本证据时,应返回对应引用。 + */ + @Test + public void shouldMatchReferenceByAnswerEvidence() { + AgentKnowledgeReference unrelated = reference("doc-1", "周期结算金额等于订单金额减去佣金金额。"); + AgentKnowledgeReference cited = reference("faq-1", + "问题:暑假安排是什么?答案:2026 年暑假安排为 7 月 1 日到 8 月 15 日。"); + + List references = matcher.match( + "根据查询到的信息,2026 年暑假安排为:7 月 1 日至 8 月 15 日。", + List.of(unrelated, cited)); + + Assert.assertEquals(1, references.size()); + Assert.assertEquals("faq-1", references.get(0).getDocumentId()); + } + + /** + * 即使只有一个候选,如果答案缺少文本支撑,也不应强行返回引用。 + */ + @Test + public void shouldNotGuessOnlyCandidateWithoutEvidence() { + AgentKnowledgeReference candidate = reference("doc-1", "唯一命中片段"); + + List references = matcher.match("final answer", List.of(candidate)); + + Assert.assertTrue(references.isEmpty()); + } + + /** + * 多候选无明确文本支撑时,应返回空引用列表。 + */ + @Test + public void shouldNotGuessMultipleCandidatesWithoutEvidence() { + AgentKnowledgeReference first = reference("doc-1", "周期结算金额等于订单金额减去佣金金额。"); + AgentKnowledgeReference second = reference("doc-2", "权限配置需要绑定角色与数据范围。"); + + List references = matcher.match("final answer", List.of(first, second)); + + Assert.assertTrue(references.isEmpty()); + } + + /** + * 日期数字相同但语义内容不相关时,不应仅凭数字覆盖返回引用。 + */ + @Test + public void shouldNotMatchByDateNumbersOnly() { + AgentKnowledgeReference unrelated = reference("doc-1", + "内部文件 胜意科技AI Infra解决方案 技术文件 武汉科技大学计算机科学与技术学院 " + + "2026年1月 目录 1 总体目标 4 2 总体方案 5 3 详细方案 6 3.2 流程与编排 7 " + + "3.3 知识库管理 8 3.4 智能文档解析 9"); + + List references = matcher.match( + "根据系统中的信息,2026 年暑假的安排是:2026 年 7 月 1 日到 8 月 15 日。", + List.of(unrelated)); + + Assert.assertTrue(references.isEmpty()); + } + + /** + * 空答案或空候选不应返回引用。 + */ + @Test + public void shouldReturnEmptyWhenAnswerOrCandidatesEmpty() { + AgentKnowledgeReference candidate = reference("doc-1", "问题:暑假安排是什么?答案:7月1日到8月15日。"); + + Assert.assertTrue(matcher.match("", List.of(candidate)).isEmpty()); + Assert.assertTrue(matcher.match("暑假安排是什么?", List.of()).isEmpty()); + } + + private AgentKnowledgeReference reference(String documentId, String chunkContent) { + AgentKnowledgeReference reference = new AgentKnowledgeReference(); + reference.setKnowledgeId("kb-1"); + reference.setKnowledgeName("知识库"); + reference.setDocumentId(documentId); + reference.setDocumentName(documentId + ".md"); + reference.setChunkId(documentId + "-chunk"); + reference.setChunkContent(chunkContent); + return reference; + } +}