Try-Best 检测与 Hand-off
用三条规则识别原地打转并暂停当前轮,再让用户选择交给 Codex CLI、Claude Code CLI 或原模型换思路,并核对设计文档里哪些模式已落地。
编码智能体陷入低收益循环时往往不会自己停:同一处改了又改,同一条失败命令原样重跑,token 一轮轮烧掉,进展却没有。上游 OpenCode 的处理器只有一个很窄的 doom_loop 检测:当前消息最后 3 个部件都是同名、同参数的工具调用,才询问用户是否继续(见流处理器与工具调用生命周期)。MiMo 的处理器里已经没有这段逻辑,默认权限表里只剩一行 doom_loop: "ask"(packages/opencode/src/agent/agent.ts:111);复读与重试方面的其他对策放在下一篇。
MiMo 新增的 Try-Best 检测器看的是更宽的“形状”:相似的编辑、反复失败的命令、没有进展的连续动作。命中后它既不询问也不自动纠正,而是暂停当前这一轮,让用户决定换一个 harness(Codex CLI 或 Claude Code CLI)接手,还是让原模型换思路。仓库的设计文档 docs/harness/Mix of Harness and Hand-off.md 把这条链路叫作 Mix of Harness 的 Fallback 模式。
怎么用
默认关闭。设置环境变量 MIMOCODE_ENABLE_TRY_BEST_HANDOFF=true(或 1)后才启用检测、暂停与对话框,比如 MIMOCODE_ENABLE_TRY_BEST_HANDOFF=1 mimo(packages/opencode/src/flag/flag.ts:278)。
阈值在配置 experimental.try_best 里调(packages/opencode/src/config/config.ts:418),默认值写在检测器顶部(packages/opencode/src/session/try-best-detector.ts:5):
| 配置键 | 默认 | 含义 |
|---|---|---|
edit_window | 12 | 参与比对的最近编辑事件数 |
edit_similarity | 0.8 | 判为近似编辑的 Jaccard 阈值,须严格大于 |
edit_matches | 2 | 需要几次近似匹配才暂停 |
action_streak | 4 | 连续几次无进展的同类动作才暂停 |
失败命令的重试上限 TRY_BEST_BASH_RETRIES 固定为 3,没有配置项。命中后 TUI 弹出标题为“Try-best loop detected — turn paused”的对话框(packages/opencode/src/cli/cmd/tui/i18n/en.ts:405)。想看到交接选项,本机 PATH 上还得有 codex 或 claude 命令,原因见后文。
三条规则
入口是 TryBestMonitor.consume(try-best-detector.ts:151):只处理已经结束(完成或失败)的工具部件,edit、write、apply_patch、multiedit 等编辑类工具进编辑分支,bash 进命令分支,其余工具一律忽略。
| 规则 | 看什么 | 默认触发条件 |
|---|---|---|
edit_repeat | 同一文件的 diff 相似度 | 最近 12 次编辑里,同一路径已有 2 次与本次相似度大于 0.8 |
bash_retry | 归一化后同一条命令的失败次数 | 累计失败 3 次,其间没有成功的编辑 |
action_streak | 同类动作有没有带来进展 | 连续 4 次编辑,或连续 4 次验证命令没有进展 |
编辑重复:normalizeDiff(try-best-detector.ts:86)只保留 diff 里的增删行,去掉文件头并压缩空白;shingleSet 把结果切成相邻 3 个词一组的集合,jaccard 取交集与并集之比。每次编辑都和窗口内同一路径的旧编辑逐一比较,超过阈值算一次匹配,匹配数达到 edit_matches 就报告,证据里的 count 是匹配数加一,也就是“第 3 次近似编辑”。窗口由所有文件共用,超过 12 条丢弃最早的。
命令重试:命令先经 normalizeCommand(try-best-detector.ts:112)抹掉易变的部分,/tmp/ 下的路径换成 <TMP>,6 位以上的数字换成 <NUM>,--seed 连同参数值换成 <SEED>,再压缩空白。工具报错,或者元数据里的 exit 不为 0,都算失败(try-best-detector.ts:207)。计数按归一化后的命令分开记,失败一次加一,到 3 就报告。计数不要求紧挨着:中间读文件、跑别的命令都不清零,只有一次成功完成的编辑会清空全部失败计数(try-best-detector.ts:167),命令自己成功一次则只清它自己的。只有验证类命令会比较输出:这次的输出经 normalizeResult 抹掉临时路径、长数字和耗时,超过 2000 字符只留首尾,若与上次不同就算有进展,计数回到 1。
无进展动作:验证类命令由正则 VERIFY_COMMAND 识别,覆盖 bun、npm、pnpm、yarn 跑 test、typecheck、lint、build(除直接的 test 外多要经 run),cargo 的 test、check、clippy、build,make 的 test、check、lint、build,以及 pytest、go test 和 tsc(try-best-detector.ts:45)。计数在 trackAction(try-best-detector.ts:225):
private trackAction(next: Action, tool: string, command?: string): TryBestIncident | undefined {
if (next.progress || this.action?.kind !== next.kind) {
this.action = next
this.streak = next.progress ? 0 : 1
return undefined
}
this.streak++
if (this.streak < (this.options.action_streak ?? TRY_BEST_ACTION_STREAK)) return undefined
return {
reason: "action_streak" as const,
evidence: { tool, command, count: this.streak, action: next.kind },
}
}编辑一律记为无进展,验证命令成功或输出有变化才算进展;非验证类的 bash 命令、读文件和搜索既不计数也不打断计数。于是连改 4 次文件、中间没跑过一次测试或构建,也会触发 action_streak,哪怕改的是 4 个不同文件、diff 毫不相似,测试里有一条专门的用例(packages/opencode/test/session/try-best-detector.test.ts:145)。
监视器缓存在模块级的 Map 里,键是 sessionID:agentID(try-best-detector.ts:245),每一步取出时重新套用配置。整份状态只在命中后由 reset() 清空;resetMonitor 和 resetAllMonitors 只有测试在调用,所以编辑窗口、失败计数和动作连击都不会因为用户开始新一轮提问而归零。
命中之后:暂停这一轮
处理器在每个工具部件完成或失败时调用 detectTryBest(packages/opencode/src/session/processor.ts:371、processor.ts:402);开关关闭时监视器是 undefined,检测什么也不做(processor.ts:262)。命中后的处理(processor.ts:267):
const detectTryBest = Effect.fn("SessionProcessor.detectTryBest")(function* (part: MessageV2.ToolPart) {
if (ctx.blocked) return
const incident = tryBest?.consume(part)
if (!incident) return
tryBest?.reset()
ctx.blocked = true
const detail = describeTryBest(incident)
yield* session.updatePart({
id: PartID.ascending(),
messageID: ctx.assistantMessage.id,
sessionID: ctx.sessionID,
type: "text",
text: `Try-best loop detected; this turn was paused. ${detail}`,
synthetic: true,
metadata: {
origin: {
kind: "try_best",
providerID: input.model.providerID,
modelID: input.model.id,
incident,
},
},
time: { start: Date.now(), end: Date.now() },
})随后发布 session.try_best.detected 事件(定义在 packages/opencode/src/session/session.ts:299,带 agentID、模型与完整证据),再发一个 metrics.try_best_detected 指标事件,后者只在开启 MIMOCODE_ENABLE_ANALYSIS 时上报(packages/opencode/src/metrics/subscriber.ts:13)。
ctx.blocked 为真后,事件流在当前事件之后被 Stream.takeUntil 截断(processor.ts:830),本步返回 stop(processor.ts:908),runLoop 随即 break,会话回到空闲。那段 synthetic 文字留在 assistant 消息里,而转换历史时 assistant 的文本部件都会带上(packages/opencode/src/session/message-v2.ts:966),所以下一轮模型能看到自己被暂停过。
和 Goal 有一处交互要留意:break 出口前有 goalGate(packages/opencode/src/session/prompt.ts:4846)。会话若同时挂着目标,暂停后裁判照样会被请来,判为未达成就写入提醒接着跑,暂停实际上被覆盖,对话框却照常弹出。
TUI 对话框
TUI 同时监听 session.try_best.detected 事件和带 origin.kind 为 try_best 的 message.part.updated(packages/opencode/src/cli/cmd/tui/app.tsx:1305),两条路径互为兜底,同一份证据 2 秒内只弹一次(app.tsx:1205)。用户当时不在该会话页面,就只弹一条 toast 说会话已暂停,不出对话框(app.tsx:1207)。
候选 harness 按当前模型排除“同类”(packages/opencode/src/cli/cmd/tui/util/handoff.ts:50):
export function handoffTargets(providerID: string, modelID: string): HandoffTarget[] {
const current = `${providerID}/${modelID}`.toLowerCase()
const codex = providerID.toLowerCase() === "openai" || /(?:gpt|codex)/.test(current)
const claude = /(?:anthropic|claude)/.test(current)
if (codex) return ["claude"]
if (claude) return ["codex"]
return ["codex", "claude"]
}提供方是 openai 或者“提供方/模型”里含 gpt、codex 的,只给 Claude Code CLI;含 anthropic、claude 的只给 Codex CLI;其余模型两个都给。候选还要过一道命令列表:sync.data.command 里得有名为 codex 或 claude-code 的命令,否则该选项直接不显示(app.tsx:1256)。列表最后固定追加一项“Continue with 模型名”。
所有选项都用 promptAsync 往原会话发下一轮消息,模型不变:
- 交接:发一条 synthetic 文本,内容由
formatHarnessReminder生成(见下)。 - 继续:发一条普通文本(
app.tsx:1287),要求模型放弃原做法、检查工作区现状、解释为什么卡住,再换一个实质不同的策略,不要原样重复同样的编辑或命令。
交接提醒(handoff.ts:59):
export function formatHarnessReminder(input: { target: HandoffTarget; detail: string }) {
const skill = input.target === "codex" ? "codex" : "claude-code"
const harness = input.target === "codex" ? "Codex CLI" : "Claude Code CLI"
return [
"<system-reminder>",
`Try-best loop detection paused the previous turn: ${input.detail}`,
`The user explicitly selected and authorized the ${harness} harness to take over the unfinished work.`,
`You MUST load and follow the \`${skill}\` skill now and invoke ${harness} as the primary executor that solves the user's original problem.`,
`The selected ${harness} must perform the investigation, implementation, fixes, and validation. Do not substitute another harness, merely ask it for advice, or continue solving the task yourself.`,
"Give the selected harness the complete user goal, relevant workspace state, the failed approach, and all remaining validation requirements. Do not include credentials, secrets, or unrelated private data.",
`Stay in this CLI and supervise ${harness} until it completes or reaches a concrete blocker. If follow-up work is needed, send it back to the same harness instead of taking over yourself.`,
"Inspect the harness result and workspace changes, ensure its validation is complete, and report the final outcome to the user. Do not stop after merely launching the harness.",
"</system-reminder>",
].join("\n")
}交接之后控制权仍在 MiMo 会话里:原模型加载技能,把目标、现场和失败经过交给外部 CLI,盯着它跑完并验收,外部 CLI 只是被拉起的子进程。这里的 harness 指外部命令行智能体,和会话上决定工具集的 harness 字段(类型 HarnessMode,packages/opencode/src/tool/gpt.ts:3)不是一回事。另外,对话框用的 HandoffDetection 类型没有 agentID,发消息时也不带;subagent 模式的子智能体与主会话共用 sessionID,它触发的暂停也按这个 sessionID 弹框。
两个技能:装了命令才有
交接依赖两个内置技能。codex 技能要求用非交互的 codex exec 而不是交互式 TUI(packages/opencode/src/skill/builtin/.bundle/codex/SKILL.md:12),日常无人值守改代码推荐 --sandbox workspace-write 加 --ask-for-approval never,--yolo 只许在隔离的一次性环境里用。claude-code 技能以 -p 打印模式为默认,另有 tmux 交互与 --bg 后台模式,它的描述限定只在用户明确要求 Claude Code 时使用(packages/opencode/src/skill/builtin/.bundle/claude-code/SKILL.md:3),交接提醒开头声明“用户已明确选择并授权”,正好满足这个前提。两个工具本身的用法见 Codex 中文手册 与 Claude Code 中文手册。
“装了命令才有”的判断在技能解包时(packages/opencode/src/skill/builtin/extract.ts:20):
const REQUIRED_COMMANDS = new Map([
["claude-code", "claude"],
["codex", "codex"],
])
export function isBuiltinSkillInstalled(name: string, resolve = which) {
const command = REQUIRED_COMMANDS.get(name)
return !command || resolve(command) !== null
}which 除了 PATH 还会查 MiMo 自己的 bin 目录(packages/opencode/src/util/which.ts:5)。extractBuiltinBundle(extract.ts:105)把内置技能解包到数据目录下按版本区分的 builtin_skills 目录,找不到对应命令的技能不写入,已经存在的目录会被删除;解包标记里记着启用清单,装上或卸掉 codex、claude 后清单变化,下次加载技能时就会重新解包。技能随后登记成同名命令(packages/opencode/src/command/index.ts:264),TUI 过滤候选时查的正是这个命令名。所以实际依据是“有没有叫 codex、claude-code 的命令”:设置 MIMOCODE_DISABLE_BUILTIN_SKILLS 会让两个交接选项一起消失;反过来,用户自己装了同名技能,即便没有对应 CLI,选项也会出现。
设计文档落地了多少
设计文档描述了 Single、Fallback、Pipeline、Parallel Competition、Debate / Review 五种编排模式,并称 Fallback 为默认。代码里落地的只有 Fallback 中“一轮之内实时检测、暂停、交接”这一段;在 packages/ 下搜不到 Pipeline 用的 HandoffPacket,也找不到让 Codex CLI 与 Claude Code CLI 并行竞争或交叉评审的代码。文档 §3.2 列出的其他停滞信号(长时间没有文件变化、压缩次数过多、成本超过预算 80%、缺少要求的产物),以及 §4.1 的“进度与消耗脱钩”“迷路模式”“过早宣告完成”,检测器都没有实现;其中“过早宣告完成”在 MiMo 里对应的是用户主动设置的 Goal 裁判,不归检测器管。
几处具体不一致:
| 设计文档 | 代码 |
|---|---|
§5.1 环境变量默认 true,设为 false 或 0 关闭 | 默认关闭,只有 true 或 1 才开启(flag.ts:278) |
| §4.4 未注册技能的选项会“灰掉” | 直接不显示,§5.3 自己也写的是隐藏 |
§4.5 选“保留当前模型”时不发提醒,只解除 ctx.blocked | 发一条普通用户消息要求换策略(app.tsx:1287);ctx.blocked 属于每一步新建的处理器上下文 |
| §4.3 part 是事实来源,重新拉起会话也能扫描 part 恢复 | TUI 只解析实时推送的 message.part.updated,打开历史会话不会补弹对话框 |
§4.2 bash_retry 要求失败输出没有变化 | 只有验证类命令比较输出,计数也不要求紧挨着 |
下一篇:复读检测与重试——MiMo 怎样发现模型在文字和工具调用上原地复读,以及请求失败后怎样重试。