一次任务类别规则的改动:内容决定染色,派出关系不决定。

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继承派出线程的类别
nullagent 调 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 个测试。