diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs index 865ef68d7..085408e7a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs @@ -391,7 +391,7 @@ pub(crate) fn ensure_static_delegate_user_input_wait_at_locked( let question_binding = game_creator_agent_user_input_action_input_summary(&action.input) .unwrap_or_else(|| "questionsSha256=unavailable".to_string()); let task = format!( - "子 Agent 需要用户澄清后才能继续。delegationId={};{question_binding}。请回答以下问题;回答完成后只创建一次 agent.delegate continuation,并将 repairOfDelegationId 与 continuationOfDelegationId 指向该原 delegation,同时提交 questionsSha256/answersSha256。", + "{AGENT_RUNTIME_DELEGATE_CLARIFICATION_TASK_PREFIX}{};{question_binding}。请回答以下问题;回答完成后只创建一次 agent.delegate continuation,并将 repairOfDelegationId 与 continuationOfDelegationId 指向该原 delegation,同时提交 questionsSha256/answersSha256。", delivery.delegation_id ); // Re-entry after a wake or restart may only reuse the exact request that diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs index 9f2187da8..542620fbd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs @@ -173,7 +173,20 @@ pub(in crate::agent) fn validate_agent_runtime_pending_context( { return Err("Agent Runtime 待确认动作身份与当前状态不匹配".to_string()); } - validate_agent_runtime_context_task_parameter(root, runtime, &pending.task)?; + // 转述 pending 是唯一一处 `task` 字段存的不是本 run 任务的动作:Runtime 代 + // Supervisor 汇总子 Agent 澄清问题时,把「回答后创建唯一 continuation 委派」 + // 那段指令连同 delegationId 写在这里(provider_recovery.rs),而 run 的 + // current_task 始终是用户原始请求。其余五个 pending 创建点传的都是 run 的真实 + // task,所以这条相等断言对它们成立,对转述则永不可能成立——D11 澄清路径实测 + // 每一次都会在 resume 时被判成 needs-reconciliation,用户答案已落盘却无法继续。 + // + // 这里只豁免文本相等这一条。pending 与 runtime 的 agent/task_id/session/run/ + // source 五项身份检查在上面已经全部通过,轮次检查在下面继续执行,转述 pending + // 本身也只能由 Runtime 在本 run 内生成,所以豁免不放开任何跨 run 或跨身份的 + // 重放面。 + if !agent_runtime_pending_is_delegate_clarification_relay(pending) { + validate_agent_runtime_context_task_parameter(root, runtime, &pending.task)?; + } if pending.loop_iteration != runtime.loop_iteration { return Err(format!( "Agent Runtime 待确认动作轮次与当前状态不匹配:pending={} state={}", @@ -820,6 +833,88 @@ mod tests { use super::*; use std::time::{SystemTime, UNIX_EPOCH}; + /// D11 转述路径的回归锚点。 + /// + /// Runtime 代 Supervisor 汇总子 Agent 澄清问题时,pending 的 `task` 存的是转述 + /// 指令(含 delegationId),而 run 的 current_task 始终是用户原始请求。此前这条 + /// 相等断言对转述 pending 永不可能成立:用户答完后 resume 立刻被判 + /// needs-reconciliation,答案已落盘却无法继续,整条立项策划链在第一次澄清就断。 + /// 生产 run 11/12/13 没暴露,只是因为澄清信封从来没触发过。 + #[test] + fn delegate_clarification_relay_pending_survives_the_task_identity_check() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after epoch") + .as_nanos(); + let temporary = crate::tests::canonical_test_tempdir(&format!( + "clarification-relay-task-{}-{unique}-", + std::process::id() + )); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "project-1", "澄清转述任务身份项目") + .expect("project init"); + let runtime = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "做个横版像素解谜小游戏", + "clarification-relay-run", + "agent-background-task", + "转述澄清问题", + vec!["等待用户回答后创建唯一 continuation 委派".to_string()], + ) + .expect("start supervisor runtime state"); + + let action = AgentRuntimeToolAction { + tool: GAME_CREATOR_USER_INPUT_REQUEST_TOOL.to_string(), + reason: Some("代 Supervisor 汇总子 Agent 的澄清问题".to_string()), + input: serde_json::json!({"questions": []}), + }; + let relay_task = format!( + "{AGENT_RUNTIME_DELEGATE_CLARIFICATION_TASK_PREFIX}delegation-abc;questionsSha256=fixture。请回答以下问题。" + ); + let build = |task: &str, action: &AgentRuntimeToolAction| { + build_game_creator_agent_runtime_pending_tool_action( + &root, + &runtime, + task, + &AgentRuntimeToolPlan::default(), + &[], + &read_game_creator_agent_runtime_project_revision(&root) + .expect("read project revision"), + &build_repository_startup_context_at(&root) + .expect("repository context") + .fingerprint, + action, + 0, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + AGENT_RUNTIME_PENDING_ACTION_STATUS_WAITING_FOR_USER_INPUT, + None, + ) + .expect("build pending action") + }; + + let relay = build(&relay_task, &action); + assert!(agent_runtime_pending_is_delegate_clarification_relay( + &relay + )); + assert_ne!(relay.task, runtime.current_task); + validate_agent_runtime_pending_context(&root, &runtime, &relay) + .expect("转述 pending 必须能通过身份校验,否则用户答案落盘后无法续跑"); + + // 豁免只覆盖转述本身。同一工具、同一 run,但 task 不是 Runtime 生成的 + // 转述指令时,原有的相等断言必须照旧拒绝——否则这就是个洞而不是修复。 + let forged = build("被改写的任务", &action); + assert!(!agent_runtime_pending_is_delegate_clarification_relay( + &forged + )); + assert!(validate_agent_runtime_pending_context(&root, &runtime, &forged).is_err()); + + // 身份五项仍然照查:换一个 run_id 的转述 pending 不得被放行。 + let mut cross_run = relay.clone(); + cross_run.run_id = format!("{}-other", cross_run.run_id); + assert!(validate_agent_runtime_pending_context(&root, &runtime, &cross_run).is_err()); + } + #[test] fn agent_runtime_context_bundle_restores_pre_checkpoint_window_boundary() { let unique = SystemTime::now() diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs index 120c8d2fe..e978b8ea1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs @@ -710,12 +710,7 @@ pub(crate) fn plan_clarification_pending_requires_project_lock_at( root: &Path, pending: &AgentRuntimePendingToolAction, ) -> Result { - let Some(delegation_id) = pending - .task - .strip_prefix("子 Agent 需要用户澄清后才能继续。delegationId=") - .and_then(|value| value.split(';').next()) - .map(str::trim) - .filter(|value| !value.is_empty()) + let Some(delegation_id) = agent_runtime_delegate_clarification_delegation_id(&pending.task) else { return Ok(false); }; @@ -730,12 +725,7 @@ fn validate_plan_clarification_answer_for_pending_at_unlocked( questions: &[AgentRuntimeUserInputQuestion], answers: &BTreeMap, ) -> Result<(), String> { - let Some(delegation_id) = pending - .task - .strip_prefix("子 Agent 需要用户澄清后才能继续。delegationId=") - .and_then(|value| value.split(';').next()) - .map(str::trim) - .filter(|value| !value.is_empty()) + let Some(delegation_id) = agent_runtime_delegate_clarification_delegation_id(&pending.task) else { return Ok(()); }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs index 0b8e2d84e..38c10db85 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs @@ -1631,12 +1631,7 @@ pub(crate) fn static_delegate_clarification_pending_matches_delivery_at( { return Ok(false); } - let Some(delegation_id) = pending - .task - .strip_prefix("子 Agent 需要用户澄清后才能继续。delegationId=") - .and_then(|value| value.split(';').next()) - .map(str::trim) - .filter(|value| !value.is_empty()) + let Some(delegation_id) = agent_runtime_delegate_clarification_delegation_id(&pending.task) else { return Ok(false); }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs index a722f630b..3f59a1cb8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs @@ -2564,7 +2564,7 @@ fn claimed_needs_user_input_delivery_becomes_one_supervisor_wait() { ); let mut spoofed = first.clone(); spoofed.task = format!( - "子 Agent 需要用户澄清后才能继续。delegationId={delegation_id};questionsSha256={}。", + "{AGENT_RUNTIME_DELEGATE_CLARIFICATION_TASK_PREFIX}{delegation_id};questionsSha256={}。", "a".repeat(64) ); assert!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/user_input.rs index bb8845517..9efa09623 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/user_input.rs @@ -2,6 +2,29 @@ use super::*; use sha2::{Digest, Sha256}; pub(crate) const GAME_CREATOR_USER_INPUT_REQUEST_TOOL: &str = "user.input_request"; +/// Runtime 代 Supervisor 汇总子 Agent 澄清问题时,pending 动作的 `task` 字段存的 +/// 不是本 run 的任务,而是这段由 Runtime 生成的转述指令,delegationId 编码在前缀 +/// 之后。系统里另外五处都要按这个前缀反解,此前各自抄了一份 `strip_prefix` 链; +/// 抄本之间一旦漂移,转述路径会静默失去与原 delivery 的绑定,所以收成一处。 +pub(crate) const AGENT_RUNTIME_DELEGATE_CLARIFICATION_TASK_PREFIX: &str = + "子 Agent 需要用户澄清后才能继续。delegationId="; + +/// 从转述 pending 的 `task` 里取回它绑定的原 delegationId;不是转述 task 返回 None。 +pub(crate) fn agent_runtime_delegate_clarification_delegation_id(task: &str) -> Option<&str> { + task.strip_prefix(AGENT_RUNTIME_DELEGATE_CLARIFICATION_TASK_PREFIX) + .and_then(|value| value.split(';').next()) + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +/// 该 pending 是否由 Runtime 代 Supervisor 生成的子 Agent 澄清转述。 +pub(crate) fn agent_runtime_pending_is_delegate_clarification_relay( + pending: &AgentRuntimePendingToolAction, +) -> bool { + pending.action.tool == GAME_CREATOR_USER_INPUT_REQUEST_TOOL + && agent_runtime_delegate_clarification_delegation_id(&pending.task).is_some() +} + pub(crate) const AGENT_RUNTIME_USER_INPUT_SCHEMA_VERSION: &str = "game-creator-runtime-user-input.v1"; pub(crate) const AGENT_RUNTIME_USER_INPUT_STATUS_PENDING: &str = "pending"; @@ -998,12 +1021,7 @@ fn bind_user_input_record_to_static_delegate_at( pending: &AgentRuntimePendingToolAction, record: &AgentRuntimeUserInputRecord, ) -> Result<(), String> { - let Some(delegation_id) = pending - .task - .strip_prefix("子 Agent 需要用户澄清后才能继续。delegationId=") - .and_then(|value| value.split(';').next()) - .map(str::trim) - .filter(|value| !value.is_empty()) + let Some(delegation_id) = agent_runtime_delegate_clarification_delegation_id(&pending.task) else { return Ok(()); };