Appearance
功能规格说明: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。
验收场景:
- 假设 沙箱脚本写了
const a = await tools.mcp__srv__fetch({ id: 1 }); return await tools.mcp__srv__store({ value: a });,当Exec执行时,则 两次 MCP 调用都必须真实发生,且脚本能拿到上一次的返回值继续使用。 - 假设 脚本 await 某个工具调用,当 该调用返回时,则 脚本收到的值就是该 MCP 工具的输出:服务器返回了
structuredContent时就是它(已解析的对象,脚本不必再JSON.parse文本),否则是它的文本,两者都没有则是null;不得再包一层信封(没有content/images字段)。 - 假设 脚本传给工具的实参对象,当 调用转发到 MCP 服务器时,则 实参必须逐字段原样透传(不重命名、不补默认值、不做 schema 校验)。
- 假设 脚本里嵌套调用的工具名不在当前池中,当 该调用发生时,则 该 await 必须 reject,错误信息说明"只有 MCP 工具可达"并指向
tools["$codemode"].search(...)。 - 假设 脚本用
try/catch包住一个会被拒绝的嵌套调用,当 该调用被拒绝时,则 脚本必须能捕获错误并继续执行后续步骤(一次失败不终止整个脚本)。 - 假设 脚本需要中间值,当 脚本调用
console.log(...)时,则 输出必须被收集并与最终结果一并返回给模型。 - 假设 脚本用
return返回一个对象,当Exec执行完成时,则 返回值必须序列化后交给模型;脚本没有返回值也没有 console 输出时,结果文本必须明确说明这一点(而不是给一个空字符串)。
用户故事:把 MCP 工具池收敛为单个 Exec 工具(优先级:P1)
作为长期使用大量 MCP 工具的用户,我希望会话的工具列表不要被整池 MCP 工具铺满,而由 Exec 的目录统一呈现,同时不因此获得任何原本拿不到的权限。
为什么是这个优先级:收敛是省上下文的唯一手段;而"目录 = 本来会被声明的那个集合"是安全不变量,不满足就不是省上下文而是提权。
独立测试:构造一个非空的 MCP 工具池(含池里只有 1 个工具的情形),断言 tools[] 中 Exec 存在、且没有任何 mcp__ 前缀的扁平声明;再令池为空,断言 Exec 不再声明。
验收场景:
- 假设 当前可编目的 MCP 工具数 ≥ 1 且
Exec已注册未被禁用,当 代理组装工具声明时,则 必须声明Exec,并且不声明任何扁平的mcp__*工具(不设数量门槛:池非空即收敛,与 opencode 一致)。 - 假设 可编目的 MCP 工具数为 0(没有任何 MCP 工具可用),当 代理组装工具声明时,则 不声明
Exec——没有可编目的内容,声明它只会让模型看到一个空目录。 - 假设
Exec被权限规则显式禁用(disallowedTools/ 拒绝规则),当 组装工具声明时,则 既不得声明Exec,也不得因为 Exec 缺席就把 MCP 工具丢掉——MCP 工具必须照常扁平声明、照常可调用。 - 假设
Exec未注册(功能开关关闭),当 组装工具声明时,则 MCP 工具必须照常扁平声明(关闭 Exec 不得连带关闭 MCP 能力)。 - 假设 池在「非空 / 空」之间反复变化(MCP 服务器连接/断开),当 每次组装声明时,则 收敛与否必须按当次的池重新判定(两个方向都要跟得上),不得残留上一次的形态。
- 假设 某个 MCP 工具被权限规则拒绝,当 构建目录时,则 该工具不得出现在目录中(目录与扁平声明走同一条拒绝过滤,不得出现"声明里没有但目录里有")。
- 假设
Exec已声明,当 模型调用Exec执行脚本时,则 沙箱可调用的工具集合必须按执行当次的池重新计算——一个刚刚新连上的服务器可以调用,一个刚刚断开的服务器则不可(两边都由同一个池构造函数导出,因此不可能出现"沙箱里能调、代理本来调不到")。 - 假设
Exec已声明且 MCP 工具已从扁平声明中撤下,当 模型调用 MCP 工具时,则 这些工具仍然必须为权限系统与执行路径所知(撤下的是"声明",不是"注册"),以便嵌套调用照常经过同一套权限与执行漏斗。
用户故事:脚本在沙箱里跑,拿不到宿主能力(优先级:P1)
作为运维该功能的人,我希望模型的脚本无论写什么都不能读写宿主文件、发网络请求、加载模块或动态编译代码,也不能把宿主对象偷渡回沙箱。
为什么是这个优先级:Exec 执行的是模型生成、可能来自不可信上下文的代码。隔离不成立,其余一切免谈。
独立测试:在脚本里依次尝试 require、import(...)、eval、new Function、process、globalThis.fetch,断言全部不可用;再断言拿到手的工具函数/返回值的 constructor 属于沙箱 realm,且经它编译代码会抛错。
验收场景:
- 假设 脚本引用
require、process、Buffer、module、globalThis.fetch、XMLHttpRequest,当 脚本执行时,则 这些必须是undefined(沙箱只注入tools与console)。 - 假设 脚本调用
eval(...)或new Function("..."),当 脚本执行时,则 必须抛出EvalError(codeGeneration: { strings: false, wasm: false }),不得执行被编译的代码。 - 假设 脚本写
import("node:fs"),当 脚本执行时,则 必须失败,且交给模型的错误信息必须是人话("import() 不可用"),不是引擎内部的 callback 报错。 - 假设 脚本试图通过
tools.someTool.constructor("return process")()之类的路径回到能编译代码的函数,当 脚本执行时,则 必须拿不到宿主能力——工具包装函数上的.constructor拿到的是沙箱Function(抛EvalError);跨回来的普通对象原型为null,.constructor直接是undefined(抛TypeError)。两条路径都不允许出现"能编译字符串的函数":沙箱里所有函数对象(含每个工具包装函数与console方法)都必须属于沙箱 realm,不得把宿主 realm 的函数直接交出去。 - 假设 脚本收到工具调用返回的对象,当 脚本修改该对象时,则 宿主侧的数据不得被影响(跨界值必须深拷贝,且跳过
__proto__与函数字段)。 - 假设 沙箱向宿主发起调用,当 消息跨线程传递时,则 只允许结构化克隆能承载的普通数据(参数必须是普通对象;带函数的对象、类实例或原型污染载荷必须被拒绝,错误返回给脚本)。
用户故事:预算是墙钟时间,且能真正终止(优先级:P1)
作为运行长任务的人,我希望脚本无论以哪种方式卡住(同步死循环、await 之后再死循环、永不 resolve 的 await)都能被终止,并且工具调用次数与日志量不会失控。
为什么是这个优先级:不设预算就是让模型的一个脚本挂死整个会话;而"能终止"是机制层面的要求,不是最佳努力。
独立测试:分别跑三个卡死脚本(同步自旋 / 先 await 再自旋 / await 一个永不 settle 的 Promise),断言三者在预算内都返回失败结果而不是挂住进程。
验收场景:
- 假设 脚本是同步死循环,当 超过时间预算时,则 必须终止沙箱并返回失败结果,错误说明"超出预算已被终止"且提示"已完成的调用照常生效"。
- 假设 脚本先
await一次工具调用、随后进入死循环,当 超过时间预算时,则 同样必须终止(这正是单线程vm的{ timeout }管不住、必须靠 workerterminate()的情形)。 - 假设 脚本 await 一个永不 settle 的 Promise,当 超过时间预算时,则 同样必须终止。
- 假设 一次脚本内的工具调用次数超过上限,当 越界的那次调用发生时,则 必须只拒绝这一次调用(脚本可在
try/catch里继续),而不是杀掉整个脚本。 - 假设 脚本大量
console.log,当 日志被收集时,则 返回给模型的日志必须被字符上限截断,不得用日志撑爆上下文。 - 假设 脚本
return的值超过字符上限,当 结果交给模型时,则 必须截断并注明总字符数——扁平 MCP 调用自身有结果大小上限(超出落盘 + 预览),嵌套调用若不加限就比它替代的那次调用更贵。 - 假设 一次脚本里发生多次嵌套调用并各自带回图片,当 结果返回时,则 图片数量必须有上限,超出部分丢弃(不得无限累积)。
- 假设 运行中会话被用户中断/session 关闭,当 abort 信号触发(或进入时已 aborted),则
Exec必须尽快返回失败结果并终止沙箱,不得继续执行。 - 假设 沙箱因任何原因崩溃或异常退出,当 宿主收到通知时,则 必须把它转成一条失败工具结果交给模型(
Exec的 Promise 不得 reject,模型总要拿到可据以行动的结果)。
用户故事:目录渲染与显式截断(优先级:P1)
作为模型,我希望在池变化时收到一条目录消息,列出当前可用的 MCP 工具及其参数形状;池太大时我希望被明确告知"只显示了一部分、怎么找其余的",而不是以为池就这么多。
为什么是这个优先级:目录是池收敛之后模型唯一的能力地图。静默截断会直接导致模型判断"这个能力不存在",是本功能最坏的失败模式。
独立测试:用小预算渲染一个较大池,断言条目数受限、每台服务器都出现一行摘要(含"一个席位都没拿到"写成 none shown 的情形)、组内条目按块成本升序、每条签名带声明过的返回类型(未声明的为 Promise<unknown>)、末尾出现带 "X of Y" 的 PARTIAL 行,且只有截断态才附上搜索说明。
验收场景:
- 假设 目录需要播报(池非空),当 渲染目录消息时,则 目录必须给出每个工具的可达写法、参数签名、返回类型与描述;签名是多行 TypeScript 形态(对象按字段逐行展开、可选字段带
?、数组写成Array<T>),字段描述与约束以 JSDoc 形式挂在对应字段上方,且必须保留 schema 里的原文(多行照排、不按宽度裁剪),约束标签含@default、@minItems/@maxItems、@format、@deprecated;只有工具自身的描述才取首行并截断到固定宽度;合法的标识符名用tools.<name>(...),非标识符名(含-、.等)必须用tools["<name>"](...)括号形式(否则会被解析成减法/属性链)。返回类型写在参数之后(: Promise<T>),T由该工具tools/list声明的outputSchema用同一个渲染器渲染;未声明outputSchema的工具渲染Promise<unknown>——返回类型必须与场景 2 的取值规则一致,不得写一个宿主不会兑现的形状。 - 假设 某个工具的 schema 很深或很宽,当 渲染签名时,则 必须按深度上限收敛(超过上限的字段渲染为
unknown,不再展开),但不得对每层属性数与枚举项数设上限——宽 schema 与多枚举项必须完整渲染;工具自身的描述按宽度上限收敛(首行 + 截断)。深度上限是唯一的递归护栏,不得再有其它的静默裁剪。 - 假设 池的总估算体积在目录预算之内,当 渲染时,则 必须完整渲染全部条目,不得出现
PARTIAL行。 - 假设 池超出目录预算,当 渲染时,则 必须在末尾追加显式截断行,写明"已显示 X / 共 Y 个工具";搜索的调用形式与"搜索覆盖完整池"的说明不写在这一行里(避免同一份调用形式在文案里出现两遍),它只出现在截断态的搜索段(见场景 12)。
- 假设 池里来自多个 MCP 服务器而预算只够一部分,当 渲染时,则 必须按服务器轮转保留(每个服务器先各得一席,再轮第二席),不得让靠后连接的服务器整段消失——"目录里没有"会被模型读成"这个能力不存在",正是本功能最坏的失败模式。
- 假设 目录被预算截断,当 模型看到截断行时,则 输出中不得出现任何预算数字(预算是调优参数,写进模型可见文本就会让改参动到 prompt)。
- 假设 单个条目本身就超过预算,当 渲染时,则 仍必须至少保留一个条目(不得渲染出空目录)。
- 假设 工具池未变化,当 两次渲染目录时,则 文本必须逐字节相同(不得混入时间戳、连接状态、预算等动态信息);而同一次请求里
Exec出现在tools[]中的描述必须与池无关——池怎么变,它一个字节都不许变。 - 假设 当前没有任何可用的 MCP 工具,当 渲染目录正文时,则 必须渲染出"当前没有可用的 MCP 工具,后续更新可能增删",而不是留一段空目录;但这份文案不是无条件播报的——只有已经读到过目录的会话才会收到它,从未有过目录的会话整轮不发声(见「目录作为尾部消息增量播报」场景 3 与 10)。
- 假设 目录被预算截断(存在未显示的条目),当 渲染时,则 必须为池里每个 MCP 服务器各输出一行摘要(形如
- mcp__github (40 tools, 13 shown);该服务器条目全部显示时省略后半段写作- mcp__playwright (25 tools);一个席位都没拿到时写作- mcp__ddg-search (3 tools, none shown)),且摘要行本身不计入预算——只有轮转加摘要合起来,才能保证"每台服务器至少被点到名"。摘要行必须与条目同源(按同一分组键取自同一个池),因此被权限规则拒绝的服务器不会凭空出现在摘要里。 - 假设 目录未被截断(全部条目都已渲染),当 渲染时,则 不得输出服务器摘要行——此时条目本身就是索引,再列一遍只是常驻冗余。
- 假设 目录被截断,当 渲染目录消息时,则 必须在目录正文之后附上搜索段:一句"目录是部分的,这样调用可列出或搜索完整池",后跟由搜索入口 schema 渲染的多行签名(因此自动带上"省略 query 会列出完整池"的字段说明);目录完整时这一段必须完全消失——搜索入口照旧可用,只是不宣传(对齐 opencode:search 仅在目录为 PARTIAL 时出现在文案里)。
- 假设 某台服务器有多个工具,当 渲染它这一段时,则 组内条目必须按渲染块成本(估算 token)升序排列、成本相同按工具名升序;不得沿用
tools/list的返回顺序。理由:轮转每轮只取各服务器尚未显示的下一个条目(见场景 5),因此组内顺序直接决定"预算不够时谁先拿到席位"——便宜的先排,同一份预算买到的条目数最多(对齐 opencode 的rankListings)。排序键只由条目自身内容决定,不得掺入连接状态或会话状态,因此场景 8 的逐字节稳定仍然成立。
用户故事:目录作为尾部消息增量播报(优先级:P1)
作为模型,我希望在池发生变化时收到一条目录消息,而不是每次请求都重新读一遍工具声明——声明变了会让整段缓存前缀失效。
为什么是这个优先级:tools[] 位于缓存前缀内,池一变就改写它等于每连一台服务器就击穿一次前缀。目录是池的函数,必须与声明解耦;而"只有它变了才播报"决定了这个解耦的代价有多大。
独立测试:让池从"空"变到"非空"、再变到"另一套服务器",断言每次变化都追加一条新消息、历史里已有消息逐字节不变、Exec 的 description 始终与池无关,且状态未变时不重复播报。
验收场景:
- 假设 池非空且
Exec已声明,当 组装tools[]时,则Exec的 description 必须是与池无关的常量:不含任何mcp__名、不含目录正文、不含PARTIAL,同一份配置下逐字节稳定。 - 假设 会话从未播报过目录且池非空,当 组装下一次请求时,则 必须在消息尾部追加一条
isMeta目录消息,且它必须已经出现在本次请求里(不是推迟一轮)。 - 假设 会话从未播报过目录且池为空,当 组装请求时,则 不得为"池为空"专门播报——此时
Exec根本不声明(池非空才收敛),一句"当前没有可用的 MCP 工具"描述的是模型从未见过的能力,没有指涉物;只对已经读到过目录的会话播报"目录变空了"(见场景 10)。 - 假设 池的目录内容发生变化,当 播报时,则 只允许向尾部追加消息,历史里任何已有消息必须逐字节不变(
tools[]同理)。 - 假设 池内容与上一次播报时相同,当 组装请求时,则 不得重复播报——同一状态只播报一次,逐轮重算不算变化。
- 假设 上下文压缩或回退把之前那条目录消息吃掉了,当 下一次组装请求时,则 必须重新播报一份完整目录("重复优于丢失":播报状态以历史里的标记为唯一真源,不依赖进程内状态)。
- 假设 池变化后目录处于截断态且各服务器的工具计数有增减,当 播报时,则 允许只发命名空间级差异(哪台服务器新出现、哪台计数变了、哪台下线);但这份差异只有在它所依据的那份完整目录仍在历史里时才允许发——依据已被压缩/回退掉时必须改发完整目录(差异是相对的,失去了基准就没有意义)。
- 假设 只发差异会比发完整目录更长(例如池整体换了一批服务器),当 播报时,则 必须发完整目录(差异不是目标,短才是)。
- 假设 池变化导致截断状态翻转(完整 ↔ 截断),当 播报时,则 必须发完整目录——截断翻转会改变搜索段是否存在,差异表达不了这件事。
- 假设 已经播报过目录的会话里池变空,当 播报时,则 必须播报一句"当前没有可用的 MCP 工具,后续更新可能增删"(这是变化而不是第一句话),且文案不得提及
Exec这个工具名——此时它不在tools[]里,提到它只会指向一个不可调用的名字。 - 假设
Exec因开关关闭、被tools排除或被权限规则拒绝而不再声明(MCP 退回扁平声明),当 播报时,则 必须追加一条"目录不再适用、勿再使用已列出的条目"的消息;通道随后重新打开而池内容未变时,必须重新播报一份完整目录(不得因为"内容没变"而永久失声)。 - 假设 宿主或测试替身没有提供这条通道,当 组装请求时,则 必须静默跳过,不得把"读不到通道"当成"目录已清空"而误发下线公告(读不到 ≠ 观察到的消失)。
用户故事:在沙箱内搜索完整目录(优先级:P1)
作为模型,当目录被截断(或我不确定某个能力的名字)时,我希望能在同一个脚本里先搜索、再调用,而不必为了找工具额外多花一轮。
为什么是这个优先级:搜索是截断的配套;只有"截断 + 可搜索"合起来,收敛池才是无损的。放进沙箱内是刻意的——搜索与使用属于同一次编排。
独立测试:用一个小预算使目录截断到一个不包含目标工具的子集,然后断言脚本仍能通过 tools["$codemode"].search(...) 找到该工具并成功调用它。
验收场景:
- 假设 脚本调用
tools["$codemode"].search({ query: "..." }),当 查询执行时,则 必须在宿主侧对完整池(不只是目录里显示出来的部分)做本地匹配并返回结果;不需要任何额外模型往返。 - 假设 查询命中某个工具,当 结果返回时,则 匹配同时覆盖工具名与描述(大小写不敏感),且结果条目包含可据以调用的完整信息(名称、描述、与目录同一形态的参数签名——同一个渲染器产出,字段文档同为完整原文,脚本可逐字照抄)。脚本拿到的值是这些条目的数组本身(
[{ name, description, signature }, ...]),不是它们的 JSON 文本——搜索与其它调用同一口径(见「在沙箱里编排」场景 2),因此脚本不必、也不该再JSON.parse一次。 - 假设 查询串为空,当 搜索执行时,则 返回完整池。
- 假设 搜索命中一个目录因截断而未显示的工具,当 脚本随后调用它时,则 调用必须成功(搜索范围与可调用范围同为完整池,二者一致)。
- 假设 搜索本身也算一次嵌套工具调用,当 脚本反复搜索时,则 它同样计入工具调用上限(不得成为绕过预算的通道)。
- 假设 沙箱内还尝试了
tools.search(...)顶层写法,当 调用发生时,则 必须得到"名字不可达、请用 search 找"的错误(搜索入口只在一个固定的保留路径下,避免与 MCP 工具名歧义)。 - 假设 模型在目录里找不到想要的工具、也说不出它的名字,当 它读到目录被截断时附上的那段搜索说明,则 必须写明查询串为空会列出完整池(含与目录同形态的签名),使模型有一条不依赖任何关键词的枚举路径——这是"截断不藏存在性"在模型侧的兑现方式;该段只在截断态出现,且不得含预算等调优参数。
- 假设 模型按目录消息里给出的形式调用 search,当 调用发生时,则 必须成功——文案里的调用形式与宿主接受的参数形状必须由同一份 schema 导出(签名由同一个渲染器渲染、入参按同一份 schema 校验,返回类型同样由该渲染器渲染,写明条目数组的形状),不得文案与实现各写一份;"文案教位置参数、实现只收对象"这类漂移正是这条要根除的缺陷。
- 假设 脚本用一个普通对象调用 search 但字段不匹配(例如
{ q: "..." }),当 调用发生时,则 必须返回点明期望形状的错误(形状串同样由那份 schema 渲染),不得静默按"空查询"处理——静默空查询会把"参数写错了"伪装成"搜索到了全部工具",比报错更难排查。 - 假设 这一轮的目录是完整的(没有附搜索说明),当 模型仍然直接调用 search 时,则 必须成功——搜索入口始终注册,"不宣传"只影响模型可见文案,不构成能力开关。
用户故事:嵌套调用复用同一套权限(优先级:P2)
作为用户,我希望 Exec 里的每一次嵌套 MCP 调用都经过与直接调用完全相同的权限确认,而不是被 Exec 包一层就绕过审批。
为什么是这个优先级:不是最核心的价值,但一旦缺失就是安全漏洞,因此必须有明确契约与验收。
独立测试:让一次嵌套调用命中拒绝规则,断言调用被拒绝且拒绝理由回到脚本;再让它命中"总是允许"规则,断言不再询问。
验收场景:
- 假设 嵌套调用的某个 MCP 工具没有匹配的权限规则,当 该调用发生时,则 必须与扁平调用一样弹出确认;用户选择"是,不再询问"后必须以叶子工具的全名(
mcp__server__tool)保存持久规则。 - 假设 已存在针对某个叶子工具全名的允许规则,当 Exec 内嵌套调用该工具时,则 必须直接执行、不再询问(规则按叶子名与叶子参数匹配,不按
Exec这个外层工具名匹配)。 - 假设 已存在针对某个叶子工具的拒绝规则,当 Exec 内嵌套调用它时,则 必须被拒绝,且拒绝理由作为该次调用的错误回到脚本。
- 假设 用户拒绝了一次嵌套调用的确认,当 脚本没有捕获该错误时,则 整个
Exec返回失败并带上该理由(不得把拒绝静默成空结果)。 - 假设
Exec自身被权限规则拒绝,当 模型调用Exec时,则 拒绝必须发生在工具层,不得进入沙箱执行。
用户故事:启用开关与回退(优先级:P2)
作为管理员或用户,我希望 Exec 默认开启、可通过配置关闭,并且关掉它之后会话退回到今天的行为(扁平 MCP 声明),不发生能力损失。
为什么是这个优先级:功能已实现、默认可开;但任何新机制都需要一条"一键回到旧行为"的退路,且必须是可远程下发的。
独立测试:分别以默认、配置 true、配置 false(外加服务端下发相反值)三种情况断言 Exec 的注册与否。
验收场景:
- 假设 未配置
enableExec,当 会话初始化时,则 按代码默认值(启用)注册Exec。 - 假设 settings.json 配置
enableExec: false,当 会话初始化时,则Exec不注册;工具声明退回扁平 MCP 声明,MCP 工具照常可调用。 - 假设 服务端下发
enableExec,当 合并配置时,则 远端值优先于本地 settings.json 与代码默认值(管理员远程回滚入口);未下发时回退本地/默认值。 - 假设 运行中会话修改
enableExec触发配置热重载,当 重新评估工具注册时,则Exec必须按新值即时注册/注销,无需重启会话;注销时 MCP 工具立即回到扁平声明。 - 假设 通过
tools/disallowedTools把Exec排除,当 组装声明时,则 与关闭开关同等处理:不声明Exec,MCP 工具保持扁平声明。
用户故事:结果摘要列出实际的内层调用(优先级:P2)
作为用户,我希望 Exec 调用的折叠行与结果行直接告诉我这次脚本调用了哪些 MCP 工具——折叠行只留工具名,结果行给出调用清单——以便不展开也能看出这次编排动了什么。
为什么是这个优先级:折叠行与结果行是 Exec 在消息流里的全部常显信息。脚本是多行代码,摘一段塞进折叠行既撑不开、也常常说不出在做什么(首个非空行往往是 const out = {}; 这类),因此折叠行不预览脚本;"调了谁、调了几次"才是有用的那部分信息,而只报次数等于把它藏起来。Agent 工具的结果行同样是「次数 + 最近调用的子工具」,Exec 对齐这个形态。
独立测试:让脚本依次调用两个不同的 MCP 工具,断言折叠行只显示工具名(不含任何脚本文本)、结果摘要首行是调用次数、其后逐行列出这两个工具名;再调用三个,断言只保留最近两次且首行带省略标记。
验收场景:
- 假设 脚本发生了 N 次嵌套调用,当 生成结果摘要时,则 首行是调用次数(
1 tool call/N tool calls),且不得出现Exec字样——折叠行已经显示工具名,重复它只是噪音。 - 假设 脚本调用了 MCP 工具,当 生成结果摘要时,则 首行之后逐行列出调用,每行只有工具名(如
mcp__server__tool),不带参数——MCP 工具本身没有参数摘要,扁平调用的折叠行也只有工具名,两者保持一致。 - 假设 嵌套调用次数超过 2,当 生成结果摘要时,则 只列出最近 2 次,且首行以
...开头(表示还有更早的调用未列出)。 - 假设 脚本尚未结束,当 某次嵌套调用发生时,则 结果摘要必须立即更新(次数与调用行一起),不必等脚本结束——与
Agent工具运行中更新 shortResult 的口径一致。 - 假设 脚本没有发生任何嵌套调用(只
console/return),当 脚本结束时,则 结果摘要为0 tool calls,不出现任何调用行。 - 假设 脚本以失败结束,当 生成结果摘要时,则 摘要为
failed(同样不带Exec前缀),失败原因由错误内容承载。 - 假设 一次调用被执行期拒绝(超出调用上限,或用户在该次调用的确认弹窗里点了拒绝),当 生成结果摘要时,则 它照常计入总数并参与最近两次的列出(摘要反映"脚本尝试调用过它",与该次调用成败无关)。
- 假设 生成该次调用的折叠行,当 渲染工具块时,则 参数位置为空——折叠行只显示
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 那样)是另一项展示改动,不在本规格范围内。