plan 根工具面按阶段开放,只读调用不再清零空转计数

run 18:Supervisor 冻结目标合同后一次也没委派,改成反复调 agent.run_status 去查
一个根本不存在的委派,24 轮里 58 次 agent.run_status、0 次 agent.delegate,烧到
超时。两道防线各自失效:

  · 空转闸门(AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT=4)只在裸 update_agent_plan 且
    步骤没变化时累加,而任意非空 actions 都清零。于是「裸计划更新被 blocked →
    调一次只读工具 → 计数归零」是一个结构上永远关不上的闸。
  · Runtime 其实有一条精准提示「调用 agent.delegate 派出策划子 Agent」,但它在
    plan.actions.is_empty() 分支里——模型一调 run_status 就有了动作,提示不再出现,
    取而代之的是一份看起来像进展的「已读取 11 个 Agent 状态」。

本地原型没有这个问题,不是因为阈值,是因为它的 Supervisor 只有四个工具且每一个都
推进链路:「调了工具」和「推进了链路」在那边是同一件事,按前者计数就是准的。生产
把两者拆开了却还在按前者计数,工具也是全程可见。

三处改动:

1. 工具面按 durable 阶段开放。合同未冻结 → 只有 agent.goal_contract;合同已冻结
   但本根 run 尚无委派 → 只有 agent.delegate;已有委派 → 取证/返工/审批那几个。
   run 18 卡死的正是第二档,那一档模型连 run_status 都调不出来。阶段只按 durable
   事实判定,不看 Provider 说了什么。

2. 砍掉 user.input_request。这条链路上它是死的:子 Agent 以信封退出后,Runtime 在
   parent-wake 屏障处自己按信封原文构造 pending 且不恢复父 run,Supervisor 永远收不
   到 needs-user-input observation。广告出去只会让它在别的时点调一次,撞上 Runtime
   已装好的那份 pending 而硬失败。playbook 第 4 步与转达提问那条一并改写。

3. 空转判据改成看「本轮有没有能推进 durable 状态的动作」,只读工具不清零。纯只读
   调查不受影响——不发裸计划更新就根本不会累加。这条对做游戏那条链路同样有效。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 08:11:12 +00:00
parent 9a3b9dce15
commit a021f18b98
8 changed files with 259 additions and 39 deletions
@@ -3,13 +3,12 @@
1. 本 run 第一轮只调用一次 `agent.goal_contract` 冻结目标合同:outcome 概括用户原话意图,`preferences` 必须传空数组,`acceptanceNodes` 提交 Runtime 指定的固定单节点。这一轮不做任何其它调用。
2. 冻结后立即用一次 `agent.delegate` 把任务委派给 `project-planning``expectedArtifacts``game/fast_gdd.md``repairOfDelegationId``runId``continuationOfDelegationId``questionsSha256``answersSha256` 全传 null。已有委派尚未收束时不要重复委派。
3. 等待子 Agent 期间不得调用 `respond_to_user`。Runtime 会通过 delegate 完成屏障保持同一父 run,回执到达后再继续。
4. 回执 contractStatus=needs-user-input 时,按 observation 给出的问题信封原样调用一次 `user.input_request`;答案收齐后为该原 delivery 创建且仅创建一次 continuation 委派,`continuationOfDelegationId``repairOfDelegationId` 都指向该原 delivery,并提交 observation 给出的 `questionsSha256``answersSha256`
4. 子 Agent 以问询信封退出时,决策卡由 Runtime 直接按信封原文呈现给用户,**不需要你调用任何工具**——你根本不会在那一刻被恢复。用户答完之后你才会拿到答案,届时为该原 delivery 创建且仅创建一次 continuation 委派,`continuationOfDelegationId``repairOfDelegationId` 都指向该原 delivery,并提交 observation 给出的 `questionsSha256``answersSha256`
5. 回执 contractStatus=evidence-ready 且 GDD 已提交时,用 `file.read` 从第 1 行分页读到 `game/fast_gdd.md` 末尾取证,用 `agent.action_history` 取回全部分页 actionId,再用一次 `agent.acceptance_update` 把它们完整写进 evidence。取证完成前审批卡不会出现。
6. 用户在审批卡上选择修改或退回时,先用 `agent.run_status` 按原 delegationId 取回已认领的权威委派合同,把其中的 acceptanceCriteria 与 expectedArtifacts 逐字照抄进返工委派(`runId` 传 null),再把用户原话完整附在 task 里;同一原委派只能返工一次。用户通过后只做一句简短收尾。
【转达的规则】
- 转达子 Agent 的问题时 `questions` 原样透传:不改写题干、不增删选项、不替用户预选。
- 把用户答案回灌给 `project-planning` 时,逐条列出全部已确认决定,每条格式为 `[已确认] {header} → 用户答:{原文}`。用户答案原文一字不改、不归纳、不拆分、不搬轮次;任务长度接近上限时压缩你自己的说明文字,绝不压缩用户答案。
- A、B、“需要原型验证”三项必须原样转述;B 是用户确认的 `confirmed/user_option`,不能转成默认建议。用户后续自由填写推翻了更早的决定时,你只负责把两轮答案的原文都原样带到,并说明后者更晚;怎么记进决定台账由 `project-planning` 判断,不要替它裁定哪条作废。
@@ -620,7 +620,7 @@ fn game_creator_project_supervisor_plan_tool_plan_system_prompt() -> String {
// 「合同说有、请求里没有」的自相矛盾。
let tool_catalog = agent_runtime_plan_root_supervisor_tools().join("");
let prompt_header = format!(
"你正在使用 Genarrative AI 游戏创作多智能体 Runtime。你必须直接调用当前请求广告的原生函数:复杂任务首次拆解、实际进度变化、steer 调整顺序或最终收束时调用 update_agent_plan,并提交 explanation 与完整 steps;无需更新时不要调用 update_agent_plan。steps 只允许 pending、in_progress、completed 且同时最多一个 in_progress;已完成步骤必须保留且不得回退,所有必要步骤 completed 前不得调用 respond_to_userRuntime 不会按工具动作下标代替你更新进度。本 run 的原生可执行工具目录只有:{tool_catalog}。写入、补丁、删除、命令、预览、素材生成、任务图、记忆、黑板、isolated child 与 MCP 工具在本 run 都不存在,调用它们只会失败。"
"你正在使用 Genarrative AI 游戏创作多智能体 Runtime。你必须直接调用当前请求广告的原生函数:复杂任务首次拆解、实际进度变化、steer 调整顺序或最终收束时调用 update_agent_plan,并提交 explanation 与完整 steps;无需更新时不要调用 update_agent_plan。steps 只允许 pending、in_progress、completed 且同时最多一个 in_progress;已完成步骤必须保留且不得回退,所有必要步骤 completed 前不得调用 respond_to_userRuntime 不会按工具动作下标代替你更新进度。本 run 的原生可执行工具目录只有:{tool_catalog},并且**按阶段开放**——每一轮只广告当前阶段能真正推进链路的那几个,没有出现在本轮函数目录里的,这一阶段调用不了,也不需要调用。澄清卡不由你发:子 Agent 的问询信封由 Runtime 直接转成决策卡,你只会在用户答完之后被恢复。写入、补丁、删除、命令、预览、素材生成、任务图、记忆、黑板、isolated child 与 MCP 工具在本 run 都不存在,调用它们只会失败。"
);
// `$platform` 整段是 command.start/exec/poll/stdin/terminate 的用法合同,
// plan 根一个 command 工具都没有;GDD 里的平台事实由 Runtime 另行注入,与
@@ -1462,7 +1462,7 @@ mod tests {
let allowlist = agent_runtime_plan_root_supervisor_tools();
assert!(
plan_prompt.contains(&format!(
"本 run 的原生可执行工具目录只有:{}",
"本 run 的原生可执行工具目录只有:{},并且**按阶段开放**",
allowlist.join("")
)),
"plan 根 prompt 头部必须逐字列出 allowlist{plan_prompt}"
@@ -155,6 +155,8 @@ 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_plan_root_supervisor_tools,
agent_runtime_tool_policy_snapshot_for_run_at, AGENT_RUNTIME_CANVAS_ASSET_KINDS,
agent_runtime_plan_root_supervisor_tools_for_stage,
agent_runtime_tool_policy_snapshot_for_run_at, plan_root_supervisor_stage_at,
PlanRootSupervisorStage, AGENT_RUNTIME_CANVAS_ASSET_KINDS,
AGENT_RUNTIME_PROJECT_PLANNING_ACTION_TOOLS,
};
@@ -553,8 +553,15 @@ 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)?;
let stage = plan_root_supervisor_stage_at(root, agent_id, run_id)?;
retain_plan_root_supervisor_native_tools(&mut request.function_tools, stage)?;
// 固定单节点 schema 只对还在广告 agent.goal_contract 的阶段有意义;收窄之后
// 它已经不在目录里,此处再调只会撞上那道 fail-closed 的"缺少工具"守卫。
if agent_runtime_plan_root_supervisor_tools_for_stage(stage)
.contains(&"agent.goal_contract")
{
restrict_plan_root_goal_contract_schema(&mut request.function_tools)?;
}
}
if runtime_owner_artifact_validation_available {
remove_autonomous_owner_manual_verification_tools(&mut request.function_tools)?;
@@ -1290,7 +1290,10 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
// 这类「必须包含 agent.spawn_isolated」的检查硬失败,晚于分支则
// 保证任何修复轮都不会把被裁掉的 36 个工具重新广告回去。
if plan_root {
retain_plan_root_supervisor_native_tools(&mut request.function_tools)?;
retain_plan_root_supervisor_native_tools(
&mut request.function_tools,
plan_root_supervisor_stage_at(root, agent_id, run_id)?,
)?;
}
request.enable_web_search = false;
}
@@ -112,20 +112,26 @@ 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 任务、不并行委派。
/// 只负责件事:冻结 Goal Contract、委派与续跑 `project-planning`、按 §13.0 取证
/// 后建审批卡。策划内容全部由子 Agent 生产,Supervisor 不写文件、不跑命令、不做
/// 预览、不生成素材、不调度 ready 任务、不并行委派。
///
/// 全量注册表会把 43 个原生工具摆在 Provider 眼前,其中约 36 个在 plan 根都会被
/// 全量注册表会把 43 个原生工具摆在 Provider 眼前,其中绝大多数在 plan 根都会被
/// 执行层拒绝——广告出去只会诱导 Supervisor 自己下场干活。这里给出 plan 根的
/// exact allowlistProvider 请求目录和 system prompt 的工具清单共用它,二者不得
/// 各自维护一份。
///
/// **`user.input_request` 不在其中**:澄清卡不是 Supervisor 发的。子 Agent 以
/// `AGC_NEEDS_USER_INPUT_V1` 终态信封退出后,Runtime 在 parent-wake 屏障处自己按
/// 信封原文构造 `user.input_request` pending 并且**不恢复父 run**
/// `ensure_static_delegate_user_input_wait_at_locked`)。Supervisor 因此永远收不到
/// needs-user-input observation,也就没有调用它的时机;广告出去只会让它在别的时点
/// 调一次,撞上 Runtime 已经装好的那份 pending 而硬失败。
///
/// `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",
@@ -135,6 +141,106 @@ pub(crate) fn agent_runtime_plan_root_supervisor_tools() -> &'static [&'static s
]
}
/// plan 根在链路上的推进阶段。
///
/// 工具面按阶段收窄,是因为「工具在它无用的阶段仍然可见」会直接制造活锁:实测一次
/// 生产 run 里 Supervisor 冻结合同后没有委派,改成反复调 `agent.run_status` 去查一个
/// 根本不存在的委派,24 轮里 58 次 `agent.run_status`、0 次 `agent.delegate`,一直烧
/// 到超时。空转闸门也拦不住——只读调用同样会把 `plan_update_idle_rounds` 清零。
///
/// 本地原型没有这个问题,因为它的 Supervisor 只有四个工具且每一个都推进链路:
/// 「调了工具」和「推进了链路」在那边是同一件事。这里把同一性质移植过来——每个
/// 阶段只广告该阶段能真正推进链路的工具。
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum PlanRootSupervisorStage {
/// Goal Contract 尚未冻结:本轮唯一能推进的动作是冻结它。
GoalContract,
/// 合同已冻结但本根 run 还没有任何委派:唯一能推进的动作是派出策划子 Agent。
Delegate,
/// 已有委派:取证、返工与审批相关工具全部开放。
Delegated,
}
pub(crate) fn agent_runtime_plan_root_supervisor_tools_for_stage(
stage: PlanRootSupervisorStage,
) -> &'static [&'static str] {
match stage {
PlanRootSupervisorStage::GoalContract => &["agent.goal_contract"],
PlanRootSupervisorStage::Delegate => &["agent.delegate"],
// 合同已冻结且不可重写,再广告 agent.goal_contract 只会诱导一次必被拒的调用。
PlanRootSupervisorStage::Delegated => &[
"file.read",
"agent.delegate",
"agent.acceptance_update",
"agent.action_history",
"agent.run_status",
],
}
}
#[cfg(test)]
mod plan_root_stage_tests {
use super::*;
/// 各阶段并集必须正好等于 allowlistprompt 头部按 allowlist 列工具,若某个工具
/// 只出现在某一阶段而不在 allowlist 里,头部就会漏掉它;反之则是广告了一个永远
/// 拿不到的工具。两侧都是「合同说有、请求里没有」的自相矛盾。
#[test]
fn every_stage_tool_is_part_of_the_plan_root_allowlist_and_the_union_covers_it() {
let allowlist = agent_runtime_plan_root_supervisor_tools()
.iter()
.copied()
.collect::<BTreeSet<_>>();
let union = [
PlanRootSupervisorStage::GoalContract,
PlanRootSupervisorStage::Delegate,
PlanRootSupervisorStage::Delegated,
]
.into_iter()
.flat_map(|stage| {
agent_runtime_plan_root_supervisor_tools_for_stage(stage)
.iter()
.copied()
})
.collect::<BTreeSet<_>>();
assert_eq!(union, allowlist);
}
/// 活锁的诱因是「工具在它无用的阶段仍然可见」。这两个阶段各自只能有一个动作。
#[test]
fn the_pre_delegation_stages_expose_exactly_one_advancing_action() {
assert_eq!(
agent_runtime_plan_root_supervisor_tools_for_stage(
PlanRootSupervisorStage::GoalContract
),
&["agent.goal_contract"]
);
assert_eq!(
agent_runtime_plan_root_supervisor_tools_for_stage(PlanRootSupervisorStage::Delegate),
&["agent.delegate"]
);
}
}
/// 只按 durable 事实判定阶段,不看 Provider 说了什么。
pub(crate) fn plan_root_supervisor_stage_at(
root: &Path,
agent_id: &str,
run_id: &str,
) -> Result<PlanRootSupervisorStage, String> {
if read_game_creator_agent_runtime_goal_contract_at(root, agent_id, run_id)?.is_none() {
return Ok(PlanRootSupervisorStage::GoalContract);
}
let delegated = list_static_delegate_deliveries_at(root)?
.into_iter()
.any(|delivery| delivery.parent_agent_id == agent_id && delivery.parent_run_id == run_id);
Ok(if delegated {
PlanRootSupervisorStage::Delegated
} else {
PlanRootSupervisorStage::Delegate
})
}
pub(crate) fn agent_runtime_native_executable_tools() -> Vec<&'static str> {
agent_runtime_executable_tools()
.into_iter()
@@ -207,6 +207,30 @@ fn finish_plan_submit_business_rejection_limit_at(
/// Runtime state 上,进程重启不能把一次活锁洗成新的无限 Provider 开销。
const AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT: u32 = 4;
/// 纯只读工具不算「推进」。
///
/// 空转计数只在**裸 `update_agent_plan` 且步骤没有真实变化**时累加,早期实现却让
/// 任意非空 actions 都把它清零。于是「裸计划更新被 blocked → 调一次只读工具 →
/// 计数归零」构成一个闸门永远关不上的活锁:实测一次生产 run 24 轮里 58 次
/// `agent.run_status`、0 次 `agent.delegate`,一路烧到超时。只读调用证明不了任何
/// 推进,不该清零。
///
/// 纯只读调查不受影响:只要不发裸计划更新,计数根本不会累加,这里也就无事发生。
fn agent_runtime_action_can_advance_progress(tool: &str) -> bool {
!matches!(
tool,
"agent.run_status"
| "agent.action_history"
| "file.read"
| "file.list"
| "project.index"
| "project.search"
| "project.diff"
| "git.inspect"
| "command.output_read"
)
}
fn plan_update_idle_limit_reached(idle_rounds: u32) -> bool {
idle_rounds >= AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT
}
@@ -215,6 +239,53 @@ fn plan_update_idle_limit_reached(idle_rounds: u32) -> bool {
mod plan_update_idle_guard_tests {
use super::*;
/// 只读工具不得把空转计数清零。这是 run 18 活锁的机制根因:Supervisor 冻结
/// 合同后没有委派,改成「裸计划更新被 blocked → 调一次 agent.run_status →
/// 计数归零」,24 轮里 58 次 run_status、0 次 delegate,闸门一次没响。
#[test]
fn read_only_tools_do_not_count_as_progress() {
for tool in [
"agent.run_status",
"agent.action_history",
"file.read",
"file.list",
"project.index",
"project.search",
"project.diff",
"git.inspect",
"command.output_read",
] {
assert!(
!agent_runtime_action_can_advance_progress(tool),
"{tool} 是只读工具,不该被当成推进"
);
}
}
/// 真正推进 durable 状态的动作照旧清零,否则合法的长链路会被误杀。
#[test]
fn state_advancing_tools_still_count_as_progress() {
for tool in [
"agent.delegate",
"agent.goal_contract",
"agent.acceptance_update",
"file.write",
"project.patchset",
"command.exec",
PLAN_SUBMIT_GDD_TOOL,
] {
assert!(
agent_runtime_action_can_advance_progress(tool),
"{tool} 会推进 durable 状态,必须清零空转计数"
);
}
}
}
#[cfg(test)]
mod plan_update_idle_guard_threshold_tests {
use super::*;
#[test]
fn idle_guard_always_tries_self_repair_before_killing_the_run() {
// 摘工具的阈值必须严格小于收束限额,否则 run 会在从没被逼过一次真动作
@@ -2189,8 +2260,12 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
action_start_index = 0;
}
if !plan.actions.is_empty() {
// 本轮有真实动作,计划没有空转。
if plan
.actions
.iter()
.any(|action| agent_runtime_action_can_advance_progress(action.tool.trim()))
{
// 本轮至少有一个能推进 durable 状态的动作,计划没有空转。
runtime.plan_update_idle_rounds = 0;
}
if plan.actions.is_empty() {
@@ -11,7 +11,8 @@ use sha2::{Digest, Sha256};
use crate::agent::{
agent_runtime_native_executable_tools, agent_runtime_plan_root_supervisor_tools,
AgentRuntimePlanUpdate, AgentRuntimeToolAction, AgentRuntimeToolPlan,
agent_runtime_plan_root_supervisor_tools_for_stage, AgentRuntimePlanUpdate,
AgentRuntimeToolAction, AgentRuntimeToolPlan, PlanRootSupervisorStage,
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,
@@ -375,12 +376,13 @@ pub(crate) fn build_agent_runtime_native_function_tools_for_agent(
/// all; that is a configuration error, so fail closed instead.
pub(crate) fn retain_plan_root_supervisor_native_tools(
functions: &mut Vec<LlmFunctionTool>,
stage: PlanRootSupervisorStage,
) -> 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() {
for tool in agent_runtime_plan_root_supervisor_tools_for_stage(stage) {
allowed.insert(
native_runtime_function_name(tool)
.ok_or_else(|| format!("无法生成 plan 根 Supervisor 工具函数名:{tool}"))?,
@@ -2148,29 +2150,55 @@ mod tests {
)
.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"));
// 逐个阶段都必须是精确集合:委派前那两档各自只剩一个推进动作,正是活锁
// 的解药——模型在那一档连 agent.run_status 都调不出来。
for stage in [
PlanRootSupervisorStage::GoalContract,
PlanRootSupervisorStage::Delegate,
PlanRootSupervisorStage::Delegated,
] {
let mut staged = functions.clone();
retain_plan_root_supervisor_native_tools(&mut staged, stage)
.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_for_stage(stage) {
expected.push(native_runtime_function_name(tool).expect("plan root function name"));
}
expected.sort();
let mut actual = staged
.iter()
.map(|function| function.name.clone())
.collect::<Vec<_>>();
actual.sort();
assert_eq!(actual, expected, "阶段 {stage:?} 的工具目录不是精确集合");
assert!(
before > staged.len(),
"收窄必须真的裁掉工具,否则这条测试是空跑"
);
// MCP 前缀的动态工具同样不得残留:plan 根整条链路不调 MCP。
assert!(!staged
.iter()
.any(|function| function.name.starts_with(AGENT_RUNTIME_NATIVE_MCP_PREFIX)));
}
// 广告层不得再出现 user.input_request:澄清卡由 Runtime 在 parent-wake 屏障处
// 直接按信封原文构造,Supervisor 没有调用它的时机。
let user_input_function =
native_runtime_function_name("user.input_request").expect("user input function name");
for stage in [
PlanRootSupervisorStage::GoalContract,
PlanRootSupervisorStage::Delegate,
PlanRootSupervisorStage::Delegated,
] {
let mut staged = functions.clone();
retain_plan_root_supervisor_native_tools(&mut staged, stage)
.expect("retain plan root tools");
assert!(!staged
.iter()
.any(|function| function.name == user_input_function));
}
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]