登记立项策划子 Agent 身份,并堵住登记带来的三处静默错分

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 06:53:06 +00:00
parent 0eca4c215f
commit ba04293204
9 changed files with 201 additions and 9 deletions
@@ -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<AgentGroup>,
}
@@ -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));
@@ -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",
@@ -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 {
@@ -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| {
@@ -16,6 +16,25 @@ fn build_game_creator_runtime_agent_catalog() -> Result<AgentCatalog, String> {
}))
})
.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()));
}
@@ -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);
@@ -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
}
@@ -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)
@@ -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();
}