From ba04293204c6fbdf7d782e81a415a0e7985d9c10 Mon Sep 17 00:00:00 2001 From: Linghong Date: Thu, 13 Aug 2026 06:53:06 +0000 Subject: [PATCH] =?UTF-8?q?=E7=99=BB=E8=AE=B0=E7=AB=8B=E9=A1=B9=E7=AD=96?= =?UTF-8?q?=E5=88=92=E5=AD=90=20Agent=20=E8=BA=AB=E4=BB=BD=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E5=A0=B5=E4=BD=8F=E7=99=BB=E8=AE=B0=E5=B8=A6=E6=9D=A5?= =?UTF-8?q?=E7=9A=84=E4=B8=89=E5=A4=84=E9=9D=99=E9=BB=98=E9=94=99=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D11 要求策划 Agent 由 Supervisor 通过 agent.delegate 静态委派,因此它必须是 agentCatalog 合法成员。登记方式照 agentCatalog.supervisor:与 supervisor 平级、 不进 groups——build.rs 的 validate_seed_task_catalog 只比对 groups[].roles[] 派生的 specialist_nodes,因此种子 DAG、new_game_creation_app_seed_tasks() 与 build.rs 三者一行不动,「做游戏链路不动」得以成立。 编译期:manifest.json 新增 planning 条目;AgentCatalog 加字段、validate_agent_catalog 纳入校验、render_agent_catalog 生成 PROJECT_PLANNING_* 静态。 运行时修复的是「非 supervisor 即专业组成员」这个二分假设的四个受害点: - prompt.rs 的 game_creator_agent_role_definition 返回 None,而两个调用方都用 .ok_or_else(...)? 转成硬错误——委派第一轮构建 Provider 上下文即中断(blocking) - pass_artifacts.rs 的内存路径解析报「未知 Agent 任务」 - task_start.rs 的 agent 枚举漏收,重启/steer/兜底对账后委派变孤儿任务 - task_ops.rs 的 task.create 在无 group 参数时静默兜底成 Design 组,污染任务审计。 此处只对 project-planning 要求显式传 group,不动 project-supervisor 同样吃这个 兜底的现役行为。 另排除 project-planning 出 agent.spawn_isolated 的合法模板集:它一旦进 catalog 就 天然满足「非 child- 前缀、非 Supervisor 本体」的放行条件,会让任何持该工具的 Agent 都能动态孵生它,属登记带来的隐性扩权。 新增 project_planning_is_a_delegatable_identity_outside_the_seed_dag 钉住四点,其中 「不在任何专业组且不在种子 DAG」直接断言 new_game_creation_app_seed_tasks()。 runtime_adapter 既有 catalog 目录测试同步更新期望集合。 本包只让身份跑通。exact allowlist 工具面、plan.submit_gdd 与 brief 正文属后续块。 Co-Authored-By: Claude Opus 5 --- .../build_support/runtime_prompt_bundle.rs | 43 +++++++++- .../src-tauri/prompts/runtime/manifest.json | 23 +++++- .../src/agent/generation/pass_artifacts.rs | 3 + .../src-tauri/src/agent/prompt.rs | 10 +++ .../src-tauri/src/agent/runtime_adapter.rs | 30 ++++++- .../src/agent/runtime_driver/task_start.rs | 4 + .../src/agent/runtime_tools/delegation.rs | 7 +- .../src/agent/runtime_tools/task_ops.rs | 12 +++ .../tests/collaboration/static_deliveries.rs | 78 +++++++++++++++++++ 9 files changed, 201 insertions(+), 9 deletions(-) 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 938945dcb..2d4f4573c 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 @@ -109,6 +109,10 @@ struct ProviderFragments { #[serde(rename_all = "camelCase", deny_unknown_fields)] struct AgentCatalog { supervisor: AgentGroup, + /// 立项策划子 Agent。与 `supervisor` 平级、**不进 `groups`**:`specialist_nodes` + /// 只从 `groups[].roles[]` 派生,因此它不参与 `build.rs` 与种子 DAG 的一致性 + /// 校验,「做游戏」的 16 任务 DAG 一行不动。详见技术方案第 3.1 节。 + planning: AgentGroup, groups: Vec, } @@ -693,11 +697,17 @@ fn validate_agent_catalog(catalog: &AgentCatalog) -> Result<(), String> { if catalog.supervisor.roles.len() != 1 { return Err("agentCatalog.supervisor 必须且只能包含一个 role".to_string()); } + if catalog.planning.roles.len() != 1 { + return Err("agentCatalog.planning 必须且只能包含一个 role".to_string()); + } if catalog.groups.is_empty() { return Err("agentCatalog.groups 不能为空".to_string()); } let mut group_brief_names = BTreeSet::new(); - for group in std::iter::once(&catalog.supervisor).chain(catalog.groups.iter()) { + for group in std::iter::once(&catalog.supervisor) + .chain(std::iter::once(&catalog.planning)) + .chain(catalog.groups.iter()) + { if !group_brief_names.insert(group.brief_path_name.as_str()) { return Err(format!( "agent group briefPathName 重复:{}", @@ -705,7 +715,10 @@ fn validate_agent_catalog(catalog: &AgentCatalog) -> Result<(), String> { )); } } - let mut generated_names = BTreeSet::from(["PROJECT_SUPERVISOR".to_string()]); + let mut generated_names = BTreeSet::from([ + "PROJECT_SUPERVISOR".to_string(), + "PROJECT_PLANNING".to_string(), + ]); for group in &catalog.groups { let generated = rust_identifier(&group.id); if !generated @@ -731,6 +744,12 @@ fn validate_agent_catalog(catalog: &AgentCatalog) -> Result<(), String> { &mut task_ids, &mut tool_ids, )?; + validate_agent_group( + &catalog.planning, + &mut group_ids, + &mut task_ids, + &mut tool_ids, + )?; for group in &catalog.groups { validate_agent_group(group, &mut group_ids, &mut task_ids, &mut tool_ids)?; } @@ -990,6 +1009,26 @@ fn render_agent_catalog(catalog: &AgentCatalog) -> String { "static PROJECT_SUPERVISOR_AGENT_DEFINITION: AgentGroupDefinition = {};\n", render_group_value(&catalog.supervisor, "&PROJECT_SUPERVISOR_AGENT_ROLES") )); + let planning_role = &catalog.planning.roles[0]; + output.push_str(&format!( + "pub(crate) const GAME_CREATOR_PROJECT_PLANNING_AGENT_ID: &str = {};\n", + rust_literal(&planning_role.task_id) + )); + output.push_str(&format!( + "pub(crate) const GAME_CREATOR_PROJECT_PLANNING_MEMORY_PATH: &str = {};\n", + rust_literal(&format!( + "memory/agents/{}", + catalog.planning.brief_path_name + )) + )); + output.push_str(&render_role_array( + "PROJECT_PLANNING_AGENT_ROLES", + &catalog.planning.roles, + )); + output.push_str(&format!( + "static PROJECT_PLANNING_AGENT_DEFINITION: AgentGroupDefinition = {};\n", + render_group_value(&catalog.planning, "&PROJECT_PLANNING_AGENT_ROLES") + )); for group in &catalog.groups { let roles_name = format!("{}_AGENT_ROLES", rust_identifier(&group.id)); output.push_str(&render_role_array(&roles_name, &group.roles)); diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/manifest.json b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/manifest.json index 5c95a838e..6304228a8 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/manifest.json @@ -64,12 +64,16 @@ { "agentId": "project-supervisor", "rootSourceKind": "supervisorGameChat", - "sections": ["projectSupervisorGameChatRouting"] + "sections": [ + "projectSupervisorGameChatRouting" + ] }, { "agentId": "code-prototype", "rootSourceKind": "supervisorGameChat", - "sections": ["codePrototypeGameChat"] + "sections": [ + "codePrototypeGameChat" + ] } ], "providerFragments": { @@ -98,6 +102,21 @@ } ] }, + "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": "design", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/pass_artifacts.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/pass_artifacts.rs index a662ba9dc..bdc1046b5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/pass_artifacts.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/pass_artifacts.rs @@ -336,6 +336,9 @@ pub(crate) fn agent_role_memory_relative_path_for_task(task_id: &str) -> Result< if task_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { return Ok(GAME_CREATOR_PROJECT_SUPERVISOR_MEMORY_PATH.to_string()); } + if task_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + return Ok(GAME_CREATOR_PROJECT_PLANNING_MEMORY_PATH.to_string()); + } for group in GAME_CREATOR_AGENT_GROUP_DEFINITIONS { for role in group.roles { if role.task_id == task_id { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs index 133e3dc44..6cf321af2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs @@ -667,6 +667,16 @@ pub(crate) fn game_creator_agent_role_definition( &PROJECT_SUPERVISOR_AGENT_ROLES[0], )); } + // 立项策划子 Agent 与 Supervisor 一样不属于任何专业组,必须在这里显式命中。 + // 否则它会落进下面的组遍历、返回 None,而两个调用方都用 `.ok_or_else(...)?` + // 把 None 转成硬错误:Supervisor 一旦把任务委派给它,第一轮构建 Provider + // 上下文时就会中断。 + if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + return Some(( + &PROJECT_PLANNING_AGENT_DEFINITION, + &PROJECT_PLANNING_AGENT_ROLES[0], + )); + } GAME_CREATOR_AGENT_GROUP_DEFINITIONS .iter() .find_map(|group_definition| { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_adapter.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_adapter.rs index 357aec35c..b7360c185 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_adapter.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_adapter.rs @@ -16,6 +16,25 @@ fn build_game_creator_runtime_agent_catalog() -> Result { })) }) .map_err(|error| format!("AI 游戏创作 Agent catalog 无效:{error}"))?]; + // 立项策划子 Agent:与 Supervisor 同为「catalog 成员但不是种子 DAG 任务」, + // 因此在遍历 GAME_CREATOR_AGENT_GROUP_DEFINITIONS 之外单独登记。它不属于任何 + // 专业组,groupId 自引用,避免与 design 组(中文 label「策划组」)语义碰撞。 + agents.push( + AgentDescriptor::try_new( + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + PROJECT_PLANNING_AGENT_DEFINITION.id, + std::iter::empty::<&str>(), + ) + .and_then(|agent| { + agent.with_metadata(serde_json::json!({ + "groupId": PROJECT_PLANNING_AGENT_DEFINITION.id, + "roleLabel": PROJECT_PLANNING_AGENT_ROLES[0].role, + "toolId": PROJECT_PLANNING_AGENT_ROLES[0].tool_id, + "capabilityAuthority": "game-creator-tool-policy-snapshot" + })) + }) + .map_err(|error| format!("AI 游戏创作 Agent catalog 无效:{error}"))?, + ); for group in GAME_CREATOR_AGENT_GROUP_DEFINITIONS { for role in group.roles { agents.push( @@ -91,10 +110,13 @@ mod tests { #[test] fn game_creator_runtime_agent_catalog_matches_the_existing_role_directory() { let catalog = game_creator_runtime_agent_catalog().expect("agent catalog"); - let mut expected = - std::collections::BTreeSet::from( - [GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()], - ); + // catalog 恰好是「两个组外单节点(Supervisor、立项策划)+ 各专业组角色」。 + // 立项策划刻意不在 GAME_CREATOR_AGENT_GROUP_DEFINITIONS 里:它不参与 + // build.rs 与种子 DAG 的一致性校验,「做游戏」16 任务 DAG 一行不动。 + let mut expected = std::collections::BTreeSet::from([ + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(), + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID.to_string(), + ]); for group in GAME_CREATOR_AGENT_GROUP_DEFINITIONS { expected.extend(group.roles.iter().map(|role| role.task_id.to_string())); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs index 6d37a13b0..ee3839f0c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs @@ -6,6 +6,10 @@ pub(in crate::agent) fn collect_game_creator_agent_runtime_agent_ids( let manifest = read_manifest_for_project(root)?; let mut agent_ids = std::collections::BTreeSet::new(); agent_ids.insert(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()); + // 立项策划子 Agent 不在种子 DAG、不属于任何专业组、也不是动态孤立实例, + // 三条既有收录路径都覆盖不到它。这个集合是崩溃恢复扫描、steer 级联取消与 + // 委派回执兜底对账的共同枚举来源,漏收会让重启后的策划委派变成孤儿任务。 + agent_ids.insert(GAME_CREATOR_PROJECT_PLANNING_AGENT_ID.to_string()); for task in manifest.tasks { if let Ok(agent_id) = normalize_game_creator_runtime_agent_id(&task.id) { agent_ids.insert(agent_id); 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 165830cec..32dc8c5d6 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 @@ -1395,9 +1395,14 @@ pub(crate) fn observe_agent_runtime_agent_spawn_isolated( }; for child in &request.children { let template = match normalize_game_creator_runtime_agent_id(&child.template_agent_id) { + // 立项策划子 Agent 只能由 Project Supervisor 通过 agent.delegate 静态委派 + // 发起(D11)。它一旦进入 catalog 就会天然满足「非 child- 前缀、非 + // Supervisor 本体」这个放行条件,使任何持有 agent.spawn_isolated 的 Agent + // 都能拿它当动态孵生模板——这是登记带来的隐性扩权,必须显式排除。 Ok(template) if !template.starts_with("child-") - && template != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID => + && template != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && template != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID => { template } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs index d91338b98..a24520aeb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs @@ -333,6 +333,18 @@ pub(in crate::agent) fn observe_agent_runtime_task_create( }; } let group_input = agent_runtime_tool_input_text(input, &["group", "area"]); + // 立项策划子 Agent 不属于任何专业组,其 groupId 映射不出 GameCreationAppAgentGroup, + // 会落进下面的 Design 兜底、静默写出一条「归属 Design 组」的假任务污染任务审计。 + // 它按设计根本不该持有 task.create(工具面是 exact allowlist),这里只做兜底: + // 不静默改归属,要求显式传 group。 + if group_input.trim().is_empty() && agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { + return AgentRuntimeToolObservation { + tool: "task.create".to_string(), + status: "failed".to_string(), + summary: "立项策划 Agent 不属于任何专业组,创建任务必须显式指定 group".to_string(), + detail: None, + }; + } let group = if group_input.trim().is_empty() { game_creator_agent_role_definition(agent_id) .map(|(group, _role)| group.id) diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs index cd180e072..663b22d14 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs @@ -4462,3 +4462,81 @@ fn game_chat_source_caps_clarification_round_at_one() { drop(target_lock); fs::remove_dir_all(root).ok(); } + +/// 立项策划子 Agent 的身份登记(D11 / 技术方案第 3.1 节)。 +/// +/// 钉住四件事: +/// 1. `project-planning` 是 catalog 合法成员,能通过 `agent.delegate` 的目标校验; +/// 2. 它能合成出角色身份——这是原先的 blocking 缺口:`game_creator_agent_role_definition` +/// 只特判 Supervisor、其余遍历专业组,`project-planning` 会返回 `None`,而调用方用 +/// `.ok_or_else(...)?` 把它转成硬错误,导致委派第一轮就中断; +/// 3. 它**不进**种子 DAG——`build.rs` 的一致性校验只比对 `groups[].roles[]` 派生的集合, +/// 「做游戏」16 任务 DAG 一行不动; +/// 4. 登记不得带来隐性扩权:它只能被 Supervisor 静态委派,不能被当作 +/// `agent.spawn_isolated` 的动态孵生模板。 +#[test] +fn project_planning_is_a_delegatable_identity_outside_the_seed_dag() { + // 1 + 2:身份可解析、角色身份可合成 + assert_eq!( + normalize_game_creator_runtime_agent_id(GAME_CREATOR_PROJECT_PLANNING_AGENT_ID) + .expect("project-planning 必须是 catalog 合法成员"), + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID + ); + let (group, role) = game_creator_agent_role_definition(GAME_CREATOR_PROJECT_PLANNING_AGENT_ID) + .expect("project-planning 必须能合成角色身份,否则委派第一轮即硬失败"); + assert_eq!(role.task_id, GAME_CREATOR_PROJECT_PLANNING_AGENT_ID); + assert_eq!(group.id, PROJECT_PLANNING_AGENT_DEFINITION.id); + + // 3:不属于任何专业组,因此不进 specialist_nodes / 种子 DAG + assert!( + !GAME_CREATOR_AGENT_GROUP_DEFINITIONS + .iter() + .any(|definition| definition + .roles + .iter() + .any(|candidate| candidate.task_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID)), + "project-planning 一旦进入任何专业组,就会被 build.rs 要求同步进种子 DAG,破坏「做游戏链路一行不动」" + ); + assert!( + !new_game_creation_app_seed_tasks() + .iter() + .any(|task| task.id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID), + "种子 DAG 不得因为登记策划 Agent 而新增节点" + ); + + // 4:不得成为动态孵生模板 + let root = unique_project_path(); + init_local_game_project_at(&root, "project-planning-identity", "策划身份登记测试") + .expect("project init"); + let run_id = "planning-identity-run"; + let mut state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "发起立项策划", + run_id, + "agent-chat", + "立项策划", + vec!["形成一份可审批的 Fast GDD".to_string()], + ) + .expect("start supervisor run"); + let _ = &mut state; + + let spawn = observe_agent_runtime_agent_spawn_isolated( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + Some("planning-identity-spawn-action"), + &serde_json::json!({ + "children": [{ + "templateAgentId": GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "task": "试图把策划 Agent 当作动态孵生模板", + }], + }), + ); + assert_eq!( + spawn.status, "failed", + "project-planning 只能由 Supervisor 静态委派,不能被 agent.spawn_isolated 当模板:{spawn:?}" + ); + + fs::remove_dir_all(root).ok(); +}