立项策划根 run 的工具面收窄成 7 个原生工具的 exact allowlist

plan 根 Supervisor 此前拿到的是未按身份收窄的全量注册表:43 个原生工具外加
项目 MCP 目录。其中约 36 个在这条链路上根本执行不了——写入、补丁、删除、
命令、预览、画布、任务图、记忆、黑板、agent.spawn_isolated、
agent.route_manifest、agent.schedule_ready 都会被执行层拒绝。

广告一个执行不了的工具不是中性的。M1A-4 已经实测到同类后果:上下文里残留的
专业角色目录让 Supervisor 照着发起 agent.delegate,被拒后再没能自行改回
project-planning,整个 run 空转到预算耗尽。工具面同理——本链路的策划内容全部
由 project-planning 子 Agent 生产,Supervisor 手里多一个写文件或跑命令的入口,
就多一条它自己下场干活的诱导路径。

allowlist 定为 user.input_request、file.read、agent.delegate、
agent.goal_contract、agent.acceptance_update、agent.action_history、
agent.run_status,加上两个协议控制函数。后三个原生工具只为 §13.0 的审批前置
取证门存在(分页读 game/fast_gdd.md 并列出全部分页 actionId);file.list 一并
砍掉,取证路径是固定的,不需要列目录。

实现沿用 restrict_plan_root_goal_contract_schema 已有的后置收窄形状,而不是给
build_agent_runtime_native_function_tools_for_agent 加参数:两处调用点本来就在
`if plan_root` 里,改动面更小,也不会波及其它 lane 的目录构建。

收窄写成交集而非断言。协议修复路径会先整份重建目录再按场景收窄,其中
restrict_agent_runtime_supervisor_collaboration_repair_tools 硬要求目录里存在
agent.spawn_isolated;plan 根这一遍必须排在所有分支收窄之后取交集,早于分支
会让那条检查失败,晚于分支才能保证任何修复轮都不把被裁掉的 36 个工具重新广告
回去。

回归断言写成精确集合而不是「不包含某几个」,将来往注册表加工具不会静默漏进
plan 根;同时断言 MCP 前缀工具不残留、收窄确实裁掉了东西(否则测试是空跑)。
已变异验证。

本次只改广告目录。提示词头部仍按全量注册表拼工具清单,二者的一致性由紧随其后
的 prompt 提交收口——单独看这一笔存在「合同说有、请求里没有」的过渡态。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 04:49:48 +00:00
parent 4f2136c919
commit 2e6c0e7ac2
5 changed files with 113 additions and 7 deletions
@@ -154,6 +154,7 @@ pub(crate) use tool_plan_protocol::{
pub(crate) use tool_policy_snapshot::{
agent_runtime_acceptance_evidence_tools,
agent_runtime_autonomous_design_foundation_command_is_allowed, agent_runtime_executable_tools,
agent_runtime_native_executable_tools, agent_runtime_tool_policy_snapshot_for_run_at,
AGENT_RUNTIME_CANVAS_ASSET_KINDS, AGENT_RUNTIME_PROJECT_PLANNING_ACTION_TOOLS,
agent_runtime_native_executable_tools, agent_runtime_plan_root_supervisor_tools,
agent_runtime_tool_policy_snapshot_for_run_at, AGENT_RUNTIME_CANVAS_ASSET_KINDS,
AGENT_RUNTIME_PROJECT_PLANNING_ACTION_TOOLS,
};
@@ -553,6 +553,7 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
)?)
.with_tool_choice(platform_llm::LlmToolChoice::Required);
if plan_root {
retain_plan_root_supervisor_native_tools(&mut request.function_tools)?;
restrict_plan_root_goal_contract_schema(&mut request.function_tools)?;
}
if runtime_owner_artifact_validation_available {
@@ -1284,6 +1284,14 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
"上一条输出不符合工具计划协议:{protocol_error}\n请修复格式,只调用当前请求广告的 update_agent_plan、动作工具或 respond_to_user;当前 in_progress 步骤已具备执行条件时,格式修复必须保留并调用对应动作工具,不能退化为只调用 update_agent_plan。不要解释,不要 markdown,不要代码围栏,也不要把计划、动作或回复放进普通文本。"
)));
}
// 修复分支会先整份重建工具目录,再按各自的场景收窄。plan 根必须在
// 所有分支收窄之后再取一次交集:早于分支就会让
// `restrict_agent_runtime_supervisor_collaboration_repair_tools`
// 这类「必须包含 agent.spawn_isolated」的检查硬失败,晚于分支则
// 保证任何修复轮都不会把被裁掉的 36 个工具重新广告回去。
if plan_root {
retain_plan_root_supervisor_native_tools(&mut request.function_tools)?;
}
request.enable_web_search = false;
}
Err(error) => {
@@ -111,6 +111,30 @@ pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> {
]
}
/// plan 根 Supervisor(`source == project-supervisor-plan`)在整条策划链路里
/// 只负责四件事:冻结 Goal Contract、委派与续跑 `project-planning`、代子 Agent
/// 向用户提问、按 §13.0 取证后建审批卡。策划内容全部由子 Agent 生产,Supervisor
/// 不写文件、不跑命令、不做预览、不生成素材、不调度 ready 任务、不并行委派。
///
/// 全量注册表会把 43 个原生工具摆在 Provider 眼前,其中约 36 个在 plan 根都会被
/// 执行层拒绝——广告出去只会诱导 Supervisor 自己下场干活。这里给出 plan 根的
/// exact allowlist,Provider 请求目录和 system prompt 的工具清单共用它,二者不得
/// 各自维护一份。
///
/// `file.read`、`agent.acceptance_update`、`agent.action_history` 只为 §13.0 的审批
/// 前置取证门存在(分页读 `game/fast_gdd.md` 并列出全部分页 actionId)。
pub(crate) fn agent_runtime_plan_root_supervisor_tools() -> &'static [&'static str] {
&[
"user.input_request",
"file.read",
"agent.delegate",
"agent.goal_contract",
"agent.acceptance_update",
"agent.action_history",
"agent.run_status",
]
}
pub(crate) fn agent_runtime_native_executable_tools() -> Vec<&'static str> {
agent_runtime_executable_tools()
.into_iter()
@@ -10,11 +10,12 @@ use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use crate::agent::{
agent_runtime_native_executable_tools, AgentRuntimePlanUpdate, AgentRuntimeToolAction,
AgentRuntimeToolPlan, AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT,
AGENT_RUNTIME_CANVAS_ASSET_KINDS, AGENT_RUNTIME_PLAN_STEP_LIMIT,
PLAN_FAST_GDD_ACCEPTANCE_NODE_CRITERION, PLAN_FAST_GDD_ACCEPTANCE_NODE_EVIDENCE,
PLAN_FAST_GDD_ACCEPTANCE_NODE_ID, PLAN_SUBMIT_GDD_INPUT_SCHEMA_VERSION, PLAN_SUBMIT_GDD_TOOL,
agent_runtime_native_executable_tools, agent_runtime_plan_root_supervisor_tools,
AgentRuntimePlanUpdate, AgentRuntimeToolAction, AgentRuntimeToolPlan,
AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT, AGENT_RUNTIME_CANVAS_ASSET_KINDS,
AGENT_RUNTIME_PLAN_STEP_LIMIT, PLAN_FAST_GDD_ACCEPTANCE_NODE_CRITERION,
PLAN_FAST_GDD_ACCEPTANCE_NODE_EVIDENCE, PLAN_FAST_GDD_ACCEPTANCE_NODE_ID,
PLAN_SUBMIT_GDD_INPUT_SCHEMA_VERSION, PLAN_SUBMIT_GDD_TOOL,
};
use crate::mcp::{
validate_game_creator_mcp_tool_arguments, GameCreatorMcpCatalog, GameCreatorMcpCatalogTool,
@@ -360,6 +361,38 @@ pub(crate) fn build_agent_runtime_native_function_tools_for_agent(
Ok(functions)
}
/// Narrow the plan root Supervisor's advertised function catalog down to
/// `agent_runtime_plan_root_supervisor_tools()` plus the two protocol controls.
///
/// This is an intersection, not an assertion: the caller may already have
/// narrowed the catalog further for a protocol-repair turn (for example
/// `restrict_agent_runtime_supervisor_collaboration_repair_tools`), and this
/// pass must never widen it back. MCP function tools carry a different prefix
/// and are dropped here too — the plan lane never calls MCP.
///
/// An empty result means the repair-branch allowlist and the plan-root
/// allowlist are disjoint, which would send a request with no callable tool at
/// all; that is a configuration error, so fail closed instead.
pub(crate) fn retain_plan_root_supervisor_native_tools(
functions: &mut Vec<LlmFunctionTool>,
) -> Result<(), String> {
let mut allowed = BTreeSet::from([
AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME.to_string(),
AGENT_RUNTIME_RESPOND_FUNCTION_NAME.to_string(),
]);
for tool in agent_runtime_plan_root_supervisor_tools() {
allowed.insert(
native_runtime_function_name(tool)
.ok_or_else(|| format!("无法生成 plan 根 Supervisor 工具函数名:{tool}"))?,
);
}
functions.retain(|function| allowed.contains(&function.name));
if functions.is_empty() {
return Err("plan 根 Supervisor 工具目录收窄后为空".to_string());
}
Ok(())
}
/// Narrow only the request-scoped Goal Contract schema used by the plan root.
/// The capability registry itself must remain dynamic: game-chat and ordinary
/// Supervisor runs still author their own acceptance graph.
@@ -2101,6 +2134,45 @@ mod tests {
assert!(description.contains("runId 必须为 null"));
}
/// plan 根 Supervisor 只广告 7 个原生工具 + 2 个协议控制。全量注册表里其余
/// 约 36 个在这条链路上全部会被执行层拒绝,广告出去只会诱导 Supervisor 自己
/// 下场写文件、跑命令、查任务图。断言写成精确集合而不是「不包含某几个」,
/// 这样将来往注册表里加工具不会静默漏进 plan 根。
#[test]
fn plan_root_supervisor_tool_catalog_is_an_exact_allowlist() {
let mcp_catalog =
native_mcp_catalog(json!({"type": "object", "additionalProperties": false}));
let mut functions = build_agent_runtime_native_function_tools_for_agent(
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&mcp_catalog,
)
.expect("build supervisor catalog");
let before = functions.len();
retain_plan_root_supervisor_native_tools(&mut functions).expect("retain plan root tools");
let mut expected = vec![
AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME.to_string(),
AGENT_RUNTIME_RESPOND_FUNCTION_NAME.to_string(),
];
for tool in agent_runtime_plan_root_supervisor_tools() {
expected.push(native_runtime_function_name(tool).expect("plan root function name"));
}
expected.sort();
let mut actual = functions
.iter()
.map(|function| function.name.clone())
.collect::<Vec<_>>();
actual.sort();
assert_eq!(actual, expected);
assert!(
before > functions.len(),
"收窄必须真的裁掉工具,否则这条测试是空跑"
);
// MCP 前缀的动态工具同样不得残留:plan 根整条链路不调 MCP。
assert!(!functions
.iter()
.any(|function| function.name.starts_with(AGENT_RUNTIME_NATIVE_MCP_PREFIX)));
}
#[test]
fn native_goal_contract_and_acceptance_update_expose_dynamic_graph_contract() {
let goal = runtime_tool_input_schema("agent.goal_contract");