feat: 标准化 Agent AG-UI 与审批运行时
- 新增 AG-UI 事件投影与协议编码模块 - 支持 Turn 级审批作用域和受信任动态审批策略
This commit is contained in:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取元数据。
|
||||
*
|
||||
|
||||
@@ -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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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 不能被重新绑定到不同工具内容。
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user