From dc0a168ef1da2e0c6399c4664680c1841edd1a38 Mon Sep 17 00:00:00 2001 From: Linghong Date: Mon, 24 Aug 2026 03:22:41 +0000 Subject: [PATCH] =?UTF-8?q?=E5=9D=8F=E4=BF=A1=E5=B0=81=E5=9C=A8=20run=20?= =?UTF-8?q?=E5=86=85=E5=B0=B1=E5=9C=B0=E9=87=8D=E5=8F=96=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E9=80=83=E9=80=B8=E6=88=90=E4=B8=80=E6=9D=A1=E5=A7=94?= =?UTF-8?q?=E6=B4=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按原型(local-scripts/deisgn_agent)的口径:信封解析失败是「本回合没推进 流程」,注入原因后在同一个 run 里重来,而不是终止 run 让它落成一条 needs-repair delivery。 接入点在 final reply 返回处。解析失败时把原因作为 observation 回灌,用带 后缀的 request slot 重取 final reply,上限 2 次(对齐原型 MAX_WASTED_TURNS=3:前两次注入重试,第三次放行)。用尽后按既有路径落盘, 此时「连写三次都不合法」确实是质量问题,按普通质量返工计费与返工深度、 澄清轮次、session 段落三套不变量全部自洽,不需要放松任何一道。 只覆盖 project-planning。做游戏 / 做素材的静态委派子 Agent 逐字保持既有 行为,它们的坏信封仍按原路落成 needs-repair。 Co-Authored-By: Claude Opus 5 --- .../src/agent/runtime_driver/main_loop.rs | 152 ++++++++++++++++-- .../src-tauri/src/delegation.rs | 2 +- 2 files changed, 139 insertions(+), 15 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs index 6856908c2..e77d83edf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs @@ -1,5 +1,30 @@ use super::*; +/// 策划子 Agent 的 `AGC_NEEDS_USER_INPUT_V1` 信封在 final reply 里写坏时,允许在 +/// **同一个 run 内**重取几次。 +/// +/// 原型(local-scripts/deisgn_agent)把信封解析失败当成「本回合没推进流程」,注入 +/// 错误后在同一条 messages 上重来,`MAX_WASTED_TURNS=3`(前两次注入重试,第三次判 +/// 本跳失败)。这里取同一口径:2 次重取,用尽后才让它按既有路径落成 needs-repair。 +/// +/// 为什么必须在这里拦:信封一旦随 final reply 逃逸,run 就终止并变成一条 +/// needs-repair delivery,之后返工深度、澄清轮次、session 段落三套不变量都会把它 +/// 当成「新段落」,而它们编码的是同一条假设——新委派 = 新段落。实测一次字节级截断 +/// 就能吃掉整条委派唯一的返工额度,两次则直接把父 run 打进 needs-reconciliation。 +const AGENT_RUNTIME_PLAN_ENVELOPE_REPAIR_ATTEMPTS: u32 = 2; + +/// 返回本条 final reply 里坏掉的信封的解析原因;不是策划子 Agent、或正文压根没有 +/// 信封首行时返回 None(后者是「这一轮不提问」的正常收束)。 +fn game_creator_agent_runtime_plan_envelope_parse_error( + agent_id: &str, + reply: &str, +) -> Option { + if agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + return None; + } + parse_static_delegate_user_input_request(Some(reply)).err() +} + struct PlanGddBlockerRuntimeProjection { phase: &'static str, current_action: &'static str, @@ -4274,20 +4299,64 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( &format!("持久化最终回复请求上下文失败:{error}"), ); } - let final_reply_result = request_game_creator_agent_background_final_reply_at( - &root, - &agent_id, - &runtime.session_id, - &runtime.run_id, - &task, - &plan, - final_reply_fallback.as_deref(), - &observations, - runtime.applied_steer_cursor, - &final_reply_request_slot, - response_revision, - ) - .await; + // 坏信封在这里就地重取,不让它随 final reply 逃逸成一条 needs-repair 委派。 + // 与 provider_tool_plan 对工具计划协议错误的定向修复同构:把解析原因作为 + // observation 回灌,下一次 final-reply 请求就带着它。 + let mut envelope_repair_attempt = 0u32; + let final_reply_result = loop { + let attempt_slot = if envelope_repair_attempt == 0 { + final_reply_request_slot.clone() + } else { + format!("{final_reply_request_slot}-envelope-repair-{envelope_repair_attempt}") + }; + let attempt_result = request_game_creator_agent_background_final_reply_at( + &root, + &agent_id, + &runtime.session_id, + &runtime.run_id, + &task, + &plan, + final_reply_fallback.as_deref(), + &observations, + runtime.applied_steer_cursor, + &attempt_slot, + response_revision, + ) + .await; + let parse_error = match &attempt_result { + Ok(RequestedAgentRuntimeFinalReplyOutcome::Ready(Some(requested_reply))) => { + game_creator_agent_runtime_plan_envelope_parse_error( + &agent_id, + &requested_reply.reply, + ) + } + _ => None, + }; + let Some(parse_error) = parse_error else { + break attempt_result; + }; + if envelope_repair_attempt >= AGENT_RUNTIME_PLAN_ENVELOPE_REPAIR_ATTEMPTS { + break attempt_result; + } + envelope_repair_attempt += 1; + let observation = AgentRuntimeToolObservation { + tool: "runtime.plan_envelope".to_string(), + status: "failed".to_string(), + summary: format!( + "AGC_NEEDS_USER_INPUT_V1 信封无法解析(第 {envelope_repair_attempt}/{AGENT_RUNTIME_PLAN_ENVELOPE_REPAIR_ATTEMPTS} 次重取):{parse_error}" + ), + detail: Some( + "原样重出同一个问题,不要改写题面或选项;信封必须是首行 AGC_NEEDS_USER_INPUT_V1,下一行严格 JSON 且完整闭合到最外层。不要输出 markdown、代码围栏或第三行正文。".to_string(), + ), + }; + let observation_summary = observation.summary(); + runtime.observations.push(observation_summary); + context_tracker.record(&observation); + observations.push(observation); + if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { + return AgentBackgroundTaskOutcome::Finished; + } + }; if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { return AgentBackgroundTaskOutcome::Finished; } @@ -4565,6 +4634,61 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( } } +#[cfg(test)] +mod plan_envelope_repair_tests { + use super::*; + + const TRUNCATED: &str = "AGC_NEEDS_USER_INPUT_V1 +{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·关键决定\",\"question\":\"当前要决定:?\",\"options\":[{\"label\":\"A\",\"description\":\"甲\"}]}"; + const COMPLETE: &str = "AGC_NEEDS_USER_INPUT_V1 +{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·关键决定\",\"question\":\"当前要决定:?\",\"options\":[{\"label\":\"A\",\"description\":\"甲\"},{\"label\":\"B\",\"description\":\"乙\"}]}]}"; + + /// 截断的信封必须在 run 内被认出来,否则它会随 final reply 逃逸成一条 + /// needs-repair 委派,把返工额度和澄清轮次一起卷进去。 + #[test] + fn a_truncated_planning_envelope_is_detected_before_the_run_ends() { + let error = game_creator_agent_runtime_plan_envelope_parse_error( + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + TRUNCATED, + ) + .expect("truncated envelope must be reported"); + assert!(error.contains("JSON"), "{error}"); + } + + /// 完整信封与普通收尾文本都不能触发重取——后者是「这一轮不提问」的正常终态。 + #[test] + fn complete_envelopes_and_plain_replies_do_not_trigger_a_repair() { + assert!(game_creator_agent_runtime_plan_envelope_parse_error( + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + COMPLETE + ) + .is_none()); + assert!(game_creator_agent_runtime_plan_envelope_parse_error( + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "已按默认建议补齐剩余空白,GDD 已提交待审批。" + ) + .is_none()); + } + + /// 只覆盖立项策划链路。做游戏 / 做素材的静态委派子 Agent 逐字保持既有行为: + /// 它们的坏信封仍旧按原路落成 needs-repair,不在这里被拦下重取。 + #[test] + fn other_agents_keep_the_existing_escape_path() { + for agent_id in ["design-director", "art-director", "project-supervisor"] { + assert!( + game_creator_agent_runtime_plan_envelope_parse_error(agent_id, TRUNCATED).is_none(), + "{agent_id} 不应进入策划信封重取" + ); + } + } + + /// 重取次数取原型的 MAX_WASTED_TURNS=3 口径:前两次注入重来,第三次放行落盘。 + #[test] + fn the_repair_budget_matches_the_prototype() { + assert_eq!(AGENT_RUNTIME_PLAN_ENVELOPE_REPAIR_ATTEMPTS, 2); + } +} + #[cfg(test)] mod plan_gdd_blocker_projection_tests { use super::*; 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 e1505a4db..a959e5023 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs @@ -1812,7 +1812,7 @@ pub(crate) fn build_static_delegate_structured_result_at( }) } -fn parse_static_delegate_user_input_request( +pub(crate) fn parse_static_delegate_user_input_request( response: Option<&str>, ) -> Result<(Option>, Option), String> { let Some(response) = response.map(str::trim).filter(|value| !value.is_empty()) else {