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

@@ -56,6 +56,7 @@ import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.BooleanSupplier;
/**
@@ -843,6 +844,9 @@ public class AgentScopeStatefulRuntimeTest {
Assert.assertFalse(((List<?>) suspended.getPayload().get("pendingApprovals")).isEmpty());
Assert.assertFalse(events.stream().anyMatch(event -> event.getEventType() == AgentRuntimeEventType.COMPLETED));
Assert.assertFalse(events.stream().anyMatch(event -> event.getEventType() == AgentRuntimeEventType.TOOL_RESULT));
Assert.assertEquals(1, events.stream()
.filter(event -> event.getEventType() == AgentRuntimeEventType.SUSPENDED)
.count());
}
@Test(expected = AgentRuntimeException.class)
@@ -927,6 +931,267 @@ public class AgentScopeStatefulRuntimeTest {
Assert.assertTrue(sessionStore.exists("session-1"));
}
/**
* 验证同一轮推理包含多个审批工具时,全部批准前不会执行任何工具。
*/
@Test
public void shouldWaitForAllToolApprovalsBeforeExecutingBatch() {
AgentInitRequest request = initRequest();
AgentToolSpec searchSpec = new AgentToolSpec();
searchSpec.setName("search");
searchSpec.setDescription("search");
searchSpec.setApprovalRequired(true);
AgentToolSpec auditSpec = new AgentToolSpec();
auditSpec.setName("audit");
auditSpec.setDescription("audit");
auditSpec.setApprovalRequired(true);
request.getAgentDefinition().setToolSpecs(List.of(searchSpec, auditSpec));
AtomicInteger invocationCount = new AtomicInteger();
request.setToolInvokers(Map.of(
"search", (arguments, context) -> {
invocationCount.incrementAndGet();
return AgentToolResult.success("search result");
},
"audit", (arguments, context) -> {
invocationCount.incrementAndGet();
return AgentToolResult.success("audit result");
}));
AgentScopeReActRuntime runtime = runtimeWithModel(List.of(
ChatResponse.builder()
.id("tool-call-message")
.content(List.of(
ToolUseBlock.builder()
.id("call-search")
.name("search")
.input(Map.of("q", "easyflow"))
.build(),
ToolUseBlock.builder()
.id("call-audit")
.name("audit")
.input(Map.of("scope", "current"))
.build()))
.finishReason("tool_calls")
.build(),
ChatResponse.builder()
.id("final-message")
.content(List.of(TextBlock.builder().text("done").build()))
.finishReason("stop")
.build()));
runtime.init(request);
List<AgentRuntimeEvent> initialEvents = runtime.stream(
AgentMessage.text(AgentMessageRole.USER, "use tools"))
.collectList()
.block();
List<AgentRuntimeEvent> approvals = initialEvents.stream()
.filter(event -> event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED)
.toList();
Assert.assertEquals(2, approvals.size());
Assert.assertEquals(1, initialEvents.stream()
.filter(event -> event.getEventType() == AgentRuntimeEventType.SUSPENDED)
.count());
List<AgentRuntimeEvent> firstResumeEvents = runtime.resume(resumeFromApproval(approvals.get(0), true))
.collectList()
.block();
Assert.assertEquals(0, invocationCount.get());
AgentRuntimeEvent waitingEvent = firstResumeEvents.stream()
.filter(event -> event.getEventType() == AgentRuntimeEventType.SUSPENDED)
.findFirst()
.orElseThrow();
Assert.assertEquals(1, ((List<?>) waitingEvent.getPayload().get("pendingApprovals")).size());
Assert.assertFalse(firstResumeEvents.stream()
.anyMatch(event -> event.getEventType() == AgentRuntimeEventType.TOOL_CALL));
List<AgentRuntimeEvent> secondResumeEvents = runtime.resume(resumeFromApproval(approvals.get(1), true))
.collectList()
.block();
Assert.assertEquals(2, invocationCount.get());
Assert.assertEquals(2, secondResumeEvents.stream()
.filter(event -> event.getEventType() == AgentRuntimeEventType.TOOL_RESULT)
.count());
Assert.assertTrue(secondResumeEvents.stream()
.anyMatch(event -> event.getEventType() == AgentRuntimeEventType.COMPLETED));
}
/**
* 验证不同 toolCallId 即使名称和入参相同也会分别审批和执行。
*/
@Test
public void shouldPreserveDistinctApprovedToolCallsWithIdenticalInput() {
AgentInitRequest request = initRequest();
AgentToolSpec toolSpec = new AgentToolSpec();
toolSpec.setName("search");
toolSpec.setDescription("search");
toolSpec.setApprovalRequired(true);
request.getAgentDefinition().setToolSpecs(List.of(toolSpec));
AtomicInteger invocationCount = new AtomicInteger();
request.setToolInvokers(Map.of("search", (arguments, context) -> {
invocationCount.incrementAndGet();
return AgentToolResult.success("tool result");
}));
Map<String, Object> input = Map.of("q", "easyflow");
AgentScopeReActRuntime runtime = runtimeWithModel(List.of(
ChatResponse.builder()
.id("tool-call-message")
.content(List.of(
ToolUseBlock.builder()
.id("call-search")
.name("search")
.input(input)
.build(),
ToolUseBlock.builder()
.id("call-search-duplicate")
.name("search")
.input(input)
.build()))
.finishReason("tool_calls")
.build(),
ChatResponse.builder()
.id("final-message")
.content(List.of(TextBlock.builder().text("done").build()))
.finishReason("stop")
.build()));
runtime.init(request);
List<AgentRuntimeEvent> suspendedEvents = runtime.stream(
AgentMessage.text(AgentMessageRole.USER, "use tool"))
.collectList()
.block();
List<AgentRuntimeEvent> approvals = suspendedEvents.stream()
.filter(event -> event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED)
.toList();
Assert.assertEquals(2, approvals.size());
List<AgentRuntimeEvent> firstResumeEvents = runtime.resume(resumeFromApproval(approvals.get(0), true))
.collectList()
.block();
Assert.assertEquals(0, invocationCount.get());
Assert.assertTrue(firstResumeEvents.stream()
.anyMatch(event -> event.getEventType() == AgentRuntimeEventType.SUSPENDED));
List<AgentRuntimeEvent> secondResumeEvents = runtime.resume(resumeFromApproval(approvals.get(1), true))
.collectList()
.block();
Assert.assertEquals(2, invocationCount.get());
Assert.assertEquals(2, secondResumeEvents.stream()
.filter(event -> event.getEventType() == AgentRuntimeEventType.TOOL_RESULT)
.count());
Assert.assertTrue(secondResumeEvents.stream()
.anyMatch(event -> event.getEventType() == AgentRuntimeEventType.COMPLETED));
}
/**
* 验证相同 toolCallId 的同轮重放只审批并执行一次。
*/
@Test
public void shouldDeduplicateRepeatedToolCallIdWithinReasoning() {
AgentInitRequest request = initRequest();
AgentToolSpec toolSpec = new AgentToolSpec();
toolSpec.setName("search");
toolSpec.setDescription("search");
toolSpec.setApprovalRequired(true);
request.getAgentDefinition().setToolSpecs(List.of(toolSpec));
AtomicInteger invocationCount = new AtomicInteger();
request.setToolInvokers(Map.of("search", (arguments, context) -> {
invocationCount.incrementAndGet();
return AgentToolResult.success("tool result");
}));
Map<String, Object> input = Map.of("q", "easyflow");
AgentScopeReActRuntime runtime = runtimeWithModel(List.of(
ChatResponse.builder()
.id("tool-call-message")
.content(List.of(
ToolUseBlock.builder()
.id("call-search")
.name("search")
.input(input)
.build(),
ToolUseBlock.builder()
.id("call-search")
.name("search")
.input(input)
.build()))
.finishReason("tool_calls")
.build(),
ChatResponse.builder()
.id("final-message")
.content(List.of(TextBlock.builder().text("done").build()))
.finishReason("stop")
.build()));
runtime.init(request);
List<AgentRuntimeEvent> suspendedEvents = runtime.stream(
AgentMessage.text(AgentMessageRole.USER, "use tool"))
.collectList()
.block();
List<AgentRuntimeEvent> approvals = suspendedEvents.stream()
.filter(event -> event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED)
.toList();
Assert.assertEquals(1, approvals.size());
List<AgentRuntimeEvent> resumeEvents = runtime.resume(resumeFromApproval(approvals.get(0), true))
.collectList()
.block();
Assert.assertEquals(1, invocationCount.get());
Assert.assertEquals(1, resumeEvents.stream()
.filter(event -> event.getEventType() == AgentRuntimeEventType.TOOL_RESULT)
.count());
Assert.assertTrue(resumeEvents.stream()
.anyMatch(event -> event.getEventType() == AgentRuntimeEventType.COMPLETED));
}
/**
* 验证模型未提供 toolCallId 时审批链路仍会获得完整的调用身份。
*/
@Test
public void shouldProvideApprovalIdentityWhenModelOmitsToolCallId() {
AgentInitRequest request = initRequest();
AgentToolSpec toolSpec = new AgentToolSpec();
toolSpec.setName("search");
toolSpec.setDescription("search");
toolSpec.setApprovalRequired(true);
request.getAgentDefinition().setToolSpecs(List.of(toolSpec));
request.setToolInvokers(Map.of("search", (arguments, context) ->
AgentToolResult.success("tool result")));
AgentScopeReActRuntime runtime = runtimeWithModel(List.of(
ChatResponse.builder()
.id("tool-call-message")
.content(List.of(ToolUseBlock.builder()
.name("search")
.input(Map.of("q", "easyflow"))
.build()))
.finishReason("tool_calls")
.build(),
ChatResponse.builder()
.id("final-message")
.content(List.of(TextBlock.builder().text("done").build()))
.finishReason("stop")
.build()));
runtime.init(request);
List<AgentRuntimeEvent> events = runtime.stream(
AgentMessage.text(AgentMessageRole.USER, "use tool"))
.collectList()
.block();
AgentRuntimeEvent approval = events.stream()
.filter(event -> event.getEventType() == AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED)
.findFirst()
.orElseThrow();
String toolCallId = String.valueOf(approval.getPayload().get("toolCallId"));
Assert.assertFalse(toolCallId.isBlank());
@SuppressWarnings("unchecked")
Map<String, Object> metadata =
(Map<String, Object>) approval.getPayload().get("approvalMetadata");
String approvalBatchId = String.valueOf(metadata.get("approvalBatchId"));
Assert.assertFalse(approvalBatchId.isBlank());
}
@Test
public void shouldCancelRejectedToolResumeWithoutExecutingTool() {
InMemoryAgentSessionStore sessionStore = new InMemoryAgentSessionStore();
@@ -1105,6 +1370,22 @@ public class AgentScopeStatefulRuntimeTest {
new AgentScopeMessageAdapter());
}
/**
* 根据审批事件创建恢复请求。
*
* @param approvalEvent 工具审批事件
* @param approved 是否批准
* @return 恢复请求
*/
private AgentResumeRequest resumeFromApproval(AgentRuntimeEvent approvalEvent, boolean approved) {
AgentResumeRequest request = new AgentResumeRequest();
AgentResumeToken token = new AgentResumeToken();
token.setValue(String.valueOf(approvalEvent.getPayload().get("resumeToken")));
request.setResumeToken(token);
request.setApproved(approved);
return request;
}
private static class ScriptedModel implements Model {
private final String modelName;

View File

@@ -0,0 +1,258 @@
package com.easyagents.agent.runtime.hitl;
import com.easyagents.agent.runtime.AgentResumeRequest;
import com.easyagents.agent.runtime.AgentRuntimeException;
import org.junit.Assert;
import org.junit.Test;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
/**
* 测试工具审批协调器。
*/
public class AgentToolApprovalCoordinatorTest {
/**
* 验证同批次全部调用批准后才签发逐调用执行授权。
*/
@Test
public void shouldAuthorizeBatchOnlyAfterAllCallsApproved() {
AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled();
AgentPendingState first = register(coordinator, "call-1", "search", Map.of("q", "first"), "batch-1");
AgentPendingState second = register(coordinator, "call-2", "search", Map.of("q", "second"), "batch-1");
AgentToolApprovalResolution firstResolution = coordinator.resolve(resume(first, true));
Assert.assertEquals(AgentToolApprovalResolution.Status.WAITING, firstResolution.getStatus());
Assert.assertEquals(1, firstResolution.getRemainingStates().size());
assertAuthorizationRejected(coordinator, "call-1", "search", Map.of("q", "first"));
AgentToolApprovalResolution secondResolution = coordinator.resolve(resume(second, true));
Assert.assertEquals(AgentToolApprovalResolution.Status.READY, secondResolution.getStatus());
coordinator.consumeExecutionAuthorization("call-1", "search", Map.of("q", "first"));
coordinator.consumeExecutionAuthorization("call-2", "search", Map.of("q", "second"));
assertAuthorizationRejected(coordinator, "call-1", "search", Map.of("q", "first"));
}
/**
* 验证拒绝一个调用会关闭整个审批批次。
*/
@Test
public void shouldRejectWholeBatchWhenAnyCallRejected() {
AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled();
AgentPendingState first = register(coordinator, "call-1", "search", Map.of("q", "first"), "batch-1");
AgentPendingState second = register(coordinator, "call-2", "search", Map.of("q", "second"), "batch-1");
coordinator.resolve(resume(first, true));
AgentResumeRequest rejection = resume(second, false);
rejection.setRejectReason("not allowed");
AgentToolApprovalResolution resolution = coordinator.resolve(rejection);
Assert.assertEquals(AgentToolApprovalResolution.Status.REJECTED, resolution.getStatus());
Assert.assertEquals("not allowed", resolution.getReason());
assertAuthorizationRejected(coordinator, "call-1", "search", Map.of("q", "first"));
try {
coordinator.resolve(resume(first, true));
Assert.fail("已消费的审批令牌不能重复使用");
} catch (AgentRuntimeException expected) {
Assert.assertTrue(expected.getMessage().contains("invalid"));
}
}
/**
* 验证过期令牌不能签发工具执行授权。
*/
@Test
public void shouldExpireApprovalBeforeResolution() {
AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled();
AgentPendingState expired = coordinator.register(
"session-1",
"agent-1",
"call-expired",
"search",
"approve",
Map.of("q", "expired"),
Map.of(),
Instant.now().minusSeconds(1),
"batch-expired");
AgentToolApprovalResolution resolution = coordinator.resolve(resume(expired, true));
Assert.assertEquals(AgentToolApprovalResolution.Status.EXPIRED, resolution.getStatus());
assertAuthorizationRejected(coordinator, "call-expired", "search", Map.of("q", "expired"));
}
/**
* 验证并发重复点击同一令牌时最多一个请求可以成功消费。
*
* @throws Exception 并发任务执行失败时抛出
*/
@Test
public void shouldConsumeConcurrentDuplicateApprovalOnlyOnce() throws Exception {
AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled();
AgentPendingState pending = register(
coordinator, "call-1", "search", Map.of("q", "easyflow"), "batch-1");
CountDownLatch start = new CountDownLatch(1);
ExecutorService executor = Executors.newFixedThreadPool(2);
try {
Future<Boolean> first = executor.submit(() -> resolveAfter(start, coordinator, pending));
Future<Boolean> second = executor.submit(() -> resolveAfter(start, coordinator, pending));
start.countDown();
int successCount = (first.get() ? 1 : 0) + (second.get() ? 1 : 0);
Assert.assertEquals(1, successCount);
coordinator.consumeExecutionAuthorization(
"call-1", "search", Map.of("q", "easyflow"));
assertAuthorizationRejected(
coordinator, "call-1", "search", Map.of("q", "easyflow"));
} finally {
executor.shutdownNow();
}
}
/**
* 验证工具名称或入参变化时批准凭证立即失效。
*/
@Test
public void shouldRejectExecutionWhenApprovedCallIsModified() {
AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled();
AgentPendingState pending = register(
coordinator, "call-1", "search", Map.of("q", "easyflow"), "batch-1");
coordinator.resolve(resume(pending, true));
assertAuthorizationRejected(
coordinator, "call-1", "search", Map.of("q", "modified"));
assertAuthorizationRejected(
coordinator, "call-1", "search", Map.of("q", "easyflow"));
}
/**
* 验证跨节点受信任恢复仍需绑定明确的工具调用信息。
*/
@Test
public void shouldAuthorizeTrustedExecutionByToolCallIdentity() {
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", "search",
"toolInput", Map.of("q", "easyflow")));
coordinator.authorizeTrustedExecution(request);
coordinator.consumeExecutionAuthorization(
"call-1", "search", Map.of("q", "easyflow"));
assertAuthorizationRejected(
coordinator, "call-1", "search", Map.of("q", "easyflow"));
}
/**
* 验证同一 toolCallId 不能被重新绑定到不同工具内容。
*/
@Test
public void shouldRejectDuplicateToolCallIdWithDifferentInput() {
AgentToolApprovalCoordinator coordinator = AgentToolApprovalCoordinator.enabled();
register(coordinator, "call-1", "search", Map.of("q", "easyflow"), "batch-1");
try {
register(coordinator, "call-1", "search", Map.of("q", "modified"), "batch-2");
Assert.fail("重复 toolCallId 不能绑定不同入参");
} catch (AgentRuntimeException expected) {
Assert.assertTrue(expected.getMessage().contains("Duplicate toolCallId"));
}
}
/**
* 注册测试用审批状态。
*
* @param coordinator 审批协调器
* @param toolCallId 工具调用ID
* @param toolName 工具名称
* @param toolInput 工具入参
* @param batchId 审批批次ID
* @return 待审批状态
*/
private AgentPendingState register(AgentToolApprovalCoordinator coordinator,
String toolCallId,
String toolName,
Map<String, Object> toolInput,
String batchId) {
return coordinator.register(
"session-1",
"agent-1",
toolCallId,
toolName,
"approve",
toolInput,
Map.of(),
Instant.now().plusSeconds(60),
batchId);
}
/**
* 创建测试用恢复请求。
*
* @param state 待审批状态
* @param approved 是否批准
* @return 恢复请求
*/
private AgentResumeRequest resume(AgentPendingState state, boolean approved) {
AgentResumeRequest request = new AgentResumeRequest();
request.setResumeToken(state.getResumeToken());
request.setApproved(approved);
return request;
}
/**
* 等待并发起跑信号后消费审批令牌。
*
* @param start 起跑信号
* @param coordinator 审批协调器
* @param pending 待审批状态
* @return 成功消费时为 true
* @throws InterruptedException 等待被中断时抛出
*/
private boolean resolveAfter(CountDownLatch start,
AgentToolApprovalCoordinator coordinator,
AgentPendingState pending) throws InterruptedException {
start.await();
try {
coordinator.resolve(resume(pending, true));
return true;
} catch (AgentRuntimeException expected) {
return false;
}
}
/**
* 断言工具执行授权不可用。
*
* @param coordinator 审批协调器
* @param toolCallId 工具调用ID
* @param toolName 工具名称
* @param toolInput 工具入参
*/
private void assertAuthorizationRejected(AgentToolApprovalCoordinator coordinator,
String toolCallId,
String toolName,
Map<String, Object> toolInput) {
try {
coordinator.consumeExecutionAuthorization(toolCallId, toolName, toolInput);
Assert.fail("未授权或已消费的工具调用必须被拒绝");
} catch (AgentToolApprovalRejectedException expected) {
Assert.assertNotNull(expected.getMessage());
}
}
}