Agent 核心循环
a3647eb025c7 · 官方 Release
Agent 生成档案
Section titled “Agent 生成档案”- 章节 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
主要源码路径
Section titled “主要源码路径”packages/opencode/src/session/prompt.tspackages/opencode/src/session/processor.tspackages/opencode/src/session/run-state.tspackages/opencode/src/session/tools.tspackages/opencode/src/session/llm.tspackages/opencode/src/session/llm/ai-sdk.tspackages/opencode/src/session/message-v2.tspackages/opencode/src/cli/cmd/run.tspackages/opencode/src/server/routes/instance/httpapi/handlers/session.ts
本章以 OpenCode 源码版本
v1.18.16(提交a3647eb025c7)为证据基线。我们用一个典型场景追踪源码:模型第一次只发出读取package.json的工具调用时,OpenCode 怎样执行工具、拿到结果,再让模型给出最终回答?这是一条由源码证明的执行路径,不是一次真实会话的运行录屏。
0. 本章学习目标
Section titled “0. 本章学习目标”学完这一章,你应该能够:
- 画出
prompt -> runLoop -> SessionProcessor -> LLM -> tool -> message history的位置图。 - 区分“一次模型请求”“一次流事件处理”和“跨请求的 agent 循环”。
- 沿源码解释一个
read类工具调用如何经历pending -> running -> completed。 - 根据源码判断循环为什么继续、何时停止、何时转去压缩上下文。
- 说清哪些是所有 tool-using agent 都需要的最小机制,哪些是 OpenCode 的产品层。
- 为自己的 mini agent 设计一个可恢复、可观察的最小循环。
1. 一句话讲明白
Section titled “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 相比旧基线改变了什么
Section titled “v1.18.16 相比旧基线改变了什么”- 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 | vSessionPrompt.prompt 记入 user message | vSessionPrompt.loop 管理同一 session 的 runner | vSessionPrompt.runLoop 每轮重读账本并调度 | | | | | +--> SessionTools.resolve 准备可执行工具 | +-----------> MessageV2 转换模型上下文 vSessionProcessor.process 消费统一的 LLMEvent | vLLM.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 行伪代码
Section titled “4. 最小机制:先看 12 行伪代码”先不要读 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 源码的逐行翻译,而是从 runLoop、process 和 handleEvent 抽出的教学骨架。OpenCode 的真实实现还要处理 subtask、权限拒绝、重试、中断和 provider 差异。
4.1 不要把三种“循环”混在一起
Section titled “4.1 不要把三种“循环”混在一起”| 层次 | 真实标识符 | 一次循环处理什么 | 结束意味着什么 |
|---|---|---|---|
| session 外循环 | SessionPrompt.runLoop 的 while (true) | 一次完整模型请求及其结果 | 整个用户任务暂时完成或失败 |
| stream 消费 | Stream.runDrain | 一个 LLMEvent | 本次模型流已消费完 |
| 工具执行 | AI SDK tool 的 execute | 一个具体工具调用 | 工具结果产生,但 Agent 未必完成 |
“工具执行结束”不等于“Agent 结束”。它通常只是把缺失事实补进账本,等待 session 外循环发起下一次模型请求。
5. 读源码前,只补两个概念
Section titled “5. 读源码前,只补两个概念”5.1 Message 是一页,Part 是页内的记录
Section titled “5.1 Message 是一页,Part 是页内的记录”MessageV2.WithParts 由 info 和 parts 组成:
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真实类型由 ToolStatePending、ToolStateRunning、ToolStateCompleted、ToolStateError 组成,并以 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;只有 noReply 为 true 时才只记账不运行。
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.ts、packages/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.ts、packages/opencode/src/session/prompt.ts。
接着准备三类输入:
SessionTools.resolve(...):把 registry 与 MCP 工具包装成模型可调用的工具。sys.environment、instruction.system、sys.skills:组成 system 内容。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.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 消费这条统一事件流:
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
对我们的例子,关键事件是:
tool-input-start:ensureToolCall创建pending的ToolPart。tool-call:processor 写入解析后的参数,状态转为running。- AI SDK 调用上一节注册的
execute,实际执行 read 类工具。 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 第七站:工具结果怎样进入下一次推理
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.ts、packages/opencode/src/session/prompt.ts。
现在可以完整复述:第一次模型请求选择工具,工具结果写进账本;第二轮从账本重建上下文,模型生成答案;第三次到达循环顶部时,看见任务已完成,于是退出。
7. 决定系统可靠性的五个分支
Section titled “7. 决定系统可靠性的五个分支”最小循环很短,工程质量却藏在停止条件和失败路径里。
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.ts、packages/opencode/src/session/processor.ts。
这是已由控制流证明的行为。至于某个具体工具需要哪些权限,必须继续查看该工具实现,不能从核心循环推断。
7.3 上下文太长不是普通 stop
Section titled “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 如何避免出现两个调度者
Section titled “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 怎样防止无限行动
Section titled “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 的选择:替代方案与代价
Section titled “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:只学会挡路的三处
Section titled “9. TypeScript / Effect:只学会挡路的三处”9.1 Effect.gen 与 yield*
Section titled “9.1 Effect.gen 与 yield*”1Effect.gen(function* () {Effect 异步工作流。2 const model = yield* getModel(/* ... */)等待 Effect 结果。3 const result = yield* handle.process(/* ... */)等待 Effect 结果。4})
这里不是用 generator 产出序列,而是用近似同步的写法组合 Effect。可以临时类比 Reactor 链或带依赖/错误通道的 CompletableFuture;但 Effect 还编码环境、错误和资源作用域,不能等同于普通 future。
9.2 字符串联合类型
Section titled “9.2 字符串联合类型”1export type Result = "compact" | "stop" | "continue"定义数据结构约束。
来源:packages/opencode/src/session/processor.ts
它在这里扮演轻量状态机事件,作用接近 Java enum,但运行时仍是字符串。
9.3 discriminated union
Section titled “9.3 discriminated union”ToolState 的每个成员都有不同的 status。检查 part.state.status === "completed" 后,TypeScript 就能缩小到带 output 的状态。它接近 Java sealed hierarchy,但 OpenCode 的 Schema 还提供运行时数据边界。
10. 可以带走的方法
Section titled “10. 可以带走的方法”方法一:把 Agent 写成“状态推进器”,不要写成超长回调
Section titled “方法一:把 Agent 写成“状态推进器”,不要写成超长回调”每一轮只做四步:读稳定状态、准备请求、执行一步、写回结果。下一轮从存储状态恢复,而不是依赖上一次函数栈里的隐式变量。
验证问题:进程在工具完成后、第二次模型请求前中断,你的系统能否仅凭已保存数据继续?
方法二:先统一事件,再更新领域状态
Section titled “方法二:先统一事件,再更新领域状态”provider adapter 先把外部事件统一为内部事件;processor 再把内部事件转换成 TextPart、ToolPart。这样 provider 变化不会直接污染 session 模型。
验证问题:接入第二家模型 provider 时,你需要修改业务状态机,还是只需增加/调整 adapter?
方法三:把停止条件写成“没有未完成工作”
Section titled “方法三:把停止条件写成“没有未完成工作””不要只写 finish === "stop"。同时检查未闭合工具调用、错误、权限阻塞、压缩任务和最大步数。
验证问题:模型声称停止,但刚刚发出了工具调用,你的 loop 会丢掉结果吗?
11. 费曼复述与练习阶梯
Section titled “11. 费曼复述与练习阶梯”11.1 60 秒复述
Section titled “11.1 60 秒复述”不要看上文,用自己的话补全:
OpenCode 先把 ______ 写进 session。
runLoop每轮从 ______ 重建上下文。模型产生 tool call 后,______ 执行工具,______ 把事件写成 ToolPart。下一轮由 ______ 把 completed ToolPart 转成模型消息。只有当 ______ 时,外循环才结束。
如果你在“工具结果怎样再次进入模型”处卡住,请回看 packages/opencode/src/session/message-v2.ts,不要用“框架自动处理”代替解释。
11.2 读源码练习
Section titled “11.2 读源码练习”- 入门:从
packages/opencode/src/cli/cmd/run.ts追到SessionPrompt.prompt,写下跨过的入口边界。 - 进阶:给
pending -> running -> completed的每条边标出对应 processor case。 - 辨析:说明
SessionTools.resolve、LLM.stream、SessionProcessor.process三者为什么不能合并成“工具模块”。 - 失败路径:把工具权限拒绝和上下文溢出的状态迁移分别画出来。
11.3 小实现
Section titled “11.3 小实现”写一个内存版 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 工具究竟从哪里来?一个 read、edit 或 shell 工具怎样声明 schema、申请权限、截断输出并返回附件?这正是“Tool 调用系统”要拆开的下一层。