澄清卡不再叠一张通用确认卡,策划根委派免确认
Project CI / Repository checks (pull_request) Failing after 14s
Project CI / Backend tests (pull_request) Failing after 14s
Project CI / Frontend tests (pull_request) Successful in 4m4s
Project CI / Native shell tests (pull_request) Successful in 16m7s

子 Agent 要提问时,Runtime 在 parent-wake 屏障处把问题包成 user.input_request
pending(ensure_static_delegate_user_input_wait_at_locked),同一份问题再投影成
userInputRequest。这个 pending 只是问题的载体,user.input_request 也从来不在
GAME_CREATION_APP_COMMANDS 的 confirm 集合里,没有任何确认语义。但三个渲染面都
无条件把 pendingToolAction 也画成通用待确认卡,于是同一个请求出现两遍:上面是问答
卡,下面是拿 tool 名当标题、拿 questionCount/questionsSha256 这种取证摘要当副标题
的确认卡,还和问答卡在聊天视图里糊在一起。

加 agentRuntimePendingActionIsUserInputCarrier,在开发者 Runtime 面板、项目总控
概览卡和聊天视图三处统一把通用确认面判掉,只留问答卡。

立项策划根 Run 的 agent.delegate 同时改为免确认。plan 根的工具面按阶段收窄到「当前
唯一能推进链路的动作」,Delegate 阶段就只广告 agent.delegate 一个工具,让用户确认
「要不要执行唯一能做的那件事」没有决策含量,返工那一轮同理;这条链上真正由人把关的
关口是 §13.0 的 Fast GDD 审批卡,不动。source 只作弱判据,命中后必须过
validate_project_supervisor_plan_root_binding_at 强判据,否则做游戏那条根的委派确认
会被漂移或伪造的 binding 悄悄放开;绑定读盘只发生在本来就要确认的 agent.delegate
这一个组合上。

新增两条用例:Rust 侧一起断言 plan 根 delegate 免确认、plan 根上 agent.spawn_isolated
照旧确认、gui 根 delegate 照旧确认;前端侧断言载体 pending 只渲染问答卡,且
user.input_request 与 questionsSha256 都不出现在界面上。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 07:09:50 +00:00
parent 8e5e0a7fea
commit 2a44169ddb
5 changed files with 199 additions and 5 deletions
@@ -106,6 +106,35 @@ pub(crate) fn game_creator_agent_runtime_tool_policy_rule_for_run(
if matches!(blocked, Some(AgentRuntimeToolPolicyBlock::Denied(_))) {
return blocked;
}
// 立项策划根 Run 的委派不再要人工确认。plan 根的工具面本身按阶段收窄
// (`agent_runtime_plan_root_supervisor_tools_for_stage`):Delegate 阶段只广告
// `agent.delegate` 这一个工具,它就是当前唯一能推进链路的动作;让用户确认「要不要
// 执行唯一能做的那件事」没有决策含量,返工那一轮同理。这条链上真正由人把关的关口
// 是 §13.0 的 Fast GDD 审批卡,那个不动。
//
// source 只是弱判据,一旦它说 plan 就必须过 `validate_project_supervisor_plan_root_binding_at`
// 这个强判据——否则做游戏那条链的委派确认会被漂移或伪造的 binding 悄悄放开。绑定
// 读盘只发生在「本来就要确认的 agent.delegate」这一个组合上,不给其它工具的每次
// 策略判定加磁盘读。
if command_id == "agent.delegate"
&& matches!(
blocked,
Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(_))
)
{
match read_game_creator_agent_runtime_run_profile_binding(root, agent_id, run_id) {
Ok(Some(binding)) if binding.source == AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE => {
if let Err(error) =
validate_project_supervisor_plan_root_binding_at(root, agent_id, run_id)
{
return Some(AgentRuntimeToolPolicyBlock::Denied(error));
}
return None;
}
Ok(_) => {}
Err(error) => return Some(AgentRuntimeToolPolicyBlock::Denied(error)),
}
}
let (run_profile, _) = match agent_runtime_run_profile_identity_at(
root,
agent_id,
@@ -714,6 +714,95 @@ fn gui_root_delegate_and_spawn_isolated_are_unaffected_by_plan_root_symmetry() {
fs::remove_dir_all(root).ok();
}
/// 立项策划根 Run 的委派免确认,并且这条豁免既不外溢到做游戏那条根,也不外溢到别的工具。
///
/// plan 根的工具面按阶段收窄到「当前唯一能推进链路的动作」,Delegate 阶段就只有
/// `agent.delegate`;再要用户点一次确认没有决策含量,人的关口留在 Fast GDD 审批卡。
/// 判据取自持久 binding 的强判据,所以这里连同 gui 根一起断言:同一份策略下,做游戏的
/// 委派必须照旧停在待确认。
#[test]
fn plan_root_delegate_is_auto_while_other_roots_and_tools_still_confirm() {
let root = unique_project_path();
init_local_game_project_at(&root, "plan-root-delegate-auto", "立项策划根委派免确认")
.expect("project init");
write_project_permission_policy_at(
&root,
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: vec![
"agent.delegate".to_string(),
"agent.spawn_isolated".to_string(),
],
agent_policies: BTreeMap::new(),
},
)
.expect("write policy");
let plan_run_id = "plan-root-delegate-run";
bind_game_creator_agent_runtime_run_profile_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
plan_run_id,
AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE,
Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD),
None,
)
.expect("bind plan root");
assert!(
game_creator_agent_runtime_tool_policy_rule_for_run(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
plan_run_id,
None,
None,
"agent.delegate",
)
.is_none(),
"立项策划根的 agent.delegate 不应再要人工确认"
);
assert!(
matches!(
game_creator_agent_runtime_tool_policy_rule_for_run(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
plan_run_id,
None,
None,
"agent.spawn_isolated",
),
Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(_))
),
"免确认只给 agent.delegate,plan 根上其它 confirm 工具必须照旧"
);
let gui_run_id = "gui-root-delegate-run";
bind_game_creator_agent_runtime_run_profile_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
gui_run_id,
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE,
Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD),
None,
)
.expect("bind gui root");
assert!(
matches!(
game_creator_agent_runtime_tool_policy_rule_for_run(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
gui_run_id,
None,
None,
"agent.delegate",
),
Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(_))
),
"做游戏那条根的委派确认不能被 plan 根的豁免带走"
);
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn background_agent_runtime_asset_generation_respects_project_policy() {
let root = unique_project_path();
@@ -857,6 +857,23 @@ export function agentRuntimeNeedsUserInput(
);
}
export const AGENT_RUNTIME_USER_INPUT_REQUEST_TOOL = 'user.input_request';
// `user.input_request` 的 pending 不是等待批准的动作,而是问题本身的载体:子 Agent 以
// needs-user-input 终态退出后,Runtime 在 parent-wake 屏障处按信封原文构造这个 pending
// (`ensure_static_delegate_user_input_wait_at_locked`),同一份问题再投影成
// `userInputRequest`。它也从来不在 `GAME_CREATION_APP_COMMANDS` 的 confirm 集合里,
// 没有任何「确认/拒绝」语义。
//
// 通用待确认卡按 `tool` + `inputSummary` 渲染,套到它身上就是把同一个请求画两遍——标题
// 是原始工具名,副标题是 `questionCount=… · questionsSha256=…` 这种给日志看的取证摘要。
// 真正的交互面是问答卡,所以这里统一把通用确认面判掉。
export function agentRuntimePendingActionIsUserInputCarrier(
action: AgentRuntimePendingToolActionSummary | null | undefined,
) {
return action?.tool === AGENT_RUNTIME_USER_INPUT_REQUEST_TOOL;
}
export function matchingAgentRuntimeForSteer(
runtimes: Array<AgentRuntimeState | null | undefined>,
agentId: string,
@@ -29,6 +29,7 @@ import {
agentRuntimeCanRetry,
agentRuntimeNeedsUserInput,
agentRuntimeNextStepFromPhase,
agentRuntimePendingActionIsUserInputCarrier,
agentRuntimePlanStepText,
agentRuntimeWaitingOnFromPhase,
createAgentRuntimeUserInputResponseId,
@@ -398,14 +399,18 @@ export function AgentRuntimeStatusPanel({
!agentGoalStatusIsPaused(runtime.phase) &&
!pendingToolAction &&
Boolean(onRetryRuntimeTask);
const pendingActionIsUserInputCarrier =
agentRuntimePendingActionIsUserInputCarrier(pendingToolAction);
const canConfirm =
Boolean(runtime.runId) &&
Boolean(pendingToolAction?.actionId) &&
!pendingActionIsUserInputCarrier &&
agentRuntimeCanConfirm(runtime.status) &&
Boolean(onConfirmRuntimeTask);
const canReject =
Boolean(runtime.runId) &&
Boolean(pendingToolAction?.actionId) &&
!pendingActionIsUserInputCarrier &&
agentRuntimeCanConfirm(runtime.status) &&
Boolean(onRejectRuntimeTask);
const canCompact =
@@ -776,6 +781,8 @@ export function ProjectSupervisorRuntimePanel({
const pendingActionPresentation = pendingToolAction
? projectSupervisorPendingActionPresentation(pendingToolAction)
: null;
const pendingActionIsUserInputCarrier =
agentRuntimePendingActionIsUserInputCarrier(pendingToolAction);
const userInputRequest = runtime?.userInputRequest ?? null;
const needsUserInput = agentRuntimeNeedsUserInput(runtime);
const needsSupervisorReconciliation = Boolean(
@@ -953,6 +960,7 @@ export function ProjectSupervisorRuntimePanel({
) : null}
{pendingToolAction &&
pendingActionPresentation &&
!pendingActionIsUserInputCarrier &&
!readOnly &&
!needsSupervisorReconciliation ? (
<div
@@ -1287,9 +1295,14 @@ export function ProjectSupervisorRuntimeControls({
) => void | Promise<void>;
}) {
const pendingToolAction = runtime?.pendingToolAction ?? null;
const confirmableToolAction = agentRuntimePendingActionIsUserInputCarrier(
pendingToolAction,
)
? null
: pendingToolAction;
const userInputRequest = runtime?.userInputRequest ?? null;
const needsUserInput = agentRuntimeNeedsUserInput(runtime);
if (!pendingToolAction && !userInputRequest && !needsUserInput) {
if (!confirmableToolAction && !userInputRequest && !needsUserInput) {
return null;
}
@@ -1307,12 +1320,12 @@ export function ProjectSupervisorRuntimeControls({
待回答问题未能读取,请稍后重试。
</p>
) : null}
{pendingToolAction ? (
{confirmableToolAction ? (
<div className="pending-command" aria-label="项目总控 Agent 待确认动作">
<span>
{pendingToolAction.tool}
{pendingToolAction.inputSummary ? (
<small>{pendingToolAction.inputSummary}</small>
{confirmableToolAction.tool}
{confirmableToolAction.inputSummary ? (
<small>{confirmableToolAction.inputSummary}</small>
) : null}
</span>
<button
@@ -730,6 +730,52 @@ export function registerSupervisorRuntimeTests() {
).toHaveLength(0);
});
it('renders only the question card when the pending action is the user input carrier', async () => {
const runId = 'supervisor-user-input-carrier-run';
const request = agentRuntimeUserInputRequest({
agentId: 'project-supervisor',
sessionId: 'supervisor-session-active',
runId,
actionId: 'action-user-input-carrier',
});
// Runtime 在 parent-wake 屏障处把子 Agent 的澄清问题包成 user.input_request pending,
// 同一份问题再投影成 userInputRequest。pending 只是载体,没有确认语义,通用待确认卡
// 套上去就是把同一个请求画两遍,还会把 questionsSha256 这类取证摘要推到用户面前。
const harness = createProjectSupervisorRuntimeHarness({
initialRuntime: {
runId,
status: 'waiting-for-user-input',
phase: 'waiting-for-user-input',
currentTask: '准备首版角色规范图',
currentAction: '等待用户补充关键信息',
waitingOn: '你的澄清回答',
nextStep: '提交全部回答后继续同一 Run',
userInputRequest: request,
pendingToolAction: {
actionId: request.actionId,
actionFingerprint: 'fingerprint-user-input-carrier',
tool: 'user.input_request',
inputSummary:
'questionCount=1 · optionCount=2 · questionChars=302 · questionsSha256=6aecb0cf',
reason: '代 Supervisor 汇总子 Agent 的澄清问题',
requestedAt: 6000,
},
updatedAt: 6000,
},
});
window.__TAURI__ = {
core: { invoke: harness.invoke },
event: { listen: harness.listen },
};
renderAppAt('/');
await openMainProject(harness.projectPath);
await screen.findByLabelText('Needs input');
expect(screen.queryByLabelText('项目总控 Agent 待确认动作')).toBeNull();
expect(screen.queryByText('user.input_request')).toBeNull();
expect(screen.queryByText(/questionsSha256=/)).toBeNull();
});
it('recovers a Project Supervisor transient reply from runtime polling without persisting it', async () => {
const runId = 'supervisor-response-stream-recovery-run';
const initialRuntime = {