# 主循环 SessionPrompt

> 四个入口、用户输入的展开、runLoop 的每一步，以及“一个会话只跑一个循环”的并发模型。

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

`packages/opencode/src/session/prompt.ts` 是 V1 会话引擎的调度中心。它不直接和模型对话——那是[流处理器](https://daiw.net/manual/opencode/processor)的事——而是决定：这一步用哪个模型、带哪些工具、先压缩还是先跑子任务、什么时候停。

## 入口

`SessionPrompt.Interface`（`prompt.ts:102`）的方法与服务端接口大体一一对应：

| 方法 | 服务端接口 | 用途 |
| --- | --- | --- |
| `prompt` | `/session/:id/message`、`/prompt_async` | 写入 user 消息，默认接着跑循环 |
| `command` | `/session/:id/command` | 展开斜杠命令模板后转给 `prompt` |
| `shell` | `/session/:id/shell` | 用户直接执行 shell 命令 |
| `loop` | 无 | 不写新消息，从当前历史继续跑 |
| `cancel` | `/session/:id/abort` | 中断会话 |

`prompt` 本身很薄（`prompt.ts:1052`）：若会话处于撤销状态，先用 `revert.cleanup` 删掉被撤销的消息；再 `createUserMessage`；`noReply` 为真就此返回，否则进入 `loop`。

## createUserMessage：把输入展开成 Part

先定 agent 与模型：模型按“输入 → agent 配置 → 会话当前模型”兜底（`prompt.ts:646`），与会话头记录不同时调用 `setAgentModel` 更新。

接着逐个解析输入 Part。这里有个关键设计：**用户 @ 的文件被伪装成一次已经发生过的 Read 工具调用**。

- 文本文件：真的执行一次 Read 工具（支持行范围），生成两条 synthetic 文本——“Called the Read tool with the following input: …”和工具输出——再附上原文件 Part。
- 目录：同样走 Read 工具，得到目录列表。
- 图片、PDF 等二进制：读成 base64 的 `data:` URL；MCP 资源同理展开，其二进制内容上限 10 MB。
- `agent` Part（@ 某个子代理）：保留原 Part，再追加一句 synthetic 提示，让模型调用 task 工具派发。

随后触发插件钩子 `chat.message`，图片统一缩放，schema 校验失败只记日志、不拒收，最后先写消息、再写 Part。命令模板里的 `@路径` 则由简化版的 `resolvePromptParts` 展开。

## loop：一个会话只跑一个循环

`loop`（`prompt.ts:1343`）只做一件事：把 `runLoop` 交给 `SessionRunState.ensureRunning`。`SessionRunState`（`run-state.ts`）为每个会话维护一个 `Runner`（`packages/opencode/src/effect/runner.ts`），它是四态状态机：`Idle`、`Running`、`Shell`、`ShellThenRun`。

- `ensureRunning` 在 `Idle` 时启动 `runLoop`；在 `Running` 时不另起循环，而是等待现有循环的结果；在 `Shell` 时把循环排到 shell 命令之后。
- `startShell` 只在 `Idle` 时接受，否则返回 `SessionBusyError`。
- `cancel` 中断正在跑的 fiber，等待者拿到最近一条 assistant 消息；回到 `Idle` 时 Runner 把会话状态置为 `idle`，而 `busy` 由 `runLoop` 每一步开头自己设置。

因此“连发几条消息”不需要显式队列：新 user 消息先落库，循环每一步都重新读取历史，下一步就会看到它。

## runLoop：每一步做什么

```mermaid
flowchart TD
  A["读取压缩感知的历史"] --> B{"最后的 assistant 已答完？"}
  B -- 是 --> Z["退出，后台修剪旧工具输出"]
  B -- 否 --> D{"有待办任务？"}
  D -- subtask --> E["handleSubtask"] --> A
  D -- compaction --> F["执行压缩"] --> A
  D -- 无 --> G{"上一步已超限？"}
  G -- 是 --> H["写入压缩请求"] --> A
  G -- 否 --> I["注入提醒，新建 assistant 消息"]
  I --> J["解析工具，组装 system 与消息"]
  J --> K["processor.process"]
  K -- stop --> Z
  K -- compact --> H
  K -- continue --> A
```

`runLoop`（`prompt.ts:1081`）每一步：

1. **读历史**：`filterCompactedEffect` 返回压缩感知的历史，`MessageV2.latest` 从中找出最后的 user 与 assistant，以及尚未处理的 `compaction` / `subtask` 任务。
2. **判断是否结束**，条件见下方摘录。
3. **首步杂务**：第 1 步 fork 出标题生成与差异统计，都不阻塞主流程。
4. **取模型**：找不到时发布带“Did you mean”建议的 `session.error` 后终止。
5. **先办任务**：`subtask` 交给 `handleSubtask`，`compaction` 交给[上下文压缩](https://daiw.net/manual/opencode/compaction)；上一条已完成的 assistant 用量已超限时，写入自动压缩请求后重来。
6. **准备这一步**：判断是否已到最大步数；`SessionReminders.apply` 注入 plan 模式等提醒（见[系统提示词](https://daiw.net/manual/opencode/system-prompt)）；新建 assistant 消息与处理器；`SessionTools.resolve` 汇总注册表工具与 MCP 工具，要求结构化输出时再加 `StructuredOutput` 工具。
7. **组装请求**：system 依次是环境信息、指令文件、MCP 说明、Skills 列表（模型家族提示词稍后在 LLM 层前置）；历史经 `toModelMessagesEffect` 转成 AI SDK 消息。
8. **调用并收尾**：`handle.process` 返回 `"compact" | "stop" | "continue"`。拿到结构化结果即退出；`finish` 为 `content-filter` 时记 `ContentFilterError` 退出；`stop` 退出；`compact` 写入压缩请求，请求真正溢出（`finish` 为空）时带上 `overflow` 标记。

循环退出后 fork 一次 `compaction.prune`，返回最后一条 assistant 消息。第 2 步的结束判断（`prompt.ts:1103`）：

```ts
          // Some providers return "stop" even when the assistant message contains
          // tool calls. Keep the loop running so tool results can be sent back to
          // the model, but ignore cleanup-marked interrupted orphans.
          const hasToolCalls =
            lastAssistantMsg?.parts.some(
              (part) => part.type === "tool" && !part.metadata?.providerExecuted && !isOrphanedInterruptedTool(part),
            ) ?? false

          if (
            lastAssistant?.finish &&
            !["tool-calls", "unknown"].includes(lastAssistant.finish) &&
            !hasToolCalls &&
            lastAssistant.parentID === lastUser.id
          ) {
```

四个条件同时成立才退出：有结束原因；结束原因不是 `tool-calls` 或 `unknown`；没有待回传结果的本地工具调用（注释说明有的提供方发起工具调用后仍返回 `stop`）；这条 assistant 回应的正是最后一条 user 消息，保证新到的消息一定会被处理。

### 最大步数

`agent.steps` 默认无穷大。到达上限的那一步，工具表照常传入，只在消息末尾追加一条 assistant 角色的 `MAX_STEPS_PROMPT`（定义在 `packages/core/src/session/runner/max-steps.ts`），要求模型只用文字总结进度——约束完全靠提示词：

```ts
              messages: [
                ...modelMsgs,
                ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS_PROMPT }] : []),
              ],
```

（`packages/opencode/src/session/prompt.ts:1279`）

## 子任务与标题

`handleSubtask`（`prompt.ts:255`）不经过主会话的模型：直接新建 assistant 消息和一个 `running` 的 task 工具 Part，调用 task 工具执行子代理，再按结果改成 `completed` 或 `error`。来自斜杠命令的子任务还会追加一条 synthetic user 消息“Summarize the task tool output above and continue with your task.”。子代理见[子代理、待办与提问](https://daiw.net/manual/opencode/task-and-todo)。

`ensureTitle`（`prompt.ts:193`）只处理标题仍为默认值、且恰好有一条真实（非 synthetic）user 消息的根会话：用隐藏的 `title` agent 和提供方的小模型生成，去掉 `<think>` 块后取第一行，超过 100 字符截断。

## shell 与 command

`shellImpl`（`prompt.ts:451`）服务于 TUI 输入框的 shell 模式：写一条 synthetic user 消息“The following tool was executed by the user”，再写 assistant 消息和一个 `running` 的 shell 工具 Part，以 `TERM=dumb` 执行命令并把输出边读边写进 Part。于是下一轮模型看到的，是一次“用户替它执行过”的工具调用。

`command`（`prompt.ts:1356`）展开斜杠命令：用 `$1` 到 `$N` 与 `$ARGUMENTS` 替换参数（最后一个编号占位符吞掉剩余参数），模板里感叹号加反引号包裹的 shell 片段先执行、再替换成输出。目标 agent 是 subagent（且未显式关闭）或命令声明 `subtask: true` 时，整条命令被包成一个 `subtask` Part 交给 `handleSubtask`。写法见[Agent、命令与 Skills](https://daiw.net/manual/opencode/agents-and-commands)。

## 取消

`cancel` 先逐层取消会话及其子会话名下的后台任务，再中断 Runner；被中断的 assistant 消息由 `finalizeInterruptedAssistant` 补上 `MessageAbortedError` 与完成时间。

下一篇：[流处理器与工具调用生命周期](https://daiw.net/manual/opencode/processor)——`handle.process` 如何把模型事件流变成 Part。
