# Try-Best 检测与 Hand-off

> 用三条规则识别原地打转并暂停当前轮，再让用户选择交给 Codex CLI、Claude Code CLI 或原模型换思路，并核对设计文档里哪些模式已落地。

- 作者：David（道雾轩）
- 专栏：MiMo Code 源码解读（https://daiw.org/manual/mimo-code.md）
- 最后更新：2026-09-17
- 原文：https://daiw.org/manual/mimo-code/try-best-handoff
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

编码智能体陷入低收益循环时往往不会自己停：同一处改了又改，同一条失败命令原样重跑，token 一轮轮烧掉，进展却没有。上游 OpenCode 的处理器只有一个很窄的 doom_loop 检测：当前消息最后 3 个部件都是同名、同参数的工具调用，才询问用户是否继续（见[流处理器与工具调用生命周期](https://daiw.org/manual/opencode/processor)）。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`）：

```ts
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`）：

```ts
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](https://daiw.org/manual/mimo-code/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`）。

```mermaid
flowchart TD
  A["工具部件结束"] --> B{"consume 命中规则"}
  B -- "否" --> A
  B -- "是" --> C["reset 并置 ctx.blocked"]
  C --> D["写入 synthetic 文本并发布事件"]
  D --> E["本步返回 stop，runLoop 退出"]
  D --> F["TUI 弹出对话框"]
  F -- "交给 Codex CLI 或 Claude Code CLI" --> G["promptAsync 注入 harness 提醒"]
  F -- "用原模型继续" --> H["promptAsync 发送换思路的指令"]
  G --> I["同一会话、同一模型加载技能并驱动外部 CLI"]
```

候选 harness 按当前模型排除“同类”（`packages/opencode/src/cli/cmd/tui/util/handoff.ts:50`）：

```ts
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`）：

```ts
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 中文手册](https://daiw.org/manual/codex) 与 [Claude Code 中文手册](https://daiw.org/manual/claude-code)。

“装了命令才有”的判断在技能解包时（`packages/opencode/src/skill/builtin/extract.ts:20`）：

```ts
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](https://daiw.org/manual/mimo-code/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` 要求失败输出没有变化 | 只有验证类命令比较输出，计数也不要求紧挨着 |

下一篇：[复读检测与重试](https://daiw.org/manual/mimo-code/loop-recovery)——MiMo 怎样发现模型在文字和工具调用上原地复读，以及请求失败后怎样重试。
