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 87eb7d36a..c01b4d3d6 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 @@ -135,10 +135,15 @@ pub(crate) use structured_plan::{ complete_agent_runtime_remaining_plan_steps, retry_agent_runtime_active_plan_step, sanitize_agent_runtime_plan_update, }; +// 不带 agentId 的三个解析入口走 `"__all_agents__"` 哨兵、跳过按身份的工具面 +// 复核,只对测试开放;生产代码必须用 `_for_agent`。 +#[cfg(test)] pub(crate) use tool_plan_protocol::{ parse_game_creator_agent_tool_plan_llm_response, parse_game_creator_agent_tool_plan_llm_response_with_catalog, parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified, +}; +pub(crate) use tool_plan_protocol::{ parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified_for_agent, parse_game_creator_agent_tool_plan_response, }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_plan_protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_plan_protocol.rs index ef5b30500..e93d9cb4a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_plan_protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_plan_protocol.rs @@ -20,6 +20,8 @@ pub(in crate::agent) fn parse_game_creator_agent_tool_plan_response_classified( parse_game_creator_agent_tool_plan_payload(payload, false) } +/// 只允许测试使用(沿用下方 `_classified` 的哨兵约束)。 +#[cfg(test)] pub(crate) fn parse_game_creator_agent_tool_plan_llm_response( response: &platform_llm::LlmRunResponse, ) -> Result { @@ -33,6 +35,8 @@ pub(crate) fn parse_game_creator_agent_tool_plan_llm_response( ) } +/// 只允许测试使用(沿用下方 `_classified` 的哨兵约束)。 +#[cfg(test)] pub(crate) fn parse_game_creator_agent_tool_plan_llm_response_with_catalog( response: &platform_llm::LlmRunResponse, mcp_catalog: &GameCreatorMcpCatalog, @@ -41,6 +45,12 @@ pub(crate) fn parse_game_creator_agent_tool_plan_llm_response_with_catalog( .map_err(|error| error.to_string()) } +/// 不带身份的解析入口,**只允许测试使用**。 +/// +/// `"__all_agents__"` 哨兵会跳过按身份的工具面复核;生产代码必须走 +/// `_for_agent` 并传真实 `agentId`。`#[cfg(test)]` 让漏改在编译期就失败, +/// 而不是在运行时静默放行本该被收窄的调用。 +#[cfg(test)] pub(crate) fn parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified( response: &platform_llm::LlmRunResponse, mcp_catalog: &GameCreatorMcpCatalog, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs index af745a164..28a53a560 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs @@ -749,6 +749,12 @@ pub(crate) fn resolve_game_creator_agent_runtime_retry_configuration_at( Some(&task.run_profile), Some(&task.run_profile_binding_fingerprint), )?; + // 声称是 plan 的 task 必须先过强判据,且这道守卫要排在全部分支之前。 + // 否则「plan source + 伪造 parent」会落进 delegated 支、「plan source + + // autonomous profile」会落进 autonomous 支,两条都绕开强判据——而强判据 + // 存在的意义正是对畸变 durable 状态 fail closed。合法 plan 根 run 无 + // parent、profile 为 standard,本守卫对它是恒真的。 + reject_supervisor_plan_root_retry_without_identity(root, task)?; let source = if delegated { "agent-delegate-retry".to_string() } else if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { @@ -765,9 +771,13 @@ pub(crate) fn resolve_game_creator_agent_runtime_retry_configuration_at( { return Err("自主构建 Agent Runtime 重试绑定不是可信 Supervisor 根 Run".to_string()); } + // 上面的守卫按 task.source 判定,挡不住「task.source 已损坏但 + // binding.source 是 plan」这一种:plan 在可信集合内,会被原样取回, + // 复活启动路径明令禁止的 plan + autonomous 组合。 + reject_supervisor_plan_autonomous_profile(&binding.source, &run_profile)?; binding.source } else if agent_runtime_supervisor_source_is_plan(&task.source) { - reject_supervisor_plan_root_retry_without_identity(root, task)?; + // 强判据已由函数开头的守卫执行过,这里不重复读 durable 状态。 AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE.to_string() } else { "agent-background-task".to_string() diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs index 7592f9e8a..d732f5b3f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs @@ -9464,3 +9464,93 @@ fn autonomous_playtest_liveness_only_enforces_the_latest_preview_result() { error.starts_with(AGENT_RUNTIME_AUTONOMOUS_DELEGATED_PLAYTEST_REPAIR_LIVENESS_ERROR_PREFIX) ); } + +/// `M1A-4` 收口:强判据必须排在 `delegated` 与 `autonomous-game-build` 两支之前。 +/// +/// 这两条路径原先都能绕开 `reject_supervisor_plan_root_retry_without_identity`: +/// 「plan source + 伪造 parent」落 delegated 支直接返回 `agent-delegate-retry`; +/// 「binding.source 是 plan + autonomous profile」落 autonomous 支,因为 plan 在 +/// 可信集合内而被原样取回,复活启动路径明令禁止的组合。两者都要 durable 状态先 +/// 畸变才可达,但强判据存在的意义正是对畸变状态 fail closed。 +#[test] +fn plan_root_retry_identity_guard_precedes_delegated_and_autonomous_branches() { + let temporary = crate::tests::canonical_test_tempdir("plan-root-retry-guard-"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "plan-root-retry-guard", "守卫前置").expect("init"); + let session_id = resolve_agent_conversation_session_id_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + None, + true, + ) + .expect("session"); + + // ① delegated=true:伪造 parent 的 plan source task 不得静默变成 + // agent-delegate-retry,必须先被强判据拒绝。 + let binding = bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "plan-retry-guard-delegated", + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), + None, + ) + .expect("bind plan root"); + let mut forged_parent = failed_supervisor_task( + "plan-retry-guard-delegated", + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + AGENT_RUNTIME_RUN_PROFILE_STANDARD, + &binding.binding_fingerprint, + &session_id, + ); + forged_parent.parent_agent_id = Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()); + forged_parent.parent_run_id = Some("forged-parent-run".to_string()); + let delegated_error = + resolve_game_creator_agent_runtime_retry_configuration_at(&root, &forged_parent, true) + .expect_err("delegated 支不得绕开 plan 根强判据"); + assert!( + delegated_error.contains(AGENT_RUNTIME_PLAN_ROOT_RETRY_IDENTITY_UNSUPPORTED_KIND), + "{delegated_error}" + ); + + // ② autonomous 支:task.source 已损坏成非 plan,但 binding.source 是 plan。 + // 顶部守卫按 task.source 判定,挡不住这一种,必须由 autonomous 支内的 + // reject_supervisor_plan_autonomous_profile 兜住。 + let autonomous_binding = bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "plan-retry-guard-autonomous", + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind plan+autonomous"); + let corrupted_source = failed_supervisor_task( + "plan-retry-guard-autonomous", + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + &autonomous_binding.binding_fingerprint, + &session_id, + ); + let autonomous_error = + resolve_game_creator_agent_runtime_retry_configuration_at(&root, &corrupted_source, false) + .expect_err("autonomous 支不得取回 plan source 复活被禁组合"); + assert!( + autonomous_error.contains(AGENT_RUNTIME_PLAN_AUTONOMOUS_PROFILE_UNSUPPORTED_KIND), + "{autonomous_error}" + ); + + // 对照:合法 plan 根 run 不受本守卫影响,仍然保源。 + let healthy = failed_supervisor_task( + "plan-retry-guard-delegated", + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + AGENT_RUNTIME_RUN_PROFILE_STANDARD, + &binding.binding_fingerprint, + &session_id, + ); + let (profile, source) = + resolve_game_creator_agent_runtime_retry_configuration_at(&root, &healthy, false) + .expect("合法 plan 根 run 必须仍然保源"); + assert_eq!(profile, AGENT_RUNTIME_RUN_PROFILE_STANDARD); + assert_eq!(source, AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE); +} 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 5f6f26399..43b7c1855 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 @@ -277,6 +277,14 @@ pub(crate) fn native_mcp_function_name(server_id: &str, tool_name: &str) -> Stri ) } +/// 不带身份的全量目录,**只允许测试使用**。 +/// +/// `"__all_agents__"` 是个不对应任何真实 Agent 的哨兵:走这条路径拿到的是 +/// 未按身份收窄的完整函数目录。生产代码必须调用 `_for_agent` 版本并传入真实 +/// `agentId`,否则按身份收窄的工具面(如 `project-planning` 的 exact +/// allowlist)会被静默绕开。这里用 `#[cfg(test)]` 把「忘记改用 `_for_agent`」 +/// 从运行时静默扩权变成编译期错误。 +#[cfg(test)] pub(crate) fn build_agent_runtime_native_function_tools( mcp_catalog: &GameCreatorMcpCatalog, ) -> Result, String> { @@ -385,6 +393,10 @@ fn validate_native_tool_identity( Ok(()) } +/// 不带身份的解析入口,**只允许测试使用**(理由同 +/// `build_agent_runtime_native_function_tools`:哨兵会跳过按身份的原始工具 +/// identity 复核)。 +#[cfg(test)] pub(crate) fn parse_agent_runtime_native_tool_calls( calls: &[LlmToolCall], mcp_catalog: &GameCreatorMcpCatalog, diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index bd78d6c1e..b42877e35 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,13 @@ # 决策记录 +## 2026-08-14 M1A 复核残余收口:retry 强判据前置、身份哨兵改为编译期约束 + +对 `M1A-1`~`M1A-4` 做整组复核后,除已由 `M1A-4` 解决的一项外,另有两处代码残余与三处文档残余。本条记录代码两处的处置,文档三处随本次提交一并订正。 + +- **retry 强判据必须排在全部分支之前。** `resolve_game_creator_agent_runtime_retry_configuration_at` 原先把 plan 分支放在 `delegated` 与 `autonomous-game-build` 之后,两支都能绕开 `reject_supervisor_plan_root_retry_without_identity`:① 「plan source + 伪造 parent」落 `delegated` 支,直接返回 `agent-delegate-retry`,强判据根本不执行;② 「`binding.source` 是 plan + autonomous profile」落 autonomous 支,因 plan 在可信集合内而被原样取回,**复活启动路径 `reject_supervisor_plan_autonomous_profile` 明令禁止的组合**。两者都要 durable 状态先畸变才可达,但强判据存在的意义正是对畸变状态 fail closed。处置:把守卫提到函数开头无条件执行(合法 plan 根 run 对它恒真),plan 分支内不再重复读 durable 状态;autonomous 支内另加 `reject_supervisor_plan_autonomous_profile(&binding.source, &run_profile)`,兜住「`task.source` 已损坏但 `binding.source` 是 plan」这一种顶部守卫按 `task.source` 判定所挡不住的情形。回归 `plan_root_retry_identity_guard_precedes_delegated_and_autonomous_branches`,已用变异测试确认去掉任一守卫即变红。 +- **`"__all_agents__"` 身份哨兵改为编译期约束。** 四个不带 `agentId` 的 wrapper(`build_agent_runtime_native_function_tools`、`parse_agent_runtime_native_tool_calls`、`parse_game_creator_agent_tool_plan_llm_response` 及其 `_with_catalog` / `_with_catalog_classified` 两层)会以哨兵跳过按身份的工具面收窄与原始工具 identity 复核。`M1A-2` 之后它们的全部调用点都只剩测试,但「将来新增生产调用点忘记改用 `_for_agent`」是**静默拿到全量目录**而非编译失败。处置:四个 wrapper 与 `runtime_actions.rs` 的对应 re-export 一并加 `#[cfg(test)]`,漏改即编译期报错。注意该哨兵当时已扩散到两个文件(`agent_native_tools.rs`、`tool_plan_protocol.rs`),属于正在复制的模式而非单点遗留。 +- 复核中另外三条按「记录不改」处置,理由见各自条目:`agent-background-task` 空 source 兜底(见本文件 `M1A-3` 条订正段)、plan 根强判据尚缺 planning pending 一维(`M1B-1` / `M1C-1` 回补)、两条写成终态的验收句(属执行稿口径,不影响实现)。 + ## 2026-08-14 M1A-4:plan 根 run 的子 Agent 创建面收窄,并冻结三条已知残留 - **补的是 `M1A-2` 的反方向。** `M1A-2` 只做了「目标是 `project-planning` → 要求父是 plan 根」这一半;反过来「父是 plan 根 → 目标必须是 `project-planning`」当时没做,也没记为 deferred。后果是 plan 根 run 可以委派任意专业 Agent,而被委派者拿的是常规 `standard` 工具面(能写文件、跑命令),第 24 节「策划全程零构建」当时只由 Prompt 兜底、不是机制保证。 @@ -29,7 +37,7 @@ - retry:`resolve_game_creator_agent_runtime_retry_configuration_at` 在 generic `agent-background-task` 兜底**之前**插入弱候选分支——`task.source == project-supervisor-plan` 时先走强判据;通过则写出 `project-supervisor-plan`,失败返回 `kind=plan-root-retry-identity-unsupported`,**不得降级**。`delegated` / `autonomous-game-build` 两支未改。 - 强弱分工:steer 继续用弱判据。不得把 steer 改成强判据——binding 缺失时会判不成 plan,反而 fail-open。 - 本包不做:`.agent/planning/`、`gddId`、plan session revision+1、按 `gdd-approval` kind 禁 retry(现役已拒 `waiting-for-user-input`)。conversation `sessionId` 复用走现役 retry 入队。 -- 同族 source 重建复核(`rg` 生产路径,测试除外):字面量 `agent-background-task` 的**唯一**构造点仍是本函数兜底分支。`start_game_creator_agent_background_task_with_link_in_session_lane_at` 接受调用方 source、自身不改写;resume / pending_recovery / recovery_scan 续跑既有 `task.source`,不另造 source。 +- ~~同族 source 重建复核(`rg` 生产路径,测试除外):字面量 `agent-background-task` 的**唯一**构造点仍是本函数兜底分支。`start_game_creator_agent_background_task_with_link_in_session_lane_at` 接受调用方 source、自身不改写;resume / pending_recovery / recovery_scan 续跑既有 `task.source`,不另造 source。~~ **← 本条于 2026-08-14 订正,结论有误,后续 PR 不得沿用。** 生产路径实际还有两处同形状的静默兜底,而且恰好就是原文点名「不改写 / 不另造」的那两个:`task_start.rs` 的 `start_game_creator_agent_background_task_with_link_in_session_lane_at` 内 `if source.trim().is_empty() { "agent-background-task" }`;`recovery_scan.rs` 内 `if task.source.trim().is_empty()` 同样兜底。两处在 source 非空时都保源,因此今天不可利用;但它们是 `M1A-3` 所堵漏洞的同一类另外两扇门,**且 `recovery_scan` 那条完全不经过 plan 根强判据**。经复核后**有意不改**:空 source 兜底是全 Agent 通用的历史默认,改成 fail closed 会波及现役全部后台任务启动与恢复路径,超出立项策划范围;若将来要收,须单列工作项并对照现役 resume/recovery 回归。 - 对照:`project-supervisor-gui` + `standard` 仍降级为 `agent-background-task`;`delegated=true` 仍为 `agent-delegate-retry`;autonomous 仍从 binding 取回可信 source。 - 回归:`plan_root_identity_requires_durable_binding_not_runtime_source`、`plan_root_retry_rejects_identity_mismatch_instead_of_degrading`、`plan_root_retry_keeps_plan_source_and_goal_contract_authority`、`gui_and_delegate_retry_sources_stay_on_existing_fallback`;既有 `autonomous_supervisor_retry_restores_trusted_source_from_run_profile_binding` 继续绿。 - 关联文档:技术方案第 4.1 节第 5、7 段(本包只落地 source 保源与强判据,不提前实现第 7 段里依赖 M1B/M1C 的 session/gdd 合同)、第 22 节 `plan retry` 行、第 23.8 节 `M1A-3`。