被拒答的任务,不要再追问一句:拒答是结局,不是故障

一次改动复盘:把 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_message | assistant 文字 | 同左 |
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. 可以带走的规则⌗
把拒答当错误,系统会本能地想重试;把它当结局,问题就变成了"怎么体面地结束”:
- 停在原地:复用已有的停止语义,不清理现场,不新造状态。
- 回执只说位置:provider、类目、步数、最后工具名;任何从触发字节里派生的东西都不往上游传。
- 结局要持久:续派、重启续跑、结果回收都从同一条落库记录判断,而不是各自记一个标志。
换模型继续答,看起来是韧性;在内容拒答这件事上,它只是把同一个问题换个收件人。