fix(approval): deny approval-required tools in non-interactive runs

This commit is contained in:
matevip 2026-05-29 18:03:13 +08:00
parent ceb4da642b
commit d4ea75a806
2 changed files with 99 additions and 2 deletions

View File

@ -493,7 +493,7 @@ public class ToolExecutionExecutor {
// 2. ToolGuard 安全检查replay 模式跳过
if (!isReplay) {
GuardDecision decision = evaluateGuard(toolCall, toolName, arguments,
conversationId, agentId, toolCalls, i, events, requesterId);
conversationId, agentId, toolCalls, i, events, requesterId, safeOrigin);
if (decision.blocked) {
allResponses.add(new ToolResponseMessage.ToolResponse(
@ -933,7 +933,8 @@ public class ToolExecutionExecutor {
private GuardDecision evaluateGuard(AssistantMessage.ToolCall toolCall, String toolName, String arguments,
String conversationId, String agentId,
List<AssistantMessage.ToolCall> allToolCalls, int currentIndex,
List<GraphEventPublisher.GraphEvent> events, String requesterId) {
List<GraphEventPublisher.GraphEvent> events, String requesterId,
ChatOrigin origin) {
// Auto-grant requires BOTH the lookup cache and the resolver to be wired.
// Legacy constructors leave them null; in that case we skip workspace
// resolution and skip the resolver block, falling back to the original
@ -979,6 +980,13 @@ public class ToolExecutionExecutor {
// requiresHuman fall through to legacy human-approval path below.
}
// No human can resolve an approval in a non-interactive (scheduled-job)
// run, so a pending request would hang the turn until it times out with
// no answer. Deny immediately with an actionable message instead.
if (origin != null && origin.cronOrigin()) {
return denyNonInteractiveApproval(toolCall, toolName, events);
}
List<AssistantMessage.ToolCall> remaining = allToolCalls.subList(currentIndex + 1, allToolCalls.size());
String approvalResponse = ToolExecutionGuardHelper.handleToolApproval(
toolCall, toolName, arguments, evaluation,
@ -998,6 +1006,9 @@ public class ToolExecutionExecutor {
}
if (guardResult.needsApproval()) {
if (origin != null && origin.cronOrigin()) {
return denyNonInteractiveApproval(toolCall, toolName, events);
}
List<AssistantMessage.ToolCall> remaining = allToolCalls.subList(currentIndex + 1, allToolCalls.size());
String approvalResponse = ToolExecutionGuardHelper.handleToolApprovalLegacy(
toolCall, toolName, arguments, guardResult,
@ -1010,6 +1021,22 @@ public class ToolExecutionExecutor {
return GuardDecision.allowed();
}
/**
* Deny an approval-required tool when the run is non-interactive (no human can
* approve), returning an actionable message so the agent falls back to a
* non-gated built-in tool instead of stalling on a pending nobody resolves.
*/
private GuardDecision denyNonInteractiveApproval(AssistantMessage.ToolCall toolCall, String toolName,
List<GraphEventPublisher.GraphEvent> events) {
String msg = "[审批不可用] 该工具需要人工审批,但当前为非交互(定时任务)运行,无人可批准,"
+ "因此无法执行。请改用无需审批的内置工具完成本步骤(例如 PDF / XLSX / 文档技能、文件读写工具),"
+ "或跳过该步骤并说明原因,不要反复重试同一命令。";
log.info("[ToolExecutor] NON_INTERACTIVE_DENY: tool={} needs approval but origin is non-interactive (cron); "
+ "denying to avoid an unresolvable pending", toolName);
events.add(GraphEventPublisher.toolComplete(toolCall.id(), toolName, msg, false));
return GuardDecision.blocked(msg);
}
// ==================== 辅助方法 ====================
/**

View File

@ -0,0 +1,70 @@
package vip.mate.agent.graph.executor;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.ai.tool.metadata.ToolMetadata;
import vip.mate.agent.AgentToolSet;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.tool.guard.ToolGuard;
import vip.mate.tool.guard.ToolGuardResult;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* A tool that requires human approval cannot be resolved in a non-interactive
* (scheduled-job) run a pending request would hang the turn until it times out
* with no answer. The executor must deny such a tool immediately for a cron-origin
* invocation while still gating it normally for an interactive (web) origin.
*/
class ToolExecutionExecutorNonInteractiveApprovalTest {
private ToolExecutionExecutor executorRequiringApproval(ToolCallback cb) {
AgentToolSet toolSet = AgentToolSet.fromCallbacks(List.of(), List.of(cb));
ToolGuard needsApproval = (name, args) ->
ToolGuardResult.needsApproval("shell command execution requires approval", "shell_tool_default");
return new ToolExecutionExecutor(toolSet, needsApproval, null, null);
}
@Test
@DisplayName("cron origin denies an approval-required tool instead of creating an unresolvable pending")
void cronOriginDeniesApprovalRequiredTool() {
ToolExecutionExecutor executor = executorRequiringApproval(
stub("execute_shell_command", args -> "ran"));
AssistantMessage.ToolCall call = new AssistantMessage.ToolCall(
"c1", "function", "execute_shell_command", "{\"command\":\"ls\"}");
ChatOrigin cron = ChatOrigin.cron("conv_cron", null, null, null, null);
ToolExecutionExecutor.ToolExecutionResult result =
executor.execute(List.of(call), "conv_cron", "agent_x", false, "system", null, cron);
assertFalse(result.awaitingApproval(),
"non-interactive origin must not create a pending approval");
ToolResponseMessage.ToolResponse resp = result.responses().get(0);
assertTrue(resp.responseData().contains("[审批不可用]"),
"cron-origin approval-required tool should be denied with guidance, got: " + resp.responseData());
}
private static ToolCallback stub(String name, java.util.function.Function<String, String> handler) {
ToolDefinition def = ToolDefinition.builder()
.name(name)
.description("test tool " + name)
.inputSchema("{\"type\":\"object\",\"properties\":{}}")
.build();
ToolMetadata md = ToolMetadata.builder().returnDirect(false).build();
return new ToolCallback() {
@Override public ToolDefinition getToolDefinition() { return def; }
@Override public ToolMetadata getToolMetadata() { return md; }
@Override public String call(String arguments) { return handler.apply(arguments); }
@Override public String call(String arguments, ToolContext toolContext) {
return handler.apply(arguments);
}
};
}
}