# Agent 核心循环

> 本章以 OpenCode 源码版本 `v1.18.16`（提交 `a3647eb025c7`）为证据基线。我们用一个典型场景追踪源码：模型第一次只发出读取 `package.json` 的工具调用时，OpenCode 怎样执行工具、拿到结果，再让模型给出最终回答？这是一条由源码证明的执行路径，不是一次真实会话的运行录屏。

## 0. 本章学习目标

学完这一章，你应该能够：

1. 画出 `prompt -> runLoop -> SessionProcessor -> LLM -> tool -> message history` 的位置图。
2. 区分“一次模型请求”“一次流事件处理”和“跨请求的 agent 循环”。
3. 沿源码解释一个 `read` 类工具调用如何经历 `pending -> running -> completed`。
4. 根据源码判断循环为什么继续、何时停止、何时转去压缩上下文。
5. 说清哪些是所有 tool-using agent 都需要的最小机制，哪些是 OpenCode 的产品层。
6. 为自己的 mini agent 设计一个可恢复、可观察的最小循环。

## 1. 一句话讲明白

OpenCode 的 Agent 核心循环，是一个**以 session 消息为账本的调度循环**：每轮从账本重建上下文，把模型的文本和工具事件再写回账本，然后依据最新状态继续、压缩或停止。

本章只追一个中心问题：

> **为什么模型第一次没有直接回答，而只是调用工具，OpenCode 最后仍能交付完整答案？**

答案不在某个“神奇的 Agent 类”里，而在三段协作中：`SessionPrompt.runLoop` 负责跨轮调度，`LLM.stream` 负责接入模型流，`SessionProcessor` 负责把流事件落成可再次读取的消息状态。证据见 `packages/opencode/src/session/prompt.ts`、`packages/opencode/src/session/processor.ts`。

## v1.18.16 相比旧基线改变了什么

- loop 现在会检查 assistant message 中是否真的存在未完成 tool call；即使 provider 错把 finish reason 写成 `stop`，也不会丢掉应回传给模型的工具结果。
- 被 cleanup 标记的 interrupted orphan tool 会被单独识别，退出时留下 warning，而不是把残缺 ToolPart 当成新一轮依据。
- structured output、最大步数、skills、MCP instructions 与普通工具都在同一轮装配，但它们仍围绕“读账本—请求模型—写回账本”的内核。

<!-- source-ref path="packages/opencode/src/session/prompt.ts" lines="1081-1130" title="v1.18.16 loop 的退出判断" note="finish reason 与真实 tool parts 一起决定是否退出。" -->

<!-- source-ref path="packages/opencode/src/session/processor.ts" lines="627-699" title="processor 消费统一模型流" note="processor 负责把一轮流事件收敛到 assistant message 与 ToolPart。" -->

## 2. 为什么现在必须理解这个循环

普通聊天可以近似成：

```text
用户问题 -> 模型 -> 一段文本
```

但“读完项目配置，再告诉我有哪些脚本”至少需要两次判断：

```text
第一次判断：我还缺 package.json 的内容 -> 调 read
第二次判断：我已经拿到文件内容 -> 组织最终答案
```

模型只负责提出下一步和生成内容。它不会自己维护 OpenCode 的 session，也不会凭空让工具结果进入下一次请求。OpenCode 必须补上四件事：

- 保存用户输入与中间结果；
- 把可用工具交给模型，并真的执行模型选择的工具；
- 把流式事件整理成稳定状态；
- 根据状态决定再问一次模型，还是结束。

如果这四件事混在一起，你会误以为 `streamText` 就等于整个 Agent，或者误以为工具返回后模型会“自动知道”。先把整张地图画出来，再打开函数。

## 3. 先画地图：循环在哪里，边界又在哪里

把 session 想成一本持续追加的**工作账本**。入口负责记下委托，模型提出下一步，工具完成动作，processor 记账，`runLoop` 再翻账本决定是否继续。

```text
CLI / HTTP
    |
    v
SessionPrompt.prompt        记入 user message
    |
    v
SessionPrompt.loop          管理同一 session 的 runner
    |
    v
SessionPrompt.runLoop       每轮重读账本并调度
    |        |        |
    |        |        +--> SessionTools.resolve  准备可执行工具
    |        +-----------> MessageV2              转换模型上下文
    v
SessionProcessor.process    消费统一的 LLMEvent
    |
    v
LLM.stream                  provider / runtime 适配
    |
    +--> text events  ------> TextPart
    +--> tool-call  --------> ToolPart running
    +--> tool-result --------> ToolPart completed
                                |
                                +--> 下一轮重新进入模型上下文
```

重要边界如下：

| 模块 | 它负责什么 | 它不负责什么 |
| --- | --- | --- |
| `SessionPrompt.runLoop` | 跨模型请求调度、分支与退出 | 不解析每个 provider 的事件格式 |
| `LLM.stream` | 组装模型请求，屏蔽 native / AI SDK 差异 | 不决定整个 session 何时完成 |
| `SessionProcessor` | 把 `LLMEvent` 写成 message parts | 不选择本轮 agent 和 model |
| `SessionTools.resolve` | 把 registry/MCP 工具包装为可执行工具并接入权限 | 不决定模型会调用哪一个工具 |
| `MessageV2` | 定义并转换可持久化的消息/part | 不执行工具 |

这张图同时划开两个层次：

- **通用 Agent 内核**：读状态 -> 调模型 -> 执行动作 -> 写结果 -> 判断是否继续。
- **OpenCode 产品层**：权限、插件、MCP、subtask、compaction、snapshot、结构化输出、provider 兼容等。

先掌握内核，再把产品层一层层加回来。

## 4. 最小机制：先看 12 行伪代码

先不要读 Effect、schema 和 provider 兼容代码。最短但仍符合真实控制流的机制是：

```ts
async function agentLoop(sessionID: string) {
  while (true) {
    const history = await loadCompactedHistory(sessionID)
    if (isFinished(history)) break

    const request = await prepareModelRequest(history)
    for await (const event of llmStream(request)) {
      await persistEventAsMessagePart(event)
    }

    const outcome = inspectPersistedState()
    if (outcome === "stop") break
    if (outcome === "compact") await enqueueCompaction(sessionID)
  }
}
```

这不是 OpenCode 源码的逐行翻译，而是从 `runLoop`、`process` 和 `handleEvent` 抽出的**教学骨架**。OpenCode 的真实实现还要处理 subtask、权限拒绝、重试、中断和 provider 差异。

### 4.1 不要把三种“循环”混在一起

| 层次 | 真实标识符 | 一次循环处理什么 | 结束意味着什么 |
| --- | --- | --- | --- |
| session 外循环 | `SessionPrompt.runLoop` 的 `while (true)` | 一次完整模型请求及其结果 | 整个用户任务暂时完成或失败 |
| stream 消费 | `Stream.runDrain` | 一个 `LLMEvent` | 本次模型流已消费完 |
| 工具执行 | AI SDK tool 的 `execute` | 一个具体工具调用 | 工具结果产生，但 Agent 未必完成 |

“工具执行结束”不等于“Agent 结束”。它通常只是把缺失事实补进账本，等待 session 外循环发起下一次模型请求。

## 5. 读源码前，只补两个概念

### 5.1 Message 是一页，Part 是页内的记录

`MessageV2.WithParts` 由 `info` 和 `parts` 组成：

```ts
export type WithParts = {
  info: Info
  parts: Part[]
}
```

来源：`packages/opencode/src/session/message-v2.ts`

`info` 表示这页是谁写的、属于哪个 session、使用哪个模型；`parts` 才承载文本、reasoning、工具、step、patch 等细节。这样做的直接价值是：流式输出不必等整段完成才落库，UI 也能看到工具从等待到完成的变化。

### 5.2 ToolPart 本身就是一个小状态机

```text
tool-input-start        tool-call              tool-result / tool-error
       |                    |                         |
       v                    v                         v
    pending  ----------> running  ----------> completed / error
```

真实类型由 `ToolStatePending`、`ToolStateRunning`、`ToolStateCompleted`、`ToolStateError` 组成，并以 `status` 作为判别字段。来源：`packages/opencode/src/session/message-v2.ts`。

Java 可以暂时把它类比为 `sealed interface ToolState` 加四个 record。类比到此为止：这里的 Schema 同时参与运行时校验，最终状态也嵌在 `ToolPart` 中，不只是 Java 编译期的类型约束。

## 6. 追一条典型源码链路：读取 `package.json` 再回答

假设用户在非交互 CLI 中输入：

> 读取 `package.json`，告诉我有哪些脚本。

我们只追这条请求，不旁观所有代码。

### 6.1 第一站：入口只负责把请求送进 session

CLI 组装 model、agent、文件与文本 part，然后调用 SDK 的 `client.session.prompt`：

```ts
const result = await client.session.prompt({
  sessionID,
  agent,
  model,
  variant: args.variant,
  parts: [...files, { type: "text", text: message }],
})
```

来源：`packages/opencode/src/cli/cmd/run.ts`

HTTP 同步入口做的事情相似：校验 session，把 payload 与 URL 中的 `sessionID` 合并后交给 `promptSvc.prompt`。来源：`packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts`。

这说明 CLI 和 HTTP 是**入口适配器**，不是 Agent runtime。真正的行为从 `SessionPrompt` 开始。

### 6.2 第二站：`prompt` 先记账，再启动循环

`SessionPrompt.prompt` 先清理 revert 状态，创建 user message，更新 session；只有 `noReply` 为 `true` 时才只记账不运行。

```ts
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
yield* revert.cleanup(session)
const message = yield* createUserMessage(input)
yield* sessions.touch(input.sessionID)
// ...把 input.tools 转成 session permission...
if (input.noReply === true) return message
return yield* loop({ sessionID: input.sessionID })
```

来源：`packages/opencode/src/session/prompt.ts`

`createUserMessage` 选择 agent、model、variant，构造 `MessageV2.User`，最后写入 message 与 parts。来源：`packages/opencode/src/session/prompt.ts`、`packages/opencode/src/session/prompt.ts`。

为什么不在这里直接调模型？因为一旦输入先成为持久化状态，后续重试、恢复、UI 订阅和下一轮推理都可以围绕同一本账本工作。这是从调用顺序可以确认的设计结果。

### 6.3 第三站：`runLoop` 每轮都重新读取最新事实

真实外循环从这里开始：

```ts
while (true) {
  yield* status.set(sessionID, { type: "busy" })
  let msgs = yield* MessageV2.filterCompactedEffect(sessionID)
  const { user: lastUser, assistant: lastAssistant, finished: lastFinished, tasks } =
    MessageV2.latest(msgs)
  // 判断退出、subtask、compaction，再进入普通模型调用
}
```

来源：`packages/opencode/src/session/prompt.ts`


`filterCompactedEffect` 取得压缩后的可用历史；`MessageV2.latest` 不是相信数组最后一项，而是按单调递增的 message id 找最新 user、assistant 和 finished message。因为压缩可能重排供模型消费的消息，数组位置并不等于时间顺序。来源：`packages/opencode/src/session/message-v2.ts`。

此时用户请求缺少新的 assistant 回答，因此不会退出，循环进入第 1 步。

### 6.4 第四站：为这一次模型请求准备“人、资料、工具”

这一轮会解析 model 和 agent，先创建 assistant message 容器，再创建 processor handle。来源：`packages/opencode/src/session/prompt.ts`、`packages/opencode/src/session/prompt.ts`。

接着准备三类输入：

1. `SessionTools.resolve(...)`：把 registry 与 MCP 工具包装成模型可调用的工具。
2. `sys.environment`、`instruction.system`、`sys.skills`：组成 system 内容。
3. `MessageV2.toModelMessagesEffect(msgs, model)`：把账本转换成 provider 可消费的模型消息。

对应源码：`packages/opencode/src/session/prompt.ts`。

最后，`handle.process` 得到本轮所需的 user、agent、permission、system、messages、tools 和 model：

```ts
const result = yield* handle.process({
  user: lastUser,
  agent,
  permission: session.permission,
  sessionID,
  system,
  messages: modelMsgs,
  tools,
  model,
})
```

上面省略了 parent session、最大步数提示和结构化输出分支；完整调用见 `packages/opencode/src/session/prompt.ts`。

### 6.5 第五站：工具为什么能够“真的执行”

`SessionTools.resolve` 不是只把工具名和 schema 给模型。它为 registry 中每个工具构造 AI SDK tool，并提供 `execute` 回调：

```ts
tools[item.id] = tool({
  description: item.description,
  inputSchema: jsonSchema(schema),
  execute(args, options) {
    return run.promise(
      Effect.gen(function* () {
        const ctx = context(args, options)
        yield* plugin.trigger("tool.execute.before", /* ... */)
        const result = yield* item.execute(args, ctx)
        yield* plugin.trigger("tool.execute.after", /* ... */)
        return output
      }),
    )
  },
})
```

节选并省略附件处理；来源：`packages/opencode/src/session/tools.ts`。


工具上下文中的 `ask` 会把 agent permission 与 session permission 合并后交给权限服务。来源：`packages/opencode/src/session/tools.ts`。因此“模型选择了 read”不等于“无条件执行 read”；权限仍位于执行边界。

### 6.6 第六站：模型流先被统一，再由 processor 记账

`LLM.stream` 会准备 provider system、参数、headers 和工具，然后选择 native runtime；不支持时回退到 AI SDK 的 `streamText`。来源：`packages/opencode/src/session/llm.ts`、`packages/opencode/src/session/llm.ts`。

AI SDK 路径不会把原始事件直接泄漏给 session 层。`LLMAISDK.toLLMEvents` 把 `tool-call`、`tool-result`、text 等事件转换为统一的 `LLMEvent`。来源：`packages/opencode/src/session/llm.ts`、`packages/opencode/src/session/llm/ai-sdk.ts`。

`SessionProcessor.process` 消费这条统一事件流：

```ts
const stream = llm.stream(streamInput)
yield* stream.pipe(
  Stream.tap((event) => handleEvent(event)),
  Stream.takeUntil(() => ctx.needsCompaction),
  Stream.runDrain,
)
```

来源：`packages/opencode/src/session/processor.ts`

对我们的例子，关键事件是：

1. `tool-input-start`：`ensureToolCall` 创建 `pending` 的 `ToolPart`。
2. `tool-call`：processor 写入解析后的参数，状态转为 `running`。
3. AI SDK 调用上一节注册的 `execute`，实际执行 read 类工具。
4. `tool-result`：processor 提取输出与附件，调用 `completeToolCall`，状态转为 `completed`。

证据分别位于 `packages/opencode/src/session/processor.ts`、`packages/opencode/src/session/processor.ts`、`packages/opencode/src/session/processor.ts`、`packages/opencode/src/session/processor.ts`。


注意：本章没有追进具体 `read` 工具文件，因此不声称它如何解析路径、截断文件或处理二进制内容。这里已经由当前源码证明的是：loop 如何把一个注册工具交给模型，以及结果如何回到 session。

### 6.7 第七站：工具结果怎样进入下一次推理

这是整章最容易被一句“自动回填”糊弄过去的地方。真实链路有两步：

第一步，`completeToolCall` 已把结果持久化成 `completed ToolPart`。第二步，下一轮 `runLoop` 再次调用 `filterCompactedEffect`，然后 `MessageV2.toModelMessagesEffect` 把 completed part 转成模型消息中的 `output-available` 工具结果：

```ts
if (part.state.status === "completed") {
  assistantMessage.parts.push({
    type: ("tool-" + part.tool) as `tool-${string}`,
    state: "output-available",
    toolCallId: part.callID,
    input: part.state.input,
    output,
  })
}
```

节选并省略 provider metadata；来源：`packages/opencode/src/session/message-v2.ts`。


所以不是模型保留了某种隐藏记忆，而是 OpenCode **持久化结果，并在下一轮显式重建上下文**。

### 6.8 第八站：第二次模型请求给出文本，循环退出

第二轮模型看见用户问题和 `package.json` 的工具结果，开始输出文本。processor 用 `text-start` 创建 `TextPart`，用 `text-delta` 追加内容，用 `text-end` 完成它。来源：`packages/opencode/src/session/processor.ts`。

模型 step 结束时，processor 记录 finish reason、tokens、cost 和 snapshot；若上下文溢出则标记 `needsCompaction`。来源：`packages/opencode/src/session/processor.ts`。

`process` 最终只返回三种调度结果：

```ts
if (ctx.needsCompaction) return "compact"
if (ctx.blocked || ctx.assistantMessage.error) return "stop"
return "continue"
```

来源：`packages/opencode/src/session/processor.ts`

即使返回 `continue`，外循环下一轮顶部仍会检查最新 assistant 是否已正常完成。若满足退出条件，就 `break` 并返回最后一条 assistant message。来源：`packages/opencode/src/session/prompt.ts`、`packages/opencode/src/session/prompt.ts`。

现在可以完整复述：第一次模型请求选择工具，工具结果写进账本；第二轮从账本重建上下文，模型生成答案；第三次到达循环顶部时，看见任务已完成，于是退出。

## 7. 决定系统可靠性的五个分支

最小循环很短，工程质量却藏在停止条件和失败路径里。

### 7.1 有 `finish` 为什么还不能立刻停止

一些 provider 可能返回 `stop`，但 assistant message 中仍包含需要回送给模型的 tool call。OpenCode 会检查最近 assistant 的非 provider-executed tool parts；只有 finish 不是 `tool-calls`、不存在这类 tool part，而且 assistant 新于 user 时才退出。来源：`packages/opencode/src/session/prompt.ts`。

这里的通用教训是：**停止条件必须检查尚未闭合的副作用**，不能只相信一个 finish 字段。

### 7.2 工具失败和权限拒绝怎样收口

`failToolCall` 把 running part 变为 `error`，保留输入、错误信息和结束时间。权限或提问被拒绝时，是否阻断循环受 `experimental.continue_loop_on_deny` 影响；processor 随后可能返回 `stop`。来源：`packages/opencode/src/session/processor.ts`、`packages/opencode/src/session/processor.ts`。

这是已由控制流证明的行为。至于某个具体工具需要哪些权限，必须继续查看该工具实现，不能从核心循环推断。

### 7.3 上下文太长不是普通 stop

当 step 使用量溢出，或捕获到 `ContextOverflowError`，processor 设置 `needsCompaction`，返回 `compact`。`runLoop` 随后创建 compaction 工作，再继续循环。来源：`packages/opencode/src/session/processor.ts`、`packages/opencode/src/session/processor.ts`、`packages/opencode/src/session/prompt.ts`。

因此 compaction 是 OpenCode 加在通用内核外的**上下文维护层**，不是“模型回答失败后随便重试”。

### 7.4 同一个 session 如何避免出现两个调度者

公开入口 `loop` 不直接调用 `runLoop`，而是交给 `SessionRunState.ensureRunning`。`SessionRunState` 按 `sessionID` 保存 runner，并统一处理 busy、idle、cancel 与 interrupt。来源：`packages/opencode/src/session/prompt.ts`、`packages/opencode/src/session/run-state.ts`。

从这些文件可以确认“每个 session 复用一个受管理的 runner”。至于并发调用方是等待、复用还是以何种细节排队，由更底层 `Runner.ensureRunning` 决定，本章未检查该文件，因此不把它简单描述成 Java `synchronized` 锁。

### 7.5 怎样防止无限行动

OpenCode 有两类可见护栏：

- agent 的 `steps` 形成最大步数；到最后一步时，loop 给模型追加 `MAX_STEPS` 提示。来源：`packages/opencode/src/session/prompt.ts`、`packages/opencode/src/session/prompt.ts`。
- processor 发现最近 3 个 part 都是同名、同输入的工具调用时，请求 `doom_loop` 权限。来源：`packages/opencode/src/session/processor.ts`、`packages/opencode/src/session/processor.ts`。

第二条不是无条件终止；源码显示它转向权限询问。因此更准确的说法是“检测重复调用并设置人工/策略检查点”。

## 8. OpenCode 的选择：替代方案与代价

| 设计问题 | OpenCode 的选择 | 可选做法 | 当前选择的收益与代价 |
| --- | --- | --- | --- |
| 中间状态放哪里 | message + parts 持久化 | 只放内存局部变量 | 易恢复、易订阅、易审计；数据模型和转换更复杂 |
| provider 差异放哪里 | `LLM.stream` 输出统一 `LLMEvent` | processor 直接处理各 SDK event | session 层稳定；需要维护 adapter |
| 工具在哪里执行 | tool wrapper 的 `execute` | loop 手写 `switch(toolName)` | registry、MCP、插件可扩展；调用链更长 |
| 何时判断继续 | processor 返回局部结果，外循环综合历史 | LLM gateway 直接决定整个任务结束 | 职责更清晰；停止逻辑分布在两处 |
| 超长上下文怎么办 | 把 compaction 作为待处理工作再入循环 | 直接丢弃旧消息或失败 | 保留任务连续性；增加消息重排与最新状态判断难度 |

这些“为什么”有两种证据强度：表中选择本身由源码直接证明；收益与代价是基于结构作出的设计解释，不是源码注释中的原话。

## 9. TypeScript / Effect：只学会挡路的三处

### 9.1 `Effect.gen` 与 `yield*`

```ts
Effect.gen(function* () {
  const model = yield* getModel(/* ... */)
  const result = yield* handle.process(/* ... */)
})
```

这里不是用 generator 产出序列，而是用近似同步的写法组合 Effect。可以临时类比 Reactor 链或带依赖/错误通道的 `CompletableFuture`；但 Effect 还编码环境、错误和资源作用域，不能等同于普通 future。

### 9.2 字符串联合类型

```ts
export type Result = "compact" | "stop" | "continue"
```

来源：`packages/opencode/src/session/processor.ts`

它在这里扮演轻量状态机事件，作用接近 Java enum，但运行时仍是字符串。

### 9.3 discriminated union

`ToolState` 的每个成员都有不同的 `status`。检查 `part.state.status === "completed"` 后，TypeScript 就能缩小到带 `output` 的状态。它接近 Java sealed hierarchy，但 OpenCode 的 Schema 还提供运行时数据边界。

## 10. 可以带走的方法

### 方法一：把 Agent 写成“状态推进器”，不要写成超长回调

每一轮只做四步：读稳定状态、准备请求、执行一步、写回结果。下一轮从存储状态恢复，而不是依赖上一次函数栈里的隐式变量。

验证问题：进程在工具完成后、第二次模型请求前中断，你的系统能否仅凭已保存数据继续？

### 方法二：先统一事件，再更新领域状态

provider adapter 先把外部事件统一为内部事件；processor 再把内部事件转换成 `TextPart`、`ToolPart`。这样 provider 变化不会直接污染 session 模型。

验证问题：接入第二家模型 provider 时，你需要修改业务状态机，还是只需增加/调整 adapter？

### 方法三：把停止条件写成“没有未完成工作”

不要只写 `finish === "stop"`。同时检查未闭合工具调用、错误、权限阻塞、压缩任务和最大步数。

验证问题：模型声称停止，但刚刚发出了工具调用，你的 loop 会丢掉结果吗？

## 11. 费曼复述与练习阶梯

### 11.1 60 秒复述

不要看上文，用自己的话补全：

> OpenCode 先把 ______ 写进 session。`runLoop` 每轮从 ______ 重建上下文。模型产生 tool call 后，______ 执行工具，______ 把事件写成 ToolPart。下一轮由 ______ 把 completed ToolPart 转成模型消息。只有当 ______ 时，外循环才结束。

如果你在“工具结果怎样再次进入模型”处卡住，请回看 `packages/opencode/src/session/message-v2.ts`，不要用“框架自动处理”代替解释。

### 11.2 读源码练习

1. 入门：从 `packages/opencode/src/cli/cmd/run.ts` 追到 `SessionPrompt.prompt`，写下跨过的入口边界。
2. 进阶：给 `pending -> running -> completed` 的每条边标出对应 processor case。
3. 辨析：说明 `SessionTools.resolve`、`LLM.stream`、`SessionProcessor.process` 三者为什么不能合并成“工具模块”。
4. 失败路径：把工具权限拒绝和上下文溢出的状态迁移分别画出来。

### 11.3 小实现

写一个内存版 mini agent：

- `messages` 用数组保存；
- 只有一个 `readFakeFile` 工具；
- 假模型第一次返回 tool call，第二次返回 text；
- 每次模型调用前必须从 `messages` 重建输入；
- 加入一个明确的最大步数，以及“同工具同参数连续 3 次”的检查点。

验收标准不是“打印出了答案”，而是你能在日志里看到两次模型请求和完整的工具状态迁移。

## 12. 最后复盘：把整台机器重新装回去

本章的唯一内核是：

```text
读 session -> 调模型 -> 执行工具 -> 写回 part -> 再读 session -> 结束或继续
```

围绕它，OpenCode 又加上了：

- `SessionRunState` 管理 session runner 与中断；
- `LLM.stream` 适配 provider/runtime；
- `SessionProcessor` 将流事件变成可观察、可恢复的 parts；
- `SessionTools.resolve` 接入 registry、MCP、权限和插件；
- compaction、subtask、structured output 与 doom-loop 检查处理产品级边界。

你现在应该能回答中心问题：模型第一次只调用工具时，OpenCode 不是等待“模型自己继续”，而是把工具结果写入 session，再由外循环重建下一次模型请求。

下一章自然要追问：`SessionTools.resolve` 收到的 registry 工具究竟从哪里来？一个 `read`、`edit` 或 `shell` 工具怎样声明 schema、申请权限、截断输出并返回附件？这正是“Tool 调用系统”要拆开的下一层。
