修复 M1A-2 引入的回归:未知工具名不是身份违规
Project CI / Repository checks (pull_request) Successful in 1m9s
Project CI / Frontend tests (pull_request) Successful in 2m54s
Project CI / Backend tests (pull_request) Successful in 3m56s
Project CI / Native shell tests (pull_request) Failing after 10m41s

background_agent_runtime_persists_receipts_for_rejected_actions 在本分支
恒失败(3/3),master 通过。首个 tool-plan 请求能收到,动作被拒绝后第二次
Provider follow-up 不再发出,测试等待超时。不是已知的 mock-LLM 本机 flake。

根因是 M1A-2 给主循环加身份门时用了 agent_runtime_tool_allowed_for_agent,
而该函数对非 project-planning 的 Agent 退化成「这个工具名是否已知」。于是
普通 Agent 调用一个不存在的工具(模型编名字,常见协议错误)被判成身份违规,
main_loop 直接 mark_needs_reconciliation 并返回 NeedsReconciliation,整个
run 中断。现役语义是未知工具产出一条 rejected observation、run 继续、由下
一轮 tool-plan 收束。

两类必须分开:身份禁止某个已知工具是安全边界,命中即硬拒;工具名根本不存在
是可恢复的协议错误,不得升级成中断整个 run。

新增 agent_runtime_tool_rejected_by_agent_identity,只在「该 Agent 带 exact
allowlist 且工具不在其中」时为真。三个命中即中断或整体拒绝的调用点改用它:
main_loop(本次回归直接原因)、provider_action_batch 的 identity_block(原会
把普通 Agent 的未知工具从 rejected 误判成 blocked)、runtime_tools/policy
(原本就正确限定 planning,改为复用同一判据以免再次分叉)。parallel_ledger
内部批次资格判定不变,其下一行的 command_id 检查本就拦得住。

planning 侧约束未放松,两条单测分别钉死普通 Agent 与 planning 两侧。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 06:35:45 +00:00
parent 5fa662be30
commit 323db581fa
6 changed files with 69 additions and 8 deletions
@@ -73,6 +73,7 @@ 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_allowed_for_agent, agent_runtime_tool_is_parallel_safe_read,
agent_runtime_tool_rejected_by_agent_identity,
game_creator_agent_runtime_parallel_read_batch_path,
game_creator_agent_runtime_pending_tool_action_path,
game_creator_agent_runtime_provider_action_batch_path,
@@ -129,10 +129,63 @@ pub(crate) fn agent_runtime_tool_allowed_for_agent(agent_id: &str, tool: &str) -
game_creator_agent_runtime_tool_command_id(tool.trim()).is_some()
}
/// 身份层面的**显式**拒绝:该 Agent 身份带 exact allowlist,且工具不在其中。
///
/// **未知工具名不属于本判据。** `agent_runtime_tool_allowed_for_agent` 对普通
/// Agent 退化成「这个工具名是否已知」,用它做身份门会把「模型编了个不存在的
/// 工具」这种普通协议错误误判成身份违规。协议错误的既有语义是:走到执行层产出
/// 一条 `rejected` observationrun 继续,由下一轮 tool-plan 收束;升级成身份
/// 拒绝会让整个 run 进 needs-reconciliation 而**不再发出 follow-up 请求**。
///
/// 因此凡是「命中即中断 run 或整体拒绝动作」的调用点都必须用本判据,不能直接
/// 用 `agent_runtime_tool_allowed_for_agent`。
pub(crate) fn agent_runtime_tool_rejected_by_agent_identity(agent_id: &str, tool: &str) -> bool {
agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID
&& !agent_runtime_tool_allowed_for_agent(agent_id, tool)
}
#[cfg(test)]
mod identity_tests {
use super::*;
/// 普通 Agent 编出来的未知工具名是**协议错误**,不是身份违规。
///
/// 判成身份违规会让 main_loop 把整个 run 打进 needs-reconciliation、不再发出
/// follow-up tool-plan——曾导致 `background_agent_runtime_persists_receipts_for_rejected_actions`
/// 在等待第二次 Provider 请求时超时。
#[test]
fn unknown_tool_on_ordinary_agent_is_not_an_identity_rejection() {
assert!(!agent_runtime_tool_rejected_by_agent_identity(
"design-director",
"runtime.unknown"
));
assert!(!agent_runtime_tool_rejected_by_agent_identity(
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
"runtime.unknown"
));
assert!(!agent_runtime_tool_rejected_by_agent_identity(
"design-director",
"file.read"
));
}
/// planning 身份仍是 exact allowlist:未知工具与越权工具都算身份拒绝。
#[test]
fn planning_identity_still_rejects_unknown_and_out_of_scope_tools() {
assert!(agent_runtime_tool_rejected_by_agent_identity(
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
"runtime.unknown"
));
assert!(agent_runtime_tool_rejected_by_agent_identity(
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
"file.write"
));
assert!(!agent_runtime_tool_rejected_by_agent_identity(
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
"file.read"
));
}
#[test]
fn planning_identity_does_not_inherit_project_search_alias() {
assert!(agent_runtime_tool_allowed_for_agent(
@@ -473,10 +473,10 @@ 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(
let identity_block = agent_runtime_tool_rejected_by_agent_identity(
&runtime.agent_id,
action.tool.trim(),
))
)
.then(|| {
AgentRuntimeToolPolicyBlock::Denied(
"当前 Agent 身份不允许执行该原始工具".to_string(),
@@ -2696,7 +2696,7 @@ 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()) {
if agent_runtime_tool_rejected_by_agent_identity(&agent_id, action.tool.trim()) {
let pending_action = prepared_action
.as_ref()
.expect("prepared action exists for an identity-rejected tool");
@@ -329,11 +329,9 @@ pub(crate) fn game_creator_agent_runtime_tool_policy_block_after_lock(
command_id: &str,
pending_action: Option<&AgentRuntimePendingToolAction>,
) -> Option<AgentRuntimeToolPolicyBlock> {
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)
})
{
if pending_action.is_some_and(|pending| {
agent_runtime_tool_rejected_by_agent_identity(agent_id, &pending.action.tool)
}) {
return Some(AgentRuntimeToolPolicyBlock::Denied(
"当前 Agent 身份不允许执行该原始工具".to_string(),
));
@@ -1,5 +1,14 @@
# 决策记录
## 2026-08-14 修复 M1A-2 引入的回归:未知工具名不是身份违规
- 症状:`background_agent_runtime_persists_receipts_for_rejected_actions` 在 feature 分支恒失败(3/3),`master` 通过。首个 tool-plan 请求能收到,动作被拒绝后**第二次 Provider follow-up 请求不再发出**,测试等待超时。**不是已知的 mock-LLM 本机 flake**,是确定性回归。
- 根因:`M1A-2` 给主循环加身份门时用了 `agent_runtime_tool_allowed_for_agent`,但该函数对**非** `project-planning` 的 Agent 退化成「这个工具名是否已知」(`game_creator_agent_runtime_tool_command_id(..).is_some()`)。于是普通 Agent 调用一个不存在的工具(模型编名字,属常见协议错误)被判成身份违规,`main_loop` 直接 `mark_needs_reconciliation``return NeedsReconciliation`,整个 run 中断。**现役语义是:未知工具走到执行层产出一条 `rejected` observationrun 继续,由下一轮 tool-plan 收束。**
- 两类必须分开:**「身份禁止某个已知工具」**planning 的 exact allowlist)是安全边界,命中即硬拒;**「工具名根本不存在」**是可恢复的协议错误,不得升级成中断整个 run。混用会把模型一次笔误变成需要人工核对的终态。
- 处置:新增 `agent_runtime_tool_rejected_by_agent_identity`,只在「该 Agent 带 exact allowlist 且工具不在其中」时为真。三个「命中即中断 run 或整体拒绝动作」的调用点改用它——`main_loop.rs`(原为硬中断,本次回归的直接原因)、`provider_action_batch.rs``identity_block`(原会把普通 Agent 的未知工具从 `rejected` 误判成 `Denied`/`blocked`)、`runtime_tools/policy.rs`(原本就正确限定了 planning,改为复用同一判据以免再次分叉)。`parallel_ledger.rs` 内部的批次资格判定不变——它下一行的 `command_id` 检查本就会拦住未知工具,无过度拒绝。
- planning 侧约束未放松:planning + 未知工具、planning + 越权工具仍判身份拒绝,回归 `planning_identity_still_rejects_unknown_and_out_of_scope_tools` 钉死;`unknown_tool_on_ordinary_agent_is_not_an_identity_rejection` 钉死普通 Agent 侧。
- 教训:给已有主循环插「命中即中断」的门时,判据必须是**专门表达该门语义**的谓词。复用一个名字听起来正确、但对多数输入退化成别的含义的通用函数,会在没人测到的分支上改变现役语义。
## 2026-08-14 M1A 复核残余收口:retry 强判据前置、身份哨兵改为编译期约束
`M1A-1``M1A-4` 做整组复核后,除已由 `M1A-4` 解决的一项外,另有两处代码残余与三处文档残余。本条记录代码两处的处置,文档三处随本次提交一并订正。