一条消息的旅程
跟着一条用户消息从 TUI 进入服务端,走完主循环与流处理器再回到屏幕,沿途标出收件箱、检查点、Goal、Max 模式、Try-Best 等挂载点。
这一篇把各层串起来:默认 mimo 命令下,一条用户输入从键盘出发、经过服务端和主循环、再以事件的形式回到屏幕。骨架和《OpenCode 源码解读》的同名篇一致,但有两点先说明。第一,那本书对照的 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 的很多机制都是在这里挂进去的。下文按消息走过的顺序展开,遇到挂载点只点名并指向后续篇目。
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),机制见启动链路。生成的 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):
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:子智能体的生成与回收。
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):
// 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)。这是其他智能体“插话”的入口,见收件箱、团队与进度检查。 - 切片:
filterCompactedEffect只取本 actor 的消息,并从最近的边界读起;边界既可以是上游的compaction部件,也可以是 MiMo 新增的checkpoint部件(packages/opencode/src/session/message-v2.ts:1208)。 - 召回提醒:会话已有记忆文件或任务记录时,在最后一条 user 消息后面临时追加一段提醒,告诉模型记忆目录在哪、该用什么工具查(
prompt.ts:3674)。它只加在这次请求的内存副本上,不落库。见分层记忆。
接着判断上一条 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):
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);把工具调用写成正文的回答会被标错丢弃后重试。这些补救属于复读检测与重试。
真正要结束时还有最后一关 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:让独立裁判决定何时停。
6. 准备这一步:上下文与请求组装
确定要跑之后,step 加一,并更新 actor 注册表里的轮次心跳(prompt.ts:3764)。第一步照例在后台生成标题;如果是顶层会话,还会检查是否该自动运行 Dream、Distill,以及启动定时任务桥(prompt.ts:3775),见 Dream 与 Distill 与定时任务与循环。子任务与压缩任务的处理基本沿用上游(prompt.ts:3831)。此外,最近 3 个已完成步骤的工具调用完全相同时,会在请求里追加一段“换个做法”的提醒(prompt.ts:3865)。
检查点与重建是 MiMo 改动最深的一段。内置且隐藏的智能体(标题、摘要、检查点写手等)不参与这部分(prompt.ts:3912),其余情况:
prune.fireCheckpoints根据上一步的用量判断是否跨过了新的阈值,跨过就在后台启动检查点写手(prompt.ts:3919),见检查点写手。- 用量真正超限时(
prompt.ts:3932),子智能体只做针对自己切片的压缩;主智能体调用rebuildEnsuringCheckpoint(prompt.ts:3972):有可用检查点就插入一条带checkpoint部件的边界消息,下一轮从检查点重建上下文;没有检查点就当场启动写手并最多等 3 分钟(AUTO_WRITER_WAIT_MS,prompt.ts:625);写手失败、记忆写入被关或检查点被关,才退回到压缩。见周期:逻辑会话与物理窗口与上下文重建与预算化注入。
然后是请求组装:
- 复读裁剪:配置
experimental.loop_streak_recovery.enabled打开时,连续多步思考内容相同(没有思考时看工具调用是否相同)的已完成步骤,会从请求里整段裁掉,裁剪范围记在父 user 消息的一个隐藏部件上,之后每次请求都会重新应用(prompt.ts:4054)。 - 提醒:
insertReminders(prompt.ts:4129)在上游 plan 模式提醒的基础上加了 compose 模式提示、改动主工作树后的一次性提醒(需开启配置auto_worktree)、附件是办公文档时推荐对应的内置技能,以及用户消息里写了/技能名时直接把SKILL.md内容注入,最多自动加载 3 个,提到两个及以上技能还会附一段编排计划要求。见内置技能包与技能检索。 - 技能目录:可用技能的清单放在系统提示词的尾部(
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 的工具契约与注册表。 - fork 分支:检查点写手这类
contextMode为full的派生智能体,不重新拼系统提示词,而是直接用派生时冻结下来的父级请求前缀加上自己的新消息(prompt.ts:4231),以便命中父会话的提示词缓存。 - 请求前缀快照:主路径按“提供方、模型、智能体、权限”等组合查找冻结的系统提示词(
prompt.ts:4441);第一次请求时把系统提示词和工具 schema 钉进session_prefix_snapshot表(prompt.ts:4495),之后原样复用,工具集变化才轮换一版。系统提示词里的运行环境块默认不加,要设MIMOCODE_ENABLE_DYNAMIC_SYSTEM_PROMPT。见压缩、修剪与请求前缀。
7. 调模型:process、Max 模式与 Try-Best
调用前先决定走哪条路(prompt.ts:4531):
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 模式:多候选与裁判。其余情况照常调用 handle.process。无论走哪条路,每一步前后都会触发 MiMo 新增的 session.userQuery.pre、session.userQuery.post 插件钩子(prompt.ts:4560)。
SessionProcessor.process(packages/opencode/src/session/processor.ts:810)订阅 llm.stream,逐个事件写部件,和上游一样(见流处理器)。不同之处有三:
- 何时截断流:除了上下文溢出,流式文本与推理出现 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。
最后给外层的结论从上游的三值变成了四值(processor.ts:906):
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 再交给父级。后台子智能体的结果同样经收件箱送达。见树状任务。
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),所以子智能体还在干活时,界面的忙碌指示也能在主智能体结束时正确熄灭。
下一篇:周期:逻辑会话与物理窗口——进入第 1 部分,看 MiMo 怎样用检查点把一个长任务切成多个上下文窗口接力完成。