From f7644ff5c5ecdc5bceac7c545273a92f3c233b10 Mon Sep 17 00:00:00 2001 From: Linghong Date: Mon, 24 Aug 2026 02:42:29 +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=BE=97=E9=87=8D=E7=BD=AE=E6=BE=84=E6=B8=85=E8=BD=AE?= =?UTF-8?q?=E6=AC=A1=EF=BC=8C=E9=A2=9D=E5=BA=A6=E6=8C=89=E8=BF=9E=E7=BB=AD?= =?UTF-8?q?=E6=AC=A1=E6=95=B0=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 无头验证抓到的回归。上一版把坏信封的免费额度做成整条谱系一个总额,超出后 落回质量返工分支——而那一支会把 round 归零。 实测复现的链:坏信封 → 第1轮 → 第2轮 → 坏信封 → 目标。第二次截断超出总额, round 被清零,planning_coordinator 于是按 current_round + 1 要求 「第1轮·关键决定」,子 Agent 按真实轮号写的 header 被拒,父 run 卡进 needs-reconciliation: PLAN_INVALID_CLARIFICATION: plan question header 必须精确等于 第1轮·关键决定 两处都改: - 坏信封跳**永远不重置** round。一次输出被截断既没有推进也没有否定任何已 确认的决定,凭什么把用户已经答过的两轮作废。 - 额度按**连续**次数算,不是整条谱系一个总额。分散在链上的多次截断各自独立; 连续多次才说明子 Agent 真的不会写这个契约,那时才计返工深度。 跳类型文案跟着改:新增 static_delegate_next_hop_is_free_envelope_retry, 只在本跳确实免费时才说「不消耗返工深度」,否则退回质量返工文案——否则文案 在超额那一跳上是假的。 Co-Authored-By: Claude Opus 5 --- .../src/agent/runtime_tools/delegation.rs | 5 +- .../src-tauri/src/delegation.rs | 152 +++++++++++++++++- 2 files changed, 145 insertions(+), 12 deletions(-) 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 cb96ba65b..f8aec8f1f 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 @@ -938,10 +938,7 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked( // 读不到 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) + static_delegate_next_hop_is_free_envelope_retry(&deliveries, original) }) }); let hop_note = match ( 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 a39b94102..e88eac57d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs @@ -1334,22 +1334,31 @@ pub(crate) fn static_delegate_lineage_counters( chain.reverse(); let mut depth = 0u32; let mut round = 0u32; - let mut envelope_retries = 0u32; + let mut consecutive_envelope_retries = 0u32; for parent in &chain[..chain.len().saturating_sub(1)] { if static_delegate_original_is_awaiting_clarification(*parent) { round += 1; + consecutive_envelope_retries = 0; } 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; + consecutive_envelope_retries = 0; + } else if static_delegate_original_envelope_was_unparsable(*parent) { + // 坏信封重投**永远不重置澄清轮次**:一次输出被截断既没有推进也没有否定 + // 任何已确认的决定,把 round 归零会让 planning_coordinator 回头要求 + // 「第1轮·关键决定」,子 Agent 按真实轮号写的 header 随即被拒,整条 + // 父 run 卡进 needs-reconciliation(实测复现过)。 + // + // 额度按**连续次数**算,不是整条谱系一个总额:分散在链上的多次截断 + // 各自独立,连续多次才说明子 Agent 真的不会写这个契约,那时才按质量 + // 返工计费,由既有的 `repair_depth >= 1` 兜住。 + consecutive_envelope_retries += 1; + if consecutive_envelope_retries > STATIC_DELEGATE_ENVELOPE_RETRY_LIMIT { + depth += 1; + } } else { depth += 1; round = 0; + consecutive_envelope_retries = 0; } } (depth, round) @@ -1372,6 +1381,26 @@ pub(crate) fn static_delegate_lineage_contains_unknown_contract_status( })) } +/// 以 `original_delegation_id` 为父的下一跳,是否还落在坏信封免费重投额度内。 +/// +/// 额度按连续次数算,所以只需要数**紧挨着链尾**的那串连续坏信封节点。跳类型 +/// 文案用它决定说「不消耗返工深度」还是退回质量返工文案——说反了就是撒谎。 +pub(crate) fn static_delegate_next_hop_is_free_envelope_retry( + deliveries: &[StaticDelegateDeliveryRecord], + original_delegation_id: &str, +) -> bool { + let Some(chain) = static_delegate_lineage_nodes(deliveries, original_delegation_id) else { + return false; + }; + // chain 是 [original .. 根],正好按「从链尾往回数」的顺序排列。 + let consecutive = chain + .iter() + .take_while(|node| static_delegate_original_envelope_was_unparsable(node)) + .count(); + consecutive > 0 + && u32::try_from(consecutive).unwrap_or(u32::MAX) <= STATIC_DELEGATE_ENVELOPE_RETRY_LIMIT +} + fn static_delegate_lineage_nodes<'a>( deliveries: &'a [StaticDelegateDeliveryRecord], delegation_id: &str, @@ -3428,6 +3457,113 @@ mod tests { assert!(blocked.contains("深度最多为 1"), "{blocked}"); } + /// 坏信封重投永远不重置澄清轮次,额度也按**连续**次数算而不是整条谱系一个总额。 + /// + /// 生产实测复现过:链是 坏信封 → 第1轮 → 第2轮 → 坏信封 → 目标,第二次截断把 + /// round 归零,planning_coordinator 回头要求「第1轮·关键决定」,子 Agent 按真实 + /// 轮号写的 header 被拒,父 run 卡进 needs-reconciliation。 + #[test] + fn static_delegate_envelope_retry_never_resets_the_clarification_round() { + let root = std::env::temp_dir().join(format!( + "genarrative-static-envelope-round-{}-{}", + 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-round", "坏信封不重置轮次测试") + .expect("project init"); + let parent_run_id = "m1c0-envelope-round-parent-run"; + + let write = |delegation_id: &str, + repair_of: Option<&str>, + status: StaticDelegateContractStatus, + unparsable: bool| { + let needs_user_input = status == StaticDelegateContractStatus::NeedsUserInput; + let mut delivery = claimed_static_delegate_for_lineage_test( + parent_run_id, + delegation_id, + repair_of, + status, + ); + let result = delivery + .structured_result + .as_mut() + .expect("structured result"); + result.user_input_envelope_unparsable = unparsable; + if needs_user_input { + // needs-user-input 的落盘校验要求问题非空且指纹匹配。 + let envelope = format!( + "{STATIC_DELEGATE_USER_INPUT_PREFIX}{}", + serde_json::json!({ + "questions": [{ + "id": "core_loop", + "header": "关键决定", + "question": "本轮要定什么?", + "options": [ + {"label": "A", "description": "方案 A"}, + {"label": "B", "description": "方案 B"} + ] + }] + }) + ); + let (questions, sha256) = parse_static_delegate_user_input_request(Some(&envelope)) + .expect("fixture clarification envelope"); + result.user_input_questions = questions.expect("fixture questions"); + result.user_input_questions_sha256 = sha256; + } + write_static_delegate_delivery_at(&root, &delivery).expect("write delivery"); + delivery + }; + + let d0 = write( + "m1c0-envelope-round-d0", + None, + StaticDelegateContractStatus::NeedsRepair, + true, + ); + let d1 = write( + "m1c0-envelope-round-d1", + Some(&d0.delegation_id), + StaticDelegateContractStatus::NeedsUserInput, + false, + ); + let d2 = write( + "m1c0-envelope-round-d2", + Some(&d1.delegation_id), + StaticDelegateContractStatus::NeedsUserInput, + false, + ); + let d3 = write( + "m1c0-envelope-round-d3", + Some(&d2.delegation_id), + StaticDelegateContractStatus::NeedsRepair, + true, + ); + let d4 = write( + "m1c0-envelope-round-d4", + Some(&d3.delegation_id), + StaticDelegateContractStatus::NeedsUserInput, + false, + ); + + let deliveries = list_static_delegate_deliveries_at(&root).expect("list deliveries"); + assert_eq!( + static_delegate_lineage_counters(&deliveries, &d4.delegation_id), + (0, 2), + "两次截断被 2 轮澄清隔开,各自都在免费额度内,轮次必须保持 2" + ); + assert!( + static_delegate_next_hop_is_free_envelope_retry(&deliveries, &d3.delegation_id), + "链尾只有一个连续坏信封,下一跳仍是免费重投" + ); + assert!( + !static_delegate_next_hop_is_free_envelope_retry(&deliveries, &d2.delegation_id), + "父节点不是坏信封时不得声称是免费重投" + ); + } + #[test] fn static_delegate_user_revision_preserves_counters_and_bypasses_depth_gate() { let root = std::env::temp_dir().join(format!(