读过不等于染上:子任务的隔离类别该跟着内容走

一次任务类别规则的改动:内容决定染色,派出关系不决定。
1. 先交代背景:什么是「类别线程」⌗
MaxClaw 里一条对话可以带一个类别。带类别的对话,内容只能发给 LLM 配置里打了同名标签的模型。拦截点只有一个,就是 LLM router:每次模型调用必须声明 contentCategory,router 在候选链、钉住的模型、palette、终极兜底池里只保留带这个标签的 provider,一个都没有就抛 CategoryModelError。它不会为了能跑通去用组外的模型。
还有几条连带规则:
- programmable CLI 用它自己的模型,不经过 router,所以类别内容不能交给 CLI。
- 读和流向由同一个谓词判定。类别内容只流向同类别线程和少数全局角色,别的线程看到的是中立状态。
这套东西本身没什么问题。问题出在后台任务从哪里得到自己的类别。
2. 旧规则:派你的人有类别,你就有⌗
改动之前,任务线程的类别由数据库插入触发器 threads_inherit_category() 从父线程照抄。类别对话派出的任务,一律是同类别任务。
这个默认看起来最安全,代价却不小:
| 被继承下来的限制 | 实际后果 |
|---|---|
| 只能用带标签的模型 | 不指定模型就用组里第一个,模型选择基本被锁死 |
| 不能用 programmable CLI | 一类执行方式整个没了 |
| 嵌套任务继续继承 | 限制顺着任务树一路往下传 |
想一个虚构的场景。一条敏感类别的对话聊到一半,需要一个和这个类别毫无关系的小工具界面,于是派了个任务去写。这个任务的 goal 里没有一个字来自敏感材料,它照样被关进了带标签的模型组,也不能交给 CLI。
旧规则看的是谁派的任务。派出者读过敏感内容,所以它派出的任务也算被染过。这种推理会让隔离不断扩散:一条对话只要碰过一次敏感材料,此后派出的所有任务都会被锁住。
3. 新规则:染色跟着内容走⌗
这次改动的判据写在 src/threads/content-category.ts 的注释里:
dye follows the content, not who read it —
a category conversation dispatching a task does not make the task's content that category
一个任务的内容由两部分组成:派出者写进 goal / context 的文字,加上它运行时能读到的东西。派出者有没有类别,并不改变这两部分是什么。所以类别应该在开任务的时候、由知道内容是什么的一方来决定,而不是让触发器在事后照父线程抄一份。
决定逻辑是一个三态函数:
export function taskCategoryChoice(
declared: string | null | undefined,
owner: ThreadCategory,
): { own: boolean; category: ThreadCategory } {
if (declared === undefined) return { own: false, category: owner } // 继承
if (declared === null) return { own: true, category: null } // 普通任务
const category = normalizeCategory(declared)
if (category === null || category !== owner) throw new Error(...) // 只能是本对话自己的类别
return { own: true, category } // 类别任务
}
declared | 谁会这样传 | 结果 |
|---|---|---|
undefined | 内部流程(自动修复等),直接调 startTask | 继承派出线程的类别 |
null | agent 调 task_start 且省略 category | 普通任务 |
| 本对话的类别 | agent 调 task_start 显式声明 | 类别任务 |
| 其他值 | — | 报错 |
细节在 undefined 和 null 的区分上。task_start 工具层写的是 category: p.category ?? null,所以 agent 走工具时永远不会落到「继承」分支,省略就是普通任务。内部流程不传这个参数,照旧继承。理由是谁知道内容谁决定:内部流程的 goal 往往直接引用了派出线程的内容(比如修复流程要读信号的上下文),它的内容本来就带着类别。agent 自己写的 goal 写了什么,它自己最清楚,不需要框架替它猜。
4. 普通任务为什么安全:结构上就读不到⌗
「默认普通」能成立,前提是普通任务确实接触不到类别内容。不能指望它自觉,要靠结构保证。
开任务时,框架会把派出对话里最近的人类消息冻结成引用,交给任务(captureDispatchMaxMessages)。读取这些引用的查询带着 visibleThreadSql(sql, viewer, ...),viewer 是任务自己的。普通任务的 viewer 没有类别,类别线程的行对它不可见:
类别对话 (类别 S)
│ 最近的人类消息 m1..m12
│
├── task_start {category 省略} ──► 普通任务
│ viewer.category = null
│ recentMaxMessages() → []
│ 模型:任意;CLI:可用
│
└── task_start {category: S} ──► 类别任务
viewer.category = S
recentMaxMessages() → [m1..]
模型:只限带 S 标签;CLI:拒绝
测试 a normal task cannot read the category thread that dispatched it 验证的就是这张图:同一条类别对话里先写入一条人类消息,再分别派一个普通任务和一个类别任务。普通任务读到 [],类别任务读到原文。
结果回流方向也对得上。普通任务的结果没有类别,流回类别对话没有问题。反方向,即类别内容流向普通线程,仍然由 assertDeliveryFlow 拦截。
CLI 桥也一起改了。以前桥的 turn 事实里带着 owner 线程的类别,现在写死成 category: null。原因是类别任务会拒绝 CLI,能走到 CLI 的只有普通任务,这一轮内容本来就没有类别。继续沿用 owner 的类别,等于在错误的地方贴了一个正确的标签。
5. 声明的边界:只能声明自己有的类别⌗
显式声明不是开放的口子,有两层检查。
应用层(taskCategoryChoice 和 createIdentity):
- 值必须等于派出对话自己的类别,否则报「不是本线程的类别」。
- 没有类别的对话传任何值都报错,提示「省略即派普通任务」。
- 类别任务指定了不带标签的模型,拒绝。
- 类别任务要 CLI,拒绝。
- 类别任务不指定模型,用组里第一个带标签的模型。
数据库层(迁移 task_category_own)新增一列 threads.thread_category_own boolean NOT NULL DEFAULT false,触发器改成:
IF parent_category IS NOT NULL THEN
IF NEW.thread_category IS NOT NULL AND NEW.thread_category <> parent_category THEN
RAISE EXCEPTION 'thread % cannot have category % under a thread of category %', ...;
END IF;
IF NOT NEW.thread_category_own THEN
NEW.thread_category := parent_category;
END IF;
END IF;
own = true 只放开了一件事:允许写入的值(包括 NULL)保留下来,不被父线程的值覆盖。「子线程不能声明父线程没有的类别」这条约束仍然在数据库里,应用层出 bug 也越不过去。分支、fork、clone、side chat 都不设这一列,继续继承。真正拿到自主权的只有任务线程。
对应的测试在 src/dispatch/task-runner.category.test.ts,一共 8 个用例:
| 用例 | 断言 |
|---|---|
| 默认普通 | 任务线程类别为 NULL,可以用不带标签的模型 |
| 声明类别 | 任务线程类别为 S,默认模型是带标签的那个 |
| 类别任务配不带标签的模型 | 拒绝 |
| 声明别的类别 | 拒绝;声明本类别成功 |
| 普通对话声明类别 | 拒绝 |
| 普通任务读派出对话 | 读到 [];类别任务读到原文 |
| 提醒句 | 只在类别对话里派普通任务时出现 |
| 内部流程 | 不传 category,继承派出线程类别 |
fixture 没有用桩:假 agent 的 getRouter() 返回真实的 LLMRouter,配置里有一个不带标签的模型和一个带标签的模型。「组里第一个」「标签不符」这些判断走的都是生产代码里的 router。
6. 参数为什么不按线程裁剪⌗
一个直觉上更干净的做法:只给类别对话的 task_start 暴露 category 参数,普通对话里根本看不到。
没这么做,原因是工具定义在线程之间共享,而且一代 session 内的 tools 数组是冻结的。按回合改 schema,这个数组就会分叉。所以参数始终留在 schema 里,用不上的场合靠运行时报错挡住:普通对话传了就报错,错误信息直接说明正确做法。
另一个方向的提示是一句 reminder。在类别对话里省略 category 派任务,结果里会多一句:
这是普通任务:goal / context 里不要带本类别的内容;要派与本类别相关的活,传 category。
它只在这一种情况下出现。普通对话不会有,显式声明了也不会有,测试逐一检查过这三种情况。
7. 代价和残留风险⌗
这次改动没有消除风险,而是把风险换到了另一个位置,这一点要说清楚:
| 旧:按派出者继承 | 新:按内容声明 | |
|---|---|---|
| 主要失效模式 | 过度染色:无关任务被锁进带标签的模型组 | 漏染:派出者把类别内容抄进普通任务的 goal |
| 能否靠结构挡住 | 能,但代价是能力损失 | 「读原文」能挡住;「抄进 goal」挡不住 |
| 兜底手段 | 无需 | 提醒句、工具描述、技能手册 |
普通任务读不到派出对话,这是结构保证。但 goal 是派出者自己写的文字,框架没法判断里面有没有敏感材料。新设计默认相信派出者知道自己写了什么,只在最容易出错的时刻(类别对话里省略声明)提醒一句。
我认为这个交换是值得的。过度染色是必然发生的,类别对话派出的每一个无关任务都要付代价。漏染需要派出者明知 goal 里有敏感内容还不声明,而且发生的位置固定、可以提醒。前者是结构性的税,后者是可以针对性防范的失误。
还有一处刻意保留的保守选择:set_thread_category 仍然会移动整棵子树。显式重新给一条对话定类别时,它底下的普通任务也会一起被改。这属于人做的显式决定,宁可多染,不必在这里讲究粒度。
封存级别这次没动,任务照旧继承。两者是正交的机制,这次只纠正了类别这一项。
8. 一句话⌗
隔离标签应该回答「这段内容是什么」,而不是「这段内容是谁派来的」。默认不染色,需要时显式声明,声明只能收窄、不能扩大,数据库再兜一层底。这样隔离守得住,也不会顺着任务树把整棵树的模型和工具都锁住。
整个改动 16 个文件,+315 / −25,新增一列、一个触发器分支、一个三态函数、8 个测试。