---
url: /wave-agent/specs/core/exec-tool.md
description: 把延迟工具池（内置 + MCP）收敛为一个可编排的沙箱脚本工具
---

# 功能规格说明：Exec 工具

**规格文件**：`docs/specs/core/exec-tool.md`
**创建日期**：2026-09-14

> **设计取舍（定案）**
>
> * **目标 = 延迟工具池收敛 + 当轮编排**。可编目工具变多以后，逐条扁平声明的代价是双份的：上下文里塞满当下用不到的工具定义，而且一轮只能调一个工具、步骤之间要来回若干轮。Exec 把整池折进一个工具，并让模型在一个脚本里把多步调用串起来。
> * **准入 = "延迟判定表"，不是"工具来源"**。池的成员由宿主侧的延迟判定表唯一决定，与工具是内置还是来自 MCP 无关：① `alwaysLoad` 声明的逃生口 → 不延迟；② 引擎侧豁免名单命中 → 不延迟；③ 可远端/配置下发的**非延迟内置名单**命中 → 不延迟；④ MCP 工具 → **无条件延迟**（不需要它自己声明）；⑤ 否则按工具自己声明的延迟意图判定。内置工具用 `ToolPlugin` 上的字段申报，MCP 工具用 `_meta` 申报同一批语义。前三条是"不可延迟"的三种覆盖口，④⑤ 是默认走向。
> * **池 = 延迟集合本身，而不是"所有工具"**。非延迟工具不进池、不进沙箱、照常扁平声明；沙箱可达集合与公告所列集合是同一个集合。这条同时守住了安全不变量："沙箱能调 ≠ 代理本来看不到"。
> * **公告 = 尾部追加的纯名字消息**。公告是池的函数，池一变（服务器连接/断开、`tools/list` 变化、开关翻转）整段文本就变；把它渲染进 `Exec` 的 `description` 等于一次连接就改写缓存前缀里的 `tools[]`，把整个前缀打掉。因此 `Exec` 的描述是与池无关的**常量**，公告改在池变化时以 `isMeta` 消息追加到消息尾部——**只追加、不改写已有历史**（与 MCP 服务器使用说明同一通道形态，见 `docs/specs/core/prompt-cache-control.md`）。
> * **公告只给名字**。公告不含签名、描述、数量、预算或任何调优参数，每个条目就是一个工具名——模型据此知道"这些能力存在"，参数形状一律靠搜索取。公告既没有预算也没有截断，因此不存在"部分显示"的状态，也不需要为"每台服务器至少被点到名"做任何取舍。
> * **公告无增量形态**。池变化时重发一份完整名字列表；判据只有"与上一次播报的正文是否逐字节相同"。名字列表本身很短，做增量（无论是命名空间计数级还是名字级）都省不下东西，却要多背一份跨轮状态。
> * **搜索是唯一的 schema 通道**。延迟工具的调用形式、参数签名与返回类型只能从 `ToolSearch` 拿到，因此搜索必须能**按名字精确命中**（`select:` 形式）、必须**有排序**（同名 > 整词 > 子串，MCP 名加权）且**有结果上限**；命中工具需要有一条不依赖描述的策展短语（`searchHint`）参与排序。搜索入口在文案里**始终宣传**（它不再是截断态的兜底，而是常规入口）。
> * **沙箱内搜索**：搜索入口是沙箱 `tools` 对象上的固定名字 `ToolSearch`（`tools.ToolSearch({ query: "..." })`，与 Claude Code 的 `ToolSearch` 工具同名），它不是池内工具、也不进 `tools[]`（不新增顶层搜索工具）。这个名字不像原 `$` 前缀那样天然不可撞（任何工具都可能叫 `ToolSearch`），因此**池内工具名不得等于它**——池构造处断言拒绝（沙箱把搜索入口赋在同一个键上，同名成员会被静默遮蔽）。调用形式不手写在文案里，由搜索入口自己的 schema 渲染（见「在沙箱内搜索」故事）。
> * **隔离机制 = `worker_threads` + `node:vm`**。`node:vm` 提供 realm 隔离与 `codeGeneration: false`；但单线程 `vm` 的 `{ timeout }` 只对同步代码生效（`await` 之后再死循环就管不住），所以脚本跑在可 `terminate()` 的 worker 里；执行预算只计**脚本自己**的时长——嵌套调用在飞期间暂停计时，且**不设等待上限**（等审批、等慢服务器属于用户与宿主的时间；永不返回的调用靠用户中断收场，与扁平调用一致）。
> * **嵌套调用 resolve 成工具输出**。脚本 `await` 一次调用拿到的就是这个工具的输出：服务器返回了 `structuredContent` 就用它，否则是它的文本，两者都没有则是 `null`（对齐 opencode）。返回值里没有信封（不是 `{ content, images }`）；图片仍由 `Exec` 结果附件的形式交给模型，脚本侧不另给一份计数。签名末尾写 `: Promise<T>`，`T` 由 `tools/list` 声明的 `outputSchema` 渲染（未声明则 `Promise<unknown>`）——签名与运行时是同一件事的两面：**写进签名的返回类型必须就是脚本真正拿到的东西**，否则就在教一种宿主不会兑现的写法。
> * **默认开启、可显式关闭回退**。机制落地时因"改写 `tools[]` 构成、形态变化大"先取默认关闭灰度，在显式开启的会话里实测确认收益后定案默认开启：未配置 `enableExec` 的会话即启用。开关保留下来作为回退入口与灰度入口——`enableExec: false`（或服务端下发）把会话退回逐条扁平声明形态，能力不变。

## 用户场景与测试 *(必填)*

### 用户故事：在沙箱里编排一轮内的多步调用（优先级：P1）

作为使用多个工具（内置或 MCP）的开发者，我希望模型用一次工具调用就能串起多步操作（取数据 → 转换 → 写回），而不是每一步都单独往返一轮。

**为什么是这个优先级**：这是 Exec 存在的首要理由。池收敛省的是上下文，编排省的是轮次，后者才是"加载更多工具之后还能用得动"的关键。

**独立测试**：给一个脚本同时调用两个池内工具并把第一步的结果作为第二步的实参，断言两次调用都真实发生且最终返回值是第二步的结果；再分别用"只回 `structuredContent`"/"只回文本"/"两者都没有"的服务器断言脚本收到的是对象/字符串/`null`。

**验收场景**：

1. **假设** 沙箱脚本写了 `const a = await tools.mcp__srv__fetch({ id: 1 }); return await tools.mcp__srv__store({ value: a });`，**当** `Exec` 执行时，**则** 两次调用都必须真实发生，且脚本能拿到上一次的返回值继续使用。
2. **假设** 脚本 await 某个工具调用，**当** 该调用返回时，**则** 脚本收到的值就是该工具的输出：返回了 `structuredContent` 时就是它（已解析的对象，脚本不必再 `JSON.parse` 文本），否则是它的文本，两者都没有则是 `null`；**不得再包一层信封**（没有 `content`/`images` 字段）。
3. **假设** 脚本传给工具的实参对象，**当** 调用转发到该工具时，**则** 实参必须逐字段原样透传（不重命名、不补默认值、不做 schema 校验）。
4. **假设** 脚本里嵌套调用的工具名不在当前池中，**当** 该调用发生时，**则** 该 await 必须 reject，错误信息说明"只有本会话的可编目工具可达"并指向 `tools.ToolSearch(...)`。
5. **假设** 脚本用 `try/catch` 包住一个会被拒绝的嵌套调用，**当** 该调用被拒绝时，**则** 脚本必须能捕获错误并继续执行后续步骤（一次失败不终止整个脚本）。
6. **假设** 脚本需要中间值，**当** 脚本调用 `console.log(...)` 时，**则** 输出必须被收集并与最终结果一并返回给模型。
7. **假设** 脚本用 `return` 返回一个对象，**当** `Exec` 执行完成时，**则** 返回值必须序列化后交给模型；脚本没有返回值也没有 console 输出时，结果文本必须明确说明这一点（而不是给一个空字符串）。

### 用户故事：把延迟工具池收敛为单个 Exec 工具（优先级：P1）

作为长期使用大量工具的用户，我希望会话的工具列表不要被延迟工具铺满，而由 `Exec` 的公告统一呈现，同时**不因此获得任何原本拿不到的权限，也不因此失去任何原本拿得到的能力**。

**为什么是这个优先级**：收敛是省上下文的唯一手段；而"池 = 延迟判定表的输出、沙箱可达集合 = 池"是安全不变量，不满足就不是省上下文而是提权；反过来漏掉工具则是能力损失。

**独立测试**：构造一个未配置 `enableExec`（默认开启）且延迟判定表产出非空池的情形，断言 `tools[]` 中 `Exec` 存在、且池内工具没有任何扁平声明；再构造只有一个延迟工具的情形与池为空的情形分别断言；最后配置 `enableExec: false` 断言池内工具全部回到扁平声明。

**验收场景**：

1. **假设** 当前延迟判定表产出非空池且 `Exec` 已注册未被禁用，**当** 代理组装工具声明时，**则** 必须声明 `Exec`，并且**不声明**池内任何一个工具的扁平定义（不设数量门槛：池非空即收敛）。
2. **假设** `enableExec` 开启且没有任何 MCP 工具可用，**当** 组装工具声明时，**则** 只要延迟判定表产出非空池，仍必须声明 `Exec`——**池的成员不再取决于 MCP 是否连接**，开启后（内置工具有若干条被判定为延迟，池恒非空）`Exec` 恒被声明。
3. **假设** 延迟判定表产出空池（例如非延迟名单把全部工具都豁免了），**当** 组装工具声明时，**则** 不声明 `Exec`——没有可编目的内容，声明它只会让模型看到一个空公告。
4. **假设** 某个内置工具的延迟意图被 `alwaysLoad` 逃生口或非延迟名单豁免，**当** 组装声明时，**则** 该工具必须照常扁平声明、不进池、不在公告里、也不可从沙箱调用。
5. **假设** 某个 MCP 工具，**当** 组装声明时，**则** 它必须进池（MCP 无条件延迟），除非它自己用 `alwaysLoad` 声明了逃生口。
6. **假设** `Exec` 被权限规则显式禁用（`disallowedTools` / 拒绝规则），**当** 组装工具声明时，**则** 既不得声明 `Exec`，**也不得**因为 Exec 缺席就把池内工具丢掉——它们必须照常扁平声明、照常可调用。
7. **假设** `Exec` 未注册（`enableExec` 关闭），**当** 组装工具声明时，**则** 池内工具（含被判定为延迟的内置工具）必须照常扁平声明——关闭 Exec 不得连带关闭任何能力。
8. **假设** MCP 服务器在会话中途连接或断开，**当** 每次组装工具声明时，**则** `Exec` 的**声明与否与描述都必须逐字节不变**（池只影响尾部公告，不影响 `tools[]`）；延迟判定表对内置工具的产出也必须与 MCP 的连接状态无关。
9. **假设** 某个池内工具被权限规则拒绝，**当** 构建池时，**则** 该工具不得出现在池中，也不得出现在公告里（池与扁平声明走同一条拒绝过滤，不得出现"声明里没有但公告里有"）。
10. **假设** `Exec` 已声明，**当** 模型调用 `Exec` 执行脚本时，**则** 沙箱可调用的工具集合必须按执行当次的池重新计算——一个刚刚新连上的服务器可以调用，一个刚刚断开的服务器则不可（两边都由同一个池构造函数导出，因此不可能出现"沙箱里能调、代理本来调不到"）。
11. **假设** 池内工具已从扁平声明中撤下，**当** 模型调用它们时，**则** 它们仍然必须为权限系统与执行路径所知（撤下的是"声明"，不是"注册"），以便嵌套调用照常经过同一套权限与执行漏斗。**同理，宿主侧一切「某工具是否可用 / 是否被调用过」的判据都必须以注册与可达集合为准，不得以 `tools[]` 为准**——把撤下读成"工具没了"，会让依赖它的机制在池内工具上静默失效（任务提醒的计数器与可用性守卫正是这样失效的）。
12. **假设** 服务端或配置下发了非延迟内置名单，**当** 组装声明时，**则** 名单命中的工具必须从池中移出并回到扁平声明；且该名单必须能在不发布新版本的情况下生效（远端覆盖口）。
13. **假设** 系统提示词里有一段按工具名渲染的引导（例如 `# Using your tools` 里指向 `TaskCreate` 的那句），**当** 组装系统提示词时，**则** 该段落必须按**可达集合**（扁平声明 ∪ 沙箱池）判定：池内工具照常拿到属于它的引导行，不得因为工具进了池、从 `tools[]` 撤下声明就静默少一句——撤下的是"声明"，不是"这个会话调用它的能力"（与场景 11 同一条判据的另一个消费方）。

### 用户故事：脚本在沙箱里跑，拿不到宿主能力（优先级：P1）

作为运维该功能的人，我希望模型的脚本无论写什么都不能读写宿主文件、发网络请求、加载模块或动态编译代码，也不能把宿主对象偷渡回沙箱。

**为什么是这个优先级**：Exec 执行的是模型生成、可能来自不可信上下文的代码。隔离不成立，其余一切免谈。

**独立测试**：在脚本里依次尝试 `require`、`import(...)`、`eval`、`new Function`、`process`、`globalThis.fetch`，断言全部不可用；再断言拿到手的工具函数/返回值的 constructor 属于沙箱 realm，且经它编译代码会抛错。

**验收场景**：

1. **假设** 脚本引用 `require`、`process`、`Buffer`、`module`、`globalThis.fetch`、`XMLHttpRequest`，**当** 脚本执行时，**则** 这些必须是 `undefined`（沙箱只注入 `tools` 与 `console`）。
2. **假设** 脚本调用 `eval(...)` 或 `new Function("...")`，**当** 脚本执行时，**则** 必须抛出 `EvalError`（`codeGeneration: { strings: false, wasm: false }`），不得执行被编译的代码。
3. **假设** 脚本写 `import("node:fs")`，**当** 脚本执行时，**则** 必须失败，且交给模型的错误信息必须是人话（"import() 不可用"），不是引擎内部的 callback 报错。
4. **假设** 脚本试图通过 `tools.someTool.constructor("return process")()` 之类的路径回到能编译代码的函数，**当** 脚本执行时，**则** 必须拿不到宿主能力——工具包装函数上的 `.constructor` 拿到的是沙箱 `Function`（抛 `EvalError`）；跨回来的普通对象原型为 `null`，`.constructor` 直接是 `undefined`（抛 `TypeError`）。两条路径都不允许出现"能编译字符串的函数"：沙箱里所有函数对象（含每个工具包装函数与 `console` 方法）都必须属于沙箱 realm，不得把宿主 realm 的函数直接交出去。
5. **假设** 脚本收到工具调用返回的对象，**当** 脚本修改该对象时，**则** 宿主侧的数据不得被影响（跨界值必须深拷贝，且跳过 `__proto__` 与函数字段）。
6. **假设** 沙箱向宿主发起调用，**当** 消息跨线程传递时，**则** 只允许结构化克隆能承载的普通数据（参数必须是普通对象；带函数的对象、类实例或原型污染载荷必须被拒绝，错误返回给脚本）。

### 用户故事：执行预算只计脚本自己的时间，且能真正终止（优先级：P1）

作为运行长任务的人，我希望脚本无论以哪种方式卡住（同步死循环、await 之后再死循环、永不 resolve 的 await）都能被终止，并且工具调用次数与日志量不会失控；反过来，脚本在等一次嵌套调用（审批弹窗、慢服务器、慢 hook）时不该被计时——否则一个等审批的脚本会被自己的预算杀死，而它的审批结果落在一次已经死掉的运行上。

**为什么是这个优先级**：不设预算就是让模型的一个脚本挂死整个会话；而"能终止"是机制层面的要求，不是最佳努力。等待段不受执行预算约束，这与扁平调用一致——不经 `Exec` 直接调同一个工具、等同一个审批，同样没有等待上限；一个永不返回的调用由用户中断收场，`Exec` 不为它另造一条预算。

**独立测试**：分别跑三个卡死脚本（同步自旋 / 先 await 再自旋 / await 一个永不 settle 的 Promise），断言三者在预算内都返回失败结果而不是挂住进程；再跑一个嵌套调用耗时远超执行预算的脚本，断言它拿到结果而不是被终止；最后在显式传入很小的 `maxNestedWaitMs` 时跑一个永不返回的嵌套调用，断言这个可选上限仍然生效。

**验收场景**：

1. **假设** 脚本是同步死循环，**当** 超过时间预算时，**则** 必须终止沙箱并返回失败结果，错误说明"超出预算已被终止"且提示"已完成的调用照常生效"。
2. **假设** 脚本先 `await` 一次工具调用、随后进入死循环，**当** 超过时间预算时，**则** 同样必须终止（这正是单线程 `vm` 的 `{ timeout }` 管不住、必须靠 worker `terminate()` 的情形）。
3. **假设** 脚本 await 一个永不 settle 的 Promise，**当** 超过时间预算时，**则** 同样必须终止。
4. **假设** 一次嵌套调用耗时远超执行预算（人工审批几十分钟后才被处理，或 MCP 服务器响应很慢），**当** 该调用返回时，**则** 执行预算不得因此耗尽，等待也不设累计上限：脚本拿到该结果并继续执行——这段时间是用户与宿主的时间，不是脚本花掉的。永不返回的调用由用户中断收场（见场景 10），与扁平调用一致。
5. **假设** 同一脚本里并发发起多次嵌套调用（`Promise.all`），**当** 计时暂停时，**则** 它们共享同一个等待窗口（暂停一次，不是每次调用各暂停一次）。
6. **假设** 一次脚本内的工具调用次数超过上限，**当** 越界的那次调用发生时，**则** 必须只拒绝这一次调用（脚本可在 `try/catch` 里继续），而不是杀掉整个脚本。
7. **假设** 脚本大量 `console.log`，**当** 日志被收集时，**则** 返回给模型的日志必须被字符上限截断，不得用日志撑爆上下文。
8. **假设** 脚本 `return` 的值超过字符上限，**当** 结果交给模型时，**则** 必须截断并注明总字符数——扁平调用自身有结果大小上限（超出落盘 + 预览），嵌套调用若不加限就比它替代的那次调用更贵。
9. **假设** 一次脚本里发生多次嵌套调用并各自带回图片，**当** 结果返回时，**则** 图片数量必须有上限，超出部分丢弃（不得无限累积）。
10. **假设** 运行中会话被用户中断/session 关闭，**当** abort 信号触发（或进入时已 aborted），**则** `Exec` 必须尽快返回失败结果并终止沙箱，不得继续执行。
11. **假设** 沙箱因任何原因崩溃或异常退出，**当** 宿主收到通知时，**则** 必须把它转成一条失败工具结果交给模型（`Exec` 的 Promise 不得 reject，模型总要拿到可据以行动的结果）。

### 用户故事：公告只列延迟工具的名字（优先级：P1）

作为模型，我希望在池变化时收到一条消息，列出当前可以从沙箱调用的工具名，让我知道这些能力存在、以及什么时候该去搜它们的参数形状。

**为什么是这个优先级**：公告是池收敛之后模型唯一的能力地图。**漏掉一个工具等于宣称这个能力不存在**，是本功能最坏的失败模式；而多写内容则是把这个地点的常驻成本抬高。

**独立测试**：渲染一个含多个内置延迟工具与多个 MCP 服务器的池，断言公告只含名字、逐字节稳定、不含任何签名/描述/计数/预算；再断言非延迟工具与 `Exec` 自己都不出现。

**验收场景**：

1. **假设** 公告需要播报（池非空），**当** 渲染公告时，**则** 必须列出池中**每一个**工具的**名称**，且每个条目**只有名字**——不得含参数签名、不得含工具描述、不得含服务器名或分组标题。
2. **假设** 池里有被判定为延迟的内置工具与 MCP 工具，**当** 渲染公告时，**则** 两者必须出现在同一份列表里，形态一致（不区分来源、不分组、不加前缀标记）。
3. **假设** 池里有非延迟工具（高频内置、`alwaysLoad` 逃生口命中的工具、非延迟名单命中的工具），**当** 渲染公告时，**则** 它们**不得**出现在公告里——它们已经在 `tools[]` 里带着完整定义，再列一遍只是冗余。
4. **假设** `Exec` 自己也是本会话的一个工具，**当** 渲染公告时，**则** 它不得出现在公告里（它是公告的载体，不是公告的内容）。
5. **假设** 池有多条工具，**当** 渲染公告时，**则** 输出中不得出现任何数量、预算、字符数、时间戳、连接状态或调用计数（这些要么是调优参数，要么是动态信息，写进模型可见文本就会让同一份池渲染出不同的字节）。
6. **假设** 池未变化，**当** 两次渲染公告时，**则** 文本必须逐字节相同；**且同一次请求里 `Exec` 出现在 `tools[]` 中的描述必须与池无关**——池怎么变，它一个字节都不许变。
7. **假设** 池很大（例如接入了几十个 MCP 服务器），**当** 渲染公告时，**则** 仍必须完整列出全部名字（不截断、不轮转、不省略），不得出现 `PARTIAL` 之类的"只显示了一部分"状态——名字列表是线性增长的，而截断会把"没列出来"读成"不存在"。
8. **假设** 模型看到公告里有一个名字，**当** 它需要该工具的参数形状时，**则** 它必须能从 `Exec` 的描述里得知要去 `ToolSearch` 取——公告本身不承载参数信息，这一点必须在公告之外的文案里说清楚（见「在沙箱内搜索」故事）。

### 用户故事：公告作为尾部消息增量播报（优先级：P1）

作为模型，我希望在池发生变化时收到一条公告消息，而不是每次请求都重新读一遍工具声明——声明变了会让整段缓存前缀失效。

**为什么是这个优先级**：`tools[]` 位于缓存前缀内，池一变就改写它等于每连一台服务器就击穿一次前缀。公告是池的函数，必须与声明解耦；而"只有它变了才播报"决定了这个解耦的代价有多大。本故事也是"`tools[]` 终于真正与池无关"这条收益的兑现处——在此之前 `Exec` 的声明会跟着 MCP 连接状态变化。

**独立测试**：让池从"空"变到"非空"、再变到"另一套服务器"，断言每次变化都追加一条新消息、历史里已有消息逐字节不变、`Exec` 的 description 始终与池无关，且状态未变时不重复播报。

**验收场景**：

1. **假设** 池非空且 `Exec` 已声明，**当** 组装 `tools[]` 时，**则** `Exec` 的 description 必须是**与池无关的常量**：不含任何工具名、不含公告正文，同一份配置下逐字节稳定。
2. **假设** 会话从未播报过公告且池非空，**当** 组装下一次请求时，**则** 必须在消息尾部追加一条 `isMeta` 公告消息，且它必须已经出现在**本次**请求里（不是推迟一轮）。
3. **假设** 会话从未播报过公告且池为空，**当** 组装请求时，**则** 不得为"池为空"专门播报——一句"当前没有可用工具"描述的是模型从未见过的能力，没有指涉物（见场景 8）。
4. **假设** 池的公告内容发生变化，**当** 播报时，**则** 只允许向尾部追加消息，历史里任何已有消息必须逐字节不变（`tools[]` 同理）。
5. **假设** 池内容与上一次播报时相同，**当** 组装请求时，**则** 不得重复播报——同一状态只播报一次，逐轮重算不算变化。
6. **假设** 上下文压缩或回退把之前那条公告消息吃掉了，**当** 下一次组装请求时，**则** 必须重新播报一份完整公告（"重复优于丢失"：播报状态以历史里的标记为唯一真源，不依赖进程内状态）。**压缩这件事不需要额外的"基准是否还在"判定**——标记没了就按"从未播报过"处理，重发完整公告即可。
7. **假设** 池在两次播报之间发生变化，**当** 播报时，**则** 必须重发**完整**的名字列表，不得发任何形式的增量（不做命名空间计数级的差异，也不做名字级的增删差异）——名字列表本身很短，增量的收益不足以抵偿它带来的跨轮状态。
8. **假设** 已经播报过公告的会话里池变空，**当** 播报时，**则** 必须播报一句"当前没有可用的可编目工具，后续更新可能增删"（这是**变化**而不是第一句话），且文案不得提及 `Exec` 这个工具名——此时它不在 `tools[]` 里，提到它只会指向一个不可调用的名字。
9. **假设** `Exec` 因开关关闭、被 `tools` 排除或被权限规则拒绝而不再声明（池内工具退回扁平声明），**当** 播报时，**则** 必须追加一条"公告不再适用、勿再使用已列出的条目"的消息；通道随后重新打开而池内容未变时，必须重新播报一份完整公告（不得因为"内容没变"而永久失声）。
10. **假设** 宿主或测试替身没有提供这条通道，**当** 组装请求时，**则** 必须静默跳过，**不得**把"读不到通道"当成"池已清空"而误发下线公告（读不到 ≠ 观察到的消失）。
11. **假设** MCP 服务器在会话中途连接或断开，**当** 组装 `tools[]` 时，**则** `Exec` 的声明与否必须不随之变化（`enableExec` 开启时恒被声明），变化的只有尾部那条公告——这使得"池抖动不再改写缓存前缀"真正成立。

### 用户故事：在沙箱内搜索（优先级：P1）

作为模型，当我看到公告里的某个名字、却不知道它的参数形状时，我希望能在**同一个脚本里**先搜索、再调用，而不必为了拿参数额外多花一轮。

**为什么是这个优先级**：公告只给名字，所以搜索是延迟工具的**唯一** schema 通道；搜索不到就等于这个能力不可用。放进沙箱内是刻意的——取形状与使用属于同一次编排，而 wave 的搜索是沙箱内的同步函数，可以在一次 `Exec` 里先 `ToolSearch` 拿到形状再直接调用，不必像"搜索完等下一轮"那样多一次模型往返。

**独立测试**：在一个池里放若干名字相近的工具，断言按精确名搜索时目标排第一；断言结果数受上限约束；断言 `select:` 形式只返回点名的那些；断言空查询返回完整池。

**验收场景**：

1. **假设** 脚本调用 `tools.ToolSearch({ query: "..." })`，**当** 查询执行时，**则** 必须在宿主侧对**完整池**做本地匹配并返回结果；不需要任何额外模型往返。
2. **假设** 查询命中某个工具，**当** 结果返回时，**则** 结果条目包含可据以调用的完整信息（名称、描述、与签名同一形态的参数与返回类型——同一个渲染器产出，字段文档为完整原文，脚本可逐字照抄）。**脚本拿到的值是一个对象**（`{ matches: [{ name, description, signature }, ...], total }`），不是它的 JSON 文本——搜索与其它调用同一口径（见「在沙箱里编排」场景 2），因此脚本不必、也不该再 `JSON.parse` 一次。返回值必须是对象而不是裸数组：命中数受上限约束，而**被截掉多少**只有 `total` 说得清（见场景 8）。
3. **假设** 模型已经看到公告里的某个名字，**当** 它用**该名字本身**（或其全等形式）作为查询时，**则** 这个工具必须排在结果首位——按名精确命中是这条通道的主路径，公告给名字、搜索还名字，两者必须闭环。
4. **假设** 模型要一次取回多个已知名字的形状，**当** 它使用 `select:` 形式（形如 `select:Read,EnterWorktree,mcp__srv__tool`）时，**则** 必须只返回被点名的工具，且**不受结果上限约束**——点名查询不是模糊搜索，截断它等于让模型无法表达"我就要这几个"。
5. **假设** 查询是关键词，**当** 排序时，**则** 必须按匹配强度分级：名字全等 > 名字整词命中 > 名字子串命中 > 描述命中；同级内 MCP 工具加权更高（名字里带服务器与工具两段信息，模型更难猜）；排序必须是确定性函数，同一份池与同一查询必须给出同一顺序。
6. **假设** 查询是关键词，**当** 结果返回时，**则** 结果条数必须有上限（对齐 Claude Code 的 `max_results = 5`），且上限必须可由入参调整但在宿主侧有硬上界——一次搜索不能把整个池倒进上下文。
7. **假设** 某个工具带有一条策展短语（`searchHint`，一句话说明它能做什么），**当** 它参与关键词匹配时，**则** 该短语必须计入匹配与打分（权重高于普通描述匹配），使模型用日常说法（而不是工具名的字面词）也能检索到；该短语本身**不得**出现在公告里（见「公告只列延迟工具的名字」场景 1）。
8. **假设** 查询串为空，**当** 搜索执行时，**则** 返回完整池；**假设** 命中数超过上限，**当** 结果返回时，**则** `total` 必须是**截断前**的命中总数（`matches.length` 可以小于 `total`），使模型知道还需要收窄查询或改用 `select:`——"命中 5 条"与"命中 200 条、只列 5 条"对模型是两件事。
9. **假设** 搜索命中一个工具，**当** 脚本随后调用它时，**则** 调用必须成功（搜索范围与可调用范围同为完整池，二者一致）。
10. **假设** 搜索本身也算一次嵌套工具调用，**当** 脚本反复搜索时，**则** 它同样计入工具调用上限（不得成为绕过预算的通道）。
11. **假设** 沙箱里写了一个既不在池内、也不是搜索入口 `ToolSearch`、又不带 `mcp__` 前缀的名字（例如旧的顶层写法 `tools.search(...)`），**当** 调用发生时，**则** 该名字根本没走到宿主，失败就是脚本自身的 JS 错误（`tools.search is not a function`），宿主不参与这次判定；**假设** 名字带 `mcp__` 前缀却不在池内，**当** 调用发生时，**则** 该调用会到达宿主并得到点明"只有本会话的可编目工具可达、可用搜索入口找"的错误。**只有 `mcp__` 前缀被放行是刻意的、不是遗漏**：MCP 名含服务器与工具两段、是模型最容易猜错的一类，而内置名认错一眼自明（脚本立刻看到 `is not a function`）；反之若把兜底扩到任意未知名，`then` 这类普通属性读取也会变成"可达"，整个 `tools` 对象就会被当成 thenable。搜索入口是 `tools` 上一个固定名字 `ToolSearch`，池内工具不得与之同名——见「在沙箱内搜索」的设计取舍。
12. **假设** 模型按 `Exec` 描述里给出的形式调用 search，**当** 调用发生时，**则** 必须成功——文案里的调用形式与宿主接受的参数形状必须由**同一份 schema** 导出（签名由同一个渲染器渲染、入参按同一份 schema 校验，返回类型同样由该渲染器渲染，写明 `{ matches, total }` 的形状），不得文案与实现各写一份；"文案教位置参数、实现只收对象"这类漂移正是这条要根除的缺陷。
13. **假设** 脚本用一个普通对象调用 search 但字段不匹配（例如 `{ q: "..." }`），**当** 调用发生时，**则** 必须返回点明期望形状的错误（形状串同样由那份 schema 渲染），不得静默按"空查询"处理——静默空查询会把"参数写错了"伪装成"搜索到了全部工具"，比报错更难排查。
14. **假设** 池里同时有内置延迟工具与 `mcp__` 前缀的工具，**当** 搜索与渲染签名时，**则** 两者都必须按同一个渲染器处理：合法的标识符名用 `tools.<name>(...)`，非标识符名（含 `-`、`.` 等）必须用 `tools["<name>"](...)` 括号形式（否则会被解析成减法或属性链）；搜索的匹配与排序**不得**依赖 `mcp__` 前缀的存在。
15. **假设** 模型从未调用过 `ToolSearch`，**当** 它读 `Exec` 的描述时，**则** 必须能直接从中读到 `ToolSearch` **自身**的参数名与返回类型（由同一份 schema 渲染）。池内每个工具的形状都能**经搜索**取得，唯独搜索入口自己不能（它不在池中）——描述是它唯一的通道，因此**不得**只写一个 `tools.ToolSearch(...)` 让模型去猜参数名、再靠报错纠正；精简档可以只给由同一渲染器导出的一行调用形式。

### 用户故事：嵌套调用复用同一套权限（优先级：P2）

作为用户，我希望 Exec 里的每一次嵌套调用都经过与直接调用完全相同的权限确认，而不是被 Exec 包一层就绕过审批。

**为什么是这个优先级**：不是最核心的价值，但一旦缺失就是安全漏洞，因此必须有明确契约与验收。

**独立测试**：让一次嵌套调用命中拒绝规则，断言调用被拒绝且拒绝理由回到脚本；再让它命中"总是允许"规则，断言不再询问。

**验收场景**：

1. **假设** 嵌套调用的某个工具没有匹配的权限规则，**当** 该调用发生时，**则** 必须与扁平调用一样弹出确认；用户选择"是，不再询问"后必须以叶子工具的全名（`mcp__server__tool` 或内置工具名）保存持久规则。
2. **假设** 已存在针对某个叶子工具全名的允许规则，**当** Exec 内嵌套调用该工具时，**则** 必须直接执行、不再询问（规则按叶子名与叶子参数匹配，不按 `Exec` 这个外层工具名匹配）。
3. **假设** 已存在针对某个叶子工具的拒绝规则，**当** Exec 内嵌套调用它时，**则** 必须被拒绝，且拒绝理由作为该次调用的错误回到脚本。
4. **假设** 用户拒绝了一次嵌套调用的确认，**当** 脚本没有捕获该错误时，**则** 整个 `Exec` 返回失败并带上该理由（不得把拒绝静默成空结果）。
5. **假设** `Exec` 自身被权限规则拒绝，**当** 模型调用 `Exec` 时，**则** 拒绝必须发生在工具层，不得进入沙箱执行。

### 用户故事：嵌套调用触发同一套 hook（优先级：P2）

作为用户，我希望 Exec 里的每一次嵌套调用都跑一遍与直接调用相同的 PreToolUse / PostToolUse hook，而不是被 Exec 包一层就跳过审计与拦截。

**为什么是这个优先级**：与"嵌套调用复用同一套权限"同一档——不改形态，但一旦缺失就是审计与安全问题：hook 的常见用途是审计/日志、自动格式化，以及用 PreToolUse 拦下危险调用（它能 block）；这些在池内工具上恰好全部失效。

**独立测试**：配置 matcher 指向池内工具名的 PreToolUse hook（一个延迟内置、一个 `mcp__server__tool` 各一条），让脚本 `await tools.<name>(...)`，断言 hook 被执行且能拦下该次调用（脚本能 catch 到拒绝理由）；再配置 PostToolUse hook，断言它在成功的嵌套调用后被触发。

**验收场景**：

1. **假设** 会话开启了 `enableExec`、hook 的 matcher 指向某个池内工具（延迟内置如 `WebFetch`，或 `mcp__<server>__<tool>`），**当** 脚本通过 `tools.<name>(...)` 调用它时，**则** PreToolUse 与 PostToolUse 都必须触发，且 matcher 按叶子工具全名匹配——与扁平调用完全一致，不按 `Exec` 这个外层工具名匹配。
2. **假设** PreToolUse hook 对该次嵌套调用以 exit 2 退出，**当** 该调用发生时，**则** 该次 `await` 必须 reject，错误文本为 `PreToolUse:<叶子工具名> hook error: <stderr>`；脚本可以 catch 后继续，未捕获时整个 `Exec` 失败并带上该理由（与权限拒绝同口径，不得把拦截静默成空结果）。
3. **假设** PostToolUse hook 对一次成功的嵌套调用以 exit 2 退出，**当** 该调用完成时，**则** 对话里新增一条 `isMeta: true` 的用户角色消息 `PostToolUse:<叶子工具名> hook blocking error from command: "<命令原文>": <stderr>`（与扁平调用同形），脚本照常继续。
4. **假设** 一次嵌套调用被 PreToolUse hook 拦下，**当** 生成结果摘要与内层调用名记录时，**则** 它照常计入调用次数、参与最近两次的列出、并进入工具块的内层调用名记录——与"用户在该次调用的确认弹窗里点了拒绝"同口径（摘要反映"脚本尝试调用过它"）。
5. **假设** 池内工具的 hook 自身抛错或超时，**当** 处理结果时，**则** 该次嵌套调用照常执行、不因 hook 失败而失败（与扁平调用同为 fail-open）。
6. **假设** 模型直接调用 `Exec` 本身，**当** hook 评估时，**则** `Exec` 自己的 PreToolUse / PostToolUse 照常触发（它是扁平声明的工具）；沙箱内的搜索入口（`ToolSearch`）不是工具调用，不触发任何 hook。
7. **假设** 同一个工具在扁平声明下被调用（`enableExec` 关闭），**当** hook 评估时，**则** 模型可见文本与开启 `enableExec` 时经沙箱调用逐字相同——hook 不感知工具是扁平声明还是池内可达。

### 用户故事：启用开关与回退（优先级：P2）

作为管理员或用户，我希望 Exec 默认开启，并可通过配置显式关闭、退回扁平声明形态，**不发生任何能力损失**（既包括 MCP，也包括被判定为延迟的内置工具）。

**为什么是这个优先级**：机制先以"默认关闭、显式开启"灰度，在显式开启的会话里自测确认收益后定案默认开启；于是这条故事只剩"退路"这一半——任何新机制都需要一条"一键回到旧行为"的出口，且必须是可远程下发的。

**独立测试**：分别以未配置（默认开启）、配置 `true`、配置 `false`（外加服务端下发相反值）三种情况断言 `Exec` 的注册与否，以及池内工具是否回到扁平声明。

**验收场景**：

1. **假设** 未配置 `enableExec`，**当** 会话初始化时，**则** 按代码默认值（开启）注册 `Exec`，池内工具从扁平声明中撤下、改由尾部公告呈现。
2. **假设** settings.json 配置 `enableExec: true`，**当** 会话初始化时，**则** 注册 `Exec`，池内工具（含被判定为延迟的内置工具与 MCP 工具）从扁平声明中撤下，改由尾部公告呈现。
3. **假设** settings.json 配置 `enableExec: false`，**当** 会话初始化时，**则** `Exec` 不注册；工具声明退回扁平形态——**MCP 工具与被判定为延迟的内置工具都必须照常扁平声明、照常可调用**。
4. **假设** 服务端下发 `enableExec`，**当** 合并配置时，**则** 远端值优先于本地 settings.json 与代码默认值（管理员远程回滚入口）；未下发时回退本地/默认值。
5. **假设** 运行中会话修改 `enableExec` 触发配置热重载，**当** 重新评估工具注册时，**则** `Exec` 必须按新值即时注册/注销，无需重启会话；注销时池内工具立即回到扁平声明。
6. **假设** 通过 `tools` / `disallowedTools` 把 `Exec` 排除，**当** 组装声明时，**则** 与关闭开关同等处理：不声明 `Exec`，池内工具保持扁平声明。
7. **假设** 用户单独把某个内置工具从延迟名单里豁免（非延迟名单），**当** 组装声明时，**则** 该工具回到扁平声明、从池与公告中消失，且**不需要**关闭整个 `Exec`——"回退"有两个粒度：单个工具与整个通道。

### 用户故事：结果摘要列出实际的内层调用（优先级：P2）

作为用户，我希望 Exec 调用的折叠行与结果行直接告诉我这次脚本调用了哪些工具——折叠行只留工具名，结果行给出调用清单——以便不展开也能看出这次编排动了什么。

**为什么是这个优先级**：折叠行与结果行是 Exec 在消息流里的全部常显信息。脚本是多行代码，摘一段塞进折叠行既撑不开、也常常说不出在做什么（首个非空行往往是 `const out = {};` 这类），因此折叠行不预览脚本；"调了谁、调了几次"才是有用的那部分信息，而只报次数等于把它藏起来。`Agent` 工具的结果行同样是「次数 + 最近调用的子工具」，Exec 对齐这个形态。

**独立测试**：让脚本依次调用两个不同的工具，断言折叠行只显示工具名（不含任何脚本文本）、结果摘要首行是调用次数、其后逐行列出这两个工具名；再调用三个，断言只保留最近两次且首行带省略标记。

**验收场景**：

1. **假设** 脚本发生了 N 次嵌套调用，**当** 生成结果摘要时，**则** 首行是调用次数（`1 tool call` / `N tool calls`），且不得出现 `Exec` 字样——折叠行已经显示工具名，重复它只是噪音。
2. **假设** 脚本调用了池内工具，**当** 生成结果摘要时，**则** 首行之后逐行列出调用，每行只有工具名（如 `mcp__server__tool` 或内置工具名），不带参数——扁平调用的折叠行也只有工具名，两者保持一致。
3. **假设** 嵌套调用次数超过 2，**当** 生成结果摘要时，**则** 只列出最近 2 次，且首行以 `... ` 开头（表示还有更早的调用未列出）。
4. **假设** 脚本尚未结束，**当** 某次嵌套调用发生时，**则** 结果摘要必须立即更新（次数与调用行一起），不必等脚本结束——与 `Agent` 工具运行中更新 shortResult 的口径一致。
5. **假设** 脚本没有发生任何嵌套调用（只 `console`/`return`），**当** 脚本结束时，**则** 结果摘要为 `0 tool calls`，不出现任何调用行。
6. **假设** 脚本以失败结束，**当** 生成结果摘要时，**则** 摘要为 `failed`（同样不带 `Exec` 前缀），失败原因由错误内容承载。
7. **假设** 一次调用被执行期拒绝（超出调用上限，或用户在该次调用的确认弹窗里点了拒绝），**当** 生成结果摘要时，**则** 它照常计入总数并参与最近两次的列出（摘要反映"脚本尝试调用过它"，与该次调用成败无关）。
8. **假设** 生成该次调用的折叠行，**当** 渲染工具块时，**则** 参数位置为空——折叠行只显示 `Exec` 这个工具名，不出现脚本首行或任何脚本片段。
9. **假设** 脚本调用过某个池内工具，**当** 该次 `Exec` 结束时，**则** 该次调用的工具块必须保留本次调用过的工具名（结构化记录，与结果摘要同一份来源），使「代理是否调用过某工具」这类宿主侧判据在工具经沙箱调用时同样成立；该记录**只供宿主读取**——不进 `tools[]`、不进交给模型的结果文本，`Exec` 的 wire 形态与延迟工具扁平声明时逐字节一致。
10. **假设** 同一次脚本里同一个工具被调用了多次，**当** 记录调用名时，**则** 每一次都必须保留（同名按出现次数重复），不得去重——判据问的是"这次调用发生过"，去重会把"调用过 3 次"压成"调用过"。

### 非功能需求

* **零新增依赖**：隔离用 Node 内置的 `node:vm` 与 `node:worker_threads`，不引入解释器、AST 库或沙箱运行时；沙箱源码以 `{ eval: true }` 的内联 worker 形式存在，因此不需要额外的构建产物（`packages/*` 的打包、JetBrains/桌面端分发都不受影响）。
* **准入判定的单一来源**：池的成员必须由**一个**判定函数产出（`alwaysLoad` → 引擎侧豁免 → 非延迟名单 → MCP 无条件 → 工具自陈），声明收敛、公告、搜索、沙箱可达集合四处必须全部从这一个函数导出。四处各写一份判定正是"公告里有、声明里没有"这类漂移的根因。
* **池的单一来源**：池的内容与（被撤下的）扁平声明必须来自同一个函数。这既是"池 = 延迟判定表的输出"的结构性保证，也避免两套并行的过期过滤逻辑。
* **单一执行漏斗**：嵌套调用必须走现有的唯一执行入口（内部含权限/审批检查），不得为 Exec 另开一条绕开权限的执行路径。
* **hook 与权限同漏斗**：PreToolUse / PostToolUse 必须挂在嵌套调用本来就经过的执行入口上——内置工具是 `ToolManager.execute`，MCP 是 `McpManager.executeMcpTool`（后者已内含权限检查）——使"扁平声明"与"沙箱可达"两条路径共用同一段 hook 代码；不得在沙箱侧另写一套 hook 调用点，也不得只对扁平声明生效。**hook 挂在"每个工具种类一个漏斗"这两处，而不是 `ToolManager.execute` 一处**：嵌套 MCP 调用直连 `McpManager.executeMcpTool`、不经 `ToolManager`。
* **搜索入口的单一来源**：沙箱内搜索的**调用形式、入参签名与返回类型**必须由同一份 schema 渲染（复用签名渲染器），宿主也按这份 schema 校验入参、按同一形状产出结果；`Exec` 描述里不得手写调用形式——文案与实现各写一份正是二者漂移的根因。**且描述里必须给出这份自身签名**：`ToolSearch` 不在池中，没有第二个通道能查到它自己的形状（见「在沙箱内搜索」场景 15）；精简档退化为一行调用形式，同样由该渲染器导出。
* **模型可见文本稳定且与池无关**：`Exec` 在 `tools[]` 里的描述必须对任意工具池逐字节相同——它只命名沙箱 API 表面（含搜索入口，且与常量同源），不包含任何**池内**工具名、公告正文、时间/调用数/连接状态。公告正文本身则对同一份池逐字节稳定；两者都不得嵌**执行预算**（执行时长、等待上限、嵌套调用数、返回字符数）——搜索结果上限不在此列，它随签名一并渲染，且是模型自己的旋钮。
* **播报无进程内状态**：公告的播报状态必须以历史里的标记行为唯一真源（同 MCP 使用说明的通道）。不得引入跨轮缓存来"记住我播报过什么"——一旦与历史失去同步，模型看到的就是一份与它记住了什么无关的公告。
* **非延迟名单可远端下发**：判定表里的"不可延迟"覆盖名单必须能从远端配置与本地配置两个口子下发，不得硬编码在代码里——出问题时需要能在不发版的情况下把某个工具从池里拎回扁平声明。
* **不阻塞会话**：脚本的执行不得阻塞宿主事件循环（含"await 之后自旋"）；执行预算只计脚本自身的执行时长，嵌套调用在飞期间不计，等待段也不设上限（可选 `maxNestedWaitMs` 供调用方自己收紧）。工具调用是异步的，沙箱与宿主之间只传普通 JSON。
* **并发语义**：`Exec` 不得被标记为并发安全（嵌套调用可触达任意池内工具，保守视为不安全）。
* **可观测**：沙箱线程报错会记日志；超时、abort、崩溃、脚本抛错一律以失败工具结果的形式回到模型（不抛异常、不静默）。
* **测试**：SDK 层覆盖准入判定（含三条覆盖口与 MCP 无条件）、池构建与声明收敛、公告渲染与**播报状态机**（首次/变化/下线/标记被压缩/无通道时不误判）、搜索（排序分级、结果上限、`select:`、`searchHint` 参与打分、空查询、形状校验）、运行时（await 贯通、拒绝、未知名、越限、日志上限、图片、abort、三种卡死形态、等待嵌套调用不消耗预算、并发调用共享一个等待窗口、可选的等待上限（显式传参时生效）、隔离与逃逸尝试）与工具层（参数校验、结果摘要与它的实时更新、内层调用名记录、与声明收敛的接线）与 **hook**（嵌套调用触发 PreToolUse / PostToolUse——内置与 MCP 两条路径各覆盖一次、拦截语义与模型可见错误文本、被拦下的调用仍计入摘要与调用名记录、hook 抛错/超时放行）。另外必须覆盖**系统提示词的按工具名段落按可达集合判定**（池内工具在开启 Exec 的会话里仍拿到它的引导行）。

### 边界情况

* **hook 挂在哪个漏斗上？** 内置工具的唯一入口是 `ToolManager.execute`（扁平调用与嵌套调用都经过它）；MCP 的唯一入口是 `McpManager.executeMcpTool`（嵌套调用直连它，扁平调用经 MCP 插件包装后同样落到它，且权限检查本来就在那里）。所以是"每个工具种类一个漏斗"的两处，而不是 `ToolManager.execute` 一处——**嵌套 MCP 不经 `ToolManager`**，只下沉到 `ToolManager.execute` 会让 MCP 的 matcher 继续失效。
* **每次嵌套调用都跑一遍 hook 会不会太贵？** 会多一次子进程调用，与扁平调用同价。这是 hook 语义的固有意成本（审计要求逐次可见），不因此对嵌套打折；慢 hook 属于嵌套调用在飞的时段，因此不计入执行预算；等待段本身没有上限，卡住的调用与扁平调用一样靠用户中断收场。
* **hook 拦下的调用与权限拒绝在脚本里怎么区分？** 都是 rejection，按错误文本前缀区分（`PreToolUse:<名> hook error:` 对权限拒绝的理由原文）。脚本不需要分别处理也能正确运行——不捕获就都是整个 `Exec` 失败。
* **`Exec` 自己会被 hook 拦下吗？** 会。`Exec` 是扁平声明的工具，它的 PreToolUse 拦下时整个 `Exec` 不执行；拦下之后不会再触发嵌套调用的 hook（沙箱没起来）。
* **bash mode（`!` 前缀）和 fork/`/btw` 路径为什么不一起收进漏斗？** 它们绕过 hook 是既有缺口（前者直接调 `bashTool.execute`、后者直接调插件的 `execute`），与工具池无关，属于另一件事，不裹进本次改动。
* **脚本里调用了池外的工具名怎么办？** 拒绝该次调用并提示"只有本会话的可编目工具可达、请用 `tools.ToolSearch(...)` 找"。**非延迟的内置工具也在池外**——它们留在 `tools[]` 里，模型直接调用即可，不需要（也不能）从沙箱里调。为此沙箱的 `tools` 对象必须拦截未知键（但不影响 `then` 之类的普通属性读取，否则该对象会被当成 thenable）。
* **为什么沙箱不适配 `Read`、`Bash` 这些高频工具？** 它们不进池，留在 `tools[]` 里直接调用。**进池的真实代价不是"每次多一次搜索"**：签名取一次就留在对话里，之后不必重搜。代价是另外两笔——① 每次调用都要包一层 `Exec` 脚本（池内工具**完全不声明**，模型无法直接发出那次调用），② 签名只活在对话里，**压缩会把它吃掉**，之后需要重搜。因此取舍的口径是「这几份 schema 每轮请求的前缀成本」对「一层包裹 + 每个压缩周期可能一次搜索」：低频或 schema 大的工具值得进池（`WebFetch`、cron 三件套、`ExitPlanMode` 的长 `prompt` 都是），每轮都用到的核心循环不值得。这条边界与"延迟与否"是同一件事，而不是"内置 vs MCP"的边界。
* **延迟集合与 Claude Code 是同一份吗？** 是。内置工具的 `defer` 申报与 Claude Code 2.1.285 的 `shouldDefer` 逐条对齐，包括最初容易误判的两处：**plan mode 这一对**（`EnterPlanMode`/`ExitPlanMode` 在 Claude 里都是 `shouldDefer: true`）与**四个 task 工具**（`TaskCreate`/`TaskGet`/`TaskUpdate`/`TaskList` 全部 `shouldDefer: true`）。延迟它们不会叠加审批——`Exec` 自己不在受限工具名单里、不弹确认，而嵌套调用带着叶子工具名走同一套确认（含 `ExitPlanMode` 的计划审批）。
* **把某个内置工具从延迟名单里拿掉会怎样？** 它回到扁平声明、从池与公告中消失、不再可从沙箱调用，其余工具不受影响。"单个工具回退"与"关闭整个 Exec"是两个粒度。
* **服务端下发的非延迟名单里出现一个不存在的工具名会怎样？** 忽略该条（名单是覆盖口，不是工具注册表），不得因此让整个名单失效或让池构建报错。
* **MCP 工具能不能逃出池？** 能，但只有一条路：自己用 `alwaysLoad` 声明（映射到 `_meta` 里的对应语义）。它不该被延迟时由服务器自己说，而不是由宿主猜测。
* **MCP 服务器在公告之后、执行之前发生变化怎么办？** 执行时按当次池重新计算可达集合，因此新连上的能用、断开的不能用；两侧同源，不会出现沙箱比代理权限更大的情况。
* **公告里显示的工具在脚本执行时已经不可用怎么办？** 该次嵌套调用失败并把原因回到脚本（脚本可以捕获后继续）。不为了让公告"兑现"而放宽可达集合。
* **`Exec` 被禁用/被排除，会不会连带丢掉工具？** 不会。撤下的只是扁平"声明"这一形态；一旦 `Exec` 不可声明，扁平声明必须在同一次组装里恢复——**MCP 工具与被判定为延迟的内置工具都一样**，照常可调用。
* **会话没有 MCP 管理器怎么办？** 池里仍有被判定为延迟的内置工具，因此 `Exec` 照常声明、公告照常只列那些内置工具；模型若调用某个 MCP 名字则得到"未知工具"的失败结果。**目录通道与"没有池"是两件事**：宿主或测试替身根本没有暴露这条通道（读不到）时必须静默跳过，绝不能读成"池已清空"而补发一条下线公告——"读不到"与"观察到消失"必须分开处理，这与 MCP 使用说明通道的守卫是同一条规矩。
* **池为空但 `Exec` 已注册时会播报吗？** 分两种：**已经读到过公告**的会话在池变空时必须收到一次"当前没有可用的可编目工具，后续更新可能增删"（这是变化，故事 6 场景 8）；**从未有过公告**的会话整轮不发声（场景 3）——此时 `Exec` 并不声明，一句"没有可用工具"描述的是模型从未见过的能力，没有指涉物，只会给每个这样的会话白加一条消息。文案在任何情况下都不得提及 `Exec` 这个工具名（它此刻不在 `tools[]` 里，提到它只会指向一个不可调用的名字）。这与自家 MCP 使用说明通道同口径：无事可报就静默。
* **池很小（比如只有 1 个延迟工具）时会怎样？** 仍然收敛——不设数量门槛。代价是极小池下比扁平声明略贵（多一次搜索往返与一条公告），换来的确定性是「只要池非空就恒为一种形态」，不随池大小在两种形态之间来回跳变。
* **公告会不会太长？** 名字列表随池线性增长：几十个工具约几百字符，比原来的签名公告小一个数量级。**刻意不设上限也不做截断**——漏列一个工具会被读成"这个能力不存在"，而名字列表并不贵；真正的重内容（参数形状）在需要时才经搜索取。
* **`code` 缺失或为空白怎么办？** 直接返回参数错误，不启动沙箱。
* **脚本抛错怎么办？** 返回失败结果，错误信息作为工具结果交给模型；抛错之前已经产生的 console 输出仍然保留（模型能看到中途状态）。
* **脚本不返回值、也不打日志怎么办？** 返回明确文案说明"没有返回值也没有输出"，不给空内容。
* **沙箱值跨界的形状限制？** 参数必须是普通对象（拒绝带函数的对象 / 类实例）；返回值深拷贝、跳过 `__proto__` 与函数字段，防止原型污染与宿主对象偷渡。
* **子代理里会注册 `Exec` 吗？** 与其它内置工具同一条注册路径，因此是否可用由该子代理的工具集合决定；`Exec` 若被排除，同样回退到扁平声明。
* **改了公告的文案或排版会怎样？** 播报状态靠"这一轮渲染出来的正文的哈希"与历史里的标记比对，因此改文案会让存量会话认为池变了，**重播一次完整公告**。这是有意接受的代价（多一条消息，不会出错），也让"哈希相同即模型看到的字节相同"这条不变量成立；不得据此声称"池未变就一定不重播"。
* **公告为什么不发增量？** 名字列表本身很短，增量省不下东西，却要多背一份跨轮状态（上一份的名字集合或各服务器的计数），而且这份状态还得处理"基准被压缩掉"的自愈问题。基准没了就整份重发，是更简单也更不容易错的做法。
* **MCP 服务器名里含 `__` 会怎样？** 只影响工具名的可读性与"非标识符名要用括号形式调用"那条渲染规则，不影响池的成员、搜索的匹配（见「在沙箱内搜索」场景 14）与沙箱可达集合。分组与轮转已随签名公告一并取消，因此不再有分组键的地方歧义。
* **搜索返回的内容会不会很大？** 单条结果含未裁剪的描述与完整签名；因此结果条数必须有上限（关键词搜索默认 5、`select:` 点名不受限），而**交给模型**的内容另受返回值字符上限约束（见「执行预算只计脚本自己的时间」场景 8）。
* **关键字搜索被上限截断时，模型怎么知道没列全？** 结果必须携带"还有更多命中"的信号（命中总数或"已显示 X / 共 Y"），否则模型会像遇到静默截断的公告一样，把"没返回"读成"不存在"。
* **服务器声明了 `outputSchema` 但返回里没有 `structuredContent` 怎么办？** 脚本拿到的退化为该次调用的文本（两者都没有则 `null`）。判断只看服务器**实际返回**了什么，不拿声明去校验、也不据声明臆造一个对象——声明只用来渲染签名里的返回类型。
* **服务器只返回图片怎么办？** 脚本侧的值是 `null`（既无文本也无结构化输出），图片照常作为 `Exec` 结果的附件交给模型。不给脚本一个只有图片计数的信封：那会把"返回值"变成固定两字段的包裹，模型每处都得再拆一层。
* **`outputSchema` 退化成 `{ type: "object" }` 这样的空对象声明（真实服务器常见）会怎样？** 与入参同一个渲染器渲染成 `{}`，信息量接近零但它是服务器自己声明的上界；不要为此臆造 `Record<string, unknown>` 这类更具体的形状——渲染器对入参与返回类型必须同一口径。
* **schema 很宽（几十个属性）会怎样？** 完整渲染，不设每层属性数上限。代价是单条搜索结果可能很长，靠结果上限与返回字符上限兜底；**不再有"单条吃光整块预算"这种问题**，因为公告里根本没有签名。
* **字段描述很长会怎样？** 原样保留（多行照排、不裁剪）——它承载的是"这个字段填什么、枚举值怎么选"这类决策信息，被切断的往往是选择标准本身。签名渲染器不再对描述做任何宽度裁剪（工具级描述曾经被压到固定宽度，那套 clamp 随签名公告一起取消）。
* **search 传了位置参数（非对象）怎么办？** 仍由沙箱的载荷契约拒绝（"参数必须是普通对象"）——沙箱只接受普通对象是既有的隔离属性，不为搜索开口子。因为 `Exec` 描述里的调用形式由 schema 渲染，模型不会从文档里学到这种写法。
* **MCP 服务器写在使用说明里的整段散文放哪？** 那是服务器级内容（`initialize` 的 `instructions`），走独立的播报通道（服务器可用时追加一条消息、不进系统提示）、不参与任何裁剪，见 `docs/specs/ecosystem/mcp.md`；**公告走同一形态的通道**（同样只追加、同样以历史标记为唯一真源），但两条通道各有各的标记，互不读取；不要把限流、注意事项这类"不调用也要知道"的内容写进单个工具的字段描述里——那是最容易被压缩掉的位置。
* **结果摘要是纯展示层的？** 展示文本那部分是：它不进模型上下文、不参与脚本校验与执行，也不影响权限判定；一次嵌套调用都没发生时它就是 `0 tool calls`，不显示调用行。**但同一次执行产生的工具名记录不是展示层**——它是宿主侧的事实，供依赖「这个工具被调用过」的机制（任务提醒的计数器即其一）读取；两者同源，都取自执行期间的那份调用序列。
* **被权限规则拒绝的工具计入调用次数吗？** 不计入。`disallowedTools`/拒绝规则命中的工具在构池时就被排除，公告与沙箱 `tools` 里都没有它，脚本调用它拿到的是"未知工具、请用 search 找"——那是一次沙箱内的失败，从未到达宿主。真正计入的是**执行期**被拒的调用（超出上限、确认弹窗里被用户拒绝）：宿主已经收到这次调用，摘要照常计数并列出。
* **内层调用在对话流里怎么显示？** 在 Exec 的结果摘要里以「次数 + 最近 2 次调用」出现，更早的调用只体现在次数里；每次嵌套调用都物化成消息流里的独立工具行（Claude Code 的 REPL 那样）是另一项展示改动，不在本规格范围内。**调用名记录不因此物化**：它挂在 `Exec` 这一个工具块上、不新增工具行，因此依赖它的宿主侧机制不受展示形态影响，展示形态的改动也不会连带改掉那份记录。
