一次把「类别封条」和「常驻开关」塞进同一份 frontmatter 的改动,以及它为什么会因为一条看起来无关的集成测试回滚掉一半。

1. 一个 skill 有七个入口⌗

一个 agent 框架里的 skill,物理形态很朴素:skills/<name>/ 一个目录,SKILL.md 写用途和操作手册,tools.ts 导出工具。麻烦的不是这个目录,而是有多少条路通向它。

面模型看到什么由谁回答
Skills 索引(system prompt)一行 name: description每个 turn 渲染
introspect("skills")全部 skill 的名字与描述工具
introspect("skill:<name>")该 skill 的手册全文工具
activate_skill({skills:[...]})整组工具变成可调工具
按精确名直接调用自动激活后执行执行路径
激活集恢复 / 历史回扫 / 预激活上一轮或历史里用过、或被关键词命中的工具构建工具面的那一刻
CLI 桥(tools/list + maxclaw_tool_call)交给另一个进程里的子 agent 的工具清单桥

不算最后那行跨进程的桥,也有六个面会独立回答同一个问题:这个 skill,此刻能不能用?

这一点值得停下来看一眼。注册表里确实只有一份工具定义,ToolGroupIndex 也确实保证了「一个工具只有一个归属」,看上去是单点。但「能不能用」不是一个数据查询,而是上面几行里每一段互相不知道对方存在的代码各自做出的决定。任何一段漏掉同一个判断,其他段都不会报错——它们只会给出不同的答案。

于是「工具发现方式」本身就成了旁路:

索引里没有这个 skill        ← 已经挡住了
    但
按精确名字直调              ← 自动激活,进来了

或者:

activate_skill 拒绝了       ← 已经挡住了
    但
历史回扫把同一组工具带回来   ← 上一段历史里调用过,必须可调

后一条尤其阴险:框架里有一条历史不变式——消息历史里出现过的工具调用,下个 turn 必须仍然可调,否则上一轮的工具结果会变成孤儿,模型会以为自己用过的东西凭空消失。这条不变式是好设计,但它恰好也是一条现成的绕行通道。安全边界和便利不变式撞在一起的时候,得有个明确的优先级。

本文基于一次落地改动:21 个文件、+742 / −76、两个提交。它同时做两件事——给 skill 加类别封条(categories),给 skill 加常驻开关(availability)。两件事看起来是同一个「给 SKILL.md 加两个字段」的需求,实现上却必须分成两套机制。这是本文真正想讲的东西。

2. 封条:非空即限制,判定只有一处⌗

categories: string[],空或缺省 = 无限制。非空 = 这个 skill 只对线程类别在这个列表里的 turn 可见、可激活。判定被压成一行纯函数(src/skills/category-seal.ts):

export function skillSealedFor(categories: readonly string[], threadCategory: string | null): boolean {
  if (categories == null || categories.length === 0) return false
  return threadCategory === null || !categories.includes(threadCategory)
}

三件事在这四行里定死了:

  1. 空列表不是「封给所有人」,是无限制。 缺省常见值的两种选法(空 = 全封 / 空 = 全开)只有一种不会让所有既有 skill 突然消失。
  2. 普通线程(类别为 null)永远不匹配任何封条。 封条是白名单,不是黑名单;没打标签的线程按「没有这个类别的资格」处理。
  3. 判定只有一个来源。 上面每一个面全部调用这一个函数,谁也不许写「大概等于」的变体。

框架里已经有一层同类边界:线程类别限制哪些模型能看到这个线程的内容(路由层是唯一卡点)。封条是把同一条边界从线程内容扩到技能面——原来是「这份内容不给这个模型看」,现在是「这个技能不摆在这个线程面前」。

拒绝要说清楚是哪一半不合格⌗

被封的 skill,被显式点名时不会回「不存在」。存在性可以泄露,封条保护的是访问,不是名字。拒绝文案单源生成,且分成两种:

情况文案开头读者该做什么
模型本身没打这个标签当前模型 <id> 未被标记具有「X」类别换模型,或给模型加标签
模型有标签,但线程不是这个类别本线程不是「X」类别的线程换线程,或改线程类别

两种都被同一条 skillSealedFor 判成「封」,但该动手的地方完全不同。给一句笼统的「无权限」,等于让人自己去猜是标签问题还是线程问题。文案里还带上了实际值:模型的 provider id、它真实的标签列表、skill 要求的类别、本线程的类别。四个值齐全,任何人都能在不翻日志的情况下判断该改哪一边。

这也是唯一一处故意泄露存在性的地方。反过来,浏览类的入口(索引、introspect("skills") 的列表)是唯一静默的面:不列出来,也不解释。浏览不该当广告位,点名不该撒谎。

封条的单位是组,不是名字⌗

最容易被漏掉的一点:一个 skill 封住的不只是它自己 tools.ts 里的工具。

ToolGroupIndex 里,一个 skill 除了自己的工具,还声明它声称拥有的 MCP namespace(SKILL.md 的 mcp: 字段)。这些 MCP server 的工具归属到这个 skill 所在的组:

skill: <name> ──┬── <自己的工具 A>        (tools.ts 导出)
                ├── <自己的工具 B>        (tools.ts 导出)
                └── mcp: <ns> ──────────  该 server 的全部工具(几打到几百个)

按名字封,等于只封住自己的 A、B 两个工具,MCP 那一整本目录照常可见。按组封,groupOf(tool) 一个映射就覆盖全部。测试里有一条用例专门盯这一点:同一类别下 activate_skill 激活出的工具集合,必须同时包含自己的工具和它声称的 namespace 里的工具——MCP 工具是随 skill 一起被打开、也一起被关上的。

所以封条的执行点挂在 groupOf 上,而不是工具名上:

function sealedToolMessage(seal, groupOf, name): string | null {
  return seal ? seal.refusalFor(groupOf(name)) : null
}

一个真实规模参考:声称拥有 MCP server 的 skill,背后可能是一整本工具目录,几百个工具、几万 token 的 schema。封错单位,泄露的不是一个函数,是一整本手册。

每个面挨个交代⌗

面处理方式
Skills 索引按线程类别过滤后再渲染(静默省略)
introspect("skills")同上,过滤
introspect("skill:<name>")显式拒绝,返回 sealed: true 与完整事实
activate_skillsealedSkill 规则排在任何其它检查之前;命中即进 problems
按精确名直调先查封条,命中返回封条文案(不是 “not available in this turn”)
别名重路由别名解析出的目标工具也查一次
常驻登记 / 激活集恢复 / 历史回扫 / 预激活四个循环各加一处 if (sealed(name)) continue
CLI 桥 tools/list过滤后不列出
CLI 桥 tools/call命中即 isError,正文是封条文案
CLI 桥 activate_skill同一个 sealedSkill 规则

十行表,其中四行是同一个模式重复了四次。这就是全部工作量的形状:没有一个面需要新逻辑,但它们一个都不能漏。

两个不显眼但要紧的选择⌗

① 检查顺序。 planSkillActivation 里,封条判定放在 index.has(skill) 之前:

const seal = rules.sealedSkill?.(skill) ?? null
if (seal != null) { problems.push({ skill, reason: seal }); continue }
if (!index.has(skill)) { /* no skill named "..." + 拼写建议 */ }

反过来写,被封的 skill 会先撞上「没有这个名字」,回一句带拼写建议的「不存在」——不但骗人,还顺手把「存在但你不能用」和「根本不存在」抹成同一件事。封条的存在性泄露是故意的,那就不允许别的分支抢先把它盖掉。

② 整体拒绝,不做部分成功。 activate_skill(['<被封的>', '<另一个正常的>']) 里只要有一个被封,两个都不激活。这不是为封条新造的规则,是既有的「任何一条 problem 就整单不落地」语义。复用既有语义,比给封条加一条特例更安全:特例会被下一个人当成异常。

3. 常驻:同一个字段位,相反的需求⌗

同一个提交里还有第二个字段:availability: resident | deferred。默认 deferred;声明 resident 必须同时给出 availability_reason,缺失或空白在解析期就抛错:

availability: resident requires availability_reason: the real failure case /
one-hop-reach argument that earns the per-turn prompt cost

为什么强制写理由?因为常驻的成本不是抽象的。工具定义进每一轮的 system prompt,是每轮都要付的 token,还会拖慢首字。一个「我觉得常用」就能换来的开关,半年后必然变成全常驻。强制写一句真实的失败案例,是把「为什么要付这个钱」钉在代码旁边。

这条字段和封条看起来对称(都是 frontmatter 一个字段、都影响工具面),实际上在每一点上都相反:

封条 categories常驻 availability
语义限制(谁能用)成本(谁能省一轮搜索)
缺省空 = 不限制缺省 = deferred
失效方向漏一个面 = 多给了访问漏一条路径 = 少给了工具
数据源只有 frontmatter中央清单为主,frontmatter 为增量
能否被别的机制取消不能,硬边界不能,纯加法
违反时的可见症状越权一个看起来毫不相关的测试挂了

最后一行的差别,就是这篇文的第二个提交。

4. 回归:Expected 5, Received 4⌗

第一个提交的想法很自然:既然 SKILL.md 已经能声明东西了,把「哪些工具常驻」也从代码里的中央清单搬到各 skill 的 frontmatter 去——单一事实来源,12 个整组常驻的 skill 一次性迁完,中央清单只剩无归属的 core 工具和混合 skill 的常驻子集。为了证明零行为差异,提交里冻了一份迁移前算出的 89 个工具名的快照,断言迁移后首轮工具面逐名一致。

快照是绿的。全量测试是红的,红在一个八竿子打不着的地方:refusal-quarantine.integration.test.ts,一个跑真实回合路径的集成测试。

它的断言是 hits(...) 等于 5,实际是 4。

那个测试的场景和 skill 毫无关系:它验证的是「内容触发拒答」的处理——某个 provider 在某个上下文段里拒绝了一段历史,框架这一轮跳过它、降级到下一个 provider。为了验证「换段之后应该重新尝试被跳过的 provider」,测试必须在中间制造一次段边界。这个框架里,上下文段的键包含工具面的指纹:工具面变了就是新段。所以测试在中途注册一个新工具:

h.registry.register({ ...echoTool, name: 'get_current_time' }, { silent: true }) // 一个常驻工具加入可见面

迁移前,get_current_time 在中央清单里,注册它 = 常驻工具面 +1 = 指纹变化 = 新段 = 被跳过的 provider 再多收一次请求 = 5。

迁移后,get_current_time 的常驻来自 skills/system/SKILL.md 的 frontmatter。而这个集成测试没有 skill loader——它自己拼装注册表,走的是「无 loader」路径。那条路径读不到任何 SKILL.md:

有 loader:  frontmatter → skills/system/availability: resident → get_current_time 常驻
无 loader:  什么都没有 → 不常驻 → 注册它不改变工具面 → 指纹不变 → 没有新段 → 4

失败信息只有 Expected 5, Received 4。它一个字都没提 skill、frontmatter 或常驻。真正的因果链是:

常驻的声明搬进了 SKILL.md
    → 无 loader 的路径读不到它
        → 某个工具不再常驻
            → 测试里「工具面变化」这个动作不再产生变化
                → 上下文段边界消失
                    → 拒答处理逻辑少做了一次重探
                        → 一个关于拒答的测试挂了

一条路径读不到某个文件,症状却出现在四层之外的另一个子系统里。这就是「只在一部分路径上成立的常驻」的真实代价:它不是「少了点优化」,是同一条代码在不同调用方式下行为不同。而且无 loader 的路径不是边角料,是:集成测试 harness、CLI 的 MCP 桥、重启预演、bootstrap。

如果当时那条测试断言的是「get_current_time 在工具面里」,问题会当场暴露。它断言的是「产生了一个新段」——一个更间接、也更接近真实用法的性质。间接断言抓到了直接断言抓不到的东西。

5. 修复:并集,而且两条路径必须给出同一个答案⌗

第二个提交把方向倒过来,并把语义说清楚(src/runtime/tool-availability.ts 文件头):

常驻是一个并集,中央清单是不变的那一半:
  1. ALWAYS_AVAILABLE_TOOL_NAMES — 不变量。必须常驻的东西列在这里,没有条件;
     frontmatter 不能取消它。
  2. skill frontmatter availability: resident(必须带 reason)— 纯加法,只服务
     新 skill。与中央清单重复无害。
  • 中央清单恢复原 70 项。
  • 12 个 SKILL.md 的 availability 全部撤销;字段保留,作为新 skill 的加法开关。
  • shouldAlwaysLoadTool(tool, skillResident?) = 中央命中 或 skill 常驻集命中。单向:frontmatter 只能加,不能减。

两个事实来源通常是个 bug。这里是有意的,理由写在同一段注释里:中央清单是唯一一个「在任何 loader 状态下都成立」的位置。所以最终形态是「一个不变量 + 一个增量」,而不是「两个平级来源」——shouldAlwaysLoadTool 保持并集语义,谁命中都算常驻,没有优先级、没有冲突解析。

关键在于,光说「并集」不够。上一版也是并集——只是两侧的集合不一样,而没有一个断言要求它们一样。所以测试改成两条不变量:

不变量断言
中央清单本身恰好 70 个名字,逐名等于冻结快照
两条路径同答案有 loader 与无 loader 构建出的常驻面逐名相等

第二条是关键的那条。它不检查任何具体工具是否常驻,只检查一件事:不管有没有 loader,答案必须一样。 只要有一个名字只在 loader 侧常驻,这条就红,逼作者把它同时写进中央清单。这条不变量把「两个来源」从隐患变成了可验证的约束。

同一份测试还钉住了第三条语义,防止「并集」被理解成「整组」:

resident skill 只加它自己的 tools.ts 工具,不带它的 mcp: 命名空间

常驻一个 skill 不等于常驻一整个 MCP server。MCP 工具一律 deferred,理由和封条那节正好对称——它们往往是几百个工具、几万 token 的目录,而激活一次就能全部拿来。常驻它是给自己每轮加一笔巨额账。

6. 两件事为什么不能用一个机制⌗

回到开头那个分类表。封条和常驻在 frontmatter 上是邻居,在工程上是反义词:

封条漏一个面 = 越权。 多出来的访问不会有人报错,只会在某天被人发现「原来那里能进」。所以它的设计目标是穷举:每一个面都要接上,且拒绝要说真话。这里没有「部分生效」这种状态,只有「有没有漏」。

常驻漏一条路径 = 少给了工具。 少一个工具通常也不会报错——模型只是「想不起来用」,或者更常见地,某个依赖它的间接性质默默失效了,比如上面那个段边界。所以它的设计目标是在所有路径上有同一个答案,而不只是「大部分路径上差不多」。

如果合成一个字段——比如 special: [{category: 'X', resident: true}],或者干脆「常驻就是要不限类别」——两套失效方向会被并进同一个开关,然后出现最糟的组合:一个被封的 skill 因为有人想让它常驻,被顺手从封条里放了出来;或者一个必须常驻的工具,因为只在 frontmatter 里声明,在无 loader 的路径上静默消失。前者的诱惑(「反正就这个线程要用」)比后者大得多。

一个字段位、两套机制、三种输入(类别列表 / 常驻开关 / 常驻理由),各自有各自的失败方向,各自有各自的测试。这是这次改动里最不显眼的决定,也是唯一一个我认为换个人写大概率会写错的决定。

7. 还有一处 fail-closed⌗

细节值得单独列:turn-runner 里 skill loader 缺席时,会回落到进程级的 skill loader:

const turnSkills = skillLoader?.getAll() ?? processSkillLoader()?.getAll() ?? []

封条和常驻都不因「管道缺口」而失效。同理,CLI 桥那一侧的封条事实是这么取的:

事实来源取不到时
本轮 providerturn 冻结值null → 视为无标签
模型的类别标签provider 配置的 categories[] → 视为无标签
线程类别任务线程继承 owner 线程的类别,由 task-runner 显式带进 CLI 轮的 turn 事实null → 普通线程

三行全部指向同一个方向:取不到信息就不是授权,是拒绝。 缺一个 provider id 不会让封条失效,只会让被封的 skill 保持被封。

顺带一个容易被忘的点:CLI 桥那一侧的 turn 事实必须重写,不能复用父 turn 的。父 turn 描述的是 owner 的那一轮,不是 CLI 子进程这一轮。所以 category 是显式搬过去的,并在注释里写明理由。跨进程搬事实,漏一个字段的默认后果是「按普通线程处理」——在这里恰好是安全的那一边。

8. 测试证明了什么,没证明什么⌗

规格主要写在四个测试文件里:

文件用例断言
category-seal.test.ts7空列表不封;普通线程永不匹配;文案含模型 id / 真实标签 / 要求类别 / 线程类别;两种文案分支;索引过滤;全部无封条时索引不变
maxclaw-direct-tools.test.ts5普通线程点名被封 skill → 拒绝且无任何工具激活;封条 skill 在常驻/历史回扫/预激活三个面都不出现;按精确名直调返回封条文案;一个被封则整单不落地;匹配类别时激活含 MCP 工具
skill-residency.test.ts6中央清单恰好 70 名;有无 loader 两条路径常驻面逐名相等;resident 只加自有工具不加 MCP;缺 reason 解析期抛错;条件 override 仍然生效;每个 SKILL.md 都能解析
parser.test.ts+2categories 接受单个或列表、非法项抛错;availability 缺省 deferred、resident 必须带非空理由、写法多余的理由会警告

全量 1910 通过 / 0 失败,tsc 干净。

这些测试证明的是:封条的判定是单点且穷举的,常驻的两个来源在两条路径上答案一致。

它们没有证明:

  • 封条等于能力隔离。 它管的是技能面的可见性。同一台机器上的 shell 能做的事,封条不会阻止,也不打算阻止。要那层边界,得在工具执行侧另做,而不是在「模型能不能看见这个 skill」这一层假装做到了。
  • 类别本身打得对。 所有面共用一条判定,但没有断言替人决定某个 skill 该封给谁。这是配置问题,不是代码问题;代码能做的是让配错的结果显式且吵,而不是让它在某个面上悄悄不生效。
  • 入口清单已经不可再增。 这是一份当下完整的清单。下一个新入口(新的桥、新的预演路径、新的恢复机制)加进来时,没有任何编译期机制强迫作者接上封条。

最后一条是最诚实的限制。它没有便宜的解法——能想到的(把封条塞进工具定义本身、让注册表在 get() 时就返回空)都会把「拒绝要说出原因」这件更有价值的事破坏掉。所以这次改动的真实形态是:一个纯函数 + 每一处入口各一个调用点 + 一组用例。它不是不可绕过的机制,它是一份写下来的、可被测试的清单。工程上这往往就是能做到的上限。

9. 一条可以带走的规则⌗

给一个东西加权限边界之前,先数清楚它有几个入口。

  • 如果一个能力有多个发现路径(索引、手册、激活、直调、恢复、跨进程桥),那权限不能挑一个门站岗。剩下的门不需要被绕过,它们本来就是开着的。
  • 权限和成本,即使落在同一个配置对象里,也不该共用一个开关。它们的失效方向相反:权限漏一处是多给了访问,成本漏一处是少给了工具,而后者往往在完全无关的地方以一条莫名其妙的断言失败现身。
  • 「零行为差异」不能靠冻结快照自证。快照只覆盖它被测到的那条路径;真正的不变量是「不同路径给出同一个答案」,那就把这句话本身写成测试。

Expected 5, Received 4 是这次改动里最有价值的一行输出。它跟这次改动的主题一个字都不沾,却是唯一一个真正指出了「你在两条路径上给出了不同答案」的断言。