立项策划:收口 M1A 复核残余,retry 强判据前置且身份哨兵改为编译期约束

retry 强判据必须排在全部分支之前。plan 分支原先排在 delegated 与
autonomous-game-build 之后,两支都能绕开
reject_supervisor_plan_root_retry_without_identity:

一是 plan source 配伪造 parent 会落 delegated 支,直接返回
agent-delegate-retry,强判据根本不执行。二是 binding.source 是 plan 配
autonomous profile 会落 autonomous 支,因 plan 在可信集合内而被原样取回,
复活启动路径 reject_supervisor_plan_autonomous_profile 明令禁止的组合。

两者都要 durable 状态先畸变才可达,但强判据存在的意义正是对畸变状态
fail closed。守卫提到函数开头无条件执行,合法 plan 根 run 对它恒真;
plan 分支不再重复读 durable 状态。autonomous 支另加一次
reject_supervisor_plan_autonomous_profile,兜住顶部守卫按 task.source
判定所挡不住的那一种。回归已用变异测试确认去掉任一守卫即变红。

__all_agents__ 身份哨兵改为编译期约束。四个不带 agentId 的 wrapper 会以
哨兵跳过按身份的工具面收窄与原始工具 identity 复核,M1A-2 之后调用点只剩
测试,但将来新增生产调用点漏改是静默拿全量目录而非编译失败。四个 wrapper
与对应 re-export 一并加 cfg(test)。该哨兵已扩散到两个文件,是正在复制的
模式而非单点遗留。

订正 M1A-3 决策条里「agent-background-task 唯一构造点」的错误结论:
task_start 与 recovery_scan 各还有一处同形状的空 source 兜底,且
recovery_scan 那条不经过 plan 根强判据。经复核有意不改,理由随条记录。

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