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