Skip to content

功能规格说明:Exec 工具 ​

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

设计取舍(定案)

  • 目标 = MCP 池收敛 + 当轮编排。MCP 工具变多以后,逐条扁平声明的代价是双份的:上下文里塞满当下用不到的工具描述,而且一轮只能调一个工具、步骤之间要来回若干轮。Exec 把整池折进一个工具,并让模型在一个脚本里把多步调用串起来。
  • 只覆盖 MCP。内置工具是热工具(几乎每轮都会用到),延迟它们只会亏:目录比逐条签名更贵,还多一次搜索往返。因此内置工具不纳入 Exec 的池,永远扁平声明。
  • 隔离机制 = worker_threads + node:vm。node:vm 提供 realm 隔离与 codeGeneration: false;但单线程 vm 的 { timeout } 只对同步代码生效(await 之后再死循环就管不住),所以脚本跑在可 terminate() 的 worker 里,预算是墙钟时间。
  • 模型可见文本不嵌调优参数。目录必须对同一个工具池逐字节稳定,因此 PARTIAL 截断行不写预算数字;改预算是调参,不该动 prompt。
  • 目录递送 = 尾部追加的持久消息,不进 tools[]。目录是池的函数,池一变(服务器连接/断开、tools/list 变化)整段文本就变;把它渲染进 Exec 的 description 等于一次连接就改写缓存前缀里的 tools[],把整个前缀打掉。因此 Exec 的描述是与池无关的常量,目录改在池变化时以 isMeta 消息增量追加到消息尾部——只追加、不改写已有历史(与 MCP 服务器使用说明同一通道形态,见 docs/specs/core/prompt-cache-control.md)。
  • 搜索只在截断态宣传。搜索入口始终注册(不宣传不等于不可用),但调用形式与「空查询列出完整池」的说明只在目录被截断时出现:目录完整时每条签名都已列出,再教一遍搜索只是常驻冗余(对齐 opencode)。
  • 命名 = 工具名 Exec,沙箱内搜索入口是保留命名空间 $codemode 下的 search(tools["$codemode"].search({ query: "..." })),不新增顶层搜索工具。调用形式不手写在文案里,由搜索入口自己的 schema 渲染(见「在沙箱内搜索完整目录」故事)。
  • 嵌套调用 resolve 成工具输出。脚本 await 一次 MCP 调用拿到的就是这个工具的输出:服务器返回了 structuredContent 就用它,否则是它的文本,两者都没有则是 null(对齐 opencode)。返回值里没有信封(不是 { content, images });图片仍由 Exec 结果附件的形式交给模型,脚本侧不另给一份计数。目录签名末尾写 : Promise<T>,T 由 tools/list 声明的 outputSchema 渲染(未声明则 Promise<unknown>)——签名与运行时是同一件事的两面:写进目录的返回类型必须就是脚本真正拿到的东西,否则目录就在教一种宿主不会兑现的写法。

用户场景与测试 (必填) ​

用户故事:在沙箱里编排一轮内的多步 MCP 调用(优先级:P1) ​

作为使用多个 MCP 服务器的开发者,我希望模型用一次工具调用就能串起多步 MCP 操作(取数据 → 转换 → 写回),而不是每一步都单独往返一轮。

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

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

验收场景:

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

用户故事:把 MCP 工具池收敛为单个 Exec 工具(优先级:P1) ​

作为长期使用大量 MCP 工具的用户,我希望会话的工具列表不要被整池 MCP 工具铺满,而由 Exec 的目录统一呈现,同时不因此获得任何原本拿不到的权限。

为什么是这个优先级:收敛是省上下文的唯一手段;而"目录 = 本来会被声明的那个集合"是安全不变量,不满足就不是省上下文而是提权。

独立测试:构造一个非空的 MCP 工具池(含池里只有 1 个工具的情形),断言 tools[] 中 Exec 存在、且没有任何 mcp__ 前缀的扁平声明;再令池为空,断言 Exec 不再声明。

验收场景:

  1. 假设 当前可编目的 MCP 工具数 ≥ 1 且 Exec 已注册未被禁用,当 代理组装工具声明时,则 必须声明 Exec,并且不声明任何扁平的 mcp__* 工具(不设数量门槛:池非空即收敛,与 opencode 一致)。
  2. 假设 可编目的 MCP 工具数为 0(没有任何 MCP 工具可用),当 代理组装工具声明时,则 不声明 Exec——没有可编目的内容,声明它只会让模型看到一个空目录。
  3. 假设 Exec 被权限规则显式禁用(disallowedTools / 拒绝规则),当 组装工具声明时,则 既不得声明 Exec,也不得因为 Exec 缺席就把 MCP 工具丢掉——MCP 工具必须照常扁平声明、照常可调用。
  4. 假设 Exec 未注册(功能开关关闭),当 组装工具声明时,则 MCP 工具必须照常扁平声明(关闭 Exec 不得连带关闭 MCP 能力)。
  5. 假设 池在「非空 / 空」之间反复变化(MCP 服务器连接/断开),当 每次组装声明时,则 收敛与否必须按当次的池重新判定(两个方向都要跟得上),不得残留上一次的形态。
  6. 假设 某个 MCP 工具被权限规则拒绝,当 构建目录时,则 该工具不得出现在目录中(目录与扁平声明走同一条拒绝过滤,不得出现"声明里没有但目录里有")。
  7. 假设 Exec 已声明,当 模型调用 Exec 执行脚本时,则 沙箱可调用的工具集合必须按执行当次的池重新计算——一个刚刚新连上的服务器可以调用,一个刚刚断开的服务器则不可(两边都由同一个池构造函数导出,因此不可能出现"沙箱里能调、代理本来调不到")。
  8. 假设 Exec 已声明且 MCP 工具已从扁平声明中撤下,当 模型调用 MCP 工具时,则 这些工具仍然必须为权限系统与执行路径所知(撤下的是"声明",不是"注册"),以便嵌套调用照常经过同一套权限与执行漏斗。

用户故事:脚本在沙箱里跑,拿不到宿主能力(优先级: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)都能被终止,并且工具调用次数与日志量不会失控。

为什么是这个优先级:不设预算就是让模型的一个脚本挂死整个会话;而"能终止"是机制层面的要求,不是最佳努力。

独立测试:分别跑三个卡死脚本(同步自旋 / 先 await 再自旋 / await 一个永不 settle 的 Promise),断言三者在预算内都返回失败结果而不是挂住进程。

验收场景:

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

用户故事:目录渲染与显式截断(优先级:P1) ​

作为模型,我希望在池变化时收到一条目录消息,列出当前可用的 MCP 工具及其参数形状;池太大时我希望被明确告知"只显示了一部分、怎么找其余的",而不是以为池就这么多。

为什么是这个优先级:目录是池收敛之后模型唯一的能力地图。静默截断会直接导致模型判断"这个能力不存在",是本功能最坏的失败模式。

独立测试:用小预算渲染一个较大池,断言条目数受限、每台服务器都出现一行摘要(含"一个席位都没拿到"写成 none shown 的情形)、组内条目按块成本升序、每条签名带声明过的返回类型(未声明的为 Promise<unknown>)、末尾出现带 "X of Y" 的 PARTIAL 行,且只有截断态才附上搜索说明。

验收场景:

  1. 假设 目录需要播报(池非空),当 渲染目录消息时,则 目录必须给出每个工具的可达写法、参数签名、返回类型与描述;签名是多行 TypeScript 形态(对象按字段逐行展开、可选字段带 ?、数组写成 Array<T>),字段描述与约束以 JSDoc 形式挂在对应字段上方,且必须保留 schema 里的原文(多行照排、不按宽度裁剪),约束标签含 @default、@minItems/@maxItems、@format、@deprecated;只有工具自身的描述才取首行并截断到固定宽度;合法的标识符名用 tools.<name>(...),非标识符名(含 -、. 等)必须用 tools["<name>"](...) 括号形式(否则会被解析成减法/属性链)。返回类型写在参数之后(: Promise<T>),T 由该工具 tools/list 声明的 outputSchema 用同一个渲染器渲染;未声明 outputSchema 的工具渲染 Promise<unknown>——返回类型必须与场景 2 的取值规则一致,不得写一个宿主不会兑现的形状。
  2. 假设 某个工具的 schema 很深或很宽,当 渲染签名时,则 必须按深度上限收敛(超过上限的字段渲染为 unknown,不再展开),但不得对每层属性数与枚举项数设上限——宽 schema 与多枚举项必须完整渲染;工具自身的描述按宽度上限收敛(首行 + 截断)。深度上限是唯一的递归护栏,不得再有其它的静默裁剪。
  3. 假设 池的总估算体积在目录预算之内,当 渲染时,则 必须完整渲染全部条目,不得出现 PARTIAL 行。
  4. 假设 池超出目录预算,当 渲染时,则 必须在末尾追加显式截断行,写明"已显示 X / 共 Y 个工具";搜索的调用形式与"搜索覆盖完整池"的说明不写在这一行里(避免同一份调用形式在文案里出现两遍),它只出现在截断态的搜索段(见场景 12)。
  5. 假设 池里来自多个 MCP 服务器而预算只够一部分,当 渲染时,则 必须按服务器轮转保留(每个服务器先各得一席,再轮第二席),不得让靠后连接的服务器整段消失——"目录里没有"会被模型读成"这个能力不存在",正是本功能最坏的失败模式。
  6. 假设 目录被预算截断,当 模型看到截断行时,则 输出中不得出现任何预算数字(预算是调优参数,写进模型可见文本就会让改参动到 prompt)。
  7. 假设 单个条目本身就超过预算,当 渲染时,则 仍必须至少保留一个条目(不得渲染出空目录)。
  8. 假设 工具池未变化,当 两次渲染目录时,则 文本必须逐字节相同(不得混入时间戳、连接状态、预算等动态信息);而同一次请求里 Exec 出现在 tools[] 中的描述必须与池无关——池怎么变,它一个字节都不许变。
  9. 假设 当前没有任何可用的 MCP 工具,当 渲染目录正文时,则 必须渲染出"当前没有可用的 MCP 工具,后续更新可能增删",而不是留一段空目录;但这份文案不是无条件播报的——只有已经读到过目录的会话才会收到它,从未有过目录的会话整轮不发声(见「目录作为尾部消息增量播报」场景 3 与 10)。
  10. 假设 目录被预算截断(存在未显示的条目),当 渲染时,则 必须为池里每个 MCP 服务器各输出一行摘要(形如 - mcp__github (40 tools, 13 shown);该服务器条目全部显示时省略后半段写作 - mcp__playwright (25 tools);一个席位都没拿到时写作 - mcp__ddg-search (3 tools, none shown)),且摘要行本身不计入预算——只有轮转加摘要合起来,才能保证"每台服务器至少被点到名"。摘要行必须与条目同源(按同一分组键取自同一个池),因此被权限规则拒绝的服务器不会凭空出现在摘要里。
  11. 假设 目录未被截断(全部条目都已渲染),当 渲染时,则 不得输出服务器摘要行——此时条目本身就是索引,再列一遍只是常驻冗余。
  12. 假设 目录被截断,当 渲染目录消息时,则 必须在目录正文之后附上搜索段:一句"目录是部分的,这样调用可列出或搜索完整池",后跟由搜索入口 schema 渲染的多行签名(因此自动带上"省略 query 会列出完整池"的字段说明);目录完整时这一段必须完全消失——搜索入口照旧可用,只是不宣传(对齐 opencode:search 仅在目录为 PARTIAL 时出现在文案里)。
  13. 假设 某台服务器有多个工具,当 渲染它这一段时,则 组内条目必须按渲染块成本(估算 token)升序排列、成本相同按工具名升序;不得沿用 tools/list 的返回顺序。理由:轮转每轮只取各服务器尚未显示的下一个条目(见场景 5),因此组内顺序直接决定"预算不够时谁先拿到席位"——便宜的先排,同一份预算买到的条目数最多(对齐 opencode 的 rankListings)。排序键只由条目自身内容决定,不得掺入连接状态或会话状态,因此场景 8 的逐字节稳定仍然成立。

用户故事:目录作为尾部消息增量播报(优先级:P1) ​

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

为什么是这个优先级:tools[] 位于缓存前缀内,池一变就改写它等于每连一台服务器就击穿一次前缀。目录是池的函数,必须与声明解耦;而"只有它变了才播报"决定了这个解耦的代价有多大。

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

验收场景:

  1. 假设 池非空且 Exec 已声明,当 组装 tools[] 时,则 Exec 的 description 必须是与池无关的常量:不含任何 mcp__ 名、不含目录正文、不含 PARTIAL,同一份配置下逐字节稳定。
  2. 假设 会话从未播报过目录且池非空,当 组装下一次请求时,则 必须在消息尾部追加一条 isMeta 目录消息,且它必须已经出现在本次请求里(不是推迟一轮)。
  3. 假设 会话从未播报过目录且池为空,当 组装请求时,则 不得为"池为空"专门播报——此时 Exec 根本不声明(池非空才收敛),一句"当前没有可用的 MCP 工具"描述的是模型从未见过的能力,没有指涉物;只对已经读到过目录的会话播报"目录变空了"(见场景 10)。
  4. 假设 池的目录内容发生变化,当 播报时,则 只允许向尾部追加消息,历史里任何已有消息必须逐字节不变(tools[] 同理)。
  5. 假设 池内容与上一次播报时相同,当 组装请求时,则 不得重复播报——同一状态只播报一次,逐轮重算不算变化。
  6. 假设 上下文压缩或回退把之前那条目录消息吃掉了,当 下一次组装请求时,则 必须重新播报一份完整目录("重复优于丢失":播报状态以历史里的标记为唯一真源,不依赖进程内状态)。
  7. 假设 池变化后目录处于截断态且各服务器的工具计数有增减,当 播报时,则 允许只发命名空间级差异(哪台服务器新出现、哪台计数变了、哪台下线);但这份差异只有在它所依据的那份完整目录仍在历史里时才允许发——依据已被压缩/回退掉时必须改发完整目录(差异是相对的,失去了基准就没有意义)。
  8. 假设 只发差异会比发完整目录更长(例如池整体换了一批服务器),当 播报时,则 必须发完整目录(差异不是目标,短才是)。
  9. 假设 池变化导致截断状态翻转(完整 ↔ 截断),当 播报时,则 必须发完整目录——截断翻转会改变搜索段是否存在,差异表达不了这件事。
  10. 假设 已经播报过目录的会话里池变空,当 播报时,则 必须播报一句"当前没有可用的 MCP 工具,后续更新可能增删"(这是变化而不是第一句话),且文案不得提及 Exec 这个工具名——此时它不在 tools[] 里,提到它只会指向一个不可调用的名字。
  11. 假设 Exec 因开关关闭、被 tools 排除或被权限规则拒绝而不再声明(MCP 退回扁平声明),当 播报时,则 必须追加一条"目录不再适用、勿再使用已列出的条目"的消息;通道随后重新打开而池内容未变时,必须重新播报一份完整目录(不得因为"内容没变"而永久失声)。
  12. 假设 宿主或测试替身没有提供这条通道,当 组装请求时,则 必须静默跳过,不得把"读不到通道"当成"目录已清空"而误发下线公告(读不到 ≠ 观察到的消失)。

用户故事:在沙箱内搜索完整目录(优先级:P1) ​

作为模型,当目录被截断(或我不确定某个能力的名字)时,我希望能在同一个脚本里先搜索、再调用,而不必为了找工具额外多花一轮。

为什么是这个优先级:搜索是截断的配套;只有"截断 + 可搜索"合起来,收敛池才是无损的。放进沙箱内是刻意的——搜索与使用属于同一次编排。

独立测试:用一个小预算使目录截断到一个不包含目标工具的子集,然后断言脚本仍能通过 tools["$codemode"].search(...) 找到该工具并成功调用它。

验收场景:

  1. 假设 脚本调用 tools["$codemode"].search({ query: "..." }),当 查询执行时,则 必须在宿主侧对完整池(不只是目录里显示出来的部分)做本地匹配并返回结果;不需要任何额外模型往返。
  2. 假设 查询命中某个工具,当 结果返回时,则 匹配同时覆盖工具名与描述(大小写不敏感),且结果条目包含可据以调用的完整信息(名称、描述、与目录同一形态的参数签名——同一个渲染器产出,字段文档同为完整原文,脚本可逐字照抄)。脚本拿到的值是这些条目的数组本身([{ name, description, signature }, ...]),不是它们的 JSON 文本——搜索与其它调用同一口径(见「在沙箱里编排」场景 2),因此脚本不必、也不该再 JSON.parse 一次。
  3. 假设 查询串为空,当 搜索执行时,则 返回完整池。
  4. 假设 搜索命中一个目录因截断而未显示的工具,当 脚本随后调用它时,则 调用必须成功(搜索范围与可调用范围同为完整池,二者一致)。
  5. 假设 搜索本身也算一次嵌套工具调用,当 脚本反复搜索时,则 它同样计入工具调用上限(不得成为绕过预算的通道)。
  6. 假设 沙箱内还尝试了 tools.search(...) 顶层写法,当 调用发生时,则 必须得到"名字不可达、请用 search 找"的错误(搜索入口只在一个固定的保留路径下,避免与 MCP 工具名歧义)。
  7. 假设 模型在目录里找不到想要的工具、也说不出它的名字,当 它读到目录被截断时附上的那段搜索说明,则 必须写明查询串为空会列出完整池(含与目录同形态的签名),使模型有一条不依赖任何关键词的枚举路径——这是"截断不藏存在性"在模型侧的兑现方式;该段只在截断态出现,且不得含预算等调优参数。
  8. 假设 模型按目录消息里给出的形式调用 search,当 调用发生时,则 必须成功——文案里的调用形式与宿主接受的参数形状必须由同一份 schema 导出(签名由同一个渲染器渲染、入参按同一份 schema 校验,返回类型同样由该渲染器渲染,写明条目数组的形状),不得文案与实现各写一份;"文案教位置参数、实现只收对象"这类漂移正是这条要根除的缺陷。
  9. 假设 脚本用一个普通对象调用 search 但字段不匹配(例如 { q: "..." }),当 调用发生时,则 必须返回点明期望形状的错误(形状串同样由那份 schema 渲染),不得静默按"空查询"处理——静默空查询会把"参数写错了"伪装成"搜索到了全部工具",比报错更难排查。
  10. 假设 这一轮的目录是完整的(没有附搜索说明),当 模型仍然直接调用 search 时,则 必须成功——搜索入口始终注册,"不宣传"只影响模型可见文案,不构成能力开关。

用户故事:嵌套调用复用同一套权限(优先级:P2) ​

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

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

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

验收场景:

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

用户故事:启用开关与回退(优先级:P2) ​

作为管理员或用户,我希望 Exec 默认开启、可通过配置关闭,并且关掉它之后会话退回到今天的行为(扁平 MCP 声明),不发生能力损失。

为什么是这个优先级:功能已实现、默认可开;但任何新机制都需要一条"一键回到旧行为"的退路,且必须是可远程下发的。

独立测试:分别以默认、配置 true、配置 false(外加服务端下发相反值)三种情况断言 Exec 的注册与否。

验收场景:

  1. 假设 未配置 enableExec,当 会话初始化时,则 按代码默认值(启用)注册 Exec。
  2. 假设 settings.json 配置 enableExec: false,当 会话初始化时,则 Exec 不注册;工具声明退回扁平 MCP 声明,MCP 工具照常可调用。
  3. 假设 服务端下发 enableExec,当 合并配置时,则 远端值优先于本地 settings.json 与代码默认值(管理员远程回滚入口);未下发时回退本地/默认值。
  4. 假设 运行中会话修改 enableExec 触发配置热重载,当 重新评估工具注册时,则 Exec 必须按新值即时注册/注销,无需重启会话;注销时 MCP 工具立即回到扁平声明。
  5. 假设 通过 tools / disallowedTools 把 Exec 排除,当 组装声明时,则 与关闭开关同等处理:不声明 Exec,MCP 工具保持扁平声明。

用户故事:结果摘要列出实际的内层调用(优先级:P2) ​

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

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

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

验收场景:

  1. 假设 脚本发生了 N 次嵌套调用,当 生成结果摘要时,则 首行是调用次数(1 tool call / N tool calls),且不得出现 Exec 字样——折叠行已经显示工具名,重复它只是噪音。
  2. 假设 脚本调用了 MCP 工具,当 生成结果摘要时,则 首行之后逐行列出调用,每行只有工具名(如 mcp__server__tool),不带参数——MCP 工具本身没有参数摘要,扁平调用的折叠行也只有工具名,两者保持一致。
  3. 假设 嵌套调用次数超过 2,当 生成结果摘要时,则 只列出最近 2 次,且首行以 ... 开头(表示还有更早的调用未列出)。
  4. 假设 脚本尚未结束,当 某次嵌套调用发生时,则 结果摘要必须立即更新(次数与调用行一起),不必等脚本结束——与 Agent 工具运行中更新 shortResult 的口径一致。
  5. 假设 脚本没有发生任何嵌套调用(只 console/return),当 脚本结束时,则 结果摘要为 0 tool calls,不出现任何调用行。
  6. 假设 脚本以失败结束,当 生成结果摘要时,则 摘要为 failed(同样不带 Exec 前缀),失败原因由错误内容承载。
  7. 假设 一次调用被执行期拒绝(超出调用上限,或用户在该次调用的确认弹窗里点了拒绝),当 生成结果摘要时,则 它照常计入总数并参与最近两次的列出(摘要反映"脚本尝试调用过它",与该次调用成败无关)。
  8. 假设 生成该次调用的折叠行,当 渲染工具块时,则 参数位置为空——折叠行只显示 Exec 这个工具名,不出现脚本首行或任何脚本片段。

非功能需求 ​

  • 零新增依赖:隔离用 Node 内置的 node:vm 与 node:worker_threads,不引入解释器、AST 库或沙箱运行时;沙箱源码以 { eval: true } 的内联 worker 形式存在,因此不需要额外的构建产物(packages/* 的打包、JetBrains/桌面端分发都不受影响)。
  • 池的单一来源:目录与(被撤下的)扁平声明必须来自同一个函数(由 McpManager 的扁平工具配置导出)。这既是"目录 = 本来会声明的集合"的结构性保证,也避免两套并行的过期过滤逻辑。
  • 单一执行漏斗:嵌套调用必须走 MCP 现有的唯一执行入口(内部含权限/审批检查),不得为 Exec 另开一条绕开权限的执行路径。
  • 搜索入口的单一来源:沙箱内搜索的调用形式、入参签名与返回类型必须由同一份 schema 渲染(复用与目录同形的签名渲染器),宿主也按这份 schema 校验入参、按同一形状产出结果;目录消息里不得手写调用形式——文案与实现各写一份正是二者漂移的根因。该段说明只在目录被截断时出现(见「在沙箱内搜索完整目录」)。
  • 模型可见文本稳定且与池无关:Exec 在 tools[] 里的描述必须对任意工具池逐字节相同——它只命名沙箱 API 表面(包含保留命名空间,且与常量同源),不包含任何工具名、目录正文、时间/调用数/预算/连接状态。目录正文本身则对同一份池逐字节稳定;两者都由"模型可见文本不得嵌调优参数"约束。
  • 播报无进程内状态:目录的播报状态必须以历史里的标记行为唯一真源(同 MCP 使用说明的通道)。不得引入跨轮缓存来"记住我播报过什么"——一旦与历史失去同步,模型看到的就是一份与它记住了什么无关的目录。
  • 上下文计价按 token:目录预算与截断按 token 估算,口径为 字符数 ÷ 4(与 opencode 的目录预算同源)。一个条目是一个多行块,其换行与缩进同样计入字符数,且按整块计价(不按行分别取整,否则多行块的估算会随切分方式漂移)。服务器摘要行不参与预算,因此截断态下的实际渲染体积会略超预算(每台服务器约多一行);刻意不做 CJK 感知——MCP 工具描述以英文为主,区分中英文不会改变实际结果。
  • 不阻塞会话:脚本的执行不得阻塞宿主事件循环(含"await 之后自旋");时间预算按墙钟计。工具调用是异步的,沙箱与宿主之间只传普通 JSON。
  • 并发语义:Exec 不得被标记为并发安全(嵌套调用可触达任意 MCP 工具,保守视为不安全)。
  • 可观测:沙箱线程报错会记日志;超时、abort、崩溃、脚本抛错一律以失败工具结果的形式回到模型(不抛异常、不静默)。
  • 测试:SDK 层覆盖目录构建/渲染/截断、目录播报状态机(首次/变化/差异/下线/基线失效/无通道时不误判)、运行时(await 贯通、拒绝、未知名、越限、日志上限、图片、abort、三种卡死形态、隔离与逃逸尝试)与工具层(参数校验、结果摘要与它的实时更新、与声明收敛的接线)。

边界情况 ​

  • 脚本里调用了池中不存在的工具名怎么办? 拒绝该次调用并提示"只有 MCP 工具可达、请用 tools["$codemode"].search(...) 找"。为此沙箱的 tools 对象对 mcp__ 前缀的未知键做拦截(只限于该前缀,避免影响普通属性读取——尤其是 then,否则该对象会被当成 thenable)。
  • MCP 服务器在声明之后、执行之前发生变化怎么办? 执行时按当次池重新计算可达集合,因此新连上的能用、断开的不能用;两侧同源,不会出现沙箱比代理权限更大的情况。
  • 目录里显示的工具在脚本执行时已经不可用怎么办? 该次嵌套调用失败并把原因回到脚本(脚本可以捕获后继续)。不为了让目录"兑现"而放宽可达集合。
  • Exec 被禁用/被排除,会不会连带丢掉 MCP 工具? 不会。撤下的只是扁平"声明"这一形态;一旦 Exec 不可声明,扁平声明必须在同一次组装里恢复,MCP 工具照常可调用。
  • 会话没有 MCP 管理器怎么办? Exec 返回明确的失败结果("本会话没有 MCP 管理器"),不抛异常;此时池为空,因此 Exec 根本不会被声明(池非空才收敛)。目录通道与"没有池"是两件事:宿主或测试替身根本没有暴露这条通道(读不到)时必须静默跳过,绝不能读成"池已清空"而补发一条下线公告——"读不到"与"观察到消失"必须分开处理,这与 MCP 使用说明通道的守卫是同一条规矩。
  • 池为空但 Exec 已注册时会播报吗? 分两种:已经读到过目录的会话在池变空时必须收到一次"当前没有可用的 MCP 工具,后续更新可能增删"(这是变化,故事 2 场景 10);从未有过目录的会话(典型是没有配 MCP 服务器)整轮不发声(场景 3)——此时 Exec 并不声明,一句"没有可用的 MCP 工具"描述的是模型从未见过的能力,没有指涉物,只会给每个无 MCP 的会话白加一条消息。文案在任何情况下都不得提及 Exec 这个工具名(它此刻不在 tools[] 里,提到它只会指向一个不可调用的名字)。这与自家 MCP 使用说明通道同口径:无事可报就静默。
  • 池很小(比如只有 1 个 MCP 工具)时会怎样? 仍然收敛——不设数量门槛(与 opencode 一致)。代价是极小池下比扁平声明略贵:池里 1–7 个工具时,目录加编排开销比逐条声明多花约 200 → 0 token 估算值(1 个工具时最多),约 8 个以上开始净省。换来的确定性是「只要连了 MCP 就恒为一种形态」,不随池大小在两种形态之间来回跳变。
  • code 缺失或为空白怎么办? 直接返回参数错误,不启动沙箱。
  • 脚本抛错怎么办? 返回失败结果,错误信息作为工具结果交给模型;抛错之前已经产生的 console 输出仍然保留(模型能看到中途状态)。
  • 脚本不返回值、也不打日志怎么办? 返回明确文案说明"没有返回值也没有输出",不给空内容。
  • 沙箱值跨界的形状限制? 参数必须是普通对象(拒绝带函数的对象 / 类实例);返回值深拷贝、跳过 __proto__ 与函数字段,防止原型污染与宿主对象偷渡。
  • 子代理里会注册 Exec 吗? 与其它内置工具同一条注册路径,因此是否可用由该子代理的工具集合决定;Exec 若被排除,同样回退到扁平 MCP 声明。
  • 目录预算够用吗? 默认预算对齐 opencode:按常见 MCP 工具的描述密度可完整容纳数十个工具,超过即显式截断(按服务器轮转保留,不按池顺序一刀切)。截断是预期行为(宁可显式截断 + 可搜索,也不要静默超预算),搜索覆盖完整池。截断态下另为每台服务器输出一行不计预算的摘要(对齐 opencode 的「namespace 计数行」)——轮转只保证"预算允许时每台先各得一席",极端块尺寸下某台仍可能一个席位都拿不到;摘要行让它在那种情况下至少被点到名。搜索的调用形式只在截断态出现(对齐 opencode:目录完整时不宣传 search)。
  • 改了目录的渲染文案会怎样? 播报状态靠"这一轮渲染出来的正文的哈希"与历史里的标记比对,因此改文案/排版会让存量会话认为目录变了,重播一次完整目录。这是有意接受的代价(多一条消息,不会出错),也让"哈希相同即模型看到的字节相同"这条不变量成立;不得据此声称"池未变就一定不重播"。
  • 差异能细到什么程度? 只到命名空间级(哪台服务器新出现 / 计数变了 / 下线),新增工具的具体签名要模型自己搜索。原因是播报标记里只存了"命名空间 → 工具数",没有存每个条目的路径(存了就是每条播报都背一份清单的常驻成本)。要再细一档就得扩标记的载荷,不要去历史里重新解析目录正文。
  • 差异消息依赖的那份完整目录被压缩掉了怎么办? 改发完整目录。差异是相对于某一份完整目录而言的,基准不在了差异就没有意义——这正是"差异可自愈"的做法(宁可重发一份完整的,也不要让模型拿着一份对不上的增量)。
  • MCP 服务器名里含 __ 会怎样? 分组键按 executeMcpTool 的同一拆法取(split("__")[1]),因此与真实路由一致:这类服务器的工具会归到实际路由去的那台机器名下,可能多个服务器共用一个分组键。这是扁平命名的既有歧义(非本功能引入),只影响目录的分组与轮转顺序,不影响可达集合。
  • 搜索返回的内容会不会很大? 脚本内部拿到的是完整匹配条目(名称、未裁剪的描述、与目录同一形态的签名,不受目录预算截断影响),这可能很大;搜索与其它嵌套调用一样计入调用上限,而交给模型的内容受返回值字符上限约束。
  • 服务器声明了 outputSchema 但返回里没有 structuredContent 怎么办? 脚本拿到的退化为该次调用的文本(两者都没有则 null)。判断只看服务器实际返回了什么,不拿声明去校验、也不据声明臆造一个对象——声明只用来渲染目录里的返回类型。
  • 服务器只返回图片怎么办? 脚本侧的值是 null(既无文本也无结构化输出),图片照常作为 Exec 结果的附件交给模型。不给脚本一个只有图片计数的信封:那会把"返回值"变成固定两字段的包裹,模型每处都得再拆一层。
  • outputSchema 退化成 { type: "object" } 这样的空对象声明(真实服务器常见)会怎样? 与入参同一个渲染器渲染成 {},信息量接近零但它是服务器自己声明的上界;不要为此臆造 Record<string, unknown> 这类更具体的形状——渲染器对入参与返回类型必须同一口径。
  • schema 很宽(几十个属性)会怎样? 完整渲染,不设每层属性数上限(对齐 opencode)。代价是单条可能吃掉整块目录预算:此时其余条目按轮转尽量排,被挤掉的部分靠 PARTIAL 行与搜索兜底,而该条目本身仍然完整保留("单条超预算仍保留一条"覆盖)。这是把保真度排在覆盖率之前的刻意取舍。
  • 字段描述很长会怎样? 原样保留(多行照排、不裁剪)——它承载的是"这个字段填什么、枚举值怎么选"这类决策信息,被切断的往往是选择标准本身。被压到固定宽度的只有工具自身的描述,而工具级散文另有一条按需通道:搜索按名命中时返回未裁剪的原文。
  • search 传了位置参数(非对象)怎么办? 仍由沙箱的载荷契约拒绝("参数必须是普通对象")——沙箱只接受普通对象是既有的隔离属性,不为搜索开口子。因为文案里的调用形式由 schema 渲染(且只在截断态出现),模型不会从文档里学到这种写法。
  • MCP 服务器写在使用说明里的整段散文放哪? 那是服务器级内容(initialize 的 instructions),走独立的播报通道(服务器可用时追加一条消息,不进系统提示)、不参与目录预算也不裁剪,见 docs/specs/ecosystem/mcp.md;目录走同一形态的通道(同样只追加、同样以历史标记为唯一真源),但两条通道各有各的标记,互不读取;不要把限流、注意事项这类"不调用也要知道"的内容写进单个工具的字段描述里——那是最容易被压缩掉的位置。
  • 结果摘要是纯展示层的? 是。它不进模型上下文、不参与脚本校验与执行,也不影响权限判定;一次嵌套调用都没发生时它就是 0 tool calls,不显示调用行。
  • 被权限规则拒绝的工具计入调用次数吗? 不计入。disallowedTools/拒绝规则命中的工具在执行期构池时就被排除,目录与沙箱 tools 里都没有它,脚本调用它拿到的是"未知工具、请用 search 找"——那是一次沙箱内的失败,从未到达宿主。真正计入的是执行期被拒的调用(超出上限、确认弹窗里被用户拒绝):宿主已经收到这次调用,摘要照常计数并列出。
  • 内层调用在对话流里怎么显示? 在 Exec 的结果摘要里以「次数 + 最近 2 次调用」出现,更早的调用只体现在次数里;每次嵌套调用都物化成消息流里的独立工具行(Claude Code 的 REPL 那样)是另一项展示改动,不在本规格范围内。