diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/supervisor-playbook.md b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/supervisor-playbook.md index 529d7c2bc..54e8f1e59 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/supervisor-playbook.md +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/supervisor-playbook.md @@ -3,13 +3,14 @@ 1. 本 run 第一轮只调用一次 `agent.goal_contract` 冻结目标合同:outcome 概括用户原话意图,`preferences` 必须传空数组,`acceptanceNodes` 提交 Runtime 指定的固定单节点。这一轮不做任何其它调用。 2. 冻结后立即用一次 `agent.delegate` 把任务委派给 `project-planning`,`expectedArtifacts` 写 `game/fast_gdd.md`,`repairOfDelegationId`、`runId`、`continuationOfDelegationId`、`questionsSha256`、`answersSha256` 全传 null。已有委派尚未收束时不要重复委派。 3. 等待子 Agent 期间不得调用 `respond_to_user`。Runtime 会通过 delegate 完成屏障保持同一父 run,回执到达后再继续。 -4. 子 Agent 以问询信封退出时,决策卡由 Runtime 直接按信封原文呈现给用户,**不需要你调用任何工具**——你根本不会在那一刻被恢复。用户答完之后你才会拿到答案,届时为该原 delivery 创建且仅创建一次 continuation 委派,`continuationOfDelegationId` 与 `repairOfDelegationId` 都指向该原 delivery,并提交 observation 给出的 `questionsSha256`、`answersSha256`。 +4. 子 Agent 以问询信封退出时,决策卡由 Runtime 直接按信封原文呈现给用户,**不需要你调用任何工具**——你根本不会在那一刻被恢复。用户答完之后你才会拿到答案,届时为该原 delivery 创建且仅创建一次 continuation 委派,`continuationOfDelegationId` 与 `repairOfDelegationId` 都指向该原 delivery,并提交 observation 给出的 `questionsSha256`、`answersSha256`。子 Agent 在 continuation 里**再次**以信封退出时,对那条新 delivery 重复同一动作:「仅创建一次」约束的是单条 delivery,不是整条链,澄清预算未用尽时这个循环继续。Runtime 会在委派 task 末尾写明已用轮次与上限,不需要你自己数,也不要替它宣布预算已尽。 5. 回执 contractStatus=evidence-ready 且 GDD 已提交时,用 `file.read` 从第 1 行读到 `game/fast_gdd.md` 末尾取证,每次都传 `maxLines: 240`(上限),尽量一页读完;确实需要第二页时从上一页的下一行开始,不要重复读同一段。每次 `file.read` 的 observation 末尾都带着 `sourceAgentId` / `sourceRunId` / `sourceActionId` 三个字段,把它们原样抄成 evidence 的 `{agentId, runId, actionId}`,用一次 `agent.acceptance_update` 一并提交即可——evidence 是按这三个字段整体查回执的,回忆错任何一个都会被判成"缺少持久动作回执"。不要为了取这些字段再去查动作历史。取证完成前审批卡不会出现。 6. 用户在审批卡上选择修改或退回时,先用 `agent.run_status` 按原 delegationId 取回已认领的权威委派合同,把其中的 acceptanceCriteria 与 expectedArtifacts 逐字照抄进返工委派(`runId` 传 null),再把用户原话完整附在 task 里;同一原委派只能返工一次。用户通过后只做一句简短收尾。 【转达的规则】 - 把用户答案回灌给 `project-planning` 时,逐条列出全部已确认决定,每条格式为 `[已确认] {header} → 用户答:{原文}`。用户答案原文一字不改、不归纳、不拆分、不搬轮次;任务长度接近上限时压缩你自己的说明文字,绝不压缩用户答案。 +- 策划链路的澄清信封**恰好一题**,不是通用静态委派协议里的 1-3 题:`project-planning` 每轮只提一个主要决定,Runtime 也只接受一题,多于一题会在出卡时被拒。委派 task 里不要写“1-3 个结构化问题”。 - A、B、“需要原型验证”三项必须原样转述;B 是用户确认的 `confirmed/user_option`,不能转成默认建议。用户后续自由填写推翻了更早的决定时,你只负责把两轮答案的原文都原样带到,并说明后者更晚;怎么记进决定台账由 `project-planning` 判断,不要替它裁定哪条作废。 【委派合同的边界】 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 e1f8e7685..6319c8894 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 @@ -11,7 +11,7 @@ - 除非用户明确说“直接出稿”,最多进行 3 轮关键澄清;每轮是新 run、同一 session。你看得到自己的历史,但用户答案以 Supervisor 委派任务中的转述为准,缺失信息不能臆造。 - 优先顺序:核心行为与本局目标 → 重玩动力 → 制作边界与 MVP。每轮最多问一个主要决定;达到第 3 轮、剩余问题不影响首个可玩闭环或 Runtime 提示接近活跃预算时,直接整理并提交。 -- 决策卡的 header 固定为“第N轮·关键决定”,正文以“当前要决定:”开头,只问尚未由平台事实或 MVP 规则排除的真实产品取舍,并说明为什么现在问;每张卡固定提供三个选项:A 是你的推荐方案(label 以 `A ·`、`A:`、`A:` 或 `A-` 开头并写明推荐、好处和代价),B 是形状不同且真实可行的平行备选(label 以 `B ·`、`B:`、`B:` 或 `B-` 开头并写明后果和代价),第三项逐字为“需要原型验证”,description 必须给出 30~90 分钟微型原型、试玩对象、观察信号和通过标准。自由输入按用户原话处理。 +- 决策卡的 header 固定为“第N轮·关键决定”,其中 N 是 Runtime 从委派谱系派生的当前轮号,必须精确相等,写错会被 Runtime 拒收:首轮恒为 1;之后每次续跑的任务正文都会写明已用轮次与上限,本轮该用的 N 就是“已用轮次 + 1”。正文以“当前要决定:”开头,只问尚未由平台事实或 MVP 规则排除的真实产品取舍,并说明为什么现在问;每张卡固定提供三个选项:A 是你的推荐方案(label 以 `A ·`、`A:`、`A:` 或 `A-` 开头并写明推荐、好处和代价),B 是形状不同且真实可行的平行备选(label 以 `B ·`、`B:`、`B:` 或 `B-` 开头并写明后果和代价),第三项逐字为“需要原型验证”,description 必须给出 30~90 分钟微型原型、试玩对象、观察信号和通过标准。自由输入按用户原话处理。 ## 低幻觉与 GDD 约束 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 358bcc504..3eec69a59 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 @@ -201,6 +201,68 @@ pub(crate) fn observe_agent_runtime_agent_message( } } +/// 委派 task 末尾那句「你在这条链路上的位置」。三种跳的语义互不相同,共用一句话 +/// 就会说谎,所以这里把它显式化。 +/// +/// - `Repair`:质量返工,`repair_depth` 上限 1,「唯一返工轮」是真的。而且这句话是 +/// 做游戏链路的**授权信号**——`design-foundation` / `art-director` / +/// `art-asset-plan` 的角色提示词都把「任务正文明确标识这是带 repairOfDelegationId +/// 的唯一返工轮」当作 `replaceExisting=true` 的唯一许可(见 agent/prompt.rs 的三处 +/// 角色 prompt)。这一支逐字不能动。 +/// - `PlanClarification`:澄清续跑不消耗 `repair_depth`,预算是 +/// `static_delegate_clarification_round_limit_at`(plan 链路 3 轮)。master 只有一道 +/// 平坦的 depth <= 1 门,那时「唯一返工轮」对澄清跳也成立;本仓库改成按谱系分类后 +/// 把预算抬到 3,这句话就变成了假天花板——生产实测 4 次澄清续跑全部命中它,命中后 +/// 全部直接出稿,没有任何一个 run 走到第 2 轮。 +/// - `None`:普通委派,不加这一段。 +pub(in crate::agent) enum StaticDelegateHopNote<'a> { + None, + Repair { + original_delegation_id: &'a str, + }, + PlanClarification { + original_delegation_id: &'a str, + rounds_used: u32, + rounds_limit: u32, + }, +} + +impl StaticDelegateHopNote<'_> { + fn is_none(&self) -> bool { + matches!(self, StaticDelegateHopNote::None) + } + + fn render(&self) -> String { + match self { + StaticDelegateHopNote::None => String::new(), + StaticDelegateHopNote::Repair { + original_delegation_id, + } => format!("\n\n这是对已认领委派 {original_delegation_id} 的唯一返工轮。"), + // 预算用尽:planning_coordinator 出卡时会用 + // `current_round >= 3` 直接拒掉第四张卡,所以这里不能再邀请提问, + // 只能要求收稿——语义上等价于原型的 INJ_MUST_DRAFT_ROUNDS。 + StaticDelegateHopNote::PlanClarification { + original_delegation_id, + rounds_used, + rounds_limit, + } if rounds_used >= rounds_limit => format!( + "\n\n这是对已认领委派 {original_delegation_id} 的澄清续跑,不是返工轮。已用澄清轮次 {rounds_used}/{rounds_limit},澄清预算已用尽:本轮不得再输出 AGC_NEEDS_USER_INPUT_V1 信封,剩余空白按默认建议补齐并标 default_pending,立即提交 GDD。" + ), + // 轮号必须和 planning_coordinator 出卡时的期望一致:那边用 + // `validate_exact_plan_clarification_question(.., current_round + 1)`, + // current_round 就是本 delivery 的谱系轮次,也就是这里的 rounds_used。 + StaticDelegateHopNote::PlanClarification { + original_delegation_id, + rounds_used, + rounds_limit, + } => format!( + "\n\n这是对已认领委派 {original_delegation_id} 的澄清续跑,不是返工轮,不消耗返工深度。已用澄清轮次 {rounds_used}/{rounds_limit}。仍有会实质改变结果的空白且预算未用尽时,可以继续以 AGC_NEEDS_USER_INPUT_V1 信封退出:questions 恰好一题,header 必须精确等于「第{next_round}轮·关键决定」。预算已用尽,或剩余空白能由默认建议覆盖且不影响首个可玩闭环时,立即提交 GDD。", + next_round = rounds_used.saturating_add(1), + ), + } + } +} + pub(in crate::agent) fn render_static_delegate_task_contract( task: &str, parent_agent_id: &str, @@ -208,12 +270,9 @@ pub(in crate::agent) fn render_static_delegate_task_contract( delegation_id: &str, acceptance_criteria: &[String], expected_artifacts: &[String], - repair_of_delegation_id: Option<&str>, + hop_note: StaticDelegateHopNote<'_>, ) -> Result { - if acceptance_criteria.is_empty() - && expected_artifacts.is_empty() - && repair_of_delegation_id.is_none() - { + if acceptance_criteria.is_empty() && expected_artifacts.is_empty() && hop_note.is_none() { return Ok(task.to_string()); } let criteria = if acceptance_criteria.is_empty() { @@ -234,11 +293,9 @@ pub(in crate::agent) fn render_static_delegate_task_contract( .collect::>() .join("\n") }; - let repair = repair_of_delegation_id - .map(|delegation_id| format!("\n\n这是对已认领委派 {delegation_id} 的唯一返工轮。")) - .unwrap_or_default(); + let hop_note_text = hop_note.render(); let rendered = format!( - "{task}\n\n委派验收合同:\n- parentAgentId: {parent_agent_id}\n- parentRunId: {parent_run_id}\n- delegationId: {delegation_id}\n验收标准:\n{criteria}\n预期产物:\n{artifacts}{repair}\n你只向父 Agent 提交内部回执和证据,不直接回答正式用户。交付前逐项核对;无法满足时明确说明缺口,不得假装完成。" + "{task}\n\n委派验收合同:\n- parentAgentId: {parent_agent_id}\n- parentRunId: {parent_run_id}\n- delegationId: {delegation_id}\n验收标准:\n{criteria}\n预期产物:\n{artifacts}{hop_note_text}\n你只向父 Agent 提交内部回执和证据,不直接回答正式用户。交付前逐项核对;无法满足时明确说明缺口,不得假装完成。" ); if rendered.chars().count() > AGENT_RUNTIME_TASK_MAX_CHARS { return Err(format!( @@ -791,6 +848,74 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked( let delegated_task_text = safe_default_repair_instruction .map(|instruction| format!("{instruction}\n\n{task}")) .unwrap_or(task); + // 澄清续跑与质量返工都带 repairOfDelegationId,但预算完全不同,末尾那句说明 + // 必须分开渲染(见 StaticDelegateHopNote 的注释)。判据用 + // `clarification_continuation_identity.is_some()` 加 target 是 project-planning: + // 前者只有 validate_static_delegate_clarification_continuation_at 认可的续跑才非空, + // 后者保证只影响立项策划链路——project-planning 的 run binding 由 + // validate_project_planning_child_binding_at 强制挂在 project-supervisor-plan 根下, + // 做游戏 / 做素材 / game-chat 的澄清续跑仍走 Repair 分支,逐字保持既有行为。 + let plan_clarification_rounds = if clarification_continuation_identity.is_some() + && target_agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID + { + match repair_of_delegation_id.as_deref() { + Some(original) => { + let deliveries = match list_static_delegate_deliveries_at(root) { + Ok(deliveries) => deliveries, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + }; + let (_, parent_round) = static_delegate_lineage_counters(&deliveries, original); + let limit = match static_delegate_clarification_round_limit_at( + root, + agent_id, + parent_run_id, + ) { + Ok(limit) => limit, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + }; + // 谱系损坏时 counters 返回 u32::MAX 哨兵。正常路径上 + // validate_static_delegate_repair_request_at 已经先一步拒掉这种链, + // 但这里不赌:算不出可信轮号就退回 Repair 文案,宁可保守也不写假轮号。 + parent_round + .checked_add(1) + .filter(|rounds_used| *rounds_used <= limit) + .map(|rounds_used| (rounds_used, limit)) + } + None => None, + } + } else { + None + }; + let hop_note = match ( + repair_of_delegation_id.as_deref(), + plan_clarification_rounds, + ) { + (Some(original_delegation_id), Some((rounds_used, rounds_limit))) => { + StaticDelegateHopNote::PlanClarification { + original_delegation_id, + rounds_used, + rounds_limit, + } + } + (Some(original_delegation_id), None) => StaticDelegateHopNote::Repair { + original_delegation_id, + }, + (None, _) => StaticDelegateHopNote::None, + }; let delegated_task = match render_static_delegate_task_contract( &delegated_task_text, agent_id, @@ -798,7 +923,7 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked( &delegation_id, &acceptance_criteria, &expected_artifacts, - repair_of_delegation_id.as_deref(), + hop_note, ) { Ok(task) => task, Err(error) => { @@ -1824,3 +1949,88 @@ pub(crate) fn observe_agent_runtime_agent_spawn_isolated( .map(|value| redact_agent_runtime_project_paths(root, &value, 3_600)), } } + +#[cfg(test)] +mod tests { + use super::*; + + /// 委派 task 末尾那句话是两条链路的合同,不能共用一份文案。 + /// + /// 上半条钉做游戏链路:`design-foundation` / `art-director` / `art-asset-plan` + /// 的角色提示词把「任务正文明确标识这是带 repairOfDelegationId 的唯一返工轮」 + /// 当作 `replaceExisting=true` 的唯一授权信号,改一个字就会让返工轮拿不到许可。 + /// + /// 下半条钉立项策划链路:澄清续跑不是返工轮,套用返工文案等于告诉策划子 Agent + /// 「你只剩这一轮」——这正是生产上 4 次澄清续跑之后无一走到第 2 轮的原因。 + /// 同时钉住轮号:`planning_coordinator` 出卡时按 `rounds_used + 1` 校验 header, + /// 这里写进 task 的必须是同一个数,否则第 2 轮信封会当场被拒。 + #[test] + fn plan_clarification_hop_note_is_not_the_repair_round_note() { + let repair = render_static_delegate_task_contract( + "任务", + "project-supervisor", + "run-1", + "delegation-new", + &["交付产物".to_string()], + &["assets/art-spec.png".to_string()], + StaticDelegateHopNote::Repair { + original_delegation_id: "delegation-old", + }, + ) + .expect("render repair"); + assert!( + repair.contains("这是对已认领委派 delegation-old 的唯一返工轮。"), + "返工轮文案是做游戏链路 replaceExisting 的授权信号,必须逐字保留:{repair}" + ); + + let clarification = render_static_delegate_task_contract( + "任务", + "project-supervisor", + "run-1", + "delegation-new", + &["交付 game/fast_gdd.md".to_string()], + &["game/fast_gdd.md".to_string()], + StaticDelegateHopNote::PlanClarification { + original_delegation_id: "delegation-old", + rounds_used: 1, + rounds_limit: 3, + }, + ) + .expect("render clarification"); + assert!( + !clarification.contains("唯一返工轮"), + "澄清续跑不得复用返工文案,否则策划子 Agent 以为只剩这一轮:{clarification}" + ); + assert!( + clarification.contains("已用澄清轮次 1/3"), + "澄清续跑必须写明已用轮次与上限:{clarification}" + ); + assert!( + clarification.contains("第2轮·关键决定"), + "task 里的轮号必须等于 planning_coordinator 校验 header 时用的 rounds_used + 1:{clarification}" + ); + + let exhausted = render_static_delegate_task_contract( + "任务", + "project-supervisor", + "run-1", + "delegation-new", + &["交付 game/fast_gdd.md".to_string()], + &["game/fast_gdd.md".to_string()], + StaticDelegateHopNote::PlanClarification { + original_delegation_id: "delegation-old", + rounds_used: 3, + rounds_limit: 3, + }, + ) + .expect("render exhausted clarification"); + assert!( + exhausted.contains("澄清预算已用尽"), + "预算用尽时必须要求收稿,出卡侧会直接拒掉第四张卡:{exhausted}" + ); + assert!( + !exhausted.contains("第4轮·关键决定"), + "预算用尽时不得再给出下一轮 header,那是一张永远递不上去的卡:{exhausted}" + ); + } +} 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 bd8b450b0..e15f04363 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs @@ -1370,7 +1370,7 @@ fn static_delegate_lineage_nodes<'a>( /// 澄清轮次上限只按发起请求所在 Run 的 source 区分(详见常量注释), /// 与仓库已有的 game-chat 分支先例(见 agent/runtime_tools/delegation.rs 的 /// may_be_game_chat 判定)同源:source 缺失 binding 时按非 game-chat 处理。 -fn static_delegate_clarification_round_limit_at( +pub(crate) fn static_delegate_clarification_round_limit_at( root: &Path, parent_agent_id: &str, parent_run_id: &str,