# Goal：让独立裁判决定何时停

> /goal 给会话设一个停止条件，主循环每次想收尾都先请一次单独的裁判调用读完对话再判定，未达成就把缺口写回去接着干，一个目标最多被踢回 12 次。

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

长任务里最常见的失败不是做错，而是做到一半就宣布完成：测试没跑、跑了没看结果，或者把“应该能行”当成“已经行了”。README 把这叫作自主工作中的“乐观停止”（`README.zh.md:184`）。上游 OpenCode 的主循环只要模型给出不再调用工具的结束回复就退出（见[主循环 SessionPrompt](https://daiw.org/manual/opencode/prompt-loop)），没有第二道把关。

MiMo 新增的 Goal 机制给会话挂一个自然语言写的停止条件，主循环准备退出时，先让另一次模型调用读完对话记录来判定。文件头注释（`packages/opencode/src/session/goal.ts:19`）说明了“独立”指什么：

> The judge is a separate model call that only reads the transcript — it does not do the work, so its verdict stays cold relative to the working agent's optimism.

## 怎么用

- `/goal <条件>`：设定目标。命令登记在 `packages/opencode/src/command/index.ts:172`，模板只有 `$ARGUMENTS`。`SessionPrompt.command` 遇到它先调用 `goal.set`，再照常展开模板（`packages/opencode/src/session/prompt.ts:4957`），所以条件文本本身就是这一轮发给模型的用户消息，模型立刻开始朝它干活。
- `/goal`、`/goal clear`、`/goal reset`：清除目标，只写入一条 synthetic 的“Goal cleared.”并带 `noReply`，不触发模型回复。
- 非交互：`mimo run --command goal "所有测试通过并已提交"`，`--command` 会被转成同一个 `session.command` 调用（`packages/opencode/src/cli/cmd/run.ts:681`）。
- 没有配置项。重入上限 `MAX_GOAL_REACT` 写死为 12，注释里留着“TODO: lift to mimocode.json config”（`prompt.ts:184`）。
- 目标只存在内存里：每个项目实例一张 `Map`，键是 sessionID，值是 `condition` 与计数 `react`（`goal.ts:111`），实例销毁或进程重启就没了。

TUI 侧栏会出现一个 Goal 区块，显示条件原文和裁判的最新结论；每条被判过的 assistant 消息下面还有一行可折叠的标记，细节见文末。

## 两个出口上的闸门

上游 v1.14.19 的 runLoop 在“上一条回复已经收尾”和“这一步的结果是 break”两处直接 `break` 退出。MiMo 在这两处前面都插了 `goalGate`：

1. 循环顶部，上一条 assistant 已经结束、被 `classifyAssistantStep` 判为收尾（`prompt.ts:3756`）。模型正常说完“做好了”走的是这里。
2. 一步处理完、结果是 break（`prompt.ts:4846`）。处理器返回 `stop`（工具调用被用户拒绝、本步出错）、结构化输出已产出等情况都会走到这里。

其他出口，比如压缩流程返回 `stop`、复读检测多次恢复无效后终止会话，不经过裁判。

```mermaid
flowchart TD
  A["runLoop 准备退出"] --> B{"主智能体且有活动目标"}
  B -- "否" --> X["退出循环"]
  B -- "是" --> C["裁判读取对话并判定"]
  C -- "达成或不可能" --> D["发布裁决并清除目标"]
  D --> X
  C -- "未达成" --> E{"重入次数超过 12"}
  E -- "是" --> D
  E -- "否" --> F["写入 synthetic 提醒"]
  F --> G["continue，模型继续干活"]
```

`goalGate`（`prompt.ts:3247`）依次做这些事：

1. **只管主智能体**：`agentID` 不是 `main` 就直接放行。子智能体另有 `actor.preStop` 重入，上限 `MAX_PRE_REACT` 为 3（`packages/opencode/src/actor/spawn.ts:39`），见 [Actor](https://daiw.org/manual/mimo-code/actors)。
2. **取对话**：用 `MessageV2.filterCompactedEffect` 按会话的 `contextFrom`、`contextWatermark` 截出主智能体此刻能看到的那段历史，与循环顶部取历史的方式一致，所以重建或压缩边界之前的内容裁判也看不到。
3. **调裁判**：外面套了一层 `Effect.catch`，出错时当作 `ok: true` 并标上 `judgeFailed`。注释的意图是裁判出问题就放行（fail-open），不把用户困在循环里，实际效果见下文。
4. **达成或不可能**：发布带裁决的 `session.goal` 事件，清除目标，放行。
5. **未达成**：`bumpReact` 加一；超过 12 同样发布裁决、清除目标、放行。否则新建一条 synthetic 用户消息，返回 `true` 让调用处 `continue`。

写回去的提醒（`prompt.ts:3338`）把裁判的理由原样带上：

```ts
text: [
  "<system-reminder>",
  `Your goal is not yet satisfied: "${active.condition}".`,
  "A judge reviewed the transcript and reported what is still missing:",
  verdict.reason,
  "Keep working toward the goal. Do not stop until it is genuinely met or impossible.",
  "</system-reminder>",
].join("\n"),
```

几个容易忽略的细节：

- **计数挂在目标上**：`react` 只在 `goal.set` 时归零（`goal.ts:119`）。常量注释写的是“per turn”，但用户中途再发消息并不重置它；一个目标最多被踢回 12 次，也就是裁判最多判 13 次。
- **闸门不问为什么停**：用户在授权框里拒绝了工具调用，处理器把本步标成 blocked 并返回 `stop`；[Try-Best](https://daiw.org/manual/mimo-code/try-best-handoff) 暂停一轮也是同样的路径。只要目标还在，这些情况都会先请裁判，判为未达成时循环照样继续。
- **每次都是全量**：每次判定都把主智能体当前的整段历史重新发一遍；`evaluate` 只取返回的对象，代码里没有把这次调用的用量记进会话。
- **放行分支未必走得到**：`evaluate` 的签名是 `Effect.Effect<Verdict>`（`goal.ts:97`），错误通道为 never，模型请求又包在 `Effect.promise` 里，请求报错只会成为缺陷（defect）。`packages/opencode/src/workflow/runtime.ts:1548` 的注释写明，这个 Effect 版本里 catch 类算子不处理缺陷。照此推断，裁判请求失败时走不到“当作达成、清除目标”的分支，缺陷会直接穿出 `goalGate`；仓库里没有覆盖这条路径的测试。

## 裁判怎么判

`evaluate`（`goal.ts:144`）是一次普通的结构化输出调用：

- **模型**：`goalGate` 传入的是 `lastUser.model`（`prompt.ts:3264`），即会话当前在用的模型。所以“独立”指的是独立的一次调用、不参与干活的角色，而不是换一个模型；`packages/opencode/src/provider/transform.ts:1139` 的注释也写明 `goal.ts` judges with the SESSION's model。
- **输入**：历史经 `MessageV2.toModelMessagesEffect` 转成原生模型消息，工具调用、工具结果与图片都保留（`goal.ts:159`），裁判能直接核对工具输出，而不是只读助手的自述。
- **请求**：system 放 `JUDGE_SYSTEM`，中间是整段对话，末尾追加一条用户消息问条件是否已满足（`goal.ts:76`）；`temperature` 为 0，用 `generateObject` 按 zod 定义的 `Verdict`（`ok`、可选的 `impossible`、`reason`）取结果。

`JUDGE_SYSTEM` 规定了三种回答形状（`goal.ts:66`）：

```text
Your response must be a JSON object with one of these shapes:
- {"ok": true, "reason": "<quote evidence from the transcript that satisfies the condition>"}
- {"ok": false, "reason": "<quote what is missing or what blocks the condition>"}
- {"ok": false, "impossible": true, "reason": "<explain why the condition can never be satisfied>"}
```

后面几句是防乐观的关键：找不到明确证据就回“insufficient evidence in transcript”（`goal.ts:71`）；`impossible` 只用于条件自相矛盾、依赖的资源或能力不可用，或者确实穷尽了合理办法的情况，拿不准时不带它。助手自己说做不到也不能照单全收（`goal.ts:73`）：

> the assistant claiming the goal is impossible is evidence, not proof

两处和提供方有关的适配：

- `impossible` 是可选字段，而 OpenAI 系 SDK 默认对 `json_schema` 开严格模式，要求每个字段都出现在 `required` 里，请求会被拒。`ProviderTransform.structuredOutputOptions` 为 `@ai-sdk/openai`、`@ai-sdk/azure`、`@ai-sdk/openai-compatible` 显式传 `strictJsonSchema: false`（`transform.ts:1165`）。
- OAuth 登录的 `openai` 提供方不发 system 消息，而是把 `JUDGE_SYSTEM` 放进 `instructions` 提供方选项并设 `store: false`，改用 `streamObject` 取结果（`goal.ts:217`）。

## 界面上的裁决

每次判定、设定、清除都会发布 `session.goal` 事件。TUI 把它合并进同步 store（`packages/opencode/src/cli/cmd/tui/context/sync.tsx:545`）：裁决按被判定的 assistant 消息 ID 存进 `verdicts`，清除事件只清掉 `condition`，已有裁决保留。

- **侧栏**：`sidebar/goal.tsx` 以 `order: 350` 排在 LSP 区块下面，显示条件原文和一行“Judge: …”，取值是 `met`、`impossible`、`error (stopped)` 或 `round N · not met`（`packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/goal.tsx:21`）。达到上限没有单独的状态，条件行消失，只剩最后一次的 `round 13 · not met`。
- **逐轮标记**：会话视图给每条被判过的 assistant 消息加一行“Judge: …”标记，点击展开裁判给出的理由（`packages/opencode/src/cli/cmd/tui/routes/session/index.tsx:1868`），方便回头查是哪一轮没过。

这些状态同样只在内存里；重新打开 TUI 后逐轮标记不会恢复，但写进历史的 synthetic 提醒消息还在。

下一篇：[树状任务](https://daiw.org/manual/mimo-code/tasks)——`task` 工具在 MiMo 里不再派子智能体，而是一套能持久化、能按编号引用的工作项。
