mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 04:18:17 +08:00
retain selected Goal identity across delayed Web approvals
This commit is contained in:
parent
4616e08e09
commit
d080bf09f6
@ -595,6 +595,19 @@ public class ChatController {
|
||||
? (regenerateSeed.content() != null ? regenerateSeed.content() : "")
|
||||
: requestMessage;
|
||||
|
||||
// Snapshot selection on the request thread before the executor can be
|
||||
// delayed behind other work. A Goal abandoned during model inference
|
||||
// must still be recognizable when that turn asks for approval.
|
||||
final vip.mate.agent.context.ChatOrigin selectedTurnOrigin;
|
||||
try {
|
||||
selectedTurnOrigin = captureWebGoal(memoryOrigin(conversationId, username,
|
||||
requesterUserIdOf(auth), workspaceId, request.getEndUserId())
|
||||
.withBaseUrl(requestBaseUrl), agentId);
|
||||
} catch (vip.mate.exception.MateClawException invalidSelection) {
|
||||
sendErrorDoneAndComplete(emitter, invalidSelection.getMessage());
|
||||
return emitter;
|
||||
}
|
||||
|
||||
// ---- 正常请求:注册流状态并附着首个订阅者 ----
|
||||
streamTracker.register(conversationId);
|
||||
setupPermit.close();
|
||||
@ -656,10 +669,7 @@ public class ChatController {
|
||||
// RFC-063r §2.5: web entry — null channelId / no ChannelTarget;
|
||||
// tools that need a workspace path read it from the agent (origin
|
||||
// is enriched with workspaceBasePath in StateGraph buildInitialState).
|
||||
vip.mate.agent.context.ChatOrigin webOrigin =
|
||||
memoryOrigin(conversationId, username, requesterUserIdOf(auth), workspaceId, request.getEndUserId())
|
||||
.withBaseUrl(requestBaseUrl)
|
||||
.withOriginMessageId(originMessageId);
|
||||
vip.mate.agent.context.ChatOrigin webOrigin = selectedTurnOrigin.withOriginMessageId(originMessageId);
|
||||
Disposable disposable = agentService.chatStructuredStream(agentId, promptText, conversationId, username, request.getThinkingLevel(), webOrigin)
|
||||
.doOnNext(delta -> {
|
||||
if (emitterDone.get()) return;
|
||||
@ -1185,9 +1195,9 @@ public class ChatController {
|
||||
String promptText = buildPromptText(request.getMessage(), request.getContentParts());
|
||||
// Carry the web origin so per-owner memory recall (read) and the
|
||||
// post-conversation memory write below agree on the same owner key.
|
||||
vip.mate.agent.context.ChatOrigin webOrigin =
|
||||
vip.mate.agent.context.ChatOrigin webOrigin = captureWebGoal(
|
||||
memoryOrigin(request.getConversationId(), username, requesterUserIdOf(auth), workspaceId,
|
||||
request.getEndUserId()).withOriginMessageId(
|
||||
request.getEndUserId()), agentId).withOriginMessageId(
|
||||
savedUser == null ? null : savedUser.getId());
|
||||
AgentService.ChatResult result = turnGate.withPermit(permit, () ->
|
||||
agentService.chatWithUsage(agentId, promptText, request.getConversationId(), webOrigin));
|
||||
@ -1362,6 +1372,12 @@ public class ChatController {
|
||||
return vip.mate.agent.context.ChatOrigin.web(conversationId, username, workspaceId, null, baseUrl, requesterUserId);
|
||||
}
|
||||
|
||||
private vip.mate.agent.context.ChatOrigin captureWebGoal(
|
||||
vip.mate.agent.context.ChatOrigin origin, Long agentId) {
|
||||
var withAgent = origin.withAgent(agentId);
|
||||
return goalApprovalRuns == null ? withAgent : goalApprovalRuns.captureSelectedGoal(withAgent);
|
||||
}
|
||||
|
||||
/** Hold the selected Goal's approver identity through the approval write. */
|
||||
private ResolveOutcome resolveWithCurrentApprover(PendingApproval pending, Authentication auth,
|
||||
String username, boolean approve) {
|
||||
@ -1579,8 +1595,8 @@ public class ChatController {
|
||||
vip.mate.agent.context.ChatOrigin queuedOrigin =
|
||||
vip.mate.agent.context.ChatOrigin.web(conversationId, preConsumedInput.createdBy(),
|
||||
queuedConversation.getWorkspaceId(), null, baseUrl, preConsumedInput.requesterUserId())
|
||||
.withAgent(agentId)
|
||||
.withOriginMessageId(queuedOriginMessageId);
|
||||
queuedOrigin = captureWebGoal(queuedOrigin, agentId);
|
||||
Disposable disposable = agentService.chatStructuredStream(agentId, queuedMessage, conversationId, preConsumedInput.createdBy(), null, queuedOrigin)
|
||||
.doOnNext(delta -> {
|
||||
if (emitterDone.get()) return;
|
||||
|
||||
@ -38,7 +38,7 @@ public class GoalApprovalRunService {
|
||||
public ChatOrigin captureSelectedGoal(ChatOrigin origin) {
|
||||
if (origin == null || origin.cronOrigin() || origin.requesterUserId() == null
|
||||
|| origin.conversationId() == null || origin.agentId() == null || origin.workspaceId() == null
|
||||
|| origin.selectedGoalId() != null
|
||||
|| (origin.selectedGoalId() != null && origin.selectedGoalId() > 0)
|
||||
|| (origin.executionAttribution() != null
|
||||
&& origin.executionAttribution().goalAttemptId() != null)) return origin;
|
||||
var selected = jdbc.queryForList("""
|
||||
|
||||
@ -18,7 +18,7 @@ Prefix: `/api/v1/goals/{goalId}/json-acceptance`. An enabled account with conver
|
||||
|
||||
Publication requires an active or paused goal and a slot referenced by a current requirement. Content must be a strict JSON object: duplicate keys, trailing documents, nesting beyond 32 levels and UTF-8 content over 1 MiB are rejected. Each goal can retain at most 32 versions; the limit rejects new publication instead of overwriting history. Each version expires after 24 hours. Republishing identical bytes still creates a new version. Reload after a generation conflict rather than automatically overwriting another publication. Retries can reuse a suitable current version and refresh its check binding; reaching the quota still permits checking and completing with that version. If the version is expired or its content must change and all 32 versions are used, further publication remains unavailable.
|
||||
|
||||
Managed bodies live independently in the database. Ordinary workspace files, cache paths and hashes in text are not substitutes. No publication API edits historical bodies; bodies and pointers commit together. SHA-256 identifies content and supports integrity checks; it does not isolate an attacker with database credentials or host privileges. The database and service host are trusted foundations of this limited protocol. The JSON service contract has been exercised on H2, MySQL 8.0.46 and PostgreSQL 16.14. On the current source, MySQL and PostgreSQL each passed 72 JSON protocol cases and 9 opt-in HTTP approval/authentication cases. The MySQL run isolated an existing V192 migration failure using a test-only migration copy; PostgreSQL used the original Kingbase migration tree while skipping an unrelated bundled-skill import failure. These are protocol tests, not confirmation that an unmodified full installation succeeds. The proprietary Kingbase engine has not been tested.
|
||||
Managed bodies live independently in the database. Ordinary workspace files, cache paths and hashes in text are not substitutes. No publication API edits historical bodies; bodies and pointers commit together. SHA-256 identifies content and supports integrity checks; it does not isolate an attacker with database credentials or host privileges. The database and service host are trusted foundations of this limited protocol. The JSON service contract has been exercised on H2, MySQL 8.0.46 and PostgreSQL 16.14. MySQL and PostgreSQL each passed 72 JSON protocol cases on cycle060 and 11 opt-in HTTP approval/authentication cases on cycle061. The MySQL run isolated an existing V192 migration failure using a test-only migration copy; PostgreSQL used the original Kingbase migration tree while skipping an unrelated bundled-skill import failure. These are protocol tests, not confirmation that an unmodified full installation succeeds. The proprietary Kingbase engine has not been tested.
|
||||
|
||||
## Agent publication
|
||||
|
||||
@ -77,4 +77,4 @@ For a Goal with managed JSON requirements, Web approval consumption also locks a
|
||||
|
||||
An interactive managed Goal approval also retains the original requester account ID. A newly signed-in account with the same username cannot consume the old account’s pending approval; the user must make a new request under the new account. A persistent Goal attempt approval checks the current approver account ID and can be approved after the new account signs in.
|
||||
|
||||
An interactive approval now persists which Goal was selected when the approval was created. If that Goal becomes terminal or changes scope while the approval waits, approval cannot consume the pending record or replay its tool. The original enabled account can deny the pending approval to clear it. An approval saved before this snapshot existed is treated conservatively when its conversation has a terminal managed Goal; if its origin is missing, approval is refused when that conversation has managed Goal history. Such a pending request should be denied or allowed to expire, then issued again. New approvals created while no managed Goal is selected explicitly record that fact and retain the existing unselected path. The chat UI shows an approval as allowed only after the server confirms it; a rejected SSE request keeps the pending card visible.
|
||||
An interactive Web turn snapshots its selected Goal before entering the execution queue. Its approval persists that identity even if the Goal becomes terminal while the model is responding. If that Goal becomes terminal or changes scope, approval cannot consume the pending record or replay its tool. The original enabled account can deny the pending approval to clear it. An approval saved before this snapshot existed is treated conservatively when its conversation has a terminal managed Goal; if its origin is missing, approval is refused when that conversation has managed Goal history. Such a pending request should be denied or allowed to expire, then issued again. New approvals created while no managed Goal is selected explicitly record that fact and retain the existing unselected path. The chat UI shows an approval as allowed only after the server confirms it; a rejected SSE request keeps the pending card visible.
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
|
||||
仅当前要求引用的槽可发布,Goal 必须 active 或 paused。正文必须是严格 JSON 对象,拒绝重复键、尾随文档、超过 32 层的嵌套及超过 1 MiB 的 UTF-8 内容。每个 Goal 最多保存 32 个版本;达到配额拒绝继续发布,不覆盖旧版本。每版有效期 24 小时,重复发布同样正文也产生新版本。客户端遇到 generation 冲突应重新读取,不自动覆盖他人发布。重试可以复用适用的当前版本并更新检查绑定;达到配额后仍可检查当前版本并在合格时完成。如果版本已过期或正文必须修改且32个版本均已使用,则不能继续发布。
|
||||
|
||||
这些版本独立存储在数据库,不能用普通工作区文件、缓存路径或文字中的 hash 替代。发布接口不支持更新历史正文;所有版本与槽指针同事务保存。SHA-256 用于标识及完整性核对,不能隔离拥有数据库凭据或宿主权限的攻击者;数据库和服务宿主是此有限协议的可信基础。JSON 服务契约已在 H2、MySQL 8.0.46 和 PostgreSQL 16.14 上实测。当前源码在MySQL与PostgreSQL上各通过72项JSON协议案例及9项显式启用的HTTP审批/认证案例。MySQL 使用仅修正既有 V192 失败的临时迁移副本;PostgreSQL 使用原始 Kingbase 迁移树,跳过无关的内置技能导入失败。这是协议验证,不能代表未修改的完整安装成功;尚未实测 Kingbase 专有引擎。
|
||||
这些版本独立存储在数据库,不能用普通工作区文件、缓存路径或文字中的 hash 替代。发布接口不支持更新历史正文;所有版本与槽指针同事务保存。SHA-256 用于标识及完整性核对,不能隔离拥有数据库凭据或宿主权限的攻击者;数据库和服务宿主是此有限协议的可信基础。JSON 服务契约已在 H2、MySQL 8.0.46 和 PostgreSQL 16.14 上实测。MySQL与PostgreSQL在cycle060各通过72项JSON协议案例,在cycle061各通过11项显式启用的HTTP审批/认证案例。MySQL 使用仅修正既有 V192 失败的临时迁移副本;PostgreSQL 使用原始 Kingbase 迁移树,跳过无关的内置技能导入失败。这是协议验证,不能代表未修改的完整安装成功;尚未实测 Kingbase 专有引擎。
|
||||
|
||||
## 代理发布
|
||||
|
||||
@ -79,4 +79,4 @@ V200 保存待审批结算对应的准确 attempt,并对审批生成的新 att
|
||||
|
||||
交互式选定Goal的旧审批还绑定最初请求者的账户ID。同名新账户重新登录也不能消费旧账户留下的待审批记录;用户需要在新账户下发起新的请求。后台Goal的持久attempt审批按当前批准者账户ID复查,允许新账户在重新登录后批准。
|
||||
|
||||
交互式审批会持久记录创建审批时选定的Goal。若等待期间该Goal进入终态或归属范围改变,“批准”不能消费pending或重放工具;原启用账户仍可“拒绝”清理。升级前没有此快照的审批,若同一会话有已终结的受管Goal,将保守拒绝批准;origin缺失且会话存在受管Goal历史时也拒绝批准。用户可拒绝或等待其过期,再重新发起请求。未选定受管Goal时新建的审批会明确记录这一状态,沿用原有路径。聊天页面仅在服务端确认后显示“已允许”;SSE拒绝后保留待审批卡片。
|
||||
交互式Web请求在进入执行队列前快照选定的Goal。即使模型响应期间该Goal进入终态,随后创建的审批仍保留该身份。若该Goal进入终态或归属范围改变,“批准”不能消费pending或重放工具;原启用账户仍可“拒绝”清理。升级前没有此快照的审批,若同一会话有已终结的受管Goal,将保守拒绝批准;origin缺失且会话存在受管Goal历史时也拒绝批准。用户可拒绝或等待其过期,再重新发起请求。未选定受管Goal时新建的审批会明确记录这一状态,沿用原有路径。聊天页面仅在服务端确认后显示“已允许”;SSE拒绝后保留待审批卡片。
|
||||
|
||||
@ -17,7 +17,8 @@ class GoalJsonExternalApprovalIntegrationTest extends GoalJsonHttpRuntimeIntegra
|
||||
@CsvSource({"false,approval,true", "true,approval,true",
|
||||
"false,terminal-approval,true", "true,terminal-approval,true",
|
||||
"false,legacy-terminal-approval,true", "true,legacy-terminal-approval,true",
|
||||
"false,originless-terminal-approval,true", "true,originless-terminal-approval,true"})
|
||||
"false,originless-terminal-approval,true", "true,originless-terminal-approval,true",
|
||||
"false,late-terminal-approval,true", "true,late-terminal-approval,true"})
|
||||
void authenticatedGoalCompletesThroughHttpOrScheduledProductionRuntime(
|
||||
boolean plan, String entry, boolean accepted) throws Exception {
|
||||
super.authenticatedGoalCompletesThroughHttpOrScheduledProductionRuntime(plan, entry, accepted);
|
||||
|
||||
@ -92,12 +92,14 @@ class GoalJsonHttpRuntimeIntegrationTest {
|
||||
"false,reassigned-approval,true", "true,reassigned-approval,true",
|
||||
"false,terminal-approval,true", "true,terminal-approval,true",
|
||||
"false,legacy-terminal-approval,true", "true,legacy-terminal-approval,true",
|
||||
"false,originless-terminal-approval,true", "true,originless-terminal-approval,true"})
|
||||
"false,originless-terminal-approval,true", "true,originless-terminal-approval,true",
|
||||
"false,late-terminal-approval,true", "true,late-terminal-approval,true"})
|
||||
void authenticatedGoalCompletesThroughHttpOrScheduledProductionRuntime(boolean plan, String entry, boolean accepted) throws Exception {
|
||||
boolean approval = entry.endsWith("approval");
|
||||
boolean doubleApproval = entry.equals("scheduled-double-approval");
|
||||
boolean reassigned = entry.contains("reassigned");
|
||||
boolean terminal = entry.contains("terminal-");
|
||||
boolean lateTerminal = entry.startsWith("late-");
|
||||
boolean detached = entry.contains("detached");
|
||||
boolean foreign = entry.contains("foreign");
|
||||
boolean supervised = entry.startsWith("supervised");
|
||||
@ -285,8 +287,10 @@ class GoalJsonHttpRuntimeIntegrationTest {
|
||||
var firstSubscribed = new java.util.concurrent.CountDownLatch(1);
|
||||
var initialResponse = reactor.core.publisher.Sinks.<ChatResponse>one();
|
||||
var firstStream = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
var firstInvocation = new java.util.concurrent.atomic.AtomicReference<org.mockito.invocation.InvocationOnMock>();
|
||||
when(model.stream(any(Prompt.class))).thenAnswer(invocation -> {
|
||||
if (queued && firstStream.compareAndSet(true, false)) {
|
||||
if ((queued || lateTerminal) && firstStream.compareAndSet(true, false)) {
|
||||
firstInvocation.set(invocation);
|
||||
return initialResponse.asMono().flux().doOnSubscribe(subscription -> firstSubscribed.countDown());
|
||||
}
|
||||
return Flux.just(script.answer(invocation));
|
||||
@ -321,6 +325,22 @@ class GoalJsonHttpRuntimeIntegrationTest {
|
||||
assertTrue(coordinator.settle(run, outcome, java.time.LocalDateTime.now()));
|
||||
assertEquals("waiting_approval", continuations.get(goal.getId()).state());
|
||||
waiting = outcome.toString();
|
||||
} else if (lateTerminal) {
|
||||
String inFlightToken = token;
|
||||
var inFlight = java.util.concurrent.CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
return requestBody("POST", "/api/v1/chat/stream", inFlightToken,
|
||||
Map.of("agentId", String.valueOf(agentId), "conversationId", conversation, "message", message));
|
||||
} catch (Exception failure) {
|
||||
throw new java.util.concurrent.CompletionException(failure);
|
||||
}
|
||||
});
|
||||
assertTrue(firstSubscribed.await(10, java.util.concurrent.TimeUnit.SECONDS));
|
||||
goals.abandon(goal.getId(), username);
|
||||
assertEquals(GoalStatus.ABANDONED, goals.getById(goal.getId()).getStatus());
|
||||
assertEquals(reactor.core.publisher.Sinks.EmitResult.OK,
|
||||
initialResponse.tryEmitValue(model.call((Prompt) firstInvocation.get().getArgument(0))));
|
||||
waiting = inFlight.get(45, java.util.concurrent.TimeUnit.SECONDS);
|
||||
} else {
|
||||
waiting = requestBody("POST", "/api/v1/chat/stream", token,
|
||||
Map.of("agentId", String.valueOf(agentId), "conversationId", conversation, "message", message));
|
||||
@ -329,7 +349,8 @@ class GoalJsonHttpRuntimeIntegrationTest {
|
||||
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(lateTerminal ? GoalStatus.ABANDONED : 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(conversation, approvals.restoreChatOrigin(persistedOrigin).conversationId());
|
||||
@ -342,10 +363,12 @@ class GoalJsonHttpRuntimeIntegrationTest {
|
||||
else {
|
||||
assertEquals(userId, approvals.restoreChatOrigin(persistedOrigin).requesterUserId());
|
||||
assertEquals(goal.getId(), approvals.restoreChatOrigin(persistedOrigin).selectedGoalId());
|
||||
var approvalReplayOrigin = approvals.restoreChatOrigin(persistedOrigin)
|
||||
.withSelectedGoalId(null).withApprovalId(pendingId);
|
||||
assertEquals(goal.getId(), approvalRuns.captureSelectedGoal(approvalReplayOrigin).selectedGoalId(),
|
||||
"A replayed interactive approval can create another selected approval");
|
||||
if (!lateTerminal) {
|
||||
var approvalReplayOrigin = approvals.restoreChatOrigin(persistedOrigin)
|
||||
.withSelectedGoalId(null).withApprovalId(pendingId);
|
||||
assertEquals(goal.getId(), approvalRuns.captureSelectedGoal(approvalReplayOrigin).selectedGoalId(),
|
||||
"A replayed interactive approval can create another selected approval");
|
||||
}
|
||||
}
|
||||
Long approvedPlan = plan ? jdbc.queryForObject("SELECT id FROM mate_plan WHERE conversation_id=?", Long.class, conversation) : null;
|
||||
if (terminal) {
|
||||
@ -359,7 +382,7 @@ class GoalJsonHttpRuntimeIntegrationTest {
|
||||
approvals.getPending(pendingId).orElseThrow().setChatOrigin(null);
|
||||
jdbc.update("UPDATE mate_tool_approval SET chat_origin=NULL WHERE pending_id=?", pendingId);
|
||||
}
|
||||
goals.abandon(goal.getId(), username);
|
||||
if (!lateTerminal) goals.abandon(goal.getId(), username);
|
||||
assertEquals(GoalStatus.ABANDONED, goals.getById(goal.getId()).getStatus());
|
||||
if (entry.startsWith("legacy-")) {
|
||||
var oldApprovalOrigin = approvals.restoreChatOrigin(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user