From a021f18b98929548dfcacbbda78bba091ffcfc1d Mon Sep 17 00:00:00 2001 From: Linghong Date: Fri, 21 Aug 2026 08:11:12 +0000 Subject: [PATCH] =?UTF-8?q?plan=20=E6=A0=B9=E5=B7=A5=E5=85=B7=E9=9D=A2?= =?UTF-8?q?=E6=8C=89=E9=98=B6=E6=AE=B5=E5=BC=80=E6=94=BE=EF=BC=8C=E5=8F=AA?= =?UTF-8?q?=E8=AF=BB=E8=B0=83=E7=94=A8=E4=B8=8D=E5=86=8D=E6=B8=85=E9=9B=B6?= =?UTF-8?q?=E7=A9=BA=E8=BD=AC=E8=AE=A1=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run 18:Supervisor 冻结目标合同后一次也没委派,改成反复调 agent.run_status 去查 一个根本不存在的委派,24 轮里 58 次 agent.run_status、0 次 agent.delegate,烧到 超时。两道防线各自失效: · 空转闸门(AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT=4)只在裸 update_agent_plan 且 步骤没变化时累加,而任意非空 actions 都清零。于是「裸计划更新被 blocked → 调一次只读工具 → 计数归零」是一个结构上永远关不上的闸。 · Runtime 其实有一条精准提示「调用 agent.delegate 派出策划子 Agent」,但它在 plan.actions.is_empty() 分支里——模型一调 run_status 就有了动作,提示不再出现, 取而代之的是一份看起来像进展的「已读取 11 个 Agent 状态」。 本地原型没有这个问题,不是因为阈值,是因为它的 Supervisor 只有四个工具且每一个都 推进链路:「调了工具」和「推进了链路」在那边是同一件事,按前者计数就是准的。生产 把两者拆开了却还在按前者计数,工具也是全程可见。 三处改动: 1. 工具面按 durable 阶段开放。合同未冻结 → 只有 agent.goal_contract;合同已冻结 但本根 run 尚无委派 → 只有 agent.delegate;已有委派 → 取证/返工/审批那几个。 run 18 卡死的正是第二档,那一档模型连 run_status 都调不出来。阶段只按 durable 事实判定,不看 Provider 说了什么。 2. 砍掉 user.input_request。这条链路上它是死的:子 Agent 以信封退出后,Runtime 在 parent-wake 屏障处自己按信封原文构造 pending 且不恢复父 run,Supervisor 永远收不 到 needs-user-input observation。广告出去只会让它在别的时点调一次,撞上 Runtime 已装好的那份 pending 而硬失败。playbook 第 4 步与转达提问那条一并改写。 3. 空转判据改成看「本轮有没有能推进 durable 状态的动作」,只读工具不清零。纯只读 调查不受影响——不发裸计划更新就根本不会累加。这条对做游戏那条链路同样有效。 Co-Authored-By: Claude Opus 5 --- .../runtime/plan/supervisor-playbook.md | 3 +- .../src-tauri/src/agent/prompt.rs | 4 +- .../src-tauri/src/agent/runtime_actions.rs | 4 +- .../provider_request_builders.rs | 11 +- .../runtime_actions/provider_tool_plan.rs | 5 +- .../runtime_actions/tool_policy_snapshot.rs | 116 +++++++++++++++++- .../src/agent/runtime_driver/main_loop.rs | 79 +++++++++++- .../src-tauri/src/agent_native_tools.rs | 76 ++++++++---- 8 files changed, 259 insertions(+), 39 deletions(-) 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 98f59670a..e1e59edbf 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,12 @@ 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. 回执 contractStatus=needs-user-input 时,按 observation 给出的问题信封原样调用一次 `user.input_request`;答案收齐后为该原 delivery 创建且仅创建一次 continuation 委派,`continuationOfDelegationId` 与 `repairOfDelegationId` 都指向该原 delivery,并提交 observation 给出的 `questionsSha256`、`answersSha256`。 +4. 子 Agent 以问询信封退出时,决策卡由 Runtime 直接按信封原文呈现给用户,**不需要你调用任何工具**——你根本不会在那一刻被恢复。用户答完之后你才会拿到答案,届时为该原 delivery 创建且仅创建一次 continuation 委派,`continuationOfDelegationId` 与 `repairOfDelegationId` 都指向该原 delivery,并提交 observation 给出的 `questionsSha256`、`answersSha256`。 5. 回执 contractStatus=evidence-ready 且 GDD 已提交时,用 `file.read` 从第 1 行分页读到 `game/fast_gdd.md` 末尾取证,用 `agent.action_history` 取回全部分页 actionId,再用一次 `agent.acceptance_update` 把它们完整写进 evidence。取证完成前审批卡不会出现。 6. 用户在审批卡上选择修改或退回时,先用 `agent.run_status` 按原 delegationId 取回已认领的权威委派合同,把其中的 acceptanceCriteria 与 expectedArtifacts 逐字照抄进返工委派(`runId` 传 null),再把用户原话完整附在 task 里;同一原委派只能返工一次。用户通过后只做一句简短收尾。 【转达的规则】 -- 转达子 Agent 的问题时 `questions` 原样透传:不改写题干、不增删选项、不替用户预选。 - 把用户答案回灌给 `project-planning` 时,逐条列出全部已确认决定,每条格式为 `[已确认] {header} → 用户答:{原文}`。用户答案原文一字不改、不归纳、不拆分、不搬轮次;任务长度接近上限时压缩你自己的说明文字,绝不压缩用户答案。 - A、B、“需要原型验证”三项必须原样转述;B 是用户确认的 `confirmed/user_option`,不能转成默认建议。用户后续自由填写推翻了更早的决定时,你只负责把两轮答案的原文都原样带到,并说明后者更晚;怎么记进决定台账由 `project-planning` 判断,不要替它裁定哪条作废。 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 3d3fc0492..a63fb2a75 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 @@ -620,7 +620,7 @@ fn game_creator_project_supervisor_plan_tool_plan_system_prompt() -> String { // 「合同说有、请求里没有」的自相矛盾。 let tool_catalog = agent_runtime_plan_root_supervisor_tools().join("、"); let prompt_header = format!( - "你正在使用 Genarrative AI 游戏创作多智能体 Runtime。你必须直接调用当前请求广告的原生函数:复杂任务首次拆解、实际进度变化、steer 调整顺序或最终收束时调用 update_agent_plan,并提交 explanation 与完整 steps;无需更新时不要调用 update_agent_plan。steps 只允许 pending、in_progress、completed 且同时最多一个 in_progress;已完成步骤必须保留且不得回退,所有必要步骤 completed 前不得调用 respond_to_user,Runtime 不会按工具动作下标代替你更新进度。本 run 的原生可执行工具目录只有:{tool_catalog}。写入、补丁、删除、命令、预览、素材生成、任务图、记忆、黑板、isolated child 与 MCP 工具在本 run 都不存在,调用它们只会失败。" + "你正在使用 Genarrative AI 游戏创作多智能体 Runtime。你必须直接调用当前请求广告的原生函数:复杂任务首次拆解、实际进度变化、steer 调整顺序或最终收束时调用 update_agent_plan,并提交 explanation 与完整 steps;无需更新时不要调用 update_agent_plan。steps 只允许 pending、in_progress、completed 且同时最多一个 in_progress;已完成步骤必须保留且不得回退,所有必要步骤 completed 前不得调用 respond_to_user,Runtime 不会按工具动作下标代替你更新进度。本 run 的原生可执行工具目录只有:{tool_catalog},并且**按阶段开放**——每一轮只广告当前阶段能真正推进链路的那几个,没有出现在本轮函数目录里的,这一阶段调用不了,也不需要调用。澄清卡不由你发:子 Agent 的问询信封由 Runtime 直接转成决策卡,你只会在用户答完之后被恢复。写入、补丁、删除、命令、预览、素材生成、任务图、记忆、黑板、isolated child 与 MCP 工具在本 run 都不存在,调用它们只会失败。" ); // `$platform` 整段是 command.start/exec/poll/stdin/terminate 的用法合同, // plan 根一个 command 工具都没有;GDD 里的平台事实由 Runtime 另行注入,与 @@ -1462,7 +1462,7 @@ mod tests { let allowlist = agent_runtime_plan_root_supervisor_tools(); assert!( plan_prompt.contains(&format!( - "本 run 的原生可执行工具目录只有:{}。", + "本 run 的原生可执行工具目录只有:{},并且**按阶段开放**", allowlist.join("、") )), "plan 根 prompt 头部必须逐字列出 allowlist:{plan_prompt}" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs index 3e06d06ff..c33708993 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs @@ -155,6 +155,8 @@ pub(crate) use tool_policy_snapshot::{ agent_runtime_acceptance_evidence_tools, agent_runtime_autonomous_design_foundation_command_is_allowed, agent_runtime_executable_tools, agent_runtime_native_executable_tools, agent_runtime_plan_root_supervisor_tools, - agent_runtime_tool_policy_snapshot_for_run_at, AGENT_RUNTIME_CANVAS_ASSET_KINDS, + agent_runtime_plan_root_supervisor_tools_for_stage, + agent_runtime_tool_policy_snapshot_for_run_at, plan_root_supervisor_stage_at, + PlanRootSupervisorStage, AGENT_RUNTIME_CANVAS_ASSET_KINDS, AGENT_RUNTIME_PROJECT_PLANNING_ACTION_TOOLS, }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index 3ab974f91..612e62f04 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs @@ -553,8 +553,15 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( )?) .with_tool_choice(platform_llm::LlmToolChoice::Required); if plan_root { - retain_plan_root_supervisor_native_tools(&mut request.function_tools)?; - restrict_plan_root_goal_contract_schema(&mut request.function_tools)?; + let stage = plan_root_supervisor_stage_at(root, agent_id, run_id)?; + retain_plan_root_supervisor_native_tools(&mut request.function_tools, stage)?; + // 固定单节点 schema 只对还在广告 agent.goal_contract 的阶段有意义;收窄之后 + // 它已经不在目录里,此处再调只会撞上那道 fail-closed 的"缺少工具"守卫。 + if agent_runtime_plan_root_supervisor_tools_for_stage(stage) + .contains(&"agent.goal_contract") + { + restrict_plan_root_goal_contract_schema(&mut request.function_tools)?; + } } if runtime_owner_artifact_validation_available { remove_autonomous_owner_manual_verification_tools(&mut request.function_tools)?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs index 4fc25f655..1aee4b4ef 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs @@ -1290,7 +1290,10 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at // 这类「必须包含 agent.spawn_isolated」的检查硬失败,晚于分支则 // 保证任何修复轮都不会把被裁掉的 36 个工具重新广告回去。 if plan_root { - retain_plan_root_supervisor_native_tools(&mut request.function_tools)?; + retain_plan_root_supervisor_native_tools( + &mut request.function_tools, + plan_root_supervisor_stage_at(root, agent_id, run_id)?, + )?; } request.enable_web_search = false; } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs index e15378549..fab7a573c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs @@ -112,20 +112,26 @@ pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> { } /// plan 根 Supervisor(`source == project-supervisor-plan`)在整条策划链路里 -/// 只负责四件事:冻结 Goal Contract、委派与续跑 `project-planning`、代子 Agent -/// 向用户提问、按 §13.0 取证后建审批卡。策划内容全部由子 Agent 生产,Supervisor -/// 不写文件、不跑命令、不做预览、不生成素材、不调度 ready 任务、不并行委派。 +/// 只负责三件事:冻结 Goal Contract、委派与续跑 `project-planning`、按 §13.0 取证 +/// 后建审批卡。策划内容全部由子 Agent 生产,Supervisor 不写文件、不跑命令、不做 +/// 预览、不生成素材、不调度 ready 任务、不并行委派。 /// -/// 全量注册表会把 43 个原生工具摆在 Provider 眼前,其中约 36 个在 plan 根都会被 +/// 全量注册表会把 43 个原生工具摆在 Provider 眼前,其中绝大多数在 plan 根都会被 /// 执行层拒绝——广告出去只会诱导 Supervisor 自己下场干活。这里给出 plan 根的 /// exact allowlist,Provider 请求目录和 system prompt 的工具清单共用它,二者不得 /// 各自维护一份。 /// +/// **`user.input_request` 不在其中**:澄清卡不是 Supervisor 发的。子 Agent 以 +/// `AGC_NEEDS_USER_INPUT_V1` 终态信封退出后,Runtime 在 parent-wake 屏障处自己按 +/// 信封原文构造 `user.input_request` pending 并且**不恢复父 run** +/// (`ensure_static_delegate_user_input_wait_at_locked`)。Supervisor 因此永远收不到 +/// needs-user-input observation,也就没有调用它的时机;广告出去只会让它在别的时点 +/// 调一次,撞上 Runtime 已经装好的那份 pending 而硬失败。 +/// /// `file.read`、`agent.acceptance_update`、`agent.action_history` 只为 §13.0 的审批 /// 前置取证门存在(分页读 `game/fast_gdd.md` 并列出全部分页 actionId)。 pub(crate) fn agent_runtime_plan_root_supervisor_tools() -> &'static [&'static str] { &[ - "user.input_request", "file.read", "agent.delegate", "agent.goal_contract", @@ -135,6 +141,106 @@ pub(crate) fn agent_runtime_plan_root_supervisor_tools() -> &'static [&'static s ] } +/// plan 根在链路上的推进阶段。 +/// +/// 工具面按阶段收窄,是因为「工具在它无用的阶段仍然可见」会直接制造活锁:实测一次 +/// 生产 run 里 Supervisor 冻结合同后没有委派,改成反复调 `agent.run_status` 去查一个 +/// 根本不存在的委派,24 轮里 58 次 `agent.run_status`、0 次 `agent.delegate`,一直烧 +/// 到超时。空转闸门也拦不住——只读调用同样会把 `plan_update_idle_rounds` 清零。 +/// +/// 本地原型没有这个问题,因为它的 Supervisor 只有四个工具且每一个都推进链路: +/// 「调了工具」和「推进了链路」在那边是同一件事。这里把同一性质移植过来——每个 +/// 阶段只广告该阶段能真正推进链路的工具。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum PlanRootSupervisorStage { + /// Goal Contract 尚未冻结:本轮唯一能推进的动作是冻结它。 + GoalContract, + /// 合同已冻结但本根 run 还没有任何委派:唯一能推进的动作是派出策划子 Agent。 + Delegate, + /// 已有委派:取证、返工与审批相关工具全部开放。 + Delegated, +} + +pub(crate) fn agent_runtime_plan_root_supervisor_tools_for_stage( + stage: PlanRootSupervisorStage, +) -> &'static [&'static str] { + match stage { + PlanRootSupervisorStage::GoalContract => &["agent.goal_contract"], + PlanRootSupervisorStage::Delegate => &["agent.delegate"], + // 合同已冻结且不可重写,再广告 agent.goal_contract 只会诱导一次必被拒的调用。 + PlanRootSupervisorStage::Delegated => &[ + "file.read", + "agent.delegate", + "agent.acceptance_update", + "agent.action_history", + "agent.run_status", + ], + } +} + +#[cfg(test)] +mod plan_root_stage_tests { + use super::*; + + /// 各阶段并集必须正好等于 allowlist:prompt 头部按 allowlist 列工具,若某个工具 + /// 只出现在某一阶段而不在 allowlist 里,头部就会漏掉它;反之则是广告了一个永远 + /// 拿不到的工具。两侧都是「合同说有、请求里没有」的自相矛盾。 + #[test] + fn every_stage_tool_is_part_of_the_plan_root_allowlist_and_the_union_covers_it() { + let allowlist = agent_runtime_plan_root_supervisor_tools() + .iter() + .copied() + .collect::>(); + let union = [ + PlanRootSupervisorStage::GoalContract, + PlanRootSupervisorStage::Delegate, + PlanRootSupervisorStage::Delegated, + ] + .into_iter() + .flat_map(|stage| { + agent_runtime_plan_root_supervisor_tools_for_stage(stage) + .iter() + .copied() + }) + .collect::>(); + assert_eq!(union, allowlist); + } + + /// 活锁的诱因是「工具在它无用的阶段仍然可见」。这两个阶段各自只能有一个动作。 + #[test] + fn the_pre_delegation_stages_expose_exactly_one_advancing_action() { + assert_eq!( + agent_runtime_plan_root_supervisor_tools_for_stage( + PlanRootSupervisorStage::GoalContract + ), + &["agent.goal_contract"] + ); + assert_eq!( + agent_runtime_plan_root_supervisor_tools_for_stage(PlanRootSupervisorStage::Delegate), + &["agent.delegate"] + ); + } +} + +/// 只按 durable 事实判定阶段,不看 Provider 说了什么。 +pub(crate) fn plan_root_supervisor_stage_at( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result { + if read_game_creator_agent_runtime_goal_contract_at(root, agent_id, run_id)?.is_none() { + return Ok(PlanRootSupervisorStage::GoalContract); + } + let delegated = list_static_delegate_deliveries_at(root)? + .into_iter() + .any(|delivery| delivery.parent_agent_id == agent_id && delivery.parent_run_id == run_id); + Ok(if delegated { + PlanRootSupervisorStage::Delegated + } else { + PlanRootSupervisorStage::Delegate + }) +} + pub(crate) fn agent_runtime_native_executable_tools() -> Vec<&'static str> { agent_runtime_executable_tools() .into_iter() 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 5dddec5a7..50228c21e 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 @@ -207,6 +207,30 @@ fn finish_plan_submit_business_rejection_limit_at( /// Runtime state 上,进程重启不能把一次活锁洗成新的无限 Provider 开销。 const AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT: u32 = 4; +/// 纯只读工具不算「推进」。 +/// +/// 空转计数只在**裸 `update_agent_plan` 且步骤没有真实变化**时累加,早期实现却让 +/// 任意非空 actions 都把它清零。于是「裸计划更新被 blocked → 调一次只读工具 → +/// 计数归零」构成一个闸门永远关不上的活锁:实测一次生产 run 24 轮里 58 次 +/// `agent.run_status`、0 次 `agent.delegate`,一路烧到超时。只读调用证明不了任何 +/// 推进,不该清零。 +/// +/// 纯只读调查不受影响:只要不发裸计划更新,计数根本不会累加,这里也就无事发生。 +fn agent_runtime_action_can_advance_progress(tool: &str) -> bool { + !matches!( + tool, + "agent.run_status" + | "agent.action_history" + | "file.read" + | "file.list" + | "project.index" + | "project.search" + | "project.diff" + | "git.inspect" + | "command.output_read" + ) +} + fn plan_update_idle_limit_reached(idle_rounds: u32) -> bool { idle_rounds >= AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT } @@ -215,6 +239,53 @@ fn plan_update_idle_limit_reached(idle_rounds: u32) -> bool { mod plan_update_idle_guard_tests { use super::*; + /// 只读工具不得把空转计数清零。这是 run 18 活锁的机制根因:Supervisor 冻结 + /// 合同后没有委派,改成「裸计划更新被 blocked → 调一次 agent.run_status → + /// 计数归零」,24 轮里 58 次 run_status、0 次 delegate,闸门一次没响。 + #[test] + fn read_only_tools_do_not_count_as_progress() { + for tool in [ + "agent.run_status", + "agent.action_history", + "file.read", + "file.list", + "project.index", + "project.search", + "project.diff", + "git.inspect", + "command.output_read", + ] { + assert!( + !agent_runtime_action_can_advance_progress(tool), + "{tool} 是只读工具,不该被当成推进" + ); + } + } + + /// 真正推进 durable 状态的动作照旧清零,否则合法的长链路会被误杀。 + #[test] + fn state_advancing_tools_still_count_as_progress() { + for tool in [ + "agent.delegate", + "agent.goal_contract", + "agent.acceptance_update", + "file.write", + "project.patchset", + "command.exec", + PLAN_SUBMIT_GDD_TOOL, + ] { + assert!( + agent_runtime_action_can_advance_progress(tool), + "{tool} 会推进 durable 状态,必须清零空转计数" + ); + } + } +} + +#[cfg(test)] +mod plan_update_idle_guard_threshold_tests { + use super::*; + #[test] fn idle_guard_always_tries_self_repair_before_killing_the_run() { // 摘工具的阈值必须严格小于收束限额,否则 run 会在从没被逼过一次真动作 @@ -2189,8 +2260,12 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( action_start_index = 0; } - if !plan.actions.is_empty() { - // 本轮有真实动作,计划没有空转。 + if plan + .actions + .iter() + .any(|action| agent_runtime_action_can_advance_progress(action.tool.trim())) + { + // 本轮至少有一个能推进 durable 状态的动作,计划没有空转。 runtime.plan_update_idle_rounds = 0; } if plan.actions.is_empty() { 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 3debb1f11..ed1223869 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 @@ -11,7 +11,8 @@ use sha2::{Digest, Sha256}; use crate::agent::{ agent_runtime_native_executable_tools, agent_runtime_plan_root_supervisor_tools, - AgentRuntimePlanUpdate, AgentRuntimeToolAction, AgentRuntimeToolPlan, + agent_runtime_plan_root_supervisor_tools_for_stage, AgentRuntimePlanUpdate, + AgentRuntimeToolAction, AgentRuntimeToolPlan, PlanRootSupervisorStage, AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT, AGENT_RUNTIME_CANVAS_ASSET_KINDS, AGENT_RUNTIME_PLAN_STEP_LIMIT, PLAN_FAST_GDD_ACCEPTANCE_NODE_CRITERION, PLAN_FAST_GDD_ACCEPTANCE_NODE_EVIDENCE, PLAN_FAST_GDD_ACCEPTANCE_NODE_ID, @@ -375,12 +376,13 @@ pub(crate) fn build_agent_runtime_native_function_tools_for_agent( /// all; that is a configuration error, so fail closed instead. pub(crate) fn retain_plan_root_supervisor_native_tools( functions: &mut Vec, + stage: PlanRootSupervisorStage, ) -> Result<(), String> { let mut allowed = BTreeSet::from([ AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME.to_string(), AGENT_RUNTIME_RESPOND_FUNCTION_NAME.to_string(), ]); - for tool in agent_runtime_plan_root_supervisor_tools() { + for tool in agent_runtime_plan_root_supervisor_tools_for_stage(stage) { allowed.insert( native_runtime_function_name(tool) .ok_or_else(|| format!("无法生成 plan 根 Supervisor 工具函数名:{tool}"))?, @@ -2148,29 +2150,55 @@ mod tests { ) .expect("build supervisor catalog"); let before = functions.len(); - retain_plan_root_supervisor_native_tools(&mut functions).expect("retain plan root tools"); - let mut expected = vec![ - AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME.to_string(), - AGENT_RUNTIME_RESPOND_FUNCTION_NAME.to_string(), - ]; - for tool in agent_runtime_plan_root_supervisor_tools() { - expected.push(native_runtime_function_name(tool).expect("plan root function name")); + // 逐个阶段都必须是精确集合:委派前那两档各自只剩一个推进动作,正是活锁 + // 的解药——模型在那一档连 agent.run_status 都调不出来。 + for stage in [ + PlanRootSupervisorStage::GoalContract, + PlanRootSupervisorStage::Delegate, + PlanRootSupervisorStage::Delegated, + ] { + let mut staged = functions.clone(); + retain_plan_root_supervisor_native_tools(&mut staged, stage) + .expect("retain plan root tools"); + let mut expected = vec![ + AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME.to_string(), + AGENT_RUNTIME_RESPOND_FUNCTION_NAME.to_string(), + ]; + for tool in agent_runtime_plan_root_supervisor_tools_for_stage(stage) { + expected.push(native_runtime_function_name(tool).expect("plan root function name")); + } + expected.sort(); + let mut actual = staged + .iter() + .map(|function| function.name.clone()) + .collect::>(); + actual.sort(); + assert_eq!(actual, expected, "阶段 {stage:?} 的工具目录不是精确集合"); + assert!( + before > staged.len(), + "收窄必须真的裁掉工具,否则这条测试是空跑" + ); + // MCP 前缀的动态工具同样不得残留:plan 根整条链路不调 MCP。 + assert!(!staged + .iter() + .any(|function| function.name.starts_with(AGENT_RUNTIME_NATIVE_MCP_PREFIX))); + } + // 广告层不得再出现 user.input_request:澄清卡由 Runtime 在 parent-wake 屏障处 + // 直接按信封原文构造,Supervisor 没有调用它的时机。 + let user_input_function = + native_runtime_function_name("user.input_request").expect("user input function name"); + for stage in [ + PlanRootSupervisorStage::GoalContract, + PlanRootSupervisorStage::Delegate, + PlanRootSupervisorStage::Delegated, + ] { + let mut staged = functions.clone(); + retain_plan_root_supervisor_native_tools(&mut staged, stage) + .expect("retain plan root tools"); + assert!(!staged + .iter() + .any(|function| function.name == user_input_function)); } - expected.sort(); - let mut actual = functions - .iter() - .map(|function| function.name.clone()) - .collect::>(); - actual.sort(); - assert_eq!(actual, expected); - assert!( - before > functions.len(), - "收窄必须真的裁掉工具,否则这条测试是空跑" - ); - // MCP 前缀的动态工具同样不得残留:plan 根整条链路不调 MCP。 - assert!(!functions - .iter() - .any(|function| function.name.starts_with(AGENT_RUNTIME_NATIVE_MCP_PREFIX))); } #[test]