跳转到内容

Agent 核心循环

v1.18.16 源码提交 a3647eb025c7 · 官方 Release
状态已完成
难度较难
预计阅读45 分钟
  • 章节 ID:03-agent-core-loop
  • 章节摘要:沿一次“读取文件再回答”的典型源码路径,理解 OpenCode 如何以 session 消息为账本,执行工具、回填结果,并决定继续、压缩或停止。
  • 教程版本:v1.18.16
  • 源码基线:a3647eb025c7615159d417dcc49fc39fdaeba65b
  • 章节元数据:/versions/v1-18-16/data/chapters.json
  • 源码映射:/versions/v1-18-16/data/source-map.json
  • packages/opencode/src/session/prompt.ts
  • packages/opencode/src/session/processor.ts
  • packages/opencode/src/session/run-state.ts
  • packages/opencode/src/session/tools.ts
  • packages/opencode/src/session/llm.ts
  • packages/opencode/src/session/llm/ai-sdk.ts
  • packages/opencode/src/session/message-v2.ts
  • packages/opencode/src/cli/cmd/run.ts
  • packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts

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

学完这一章,你应该能够:

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

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

本章只追一个中心问题:

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

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

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

finish reason 与真实 tool parts 一起决定是否退出。

1081    const runLoop: (sessionID: SessionID) => Effect.Effect<SessionV1.WithParts> = Effect.fn("SessionPrompt.run")(agent 核心循环。1082      function* (sessionID: SessionID) {定义一段可复用逻辑。1083        const ctx = yield* InstanceState.context等待 Effect 结果。1084        let structured: unknown1085        let step = 01086        const session = yield* sessions.get(sessionID).pipe(Effect.orDie)Effect 异步工作流。10871088        while (true) {agent 核心循环。1089          yield* status.set(sessionID, { type: "busy" })等待 Effect 结果。1090          yield* Effect.logInfo("loop", { "session.id": sessionID, step })Effect 异步工作流。10911092          let msgs = yield* MessageV2.filterCompactedEffect(sessionID).pipe(会话消息片段结构。1093            Effect.provideService(Database.Service, database),Effect 异步工作流。1094          )10951096          const { user: lastUser, assistant: lastAssistant, finished: lastFinished, tasks } = MessageV2.latest(msgs)会话消息片段结构。10971098          if (!lastUser) throw new Error("No user message found in stream. This should never happen.")按条件进入分支。10991100          const lastAssistantMsg = msgs.findLast(1101            (msg) => msg.info.role === "assistant" && msg.info.id === lastAssistant?.id,1102          )1103          // Some providers return "stop" even when the assistant message contains1104          // tool calls. Keep the loop running so tool results can be sent back to1105          // the model, but ignore cleanup-marked interrupted orphans.1106          const hasToolCalls =1107            lastAssistantMsg?.parts.some(1108              (part) => part.type === "tool" && !part.metadata?.providerExecuted && !isOrphanedInterruptedTool(part),选择模型或 provider。1109            ) ?? false11101111          if (按条件进入分支。1112            lastAssistant?.finish &&1113            !["tool-calls"].includes(lastAssistant.finish) &&1114            !hasToolCalls &&1115            lastAssistant.parentID === lastUser.id1116          ) {1117            const orphan = lastAssistantMsg?.parts.find(1118              (part): part is SessionV1.ToolPart => part.type === "tool" && isOrphanedInterruptedTool(part),会话消息片段结构。1119            )1120            if (orphan) {按条件进入分支。1121              yield* Effect.logWarning("loop exit with orphaned interrupted tool", {Effect 异步工作流。1122                "session.id": sessionID,1123                messageID: lastAssistant.id,1124                tool: orphan.tool,1125                callID: orphan.callID,1126              })1127            }1128            yield* Effect.logInfo("exiting loop", { "session.id": sessionID })Effect 异步工作流。1129            break1130          }
processor 消费统一模型流 packages/opencode/src/session/processor.ts:627-699

processor 负责把一轮流事件收敛到 assistant message 与 ToolPart。

627      const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) {处理模型流事件。628        yield* Effect.logInfo("process", {Effect 异步工作流。629          "session.id": input.sessionID,630          messageID: input.assistantMessage.id,把流事件写回消息。631        })632        ctx.needsCompaction = false633        ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true读取运行配置。634635        return yield* Effect.gen(function* () {Effect 异步工作流。636          yield* Effect.gen(function* () {Effect 异步工作流。637            ctx.currentText = undefined638            ctx.reasoningMap = {}639            yield* status.set(ctx.sessionID, { type: "busy" })等待 Effect 结果。640            const stream = llm.stream(streamInput)641642            yield* stream.pipe(等待 Effect 结果。643              Stream.tap((event) => handleEvent(event)),644              Stream.takeUntil(() => ctx.needsCompaction),645              Stream.runDrain,646            )647          }).pipe(648            Effect.onInterrupt(() =>Effect 异步工作流。649              Effect.gen(function* () {Effect 异步工作流。650                aborted = true651                if (!ctx.assistantMessage.error) {按条件进入分支。652                  yield* halt(new DOMException("Aborted", "AbortError"))等待 Effect 结果。653                }654              }),655            ),656            Effect.catchCauseIf(Effect 异步工作流。657              (cause) => !Cause.hasInterruptsOnly(cause),658              (cause) => Effect.fail(Cause.squash(cause)),Effect 异步工作流。659            ),660            Effect.retry(Effect 异步工作流。661              SessionRetry.policy({662                provider: input.model.providerID,选择模型或 provider。663                parse,664                set: (info) => {665                  return status.set(ctx.sessionID, {返回给上一层。666                    type: "retry",667                    attempt: info.attempt,668                    message: info.message,把流事件写回消息。669                    action: info.action,670                    next: info.next,671                  })672                },673              }),674            ),675            Effect.catch(halt),Effect 异步工作流。676            Effect.ensuring(cleanup()),Effect 异步工作流。677          )678679          if (ctx.needsCompaction) return "compact"按条件进入分支。680          if (ctx.blocked || ctx.assistantMessage.error) return "stop"按条件进入分支。681          return "continue"返回给上一层。682        })683      })684685      return {返回给上一层。686        get message() {把流事件写回消息。687          return ctx.assistantMessage返回给上一层。688        },689        updateToolCall,690        completeToolCall,691        process,692      } satisfies Handle693    })694695    return Service.of({ create })返回给上一层。696  }),697)698699export const node = LayerNode.make({对外暴露模块成员。

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

Section titled “2. 为什么现在必须理解这个循环”

普通聊天可以近似成:

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

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

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

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

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

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

3. 先画地图:循环在哪里,边界又在哪里

Section titled “3. 先画地图:循环在哪里,边界又在哪里”

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

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 何时完成
SessionProcessorLLMEvent 写成 message parts不选择本轮 agent 和 model
SessionTools.resolve把 registry/MCP 工具包装为可执行工具并接入权限不决定模型会调用哪一个工具
MessageV2定义并转换可持久化的消息/part不执行工具

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

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

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

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

1async function agentLoop(sessionID: string) {定义一段可复用逻辑。2  while (true) {持续循环到退出条件。3    const history = await loadCompactedHistory(sessionID)4    if (isFinished(history)) break按条件进入分支。56    const request = await prepareModelRequest(history)7    for await (const event of llmStream(request)) {消费异步流。8      await persistEventAsMessagePart(event)9    }1011    const outcome = inspectPersistedState()12    if (outcome === "stop") break按条件进入分支。13    if (outcome === "compact") await enqueueCompaction(sessionID)按条件进入分支。14  }15}

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

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

Section titled “4.1 不要把三种“循环”混在一起”
层次真实标识符一次循环处理什么结束意味着什么
session 外循环SessionPrompt.runLoopwhile (true)一次完整模型请求及其结果整个用户任务暂时完成或失败
stream 消费Stream.runDrain一个 LLMEvent本次模型流已消费完
工具执行AI SDK tool 的 execute一个具体工具调用工具结果产生,但 Agent 未必完成

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

5.1 Message 是一页,Part 是页内的记录

Section titled “5.1 Message 是一页,Part 是页内的记录”

MessageV2.WithPartsinfoparts 组成:

1export type WithParts = {定义数据结构约束。2  info: Info3  parts: Part[]4}

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

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

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

Section titled “5.2 ToolPart 本身就是一个小状态机”
tool-input-start tool-call tool-result / tool-error
| | |
v v v
pending ----------> running ----------> completed / error

真实类型由 ToolStatePendingToolStateRunningToolStateCompletedToolStateError 组成,并以 status 作为判别字段。来源:packages/opencode/src/session/message-v2.ts

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

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

Section titled “6. 追一条典型源码链路:读取 package.json 再回答”

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

读取 package.json,告诉我有哪些脚本。

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

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

Section titled “6.1 第一站:入口只负责把请求送进 session”

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

1const result = await client.session.prompt({把输入交给会话主流程。2  sessionID,3  agent,4  model,5  variant: args.variant,6  parts: [...files, { type: "text", text: message }],7})

来源: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 先记账,再启动循环

Section titled “6.2 第二站:prompt 先记账,再启动循环”

SessionPrompt.prompt 先清理 revert 状态,创建 user message,更新 session;只有 noReplytrue 时才只记账不运行。

1const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)Effect 异步工作流。2yield* revert.cleanup(session)等待 Effect 结果。3const message = yield* createUserMessage(input)等待 Effect 结果。4yield* sessions.touch(input.sessionID)等待 Effect 结果。5// ...把 input.tools 转成 session permission...6if (input.noReply === true) return message按条件进入分支。7return yield* loop({ sessionID: input.sessionID })等待 Effect 结果。

来源:packages/opencode/src/session/prompt.ts

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

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

6.3 第三站:runLoop 每轮都重新读取最新事实

Section titled “6.3 第三站:runLoop 每轮都重新读取最新事实”

真实外循环从这里开始:

1while (true) {持续循环到退出条件。2  yield* status.set(sessionID, { type: "busy" })等待 Effect 结果。3  let msgs = yield* MessageV2.filterCompactedEffect(sessionID)会话消息片段结构。4  const { user: lastUser, assistant: lastAssistant, finished: lastFinished, tasks } =5    MessageV2.latest(msgs)会话消息片段结构。6  // 判断退出、subtask、compaction,再进入普通模型调用7}

来源: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 第四站:为这一次模型请求准备“人、资料、工具”

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

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

接着准备三类输入:

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

对应源码:packages/opencode/src/session/prompt.ts

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

1const result = yield* handle.process({等待 Effect 结果。2  user: lastUser,3  agent,4  permission: session.permission,5  sessionID,6  system,7  messages: modelMsgs,8  tools,9  model,10})

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

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

Section titled “6.5 第五站:工具为什么能够“真的执行””

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

1tools[item.id] = tool({2  description: item.description,3  inputSchema: jsonSchema(schema),4  execute(args, options) {工具真正执行入口。5    return run.promise(返回给上一层。6      Effect.gen(function* () {Effect 异步工作流。7        const ctx = context(args, options)8        yield* plugin.trigger("tool.execute.before", /* ... */)调用插件扩展点。9        const result = yield* item.execute(args, ctx)工具真正执行入口。10        yield* plugin.trigger("tool.execute.after", /* ... */)调用插件扩展点。11        return output返回给上一层。12      }),13    )14  },15})

节选并省略附件处理;来源:packages/opencode/src/session/tools.ts

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

6.6 第六站:模型流先被统一,再由 processor 记账

Section titled “6.6 第六站:模型流先被统一,再由 processor 记账”

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

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

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

1const stream = llm.stream(streamInput)2yield* stream.pipe(等待 Effect 结果。3  Stream.tap((event) => handleEvent(event)),4  Stream.takeUntil(() => ctx.needsCompaction),5  Stream.runDrain,6)

来源:packages/opencode/src/session/processor.ts

对我们的例子,关键事件是:

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

证据分别位于 packages/opencode/src/session/processor.tspackages/opencode/src/session/processor.tspackages/opencode/src/session/processor.tspackages/opencode/src/session/processor.ts

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

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

Section titled “6.7 第七站:工具结果怎样进入下一次推理”

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

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

1if (part.state.status === "completed") {按条件进入分支。2  assistantMessage.parts.push({3    type: ("tool-" + part.tool) as `tool-${string}`,4    state: "output-available",5    toolCallId: part.callID,6    input: part.state.input,7    output,8  })9}

节选并省略 provider metadata;来源:packages/opencode/src/session/message-v2.ts

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

6.8 第八站:第二次模型请求给出文本,循环退出

Section titled “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 最终只返回三种调度结果:

1if (ctx.needsCompaction) return "compact"按条件进入分支。2if (ctx.blocked || ctx.assistantMessage.error) return "stop"按条件进入分支。3return "continue"返回给上一层。

来源:packages/opencode/src/session/processor.ts

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

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

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

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

Section titled “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 工具失败和权限拒绝怎样收口

Section titled “7.2 工具失败和权限拒绝怎样收口”

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

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

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

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

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

Section titled “7.4 同一个 session 如何避免出现两个调度者”

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

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

OpenCode 有两类可见护栏:

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

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

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

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

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

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

Section titled “9. TypeScript / Effect:只学会挡路的三处”
1Effect.gen(function* () {Effect 异步工作流。2  const model = yield* getModel(/* ... */)等待 Effect 结果。3  const result = yield* handle.process(/* ... */)等待 Effect 结果。4})

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

1export type Result = "compact" | "stop" | "continue"定义数据结构约束。

来源:packages/opencode/src/session/processor.ts

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

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

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

Section titled “方法一:把 Agent 写成“状态推进器”,不要写成超长回调”

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

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

方法二:先统一事件,再更新领域状态

Section titled “方法二:先统一事件,再更新领域状态”

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

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

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

Section titled “方法三:把停止条件写成“没有未完成工作””

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

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

不要看上文,用自己的话补全:

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

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

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

写一个内存版 mini agent:

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

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

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

Section titled “12. 最后复盘:把整台机器重新装回去”

本章的唯一内核是:

读 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 工具究竟从哪里来?一个 readeditshell 工具怎样声明 schema、申请权限、截断输出并返回附件?这正是“Tool 调用系统”要拆开的下一层。