fix: 完善工具审批调用绑定

- 以 toolCallId、工具名称和入参绑定一次性执行授权

- 支持批次审批、重复调用去重及拒绝过期处理

- 补充多工具审批与授权消费回归测试
This commit is contained in:
2026-07-23 19:45:49 +08:00
parent 7e59f0e638
commit fbeece2d89
7 changed files with 1454 additions and 87 deletions

View File

@@ -9,7 +9,9 @@ import com.easyagents.agent.runtime.event.observer.AgentRuntimeErrorObserver;
import com.easyagents.agent.runtime.event.observer.ReasoningLifecycleObserver;
import com.easyagents.agent.runtime.event.observer.SkillExecutionObserver;
import com.easyagents.agent.runtime.event.observer.ToolExecutionObserver;
import com.easyagents.agent.runtime.hitl.AgentPendingState;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalCoordinator;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalResolution;
import com.easyagents.agent.runtime.hitl.AgentToolApprovalRejectedException;
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeSpec;
import com.easyagents.agent.runtime.knowledge.citation.AgentKnowledgeCitationMatcher;
@@ -153,6 +155,9 @@ public class AgentScopeReActRuntime implements AgentRuntime {
*/
@Override
public void close() {
if (approvalCoordinator != null) {
approvalCoordinator.cancelAll("Agent runtime has been closed.");
}
closeMcpClients();
initialized.set(false);
}
@@ -188,17 +193,27 @@ public class AgentScopeReActRuntime implements AgentRuntime {
return Flux.error(new AgentRuntimeException("Agent runtime is already streaming."));
}
AgentRuntimeExecutionContext executionContext = createResumeExecutionContext(request);
AgentToolApprovalResolution resolution = null;
try {
if (!request.isTrusted()) {
approvalCoordinator.consume(request);
if (request.isTrusted()) {
approvalCoordinator.authorizeTrustedExecution(request);
} else {
resolution = approvalCoordinator.resolve(request);
}
} catch (RuntimeException error) {
running.set(false);
throw error;
}
// 审批拒绝
if (!request.isApproved()) {
executionContext.setCancelReason(request.getRejectReason());
if (resolution != null
&& resolution.getStatus() == AgentToolApprovalResolution.Status.WAITING) {
return waitingForRemainingApprovals(executionContext, resolution);
}
if (!request.isApproved()
|| resolution != null
&& (resolution.getStatus() == AgentToolApprovalResolution.Status.REJECTED
|| resolution.getStatus() == AgentToolApprovalResolution.Status.EXPIRED)) {
String cancelReason = resolution == null ? request.getRejectReason() : resolution.getReason();
executionContext.setCancelReason(cancelReason);
return Flux.defer(() -> {
saveSession();
return Flux.just(started(executionContext), cancelled(executionContext));
@@ -240,8 +255,6 @@ public class AgentScopeReActRuntime implements AgentRuntime {
AtomicReference<AgentMessage> finalMessage = new AtomicReference<>();
// HITL 暂停事件。被设置后,本轮以 SUSPENDED 挂起而不是 COMPLETED 结束。
AtomicReference<AgentRuntimeEvent> suspendedEvent = new AtomicReference<>();
// 本轮 HITL 待审批项来自旁路交互事件,最终会合并进 SUSPENDED 挂起事件。
List<Map<String, Object>> pendingApprovals = new CopyOnWriteArrayList<>();
// 知识库引注。
Map<String, AgentKnowledgeReference> knowledgeReferences = new LinkedHashMap<>();
// 流式输出归一化,防止出现累计快照的重复输出。
@@ -250,8 +263,7 @@ public class AgentScopeReActRuntime implements AgentRuntime {
AtomicBoolean cancelled = new AtomicBoolean(false);
// 旁线路监察事件流式输出。
Flux<AgentRuntimeEvent> sideEventFlux = sideEvents.asFlux()
.doOnNext(event -> updateKnowledgeReferences(knowledgeReferences, event))
.doOnNext(event -> updatePendingApprovals(pendingApprovals, event));
.doOnNext(event -> updateKnowledgeReferences(knowledgeReferences, event));
// 主线路 agent 交互。resume 场景会传入空列表,让 AgentScope 从 pending tool 继续执行。
Flux<AgentRuntimeEvent> mainEventFlux = agent.stream(inputSupplier.get(), streamOptions())
.timeout(executionContext.getAgentDefinition().getExecutionOptions().getTimeout())
@@ -271,9 +283,8 @@ public class AgentScopeReActRuntime implements AgentRuntime {
.concatWith(Flux.defer(() -> {
AgentRuntimeEvent suspended = suspendedEvent.get();
if (suspended != null) {
// 触发 hitl 审批事件,暂时挂起
suspended.getPayload().put("pendingApprovals", pendingApprovals);
return Flux.just(suspended);
// SUSPENDED 已在主线路中输出,结束阶段不再重复发送
return Flux.empty();
}
return Flux.just(completed(executionContext, finalText.toString(),
finalMessage.get(), knowledgeReferences));
@@ -542,9 +553,10 @@ public class AgentScopeReActRuntime implements AgentRuntime {
if (sourceEvent.getMessage() != null) {
event.setMessage(messageAdapter.toAgentMessage(sourceEvent.getMessage()));
}
event.getPayload().put("reason", context.getMetadata().getOrDefault("hitlSuspendReason", "TOOL_APPROVAL_REQUIRED"));
Object pendingApprovals = context.getMetadata().get("hitlPendingApprovals");
event.getPayload().put("pendingApprovals", pendingApprovals instanceof List<?> list ? list : List.of());
event.getPayload().put("reason", "TOOL_APPROVAL_REQUIRED");
event.getPayload().put("pendingApprovals", approvalCoordinator.pendingStates(context.getSessionId()).stream()
.map(this::pendingApprovalPayload)
.toList());
event.getMetadata().put("source", "AGENTSCOPE_STREAM");
event.getMetadata().put("generateReason", sourceEvent.getMessage() == null
? GenerateReason.REASONING_STOP_REQUESTED.name()
@@ -552,6 +564,46 @@ public class AgentScopeReActRuntime implements AgentRuntime {
return event;
}
/**
* 在同一审批批次仍有未决工具时返回挂起事件,并保持 AgentScope pending tools 不执行。
*
* @param context 本轮恢复上下文
* @param resolution 审批批次决议
* @return 开始与挂起事件流
*/
private Flux<AgentRuntimeEvent> waitingForRemainingApprovals(AgentRuntimeExecutionContext context,
AgentToolApprovalResolution resolution) {
AgentRuntimeEvent suspended = base(context, AgentRuntimeEventType.SUSPENDED);
suspended.getPayload().put("reason", "TOOL_APPROVAL_REQUIRED");
suspended.getPayload().put("pendingApprovals", resolution.getRemainingStates().stream()
.map(this::pendingApprovalPayload)
.toList());
suspended.getMetadata().put("source", "APPROVAL_COORDINATOR");
suspended.getMetadata().put("approvalStatus", AgentToolApprovalResolution.Status.WAITING.name());
return Flux.just(started(context), suspended)
.doOnNext(event -> context.getConversationRecorder().record(context, event))
.doFinally(signalType -> cleanupTurn());
}
/**
* 将待审批状态转换为前端可消费的稳定字段。
*
* @param state 待审批状态
* @return 待审批载荷
*/
private Map<String, Object> pendingApprovalPayload(AgentPendingState state) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("resumeToken", state.getResumeToken().getValue());
payload.put("toolCallId", state.getToolCallId());
payload.put("toolName", state.getToolName());
payload.put("toolInput", state.getToolInput());
payload.put("input", state.getToolInput());
payload.put("approvalPrompt", state.getApprovalPrompt());
payload.put("approvalMetadata", state.getMetadata());
payload.put("expiresAt", state.getExpiresAt() == null ? null : state.getExpiresAt().toString());
return payload;
}
/**
* 生成开始事件。
*
@@ -742,6 +794,7 @@ public class AgentScopeReActRuntime implements AgentRuntime {
* 清理本轮状态。
*/
private void cleanupTurn() {
approvalCoordinator.clearExecutionAuthorizations();
turnContextHolder.clear();
running.set(false);
}
@@ -822,26 +875,6 @@ public class AgentScopeReActRuntime implements AgentRuntime {
}
}
/**
* 从工具审批旁路事件中收集本轮待审批项。
*
* @param pendingApprovals 待审批项集合
* @param event 运行时事件
*/
private void updatePendingApprovals(List<Map<String, Object>> pendingApprovals, AgentRuntimeEvent event) {
if (event == null || event.getEventType() != AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED) {
return;
}
Map<String, Object> approval = new LinkedHashMap<>();
approval.put("resumeToken", event.getPayload().get("resumeToken"));
approval.put("toolCallId", event.getPayload().get("toolCallId"));
approval.put("toolName", event.getPayload().get("toolName"));
approval.put("toolInput", event.getPayload().get("toolInput"));
approval.put("expiresAt", event.getPayload().get("expiresAt"));
approval.put("approvalPrompt", event.getPayload().get("approvalPrompt"));
pendingApprovals.add(approval);
}
/**
* 从知识库旁路事件中收集本轮候选引用。
*