From 49a7de34bb3008d97eaca8e57c37fbeeb323d958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Wed, 19 Aug 2026 21:50:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=A0=87=E5=87=86=E5=8C=96=20Agent=20A?= =?UTF-8?q?G-UI=20=E4=B8=8E=E5=AE=A1=E6=89=B9=E8=BF=90=E8=A1=8C=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 AG-UI 事件投影与协议编码模块 - 支持 Turn 级审批作用域和受信任动态审批策略 --- .../interceptor/ToolHitlInterceptor.java | 192 +++++++++++++- .../hitl/AgentToolApprovalCoordinator.java | 87 +++++- .../hitl/AgentToolApprovalEvaluation.java | 49 ++++ .../runtime/hitl/AgentToolApprovalPolicy.java | 18 ++ .../agent/runtime/tool/AgentToolSpec.java | 20 ++ .../AgentToolApprovalCoordinatorTest.java | 132 ++++++++++ easy-agents-agui/pom.xml | 36 +++ .../easyagents/agui/AguiExtendedEvent.java | 84 ++++++ .../agui/AguiProtocolEventEncoder.java | 39 +++ .../agui/AguiRuntimeEventProjector.java | 249 ++++++++++++++++++ .../agui/AguiProtocolEventEncoderTest.java | 43 +++ .../agui/AguiRuntimeEventProjectorTest.java | 90 +++++++ easy-agents-bom/pom.xml | 4 + pom.xml | 13 + 14 files changed, 1043 insertions(+), 13 deletions(-) create mode 100644 easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/hitl/AgentToolApprovalEvaluation.java create mode 100644 easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/hitl/AgentToolApprovalPolicy.java create mode 100644 easy-agents-agui/pom.xml create mode 100644 easy-agents-agui/src/main/java/com/easyagents/agui/AguiExtendedEvent.java create mode 100644 easy-agents-agui/src/main/java/com/easyagents/agui/AguiProtocolEventEncoder.java create mode 100644 easy-agents-agui/src/main/java/com/easyagents/agui/AguiRuntimeEventProjector.java create mode 100644 easy-agents-agui/src/test/java/com/easyagents/agui/AguiProtocolEventEncoderTest.java create mode 100644 easy-agents-agui/src/test/java/com/easyagents/agui/AguiRuntimeEventProjectorTest.java 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 index b72bbc2..83c705a 100644 --- 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 @@ -7,6 +7,7 @@ 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.AgentToolApprovalEvaluation; import com.easyagents.agent.runtime.hitl.AgentToolApprovalCoordinator; import com.easyagents.agent.runtime.hitl.AgentToolApprovalRequest; import com.easyagents.agent.runtime.tool.AgentToolSpec; @@ -22,9 +23,11 @@ import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; @@ -140,7 +143,12 @@ public class ToolHitlInterceptor implements AgentRuntimeInterceptor { private void interceptPreActing(PreActingEvent event) { ToolUseBlock toolUse = event.getToolUse(); AgentToolSpec toolSpec = toolUse == null ? null : toolSpecs.get(toolUse.getName()); - if (toolSpec == null || !toolSpec.isApprovalRequired()) { + if (!requiresApproval(toolSpec, toolUse)) { + return; + } + Map approvalMetadata = approvalMetadata(toolSpec, toolUse); + if (!requiresForcedApproval(toolSpec, toolUse) + && approvalCoordinator.isReusableApprovalGranted(approvalMetadata)) { return; } // 执行授权与 toolCallId、工具名称及入参同时绑定,并且只能消费一次。 @@ -239,7 +247,121 @@ public class ToolHitlInterceptor implements AgentRuntimeInterceptor { */ private boolean isApprovalRequired(ToolUseBlock toolUse) { AgentToolSpec toolSpec = toolUse == null ? null : toolSpecs.get(toolUse.getName()); - return toolSpec != null && toolSpec.isApprovalRequired(); + return requiresApproval(toolSpec, toolUse); + } + + /** + * 判断工具声明或当前调用是否要求审批。 + * + * @param toolSpec 工具声明 + * @param toolUse 当前工具调用 + * @return 需要审批时为 true + */ + private boolean requiresApproval(AgentToolSpec toolSpec, ToolUseBlock toolUse) { + if (toolSpec == null) { + return false; + } + AgentToolApprovalEvaluation evaluation = approvalEvaluation(toolSpec, toolUse); + if (evaluation != null && !evaluation.valid()) { + // 无效命令直接进入工具并返回结构化拒绝,避免产生必然失败的审批请求。 + return false; + } + return toolSpec.isApprovalRequired() + || (evaluation != null && evaluation.approvalRequired()) + || requiresLegacyForcedApproval(toolSpec, toolUse); + } + + /** + * 根据工具声明中的强制审批命令规则检查当前调用。 + * + *

命令首词解析与受控 Shell 的引号、反斜杠规则保持一致,避免通过 + * {@code 'rm'} 或 {@code r\m} 绕过动态审批。畸形命令仍由 Shell 工具拒绝。

+ * + * @param toolSpec 工具声明 + * @param toolUse 当前工具调用 + * @return 命中强制审批命令时为 true + */ + private boolean requiresForcedApproval(AgentToolSpec toolSpec, ToolUseBlock toolUse) { + AgentToolApprovalEvaluation evaluation = approvalEvaluation(toolSpec, toolUse); + if (evaluation != null) { + return evaluation.valid() && evaluation.forced(); + } + return requiresLegacyForcedApproval(toolSpec, toolUse); + } + + /** + * 使用旧版元数据规则判断当前调用是否命中强制审批命令。 + * + * @param toolSpec 工具声明 + * @param toolUse 当前工具调用 + * @return 命中旧版强制审批规则时为 true + */ + private boolean requiresLegacyForcedApproval(AgentToolSpec toolSpec, ToolUseBlock toolUse) { + if (toolSpec == null || toolUse == null || toolSpec.getMetadata() == null) { + return false; + } + Object commandsValue = toolSpec.getMetadata().get("forceApprovalCommands"); + Object argumentValue = toolSpec.getMetadata().get("forceApprovalCommandArgument"); + if (!(commandsValue instanceof Iterable commands) || !(argumentValue instanceof String argumentName) + || argumentName.isBlank() || toolUse.getInput() == null) { + return false; + } + Object commandValue = toolUse.getInput().get(argumentName); + if (!(commandValue instanceof String command)) { + return false; + } + String executable = firstCommandToken(command); + if (executable == null) { + return false; + } + for (Object forcedCommand : commands) { + if (forcedCommand instanceof String value && executable.equals(value)) { + return true; + } + } + return false; + } + + /** + * 解析受限命令行的首个参数。 + * + * @param command 命令行 + * @return 首个参数;无有效参数时返回 null + */ + private String firstCommandToken(String command) { + if (command == null || command.isBlank()) { + return null; + } + StringBuilder token = new StringBuilder(); + char quote = 0; + boolean escaping = false; + boolean started = false; + for (int index = 0; index < command.length(); index++) { + char character = command.charAt(index); + if (!started && Character.isWhitespace(character)) { + continue; + } + started = true; + if (escaping) { + token.append(character); + escaping = false; + } else if (character == '\\' && quote != '\'') { + escaping = true; + } else if (character == '\'' || character == '"') { + if (quote == 0) { + quote = character; + } else if (quote == character) { + quote = 0; + } else { + token.append(character); + } + } else if (Character.isWhitespace(character) && quote == 0) { + break; + } else { + token.append(character); + } + } + return token.isEmpty() ? null : token.toString(); } /** @@ -270,9 +392,26 @@ public class ToolHitlInterceptor implements AgentRuntimeInterceptor { if (toolUses == null || toolUses.isEmpty()) { return List.of(); } - return toolUses.stream() - .filter(this::isApprovalRequired) - .toList(); + List approvalTools = new ArrayList<>(); + Set pendingReusableScopes = new LinkedHashSet<>(); + for (ToolUseBlock toolUse : toolUses) { + AgentToolSpec toolSpec = toolUse == null ? null : toolSpecs.get(toolUse.getName()); + if (!requiresApproval(toolSpec, toolUse)) { + continue; + } + Map approvalMetadata = approvalMetadata(toolSpec, toolUse); + if (!requiresForcedApproval(toolSpec, toolUse) + && approvalCoordinator.isReusableApprovalGranted(approvalMetadata)) { + continue; + } + String reusableScope = approvalCoordinator.reusableApprovalScope(approvalMetadata); + if (reusableScope != null && !pendingReusableScopes.add(reusableScope)) { + // 同一推理消息中同一 MCP 的多个工具共享一个审批请求。 + continue; + } + approvalTools.add(toolUse); + } + return approvalTools; } /** @@ -294,12 +433,18 @@ public class ToolHitlInterceptor implements AgentRuntimeInterceptor { Map metadata = approvalRequest == null ? new LinkedHashMap<>() : new LinkedHashMap<>(approvalRequest.getMetadata()); + metadata.putAll(toolUse.getMetadata() == null ? Map.of() : toolUse.getMetadata()); if (toolSpec.getMetadata() != null && !toolSpec.getMetadata().isEmpty()) { + // ToolSpec 由工具编译阶段生成,必须覆盖模型返回的同名治理字段(例如 toolType、mcpId)。 metadata.putAll(toolSpec.getMetadata()); } + AgentToolApprovalEvaluation evaluation = approvalEvaluation(toolSpec, toolUse); + if (evaluation != null && evaluation.metadata() != null) { + // 动态策略由受信任工具实例计算,必须覆盖模型与静态声明中的同名字段。 + metadata.putAll(evaluation.metadata()); + } 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(), @@ -312,6 +457,40 @@ public class ToolHitlInterceptor implements AgentRuntimeInterceptor { approvalBatchId); } + /** + * 调用工具声明中的受信任动态审批策略。 + * + * @param toolSpec 工具声明 + * @param toolUse 当前工具调用 + * @return 动态审批判定;未配置策略时返回 null + */ + private AgentToolApprovalEvaluation approvalEvaluation(AgentToolSpec toolSpec, ToolUseBlock toolUse) { + if (toolSpec == null || toolSpec.getApprovalPolicy() == null || toolUse == null) { + return null; + } + return toolSpec.getApprovalPolicy().evaluate( + toolUse.getInput() == null ? Map.of() : toolUse.getInput()); + } + + /** + * 合并静态工具元数据与动态审批元数据。 + * + * @param toolSpec 工具声明 + * @param toolUse 当前工具调用 + * @return 用于审批作用域判断的受信任元数据 + */ + private Map approvalMetadata(AgentToolSpec toolSpec, ToolUseBlock toolUse) { + Map metadata = new LinkedHashMap<>(); + if (toolSpec != null && toolSpec.getMetadata() != null) { + metadata.putAll(toolSpec.getMetadata()); + } + AgentToolApprovalEvaluation evaluation = approvalEvaluation(toolSpec, toolUse); + if (evaluation != null && evaluation.metadata() != null) { + metadata.putAll(evaluation.metadata()); + } + return metadata; + } + /** * 构建工具审批请求事件。 * @@ -373,6 +552,7 @@ public class ToolHitlInterceptor implements AgentRuntimeInterceptor { putIfPresent(payload, metadata, "toolDisplayName"); putIfPresent(payload, metadata, "rawMcpToolName"); putIfPresent(payload, metadata, "mcpToolName"); + putIfPresent(payload, metadata, "mcpId"); putIfPresent(payload, metadata, "mcpName"); putIfPresent(payload, metadata, "mcpTitle"); } 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 db1ca3e..e3435ff 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 @@ -19,6 +19,9 @@ import java.util.concurrent.CompletableFuture; */ public class AgentToolApprovalCoordinator { + /** MCP 工具类型。 */ + private static final String MCP_TOOL_TYPE = "MCP"; + /** 是否启用内存审批协调。 */ private final boolean enabled; /** 恢复令牌到待审批项的索引。 */ @@ -29,6 +32,8 @@ public class AgentToolApprovalCoordinator { private final Map tokensByToolCallId = new LinkedHashMap<>(); /** 工具调用ID到一次性执行授权的索引。 */ private final Map executionAuthorizations = new LinkedHashMap<>(); + /** 当前 Turn 已批准的可复用工具作用域。 */ + private final Set reusableApprovalScopes = new LinkedHashSet<>(); /** * 创建已启用的协调器。 @@ -300,11 +305,12 @@ public class AgentToolApprovalCoordinator { } /** - * 根据服务端持久化审批结果签发受信任的一次性执行授权。 + * 根据服务端持久化审批结果签发受信任的执行授权。 * *

恢复元数据应提供单个 {@code toolCallId/toolName/toolInput},或通过 - * {@code approvedToolCalls} 提供上述字段组成的列表。该入口仅供已经完成持久化令牌 - * 校验和一次性消费的服务端集成层使用。

+ * {@code approvedToolCalls} 提供上述字段组成的列表。MCP 调用可额外携带受信任的 + * {@code toolType/mcpId},用于签发当前 Turn 的复用作用域。该入口仅供已经完成 + * 持久化令牌校验和一次性消费的服务端集成层使用。

* * @param request 受信任恢复请求 */ @@ -318,16 +324,17 @@ public class AgentToolApprovalCoordinator { : request.getMetadata(); Object approvedToolCalls = metadata.get("approvedToolCalls"); Map trustedAuthorizations = new LinkedHashMap<>(); + Set trustedReusableScopes = new LinkedHashSet<>(); int authorizationCount = 0; if (approvedToolCalls instanceof List calls) { for (Object call : calls) { if (call instanceof Map callMap) { - authorizeTrustedCall(callMap, trustedAuthorizations); + authorizeTrustedCall(callMap, trustedAuthorizations, trustedReusableScopes); authorizationCount++; } } } else if (metadata.containsKey("toolCallId")) { - authorizeTrustedCall(metadata, trustedAuthorizations); + authorizeTrustedCall(metadata, trustedAuthorizations, trustedReusableScopes); authorizationCount++; } if (authorizationCount == 0) { @@ -335,6 +342,7 @@ public class AgentToolApprovalCoordinator { "Trusted resume metadata must include approved toolCallId, toolName, and toolInput."); } executionAuthorizations.putAll(trustedAuthorizations); + reusableApprovalScopes.addAll(trustedReusableScopes); } /** @@ -363,6 +371,45 @@ public class AgentToolApprovalCoordinator { } } + /** + * 判断工具元数据对应的复用作用域是否已在当前 Turn 获得批准。 + * + * @param metadata 服务端工具元数据 + * @return 当前 Turn 已批准时为 true + */ + public synchronized boolean isReusableApprovalGranted(Map metadata) { + String approvalScope = reusableApprovalScope(metadata); + return approvalScope != null && reusableApprovalScopes.contains(approvalScope); + } + + /** + * 解析可在当前 Turn 复用的审批作用域。 + * + *

MCP 使用稳定 {@code mcpId} 生成作用域;受控 Shell 脚本仅接受动态审批策略写入的 + * 内容摘要作用域。缺少受信任标识时返回 null,使调用方继续执行逐调用审批。

+ * + * @param metadata 服务端工具元数据 + * @return 可复用审批作用域;不可复用时返回 null + */ + public String reusableApprovalScope(Map metadata) { + if (metadata == null || metadata.isEmpty()) { + return null; + } + String explicitScope = stringValue(metadata.get("approvalScope")); + if (Boolean.TRUE.equals(metadata.get("operateTool")) + && "SHELL".equalsIgnoreCase(stringValue(metadata.get("operateToolType"))) + && explicitScope != null + && explicitScope.startsWith("SHELL_SCRIPT:")) { + return explicitScope; + } + String toolType = stringValue(metadata.get("toolType")); + String mcpId = stringValue(metadata.get("mcpId")); + if (!MCP_TOOL_TYPE.equalsIgnoreCase(toolType) || mcpId == null) { + return null; + } + return MCP_TOOL_TYPE + ":" + mcpId; + } + /** * 清理尚未消费的一次性执行授权。 */ @@ -370,6 +417,13 @@ public class AgentToolApprovalCoordinator { executionAuthorizations.clear(); } + /** + * 清理当前 Turn 的可复用工具审批作用域。 + */ + public synchronized void clearReusableApprovalScopes() { + reusableApprovalScopes.clear(); + } + /** * 获取指定会话当前仍待处理的审批状态。 * @@ -399,6 +453,7 @@ public class AgentToolApprovalCoordinator { approvals.clear(); tokensByToolCallId.clear(); executionAuthorizations.clear(); + reusableApprovalScopes.clear(); } /** @@ -459,6 +514,11 @@ public class AgentToolApprovalCoordinator { */ private void authorize(PendingApproval pendingApproval) { AgentPendingState state = pendingApproval.state; + String approvalScope = reusableApprovalScope(state.getMetadata()); + if (approvalScope != null) { + reusableApprovalScopes.add(approvalScope); + return; + } if (state.getToolCallId() == null || state.getToolCallId().isBlank()) { throw new AgentRuntimeException("Approved tool call is missing toolCallId."); } @@ -471,10 +531,12 @@ public class AgentToolApprovalCoordinator { * 为服务端持久化审批结果签发一次性执行授权。 * * @param callMap 已批准调用元数据 - * @param trustedAuthorizations 本次恢复待签发的临时授权集合 + * @param trustedAuthorizations 本次恢复待签发的一次性授权集合 + * @param trustedReusableScopes 本次恢复待签发的可复用作用域集合 */ private void authorizeTrustedCall(Map callMap, - Map trustedAuthorizations) { + Map trustedAuthorizations, + Set trustedReusableScopes) { String toolCallId = stringValue(callMap.get("toolCallId")); String toolName = stringValue(callMap.get("toolName")); if (toolCallId == null || toolName == null) { @@ -482,6 +544,17 @@ public class AgentToolApprovalCoordinator { "Trusted resume metadata must include non-empty toolCallId and toolName."); } Map toolInput = stringKeyMap(callMap.get("toolInput")); + Map scopeMetadata = new LinkedHashMap<>(); + scopeMetadata.put("toolType", callMap.get("toolType")); + scopeMetadata.put("mcpId", callMap.get("mcpId")); + scopeMetadata.put("operateTool", callMap.get("operateTool")); + scopeMetadata.put("operateToolType", callMap.get("operateToolType")); + scopeMetadata.put("approvalScope", callMap.get("approvalScope")); + String reusableScope = reusableApprovalScope(scopeMetadata); + if (reusableScope != null) { + trustedReusableScopes.add(reusableScope); + return; + } ExecutionAuthorization authorization = new ExecutionAuthorization(toolName, toolInput); ExecutionAuthorization previous = trustedAuthorizations.put(toolCallId, authorization); if (previous != null diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/hitl/AgentToolApprovalEvaluation.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/hitl/AgentToolApprovalEvaluation.java new file mode 100644 index 0000000..f95289e --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/hitl/AgentToolApprovalEvaluation.java @@ -0,0 +1,49 @@ +package com.easyagents.agent.runtime.hitl; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 单次工具调用的动态审批判定。 + * + * @param valid 调用是否通过审批前静态校验 + * @param approvalRequired 是否需要人工审批 + * @param forced 是否禁止复用既有审批 + * @param reusableScope 可复用审批作用域;为空表示逐调用审批 + * @param metadata 写入审批事件的受信任元数据 + */ +public record AgentToolApprovalEvaluation( + boolean valid, + boolean approvalRequired, + boolean forced, + String reusableScope, + Map metadata) { + + /** + * 创建审批前静态校验失败的判定。 + * + * @return 无需弹出审批的无效判定 + */ + public static AgentToolApprovalEvaluation invalid() { + return new AgentToolApprovalEvaluation(false, false, false, null, Map.of()); + } + + /** + * 创建通过静态校验的判定。 + * + * @param approvalRequired 是否需要审批 + * @param forced 是否强制逐调用审批 + * @param reusableScope 可复用作用域 + * @return 动态审批判定 + */ + public static AgentToolApprovalEvaluation valid(boolean approvalRequired, + boolean forced, + String reusableScope) { + Map metadata = new LinkedHashMap<>(); + if (reusableScope != null && !reusableScope.isBlank()) { + metadata.put("approvalScope", reusableScope); + } + return new AgentToolApprovalEvaluation( + true, approvalRequired, forced, reusableScope, Map.copyOf(metadata)); + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/hitl/AgentToolApprovalPolicy.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/hitl/AgentToolApprovalPolicy.java new file mode 100644 index 0000000..7c1c1d3 --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/hitl/AgentToolApprovalPolicy.java @@ -0,0 +1,18 @@ +package com.easyagents.agent.runtime.hitl; + +import java.util.Map; + +/** + * 根据单次工具入参执行审批前校验并计算动态审批策略。 + */ +@FunctionalInterface +public interface AgentToolApprovalPolicy { + + /** + * 评估一次工具调用。 + * + * @param toolInput 工具调用入参 + * @return 动态审批判定 + */ + AgentToolApprovalEvaluation evaluate(Map toolInput); +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/AgentToolSpec.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/AgentToolSpec.java index 9755333..fd7757a 100644 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/AgentToolSpec.java +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/tool/AgentToolSpec.java @@ -1,6 +1,7 @@ package com.easyagents.agent.runtime.tool; import com.easyagents.agent.runtime.hitl.AgentToolApprovalRequest; +import com.easyagents.agent.runtime.hitl.AgentToolApprovalPolicy; import java.util.LinkedHashMap; import java.util.Map; @@ -18,6 +19,7 @@ public class AgentToolSpec { private AgentToolVisibility visibility = AgentToolVisibility.VISIBLE; private boolean approvalRequired; private AgentToolApprovalRequest approvalRequest = new AgentToolApprovalRequest(); + private AgentToolApprovalPolicy approvalPolicy; private Map metadata = new LinkedHashMap<>(); /** @@ -164,6 +166,24 @@ public class AgentToolSpec { this.approvalRequest = approvalRequest == null ? new AgentToolApprovalRequest() : approvalRequest; } + /** + * 获取单次调用动态审批策略。 + * + * @return 动态审批策略;未配置时返回 null + */ + public AgentToolApprovalPolicy getApprovalPolicy() { + return approvalPolicy; + } + + /** + * 设置单次调用动态审批策略。 + * + * @param approvalPolicy 动态审批策略 + */ + public void setApprovalPolicy(AgentToolApprovalPolicy approvalPolicy) { + this.approvalPolicy = approvalPolicy; + } + /** * 获取元数据。 * diff --git a/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/hitl/AgentToolApprovalCoordinatorTest.java b/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/hitl/AgentToolApprovalCoordinatorTest.java index c3aad9d..f78a150 100644 --- a/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/hitl/AgentToolApprovalCoordinatorTest.java +++ b/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/hitl/AgentToolApprovalCoordinatorTest.java @@ -158,6 +158,138 @@ public class AgentToolApprovalCoordinatorTest { coordinator, "call-1", "search", Map.of("q", "easyflow")); } + /** + * 验证受信任恢复可以签发当前 Turn 内可复用的 MCP 审批作用域。 + */ + @Test + public void shouldAuthorizeTrustedMcpScope() { + AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled(); + AgentResumeRequest request = new AgentResumeRequest(); + AgentResumeToken token = new AgentResumeToken(); + token.setValue("persisted-token"); + request.setResumeToken(token); + request.setApproved(true); + request.setTrusted(true); + request.setMetadata(Map.of( + "toolCallId", "call-1", + "toolName", "mcp_101_search", + "toolInput", Map.of("q", "easyflow"), + "toolType", "MCP", + "mcpId", "101")); + + coordinator.authorizeTrustedExecution(request); + + Assert.assertTrue(coordinator.isReusableApprovalGranted( + Map.of("toolType", "MCP", "mcpId", "101"))); + } + + /** + * 验证 MCP 批准可在当前 Turn 按稳定 mcpId 复用。 + */ + @Test + public void shouldReuseApprovedMcpScopeWithinCurrentTurn() { + AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled(); + Map mcpMetadata = Map.of("toolType", "MCP", "mcpId", "101"); + AgentPendingState pending = coordinator.register( + "session-1", + "agent-1", + "call-resolve", + "mcp_101_resolve_library_id", + "approve", + Map.of("libraryName", "AG-UI"), + mcpMetadata, + Instant.now().plusSeconds(60), + "batch-mcp"); + + coordinator.resolve(resume(pending, true)); + + Assert.assertTrue(coordinator.isReusableApprovalGranted(mcpMetadata)); + Assert.assertFalse(coordinator.isReusableApprovalGranted( + Map.of("toolType", "MCP", "mcpId", "102"))); + Assert.assertFalse(coordinator.isReusableApprovalGranted( + Map.of("toolType", "MCP", "mcpName", "context7"))); + + coordinator.clearReusableApprovalScopes(); + + Assert.assertFalse(coordinator.isReusableApprovalGranted(mcpMetadata)); + } + + /** + * 验证受控 Shell 脚本只能按受信任内容摘要在当前 Turn 复用审批。 + */ + @Test + public void shouldReuseApprovedShellScriptScopeWithinCurrentTurn() { + AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled(); + Map scriptMetadata = Map.of( + "operateTool", true, + "operateToolType", "SHELL", + "approvalScope", "SHELL_SCRIPT:abc123"); + AgentPendingState pending = coordinator.register( + "session-1", "agent-1", "call-script", "execute_shell_command", "approve", + Map.of("command", "python3 report.py"), scriptMetadata, + Instant.now().plusSeconds(60), "batch-script"); + + coordinator.resolve(resume(pending, true)); + + Assert.assertTrue(coordinator.isReusableApprovalGranted(scriptMetadata)); + Assert.assertFalse(coordinator.isReusableApprovalGranted(Map.of( + "operateTool", true, + "operateToolType", "SHELL", + "approvalScope", "SHELL_SCRIPT:changed"))); + Assert.assertNull(coordinator.reusableApprovalScope(Map.of( + "approvalScope", "SHELL_SCRIPT:abc123"))); + } + + /** + * 验证跨节点受信任恢复可恢复 Shell 脚本内容摘要作用域。 + */ + @Test + public void shouldAuthorizeTrustedShellScriptScope() { + AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled(); + AgentResumeRequest request = new AgentResumeRequest(); + AgentResumeToken token = new AgentResumeToken(); + token.setValue("persisted-token"); + request.setResumeToken(token); + request.setApproved(true); + request.setTrusted(true); + request.setMetadata(Map.of( + "toolCallId", "call-script", + "toolName", "execute_shell_command", + "toolInput", Map.of("command", "node report.mjs"), + "operateTool", true, + "operateToolType", "SHELL", + "approvalScope", "SHELL_SCRIPT:def456")); + + coordinator.authorizeTrustedExecution(request); + + Assert.assertTrue(coordinator.isReusableApprovalGranted(Map.of( + "operateTool", true, + "operateToolType", "SHELL", + "approvalScope", "SHELL_SCRIPT:def456"))); + } + + /** + * 验证拒绝和过期不会产生可复用 MCP 批准。 + */ + @Test + public void shouldNotReuseRejectedOrExpiredMcpApproval() { + AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled(); + Map rejectedMetadata = Map.of("toolType", "MCP", "mcpId", "201"); + AgentPendingState rejected = coordinator.register( + "session-1", "agent-1", "call-rejected", "mcp_rejected", "approve", + Map.of(), rejectedMetadata, Instant.now().plusSeconds(60), "batch-rejected"); + coordinator.resolve(resume(rejected, false)); + + Map expiredMetadata = Map.of("toolType", "MCP", "mcpId", "202"); + AgentPendingState expired = coordinator.register( + "session-1", "agent-1", "call-expired-mcp", "mcp_expired", "approve", + Map.of(), expiredMetadata, Instant.now().minusSeconds(1), "batch-expired-mcp"); + coordinator.resolve(resume(expired, true)); + + Assert.assertFalse(coordinator.isReusableApprovalGranted(rejectedMetadata)); + Assert.assertFalse(coordinator.isReusableApprovalGranted(expiredMetadata)); + } + /** * 验证同一 toolCallId 不能被重新绑定到不同工具内容。 */ diff --git a/easy-agents-agui/pom.xml b/easy-agents-agui/pom.xml new file mode 100644 index 0000000..d6c44ce --- /dev/null +++ b/easy-agents-agui/pom.xml @@ -0,0 +1,36 @@ + + + 4.0.0 + + + com.easyagents + easy-agents + ${revision} + + + easy-agents-agui + easy-agents-agui + + + 17 + UTF-8 + + + + + com.easyagents + easy-agents-agent-runtime + + + io.agentscope + agentscope-extensions-agui + + + junit + junit + test + + + diff --git a/easy-agents-agui/src/main/java/com/easyagents/agui/AguiExtendedEvent.java b/easy-agents-agui/src/main/java/com/easyagents/agui/AguiExtendedEvent.java new file mode 100644 index 0000000..054b02d --- /dev/null +++ b/easy-agents-agui/src/main/java/com/easyagents/agui/AguiExtendedEvent.java @@ -0,0 +1,84 @@ +package com.easyagents.agui; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.agentscope.core.agui.model.AguiMessage; + +import java.util.List; +import java.util.Objects; + +/** + * AgentScope 1.0.12 尚未提供的 AG-UI 标准线级事件。 + * + *

该补充层只覆盖当前官方扩展缺失的标准事件,不复制 AG-UI 事件枚举。

+ */ +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") +@JsonSubTypes({ + @JsonSubTypes.Type(value = AguiExtendedEvent.RunError.class, name = "RUN_ERROR"), + @JsonSubTypes.Type(value = AguiExtendedEvent.MessagesSnapshot.class, name = "MESSAGES_SNAPSHOT") +}) +public sealed interface AguiExtendedEvent + permits AguiExtendedEvent.RunError, AguiExtendedEvent.MessagesSnapshot { + + /** + * 运行失败事件。 + * + * @param threadId AG-UI 线程 ID + * @param runId AG-UI 运行 ID + * @param message 可安全展示的错误消息 + * @param code 稳定错误码 + */ + record RunError(String threadId, String runId, String message, String code) + implements AguiExtendedEvent { + + /** + * 创建运行失败事件。 + * + * @param threadId AG-UI 线程 ID + * @param runId AG-UI 运行 ID + * @param message 可安全展示的错误消息 + * @param code 稳定错误码 + */ + @JsonCreator + public RunError( + @JsonProperty("threadId") String threadId, + @JsonProperty("runId") String runId, + @JsonProperty("message") String message, + @JsonProperty("code") String code) { + this.threadId = Objects.requireNonNull(threadId, "threadId cannot be null"); + this.runId = Objects.requireNonNull(runId, "runId cannot be null"); + this.message = Objects.requireNonNull(message, "message cannot be null"); + this.code = code; + } + } + + /** + * 消息全量快照事件。 + * + * @param threadId AG-UI 线程 ID + * @param runId AG-UI 运行 ID + * @param messages 消息快照 + */ + record MessagesSnapshot(String threadId, String runId, List messages) + implements AguiExtendedEvent { + + /** + * 创建消息全量快照事件。 + * + * @param threadId AG-UI 线程 ID + * @param runId AG-UI 运行 ID + * @param messages 消息快照 + */ + @JsonCreator + public MessagesSnapshot( + @JsonProperty("threadId") String threadId, + @JsonProperty("runId") String runId, + @JsonProperty("messages") List messages) { + this.threadId = Objects.requireNonNull(threadId, "threadId cannot be null"); + this.runId = Objects.requireNonNull(runId, "runId cannot be null"); + this.messages = messages == null ? List.of() : List.copyOf(messages); + } + } +} diff --git a/easy-agents-agui/src/main/java/com/easyagents/agui/AguiProtocolEventEncoder.java b/easy-agents-agui/src/main/java/com/easyagents/agui/AguiProtocolEventEncoder.java new file mode 100644 index 0000000..77173bc --- /dev/null +++ b/easy-agents-agui/src/main/java/com/easyagents/agui/AguiProtocolEventEncoder.java @@ -0,0 +1,39 @@ +package com.easyagents.agui; + +import io.agentscope.core.agui.AguiException; +import io.agentscope.core.util.JsonException; +import io.agentscope.core.util.JsonUtils; + +/** + * 将 AG-UI 事件编码为 JSON 或 SSE 数据帧。 + * + *

编码器无可变状态,可安全跨请求复用。

+ */ +public final class AguiProtocolEventEncoder { + + /** + * 编码为 JSON。 + * + * @param event AG-UI 官方事件或补充标准事件 + * @return JSON 文本 + * @throws AguiException.EncodingException 序列化失败时抛出 + */ + public String encodeToJson(Object event) { + try { + return JsonUtils.getJsonCodec().toJson(event); + } catch (JsonException exception) { + throw new AguiException.EncodingException("Failed to encode AG-UI event", exception); + } + } + + /** + * 编码为 SSE data 帧。 + * + * @param event AG-UI 官方事件或补充标准事件 + * @return 完整 SSE data 帧 + * @throws AguiException.EncodingException 序列化失败时抛出 + */ + public String encode(Object event) { + return "data: " + encodeToJson(event) + "\n\n"; + } +} diff --git a/easy-agents-agui/src/main/java/com/easyagents/agui/AguiRuntimeEventProjector.java b/easy-agents-agui/src/main/java/com/easyagents/agui/AguiRuntimeEventProjector.java new file mode 100644 index 0000000..4126b83 --- /dev/null +++ b/easy-agents-agui/src/main/java/com/easyagents/agui/AguiRuntimeEventProjector.java @@ -0,0 +1,249 @@ +package com.easyagents.agui; + +import com.easyagents.agent.runtime.event.AgentRuntimeEvent; +import com.easyagents.agent.runtime.event.AgentRuntimeEventType; +import io.agentscope.core.agui.event.AguiEvent; +import io.agentscope.core.util.JsonException; +import io.agentscope.core.util.JsonUtils; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * 将 Easy-Agents 中立运行时事件有序投影为 AG-UI 事件。 + * + *

实例绑定单个 run 且非线程安全。调用方应按运行顺序串行调用 {@link #project(AgentRuntimeEvent)}。

+ */ +public final class AguiRuntimeEventProjector { + + private static final String DEFAULT_ERROR_MESSAGE = "Agent runtime failed."; + + /** AG-UI thread ID。 */ + private final String threadId; + /** AG-UI run ID。 */ + private final String runId; + /** 已开始但尚未收到结果的工具调用。 */ + private final Set knownToolCallIds = new LinkedHashSet<>(); + + private boolean runStarted; + private boolean terminated; + private String openMessageId; + private String openReasoningMessageId; + private long generatedMessageSequence; + + /** + * 创建不含业务 Custom Event 的投影器。 + * + * @param threadId AG-UI thread ID + * @param runId AG-UI run ID + */ + public AguiRuntimeEventProjector(String threadId, String runId) { + this.threadId = requireText(threadId, "threadId"); + this.runId = requireText(runId, "runId"); + } + + /** + * 按输入顺序投影一条运行时事件。 + * + * @param event Easy-Agents 运行时事件 + * @return 零到多条 AG-UI 官方或补充标准事件 + */ + public List project(AgentRuntimeEvent event) { + if (event == null || event.getEventType() == null || terminated) { + return List.of(); + } + List output = new ArrayList<>(); + switch (event.getEventType()) { + case STARTED -> startRun(output); + case MESSAGE_DELTA -> projectMessageDelta(event, output); + case REASONING_STARTED -> startReasoning(event, output); + case REASONING_DELTA -> projectReasoningDelta(event, output); + case REASONING_COMPLETED -> closeReasoning(output); + case TOOL_CALL -> projectToolCall(event, output); + case TOOL_RESULT -> projectToolResult(event, output); + case COMPLETED -> finishSuccessfully(output); + case FAILED -> finishWithError(event, "AGENT_RUNTIME_FAILED", output); + case CANCELLED -> finishWithError(event, "RUN_CANCELLED", output); + default -> { + // EasyFlow 等上层业务扩展由各自协议边界映射为 CUSTOM,通用模块保持业务无关。 + } + } + return List.copyOf(output); + } + + /** + * 判断当前投影是否已经产生协议终态。 + * + * @return 已产生 RUN_FINISHED 或 RUN_ERROR 时为 true + */ + public boolean isTerminated() { + return terminated; + } + + private void startRun(List output) { + if (!runStarted) { + output.add(new AguiEvent.RunStarted(threadId, runId)); + runStarted = true; + } + } + + private void projectMessageDelta(AgentRuntimeEvent event, List output) { + startRun(output); + String messageId = eventMessageId(event, "assistant"); + if (!Objects.equals(openMessageId, messageId)) { + closeMessage(output); + output.add(new AguiEvent.TextMessageStart(threadId, runId, messageId, "assistant")); + openMessageId = messageId; + } + String delta = stringValue(event.getPayload(), "text"); + if (!delta.isEmpty()) { + output.add(new AguiEvent.TextMessageContent(threadId, runId, messageId, delta)); + } + } + + private void startReasoning(AgentRuntimeEvent event, List output) { + startRun(output); + if (openReasoningMessageId != null) { + return; + } + openReasoningMessageId = eventMessageId(event, "reasoning"); + output.add(new AguiEvent.ReasoningMessageStart( + threadId, runId, openReasoningMessageId, "reasoning")); + } + + private void projectReasoningDelta(AgentRuntimeEvent event, List output) { + startReasoning(event, output); + String delta = stringValue(event.getPayload(), "reasoning"); + if (!delta.isEmpty()) { + output.add(new AguiEvent.ReasoningMessageContent( + threadId, runId, openReasoningMessageId, delta)); + } + } + + private void projectToolCall(AgentRuntimeEvent event, List output) { + startRun(output); + String toolCallId = firstText(event.getToolCallId(), stringValue(event.getPayload(), "toolCallId")); + if (toolCallId == null || !knownToolCallIds.add(toolCallId)) { + return; + } + String toolName = firstText( + stringValue(event.getPayload(), "toolName"), + stringValue(event.getPayload(), "name"), + "tool"); + output.add(new AguiEvent.ToolCallStart(threadId, runId, toolCallId, toolName)); + output.add(new AguiEvent.ToolCallArgs( + threadId, runId, toolCallId, jsonValue(event.getPayload().get("input")))); + output.add(new AguiEvent.ToolCallEnd(threadId, runId, toolCallId)); + } + + private void projectToolResult(AgentRuntimeEvent event, List output) { + startRun(output); + String toolCallId = firstText(event.getToolCallId(), stringValue(event.getPayload(), "toolCallId")); + if (toolCallId == null || !knownToolCallIds.contains(toolCallId)) { + return; + } + String messageId = eventMessageId(event, "tool-" + toolCallId); + String content = nullToEmpty(firstText( + stringValue(event.getPayload(), "text"), + event.getPayload().containsKey("result") + ? jsonValue(event.getPayload().get("result")) + : null)); + output.add(new AguiEvent.ToolCallResult( + threadId, runId, toolCallId, content, "tool", messageId)); + } + + private void finishSuccessfully(List output) { + startRun(output); + closeOpenFragments(output); + output.add(new AguiEvent.RunFinished(threadId, runId)); + terminated = true; + } + + private void finishWithError(AgentRuntimeEvent event, String code, List output) { + startRun(output); + closeOpenFragments(output); + String message = firstText( + stringValue(event.getPayload(), "message"), + stringValue(event.getPayload(), "reason"), + DEFAULT_ERROR_MESSAGE); + output.add(new AguiExtendedEvent.RunError(threadId, runId, message, code)); + terminated = true; + } + + private void closeOpenFragments(List output) { + closeReasoning(output); + closeMessage(output); + } + + private void closeMessage(List output) { + if (openMessageId != null) { + output.add(new AguiEvent.TextMessageEnd(threadId, runId, openMessageId)); + openMessageId = null; + } + } + + private void closeReasoning(List output) { + if (openReasoningMessageId != null) { + output.add(new AguiEvent.ReasoningMessageEnd( + threadId, runId, openReasoningMessageId)); + openReasoningMessageId = null; + } + } + + private String eventMessageId(AgentRuntimeEvent event, String suffix) { + String messageId = firstText( + event.getMessageId(), + event.getMessage() == null ? null : event.getMessage().getMessageId()); + if (messageId != null) { + return messageId; + } + generatedMessageSequence++; + return runId + "-" + suffix + "-" + generatedMessageSequence; + } + + private static String stringValue(Map payload, String key) { + if (payload == null) { + return ""; + } + Object value = payload.get(key); + return value instanceof String text ? text : ""; + } + + private static String jsonValue(Object value) { + if (value == null) { + return "{}"; + } + if (value instanceof String text) { + return text; + } + try { + return JsonUtils.getJsonCodec().toJson(value); + } catch (JsonException exception) { + throw new IllegalArgumentException("Failed to encode AG-UI payload", exception); + } + } + + private static String firstText(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } + + private static String requireText(String value, String name) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(name + " cannot be blank"); + } + return value; + } + + private static String nullToEmpty(String value) { + return value == null ? "" : value; + } +} diff --git a/easy-agents-agui/src/test/java/com/easyagents/agui/AguiProtocolEventEncoderTest.java b/easy-agents-agui/src/test/java/com/easyagents/agui/AguiProtocolEventEncoderTest.java new file mode 100644 index 0000000..4e9bf65 --- /dev/null +++ b/easy-agents-agui/src/test/java/com/easyagents/agui/AguiProtocolEventEncoderTest.java @@ -0,0 +1,43 @@ +package com.easyagents.agui; + +import io.agentscope.core.agui.event.AguiEvent; +import io.agentscope.core.agui.model.AguiMessage; +import org.junit.Assert; +import org.junit.Test; + +import java.util.List; + +/** + * {@link AguiProtocolEventEncoder} 的线级协议测试。 + */ +public class AguiProtocolEventEncoderTest { + + private final AguiProtocolEventEncoder encoder = new AguiProtocolEventEncoder(); + + /** + * 验证 AgentScope 官方事件保留 AG-UI type 字段。 + */ + @Test + public void shouldEncodeOfficialEvent() { + String json = encoder.encodeToJson(new AguiEvent.RunStarted("thread-1", "run-1")); + + Assert.assertTrue(json.contains("\"type\":\"RUN_STARTED\"")); + Assert.assertTrue(json.contains("\"threadId\":\"thread-1\"")); + } + + /** + * 验证补充的失败和消息快照事件使用现代 AG-UI 标准事件名。 + */ + @Test + public void shouldEncodeExtendedStandardEvents() { + String error = encoder.encodeToJson( + new AguiExtendedEvent.RunError("thread-1", "run-1", "boom", "FAILED")); + String snapshot = encoder.encodeToJson(new AguiExtendedEvent.MessagesSnapshot( + "thread-1", "run-1", List.of(AguiMessage.userMessage("message-1", "hello")))); + + Assert.assertTrue(error.contains("\"type\":\"RUN_ERROR\"")); + Assert.assertTrue(error.contains("\"code\":\"FAILED\"")); + Assert.assertTrue(snapshot.contains("\"type\":\"MESSAGES_SNAPSHOT\"")); + Assert.assertTrue(snapshot.contains("\"role\":\"user\"")); + } +} diff --git a/easy-agents-agui/src/test/java/com/easyagents/agui/AguiRuntimeEventProjectorTest.java b/easy-agents-agui/src/test/java/com/easyagents/agui/AguiRuntimeEventProjectorTest.java new file mode 100644 index 0000000..1b2ea3e --- /dev/null +++ b/easy-agents-agui/src/test/java/com/easyagents/agui/AguiRuntimeEventProjectorTest.java @@ -0,0 +1,90 @@ +package com.easyagents.agui; + +import com.easyagents.agent.runtime.event.AgentRuntimeEvent; +import com.easyagents.agent.runtime.event.AgentRuntimeEventType; +import io.agentscope.core.agui.event.AguiEvent; +import org.junit.Assert; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * {@link AguiRuntimeEventProjector} 的协议顺序与终态测试。 + */ +public class AguiRuntimeEventProjectorTest { + + /** + * 验证文本、推理、工具和成功终态按 AG-UI 顺序投影。 + */ + @Test + public void shouldProjectSuccessfulRunInProtocolOrder() { + AguiRuntimeEventProjector projector = new AguiRuntimeEventProjector("thread-1", "run-1"); + List events = new ArrayList<>(); + + events.addAll(projector.project(event(AgentRuntimeEventType.STARTED, null, null, Map.of()))); + events.addAll(projector.project(event( + AgentRuntimeEventType.REASONING_STARTED, "reasoning-1", null, Map.of()))); + events.addAll(projector.project(event( + AgentRuntimeEventType.REASONING_DELTA, "reasoning-1", null, Map.of("reasoning", "分析")))); + events.addAll(projector.project(event( + AgentRuntimeEventType.REASONING_COMPLETED, "reasoning-1", null, Map.of()))); + events.addAll(projector.project(event( + AgentRuntimeEventType.MESSAGE_DELTA, "message-1", null, Map.of("text", "你好")))); + events.addAll(projector.project(event( + AgentRuntimeEventType.TOOL_CALL, null, "tool-1", + Map.of("toolName", "search", "input", Map.of("q", "AG-UI"))))); + events.addAll(projector.project(event( + AgentRuntimeEventType.TOOL_RESULT, null, "tool-1", Map.of("text", "done")))); + events.addAll(projector.project(event(AgentRuntimeEventType.COMPLETED, null, null, Map.of()))); + + Assert.assertEquals(List.of( + "RunStarted", + "ReasoningMessageStart", + "ReasoningMessageContent", + "ReasoningMessageEnd", + "TextMessageStart", + "TextMessageContent", + "ToolCallStart", + "ToolCallArgs", + "ToolCallEnd", + "ToolCallResult", + "TextMessageEnd", + "RunFinished"), events.stream().map(value -> value.getClass().getSimpleName()).toList()); + Assert.assertEquals( + "reasoning", ((AguiEvent.ReasoningMessageStart) events.get(1)).role()); + Assert.assertTrue(projector.isTerminated()); + } + + /** + * 验证失败终态不会追加成功事件,终态后的迟到事件会被丢弃。 + */ + @Test + public void shouldEmitSingleErrorTerminalAndIgnoreLateEvents() { + AguiRuntimeEventProjector projector = new AguiRuntimeEventProjector("thread-1", "run-1"); + + List failed = projector.project(event( + AgentRuntimeEventType.FAILED, null, null, Map.of("message", "boom"))); + List late = projector.project(event(AgentRuntimeEventType.COMPLETED, null, null, Map.of())); + + Assert.assertEquals(2, failed.size()); + Assert.assertTrue(failed.get(0) instanceof AguiEvent.RunStarted); + Assert.assertEquals( + new AguiExtendedEvent.RunError("thread-1", "run-1", "boom", "AGENT_RUNTIME_FAILED"), + failed.get(1)); + Assert.assertTrue(late.isEmpty()); + } + + private static AgentRuntimeEvent event( + AgentRuntimeEventType type, + String messageId, + String toolCallId, + Map payload) { + AgentRuntimeEvent event = AgentRuntimeEvent.of(type); + event.setMessageId(messageId); + event.setToolCallId(toolCallId); + event.setPayload(payload); + return event; + } +} diff --git a/easy-agents-bom/pom.xml b/easy-agents-bom/pom.xml index 32234e4..254b793 100644 --- a/easy-agents-bom/pom.xml +++ b/easy-agents-bom/pom.xml @@ -264,6 +264,10 @@ com.easyagents easy-agents-agent-runtime + + com.easyagents + easy-agents-agui + diff --git a/pom.xml b/pom.xml index 455abe1..69a6bbb 100644 --- a/pom.xml +++ b/pom.xml @@ -29,6 +29,7 @@ easy-agents-mcp easy-agents-skill easy-agents-agent-runtime + easy-agents-agui easy-agents-flow easy-agents-support @@ -456,12 +457,24 @@ ${revision} + + com.easyagents + easy-agents-agui + ${revision} + + io.agentscope agentscope ${agentscope.version} + + io.agentscope + agentscope-extensions-agui + ${agentscope.version} + +