feat: 标准化 Agent AG-UI 与审批运行时

- 新增 AG-UI 事件投影与协议编码模块

- 支持 Turn 级审批作用域和受信任动态审批策略
This commit is contained in:
2026-08-19 21:50:58 +08:00
parent a34ca9271e
commit 49a7de34bb
14 changed files with 1043 additions and 13 deletions

View File

@@ -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<String, Object> 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);
}
/**
* 根据工具声明中的强制审批命令规则检查当前调用。
*
* <p>命令首词解析与受控 Shell 的引号、反斜杠规则保持一致,避免通过
* {@code 'rm'} 或 {@code r\m} 绕过动态审批。畸形命令仍由 Shell 工具拒绝。</p>
*
* @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<ToolUseBlock> approvalTools = new ArrayList<>();
Set<String> pendingReusableScopes = new LinkedHashSet<>();
for (ToolUseBlock toolUse : toolUses) {
AgentToolSpec toolSpec = toolUse == null ? null : toolSpecs.get(toolUse.getName());
if (!requiresApproval(toolSpec, toolUse)) {
continue;
}
Map<String, Object> 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<String, Object> 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<String, Object> approvalMetadata(AgentToolSpec toolSpec, ToolUseBlock toolUse) {
Map<String, Object> 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");
}

View File

@@ -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<String, String> tokensByToolCallId = new LinkedHashMap<>();
/** 工具调用ID到一次性执行授权的索引。 */
private final Map<String, ExecutionAuthorization> executionAuthorizations = new LinkedHashMap<>();
/** 当前 Turn 已批准的可复用工具作用域。 */
private final Set<String> reusableApprovalScopes = new LinkedHashSet<>();
/**
* 创建已启用的协调器。
@@ -300,11 +305,12 @@ public class AgentToolApprovalCoordinator {
}
/**
* 根据服务端持久化审批结果签发受信任的一次性执行授权。
* 根据服务端持久化审批结果签发受信任的执行授权。
*
* <p>恢复元数据应提供单个 {@code toolCallId/toolName/toolInput},或通过
* {@code approvedToolCalls} 提供上述字段组成的列表。该入口仅供已经完成持久化令牌
* 校验和一次性消费的服务端集成层使用。</p>
* {@code approvedToolCalls} 提供上述字段组成的列表。MCP 调用可额外携带受信任的
* {@code toolType/mcpId},用于签发当前 Turn 的复用作用域。该入口仅供已经完成
* 持久化令牌校验和一次性消费的服务端集成层使用。</p>
*
* @param request 受信任恢复请求
*/
@@ -318,16 +324,17 @@ public class AgentToolApprovalCoordinator {
: request.getMetadata();
Object approvedToolCalls = metadata.get("approvedToolCalls");
Map<String, ExecutionAuthorization> trustedAuthorizations = new LinkedHashMap<>();
Set<String> 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<String, Object> metadata) {
String approvalScope = reusableApprovalScope(metadata);
return approvalScope != null && reusableApprovalScopes.contains(approvalScope);
}
/**
* 解析可在当前 Turn 复用的审批作用域。
*
* <p>MCP 使用稳定 {@code mcpId} 生成作用域;受控 Shell 脚本仅接受动态审批策略写入的
* 内容摘要作用域。缺少受信任标识时返回 null使调用方继续执行逐调用审批。</p>
*
* @param metadata 服务端工具元数据
* @return 可复用审批作用域;不可复用时返回 null
*/
public String reusableApprovalScope(Map<String, Object> 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<String, ExecutionAuthorization> trustedAuthorizations) {
Map<String, ExecutionAuthorization> trustedAuthorizations,
Set<String> 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<String, Object> toolInput = stringKeyMap(callMap.get("toolInput"));
Map<String, Object> 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

View File

@@ -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<String, Object> 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<String, Object> metadata = new LinkedHashMap<>();
if (reusableScope != null && !reusableScope.isBlank()) {
metadata.put("approvalScope", reusableScope);
}
return new AgentToolApprovalEvaluation(
true, approvalRequired, forced, reusableScope, Map.copyOf(metadata));
}
}

View File

@@ -0,0 +1,18 @@
package com.easyagents.agent.runtime.hitl;
import java.util.Map;
/**
* 根据单次工具入参执行审批前校验并计算动态审批策略。
*/
@FunctionalInterface
public interface AgentToolApprovalPolicy {
/**
* 评估一次工具调用。
*
* @param toolInput 工具调用入参
* @return 动态审批判定
*/
AgentToolApprovalEvaluation evaluate(Map<String, Object> toolInput);
}

View File

@@ -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<String, Object> 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;
}
/**
* 获取元数据。
*