fix: 完善工具审批调用绑定
- 以 toolCallId、工具名称和入参绑定一次性执行授权 - 支持批次审批、重复调用去重及拒绝过期处理 - 补充多工具审批与授权消费回归测试
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user