登记立项策划子 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:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user