From 9a3b9dce154e98dcd23b4cd2be20fe6ab737e323 Mon Sep 17 00:00:00 2001 From: Linghong Date: Fri, 21 Aug 2026 07:37:28 +0000 Subject: [PATCH] =?UTF-8?q?0=20=E8=BD=AE=E7=9B=B4=E5=87=BA=E5=8F=AF?= =?UTF-8?q?=E4=BB=A5=E6=A0=87=E5=8E=9F=E5=9E=8B=E9=AA=8C=E8=AF=81=E9=A1=B9?= =?UTF-8?q?=EF=BC=9B=E5=9D=8F=E4=BF=A1=E5=B0=81=E9=99=8D=E7=BA=A7=E6=88=90?= =?UTF-8?q?=E5=8F=AF=E8=BF=94=E5=B7=A5=E8=80=8C=E4=B8=8D=E6=98=AF=E5=88=A4?= =?UTF-8?q?=E6=AD=BB=E5=A7=94=E6=B4=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两条都是 run 17(571 字完整需求走生产链路)实测撞出来的,都在既有代码里, 都比刚修的台账约束更早触发。 N1 —— round=0 决定被钉死成 default_pending。用户一次把需求说全时全部决定都是 round=0,于是没有任何决定可能成为 prototype_pending;而 validate_decisions 的双射 又要求 prototypeValidationItems 逐项对应 prototype_pending 决定,结果首次 plan.submit_gdd 必被预检拒收,这份稿子也永远不可能带上原型验证项。实测子 Agent 读懂了拒绝理由,代价是把「触控手感」「30 回合是不是真的 8-12 分钟」这类没人验证过 的假设一律标成「默认,待确认」——那是在说谎,它们不是默认值。 round=0 真正要守的是「从未提问过的决定不得声称任何用户权威」,即 answerSource 必须是 default,而不是把状态钉死。改成允许 default_pending 与 prototype_pending 两种,answerSource 仍强制 default,confirmed 照旧拒。双射保持 严格不动——Agent 现在有合法途径同时给出决定和验证项。 N2 —— AGC_NEEDS_USER_INPUT_V1 的解析在 build_static_delegate_structured_result_at 里 `?` 往外传,而这个函数跑在投递回执时,子 run 已经 idle/completed,没有任何一轮 可以把解析错误回灌回去。实测一次 option 多写 `id` 字段就让整条委派 result_failed、 父 Supervisor 直接 needs-reconciliation 停下等人。不是预算设成 0,是结构上没地方 重试;对照原型,它在 run 循环内解析、坏了注入 INJ_ENVELOPE_REJECTED 重试 3 次。 信封格式属于「本次 Provider 输出写错」,与 plan.submit_gdd 的业务拒绝同类,不是 durable 权威损坏。降级成 needs-repair,并把解析失败在哪当返工理由带上(而不是把 那段无法解析的原文照抄回去,那对子 Agent 没有可操作信息)。Supervisor 用既有的一次 返工额度即可自愈。 role brief 补上 prototypeValidationItems:schema 把它列为 required,brief 此前 一个字没提它、也没提双射,模型只能靠撞。 Co-Authored-By: Claude Opus 5 --- .../prompts/runtime/roles/project-planning.md | 1 + .../runtime_protocol/planning_storage.rs | 33 +++++++-- .../agent/runtime_protocol/planning_submit.rs | 63 ++++++++++++++++ .../src-tauri/src/delegation.rs | 74 ++++++++++++++++++- 4 files changed, 160 insertions(+), 11 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/project-planning.md b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/project-planning.md index ccb206cea..e1f8e7685 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/project-planning.md +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/project-planning.md @@ -19,6 +19,7 @@ - 决定台账里,**事实归 Runtime、判断归你**。`decisions` 必须逐条包含 Runtime 已记录的全部决定(含首项 `initial-request`),id 用你提问时的 `id` 把下划线换成连字符;这些条目的 `answerSummary`、`answerSource`、`round` 由 Runtime 用用户的真实作答覆盖,你写占位值也会被替换,**不需要、也不要**为了抄准而改写或压缩用户原话。你真正决定的是 `topic` 和 `state`。 - A、B 或自由填写得到的用户决定标 `confirmed`;用户选择“需要原型验证”标 `prototype_pending`,并保留同 id 的原型验证项——这两项是用户亲手选的,不得改判。只有未提问、由你按默认建议填写的字段才标 `default_pending`,其 `answerSource=default`、`round=0`。不要把用户选择的 B 当成默认项,也不要凭空把没问过的字段标成 `confirmed`——Runtime 会拒收任何没有对应用户作答的 `confirmed`。 - 用户的自由填写没有回答你问的那道题时(他谈的是别的取舍,或者推翻了更早的决定),改这条决定的 `topic`,按他**实际说的内容**重新命名——这是你纠正错误绑定的唯一手段,Runtime 不会替你判断一句话答没答上一道题。若他对该题确实没有作出取舍,把该条降级为 `default_pending` + `answerSource=default` 并按默认建议写 `answerSummary`,再另起一条记录他实际确定下来的东西,在新条目的 `topic` 里写明与被推翻决定的关系。降级只能往这个方向;用户已作出的决定不得整条丢弃。 +- `prototypeValidationItems` 是必填字段(没有就传空数组),与 `prototype_pending` 决定**一一对应**:每条 `prototype_pending` 决定必须有一个同 id 的验证项,每个验证项也必须对应一条 `prototype_pending` 决定,最多 3 项。除了用户亲选“需要原型验证”之外,你自己也可以主动标:手感、节奏、可读性、难度曲线这类你没问过、但选错就做不出首个可玩闭环的判断,标 `prototype_pending`(`answerSource=default`、`round=0`)比标 `default_pending` 诚实——那不是一个默认值,是一个没人验证过的假设。每项写清 30~90 分钟微型原型做什么、让谁试玩、观察什么信号、什么算通过。 - 只定义一个完整可玩闭环。MVP 不含多人、商城、服务器、开放世界、赛季、复杂社交、完整剧情或全量内容,除非用户明确改变范围。 - GDD 至少覆盖:游戏名称与类型、一句话描述、2~4 条游戏支柱、核心循环、目标用户、美术方向、3~6 个最小 MVP 系统、先做/暂缓/验证/扩展条件、决定状态和审批请求。不要把 Runtime 注入的身份、时间、指纹、审批 receipt 或平台事实当作 Provider 输入字段。 - 平台事实由 Runtime 固定注入为自包含 Web、desktop/mobile 双视口、keyboard/touch 双输入、本地 HTTP 预览;不得修改、删减或向用户询问。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs index 166666990..0a99060a7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs @@ -880,13 +880,32 @@ fn validate_decisions( if decision.round > 3 { return Err(invalid(format!("decisions[{index}].round 不能超过 3"))); } - if decision.round == 0 - && decision.id != "initial-request" - && !(decision.state == "default_pending" && decision.answer_source == "default") - { - return Err(invalid( - "round=0 的非 initial-request 只能是 default_pending/default", - )); + // round=0 表示这条决定从未向用户提问过,因此它不能声称任何用户权威: + // answerSource 必须是 default。但它可以落在两种状态上——由 Agent 按默认 + // 建议填写(default_pending),或者 Agent 判定这项会实质影响首个可玩闭环、 + // 不该由它替用户拍板,需要一个 30~90 分钟微型原型来验证 + // (prototype_pending,并配同 id 的 prototypeValidationItems 项)。 + // + // 早期实现把 round=0 钉死成 default_pending。于是用户一次把需求说全、 + // 走 0 轮直出时,全部决定都是 round=0,没有任何决定可能成为 + // prototype_pending;而下面的双射又要求验证项逐项对应 prototype_pending + // 决定,结果是首次 plan.submit_gdd 必被预检拒收,且这份稿子永远不可能 + // 带上原型验证项。把一项未经验证的风险标成「默认,待确认」是在说谎: + // 那不是一个默认值,那是一个没人验证过的假设。 + if decision.round == 0 && decision.id != "initial-request" { + if decision.answer_source != "default" { + return Err(invalid(format!( + "decisions[{index}] round=0 未经提问,answerSource 只能是 default" + ))); + } + if !matches!( + decision.state.as_str(), + "default_pending" | "prototype_pending" + ) { + return Err(invalid(format!( + "decisions[{index}] round=0 只能是 default_pending 或 prototype_pending" + ))); + } } validate_text( &decision.answer_summary, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs index 2a0accee8..dd614e229 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs @@ -3694,6 +3694,69 @@ mod tests { .expect_err("non-initial decisions keep the 400 scalar bound"); } + /// N1:0 轮直出(用户一次把需求说全)时全部决定都是 round=0。Agent 必须能把 + /// 「我没问过、但选错会做不出首个可玩闭环」的判断标成 prototype_pending 并配 + /// 微型原型,而不是被迫标成 default_pending 假装那是个默认值。 + #[test] + fn an_unasked_risk_may_be_flagged_for_prototype_validation_at_round_zero() { + let mut input = valid_input(); + input.decisions.push(PlanSubmitDecision { + id: "touch-readability".to_string(), + topic: "触控放置手感与信息可读性".to_string(), + state: "prototype_pending".to_string(), + answer_source: "default".to_string(), + round: 0, + answer_summary: "没有向用户提问,选错会让首个可玩闭环立不住".to_string(), + }); + input + .prototype_validation_items + .push(PlanPrototypeValidationItem { + id: "touch-readability".to_string(), + question: "玩家能否在不读说明的情况下完成一次放置".to_string(), + micro_prototype: "用 30~90 分钟做一屏占位网格与三种占位单位".to_string(), + observation: "记录首次放置耗时与误触次数".to_string(), + pass_criterion: "3 名试玩者中 2 名在 30 秒内完成首次放置".to_string(), + }); + validate_plan_submit_gdd_input(&input) + .expect("an unasked risk may be flagged for prototype validation"); + + let (root, context, input) = submit_fixture_from(input); + execute_plan_submit_gdd(&root, &context, &input) + .expect("a zero-clarification draft may carry prototype validation items"); + let chain = read_plan_gdd_chain(&root).expect("read submitted chain"); + assert_eq!(chain[0].decisions[1].state, "prototype_pending"); + assert_eq!(chain[0].prototype_validation_items.len(), 1); + cleanup_fixture(root); + } + + /// round=0 放开的只是状态,不是权威:从未提问过的决定仍然不许声称用户拍过板, + /// 也不许挂上任何 user_* 来源。 + #[test] + fn a_round_zero_decision_still_cannot_claim_any_user_authority() { + let mut confirmed = valid_input(); + confirmed.decisions.push(PlanSubmitDecision { + id: "invented".to_string(), + topic: "没问过却声称已确认".to_string(), + state: "confirmed".to_string(), + answer_source: "default".to_string(), + round: 0, + answer_summary: "伪造".to_string(), + }); + validate_plan_submit_gdd_input(&confirmed).expect_err("round=0 may not be confirmed"); + + let mut sourced = valid_input(); + sourced.decisions.push(PlanSubmitDecision { + id: "invented".to_string(), + topic: "没问过却挂上用户来源".to_string(), + state: "prototype_pending".to_string(), + answer_source: "user_option".to_string(), + round: 0, + answer_summary: "伪造".to_string(), + }); + validate_plan_submit_gdd_input(&sourced) + .expect_err("round=0 may not carry a user answer source"); + } + #[test] fn submit_replay_reports_missing_session_without_guessing_or_allocating_version() { let (root, context, input) = submit_fixture(); 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 38c10db85..bd8b450b0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs @@ -1725,11 +1725,24 @@ pub(crate) fn build_static_delegate_structured_result_at( let verification_passed = !verification_required || (verification_status == Some("passed") && verified_revision.is_some()); let completed = terminal_status == "completed"; - let (user_input_questions, user_input_questions_sha256) = - parse_static_delegate_user_input_request(error)?; + // 终态信封在这里解析,而这里是**投递回执时**——子 run 此刻已经终止,没有任何 + // 一轮可以把解析错误回灌给它。早期实现在这一步直接 `?`,于是一次格式手滑 + // (实测:option 对象里多写了一个 `id` 字段)就把整条委派判成投递失败,父 + // Supervisor 直接进 needs-reconciliation 停下等人。 + // + // 信封格式属于「本次 Provider 输出写错」,不是「durable 权威损坏」,和 + // plan.submit_gdd 的业务拒绝同类。降级成 needs-repair 并把解析错误当返工理由 + // 带上:Supervisor 用既有的一次返工额度就能让子 Agent 重写,不需要人工介入。 + let (user_input_questions, user_input_questions_sha256, user_input_parse_error) = + match parse_static_delegate_user_input_request(error) { + Ok((questions, sha256)) => (questions, sha256, None), + Err(parse_error) => (None, None, Some(parse_error)), + }; let needs_user_input = completed && user_input_questions.is_some(); let contract_status = if needs_user_input { StaticDelegateContractStatus::NeedsUserInput + } else if user_input_parse_error.is_some() { + StaticDelegateContractStatus::NeedsRepair } else if completed && missing_expected_artifacts.is_empty() && verification_passed { StaticDelegateContractStatus::EvidenceReady } else { @@ -1749,8 +1762,19 @@ pub(crate) fn build_static_delegate_structured_result_at( }); } let derived_error = if contract_status == StaticDelegateContractStatus::NeedsRepair { - error - .map(|value| redact_agent_runtime_error(root, value, 500)) + // 坏信封的返工理由必须是**解析失败在哪**,而不是把那段无法解析的原文照抄 + // 回去——后者对子 Agent 没有任何可操作信息。 + user_input_parse_error + .map(|parse_error| { + redact_agent_runtime_error( + root, + &format!( + "AGC_NEEDS_USER_INPUT_V1 信封无法解析:{parse_error}。请严格按信封契约重写问题(questions 恰好一题,元素只含 id/header/question/options,option 只含 label/description),或者直接完成交付收束。" + ), + 500, + ) + }) + .or_else(|| error.map(|value| redact_agent_runtime_error(root, value, 500))) .filter(|value| !value.trim().is_empty()) .or_else(|| { if !completed { @@ -2873,6 +2897,48 @@ mod tests { assert!(error.contains("user.input_request")); } + /// 坏信封是 Provider 输出质量问题,不是 durable 权威损坏。它必须变成一次可返工 + /// 的 needs-repair,而不是把整条委派判成投递失败、把父 Supervisor 推进 + /// needs-reconciliation——解析发生在子 run 终止之后,那条路上没有任何一轮能自愈。 + #[test] + fn a_malformed_user_input_envelope_degrades_to_needs_repair_with_the_parse_reason() { + // 实测形态:option 对象里多写了一个 `id` 字段。 + let response = concat!( + "AGC_NEEDS_USER_INPUT_V1\n", + "{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·关键决定\",", + "\"question\":\"当前要决定:核心闭环形状。\",\"options\":[", + "{\"id\":\"a\",\"label\":\"A · 甲方案\",\"description\":\"甲方案的后果\"},", + "{\"id\":\"b\",\"label\":\"B · 乙方案\",\"description\":\"乙方案的后果\"},", + "{\"id\":\"c\",\"label\":\"需要原型验证\",\"description\":\"做个微型原型看看\"}]}]}" + ); + parse_static_delegate_user_input_request(Some(response)) + .expect_err("an option carrying an extra field is still a malformed envelope"); + + let result = build_static_delegate_structured_result_at( + &std::env::temp_dir(), + "completed", + &[], + false, + None, + None, + None, + Some(response), + ) + .expect("a malformed envelope must not fail the whole delivery"); + assert_eq!( + result.contract_status, + StaticDelegateContractStatus::NeedsRepair + ); + assert!(result.user_input_questions.is_empty()); + assert!(result.user_input_questions_sha256.is_none()); + let reason = result.error.as_deref().expect("a repair reason is derived"); + // 返工理由要说清错在哪,而不是把那段无法解析的原文照抄回去。 + assert!(reason.contains("AGC_NEEDS_USER_INPUT_V1 信封无法解析")); + assert!(reason.contains("label")); + validate_static_delegate_structured_result(&result, "completed", &[]) + .expect("the degraded result still validates"); + } + #[test] fn game_chat_safe_default_replacement_recovers_both_persisted_half_states() { let root = std::env::temp_dir().join(format!(