mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +08:00
fix(agent): preserve Plan approval replay through requester-aware entry
This commit is contained in:
parent
716c765c3a
commit
92723d6919
@ -93,10 +93,17 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
||||
@Override
|
||||
public Flux<AgentService.StreamDelta> chatWithReplayStream(String userMessage, String conversationId,
|
||||
String toolCallPayload) {
|
||||
return chatWithReplayStream(userMessage, conversationId, toolCallPayload, "");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<AgentService.StreamDelta> chatWithReplayStream(String userMessage, String conversationId,
|
||||
String toolCallPayload, String requesterId) {
|
||||
setState(AgentState.RUNNING);
|
||||
try {
|
||||
log.info("[{}] Plan-Execute replay stream: conversationId={}", agentName, conversationId);
|
||||
Map<String, Object> inputs = buildInitialState(userMessage, conversationId);
|
||||
inputs.put(MateClawStateKeys.REQUESTER_ID, requesterId != null ? requesterId : "");
|
||||
|
||||
// 从 DB 恢复 awaiting_approval 状态的计划上下文(按 conversationId 过滤,避免并发会话误取)
|
||||
PlanningService.PlanResumeContext ctx = planningService.findAwaitingApprovalContext(conversationId);
|
||||
|
||||
@ -64,3 +64,5 @@ From V199, queued Web input stores the authenticated account ID at enqueue time,
|
||||
Approval replay restores the persisted runtime identity; approval does not renew an expired attempt lease or override account revocation. Legacy snapshots without an authenticated account ID cannot gain managed JSON access from a display username alone.
|
||||
|
||||
JWT requests match the signed userId to the current enabled account ID. Recreating an account with the same username does not let the old token modify managed requirements or acquire the new runtime identity. A missing or malformed ID requires a fresh login. Sliding renewal retains the validated account identity. Managed HTTP operations also lock and recheck the authenticated account ID inside their transaction, retaining that lock until the read or write finishes. A username alone or an in-flight identity whose account was replaced cannot access these endpoints.
|
||||
|
||||
After Web approval, Plan execution restores the original plan and approved call, retaining the requester and managed acceptance requirements. Approval itself does not replace a JSON check or complete the goal.
|
||||
|
||||
@ -66,3 +66,5 @@ Web排队消息从V199起保存入队时已认证账户的内部ID,普通Web
|
||||
审批重放还原持久化的运行身份,但审批不会延长过期 attempt 租约,也不能覆盖账户撤权。缺少认证账户 ID 的旧快照不能仅凭显示用户名获得托管 JSON 权限。
|
||||
|
||||
JWT请求同时核对签名令牌的userId与当前启用账户ID。同名账户重新创建后,旧账户令牌不能修改托管要求或取得新账户的运行身份;缺失或格式错误的ID需要重新登录。滑动续期沿用已验证的账户身份。 托管HTTP操作还会在事务内按认证账户ID加锁复查,并持锁至读取或修改结束;仅有用户名或已被替换的在途身份不能访问这些接口。
|
||||
|
||||
Web审批后的Plan执行会恢复原计划和已批准调用,并保留请求者及受管验收要求;审批通过本身不能替代JSON检查或完成目标。
|
||||
|
||||
@ -52,6 +52,10 @@ class GoalJsonHttpRuntimeIntegrationTest {
|
||||
@Autowired private GoalRecoveryService recovery;
|
||||
@Autowired private GoalSegmentRunner runner;
|
||||
@Autowired private GoalAttemptStore attempts;
|
||||
@Autowired private vip.mate.approval.ApprovalWorkflowService approvals;
|
||||
@Autowired private vip.mate.tool.guard.repository.ToolGuardRuleMapper guardRules;
|
||||
@Autowired private vip.mate.tool.guard.engine.ToolGuardRuleRegistry guardRegistry;
|
||||
@Autowired private vip.mate.tool.guard.service.ToolGuardConfigService guardConfig;
|
||||
@LocalServerPort private int port;
|
||||
|
||||
@org.junit.jupiter.api.BeforeEach
|
||||
@ -74,8 +78,10 @@ class GoalJsonHttpRuntimeIntegrationTest {
|
||||
"false,scheduled,false", "true,scheduled,false", "false,recovered,false", "true,recovered,false",
|
||||
"false,queued,true", "false,reuse,true", "true,reuse,true", "false,recheck,true", "true,recheck,true",
|
||||
"false,supervised,true", "true,supervised,true", "false,supervised-recovered,true", "true,supervised-recovered,true",
|
||||
"false,supervised,false", "true,supervised,false", "false,supervised-recovered,false", "true,supervised-recovered,false"})
|
||||
"false,supervised,false", "true,supervised,false", "false,supervised-recovered,false", "true,supervised-recovered,false",
|
||||
"false,approval,true", "true,approval,true"})
|
||||
void authenticatedGoalCompletesThroughHttpOrScheduledProductionRuntime(boolean plan, String entry, boolean accepted) throws Exception {
|
||||
boolean approval = entry.equals("approval");
|
||||
boolean supervised = entry.startsWith("supervised");
|
||||
boolean scheduled = entry.equals("scheduled") || entry.equals("recovered") || supervised;
|
||||
boolean reuse = entry.equals("reuse");
|
||||
@ -152,7 +158,13 @@ class GoalJsonHttpRuntimeIntegrationTest {
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
java.util.concurrent.atomic.AtomicReference<String> revision = new java.util.concurrent.atomic.AtomicReference<>();
|
||||
java.util.concurrent.atomic.AtomicReference<String> originalCheck = new java.util.concurrent.atomic.AtomicReference<>();
|
||||
var planApprovalReplay = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
org.mockito.stubbing.Answer<ChatResponse> script = invocation -> {
|
||||
if (approval && plan && planApprovalReplay.compareAndSet(true, false)) {
|
||||
// Plan replay asks again for the persisted approved call; ReAct forces it without an LLM call.
|
||||
return new ChatResponse(List.of(new Generation(AssistantMessage.builder().content("")
|
||||
.toolCalls(List.of(new AssistantMessage.ToolCall("approved-read", "function", "getManagedGoalJsonSlots", "{}"))).build())));
|
||||
}
|
||||
Prompt prompt = invocation.getArgument(0);
|
||||
int step = calls.getAndIncrement();
|
||||
if (recovered && step == (plan ? 1 : 0)) {
|
||||
@ -253,7 +265,42 @@ class GoalJsonHttpRuntimeIntegrationTest {
|
||||
when(model.getDefaultOptions()).thenReturn(org.springframework.ai.chat.prompt.ChatOptions.builder().model("json-http-fixture").build());
|
||||
when(modelFactory.buildFor(any(), any())).thenReturn(model);
|
||||
String message = "Produce, publish, check and complete the managed JSON report.";
|
||||
if (queued) {
|
||||
if (approval) {
|
||||
var rule = new vip.mate.tool.guard.model.ToolGuardRuleEntity();
|
||||
rule.setId(IdWorker.getId()); rule.setRuleId("json-http-approval-" + goal.getId());
|
||||
rule.setName("Offline managed JSON approval fixture"); rule.setDescription("Exercise the real approval replay path");
|
||||
rule.setToolName("getManagedGoalJsonSlots"); rule.setParamName("args");
|
||||
rule.setCategory("RESOURCE_ABUSE"); rule.setSeverity("MEDIUM"); rule.setDecision("NEEDS_APPROVAL");
|
||||
rule.setPattern("getManagedGoalJsonSlots"); rule.setBuiltin(false); rule.setEnabled(true); rule.setPriority(1000); rule.setDeleted(0);
|
||||
guardRules.insert(rule); guardRegistry.reload();
|
||||
var guard = guardConfig.getConfig(); guard.setEnabled(true); guardConfig.updateConfig(guard);
|
||||
try {
|
||||
String waiting = requestBody("POST", "/api/v1/chat/stream", token,
|
||||
Map.of("agentId", String.valueOf(agentId), "conversationId", conversation, "message", message));
|
||||
JsonNode pending = request("GET", "/api/v1/chat/" + conversation + "/pending-approvals", token, null).path("data");
|
||||
assertEquals(1, pending.size(), waiting);
|
||||
String pendingId = pending.get(0).path("pendingId").asText();
|
||||
assertEquals("getManagedGoalJsonSlots", pending.get(0).path("toolName").asText());
|
||||
assertEquals(GoalStatus.ACTIVE, goals.getById(goal.getId()).getStatus());
|
||||
assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM mate_goal_json_artifact WHERE goal_id=?", Integer.class, goal.getId()));
|
||||
String persistedOrigin = jdbc.queryForObject("SELECT chat_origin FROM mate_tool_approval WHERE pending_id=?", String.class, pendingId);
|
||||
assertEquals(userId, approvals.restoreChatOrigin(persistedOrigin).requesterUserId());
|
||||
Long approvedPlan = plan ? jdbc.queryForObject("SELECT id FROM mate_plan WHERE conversation_id=?", Long.class, conversation) : null;
|
||||
planApprovalReplay.set(plan);
|
||||
String replay = requestBody("POST", "/api/v1/chat/stream", token,
|
||||
Map.of("agentId", String.valueOf(agentId), "conversationId", conversation, "message", "/approve", "pendingApprovalId", pendingId));
|
||||
assertTrue(replay.contains("Managed JSON fixture completed."), replay);
|
||||
assertEquals("CONSUMED", jdbc.queryForObject("SELECT status FROM mate_tool_approval WHERE pending_id=?", String.class, pendingId));
|
||||
if (plan) {
|
||||
assertEquals(approvedPlan, jdbc.queryForObject("SELECT id FROM mate_plan WHERE conversation_id=?", Long.class, conversation),
|
||||
"Approval replay must finish the original plan without creating a replacement");
|
||||
assertEquals("completed", jdbc.queryForObject("SELECT status FROM mate_plan WHERE id=?", String.class, approvedPlan));
|
||||
}
|
||||
} finally {
|
||||
jdbc.update("DELETE FROM mate_tool_guard_rule WHERE id=?", rule.getId());
|
||||
guardRegistry.reload();
|
||||
}
|
||||
} else if (queued) {
|
||||
var response = java.util.concurrent.CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
return requestBody("POST", "/api/v1/chat/stream", token,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user