# 一条消息的旅程

> 跟着一条用户消息从 TUI 进入服务端，走完主循环与流处理器再回到屏幕，沿途标出收件箱、检查点、Goal、Max 模式、Try-Best 等挂载点。

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

这一篇把各层串起来：默认 `mimo` 命令下，一条用户输入从键盘出发、经过服务端和主循环、再以事件的形式回到屏幕。骨架和[《OpenCode 源码解读》的同名篇](https://daiw.net/manual/opencode/message-lifecycle)一致，但有两点先说明。第一，那本书对照的 v1.18.30 已经把 TUI 拆成独立包、服务端换成 HttpApi；MiMo 基于 v1.14.19，TUI 仍在 `packages/opencode/src/cli/cmd/tui/`，路由仍是 Hono。第二，主循环所在的 `packages/opencode/src/session/prompt.ts` 从上游的 1844 行涨到 5647 行，其中 `runLoop` 一个函数就从 229 行变成了 1892 行，MiMo 的很多机制都是在这里挂进去的。下文按消息走过的顺序展开，遇到挂载点只点名并指向后续篇目。

```mermaid
flowchart TD
  A["TUI 输入框提交<br/>session.promptAsync"] --> B["RPC 隧道与 Hono 路由<br/>POST /session/:id/prompt_async"]
  B --> C["SessionPrompt.prompt<br/>createUserMessage"]
  C --> D["loop<br/>每个（会话，actor）一个 Runner"]
  D --> E["runLoop 新一轮<br/>inbox.drain，读取本 actor 的消息切片"]
  E --> F{"classifyAssistantStep"}
  F -- "final" --> G{"goalGate"}
  F -- "continue" --> H["任务与上下文管理<br/>fireCheckpoints，超限则重建"]
  G -- "目标未达成" --> E
  G -- "放行" --> Z["退出循环<br/>修剪、上报、通知父级"]
  H --> I["复读裁剪，insertReminders<br/>resolveTools，请求前缀快照"]
  I --> J["handle.process<br/>或 MaxMode.runMaxStep"]
  J --> K["流处理器<br/>写部件，detectTryBest"]
  K --> L{"结果分类"}
  L -- "继续或已注入提醒" --> E
  L -- "结束" --> G
```

## 1. TUI 提交

提交逻辑在 `packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx:1082` 的 `submit`。发送前 MiMo 多了几道检查：免费模型要先接受一次使用协议，已下线的免费接口直接拦下；当前智能体是 orchestrator 时，消息落进全局唯一的 orchestrator 根会话而不是新建会话。随后分流：Shell 模式调 `session.shell`；`/btw 问题` 调 `session.ask`，在对话框里显示一个只读的旁支回答，不写进对话（`index.tsx:1259`）；客户端斜杠命令就地执行，服务端命令与技能调 `session.command`；其余调 `session.promptAsync`（`index.tsx:1321`）。

上游同一处调的是 `session.prompt`，那个 HTTP 请求要等整轮对话结束才返回；MiMo 换成了立即返回的 `promptAsync`，并多传一个界面语言 `titleLocale`，让标题用用户的语言生成。输入框随即清空，之后看到的进度全部来自事件。

## 2. 隧道与路由

默认 TUI 的服务端跑在 Worker 线程里，`fetch` 被替换成 RPC 调用（`packages/opencode/src/cli/cmd/tui/thread.ts:28`），Worker 收到后交给 `Server.Default().app.fetch` 在进程内处理（`packages/opencode/src/cli/cmd/tui/worker.ts:62`），机制见[启动链路](https://daiw.net/manual/opencode/startup)。生成的 SDK 把 `promptAsync` 变成 `POST /session/{sessionID}/prompt_async`（`packages/sdk/js/src/v2/gen/sdk.gen.ts:2716`）。

路由在 `packages/opencode/src/server/routes/instance/session.ts:1206`：MiMo 给它加了限流中间件，同一来源 60 秒内最多 20 次；处理函数不等结果，把 `SessionPrompt.prompt` 丢到后台执行后立即回 204，后台出错时发布 `session.error` 事件。

同步端点 `POST /session/:sessionID/message`（`session.ts:1102`）仍在，`mimo run` 用的就是它（`packages/opencode/src/cli/cmd/run.ts:700`）。MiMo 在这里补了三件事：会话主 Runner 正忙时直接回 409，而不是悄悄排队（`session.ts:1140`）；等待期间定时写空白字符当心跳，防止客户端超时（`session.ts:1183`）；客户端断开就取消这一轮，免得下一个请求挂在一个没人消费的运行上。

## 3. prompt 与 loop

`SessionPrompt.prompt`（`packages/opencode/src/session/prompt.ts:2925`）对不是由派生或钩子发起的消息先做清理：撤销状态收尾，再清掉进程被强杀后留下的“孤儿” assistant 消息和卡在 `running` 的工具部件。然后 `createUserMessage`（`prompt.ts:2428`）解析智能体与模型（多了 `modelRef` 写法，可以引用 `model_groups` 里定义的模型分组），展开文件、MCP 资源等部件；`@智能体` 提及保留原部件，再追加一句 synthetic 提示，让模型去调 `actor` 工具（`prompt.ts:2741`），因为上游 `task` 工具的派发职责已经归了 `actor`。所有部件都没有实质内容的消息直接丢弃，不落库也不开跑（`prompt.ts:2760`）。

之后进入 `loop`（`prompt.ts:4923`）：

```ts
    const loop: (input: z.infer<typeof LoopInput>) => Effect.Effect<MessageV2.WithParts> = Effect.fn(
      "SessionPrompt.loop",
    )(function* (input: z.infer<typeof LoopInput>) {
      const agentID = input.agentID ?? "main"
      return yield* state.ensureRunning(
        input.sessionID,
        agentID,
        lastAssistant(input.sessionID, agentID),
        runLoop(input.sessionID, agentID, input.task_id, input.notifyParentOnComplete, input.titleLocale),
      )
    })
```

和上游最大的区别是多了 `agentID`。`SessionRunState` 的 Runner 以 `会话 ID:agentID` 为键（`packages/opencode/src/session/run-state.ts:29`），上游则是一个会话一个。原因在于 MiMo 里 `subagent` 模式的子智能体与父级共用会话 ID：`actor` 工具生成的子智能体会带着自己的 `agentID` 重新调用 `SessionPrompt.prompt`（`packages/opencode/src/actor/spawn.ts:341`），消息靠 `message.agent_id` 列切成各自的片，每片各跑各的循环。“会话忙时再发消息不另起循环、新消息由下一轮读到”这条上游语义，现在是按 actor 成立的。详见 [Actor：子智能体的生成与回收](https://daiw.net/manual/mimo-code/actors)。

## 4. runLoop 的主干

`runLoop`（`prompt.ts:3030`，span 名是 `SessionPrompt.run`）大致分成这几段：

| 行号 | 内容 |
| --- | --- |
| 3030–3137 | 各类重试计数器；`firePostSession` 在循环退出时（无论成败）触发插件钩子 `session.post` 与 MCP 的回合结束通知 |
| 3138–3613 | 触发 `session.pre` 钩子；定义一组补救函数：输出截断续写、`goalGate`、空输出提醒、文本形式工具调用重试、结构化输出重试、n-gram 复读恢复等 |
| 3615–3760 | 每轮开头：置忙、收件箱、读切片、召回提醒、判断上一步 |
| 3762–4129 | 准备这一步：标题与后台任务、子任务与压缩任务、重复步骤提醒、检查点与重建、复读裁剪、`insertReminders` |
| 4131–4780 | 调用这一步：新建 assistant、`resolveTools`、fork 分支、请求前缀快照、Max 模式或 `handle.process`、结果处理 |
| 4782–4850 | 一步之后：跨步文本复读检测，结束前再过一次 `goalGate` |
| 4852–4918 | 退出：后台修剪、上报指标、被唤醒的后台 peer 通知父级 |

## 5. 每轮开头：收件箱、切片与“要不要停”

循环体的开头就能看到两处 MiMo 的改动（`prompt.ts:3620`）：

```ts
          // F55: only main agent sets session status to busy; subagent runners
          // must not touch session-level status (Runner.onBusy is Effect.void
          // for non-main actors per F47).
          if (!agentID || agentID === "main") yield* status.set(sessionID, { type: "busy" })
          yield* inbox.drain(sessionID, agentID ?? "main").pipe(Effect.ignore)
          yield* slog.info("loop", { step })

          // F37: filter by agentID so subagent slices stay isolated from the
          // main agent's slice within the same session. Without this, an actor
          // (explore/general/etc) spawned via mimocode's shared-sessionID
          // design would see the parent's full conversation here and drift
          // off-task. agentID === "main" => main agent slice (agent_id = 'main'
          // in DB), agentID === "explore-1" => only explore-1's slice.
          let msgs = yield* MessageV2.filterCompactedEffect(sessionID, {
            contextFrom: session.contextFrom,
            contextWatermark: session.contextWatermark,
            agentID: agentID ?? "main",
          })
```

- **收件箱**：`inbox.drain` 把发给这个 actor 的待处理消息渲染成一条 synthetic user 消息，再删掉收件箱里的行（`packages/opencode/src/inbox/inbox.ts:201`）。这是其他智能体“插话”的入口，见[收件箱、团队与进度检查](https://daiw.net/manual/mimo-code/inbox-team)。
- **切片**：`filterCompactedEffect` 只取本 actor 的消息，并从最近的边界读起；边界既可以是上游的 `compaction` 部件，也可以是 MiMo 新增的 `checkpoint` 部件（`packages/opencode/src/session/message-v2.ts:1208`）。
- **召回提醒**：会话已有记忆文件或任务记录时，在最后一条 user 消息后面临时追加一段提醒，告诉模型记忆目录在哪、该用什么工具查（`prompt.ts:3674`）。它只加在这次请求的内存副本上，不落库。见[分层记忆](https://daiw.net/manual/mimo-code/memory)。

接着判断上一条 assistant 是否意味着该停。上游这里是四个条件的组合判断；MiMo 抽成了纯函数 `classifyAssistantStep`（`packages/opencode/src/session/classify.ts:32`），返回 `final`、`continue`、`text-tool-call`、`filtered`、`think-only`、`invalid`、`failed` 七类，`runLoop` 按类别决定下一步。它的第一条保证是：这一步里只要有本地执行、且没出错的工具调用，结果就得回传给模型，一律 `continue`，优先级高于一切。各类别的处理（`prompt.ts:3747`）：

```ts
            if (classification.type === "think-only" || classification.type === "invalid") {
              const reason = classification.type === "invalid" ? classification.reason : "think-only"
              if (yield* autoContinueInvalidOutput({ lastUser, assistant: lastAssistant, reason })) continue
              yield* slog.info("exiting loop", { classification: classification.type })
              break
            }
            if (classification.type === "final" && classification.degraded)
              yield* slog.warn("degraded final on abnormal finish", { finish: lastAssistant.finish })
            if (classification.type !== "continue") {
              if (yield* goalGate(lastUser)) continue
              yield* slog.info("exiting loop", { classification: classification.type })
              break
            }
```

只有推理没有正文、或者干脆为空的回答，会被注入一条 `<system-reminder>` 让模型重来，次数由 `MIMOCODE_INVALID_OUTPUT_CONTINUATION_LIMIT` 限定（默认 2，`packages/opencode/src/flag/flag.ts:178`）；把工具调用写成正文的回答会被标错丢弃后重试。这些补救属于[复读检测与重试](https://daiw.net/manual/mimo-code/loop-recovery)。

真正要结束时还有最后一关 `goalGate`（`prompt.ts:3247`）：用户用 `/goal` 设过停止条件，就用会话当前的模型另起一次调用，只读对话记录、判断条件是否满足；不满足则写入一条 synthetic user 消息把裁判给出的缺口告诉模型，其中一句是“Keep working toward the goal. Do not stop until it is genuinely met or impossible.”（`prompt.ts:3343`），然后继续循环。裁判出错时放行，最多重入 12 次（`prompt.ts:186`），只对主智能体生效。见[Goal：让独立裁判决定何时停](https://daiw.net/manual/mimo-code/goal)。

## 6. 准备这一步：上下文与请求组装

确定要跑之后，`step` 加一，并更新 actor 注册表里的轮次心跳（`prompt.ts:3764`）。第一步照例在后台生成标题；如果是顶层会话，还会检查是否该自动运行 Dream、Distill，以及启动定时任务桥（`prompt.ts:3775`），见 [Dream 与 Distill](https://daiw.net/manual/mimo-code/dream-distill) 与[定时任务与循环](https://daiw.net/manual/mimo-code/cron)。子任务与压缩任务的处理基本沿用上游（`prompt.ts:3831`）。此外，最近 3 个已完成步骤的工具调用完全相同时，会在请求里追加一段“换个做法”的提醒（`prompt.ts:3865`）。

**检查点与重建**是 MiMo 改动最深的一段。内置且隐藏的智能体（标题、摘要、检查点写手等）不参与这部分（`prompt.ts:3912`），其余情况：

1. `prune.fireCheckpoints` 根据上一步的用量判断是否跨过了新的阈值，跨过就在后台启动检查点写手（`prompt.ts:3919`），见[检查点写手](https://daiw.net/manual/mimo-code/checkpoint-writer)。
2. 用量真正超限时（`prompt.ts:3932`），子智能体只做针对自己切片的压缩；主智能体调用 `rebuildEnsuringCheckpoint`（`prompt.ts:3972`）：有可用检查点就插入一条带 `checkpoint` 部件的边界消息，下一轮从检查点重建上下文；没有检查点就当场启动写手并最多等 3 分钟（`AUTO_WRITER_WAIT_MS`，`prompt.ts:625`）；写手失败、记忆写入被关或检查点被关，才退回到压缩。见[周期：逻辑会话与物理窗口](https://daiw.net/manual/mimo-code/cycles)与[上下文重建与预算化注入](https://daiw.net/manual/mimo-code/context-rebuild)。

然后是请求组装：

- **复读裁剪**：配置 `experimental.loop_streak_recovery.enabled` 打开时，连续多步思考内容相同（没有思考时看工具调用是否相同）的已完成步骤，会从请求里整段裁掉，裁剪范围记在父 user 消息的一个隐藏部件上，之后每次请求都会重新应用（`prompt.ts:4054`）。
- **提醒**：`insertReminders`（`prompt.ts:4129`）在上游 plan 模式提醒的基础上加了 compose 模式提示、改动主工作树后的一次性提醒（需开启配置 `auto_worktree`）、附件是办公文档时推荐对应的内置技能，以及用户消息里写了 `/技能名` 时直接把 `SKILL.md` 内容注入，最多自动加载 3 个，提到两个及以上技能还会附一段编排计划要求。见[内置技能包与技能检索](https://daiw.net/manual/mimo-code/skills)。
- **技能目录**：可用技能的清单放在系统提示词的尾部（`prompt.ts:4447` 调用的 `sys.skills`）。早期版本曾把它作为 synthetic 提醒写进 user 消息；`packages/opencode/src/session/skill-catalog.ts` 现在只放识别这类提醒的标记与辅助函数，源码里实际用到的只有 `isSkillCatalogReminder`：转换历史时把旧版本落库的目录提醒跳过，免得恢复旧会话时出现两份目录（`message-v2.ts:858`）。
- **工具**：新建 assistant 消息与处理器后，`resolveTools`（`prompt.ts:4159`）包装每个工具：actor 设了工具白名单时，名单外的调用直接被拒；同时决定权限询问怎么路由：检查点写手这类系统拉起的智能体没人应答，遇到需要询问的情况直接失败；orchestrator 的子会话转发给协调者审批；普通后台子智能体继承父级已有的授权；其余照常弹框询问。求值用的规则集是 `Agent.runtimePermission`，它把智能体的 `hardPermission` 追加在用户规则之后。工具契约本身见 [OpenCode 的工具契约与注册表](https://daiw.net/manual/opencode/tool-contract)。
- **fork 分支**：检查点写手这类 `contextMode` 为 `full` 的派生智能体，不重新拼系统提示词，而是直接用派生时冻结下来的父级请求前缀加上自己的新消息（`prompt.ts:4231`），以便命中父会话的提示词缓存。
- **请求前缀快照**：主路径按“提供方、模型、智能体、权限”等组合查找冻结的系统提示词（`prompt.ts:4441`）；第一次请求时把系统提示词和工具 schema 钉进 `session_prefix_snapshot` 表（`prompt.ts:4495`），之后原样复用，工具集变化才轮换一版。系统提示词里的运行环境块默认不加，要设 `MIMOCODE_ENABLE_DYNAMIC_SYSTEM_PROMPT`。见[压缩、修剪与请求前缀](https://daiw.net/manual/mimo-code/compaction-and-prune)。

## 7. 调模型：process、Max 模式与 Try-Best

调用前先决定走哪条路（`prompt.ts:4531`）：

```ts
            const cfg = yield* config.get()
            const maxModeCfg = cfg.experimental?.maxMode
            const useMaxMode =
              agent.name === MaxMode.MAX_MODE_AGENT && maxModeCfg !== undefined && format.type !== "json_schema"
```

当前智能体是 `max`、配置了 `experimental.maxMode` 且不要求结构化输出时，改走 `MaxMode.runMaxStep`：并行跑多个只提议不执行的候选，由一次裁判调用挑出一个，再把胜者交给处理器的 `replay` 真正执行，见[Max 模式：多候选与裁判](https://daiw.net/manual/mimo-code/max-mode)。其余情况照常调用 `handle.process`。无论走哪条路，每一步前后都会触发 MiMo 新增的 `session.userQuery.pre`、`session.userQuery.post` 插件钩子（`prompt.ts:4560`）。

`SessionProcessor.process`（`packages/opencode/src/session/processor.ts:810`）订阅 `llm.stream`，逐个事件写部件，和上游一样（见[流处理器](https://daiw.net/manual/opencode/processor)）。不同之处有三：

- **何时截断流**：除了上下文溢出，流式文本与推理出现 n-gram 复读、工具调用被用户拒绝、或者 Try-Best 把本轮判定为阻塞时，都会提前停止消费（`processor.ts:830`）。
- **重试是否安全**：一旦出现工具调用就把 `retrySafe` 置假，因为工具可能已经产生了副作用，整步重放不再安全（`processor.ts:499`）。
- **Try-Best 检测**：只有 `MIMOCODE_ENABLE_TRY_BEST_HANDOFF` 打开时才创建监视器（`processor.ts:262`），阈值读配置 `experimental.try_best`。每个工具调用完成或失败时，`detectTryBest` 把工具部件喂给监视器；判定为“反复编辑几乎相同的内容”“反复重试同一条失败命令”或“连续多步没有进展”时，把本轮标为阻塞、写一段说明文字，并发布 `session.try_best.detected` 事件（`processor.ts:267`）。TUI 收到后弹出对话框：在对应技能可用时，用户可以把工作交给 Codex CLI 或 Claude Code CLI 接手，也可以让当前模型换个思路继续（`packages/opencode/src/cli/cmd/tui/app.tsx:1305`）。见[Try-Best 检测与 Hand-off](https://daiw.net/manual/mimo-code/try-best-handoff)。

最后给外层的结论从上游的三值变成了四值（`processor.ts:906`）：

```ts
          if (ctx.needsOverflowHandling) return "overflow"
          if (ctx.textNgramRepeat) return "text-repeat"
          if (ctx.blocked || ctx.assistantMessage.error) return "stop"
          return "continue"
```

上游这里返回 `compact`，由主循环写入压缩请求；MiMo 返回 `overflow`，对应的状态也从 `needsCompaction` 改成了 `needsOverflowHandling`：主智能体溢出后先尝试检查点重建，压缩只是兜底。

## 8. 一步之后，以及循环怎么收尾

`handle.process` 返回后，主路径先推进请求前缀快照的水位（`prompt.ts:4645`），再依次处理：输出因长度截断且没有工具调用就注入续写提醒；`text-repeat` 交给 `handleTextRepeat`，第一次提醒、第二次要求重新规划、再犯就报错结束（`prompt.ts:4661`）；拿到结构化输出就结束；否则用 `classifyAssistantStep` 再分类一次（`prompt.ts:4674`）；若结论是 `overflow`，按第 6 节同样的规则重建或压缩。

这一步如果产出了正文，还会参与跨步的文本复读检测：最近几步的正文加上工具签名归一化后放进一个小缓冲区，重复次数达到阈值就注入一条 synthetic user 消息要求换个方向，恢复次数用尽则报错终止（`prompt.ts:4798`）。决定结束（`outcome` 为 `break`）时，同样先过 `goalGate`（`prompt.ts:4845`）。

循环退出后（`prompt.ts:4852`）：后台 fork 一次修剪；发布一条单轮请求的指标事件；如果这一轮是被收件箱唤醒的后台 peer（`notifyParentOnComplete` 为真），就把最终结果作为 `actor_notification` 发回父级的收件箱（`prompt.ts:4883`）；`Inbox.send` 写入后会顺手唤醒父级的循环，父级下一轮开头的 `inbox.drain` 就会读到它（`inbox.ts:178`）。`Effect.onExit(firePostSession)` 保证 `session.post` 钩子无论成败都会触发（`prompt.ts:4919`）。

符合条件的子智能体（比如带 `completionGate: true` 的 `general`）还多一道**任务门**。它的循环返回后，`actor/spawn.ts` 会检查它名下还有没有 `open` 或 `in_progress` 的任务，有就用一条提醒让它再跑一轮，最多 2 次（`packages/opencode/src/task/gate.ts:10`，调用处在 `spawn.ts:562`）；仍未完成则把返回状态降级为 partial 或 blocked 再交给父级。后台子智能体的结果同样经收件箱送达。见[树状任务](https://daiw.net/manual/mimo-code/tasks)。

## 9. 回到屏幕

每次 `Session.updatePart` 都经 `SyncEvent.run` 先在事务里由投影器写库，再发布到总线（`packages/opencode/src/sync/index.ts:215`）；文本与推理的增量走 `updatePartDelta`，只发布、不落库（`packages/opencode/src/session/session.ts:871`）。总线的 `publish` 顺带把事件发到进程级的 `GlobalBus`（`packages/opencode/src/bus/index.ts:97`），Worker 订阅后经 `Rpc.emit("global.event", ...)` 推回主线程（`worker.ts:43`）。TUI 的 SDK 上下文把 16 毫秒内到达的事件合并成一批（`packages/opencode/src/cli/cmd/tui/context/sdk.tsx:69`），同目录的 `sync.tsx` 再按类型更新 store：`message.part.updated` 二分查找后插入或替换（`packages/opencode/src/cli/cmd/tui/context/sync.tsx:643`），`message.part.delta` 把增量拼到对应字段（`sync.tsx:664`）。

MiMo 在同一个 store 里多接了一批事件：`session.goal` 记录目标与每轮裁判结论（`sync.tsx:545`），`task.created`、`task.updated` 维护任务列表供侧栏读取，`actor.registered`、`actor.status` 跟踪子智能体，`workflow.started`、`workflow.phase`、`workflow.finished` 更新 Workflow 的运行状态与当前阶段。只有主智能体会改写会话级状态（`processor.ts:252`），所以子智能体还在干活时，界面的忙碌指示也能在主智能体结束时正确熄灭。

下一篇：[周期：逻辑会话与物理窗口](https://daiw.net/manual/mimo-code/cycles)——进入第 1 部分，看 MiMo 怎样用检查点把一个长任务切成多个上下文窗口接力完成。
