一个字段位,两种漏法:技能封条和常驻开关为什么必须分开

一次把「类别封条」和「常驻开关」塞进同一份 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)
}
三件事在这四行里定死了:
- 空列表不是「封给所有人」,是无限制。 缺省常见值的两种选法(空 = 全封 / 空 = 全开)只有一种不会让所有既有 skill 突然消失。
- 普通线程(类别为
null)永远不匹配任何封条。 封条是白名单,不是黑名单;没打标签的线程按「没有这个类别的资格」处理。 - 判定只有一个来源。 上面每一个面全部调用这一个函数,谁也不许写「大概等于」的变体。
框架里已经有一层同类边界:线程类别限制哪些模型能看到这个线程的内容(路由层是唯一卡点)。封条是把同一条边界从线程内容扩到技能面——原来是「这份内容不给这个模型看」,现在是「这个技能不摆在这个线程面前」。
拒绝要说清楚是哪一半不合格⌗
被封的 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_skill | sealedSkill 规则排在任何其它检查之前;命中即进 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 桥那一侧的封条事实是这么取的:
| 事实 | 来源 | 取不到时 |
|---|---|---|
| 本轮 provider | turn 冻结值 | null → 视为无标签 |
| 模型的类别标签 | provider 配置的 categories | [] → 视为无标签 |
| 线程类别 | 任务线程继承 owner 线程的类别,由 task-runner 显式带进 CLI 轮的 turn 事实 | null → 普通线程 |
三行全部指向同一个方向:取不到信息就不是授权,是拒绝。 缺一个 provider id 不会让封条失效,只会让被封的 skill 保持被封。
顺带一个容易被忘的点:CLI 桥那一侧的 turn 事实必须重写,不能复用父 turn 的。父 turn 描述的是 owner 的那一轮,不是 CLI 子进程这一轮。所以 category 是显式搬过去的,并在注释里写明理由。跨进程搬事实,漏一个字段的默认后果是「按普通线程处理」——在这里恰好是安全的那一边。
8. 测试证明了什么,没证明什么⌗
规格主要写在四个测试文件里:
| 文件 | 用例 | 断言 |
|---|---|---|
category-seal.test.ts | 7 | 空列表不封;普通线程永不匹配;文案含模型 id / 真实标签 / 要求类别 / 线程类别;两种文案分支;索引过滤;全部无封条时索引不变 |
maxclaw-direct-tools.test.ts | 5 | 普通线程点名被封 skill → 拒绝且无任何工具激活;封条 skill 在常驻/历史回扫/预激活三个面都不出现;按精确名直调返回封条文案;一个被封则整单不落地;匹配类别时激活含 MCP 工具 |
skill-residency.test.ts | 6 | 中央清单恰好 70 名;有无 loader 两条路径常驻面逐名相等;resident 只加自有工具不加 MCP;缺 reason 解析期抛错;条件 override 仍然生效;每个 SKILL.md 都能解析 |
parser.test.ts | +2 | categories 接受单个或列表、非法项抛错;availability 缺省 deferred、resident 必须带非空理由、写法多余的理由会警告 |
全量 1910 通过 / 0 失败,tsc 干净。
这些测试证明的是:封条的判定是单点且穷举的,常驻的两个来源在两条路径上答案一致。
它们没有证明:
- 封条等于能力隔离。 它管的是技能面的可见性。同一台机器上的 shell 能做的事,封条不会阻止,也不打算阻止。要那层边界,得在工具执行侧另做,而不是在「模型能不能看见这个 skill」这一层假装做到了。
- 类别本身打得对。 所有面共用一条判定,但没有断言替人决定某个 skill 该封给谁。这是配置问题,不是代码问题;代码能做的是让配错的结果显式且吵,而不是让它在某个面上悄悄不生效。
- 入口清单已经不可再增。 这是一份当下完整的清单。下一个新入口(新的桥、新的预演路径、新的恢复机制)加进来时,没有任何编译期机制强迫作者接上封条。
最后一条是最诚实的限制。它没有便宜的解法——能想到的(把封条塞进工具定义本身、让注册表在 get() 时就返回空)都会把「拒绝要说出原因」这件更有价值的事破坏掉。所以这次改动的真实形态是:一个纯函数 + 每一处入口各一个调用点 + 一组用例。它不是不可绕过的机制,它是一份写下来的、可被测试的清单。工程上这往往就是能做到的上限。
9. 一条可以带走的规则⌗
给一个东西加权限边界之前,先数清楚它有几个入口。
- 如果一个能力有多个发现路径(索引、手册、激活、直调、恢复、跨进程桥),那权限不能挑一个门站岗。剩下的门不需要被绕过,它们本来就是开着的。
- 权限和成本,即使落在同一个配置对象里,也不该共用一个开关。它们的失效方向相反:权限漏一处是多给了访问,成本漏一处是少给了工具,而后者往往在完全无关的地方以一条莫名其妙的断言失败现身。
- 「零行为差异」不能靠冻结快照自证。快照只覆盖它被测到的那条路径;真正的不变量是「不同路径给出同一个答案」,那就把这句话本身写成测试。
Expected 5, Received 4 是这次改动里最有价值的一行输出。它跟这次改动的主题一个字都不沾,却是唯一一个真正指出了「你在两条路径上给出了不同答案」的断言。