From f38ce456d1ac2cc48cb42445fa7b873e28b13f10 Mon Sep 17 00:00:00 2001 From: Linghong Date: Sun, 23 Aug 2026 17:05:30 +0000 Subject: [PATCH] =?UTF-8?q?=E5=9D=8F=E4=BF=A1=E5=B0=81=E9=87=8D=E6=8A=95?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=90=83=E6=8E=89=E5=94=AF=E4=B8=80=E7=9A=84?= =?UTF-8?q?=E8=BF=94=E5=B7=A5=E9=A2=9D=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 子 Agent 终态首行是 AGC_NEEDS_USER_INPUT_V1 但信封 JSON 解析不了时, Runtime 把它降级成 needs-repair,于是下一跳按质量返工计费,吃掉整条委派 唯一的 repair_depth。 生产实测这不是质量问题:农场经营项目里被截断那次的正文与重投那次逐字节 相同(445 vs 447 字符,前者是后者的严格前缀),只少了收尾的 `]}`, 报错是 EOF while parsing a list at line 1 column 904。一次字节级截断把 返工额度用光,之后真出现方案质量问题时已经没有返工可用。 structured result 新增可持久化标记 userInputEnvelopeUnparsable(旧记录 反序列化为 false,按原样当质量返工处理)。谱系计数遇到带这个标记的父节点 时既不加 repair_depth 也不重置 clarification_round,额度上限 1:连续第二次 落回返工分支,由既有的 repair_depth <= 1 兜住,不会无限重投。 任务正文同步加 EnvelopeRetry 一支。原来那句「唯一返工轮」对重投跳既是 假天花板,又是做游戏链路三个视觉角色 replaceExisting=true 的授权信号, 不能复用。Repair 分支逐字不变。 顺带把 cargo fmt 对前两个提交里新增代码的重排一并带上。 Co-Authored-By: Claude Opus 5 --- .../src-tauri/src/agent/prompt.rs | 6 +- .../src/agent/runtime_tools/delegation.rs | 61 +++++++++ .../src-tauri/src/agent_native_tools.rs | 8 +- .../src-tauri/src/delegation.rs | 118 ++++++++++++++++++ .../src/tests/collaboration/claims.rs | 1 + .../src/tests/collaboration/delegation.rs | 2 + .../tests/collaboration/static_deliveries.rs | 1 + 7 files changed, 193 insertions(+), 4 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs index 2323c3d71..41e6e26b8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs @@ -1587,8 +1587,10 @@ mod tests { assert!(plan.contains(required), "回灌格式缺少 {required}:{plan}"); } - let planning = - game_creator_agent_runtime_role_overlay_prompt(GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, None); + let planning = game_creator_agent_runtime_role_overlay_prompt( + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + None, + ); assert!( planning.contains("已确认决定关掉的那条轴不得重问"), "子 Agent 侧缺少重复提问的兜底约束:{planning}" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs index 3eec69a59..20f6b27e8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs @@ -214,12 +214,19 @@ pub(crate) fn observe_agent_runtime_agent_message( /// 平坦的 depth <= 1 门,那时「唯一返工轮」对澄清跳也成立;本仓库改成按谱系分类后 /// 把预算抬到 3,这句话就变成了假天花板——生产实测 4 次澄清续跑全部命中它,命中后 /// 全部直接出稿,没有任何一个 run 走到第 2 轮。 +/// - `EnvelopeRetry`:上一跳的 AGC_NEEDS_USER_INPUT_V1 信封解析不了(实测形态是 +/// 输出被截断,字节级前缀与重投那次完全相同、只少了收尾的 `]}`)。它不消耗 +/// `repair_depth`,所以这里也不能说「唯一返工轮」——那既是假天花板,又会在做游戏 +/// 链路上误触发三个视觉角色的 `replaceExisting=true` 授权。 /// - `None`:普通委派,不加这一段。 pub(in crate::agent) enum StaticDelegateHopNote<'a> { None, Repair { original_delegation_id: &'a str, }, + EnvelopeRetry { + original_delegation_id: &'a str, + }, PlanClarification { original_delegation_id: &'a str, rounds_used: u32, @@ -238,6 +245,11 @@ impl StaticDelegateHopNote<'_> { StaticDelegateHopNote::Repair { original_delegation_id, } => format!("\n\n这是对已认领委派 {original_delegation_id} 的唯一返工轮。"), + StaticDelegateHopNote::EnvelopeRetry { + original_delegation_id, + } => format!( + "\n\n上一轮对已认领委派 {original_delegation_id} 的 AGC_NEEDS_USER_INPUT_V1 信封没能解析完整,多半是那一轮输出被截断。这是同一轮的重投,不是返工轮,不消耗返工深度,也不改变已用澄清轮次。请原样重出上一轮那个问题,并确保信封 JSON 完整闭合到最外层的 `]}}`。" + ), // 预算用尽:planning_coordinator 出卡时会用 // `current_round >= 3` 直接拒掉第四张卡,所以这里不能再邀请提问, // 只能要求收稿——语义上等价于原型的 INJ_MUST_DRAFT_ROUNDS。 @@ -900,6 +912,16 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked( } else { None }; + // 坏信封重投在谱系计数里不吃返工深度,任务正文就不能继续叫它「唯一返工轮」。 + // 读不到 delivery 时退回 Repair 文案:宁可保守,也不凭空宣布这是免费重投。 + let envelope_retry = repair_of_delegation_id.as_deref().is_some_and(|original| { + list_static_delegate_deliveries_at(root).is_ok_and(|deliveries| { + deliveries + .iter() + .find(|delivery| delivery.delegation_id == original) + .is_some_and(static_delegate_original_envelope_was_unparsable) + }) + }); let hop_note = match ( repair_of_delegation_id.as_deref(), plan_clarification_rounds, @@ -911,6 +933,11 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked( rounds_limit, } } + (Some(original_delegation_id), None) if envelope_retry => { + StaticDelegateHopNote::EnvelopeRetry { + original_delegation_id, + } + } (Some(original_delegation_id), None) => StaticDelegateHopNote::Repair { original_delegation_id, }, @@ -2033,4 +2060,38 @@ mod tests { "预算用尽时不得再给出下一轮 header,那是一张永远递不上去的卡:{exhausted}" ); } + + /// 坏信封重投既不吃返工深度,任务正文也不能说它是返工轮——那句话同时是做游戏 + /// 链路三个视觉角色 `replaceExisting=true` 的授权信号。 + #[test] + fn envelope_retry_hop_note_is_neither_a_repair_round_nor_a_clarification_round() { + let retry = render_static_delegate_task_contract( + "任务", + "project-supervisor", + "run-1", + "delegation-new", + &["交付 game/fast_gdd.md".to_string()], + &["game/fast_gdd.md".to_string()], + StaticDelegateHopNote::EnvelopeRetry { + original_delegation_id: "delegation-old", + }, + ) + .expect("render envelope retry"); + assert!( + !retry.contains("唯一返工轮"), + "坏信封重投不是返工轮,复用这句话会误触发 replaceExisting 授权:{retry}" + ); + assert!( + !retry.contains("的澄清续跑"), + "重投是同一轮的重发,不得写成推进一轮的澄清续跑:{retry}" + ); + assert!( + retry.contains("不改变已用澄清轮次"), + "必须写明重投不推进轮次,否则子 Agent 会把 header 的 N 加一:{retry}" + ); + assert!( + retry.contains("delegation-old") && retry.contains("输出被截断"), + "重投必须指出原委派和真实原因,子 Agent 才知道要原样重出并闭合 JSON:{retry}" + ); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 19612be70..efe18dcf1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -2087,7 +2087,9 @@ mod tests { #[test] fn native_agent_delegate_rejects_partial_or_invalid_clarification_binding() { let mut single_digest = valid_delegate_input(json!("delegation-id"), Value::Null); - let object = single_digest.as_object_mut().expect("delegate input object"); + let object = single_digest + .as_object_mut() + .expect("delegate input object"); object.insert( "continuationOfDelegationId".to_string(), json!("delegation-id"), @@ -2099,7 +2101,9 @@ mod tests { .contains("必须成对提供")); let mut orphan_digests = valid_delegate_input(json!("delegation-id"), Value::Null); - let object = orphan_digests.as_object_mut().expect("delegate input object"); + let object = orphan_digests + .as_object_mut() + .expect("delegate input object"); object.insert("questionsSha256".to_string(), json!("a".repeat(64))); object.insert("answersSha256".to_string(), json!("b".repeat(64))); assert!(validate_native_agent_delegate_input(&orphan_digests) 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..a39b94102 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs @@ -69,6 +69,10 @@ pub(crate) fn schema_max_clarification_envelope() -> String { // 从未承诺给它 3 轮预算,因此取 1;其它 source(包括 Project Supervisor 常规协作)取 3。 const STATIC_DELEGATE_CLARIFICATION_ROUND_LIMIT_DEFAULT: u32 = 3; const STATIC_DELEGATE_CLARIFICATION_ROUND_LIMIT_GAME_CHAT: u32 = 1; +// 一条谱系上最多给几次「坏信封重投」的免费额度。取 1:信封写坏一次通常是这一轮 +// 输出没写完,重投就能拿到同一份内容;连续两次多半是子 Agent 真的不会写这个契约, +// 那就该按质量返工计费,让既有的 repair_depth <= 1 把它兜住。 +const STATIC_DELEGATE_ENVELOPE_RETRY_LIMIT: u32 = 1; // 链上重放的防环 / 防越界上限,远大于设计允许的最大 7 跳,纯粹是安全阀。 const STATIC_DELEGATE_LINEAGE_MAX_HOPS: usize = 32; @@ -173,6 +177,11 @@ pub(crate) struct StaticDelegateStructuredResult { pub(crate) user_input_questions: Vec, #[serde(default)] pub(crate) user_input_questions_sha256: Option, + /// 本次终态带了 AGC_NEEDS_USER_INPUT_V1 首行但信封 JSON 解析不了。 + /// 这是「本轮输出没写完/写坏」,不是子 Agent 的方案质量问题,所以谱系计数 + /// 不把它当返工跳。旧记录反序列化为 false,按质量返工照常处理。 + #[serde(default)] + pub(crate) user_input_envelope_unparsable: bool, } impl Default for StaticDelegateStructuredResult { @@ -187,6 +196,7 @@ impl Default for StaticDelegateStructuredResult { error: None, user_input_questions: Vec::new(), user_input_questions_sha256: None, + user_input_envelope_unparsable: false, } } } @@ -430,6 +440,7 @@ pub(crate) fn mark_static_delegate_delivery_ready_at( error: (terminal_status != "completed").then(|| result_summary.to_string()), user_input_questions: Vec::new(), user_input_questions_sha256: None, + user_input_envelope_unparsable: false, }; mark_static_delegate_delivery_ready_with_result_at( root, @@ -1275,6 +1286,18 @@ fn static_delegate_original_is_user_revision_requested( }) } +/// 上一跳的终态带了信封前缀但 JSON 解析不了——实测的形态是输出被截断,字节级 +/// 前缀完全相同、只少了收尾的 `]}`。这属于「这一轮没写完」,重投一次就能拿到 +/// 同一份内容,不该按方案质量返工计费。 +pub(crate) fn static_delegate_original_envelope_was_unparsable( + delivery: &StaticDelegateDeliveryRecord, +) -> bool { + delivery + .structured_result + .as_ref() + .is_some_and(|result| result.user_input_envelope_unparsable) +} + /// A newer durable contract status is intentionally not a repairable contract. /// The old client may preserve and report it, but must not manufacture a /// mutation under semantics it does not understand. @@ -1311,11 +1334,19 @@ pub(crate) fn static_delegate_lineage_counters( chain.reverse(); let mut depth = 0u32; let mut round = 0u32; + let mut envelope_retries = 0u32; for parent in &chain[..chain.len().saturating_sub(1)] { if static_delegate_original_is_awaiting_clarification(*parent) { round += 1; } else if static_delegate_original_is_user_revision_requested(*parent) { // 用户明确触发的修订不是 runaway-agent 返工;保留两个运行时派生计数。 + } else if static_delegate_original_envelope_was_unparsable(*parent) + && envelope_retries < STATIC_DELEGATE_ENVELOPE_RETRY_LIMIT + { + // 坏信封重投既不吃返工深度,也不重置澄清轮次:它没有推进也没有否定 + // 任何已确认的决定。给的额度是有限的——超出后落回下面的返工分支, + // 由 `repair_depth >= 1` 这道既有的门兜住,不会无限重投。 + envelope_retries += 1; } else { depth += 1; round = 0; @@ -1744,6 +1775,9 @@ pub(crate) fn build_static_delegate_structured_result_at( Ok((questions, sha256)) => (questions, sha256, None), Err(parse_error) => (None, None, Some(parse_error)), }; + // parse_static_delegate_user_input_request 只在首行确实是信封前缀时才会报错, + // 所以这个标记不会被普通文本终态误置。 + let user_input_envelope_unparsable = user_input_parse_error.is_some(); let needs_user_input = completed && user_input_questions.is_some(); let contract_status = if needs_user_input { StaticDelegateContractStatus::NeedsUserInput @@ -1809,6 +1843,7 @@ pub(crate) fn build_static_delegate_structured_result_at( error: derived_error, user_input_questions: user_input_questions.unwrap_or_default(), user_input_questions_sha256, + user_input_envelope_unparsable, }) } @@ -3310,6 +3345,89 @@ mod tests { delivery } + /// 坏信封(首行是 AGC_NEEDS_USER_INPUT_V1 但 JSON 解析不了)重投不吃返工深度, + /// 但额度有限:连续第二次就落回质量返工,由既有的 depth<=1 门兜住。 + /// + /// 生产实测的形态是输出被截断——重投那次的正文与被截断那次逐字节相同,只多出 + /// 收尾的 `]}`。原实现把它记成质量返工,一次字节级截断就把整条委派唯一的返工 + /// 额度吃光,之后真出现方案质量问题时已经没有返工可用。 + #[test] + fn static_delegate_unparsable_envelope_retry_is_free_once_then_costs_repair_depth() { + let root = std::env::temp_dir().join(format!( + "genarrative-static-envelope-retry-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time after unix epoch") + .as_nanos() + )); + init_local_game_project_at(&root, "m1c0-envelope-retry", "坏信封重投计费测试") + .expect("project init"); + let parent_run_id = "m1c0-envelope-retry-parent-run"; + + let mut mark_unparsable = |delegation_id: &str, repair_of: Option<&str>| { + let mut delivery = claimed_static_delegate_for_lineage_test( + parent_run_id, + delegation_id, + repair_of, + StaticDelegateContractStatus::NeedsRepair, + ); + delivery + .structured_result + .as_mut() + .expect("structured result") + .user_input_envelope_unparsable = true; + write_static_delegate_delivery_at(&root, &delivery).expect("write delivery"); + delivery + }; + + let d0 = mark_unparsable("m1c0-envelope-retry-d0", None); + let d1 = mark_unparsable("m1c0-envelope-retry-d1", Some(&d0.delegation_id)); + let deliveries = list_static_delegate_deliveries_at(&root).expect("list deliveries"); + assert_eq!( + static_delegate_lineage_counters(&deliveries, &d1.delegation_id), + (0, 0), + "第一次坏信封重投既不吃返工深度,也不动澄清轮次" + ); + validate_static_delegate_repair_request_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + "m1c0-envelope-retry-d2", + "design-director", + &[], + &[], + Some(&d1.delegation_id), + ) + .expect("first envelope retry must leave the quality-repair budget intact"); + + let d2 = claimed_static_delegate_for_lineage_test( + parent_run_id, + "m1c0-envelope-retry-d2", + Some(&d1.delegation_id), + StaticDelegateContractStatus::NeedsRepair, + ); + write_static_delegate_delivery_at(&root, &d2).expect("write d2"); + let deliveries = list_static_delegate_deliveries_at(&root).expect("list deliveries"); + assert_eq!( + static_delegate_lineage_counters(&deliveries, &d2.delegation_id), + (1, 0), + "免费额度只有一次,第二次坏信封按质量返工计费" + ); + let blocked = validate_static_delegate_repair_request_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + "m1c0-envelope-retry-d3", + "design-director", + &[], + &[], + Some(&d2.delegation_id), + ) + .expect_err("repeated bad envelopes must fall back to the depth gate"); + assert!(blocked.contains("深度最多为 1"), "{blocked}"); + } + #[test] fn static_delegate_user_revision_preserves_counters_and_bypasses_depth_gate() { let root = std::env::temp_dir().join(format!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/claims.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/claims.rs index fa3e6a0bb..efe8d653e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/claims.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/claims.rs @@ -1007,6 +1007,7 @@ fn project_supervisor_static_ready_receipt_over_budget_is_non_mutating() { error: None, user_input_questions: Vec::new(), user_input_questions_sha256: None, + user_input_envelope_unparsable: false, }; mark_static_delegate_delivery_ready_with_result_at( &root, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs index cb0c43cfb..ed1cde8b9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs @@ -1467,6 +1467,7 @@ fn canvas_replacement_requires_the_claimed_original_and_unique_repair_contract() error: None, user_input_questions: Vec::new(), user_input_questions_sha256: None, + user_input_envelope_unparsable: false, }); original.claimed_by_action_id = Some("canvas-replacement-claim-action".to_string()); create_or_read_static_delegate_delivery_at(&root, &original) @@ -1694,6 +1695,7 @@ async fn canvas_replacement_rejects_parent_run_that_terminates_during_external_r error: None, user_input_questions: Vec::new(), user_input_questions_sha256: None, + user_input_envelope_unparsable: false, }); original.claimed_by_action_id = Some("canvas-repair-claim-action".to_string()); create_or_read_static_delegate_delivery_at(&root, &original) 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 c1f1a4e61..b5d2cb211 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 @@ -6739,6 +6739,7 @@ fn legacy_pre_clarification_delivery_repair_counts_toward_depth() { error: Some("缺少 1 个预期产物".to_string()), user_input_questions: Vec::new(), user_input_questions_sha256: None, + user_input_envelope_unparsable: false, }); legacy_delivery.claimed_by_action_id = Some("legacy-delivery-claim-action".to_string()); write_static_delegate_delivery_at(&root, &legacy_delivery).expect("persist legacy delivery");