一次改动复盘:把 provider 拒答从「可以绕过去的错误」改成「任务的一种结局」,以及为此必须关上的三扇门。

1. 绕过去的三种姿势⌗

一个多模型 agent 框架里,后台任务跑到一半,provider 返回了内容政策拒答(Anthropic 的 stop_reason: refusal,或 OpenAI 的 cyber_policy / content_filter)。框架手里有好几个"让它继续"的办法:

姿势做法实际效果
原地重试同一 provider 再发一次历史逐字节重放,触发内容还在前缀里,必然再拒
换模型接力本段跳过该 provider,路由到下一个候选把被一家判定不处理的内容,交给另一家处理
追加消息续派父线程 task_message 说一句"继续"新一轮带着同一份历史开跑,又是第一种

前一篇(等十五分钟,它还是会拒绝)处理的是第一种:重探要有尽头。但那次修完之后,用尽重探的出口仍然是第二种——本段跳过它,换下一个模型继续。

这次改动的判断是:对后台任务而言,这三种都不是补救,而是换着花样把同一份内容再送一次。拒答是 provider 对这段请求做出的裁决,它是这次作答的结局,不是一个等待被绕过的故障。

2. 新的处置矩阵⌗

改动后,src/runtime/turn-runner.ts 里 DirectLoopRefusalError 的分支先判两件事:

const fixTask = control?.usageSource?.startsWith(FIX_USAGE_PREFIX) === true
const task = taskType === 'subagent' || executionScope === 'worker'
...
if (task && !fixTask) return await stopRefusal(history)

注意 task 的定义不只看路由类型:用 chat 路由、但执行范围是 worker 的任务也算任务。集成测试 it.each(['chat', 'subagent']) 两种都覆盖了。

三类线程的路径于是分开:

线程类型第一次拒答隔离用尽后换模型
普通后台任务立即停,不走隔离—从不
非任务线程(对话)先隔离未被接受的增量,同 provider 重试停工 + 一条 critical 通知从不
自动修复任务隔离、重试段内跳过、重探、冻结(旧路径)保留

为什么任务连隔离都不试?隔离的前提是"被拒的是一段刚进来、还没被接受的外部文本,换个中性形态就能继续"——收件箱摘要、跨线程事件这类。任务的增量绝大部分是它自己发起的工具调用和结果,那就是它在干的活本身;把工具结果换成占位符再重试,得到的是一个在空白现场上继续推理的任务,比停下更糟。

为什么对话线程隔离用尽也不换模型?测试 a non-task stops when quarantine is exhausted 写得很直白:

expect(hits(CLAUDE_URL)).toBe(4)
expect(hits(GPT_URL)).toBe(0)
expect(events.some(e => e.kind === 'fallback')).toBe(false)

旧路径在这里会去打第二家。新路径停下,通知里写的恢复办法是由人来换模型,或用 fresh_start / 新对话开一个不带旧历史的开头。下一条普通消息进来,测试断言它照样被拒(hits(CLAUDE_URL) 变成 5,第二家仍是 0)——框架不假装这条线程已经好了。

自动修复是唯一的例外,原因也写在测试名里:它的候选切换循环要读 refusalsInThread 的拒答证据来决定下一个候选,“换个模型试试"就是它的本职工作。为此 refusalView 的段状态只对 FIX_USAGE_PREFIX 开头的用量来源生效,其余线程每回合都按全新视图处理。

3. 停下:复用 stopped,不新建状态⌗

一种自然的写法是给任务加一个 refused 终态。改动没这么做,而是复用已有的 stopped,只在结局上挂一个标记:

| { kind: 'stopped'; durationMs: number; response: string; refused?: true }

好处是下游所有理解 stopped 的地方——task_wait 的收齐、结果投递、settlement——不需要学一个新词。task_wait 照常只被唤醒一次,投递状态记为 cancelled,测试 refused tasks settle task_wait once as stopped 校验了 task-result 事件恰好一条、task-wait-done 恰好一条。

“停"的语义也刻意保守:

const response = parked || result.refusalStopped
  ? result.response
  : result.response + await closeUnattendedShells(msgId, identity)

正常结束的任务会收尾它留下的后台 shell;被拒答停下的不收。测试 a subagent stopped by a safety refusal leaves its running shell untouched 起一个顽固的后台进程,断言任务停下后 PID 仍然存活。理由很简单:拒答针对的是下一次模型调用,不是已经在跑的构建或下载。现场与产物原样留在磁盘上,接手的新任务可以直接用。

4. 回执:只说"停在哪”,不说"为什么”⌗

停下之后父线程要知道发生了什么。危险恰恰在这里:如果回执里带着触发拒答的工具参数或输出,父线程的下一次请求就会带着它——拒答会顺着结果事件传染到派出它的线程。

所以回执的正文由 refusalStopNote 生成,只有这些字段:

interface RefusalStop {
  providerId: string
  category: string | null
  round: number
  tool?: string
}

渲染出来大致是:

任务因 <provider> 的 cyber 拒答停下。停在第 3 步(最后工具:read_file)。
现场与产物保持原样;触发内容不转交。不能用 task_message 续派;
请用 task_start 换模型开新任务,可引用旧任务的产物路径/分支。

<本轮此前 assistant 自己写的文字>

后面拼接的只有本轮 assistant 自己的文字——那是模型已经说出口、被 provider 接受过的内容。工具参数、工具结果、用户输入都不进来。

task_stop 抢在拒答前后到达时,回执走另一条路 task-stop-evidence.ts,同样要剥干净:

证据字段正常停止拒答停止
last_messageassistant 文字同左
last_tool.tool工具名同左
last_tool.args_firstline参数首行[拒答触发内容不转交]
last_tool.outcome结局[不转交]
changed_paths从工具结果里提取的写入路径[]

最后一行容易被忽略:写入路径看起来无害,但它是从工具结果里解析出来的,而工具结果可能正是触发内容。宁可让接手者自己去看磁盘,也不从可疑字节里提取任何东西。测试用的是一个固定字符串 trigger,断言整个 JSON 序列化后都不含它。

另外两个细节:截断不再附带"去任务线程读全文"的引导(task-results-body.test.ts 断言不含 message_history_read),因为全文就是触发现场;而触发内容本身留在任务线程里不做隔离(测试断言 tool 行的 quarantine 为 undefined),现场保持可审计。

5. 关门:续派、重启续跑、重复投递⌗

结局要成立,就得让所有"再来一轮"的入口都认它。改动把拒答记成一条持久消息行,source_meta.kind = 'provider-refusal-stop',modelVisible: false、不进检索;之后所有入口都查这条行,而不是查内存标志:

                  ┌─ executeTurn ────────┐
task_message ────▶│                      │
task_message(同步)▶ assertTaskNotRefused ├─▶ 有 provider-refusal-stop 行?
                  └──────────────────────┘        │ 是 → 抛错:用 task_start 换模型
重启续跑 resumeTaskTurnForRestart ───────────────▶│ 是 → 清掉 in-flight,按 cancelled 回收
主机启动 initTaskRunner ─────────────────────────▶│ 是 → 活动表直接记 stopped
结果回收 recoverTaskResults ─────────────────────▶│ 是 → 投递 refused 结局,不投"重启中断"

为什么一定要持久化:拒答停下和进程重启可能撞在一起。测试 a persisted refusal is recovered as cancelled, never restarted or redelivered 构造了这个现场——拒答行已落库,但任务的 in-flight 标记还在,然后调用重启续跑。断言是模型调用次数一次都没增加,owner 恰好收到一条投递,内容是安全回执,结果状态 cancelled。另一条测试在"重启后"的新 world 里再 task_message,断言抛出含"换模型"的错误、calls 长度为 0。

续派被拒时的报错文字和 task_stop、task_status 返回的提示是同一句话:去 task_start 换模型开新任务,引用旧任务的产物路径或分支。task_status 也会带上 refusal 字段和停止原因,父线程不用翻历史就知道为什么。

6. 这样做付出了什么⌗

代价说明
误拒也会停分类器误判时,任务同样停下;恢复要父线程开一个新任务,旧上下文不能继承
对话线程需要人隔离用尽后不再自动换模型,必须有人换模型或开新一代
每个入口多一次查询assertTaskNotRefused 每次查消息表;任务续派不是热路径,可接受
新任务看不到触发内容有意为之:新任务要么从磁盘现场重新读,要么由人决定交给谁

第一条值得多说一句。“误拒就该换个模型"听起来很合理,但框架没法在运行时区分误拒和真拒——能区分的话 provider 自己早就不拒了。在不能区分的前提下,自动绕过的行为对两种情况是一样的;而把决定交回给派出任务的一方,至少让"换给谁、带不带旧历史"成为一个显式的选择,而不是路由表里的下一行。

7. 可以带走的规则⌗

把拒答当错误,系统会本能地想重试;把它当结局,问题就变成了"怎么体面地结束”:

  1. 停在原地:复用已有的停止语义,不清理现场,不新造状态。
  2. 回执只说位置:provider、类目、步数、最后工具名;任何从触发字节里派生的东西都不往上游传。
  3. 结局要持久:续派、重启续跑、结果回收都从同一条落库记录判断,而不是各自记一个标志。

换模型继续答,看起来是韧性;在内容拒答这件事上,它只是把同一个问题换个收件人。