diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/runtime_prompt_bundle.rs b/apps/ai-game-creator-shell/src-tauri/build_support/runtime_prompt_bundle.rs index 2d4f4573c..4225d51f4 100644 --- a/apps/ai-game-creator-shell/src-tauri/build_support/runtime_prompt_bundle.rs +++ b/apps/ai-game-creator-shell/src-tauri/build_support/runtime_prompt_bundle.rs @@ -303,6 +303,7 @@ pub fn compile_manifest(manifest_path: &Path) -> Result String { + if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + return game_creator_project_planning_tool_plan_system_prompt(); + } let prompt = game_creator_agent_runtime_tool_plan_system_prompt(); if agent_id == "design-foundation" { return game_creator_design_foundation_tool_plan_prompt( @@ -548,6 +551,19 @@ pub(crate) fn game_creator_agent_runtime_tool_plan_system_prompt_for_agent( game_creator_project_supervisor_tool_plan_prompt(&prompt, editor_api_key_is_configured()) } +/// The planning child has an exact native allowlist. Do not reuse the broad +/// runtime composition here: its common section contains examples for +/// mutation, commands, previews, delegation and user-input actions that are +/// not present in the planning request's function catalog. Keeping this +/// prompt deliberately small makes the advertised surface and the textual +/// contract agree; the role-specific Fast GDD brief is appended by the +/// Provider request builder after the identity binding has been checked. +fn game_creator_project_planning_tool_plan_system_prompt() -> String { + format!( + "你正在使用 Genarrative AI 游戏创作多智能体 Runtime。当前请求只广告以下原生函数:file.read、file.list、update_agent_plan、respond_to_user。只能直接调用这些函数;不得调用未广告的函数、动态工具或普通文本伪造工具调用。\n\n读取工具只用于获取项目内已有文本和文件摘要;不要把读取结果当作已经写入、提交、审批或构建完成。需要记录真实计划变化时调用 update_agent_plan,arguments 必须提交完整 steps;已有足够 observation、需要交付终态信封或当前轮次应收束时调用 respond_to_user。Runtime 身份、审批事实、项目版本和平台事实均由系统维护,不得自行生成或修改。" + ) +} + fn game_creator_project_supervisor_tool_plan_prompt( prompt: &str, editor_api_key_is_configured: bool, @@ -818,6 +834,24 @@ mod tests { ); } + #[test] + fn project_planning_role_brief_is_isolated_to_its_manifest_overlay() { + let planning = game_creator_agent_runtime_role_overlay_prompt( + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + None, + ); + assert!(planning.contains("立项策划 Agent")); + assert!(planning.contains("AGC_NEEDS_USER_INPUT_V1")); + assert!(game_creator_agent_runtime_role_overlay_prompt( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + None, + ) + .is_empty()); + assert!( + game_creator_agent_runtime_role_overlay_prompt("design-foundation", None).is_empty() + ); + } + #[test] fn runtime_prompt_tool_catalog_tracks_the_native_capability_registry() { let prompt = game_creator_agent_runtime_tool_plan_system_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 b9995535b..87eb7d36a 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 @@ -72,7 +72,8 @@ pub(crate) use autonomous_policy::{ pub(crate) use context_compaction::compact_game_creator_agent_runtime_session_at; pub(crate) use parallel_ledger::{ agent_runtime_confirmation_path_component, agent_runtime_parallel_read_batch_len, - agent_runtime_tool_is_parallel_safe_read, game_creator_agent_runtime_parallel_read_batch_path, + agent_runtime_tool_allowed_for_agent, agent_runtime_tool_is_parallel_safe_read, + game_creator_agent_runtime_parallel_read_batch_path, game_creator_agent_runtime_pending_tool_action_path, game_creator_agent_runtime_provider_action_batch_path, }; @@ -138,6 +139,7 @@ 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, + parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified_for_agent, parse_game_creator_agent_tool_plan_response, }; pub(crate) use tool_policy_snapshot::{ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs index 06dc6cc0d..528468fee 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs @@ -37,6 +37,16 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ pending_action: Option<&AgentRuntimePendingToolAction>, ) -> AgentRuntimeToolObservation { let tool = action.tool.trim(); + if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID + && !matches!(tool, "file.read" | "file.list") + { + return AgentRuntimeToolObservation { + tool: tool.to_string(), + status: "rejected".to_string(), + summary: "当前 Agent 身份不允许执行该工具".to_string(), + detail: None, + }; + } let action_fingerprint = pending_action .map(|pending| { agent_runtime_pending_tool_action_fingerprint( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs index 386077767..e154e25d8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs @@ -41,6 +41,9 @@ pub(in crate::agent) fn agent_runtime_parallel_read_batch_is_auto_at( actions: &[AgentRuntimeToolAction], ) -> bool { actions.iter().all(|action| { + if !agent_runtime_tool_allowed_for_agent(agent_id, action.tool.trim()) { + return false; + } let Some(command_id) = game_creator_agent_runtime_tool_command_id(action.tool.trim()) else { return false; @@ -109,6 +112,48 @@ pub(in crate::agent) fn game_creator_agent_runtime_tool_command_id( } } +/// Check the original provider/runtime tool identity before translating it to +/// a project permission command. Some tools intentionally share a command +/// id (for example `project.search` and `file.read`); policy lookup alone must +/// not turn that aliasing into an identity escalation for a restricted Agent. +pub(crate) fn agent_runtime_tool_allowed_for_agent(agent_id: &str, tool: &str) -> bool { + if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + return matches!(tool.trim(), "file.read" | "file.list"); + } + if tool.trim() == GAME_CREATOR_USER_INPUT_REQUEST_TOOL { + // `user.input_request` is a protocol control handled by the main + // loop, not by the command-id policy map. It remains available to + // standard Agents and is separately denied for autonomous profiles. + return true; + } + game_creator_agent_runtime_tool_command_id(tool.trim()).is_some() +} + +#[cfg(test)] +mod identity_tests { + use super::*; + + #[test] + fn planning_identity_does_not_inherit_project_search_alias() { + assert!(agent_runtime_tool_allowed_for_agent( + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "file.read" + )); + assert!(agent_runtime_tool_allowed_for_agent( + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "file.list" + )); + assert!(!agent_runtime_tool_allowed_for_agent( + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "project.search" + )); + assert!(agent_runtime_tool_allowed_for_agent( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "project.search" + )); + } +} + pub(crate) fn agent_runtime_confirmation_path_component(value: &str, fallback: &str) -> String { let normalized = value .trim() diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_read.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_read.rs index 505eea32c..e2ba20fc1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_read.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_read.rs @@ -140,6 +140,14 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_parallel_read_pendin root, &error, ))); } + if !agent_runtime_tool_allowed_for_agent(&pending.agent_id, &pending.action.tool) { + return Ok(Some(agent_runtime_tool_policy_block_observation( + &pending.action.tool, + AgentRuntimeToolPolicyBlock::Denied( + "当前 Agent 身份不允许执行该原始工具".to_string(), + ), + ))); + } if let Some(observation) = pending_repository_context_drift_observation(root, pending)? { return Ok(Some(observation)); } @@ -313,6 +321,12 @@ pub(in crate::agent) fn prepare_and_execute_game_creator_agent_runtime_parallel_ { return Ok(AgentRuntimeParallelReadBatchExecution::NotEligible); } + if !actions + .iter() + .all(|action| agent_runtime_tool_allowed_for_agent(&runtime.agent_id, &action.tool)) + { + return Ok(AgentRuntimeParallelReadBatchExecution::NotEligible); + } let _project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( &root, "runtime.parallel_read_batch", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs index 905b8038e..d1a211297 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs @@ -473,6 +473,15 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch( None, )?; let command_id = game_creator_agent_runtime_tool_command_id(action.tool.trim()); + let identity_block = (!agent_runtime_tool_allowed_for_agent( + &runtime.agent_id, + action.tool.trim(), + )) + .then(|| { + AgentRuntimeToolPolicyBlock::Denied( + "当前 Agent 身份不允许执行该原始工具".to_string(), + ) + }); let game_chat_art_scope_block = game_chat_delegated_art_agent_input_mutation_block( root, &runtime.agent_id, @@ -493,7 +502,8 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch( } else { None }; - let local_policy_block = game_chat_art_scope_block + let local_policy_block = identity_block + .or(game_chat_art_scope_block) .or(isolated_scope_block) .or_else(|| { command_id 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 c1878cc71..57b1c4c2a 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 @@ -4,6 +4,8 @@ use platform_llm::LlmFunctionTool; const AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL: &str = "通用完成阻断规则:如果最新 observation 的 tool 为 runtime.autonomous_completion 且 status 为 blocked,本轮禁止调用 respond_to_user;必须先读取该 observation.detail 的 nextRequiredAction,并据此调用合适的读取、修复和验证工具。只有完成要求的动作、取得后续可信 observation 且完成门禁不再阻断后,才能给最终回复;不得反复提交 final response,也不得按项目正文硬编码某一种 blocker 的处理方式。"; +const GAME_CREATOR_PROJECT_PLANNING_FINAL_REPLY_SYSTEM_PROMPT: &str = "你是 Genarrative 的立项策划 Agent final-reply 收束器。你只能依据当前请求中明确提供的后台任务、运行中用户追加指令、收束摘要和已获准工具 observation 作答;不得使用通用角色聊天人格,也不得补充这些材料之外的项目事实。不要声称已经写入文件、提交 GDD、获得审批、生成素材、构建或验证完成;不要声称调用了未出现在 observation 中的工具,也不要把建议当成用户确认。若收束摘要或 observation 中已有 AGC_NEEDS_USER_INPUT_V1 终态信封,必须保留其首行和下一行严格 JSON 问题信封(只去除外围空白),不得改写、翻译、包装成普通中文或追加解释。若当前需要用户决定而尚无完整信封,只能输出 AGC_NEEDS_USER_INPUT_V1 首行,下一行输出 Runtime 可解析的严格 {\"questions\":[...]} JSON;不得输出 markdown、代码围栏或第三行正文。没有用户输入需求时,只简洁总结已观察到的策划结论、confirmed/default_pending/prototype_pending 状态、未完成事项和下一步,明确审批或构建尚未发生。回复保持中文。"; + #[derive(Clone, Copy)] enum AgentBackgroundContextMode { ToolPlan, @@ -104,6 +106,10 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( loop_index: usize, mcp_catalog: &GameCreatorMcpCatalog, ) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest, String), String> { + let planning_agent = agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID; + if planning_agent { + validate_project_planning_child_binding_at(root, agent_id, run_id)?; + } let effective_task = autonomous_effective_root_task_at(root, agent_id, run_id, task)?; let (llm, config_path, context, repository_context_fingerprint, prompt_observations) = build_game_creator_background_agent_context( @@ -188,7 +194,9 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( "deniedTools": denied_tools, })) .map_err(|error| format!("序列化 Agent 工具策略失败:{error}"))?; - let collaboration_policy_json = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + let collaboration_policy_json = if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + "null".to_string() + } else if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { render_supervisor_collaboration_policy_for_prompt_at(root, agent_id, run_id)? } else { "null".to_string() @@ -224,7 +232,11 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( }; let steers_json = render_game_creator_agent_runtime_steers_for_prompt(root, agent_id, session_id, run_id)?; - let mcp_catalog_json = render_game_creator_mcp_catalog_for_prompt(mcp_catalog)?; + let mcp_catalog_json = if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + "[]".to_string() + } else { + render_game_creator_mcp_catalog_for_prompt(mcp_catalog)? + }; let loop_index = loop_index.saturating_add(1); let context_preload_notice = game_creator_agent_context_preload_notice(agent_id); let canvas_asset_kind_catalog = AGENT_RUNTIME_CANVAS_ASSET_KINDS.join("|"); @@ -317,9 +329,46 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( "{prompt}\n\n持久进程协议:{command_start_contract};args 中的项目路径必须相对 cwd,禁止绝对路径、file URI、路径加行号以及把绝对路径嵌入脚本或说明文字。默认需要精确确认;它只用于已经从仓库清单确认需要持续交互的长进程,有限诊断、文件探测、构建和测试必须使用 command.exec,不得用 command.start 试错。成功后保存 observation 返回的 processId 和 cursor;同一服务后续只能沿该 processId 继续,不得为探测、重试、交互或停止另起 process session。command.poll 使用 {{\"processId\":\"proc-...\",\"cursor\":null,\"maxChars\":8000,\"waitMs\":1000}};首次调用必须显式传 cursor=null,后续把上一页 nextCursor 原样传入 cursor,并按 nextCursor 增量读取,不要无等待忙轮询。command.stdin 使用 {{\"processId\":\"proc-...\",\"data\":\"UTF-8 文本\",\"appendNewline\":true,\"eof\":false}},正文会写入 PTY 且默认需要确认;command.terminate 使用 {{\"processId\":\"proc-...\",\"cursor\":\"最后一次 poll 的 nextCursor\"}} 并默认需要确认,terminate 不消费输出,后续继续用它返回的同一 nextCursor poll 终态。command.start 会推进 revision 但永远不能签发验证凭证;当前 run 的进程会话必须 poll 到可信终态,或先 terminate 再 poll,才能调用 respond_to_user 收束;needs-reconciliation 只能等待人工核对,不能重启、按 PID 重连或假装已退出。" ); let api_kind = parse_game_creator_llm_api_kind(&llm.api_kind)?; - let protocol_prompt = format!( - "必须直接调用当前请求提供的原生函数:需要更新持久计划时调用 update_agent_plan,需要行动时调用对应动作工具,已有观察足够时调用 respond_to_user。只有步骤或状态真实变化时才单独调用 update_agent_plan;当前 in_progress 步骤已具备执行条件时必须在同一响应调用对应动作工具,不能只改计划解释。不要调用未广告的旧 submit_agent_tool_plan,也不要把计划或动作放在普通文本中。\n\n{AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL}" - ); + let protocol_prompt = if planning_agent { + "必须直接调用当前请求提供的原生函数:需要更新持久计划时调用 update_agent_plan,已有观察足够或需要交付终态信封时调用 respond_to_user。不要调用未广告的函数,也不要把计划、动作或回复放在普通文本中。" + .to_string() + } else { + format!( + "必须直接调用当前请求提供的原生函数:需要更新持久计划时调用 update_agent_plan,需要行动时调用对应动作工具,已有观察足够时调用 respond_to_user。只有步骤或状态真实变化时才单独调用 update_agent_plan;当前 in_progress 步骤已具备执行条件时必须在同一响应调用对应动作工具,不能只改计划解释。不要调用未广告的旧 submit_agent_tool_plan,也不要把计划或动作放在普通文本中。\n\n{AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL}" + ) + }; + let prompt = if planning_agent { + format!( + "当前 planning 子 Agent 只可调用 file.read、file.list、update_agent_plan、respond_to_user;未广告的函数一律不可调用。读取工具只用于获取已有项目文本和文件摘要,不代表已经写入、提交、审批或构建完成。\n\n运行上下文如下。只能依据已获准工具返回的 observation 使用;未出现在 observation 里的项目事实不得自行假设。\n\n{context}\n\n后台任务:\n{effective_task}\n\n运行中用户追加指令:\n{steers_json}\n\n已有工具观察:\n{observations_json}\n\nfile.list 使用 {{\"path\":\"\"}};file.read 使用 {{\"path\":\"项目内相对路径\",\"startLine\":1,\"maxLines\":120}}。arguments 外层严格为 {{\"reason\":\"为什么需要\",\"input\":{{...}}}}。不要输出普通文本来代替函数调用。" + ) + } else { + prompt + }; + if planning_agent { + let mut planning_system_prompt = + game_creator_agent_runtime_tool_plan_system_prompt_for_agent(agent_id); + let role_brief = game_creator_agent_runtime_role_overlay_prompt(agent_id, None); + if !role_brief.is_empty() { + planning_system_prompt.push_str("\n\n"); + planning_system_prompt.push_str(&role_brief); + } + let request = LlmRunRequest::new(vec![ + LlmMessage::system(planning_system_prompt), + LlmMessage::user(prompt), + LlmMessage::user(protocol_prompt), + ]) + .with_api_kind(api_kind) + .with_max_output_tokens(AGENT_RUNTIME_TOOL_PLAN_MAX_OUTPUT_TOKENS) + .with_response_text_verbosity(platform_llm::LlmResponseTextVerbosity::Low) + .with_function_tools(build_agent_runtime_native_function_tools_for_agent( + agent_id, + mcp_catalog, + )?) + .with_tool_choice(platform_llm::LlmToolChoice::Required); + let request = apply_game_creator_llm_reasoning_effort(request, &llm)? + .with_web_search(false); + return Ok((llm, config_path, request, repository_context_fingerprint)); + } let mut system_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent(agent_id); if autonomous_game_build { system_prompt.push_str("\n\n"); @@ -380,7 +429,10 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( .with_api_kind(api_kind) .with_max_output_tokens(AGENT_RUNTIME_TOOL_PLAN_MAX_OUTPUT_TOKENS) .with_response_text_verbosity(platform_llm::LlmResponseTextVerbosity::Low) - .with_function_tools(build_agent_runtime_native_function_tools(mcp_catalog)?) + .with_function_tools(build_agent_runtime_native_function_tools_for_agent( + agent_id, + mcp_catalog, + )?) .with_tool_choice(platform_llm::LlmToolChoice::Required); if runtime_owner_artifact_validation_available { remove_autonomous_owner_manual_verification_tools(&mut request.function_tools)?; @@ -466,11 +518,12 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( "当前根 Run 尚未冻结 Goal Contract。本轮唯一可用工具是 agent.goal_contract;必须且只能调用一次,用 outcome 具体概括当前用户最终意图,acceptanceNodes 至少提交一项可核对标准。每个 requiredEvidence 必须选择在该标准所有合法结果下都能成功产生回执的工具;环境探测可能以 rejected/failed 表示正常否定结果时,不得把该探测工具写成必需成功回执(例如非 Git 项目不得要求 git.inspect 成功,应使用 project.index 的成功回执证明 isRepository=false)。nonNegotiables、preferences、forbiddenAssumptions、openQuestions 没有内容时传空数组。不得调用 update_agent_plan、respond_to_user 或任何其他动作,不得输出普通文本。", )); } - request = apply_game_creator_llm_web_search( - apply_game_creator_llm_reasoning_effort(request, &llm)?, - &llm, - true, - )?; + let mut request = apply_game_creator_llm_reasoning_effort(request, &llm)?; + request = if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + request.with_web_search(false) + } else { + apply_game_creator_llm_web_search(request, &llm, true)? + }; Ok((llm, config_path, request, repository_context_fingerprint)) } @@ -506,15 +559,27 @@ pub(in crate::agent) fn build_game_creator_agent_background_final_reply_request( .map_err(|error| format!("序列化 Agent 收束摘要失败:{error}"))?; let steers_json = render_game_creator_agent_runtime_steers_for_prompt(root, agent_id, session_id, run_id)?; + let planning_agent = agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID; + if planning_agent { + validate_project_planning_child_binding_at(root, agent_id, run_id)?; + } let audience = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { "用户" } else { "开发者" }; - let prompt = format!( - "运行上下文如下。请只依据后台任务、运行中用户追加指令、收束摘要和已获准工具返回的 observation,给{audience}一个正常中文回复。不要输出 JSON,不要假装执行未执行的工具,也不要补充 observation 中不存在的项目事实。\n\n{context}\n\n后台任务:\n{effective_task}\n\n运行中用户追加指令:\n{steers_json}\n\n收束摘要:\n{plan_json}\n\n工具观察:\n{observations_json}" - ); - let system_prompt = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + let prompt = if planning_agent { + format!( + "当前是 project-planning 子 Agent 的 final-reply 收束请求。只依据下列后台任务、运行中用户追加指令、收束摘要和已获准工具 observation。若收束摘要或 observation 已包含 AGC_NEEDS_USER_INPUT_V1 信封,逐字保留其首行与下一行严格 JSON;不要改写问题,不要输出普通解释。若没有完整信封且仍缺少用户决定,只输出可解析的 AGC_NEEDS_USER_INPUT_V1 信封;否则只总结已观察到的策划结论和未完成事项。\n\n运行上下文:\n{context}\n\n后台任务:\n{effective_task}\n\n运行中用户追加指令:\n{steers_json}\n\n收束摘要:\n{plan_json}\n\n工具观察:\n{observations_json}" + ) + } else { + format!( + "运行上下文如下。请只依据后台任务、运行中用户追加指令、收束摘要和已获准工具返回的 observation,给{audience}一个正常中文回复。不要输出 JSON,不要假装执行未执行的工具,也不要补充 observation 中不存在的项目事实。\n\n{context}\n\n后台任务:\n{effective_task}\n\n运行中用户追加指令:\n{steers_json}\n\n收束摘要:\n{plan_json}\n\n工具观察:\n{observations_json}" + ) + }; + let system_prompt = if planning_agent { + GAME_CREATOR_PROJECT_PLANNING_FINAL_REPLY_SYSTEM_PROMPT + } else if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { game_creator_project_supervisor_chat_system_prompt() } else { game_creator_role_agent_chat_system_prompt() @@ -529,6 +594,11 @@ pub(in crate::agent) fn build_game_creator_agent_background_final_reply_request( .with_response_text_verbosity(platform_llm::LlmResponseTextVerbosity::Low), &llm, )?; + let request = if planning_agent { + request.with_web_search(false) + } else { + request + }; Ok((llm, config_path, request)) } @@ -671,9 +741,12 @@ mod tests { AgentRuntimeGoalContractDraft, AgentRuntimeTaskLink, AgentRuntimeToolObservation, AgentRuntimeToolPlan, GameCreatorMcpCatalog, GameCreatorMcpCatalogTool, AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL, AGENT_RUNTIME_RESPOND_FUNCTION_NAME, + GAME_CREATOR_PROJECT_PLANNING_FINAL_REPLY_SYSTEM_PROMPT, AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, - AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + AGENT_RUNTIME_RUN_PROFILE_STANDARD, AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, RUNTIME_PROMPT_SUPERVISOR_CHAT_COMPOSITION, }; @@ -1562,6 +1635,148 @@ mod tests { assert!(function.parameters.pointer("/properties/reason").is_some()); } + #[test] + fn project_planning_brief_is_injected_only_for_standard_delegate_child() { + let directory = crate::tests::canonical_test_tempdir("planning-role-brief-provider-"); + let root = directory.path().join("project"); + init_local_game_project_at(&root, "planning-role-brief", "立项策划 brief 注入测试") + .expect("project init"); + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let parent = bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "planning-role-brief-parent", + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), + None, + ) + .expect("bind planning parent"); + let child = bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "planning-role-brief-child", + "agent-delegate", + Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), + Some(&AgentRuntimeTaskLink { + parent_agent_id: Some(parent.agent_id.clone()), + parent_run_id: Some(parent.run_id.clone()), + delegation_id: Some("planning-role-brief-delegation".to_string()), + }), + ) + .expect("bind planning child"); + let planning_state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "围绕用户需求形成 Fast GDD", + &child.run_id, + "agent-delegate", + "构建 planning request", + vec!["读取需求并准备澄清".to_string()], + ) + .expect("start planning child"); + let catalog = GameCreatorMcpCatalog { + fingerprint: String::new(), + servers: Vec::new(), + tools: Vec::new(), + }; + let (_, _, planning_request, _) = build_game_creator_agent_background_tool_plan_request( + &root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &planning_state.session_id, + &planning_state.run_id, + &planning_state.current_task, + &[], + 0, + &catalog, + ) + .expect("build planning request"); + let planning_system_prompt = &planning_request.messages[0].content; + let planning_brief_marker = "你是“立项策划 Agent”(`agentId=project-planning`)"; + assert!(planning_system_prompt.contains(planning_brief_marker)); + assert!(planning_system_prompt.contains("当前请求只广告以下原生函数")); + assert!(!planning_system_prompt.contains("Runtime 当前注册的原生可执行工具:")); + let planning_prompt_text = planning_request + .messages + .iter() + .map(|message| message.content.as_str()) + .collect::>() + .join("\n"); + for leaked_contract in [ + "project.search 使用", + "project.patchset 的每个 change", + "file.write 使用", + "command.exec 使用", + "preview.validate 使用", + "当前 MCP 动态工具目录", + "持久进程协议", + ] { + assert!( + !planning_prompt_text.contains(leaked_contract), + "planning prompt 不得注入通用工具契约:{leaked_contract}" + ); + } + assert!(planning_prompt_text.contains("file.read 使用")); + assert!(planning_prompt_text.contains("file.list 使用")); + + let planning_plan = AgentRuntimeToolPlan { + thinking_summary: "等待用户确认核心循环".to_string(), + plan_update: None, + plan: Vec::new(), + actions: Vec::new(), + response: "AGC_NEEDS_USER_INPUT_V1\n{\"questions\":[{\"id\":\"core_loop\"}]}".to_string(), + }; + let (_, _, planning_final_request) = build_game_creator_agent_background_final_reply_request( + &root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &planning_state.session_id, + &planning_state.run_id, + &planning_state.current_task, + &planning_plan, + &[], + ) + .expect("build planning final reply request"); + assert_eq!( + planning_final_request.messages[0].content, + GAME_CREATOR_PROJECT_PLANNING_FINAL_REPLY_SYSTEM_PROMPT + ); + assert!(!planning_final_request.enable_web_search); + let planning_final_prompt = planning_final_request + .messages + .iter() + .map(|message| message.content.as_str()) + .collect::>() + .join("\n"); + assert!(planning_final_prompt.contains("AGC_NEEDS_USER_INPUT_V1")); + assert!(planning_final_prompt.contains("不要声称已经写入文件")); + assert!(!planning_final_prompt.contains("正常中文回复")); + assert!(!planning_final_prompt.contains("你拥有最终回复权")); + + let supervisor_state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "委派立项策划子 Agent", + &parent.run_id, + AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, + "构建 supervisor planning request", + vec!["准备委派".to_string()], + ) + .expect("start supervisor"); + let (_, _, supervisor_request, _) = build_game_creator_agent_background_tool_plan_request( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &supervisor_state.session_id, + &supervisor_state.run_id, + &supervisor_state.current_task, + &[], + 0, + &catalog, + ) + .expect("build supervisor request"); + assert!(!supervisor_request.messages[0] + .content + .contains(planning_brief_marker)); + } + #[test] fn completion_blocker_protocol_requires_tool_repair_before_response() { let protocol = AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL; 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 5fb86a613..945613b73 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 @@ -408,7 +408,8 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at Sha256::digest(response_handoff.provider_request_id.as_bytes()) ); let mut supervisor_collaboration_candidate_actions = None; - let parsed = parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified( + let parsed = parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified_for_agent( + agent_id, &response, &mcp_catalog, ) @@ -877,7 +878,16 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at agent_runtime_protocol_error_requires_supervisor_collaboration_repair( &protocol_error, ) && !request.function_tools.is_empty(); - if force_root_goal_contract + if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + request.function_tools = + build_agent_runtime_native_function_tools_for_agent( + agent_id, + &mcp_catalog, + )?; + request.messages.push(LlmMessage::user(format!( + "上一条输出不符合 planning 工具计划协议:{protocol_error}\n本轮修复仍只允许调用 file.read、file.list、update_agent_plan、respond_to_user。不得调用或描述其它工具,不得输出普通文本来代替函数调用;需要用户决定时以 AGC_NEEDS_USER_INPUT_V1 终态信封收束。" + ))); + } else if force_root_goal_contract || force_supervisor_initial_collaboration || force_autonomous_specialist_mutation_only || force_autonomous_specialist_verification_only @@ -895,7 +905,10 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at || force_autonomous_pre_mutation { request.function_tools = - build_agent_runtime_native_function_tools(&mcp_catalog)?; + build_agent_runtime_native_function_tools_for_agent( + agent_id, + &mcp_catalog, + )?; 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/tool_plan_protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_plan_protocol.rs index 60447414b..ef5b30500 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 @@ -44,22 +44,34 @@ pub(crate) fn parse_game_creator_agent_tool_plan_llm_response_with_catalog( pub(crate) fn parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified( response: &platform_llm::LlmRunResponse, mcp_catalog: &GameCreatorMcpCatalog, +) -> Result { + parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified_for_agent( + "__all_agents__", + response, + mcp_catalog, + ) +} + +pub(crate) fn parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified_for_agent( + agent_id: &str, + response: &platform_llm::LlmRunResponse, + mcp_catalog: &GameCreatorMcpCatalog, ) -> Result { if response.tool_calls.is_empty() { - return parse_game_creator_agent_tool_plan_response_classified(response.text.as_str()).map( - |plan| ParsedAgentRuntimeToolPlan { - plan, - protocol: "text_json", - call_id: None, - function_name: None, - call_ids: Vec::new(), - function_names: Vec::new(), - normalization_kinds: Vec::new(), - normalization_count: 0, - normalized_text_chars: 0, - normalized_text_sha256: None, - }, - ); + let plan = parse_game_creator_agent_tool_plan_response_classified(response.text.as_str())?; + validate_agent_runtime_tool_plan_identity(agent_id, &plan)?; + return Ok(ParsedAgentRuntimeToolPlan { + plan, + protocol: "text_json", + call_id: None, + function_name: None, + call_ids: Vec::new(), + function_names: Vec::new(), + normalization_kinds: Vec::new(), + normalization_count: 0, + normalized_text_chars: 0, + normalized_text_sha256: None, + }); } let mut text_normalization = normalize_game_creator_agent_tool_plan_function_text(&response.text); @@ -81,6 +93,12 @@ pub(crate) fn parse_game_creator_agent_tool_plan_llm_response_with_catalog_class if response.tool_calls.len() == 1 && response.tool_calls[0].name == AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME { + if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + return Err(AgentRuntimeToolPlanProtocolError::new( + AgentRuntimeToolPlanProtocolErrorKind::UnknownFunction, + "Agent 原生工具协议错误:project-planning 不允许旧 submit_agent_tool_plan 包装器", + )); + } let call = &response.tool_calls[0]; let plan = parse_game_creator_agent_tool_plan_payload(call.arguments.as_str(), true) .map_err(|error| { @@ -102,8 +120,13 @@ pub(crate) fn parse_game_creator_agent_tool_plan_llm_response_with_catalog_class normalized_text_sha256: text_normalization.source_text_sha256, }); } - let native = parse_agent_runtime_native_tool_calls(&response.tool_calls, mcp_catalog)?; + let native = parse_agent_runtime_native_tool_calls_for_agent( + agent_id, + &response.tool_calls, + mcp_catalog, + )?; let plan = normalize_game_creator_agent_tool_plan(native.plan)?; + validate_agent_runtime_tool_plan_identity(agent_id, &plan)?; Ok(ParsedAgentRuntimeToolPlan { plan, protocol: "native_runtime_tools", @@ -118,6 +141,30 @@ pub(crate) fn parse_game_creator_agent_tool_plan_llm_response_with_catalog_class }) } +fn validate_agent_runtime_tool_plan_identity( + agent_id: &str, + plan: &AgentRuntimeToolPlan, +) -> Result<(), AgentRuntimeToolPlanProtocolError> { + if agent_id.trim() != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + return Ok(()); + } + if let Some(action) = plan + .actions + .iter() + .find(|action| !agent_runtime_native_tool_allowed_for_agent(agent_id, &action.tool)) + { + return Err(AgentRuntimeToolPlanProtocolError::new( + AgentRuntimeToolPlanProtocolErrorKind::UnknownFunction, + format!( + "Agent 原生工具协议错误:Agent {} 不允许调用 {}", + agent_id.trim(), + action.tool.trim() + ), + )); + } + Ok(()) +} + #[derive(Default)] pub(in crate::agent) struct AgentRuntimeToolPlanTextNormalization { pub(in crate::agent) visible_text: String, 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 23522676e..5db0ac7ee 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 @@ -195,6 +195,40 @@ pub(crate) fn agent_runtime_tool_policy_snapshot_for_run_at( )?; snapshot.run_profile = run_profile.clone(); snapshot.run_profile_binding_fingerprint = binding_fingerprint; + if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + validate_project_planning_child_binding_at(root, agent_id, run_id)?; + // Planning is a delegated child. Never let normalization/recovery + // repopulate the broad default policy for this identity. + let exact = ["file.read", "file.list"]; + snapshot.allowed_tools.retain(|tool| exact.contains(&tool.as_str())); + snapshot.auto_tools.retain(|tool| exact.contains(&tool.as_str())); + snapshot.confirm_tools.retain(|tool| exact.contains(&tool.as_str())); + // `snapshot_at` has already applied the project- and Agent-level + // permission policy. Keep an exact-tool deny in that result instead + // of replacing it with the ceiling's non-exact denies. Deny wins + // over auto/confirm so a stale or hand-edited snapshot cannot + // advertise a denied planning read as executable. + let exact_denied = snapshot + .denied_tools + .iter() + .filter(|tool| exact.contains(&tool.as_str())) + .cloned() + .collect::>(); + snapshot + .auto_tools + .retain(|tool| !exact_denied.iter().any(|denied| denied == tool)); + snapshot + .confirm_tools + .retain(|tool| !exact_denied.iter().any(|denied| denied == tool)); + snapshot.denied_tools = exact_denied; + snapshot.denied_tools.extend( + agent_runtime_executable_tools() + .into_iter() + .filter(|tool| !exact.contains(tool)) + .map(str::to_string), + ); + return Ok(snapshot); + } if run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { return Ok(snapshot); } 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 fa6ecd3fc..b65b36c3a 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 @@ -2662,12 +2662,19 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( let mut pending_action = prepared_action .take() .expect("prepared user input action exists"); - if runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { + if runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + || runtime.agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID + { + let reason = if runtime.agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + "project-planning 子 Agent 不允许 user.input_request 进入等待态" + } else { + "自主构建 Run 的 user.input_request 绕过了 Provider action 预检,已拒绝进入等待态" + }; let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( &root, &mut runtime, &pending_action, - "自主构建 Run 的 user.input_request 绕过了 Provider action 预检,已拒绝进入等待态", + reason, ); return AgentBackgroundTaskOutcome::NeedsReconciliation; } @@ -2689,6 +2696,18 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( } return AgentBackgroundTaskOutcome::WaitingForUserInput; } + if !agent_runtime_tool_allowed_for_agent(&agent_id, action.tool.trim()) { + let pending_action = prepared_action + .as_ref() + .expect("prepared action exists for an identity-rejected tool"); + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + pending_action, + "当前 Agent 身份不允许执行该工具,已拒绝进入执行层", + ); + return AgentBackgroundTaskOutcome::NeedsReconciliation; + } let command_id = game_creator_agent_runtime_tool_command_id(action.tool.trim()); let action_fingerprint = prepared_action .as_ref() diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs index 7e130e4a5..7d16acd07 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs @@ -787,15 +787,21 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at( can_repair_terminal_receipt = true; } if pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_WAITING_FOR_USER_INPUT { - if runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD - && !static_delegate_clarification_pending_matches_delivery_at(root, &pending)? + let planning_agent = runtime.agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID; + if planning_agent + || (runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && !static_delegate_clarification_pending_matches_delivery_at(root, &pending)?) { let _ = cancel_game_creator_agent_user_input_request_for_pending_at(root, &pending); mark_game_creator_agent_runtime_needs_reconciliation_at( root, &mut runtime, &pending, - "自主构建 Run 恢复到 legacy waiting-for-user-input,已拒绝继续等待", + if planning_agent { + "project-planning 子 Agent 恢复到 waiting-for-user-input,已拒绝继续等待" + } else { + "自主构建 Run 恢复到 legacy waiting-for-user-input,已拒绝继续等待" + }, )?; return read_game_creator_agent_runtime_at(root, agent_id) .map(AgentRuntimePendingActionResume::Handled); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs index dac96b4e1..ced05c998 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs @@ -126,6 +126,83 @@ pub(in crate::agent) fn validate_agent_runtime_run_profile_binding_record( Ok(()) } +/// Validate the sole root identity that may dispatch the planning child under D11. +/// +/// This deliberately does not infer authority from an in-memory runtime or from a +/// source string alone. The durable binding must describe a top-level +/// `project-supervisor-plan` standard run whose root fields point back to itself +/// and which has no parent link. +pub(in crate::agent) fn validate_project_supervisor_plan_root_binding_at( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result { + let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; + let run_id = run_id.trim(); + if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID || run_id.is_empty() { + return Err("project-planning 父 Run 必须是 project-supervisor 的非空根 Run".to_string()); + } + let binding = read_game_creator_agent_runtime_run_profile_binding(root, &agent_id, run_id)? + .ok_or_else(|| "project-planning 父 Run 缺少 Run Profile 绑定".to_string())?; + if binding.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || binding.run_id != run_id + || binding.root_agent_id != binding.agent_id + || binding.root_run_id != binding.run_id + || binding.parent_agent_id.is_some() + || binding.parent_run_id.is_some() + || binding.source != AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE + || binding.profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD + { + return Err( + "project-planning 父 Run 必须是 project-supervisor-plan standard 顶层根 Run".to_string(), + ); + } + Ok(binding) +} + +/// Validate the exact D11 identity of the statically delegated planning child. +/// +/// The parent binding is checked independently and the child's root IDs and +/// parent-binding fingerprint are required to agree with it. Any missing, +/// malformed, or cross-lineage binding fails closed. +pub(in crate::agent) fn validate_project_planning_child_binding_at( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result { + let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; + let run_id = run_id.trim(); + if agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID || run_id.is_empty() { + return Err("project-planning child 身份不匹配".to_string()); + } + let binding = read_game_creator_agent_runtime_run_profile_binding(root, &agent_id, run_id)? + .ok_or_else(|| "project-planning 缺少 Run Profile 绑定".to_string())?; + if binding.agent_id != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID + || binding.run_id != run_id + || binding.source != "agent-delegate" + || binding.profile != AGENT_RUNTIME_RUN_PROFILE_STANDARD + || binding.parent_agent_id.as_deref() != Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + || binding.parent_run_id.as_deref().is_none() + { + return Err("project-planning Run Profile 身份不符合静态委派合同".to_string()); + } + let parent_agent_id = binding.parent_agent_id.as_deref().unwrap_or_default(); + let parent_run_id = binding.parent_run_id.as_deref().unwrap_or_default(); + let parent = validate_project_supervisor_plan_root_binding_at( + root, + parent_agent_id, + parent_run_id, + )?; + if binding.root_agent_id != parent.root_agent_id + || binding.root_run_id != parent.root_run_id + || binding.parent_binding_fingerprint.as_deref() + != Some(parent.binding_fingerprint.as_str()) + { + return Err("project-planning child 与 project-supervisor-plan 根 Run 身份不一致".to_string()); + } + Ok(binding) +} + pub(in crate::agent) fn read_game_creator_agent_runtime_run_profile_binding_once( root: &Path, agent_id: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs index 52aa4a66d..1193a2e3a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs @@ -1615,7 +1615,7 @@ pub(crate) fn default_game_creator_agent_runtime_state( recent_tool_calls: Vec::new(), pending_tool_action: None, task_queue: AgentRuntimeTaskQueueSummary::default(), - allowed_tools: default_game_creator_agent_runtime_allowed_tools(), + allowed_tools: default_game_creator_agent_runtime_allowed_tools_for_agent(agent_id), tool_policy: AgentRuntimeToolPolicySnapshot::default(), applied_steer_cursor: 0, applied_steer_refs: Vec::new(), @@ -1662,6 +1662,56 @@ pub(crate) fn default_game_creator_agent_runtime_allowed_tools() -> Vec .collect() } +/// Return the durable Runtime tool surface for an Agent identity. +/// +/// Delegated `project-planning` runs are intentionally narrower than the +/// normal Runtime catalog. Keeping this decision in the state constructor +/// prevents a freshly-created planning state from briefly advertising the +/// broad catalog before its policy snapshot is hydrated. +pub(crate) fn default_game_creator_agent_runtime_allowed_tools_for_agent( + agent_id: &str, +) -> Vec { + if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + return vec!["file.read".to_string(), "file.list".to_string()]; + } + default_game_creator_agent_runtime_allowed_tools() +} + +#[cfg(test)] +mod planning_state_tests { + use super::*; + + #[test] + fn planning_state_normalization_cannot_expand_tool_surface() { + let mut state = default_game_creator_agent_runtime_state( + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "planning-normalize-run", + ); + state.allowed_tools = default_game_creator_agent_runtime_allowed_tools(); + state.tool_policy.allowed_tools = default_game_creator_agent_runtime_allowed_tools(); + state.tool_policy.auto_tools = default_game_creator_agent_runtime_allowed_tools(); + normalize_game_creator_agent_runtime_state( + &mut state, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + ); + assert_eq!( + state.allowed_tools, + vec!["file.read".to_string(), "file.list".to_string()] + ); + assert_eq!(state.tool_policy.allowed_tools, state.allowed_tools); + assert!(state + .tool_policy + .denied_tools + .iter() + .any(|tool| tool == "project.search")); + assert!(state + .tool_policy + .denied_tools + .iter() + .any(|tool| tool == "file.write")); + } +} + pub(super) fn normalize_game_creator_agent_runtime_state( state: &mut AgentRuntimeState, agent_id: &str, @@ -1772,7 +1822,16 @@ pub(super) fn normalize_game_creator_agent_runtime_state( state.next_step = "修复计划快照后恢复当前 run".to_string(); state.error = Some(sanitize_agent_runtime_text(&error, 500)); } - if state.allowed_tools.is_empty() { + let planning_agent = agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID + || state.agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID; + if planning_agent { + // State hydration is an authority boundary. Never let an old or + // caller-supplied full catalog expand a planning child back into a + // general-purpose Agent. + state.allowed_tools = default_game_creator_agent_runtime_allowed_tools_for_agent( + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + ); + } else if state.allowed_tools.is_empty() { state.allowed_tools = default_game_creator_agent_runtime_allowed_tools(); } else { for tool in default_game_creator_agent_runtime_allowed_tools() { @@ -1784,7 +1843,43 @@ pub(super) fn normalize_game_creator_agent_runtime_state( if state.updated_at == 0 { state.updated_at = unix_timestamp(); } - if state.tool_policy.allowed_tools.is_empty() { + if planning_agent { + let exact = default_game_creator_agent_runtime_allowed_tools_for_agent( + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + ); + state.tool_policy.allowed_tools = exact.clone(); + // Recovery/normalization may receive a stale snapshot. Preserve + // permission-derived denies for the exact planning tools, while the + // planning ceiling keeps every other executable tool fail-closed. + let exact_denied = state + .tool_policy + .denied_tools + .iter() + .filter(|tool| exact.iter().any(|allowed| allowed == *tool)) + .cloned() + .collect::>(); + state + .tool_policy + .auto_tools + .retain(|tool| { + exact.iter().any(|allowed| allowed == tool) + && !exact_denied.iter().any(|denied| denied == tool) + }); + state + .tool_policy + .confirm_tools + .retain(|tool| { + exact.iter().any(|allowed| allowed == tool) + && !exact_denied.iter().any(|denied| denied == tool) + }); + state.tool_policy.denied_tools = exact_denied; + state.tool_policy.denied_tools.extend( + agent_runtime_executable_tools() + .into_iter() + .filter(|tool| !exact.iter().any(|allowed| allowed == tool)) + .map(str::to_string), + ); + } else if state.tool_policy.allowed_tools.is_empty() { state.tool_policy.allowed_tools = agent_runtime_executable_tools() .into_iter() .map(str::to_string) 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 32dc8c5d6..b2ae2e092 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 @@ -458,6 +458,24 @@ pub(crate) fn observe_agent_runtime_agent_delegate( detail: None, }; } + // D11 reserves the planning child for the exact top-level plan root. Do + // this before parsing or persisting the delegation so forged source/profile + // combinations cannot create a child that later looks like a valid plan + // continuation. + if target_agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + if let Err(error) = validate_project_supervisor_plan_root_binding_at( + root, + agent_id, + parent_run_id, + ) { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + } let action_identity = action_id .filter(|value| !value.trim().is_empty()) .map(str::to_string) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs index ba28c035f..86ed79339 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs @@ -51,6 +51,34 @@ pub(crate) fn game_creator_agent_runtime_tool_policy_rule_for_run( stored_binding_fingerprint: Option<&str>, command_id: &str, ) -> Option { + if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + if let Err(error) = agent_runtime_run_profile_identity_at( + root, + agent_id, + run_id, + stored_profile, + stored_binding_fingerprint, + ) { + return Some(AgentRuntimeToolPolicyBlock::Denied(error)); + } + if let Err(error) = validate_project_planning_child_binding_at(root, agent_id, run_id) { + return Some(AgentRuntimeToolPolicyBlock::Denied(error)); + } + // Project/Agent permission policy remains authoritative even for the + // narrower planning ceiling. Evaluate it before applying the exact + // allowlist so a denied read cannot be turned into an auto action and + // a confirmed read remains pending confirmation. + let permission_block = game_creator_agent_runtime_tool_policy_rule(root, agent_id, command_id); + if matches!(permission_block, Some(AgentRuntimeToolPolicyBlock::Denied(_))) { + return permission_block; + } + if !matches!(command_id, "file.read" | "file.list") { + return Some(AgentRuntimeToolPolicyBlock::Denied(format!( + "project-planning exact 工具面拒绝:{command_id}" + ))); + } + return permission_block; + } let blocked = game_creator_agent_runtime_tool_policy_rule(root, agent_id, command_id); if matches!(blocked, Some(AgentRuntimeToolPolicyBlock::Denied(_))) { return blocked; @@ -301,6 +329,15 @@ pub(crate) fn game_creator_agent_runtime_tool_policy_block_after_lock( command_id: &str, pending_action: Option<&AgentRuntimePendingToolAction>, ) -> Option { + if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID + && pending_action.is_some_and(|pending| { + !agent_runtime_tool_allowed_for_agent(agent_id, &pending.action.tool) + }) + { + return Some(AgentRuntimeToolPolicyBlock::Denied( + "当前 Agent 身份不允许执行该原始工具".to_string(), + )); + } let blocked = match pending_action { Some(pending) => game_creator_agent_runtime_tool_policy_rule_for_run( root, 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 6a9e44e4e..5f6f26399 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 @@ -14,6 +14,7 @@ use crate::agent::{ AgentRuntimeToolPlan, AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT, AGENT_RUNTIME_CANVAS_ASSET_KINDS, AGENT_RUNTIME_PLAN_STEP_LIMIT, }; +use crate::GAME_CREATOR_PROJECT_PLANNING_AGENT_ID; use crate::mcp::{ validate_game_creator_mcp_tool_arguments, GameCreatorMcpCatalog, GameCreatorMcpCatalogTool, GAME_CREATOR_MCP_CALL_TOOL, @@ -278,6 +279,22 @@ pub(crate) fn native_mcp_function_name(server_id: &str, tool_name: &str) -> Stri pub(crate) fn build_agent_runtime_native_function_tools( mcp_catalog: &GameCreatorMcpCatalog, +) -> Result, String> { + build_agent_runtime_native_function_tools_for_agent( + "__all_agents__", + mcp_catalog, + ) +} + +/// Build the function catalog for a specific Agent identity. +/// +/// `project-planning` is deliberately handled as an exact allowlist. The +/// `plan.submit_gdd` capability is not registered yet (it belongs to M1B-2), +/// so it must not be advertised here or added to the global capability +/// registry prematurely. Protocol controls remain available to every Agent. +pub(crate) fn build_agent_runtime_native_function_tools_for_agent( + agent_id: &str, + mcp_catalog: &GameCreatorMcpCatalog, ) -> Result, String> { let mut functions = vec![plan_update_function_tool(), response_function_tool()]; let mut names = BTreeSet::from([ @@ -285,7 +302,13 @@ pub(crate) fn build_agent_runtime_native_function_tools( AGENT_RUNTIME_RESPOND_FUNCTION_NAME.to_string(), ]); + let planning_agent = agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID; for definition in agent_runtime_native_capability_registry()?.iter() { + if planning_agent + && !matches!(definition.dispatch().as_str(), "file.read" | "file.list") + { + continue; + } let name = definition.function_name().to_string(); if !names.insert(name.clone()) { return Err(format!("Runtime 原生函数名重复:{name}")); @@ -300,6 +323,11 @@ pub(crate) fn build_agent_runtime_native_function_tools( ); } + // Planning Agents never receive an MCP catalog, even if a caller passes + // one accidentally. This keeps the ad surface fail-closed by identity. + if planning_agent { + return Ok(functions); + } for tool in &mcp_catalog.tools { let name = native_mcp_function_name(&tool.server_id, &tool.name); if !names.insert(name.clone()) { @@ -314,9 +342,64 @@ pub(crate) fn build_agent_runtime_native_function_tools( Ok(functions) } +pub(crate) fn agent_runtime_native_tool_allowed_for_agent( + agent_id: &str, + tool: &str, +) -> bool { + if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + // update_agent_plan/respond_to_user are protocol controls and are + // validated outside the action capability registry. + return matches!( + tool.trim(), + "file.read" | "file.list" | AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME + | AGENT_RUNTIME_RESPOND_FUNCTION_NAME + ); + } + if tool.trim() == GAME_CREATOR_MCP_CALL_TOOL { + // MCP calls are bound and checked against the current catalog by the + // MCP policy path; they are not part of the native capability registry. + return true; + } + agent_runtime_native_capability_registry() + .ok() + .and_then(|registry| registry.get(tool.trim())) + .is_some() +} + +fn validate_native_tool_identity( + agent_id: &str, + runtime_tool: Option<&str>, +) -> Result<(), AgentRuntimeToolPlanProtocolError> { + if let Some(tool) = runtime_tool { + if !agent_runtime_native_tool_allowed_for_agent(agent_id, tool) { + return Err(protocol_error( + AgentRuntimeToolPlanProtocolErrorKind::UnknownFunction, + format!( + "Agent 原生工具协议错误:Agent {} 不允许调用 {}", + agent_id.trim(), + tool + ), + )); + } + } + Ok(()) +} + pub(crate) fn parse_agent_runtime_native_tool_calls( calls: &[LlmToolCall], mcp_catalog: &GameCreatorMcpCatalog, +) -> Result { + parse_agent_runtime_native_tool_calls_for_agent( + "__all_agents__", + calls, + mcp_catalog, + ) +} + +pub(crate) fn parse_agent_runtime_native_tool_calls_for_agent( + agent_id: &str, + calls: &[LlmToolCall], + mcp_catalog: &GameCreatorMcpCatalog, ) -> Result { if calls.is_empty() { return Err(protocol_error( @@ -373,7 +456,14 @@ pub(crate) fn parse_agent_runtime_native_tool_calls( } let runtime_tool = runtime_tool_for_native_function(&call.name); + validate_native_tool_identity(agent_id, runtime_tool.as_deref())?; let mcp_tool = mcp_tool_for_native_function(&call.name, mcp_catalog)?; + if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID && mcp_tool.is_some() { + return Err(protocol_error( + AgentRuntimeToolPlanProtocolErrorKind::UnknownFunction, + "Agent 原生工具协议错误:project-planning 不允许 MCP 工具", + )); + } if runtime_tool.is_none() && mcp_tool.is_none() { return Err(protocol_error( AgentRuntimeToolPlanProtocolErrorKind::UnknownFunction, @@ -1504,6 +1594,33 @@ mod tests { } } + #[test] + fn project_planning_catalog_is_exact_and_mcp_free() { + let functions = build_agent_runtime_native_function_tools_for_agent( + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &native_mcp_catalog(empty_input_schema()), + ) + .expect("planning function catalog"); + let names = functions + .iter() + .map(|function| function.name.as_str()) + .collect::>(); + assert!(names.contains(AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME)); + assert!(names.contains(AGENT_RUNTIME_RESPOND_FUNCTION_NAME)); + assert!(names.contains("runtime_tool_file_read")); + assert!(names.contains("runtime_tool_file_list")); + assert_eq!(names.len(), 4); + assert!(!names.iter().any(|name| name.starts_with("mcp_tool_"))); + assert!(!agent_runtime_native_tool_allowed_for_agent( + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "user.input_request" + )); + assert!(!agent_runtime_native_tool_allowed_for_agent( + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "file.write" + )); + } + fn native_mcp_catalog(input_schema: Value) -> GameCreatorMcpCatalog { GameCreatorMcpCatalog { fingerprint: "catalog-fingerprint".to_string(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs index fa253f251..1768d462d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs @@ -7861,6 +7861,59 @@ fn agent_native_tool_parser_accepts_plan_with_reply_and_rejects_reply_with_actio assert!(error.contains("最终回复不能与动作工具同时提交")); } +#[test] +fn planning_agent_parser_rejects_text_and_legacy_tool_plan_bypasses() { + let catalog = GameCreatorMcpCatalog { + fingerprint: "planning-parser-empty-catalog".to_string(), + servers: Vec::new(), + tools: Vec::new(), + }; + let payload = serde_json::json!({ + "thinkingSummary": "不应执行搜索", + "planUpdate": null, + "plan": [], + "actions": [{ + "tool": "project.search", + "reason": "绕过 planning allowlist", + "input": {"query": "secret", "path": "", "maxResults": 20, "caseSensitive": false} + }], + "response": "" + }) + .to_string(); + let text_error = + parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified_for_agent( + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &agent_tool_plan_llm_response(payload.clone(), Vec::new()), + &catalog, + ) + .expect_err("planning text JSON must not bypass the exact tool identity gate"); + assert_eq!( + text_error.kind(), + AgentRuntimeToolPlanProtocolErrorKind::UnknownFunction + ); + assert!(text_error.to_string().contains("project.search")); + + let legacy_error = + parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified_for_agent( + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + &agent_tool_plan_llm_response( + "", + vec![platform_llm::LlmToolCall { + id: "planning-legacy-wrapper".to_string(), + name: AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME.to_string(), + arguments: payload, + }], + ), + &catalog, + ) + .expect_err("planning must reject the unadvertised legacy wrapper"); + assert_eq!( + legacy_error.kind(), + AgentRuntimeToolPlanProtocolErrorKind::UnknownFunction + ); + assert!(legacy_error.to_string().contains("submit_agent_tool_plan")); +} + #[test] fn agent_native_tool_parser_binds_dynamic_mcp_function_without_model_fingerprints() { let tool = GameCreatorMcpCatalogTool { diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/policy.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/policy.rs index cb4061ea6..b330753f3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/policy.rs @@ -17,6 +17,60 @@ fn agent_runtime_default_allowed_tools_match_executable_whitelist() { assert!(!expected.contains(&"conversation.write".to_string())); } +#[test] +fn planning_agent_original_tool_identity_is_not_widened_by_command_aliases() { + assert!(agent_runtime_tool_allowed_for_agent( + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "file.read" + )); + assert!(agent_runtime_tool_allowed_for_agent( + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "file.list" + )); + assert!(!agent_runtime_tool_allowed_for_agent( + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "project.search" + )); + assert!(agent_runtime_tool_allowed_for_agent( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "project.search" + )); +} + +#[tokio::test] +async fn planning_runtime_rejects_search_alias_and_user_input_before_execution() { + let root = unique_project_path(); + init_local_game_project_at(&root, "planning-boundary", "策划 Agent 运行时边界").expect("project init"); + + for (tool, input) in [ + ( + "project.search", + serde_json::json!({ "query": "should-not-run" }), + ), + ( + GAME_CREATOR_USER_INPUT_REQUEST_TOOL, + serde_json::json!({ "questions": [] }), + ), + ] { + let observation = execute_game_creator_agent_runtime_tool_action( + &root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "planning-boundary-run", + "验证策划 Agent 工具边界", + &AgentRuntimeToolAction { + tool: tool.to_string(), + reason: Some("边界测试".to_string()), + input, + }, + ) + .await; + assert_eq!(observation.status, "rejected", "{tool}: {observation:?}"); + assert!(observation.summary.contains("不允许"), "{observation:?}"); + } + + fs::remove_dir_all(root).ok(); +} + #[test] fn agent_runtime_failure_redacts_legacy_plan_detail_and_all_error_projections() { let root = unique_project_path(); @@ -210,6 +264,100 @@ fn agent_runtime_tool_policy_snapshot_reflects_project_policy() { fs::remove_dir_all(root).ok(); } +#[test] +fn planning_tool_policy_snapshot_keeps_exact_permission_decisions() { + let root = unique_project_path(); + init_local_game_project_at(&root, "planning-policy-snapshot", "策划工具策略快照").expect("project init"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: vec!["file.read".to_string()], + confirm_commands: vec!["file.list".to_string()], + agent_policies: BTreeMap::new(), + }, + ) + .expect("write planning policy"); + + let parent_run_id = "planning-policy-parent-run"; + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + "project-supervisor-plan", + Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), + None, + ) + .expect("bind plan root"); + let child_run_id = "planning-policy-child-run"; + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + child_run_id, + "agent-delegate", + Some(AGENT_RUNTIME_RUN_PROFILE_STANDARD), + Some(&AgentRuntimeTaskLink { + parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), + parent_run_id: Some(parent_run_id.to_string()), + delegation_id: Some("planning-policy-delegation".to_string()), + }), + ) + .expect("bind planning child"); + + let snapshot = agent_runtime_tool_policy_snapshot_for_run_at( + &root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + child_run_id, + None, + None, + ) + .expect("read planning policy snapshot"); + assert_eq!(snapshot.allowed_tools, vec!["file.list", "file.read"]); + assert!(snapshot.denied_tools.iter().any(|tool| tool == "file.read")); + assert!(!snapshot.auto_tools.iter().any(|tool| tool == "file.read")); + assert!(!snapshot.confirm_tools.iter().any(|tool| tool == "file.read")); + assert!(snapshot.confirm_tools.iter().any(|tool| tool == "file.list")); + assert!(snapshot.denied_tools.iter().any(|tool| tool == "project.search")); + + assert!(matches!( + game_creator_agent_runtime_tool_policy_rule_for_run( + &root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + child_run_id, + None, + None, + "file.read", + ), + Some(AgentRuntimeToolPolicyBlock::Denied(reason)) + if reason.contains("项目权限策略拒绝执行") + )); + assert!(matches!( + game_creator_agent_runtime_tool_policy_rule_for_run( + &root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + child_run_id, + None, + None, + "file.list", + ), + Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(reason)) + if reason.contains("项目权限策略要求用户确认") + )); + assert!(matches!( + game_creator_agent_runtime_tool_policy_rule_for_run( + &root, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + child_run_id, + Some("autonomous-game-build"), + Some("forged-binding-fingerprint"), + "file.list", + ), + Some(AgentRuntimeToolPolicyBlock::Denied(reason)) + if reason.contains("Run Profile") + )); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_asset_generation_respects_project_policy() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs index 4cc6d88c5..c99a17875 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs @@ -38,6 +38,7 @@ pub(super) use crate::{ agent_runtime_read_only_delivery_completion_plan_update, agent_runtime_run_profile_identity_at, agent_runtime_tool_action_fingerprint, agent_runtime_tool_action_id, agent_runtime_tool_policy_snapshot_for_run_at, + agent_runtime_tool_allowed_for_agent, agent_runtime_tool_requires_pending_revision_gate, agent_runtime_tool_requires_repository_context_fingerprint_gate, agent_runtime_verified_delivery_completion_plan_update, append_agent_db_record, @@ -114,5 +115,6 @@ pub(super) use crate::{ AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE, AGENT_RUNTIME_UI_PROTOTYPE_PATH, AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE, AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, GAME_CREATOR_CONFIG_FILE_NAME, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, GAME_CREATOR_USER_INPUT_REQUEST_TOOL, PROJECT_BLACKBOARD_MEMORY_PATH, }; diff --git a/apps/ai-game-creator-shell/src-tauri/tests/runtime_prompt_bundle_build.rs b/apps/ai-game-creator-shell/src-tauri/tests/runtime_prompt_bundle_build.rs index aa97ae22a..62941d560 100644 --- a/apps/ai-game-creator-shell/src-tauri/tests/runtime_prompt_bundle_build.rs +++ b/apps/ai-game-creator-shell/src-tauri/tests/runtime_prompt_bundle_build.rs @@ -189,6 +189,21 @@ fn valid_manifest() -> Value { } ] }, + "planning": { + "id": "planning", + "label": "立项策划", + "role": "Project Planning", + "briefPathName": "project-planning.md", + "roles": [ + { + "id": "project-planning", + "role": "Project Planning", + "taskId": "project-planning", + "toolId": "agent.runtime.project-planning", + "briefPathName": "project-planning.md" + } + ] + }, "groups": [ { "id": "code", diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 186fb5e85..a6eeb8e3b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,14 @@ # 决策记录 +## 2026-08-13 M1A-2:planning 子 Agent 两层工具面与角色 brief 注入 + +- 落地范围:在 `M1A-1` 的 `project-supervisor-plan` source 基础上,收口两层工具面。Supervisor 根 run 继续使用 `standard` 的现役工具面;`project-planning` 只接受 `source=agent-delegate`、`profile=standard`、父 Agent 为 `project-supervisor` 的静态委派身份。 +- planning 子 Agent 的当前 native action exact allowlist 只有 `file.read`、`file.list`;`update_agent_plan` / `respond_to_user` 是协议控制函数,不计入 action capability。MCP catalog 强制为空,`webSearchEnabled=false`,`collaborationPolicy=null`。`plan.submit_gdd` 刻意未注册、未广告、未执行,留给后续 `M1B-2`,因此本条不代表 GDD 提交、版本存储或审批闭环已完成。 +- Prompt:Prompt Bundle 新增并登记 `project-planning` role brief,standard planning child 的初始请求与 repair/rebuild 请求均注入同一 brief;Supervisor 和其它 Agent 不注入该 section。brief 只描述 Fast GDD 澄清、终态 `AGC_NEEDS_USER_INPUT_V1`、三轮边界、平台事实与低幻觉约束,不授予任何写入、命令、MCP、预览、生成或审批能力。 +- 纵深拒绝:广告层不再向 planning child 暴露 `user.input_request`;Provider parser、action batch/pending、并行只读、执行层和状态恢复均按原始 tool identity 再校验。伪造写入/命令/MCP、`project.search` 等映射为 `file.read` 的 alias、`user.input_request` 都 fail-closed;恢复旧快照不得把 planning 工具面扩回全量目录。委派子 Agent 原有 `validate_user_input_action_owner` 执行层拒绝继续保留。 +- 回归与边界:覆盖 planning 函数目录精确集合、brief 只注入 planning、MCP/web search/collaboration 收窄、原始工具身份拒绝及状态归一化不扩权;Supervisor 根 run 的 standard 工具面保持既有行为。M1A-3 的 source 保留、强判据与 retry 语义不改;`M1B-1`/`M1B-2` 的 planning 存储、strict schema、typed 指纹和 `plan.submit_gdd` 提交仍未实现。 +- 关联文档:`docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md` 第 4.3、6、19.2、23.6、23.8 节。 + ## 2026-08-13 M1A-3:plan 根 run 强判据与 retry 保源 - 落地:新增 `supervisor_plan_root_identity_holds_at`。必须核 durable run-profile binding(含 project/fingerprint 校验),并与 task 的 `agentId/source/profile/parent/delegation/root*` 以及「存在且 runId 相同」的 runtime、尚存 provider action batch 逐项相等。**不得只比较内存 `runtime.source`。** `agent_runtime_supervisor_source_is_plan` 仍只用于拒绝(steer),本函数只用于授予 retry 保源。 diff --git a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md index eca75b858..a3e537f0f 100644 --- a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md +++ b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md @@ -1,9 +1,9 @@ # 立项策划 Agent(Fast GDD)技术方案 - 日期:2026-08-10 -- 状态:2026-08-12 **M0 代码工作包全部完成**(`M0A-2`、`M0B-1`、`M0B-2` 已合入 M0 集成分支并通过各自门禁,见第 23.4 节);同日 **D6 作废、拓扑改变**,`M0A-1` 交付的文档基线随之失效,需以工作包 `M0A-3` 修订,**修订完成前 M0 不计完整完成**(见第 1.1 节)。2026-08-13 **D9 二次作废、D10 作废,由 D11 取代**:立项策划节点改为 Project Supervisor 通过 `agent.delegate` 发起的静态委派子 Agent,问询复用 PR #165 中转链路(见第 1.1 节「D11 新拓扑」);D11 依赖 WP1(静态委派澄清轮次与返工深度拆分)为强制前置,**该前置已于 2026-08-13 落地并合入**(`WP1` 生产代码 + `WP2` 回归,完成状态与门禁见第 23.5 节),澄清轮次上限现为 3(game-chat source 仍为 1)。后续执行计划见第 23.6 节。M1~M3 的策划功能本身仍未实现,`fast_gdd` / `.agent/planning` / `project-planning` 相关代码全仓库零命中 +- 状态:2026-08-12 **M0 代码工作包全部完成**(`M0A-2`、`M0B-1`、`M0B-2` 已合入 M0 集成分支并通过各自门禁,见第 23.4 节);同日 **D6 作废、拓扑改变**,`M0A-1` 交付的文档基线随之失效,需以工作包 `M0A-3` 修订,**修订完成前 M0 不计完整完成**(见第 1.1 节)。2026-08-13 **D9 二次作废、D10 作废,由 D11 取代**:立项策划节点改为 Project Supervisor 通过 `agent.delegate` 发起的静态委派子 Agent,问询复用 PR #165 中转链路(见第 1.1 节「D11 新拓扑」);D11 依赖 WP1(静态委派澄清轮次与返工深度拆分)为强制前置,**该前置已于 2026-08-13 落地并合入**(`WP1` 生产代码 + `WP2` 回归,完成状态与门禁见第 23.5 节),澄清轮次上限现为 3(game-chat source 仍为 1)。随后 `M1A-1`、`M1A-2`、`M1A-3` 已分别落地:`M1A-2` 仅收口两层工具面、`project-planning` role brief 注入和 fail-closed 拒绝边界;`plan.submit_gdd`、`.agent/planning` 存储、提交/审批闭环仍未实现,见第 23.6、23.8 节。后续执行计划见第 23.6 节。 - 适用范围:AI 游戏创作独立 App、Project Supervisor、Agent Runtime、本地项目策划 sidecar 与后续完整构建准入 -- 当前实现边界:本文件是后续详细设计与实现的仓库内阶段基线;M0 工作包只冻结 Fast GDD 合同并修复现有 owner 验证、game-chat retry 与前端投影边界,不代表立项策划入口、审批 UI、Runtime 持久化或构建绑定已经可用 +- 当前实现边界:本文件是后续详细设计与实现的仓库内阶段基线;M0 工作包冻结 Fast GDD 合同并修复现有 owner 验证、game-chat retry 与前端投影边界,`M1A-1`~`M1A-3` 已提供 plan source、两层工具面和角色 brief 的 Runtime 基础,但不代表立项策划入口、GDD/`.agent/planning` 持久化、`plan.submit_gdd` 提交、审批 UI 或构建绑定已经可用 ## 1. 背景与目标 @@ -64,7 +64,7 @@ | `M0B-1` game-chat 美术边界 | **不受影响** | 只处理 game-chat 单主谱系 | | `M0B-2` game-chat 前端投影 | **不受影响** | 只认 game-chat root → 单主 `code-prototype` 谱系 | -M0 三个代码工作包无一行按 D6 编写,**不需要为新设计回退或修改任何已合入代码**。全仓库检索 `fast_gdd`、`.agent/planning`、`project-supervisor-plan-chat`、`is_exact_supervisor_plan_run_at` 均零命中,M1 尚未落地任何代码,因此改文档没有迁移成本。 +M0 三个代码工作包无一行按 D6 编写,**不需要为新设计回退或修改任何已合入代码**。在本次 M0A-3 文档修订时,全仓库检索 `fast_gdd`、`.agent/planning`、`project-supervisor-plan-chat`、`is_exact_supervisor_plan_run_at` 均零命中,M1 代码尚未落地,因此改文档没有迁移成本;后续 M1A 工作包的落地状态以本文当前状态行和第 23.6/23.8 节为准。 **但 `M0A-2` 的实现不可被 standard 路径复用。** `autonomous_owner_artifact_validation_available_for_run_at`(`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs:416-441`)四重绑死:owner agent 白名单、`profile == autonomous-game-build`、`source == agent-ready-task-scheduler`、**且要求 `parent_agent_id` 为 Project Supervisor**。standard ready-task 节点无 parent,第 436 行即不通过。standard 路径的 owner 产物验证必须另建一套物理独立实现——这是**新增**,不是扩展,不要把「不受影响」误读为「可以直接改现有函数」。 @@ -90,7 +90,7 @@ M0 三个代码工作包无一行按 D6 编写,**不需要为新设计回退 2026-08-13 批一执行状态:批一所列五项已全部落笔并合入(文首状态行、第 1.1 节、第 2 节决定表、第 19 节第 2 条、第 24 节第 8 条、第 23.1 节、第 22 节证据表、第 23.4 节)。其中第 19 节第 2 条与第 24 节第 8 条在 D11 之后又按新拓扑二次改写,现行表述以 D11 为准。批一至此收口,`M0A-3` 的剩余工作只有批二。 -2026-08-13 二次更新:`build.rs` agentCatalog 一致性问题已定稿(见第 3.1 节)——`project-planning` 登记为与 `supervisor` 平级、不进 `groups` 数组的独立条目,`specialist_nodes` 只读 `groups[].roles[]`,`build.rs`/种子 DAG/`new_game_creation_app_seed_tasks()` 均不需要改动。批二不再被这一具体问题阻塞。但第 3.1 节调研同时发现一个新的、更靠后的执行层缺口:`prompt.rs` 的 `game_creator_agent_role_definition`(`apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs:661-679`)目前不认识 `project-planning`,仅做 catalog 登记不足以让静态委派可执行,需 M1 补一个平行于 supervisor 特判的分支(清单见第 3.1 节)。批二仍不在本轮范围内,此处只记录状态变化,不落笔批二内容。 +2026-08-13 二次更新:`build.rs` agentCatalog 一致性问题已定稿并随 M1A 基础代码落地——`project-planning` 登记为与 `supervisor` 平级、不进 `groups` 数组的独立条目,`specialist_nodes` 只读 `groups[].roles[]`,`build.rs`/种子 DAG/`new_game_creation_app_seed_tasks()` 均不需要改动。Prompt Bundle 已登记 `project-planning` brief,角色 overlay 已接入 Provider;本状态只表示身份与 brief 基础可执行,不表示 `plan.submit_gdd`、GDD 存储或审批闭环已完成。 #### Goal Contract:四方案作废,替换为三条已验证约束 @@ -103,7 +103,7 @@ M0 三个代码工作包无一行按 D6 编写,**不需要为新设计回退 #### `M0A-3` 门禁与完成定义 - 批一全部合入,且本文不再存在自相矛盾的表述(同一事实在两处给出不同结论即视为未完成)。 -- 命名裁决冻结并写入决策记录后(2026-08-13 已完成),批二全部合入——`build.rs` agentCatalog 一致性问题已于 2026-08-13 定稿(见第 3.1 节与第 23.1 节),批二不再被这一具体问题阻塞;但第 3.1 节同时查实了 M1 执行层的一个新缺口(`prompt.rs` 角色身份合成不认识 `project-planning`,见第 3.1 节「登记前必须先处理的代码改动」),批二注册表相关小节落笔前仍需以此为准,避免写出「登记即可用」的失真表述。批二本身依旧不在本轮(`M0A-3`)范围内。 +- 命名裁决冻结并写入决策记录后(2026-08-13 已完成),批二全部合入——`build.rs` agentCatalog 一致性问题已于 2026-08-13 定稿,且 M1A 已补齐对应 catalog/role brief 基础;批二的实现收口不等于 `plan.submit_gdd` 或 GDD/审批闭环可用,后续仍按第 23.8 节拆包推进。 - decision log 与 pitfalls 同步补记:D6 作废理由、autonomous 下父子皆不可提问且硬闯会瘫痪整条工作流、`M0A-2` owner 验证不可复用;2026-08-13 起还需同步补记 D9 二次作废、D10 作废、D11 新拓扑与 WP1 澄清轮次/返工深度拆分定稿(已完成,见 decision-log 2026-08-13 条)。 - **仅文档工作包,不含任何代码改动**;合入只表示设计基线恢复自洽,不表示任何功能可用。 @@ -353,17 +353,21 @@ M1 不能把新 source 直接塞进一个被 autonomous 语义复用的总 match **第二层:策划子 Agent(`agentId=project-planning`,`source=agent-delegate`)** +M1A-2 已把本层的身份绑定落实到 Provider 初始请求、repair/rebuild 请求、tool-plan parser、action batch/pending、并行只读、执行和状态恢复:只有 `source=agent-delegate`、`profile=standard`、父 Agent 为 `project-supervisor` 的 planning child 才能使用该窄工具面;Supervisor 根 run 不继承该 allowlist。恢复时即使旧快照或调用方带入完整目录,也会归一化回窄面,避免从空/损坏快照扩权。 + action 工具广告与执行双门都必须是 exact allowlist,MCP catalog 为空,`webSearchEnabled` 固定为 false。允许项恰好是: - `file.read` - `file.list` - `plan.submit_gdd` +> **实现分层说明(M1A-2,2026-08-13)**:上表保留最终策划合同的 `plan.submit_gdd` 位置,但该 capability 属于后续 `M1B-2`,当前 M1A-2 不注册、不广告、也不执行提交逻辑。当前已落地的 planning native action 仅为 `file.read`、`file.list`;`update_agent_plan` 与 `respond_to_user` 仍是协议控制函数。这样既先收口身份隔离和 Provider 请求形状,也不把未实现的 GDD 提交误报为可用。 + `update_agent_plan` 与 `respond_to_user` 是 Runtime 协议控制函数,不计入上述清单,但仍受现有结构、轮次和终态门禁约束。 明确禁止:通用写入、patch/delete、command、process、preview、canvas、asset generation、确认型副作用、`agent.delegate`、`agent.route_manifest`、isolated child、任务图调度和所有 MCP 工具。 -`user.input_request` **不在允许清单内**,但要如实记录它的排除性质:这不是 exact allowlist 独力保证的——委派子 Agent 天然带 `parent_agent_id`/`parent_run_id`,该调用被执行层 `validate_user_input_action_owner`(`apps/ai-game-creator-shell/src-tauri/src/user_input.rs:367-394`)兜底拒绝;**广告层仍会把它列进函数目录**(**这是 `M1A-2` 之前的现状**;`M1A-2` 落地后它不再出现在函数目录,见第 19 节第 2 条),模型看得见、会去调,只是调用必失败并转成一次失败的 tool observation。allowlist 的作用是让它在广告层就消失、避免模型浪费轮次,兜底则由 Runtime 提供。两者都要有,不可互相替代(见第 19 节第 2 条)。 +`user.input_request` **不在允许清单内**。委派子 Agent 天然带 `parent_agent_id`/`parent_run_id`,该调用被执行层 `validate_user_input_action_owner`(`apps/ai-game-creator-shell/src-tauri/src/user_input.rs:367-394`)兜底拒绝;M1A-2 同时让它从 planning 的函数目录消失,避免模型浪费轮次。广告层过滤、Provider parser 原始身份校验、batch/pending/recovery 再验证和执行层兜底必须并存,不能只测其中一层。 **collaboration policy 的处置(原「六类入口冻结」按 D11 修订)** @@ -1808,7 +1812,7 @@ D9 之后,root 是未变的 Project Supervisor,它在 `standard` 下天然 *产品侧替代路径*:用户中途要改方向,走既有的两条——本轮问询里回答/自由填写来纠偏;或在 GDD 审批卡上 `revise` / `reject`。都不行时放弃本轮、重开一条 plan lineage(第 4.1 节的 `PLAN_ACTIVE_RUN_EXISTS` 约束保证同一时刻只有一条非终态 lineage)。 *连带收益*:本裁决同时消解了原条目里「父 Supervisor 被 steer 时下游委派子 Agent 如何收束」这个问题——plan 根 run 不可被 steer,该场景不存在。game-chat 路径的同类问题不受影响,仍按 decision-log 2026-08-11 条的既有结论处理。 -- ~~**`project-planning` 的编译期 agentCatalog 登记方式与 `build.rs` 一致性校验**~~——**2026-08-13 机制已定稿**(见第 3.1 节):`project-planning` 在 `agentCatalog` 下登记为与 `supervisor` 平级、不进 `groups` 数组的独立条目,`specialist_nodes` 只读 `manifest.agent_catalog.groups[].roles[]`(`build_support/runtime_prompt_bundle.rs:387-398`),因此 `build.rs` 的 `validate_seed_task_catalog`(`apps/ai-game-creator-shell/src-tauri/build.rs:23-46`)、16 任务种子 DAG、`new_game_creation_app_seed_tasks()` 均不需要改动。批二不再被 catalog 一致性问题阻塞;但登记机制定稿过程中查实了一个新的、真正的**待裁决/待处置项**(不是同一个问题的延续,是第 3.1 节调研发现的独立缺口):`prompt.rs` 的 `game_creator_agent_role_definition`(`apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs:661-679`)是把 `agent_id` 合成出角色身份的唯一函数,目前硬编码「非 `project-supervisor` 即 group 角色」的二分,`project-planning` 落入 else 分支会返回 `None`,其两个调用方(`provider_request_builders.rs:536-539`、`prompt.rs:416-417`)都会把 `None` 转成 `Err` 中断执行,报「未知 Agent 模板:project-planning」。也就是说**仅做 catalog 登记不足以让 D11 的静态委派可执行**,还需要在 `game_creator_agent_role_definition` 里补一个平行于 supervisor 特判的 `project-planning` 分支——这是 M1 落地范围,第 3.1 节已列出完整改动清单(含另外两处 needs_change:`task_ops.rs` 的 group/role 兜底误分类、`delegation.rs` 的 `agent.spawn_isolated` 放行面),本文档不再假装这只是「登记方式未裁决」,而是明确记录为「机制已定稿、代码待 M1 落地」。 +- ~~**`project-planning` 的编译期 agentCatalog 登记方式与 `build.rs` 一致性校验**~~——**2026-08-13 机制与 M1A 基础代码已落地**(见第 3.1 节):`project-planning` 在 `agentCatalog` 下登记为与 `supervisor` 平级、不进 `groups` 数组的独立条目,`specialist_nodes` 只读 `manifest.agent_catalog.groups[].roles[]`(`build_support/runtime_prompt_bundle.rs:387-398`),因此 `build.rs` 的 `validate_seed_task_catalog`(`apps/ai-game-creator-shell/src-tauri/build.rs:23-46`)、16 任务种子 DAG、`new_game_creation_app_seed_tasks()` 均不需要改动。Prompt Bundle 已登记并注入 planning role brief,`prompt.rs` 已补齐 `project-planning` 角色 overlay;本段只代表身份/brief 基础可执行,不代表 `plan.submit_gdd` 或 GDD 存储/审批闭环已完成。 ### 23.2 M0-3:统一 owner 产物验证与可玩验收边界(PR 工作包 `M0A-2`) @@ -1838,7 +1842,7 @@ M0 收口时的三项已知残留,均已定性且不阻塞后续阶段: - `set_task_status` 无秩序守卫的无条件覆盖,属 master 既有问题,另行提 issue,不在 M0 范围内修。 - 第 23.1 节的 M1 入口前置决策:**2026-08-13 起为空**。三项当日全部关闭——「plan source 与 Goal Contract 协议的关系」裁决为进可信 matcher;「plan run 是否允许 steer」裁决为不允许;「checkpoint handoff 私有持久化」随 D10 一并作废、无需裁决。M1 不再有未决的合入前置决策。 -M0 完成不表示任何策划功能已上线。M1 的功能实现仍未开始,第 19 节第 2 条等 plan source 不变量目前只是设计意图。 +M0 完成不表示完整策划闭环已经上线。`M1A-1`、`M1A-2`、`M1A-3` 已落地 plan source、两层工具面/角色 brief 以及根 run retry 身份保源;`plan.submit_gdd`、`.agent/planning` strict schema/版本链、审批 pending/receipt、前端入口和构建准入仍待后续 M1B~M1E 工作包。第 19 节第 2 条中关于工具面与执行拒绝的目标不变量已由 M1A-2 覆盖,其余 GDD 事实与审批不变量仍是后续实现目标。 ### 23.5 `WP1` / `WP2`:静态委派澄清轮次与返工深度拆分(2026-08-13 完成) @@ -1878,7 +1882,7 @@ M0 完成不表示任何策划功能已上线。M1 的功能实现仍未开始 | 三 | `project-planning` 的 agentCatalog 登记 | **已完成**(机制冻结见第 3.1 节;**代码亦已落地**,2026-08-13:manifest、prompt bundle、runtime adapter、`prompt.rs` 角色合成分支及四处 needs_change 全部合入) | | 四 | `M0A-3` 批二:拓扑与工具面部分 | **已完成**(2026-08-13),拆解见下 | | 四之余 | schema 与 golden vector 收口 | **已完成**(2026-08-13),拆解见下 | -| 五 | M1 本体:策划闭环功能实现 | **`M1A-1`、`M1A-3` 已落地**;`M1A-2` 与其余 PR 未开始。合入门见第 23.8 节 | +| 五 | M1 本体:策划闭环功能实现 | **`M1A-1`、`M1A-2`、`M1A-3` 已落地**;`M1B-1` 及之后仍未开始。M1A-2 只交付工具面、brief 注入和拒绝边界,不包含 GDD 提交/审批。合入门见第 23.8 节 | 批二在 2026-08-13 拆成两半,因为其中一半在 M1 代码存在之前**做不完**: @@ -1955,7 +1959,7 @@ M0 完成不表示任何策划功能已上线。M1 的功能实现仍未开始 | PR | 主题 | 依赖 | 合入门禁 | | --- | --- | --- | --- | | `M1A-1` | source 常量 `project-supervisor-plan` + 进可信 matcher + steer 独立否决 | — | **已落地**。消费点复核见 decision-log 2026-08-13 `M1A-1` 条;steer `kind=plan-root-steer-unsupported`,独立于 matcher | -| `M1A-2` | 两层工具面 + `project-planning` 角色 brief | `M1A-1` | 两层工具面快照;`user.input_request` 在广告层不出现且执行层仍拒(第 19 节第 2 条两半) | +| `M1A-2` | 两层工具面 + `project-planning` 角色 brief | `M1A-1` | **已落地**:Supervisor 根 run 保持 standard 工具面;planning 子 Agent 按 `agent-delegate`/`standard`/Supervisor parent 身份构建 exact native allowlist(`file.read`、`file.list` 与两个协议控制函数),MCP 为空、web search 关闭、collaboration policy 为 `null`;Prompt Bundle brief 只注入 `project-planning`,repair/rebuild 与初始请求一致;伪造写入、命令、MCP、`project.search` alias、`user.input_request` 等原始工具在广告/解析/执行/恢复路径均 fail-closed。**不包含 `plan.submit_gdd` 注册或 GDD 提交/审批。** | | `M1A-3` | plan 根 run 强判据函数 + retry 保源(本节下方「`M1A-3` 的由来」,规格见第 4.1 节第 5、7 段) | `M1A-1` | **已落地**(source 保源与强判据)。`kind=plan-root-retry-identity-unsupported`;gui/cli 兜底不变。第 4.1 节第 7 段里依赖 plan-session / `gddId` / `gdd-approval` kind 的部分仍后置。必须早于 `M1C-2a` | | `M1B-1` | `.agent/planning/` 存储层、strict schema、typed 指纹、版本链;含 `.agent/planning/**` 只挡写判据 | `M1A-2` | **第 9.1 节 golden vector 逐字节相等且指纹相等**(先于其它测试);create-only 与等前缀不可变 | | `M1B-2` | `plan.submit_gdd` 原生工具与提交点 | `M1B-1` | 全部拒绝分支;提交点前后强杀恢复;同 submissionId replay 不产生 vN+1 |