跳转到内容

Agent 核心循环

旧版 eec0843c 源码提交 eec0843ce422
状态已完成
难度较难
预计阅读45 分钟
  • 章节 ID:03-agent-core-loop
  • 章节摘要:沿一次“读取文件再回答”的典型源码路径,理解 OpenCode 如何以 session 消息为账本,执行工具、回填结果,并决定继续、压缩或停止。
  • 教程版本:eec0843c
  • 源码基线:eec0843ce42298080569ca31a6455bc3f699d213
  • 章节元数据:/versions/eec0843c/data/chapters.json
  • 源码映射:/versions/eec0843c/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 源码版本 eec0843ce422 为证据基线。我们用一个典型场景追踪源码:模型第一次只发出读取 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.ts:1240-1481packages/opencode/src/session/processor.ts:779-847

packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:1240-1481
1240    const runLoop: (sessionID: SessionID) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.run")(agent 核心循环。1241      function* (sessionID: SessionID) {定义一段可复用逻辑。1242        const ctx = yield* InstanceState.context等待 Effect 结果。1243        const slog = elog.with({ sessionID })1244        let structured: unknown1245        let step = 01246        const session = yield* sessions.get(sessionID).pipe(Effect.orDie)Effect 异步工作流。12471248        while (true) {agent 核心循环。1249          yield* status.set(sessionID, { type: "busy" })等待 Effect 结果。1250          yield* slog.info("loop", { step })等待 Effect 结果。12511252          let msgs = yield* MessageV2.filterCompactedEffect(sessionID)会话消息片段结构。12531254          const { user: lastUser, assistant: lastAssistant, finished: lastFinished, tasks } = MessageV2.latest(msgs)会话消息片段结构。12551256          if (!lastUser) throw new Error("No user message found in stream. This should never happen.")按条件进入分支。12571258          const lastAssistantMsg = msgs.findLast(1259            (msg) => msg.info.role === "assistant" && msg.info.id === lastAssistant?.id,1260          )1261          // Some providers return "stop" even when the assistant message contains tool calls.1262          // Keep the loop running so tool results can be sent back to the model.1263          // Skip provider-executed tool parts — those were fully handled within the1264          // provider's stream (e.g. DWS Agent Platform) and don't need a re-loop.1265          const hasToolCalls =1266            lastAssistantMsg?.parts.some((part) => part.type === "tool" && !part.metadata?.providerExecuted) ?? false选择模型或 provider。12671268          if (按条件进入分支。1269            lastAssistant?.finish &&1270            !["tool-calls"].includes(lastAssistant.finish) &&1271            !hasToolCalls &&1272            lastUser.id < lastAssistant.id1273          ) {1274            yield* slog.info("exiting loop")等待 Effect 结果。1275            break1276          }12771278          step++1279          if (step === 1)按条件进入分支。1280            yield* title({等待 Effect 结果。1281              session,1282              modelID: lastUser.model.modelID,选择模型或 provider。1283              providerID: lastUser.model.providerID,选择模型或 provider。1284              history: msgs,1285            }).pipe(Effect.ignore, Effect.forkIn(scope))Effect 异步工作流。12861287          const model = yield* getModel(lastUser.model.providerID, lastUser.model.modelID, sessionID)选择模型或 provider。1288          const task = tasks.pop()12891290          if (task?.type === "subtask") {按条件进入分支。1291            yield* handleSubtask({ task, model, lastUser, sessionID, session, msgs })等待 Effect 结果。1292            continue1293          }12941295          if (task?.type === "compaction") {按条件进入分支。1296            const result = yield* compaction.process({等待 Effect 结果。1297              messages: msgs,1298              parentID: lastUser.id,1299              sessionID,1300              auto: task.auto,1301              overflow: task.overflow,1302            })1303            if (result === "stop") break按条件进入分支。1304            continue1305          }13061307          if (按条件进入分支。1308            lastFinished &&1309            lastFinished.summary !== true &&1310            (yield* compaction.isOverflow({ tokens: lastFinished.tokens, model }))等待 Effect 结果。1311          ) {1312            yield* compaction.create({ sessionID, agent: lastUser.agent, model: lastUser.model, auto: true })选择模型或 provider。1313            continue1314          }13151316          const agent = yield* agents.get(lastUser.agent)等待 Effect 结果。1317          if (!agent) {按条件进入分支。1318            const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)等待 Effect 结果。1319            const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""1320            const error = new NamedError.Unknown({ message: `Agent not found: "${lastUser.agent}".${hint}` })1321            yield* bus.publish(Session.Event.Error, { sessionID, error: error.toObject() })广播状态变化。1322            throw error失败时抛出错误。1323          }1324          const maxSteps = agent.steps ?? Infinity1325          const isLastStep = step >= maxSteps1326          msgs = yield* SessionReminders.apply({ messages: msgs, agent, session }).pipe(等待 Effect 结果。1327            Effect.provideService(RuntimeFlags.Service, flags),Effect 异步工作流。1328            Effect.provideService(AppFileSystem.Service, fsys),读写本地文件。1329            Effect.provideService(Session.Service, sessions),Effect 异步工作流。1330          )13311332          const msg: MessageV2.Assistant = {会话消息片段结构。1333            id: MessageID.ascending(),1334            parentID: lastUser.id,1335            role: "assistant",1336            mode: agent.name,1337            agent: agent.name,1338            variant: lastUser.model.variant,1339            path: { cwd: ctx.directory, root: ctx.worktree },1340            cost: 0,1341            tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },1342            modelID: model.id,选择模型或 provider。1343            providerID: model.providerID,选择模型或 provider。1344            time: { created: Date.now() },1345            sessionID,1346          }1347          yield* sessions.updateMessage(msg)等待 Effect 结果。13481349          const finalizeInterruptedAssistant = Effect.gen(function* () {Effect 异步工作流。1350            if (msg.time.completed) return按条件进入分支。1351            msg.error ??= MessageV2.fromError(new DOMException("Aborted", "AbortError"), {会话消息片段结构。1352              providerID: msg.providerID,选择模型或 provider。1353              aborted: true,1354            })1355            msg.time.completed = Date.now()1356            yield* sessions.updateMessage(msg)等待 Effect 结果。1357          })13581359          const handle = yield* processor等待 Effect 结果。1360            .create({1361              assistantMessage: msg,1362              sessionID,1363              model,1364            })1365            .pipe(Effect.onInterrupt(() => finalizeInterruptedAssistant))Effect 异步工作流。13661367          const outcome: "break" | "continue" = yield* Effect.gen(function* () {Effect 异步工作流。1368            const lastUserMsg = msgs.findLast((m) => m.info.role === "user")1369            const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false1370            const promptOps = yield* ops()等待 Effect 结果。13711372            const tools = yield* SessionTools.resolve({等待 Effect 结果。1373              agent,1374              session,1375              model,1376              processor: handle,1377              bypassAgentCheck,1378              messages: msgs,1379              promptOps,1380            }).pipe(1381              Effect.provideService(Plugin.Service, plugin),调用插件扩展点。1382              Effect.provideService(Permission.Service, permission),Effect 异步工作流。1383              Effect.provideService(ToolRegistry.Service, registry),Effect 异步工作流。1384              Effect.provideService(MCP.Service, mcp),Effect 异步工作流。1385              Effect.provideService(Truncate.Service, truncate),Effect 异步工作流。1386            )13871388            if (lastUser.format?.type === "json_schema") {按条件进入分支。1389              tools["StructuredOutput"] = createStructuredOutputTool({1390                schema: lastUser.format.schema,定义并校验数据形状。1391                onSuccess(output) {1392                  structured = output1393                },1394              })1395            }13961397            if (step === 1)按条件进入分支。1398              yield* summary.summarize({ sessionID, messageID: lastUser.id }).pipe(Effect.ignore, Effect.forkIn(scope))Effect 异步工作流。13991400            if (step > 1 && lastFinished) {按条件进入分支。1401              for (const m of msgs) {遍历集合。1402                if (m.info.role !== "user" || m.info.id <= lastFinished.id) continue按条件进入分支。1403                for (const p of m.parts) {遍历集合。1404                  if (p.type !== "text" || p.ignored || p.synthetic) continue按条件进入分支。1405                  if (!p.text.trim()) continue按条件进入分支。1406                  p.text = [1407                    "<system-reminder>",1408                    "The user sent the following message:",1409                    p.text,1410                    "",1411                    "Please address this message and continue with your tasks.",1412                    "</system-reminder>",1413                  ].join("\n")1414                }1415              }1416            }14171418            yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })调用插件扩展点。14191420            const [skills, env, instructions, modelMsgs] = yield* Effect.all([Effect 异步工作流。1421              sys.skills(agent),1422              sys.environment(model),1423              instruction.system().pipe(Effect.orDie),Effect 异步工作流。1424              MessageV2.toModelMessagesEffect(msgs, model),会话消息片段结构。1425            ])1426            const system = [...env, ...instructions, ...(skills ? [skills] : [])]1427            const format = lastUser.format ?? { type: "text" as const }1428            if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)按条件进入分支。1429            const result = yield* handle.process({等待 Effect 结果。1430              user: lastUser,1431              agent,1432              permission: session.permission,1433              sessionID,1434              parentSessionID: session.parentID,1435              system,1436              messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [])],1437              tools,1438              model,1439              toolChoice: format.type === "json_schema" ? "required" : undefined,1440            })14411442            if (structured !== undefined) {按条件进入分支。1443              handle.message.structured = structured1444              handle.message.finish = handle.message.finish ?? "stop"1445              yield* sessions.updateMessage(handle.message)等待 Effect 结果。1446              return "break" as const返回给上一层。1447            }14481449            const finished = handle.message.finish && !["tool-calls", "unknown"].includes(handle.message.finish)1450            if (finished && !handle.message.error) {按条件进入分支。1451              if (format.type === "json_schema") {按条件进入分支。1452                handle.message.error = new MessageV2.StructuredOutputError({会话消息片段结构。1453                  message: "Model did not produce structured output",1454                  retries: 0,1455                }).toObject()1456                yield* sessions.updateMessage(handle.message)等待 Effect 结果。1457                return "break" as const返回给上一层。1458              }1459            }14601461            if (result === "stop") return "break" as const按条件进入分支。1462            if (result === "compact") {按条件进入分支。1463              yield* compaction.create({等待 Effect 结果。1464                sessionID,1465                agent: lastUser.agent,1466                model: lastUser.model,选择模型或 provider。1467                auto: true,1468                overflow: !handle.message.finish,1469              })1470            }1471            return "continue" as const返回给上一层。1472          }).pipe(1473            Effect.ensuring(instruction.clear(handle.message.id)),Effect 异步工作流。1474            Effect.onInterrupt(() => finalizeInterruptedAssistant),Effect 异步工作流。1475          )1476          if (outcome === "break") break按条件进入分支。1477          continue1478        }14791480        yield* compaction.prune({ sessionID }).pipe(Effect.ignore, Effect.forkIn(scope))Effect 异步工作流。1481        return yield* lastAssistant(sessionID)等待 Effect 结果。
packages/opencode/src/session/processor.ts packages/opencode/src/session/processor.ts:779-847
779      const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) {处理模型流事件。780        slog.info("process")781        ctx.needsCompaction = false782        ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true读取运行配置。783784        return yield* Effect.gen(function* () {Effect 异步工作流。785          yield* Effect.gen(function* () {Effect 异步工作流。786            ctx.currentText = undefined787            ctx.reasoningMap = {}788            yield* status.set(ctx.sessionID, { type: "busy" })等待 Effect 结果。789            const stream = llm.stream(streamInput)790791            yield* stream.pipe(等待 Effect 结果。792              Stream.tap((event) => handleEvent(event)),793              Stream.takeUntil(() => ctx.needsCompaction),794              Stream.runDrain,795            )796          }).pipe(797            Effect.onInterrupt(() =>Effect 异步工作流。798              Effect.gen(function* () {Effect 异步工作流。799                aborted = true800                if (!ctx.assistantMessage.error) {按条件进入分支。801                  yield* halt(new DOMException("Aborted", "AbortError"))等待 Effect 结果。802                }803              }),804            ),805            Effect.catchCauseIf(Effect 异步工作流。806              (cause) => !Cause.hasInterruptsOnly(cause),807              (cause) => Effect.fail(Cause.squash(cause)),Effect 异步工作流。808            ),809            Effect.retry(Effect 异步工作流。810              SessionRetry.policy({811                provider: input.model.providerID,选择模型或 provider。812                parse,813                set: (info) => {814                  // TODO(v2): Temporary dual-write while migrating session messages to v2 events.815                  const event = flags.experimentalEventSystem816                    ? events.publish(SessionEvent.Retried, {广播状态变化。817                        sessionID: ctx.sessionID,818                        attempt: info.attempt,819                        error: {820                          message: info.message,把流事件写回消息。821                          isRetryable: true,822                        },823                        timestamp: DateTime.makeUnsafe(Date.now()),824                      })825                    : Effect.voidEffect 异步工作流。826                  return event.pipe(返回给上一层。827                    Effect.andThen(Effect 异步工作流。828                      status.set(ctx.sessionID, {829                        type: "retry",830                        attempt: info.attempt,831                        message: info.message,把流事件写回消息。832                        action: info.action,833                        next: info.next,834                      }),835                    ),836                  )837                },838              }),839            ),840            Effect.catch(halt),Effect 异步工作流。841            Effect.ensuring(cleanup()),Effect 异步工作流。842          )843844          if (ctx.needsCompaction) return "compact"按条件进入分支。845          if (ctx.blocked || ctx.assistantMessage.error) return "stop"按条件进入分支。846          return "continue"返回给上一层。847        })

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:554-561

packages/opencode/src/session/message-v2.ts packages/opencode/src/session/message-v2.ts:554-561
554export const WithParts = Schema.Struct({定义并校验数据形状。555  info: Info,556  parts: Schema.Array(Part),定义并校验数据形状。557})558export type WithParts = {定义数据结构约束。559  info: Info560  parts: Part[]561}

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:248-320

packages/opencode/src/session/message-v2.ts packages/opencode/src/session/message-v2.ts:248-320
248export const ToolStatePending = Schema.Struct({定义并校验数据形状。249  status: Schema.Literal("pending"),定义并校验数据形状。250  input: Schema.Record(Schema.String, Schema.Any),定义并校验数据形状。251  raw: Schema.String,定义并校验数据形状。252}).annotate({ identifier: "ToolStatePending" })253export type ToolStatePending = Types.DeepMutable<Schema.Schema.Type<typeof ToolStatePending>>定义并校验数据形状。254255export const ToolStateRunning = Schema.Struct({定义并校验数据形状。256  status: Schema.Literal("running"),定义并校验数据形状。257  input: Schema.Record(Schema.String, Schema.Any),定义并校验数据形状。258  title: Schema.optional(Schema.String),定义并校验数据形状。259  metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),定义并校验数据形状。260  time: Schema.Struct({定义并校验数据形状。261    start: NonNegativeInt,262  }),263}).annotate({ identifier: "ToolStateRunning" })264export type ToolStateRunning = Types.DeepMutable<Schema.Schema.Type<typeof ToolStateRunning>>定义并校验数据形状。265266export const ToolStateCompleted = Schema.Struct({定义并校验数据形状。267  status: Schema.Literal("completed"),定义并校验数据形状。268  input: Schema.Record(Schema.String, Schema.Any),定义并校验数据形状。269  output: Schema.String,定义并校验数据形状。270  title: Schema.String,定义并校验数据形状。271  metadata: Schema.Record(Schema.String, Schema.Any),定义并校验数据形状。272  time: Schema.Struct({定义并校验数据形状。273    start: NonNegativeInt,274    end: NonNegativeInt,275    compacted: Schema.optional(NonNegativeInt),定义并校验数据形状。276  }),277  attachments: Schema.optional(Schema.Array(FilePart)),定义并校验数据形状。278}).annotate({ identifier: "ToolStateCompleted" })279export type ToolStateCompleted = Types.DeepMutable<Schema.Schema.Type<typeof ToolStateCompleted>>定义并校验数据形状。280281function truncateToolOutput(text: string, maxChars?: number) {定义一段可复用逻辑。282  if (!maxChars || text.length <= maxChars) return text按条件进入分支。283  const omitted = text.length - maxChars284  return `${text.slice(0, maxChars)}\n[Tool output truncated for compaction: omitted ${omitted} chars]`返回给上一层。285}286287export const ToolStateError = Schema.Struct({定义并校验数据形状。288  status: Schema.Literal("error"),定义并校验数据形状。289  input: Schema.Record(Schema.String, Schema.Any),定义并校验数据形状。290  error: Schema.String,定义并校验数据形状。291  metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),定义并校验数据形状。292  time: Schema.Struct({定义并校验数据形状。293    start: NonNegativeInt,294    end: NonNegativeInt,295  }),296}).annotate({ identifier: "ToolStateError" })297export type ToolStateError = Types.DeepMutable<Schema.Schema.Type<typeof ToolStateError>>定义并校验数据形状。298299export const ToolState = Schema.Union([定义并校验数据形状。300  ToolStatePending,301  ToolStateRunning,302  ToolStateCompleted,303  ToolStateError,304]).annotate({305  discriminator: "status",306  identifier: "ToolState",307})308export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError定义数据结构约束。309310export const ToolPart = Schema.Struct({定义并校验数据形状。311  ...partBase,312  type: Schema.Literal("tool"),定义并校验数据形状。313  callID: Schema.String,定义并校验数据形状。314  tool: Schema.String,定义并校验数据形状。315  state: ToolState,316  metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),定义并校验数据形状。317}).annotate({ identifier: "ToolPart" })会话消息片段结构。318export type ToolPart = Omit<Types.DeepMutable<Schema.Schema.Type<typeof ToolPart>>, "state"> & {定义并校验数据形状。319  state: ToolState320}

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:791-798

packages/opencode/src/cli/cmd/run.ts packages/opencode/src/cli/cmd/run.ts:791-798
791          const model = pick(args.model)792          const result = await client.session.prompt({把输入交给会话主流程。793            sessionID,794            agent,795            model,796            variant: args.variant,797            parts: [...files, { type: "text", text: message }],798          })

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

packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts:279-293
279    const prompt = Effect.fn("SessionHttpApi.prompt")(function* (ctx: {Effect 异步工作流。280      params: { sessionID: SessionID }281      payload: typeof PromptPayload.Type282    }) {283      yield* requireSession(ctx.params.sessionID)等待 Effect 结果。284      const message = yield* promptSvc等待 Effect 结果。285        .prompt({286          ...ctx.payload,287          sessionID: ctx.params.sessionID,288        })289        .pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))Effect 异步工作流。290      return HttpServerResponse.stream(Stream.make(JSON.stringify(message)).pipe(Stream.encodeText), {返回给上一层。291        contentType: "application/json",292      })293    })

这说明 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:1211-1229

packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:1211-1229
1211    const prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts, Image.Error> = Effect.fn(会话消息片段结构。1212      "SessionPrompt.prompt",把输入交给会话主流程。1213    )(function* (input: PromptInput) {1214      const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)Effect 异步工作流。1215      yield* revert.cleanup(session)等待 Effect 结果。1216      const message = yield* createUserMessage(input)等待 Effect 结果。1217      yield* sessions.touch(input.sessionID)等待 Effect 结果。12181219      const permissions: Permission.Ruleset = []1220      for (const [t, enabled] of Object.entries(input.tools ?? {})) {遍历集合。1221        permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" })1222      }1223      if (permissions.length > 0) {按条件进入分支。1224        session.permission = permissions1225        yield* sessions.setPermission({ sessionID: session.id, permission: permissions })等待 Effect 结果。1226      }12271228      if (input.noReply === true) return message按条件进入分支。1229      return yield* loop({ sessionID: input.sessionID })等待 Effect 结果。

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

packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:689-731
689    const createUserMessage = Effect.fn("SessionPrompt.createUserMessage")(function* (input: PromptInput) {Effect 异步工作流。690      const agentName = input.agent691      const ag = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo()等待 Effect 结果。692      if (!ag) {按条件进入分支。693        const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)等待 Effect 结果。694        const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""695        const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` })696        yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })广播状态变化。697        throw error失败时抛出错误。698      }699700      const current = Database.use((db) =>701        db702          .select({ agent: SessionTable.agent, model: SessionTable.model })选择模型或 provider。703          .from(SessionTable)704          .where(eq(SessionTable.id, input.sessionID))705          .get(),706      )707      const model = input.model ?? ag.model ?? (yield* currentModel(input.sessionID))等待 Effect 结果。708      const same = ag.model && model.providerID === ag.model.providerID && model.modelID === ag.model.modelID选择模型或 provider。709      const full =710        !input.variant && ag.variant && same711          ? yield* provider选择模型或 provider。712              .getModel(model.providerID, model.modelID)选择模型或 provider。713              .pipe(Effect.catchIf(Provider.ModelNotFoundError.isInstance, () => Effect.succeed(undefined)))选择模型或 provider。714          : undefined715      const variant = input.variant ?? (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined)716717      const info: MessageV2.User = {会话消息片段结构。718        id: input.messageID ?? MessageID.ascending(),719        role: "user",720        sessionID: input.sessionID,721        time: { created: Date.now() },722        tools: input.tools,723        agent: ag.name,724        model: {选择模型或 provider。725          providerID: model.providerID,选择模型或 provider。726          modelID: model.modelID,选择模型或 provider。727          variant,728        },729        system: input.system,730        format: input.format,731      }
packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:1116-1117
1116      yield* sessions.updateMessage(info)等待 Effect 结果。1117      for (const part of parts) yield* sessions.updatePart(part)等待 Effect 结果。

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

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

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

真实外循环从这里开始:

1while (true) {agent 核心循环。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:1248-1316

packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:1248-1316
1248        while (true) {agent 核心循环。1249          yield* status.set(sessionID, { type: "busy" })等待 Effect 结果。1250          yield* slog.info("loop", { step })等待 Effect 结果。12511252          let msgs = yield* MessageV2.filterCompactedEffect(sessionID)会话消息片段结构。12531254          const { user: lastUser, assistant: lastAssistant, finished: lastFinished, tasks } = MessageV2.latest(msgs)会话消息片段结构。12551256          if (!lastUser) throw new Error("No user message found in stream. This should never happen.")按条件进入分支。12571258          const lastAssistantMsg = msgs.findLast(1259            (msg) => msg.info.role === "assistant" && msg.info.id === lastAssistant?.id,1260          )1261          // Some providers return "stop" even when the assistant message contains tool calls.1262          // Keep the loop running so tool results can be sent back to the model.1263          // Skip provider-executed tool parts — those were fully handled within the1264          // provider's stream (e.g. DWS Agent Platform) and don't need a re-loop.1265          const hasToolCalls =1266            lastAssistantMsg?.parts.some((part) => part.type === "tool" && !part.metadata?.providerExecuted) ?? false选择模型或 provider。12671268          if (按条件进入分支。1269            lastAssistant?.finish &&1270            !["tool-calls"].includes(lastAssistant.finish) &&1271            !hasToolCalls &&1272            lastUser.id < lastAssistant.id1273          ) {1274            yield* slog.info("exiting loop")等待 Effect 结果。1275            break1276          }12771278          step++1279          if (step === 1)按条件进入分支。1280            yield* title({等待 Effect 结果。1281              session,1282              modelID: lastUser.model.modelID,选择模型或 provider。1283              providerID: lastUser.model.providerID,选择模型或 provider。1284              history: msgs,1285            }).pipe(Effect.ignore, Effect.forkIn(scope))Effect 异步工作流。12861287          const model = yield* getModel(lastUser.model.providerID, lastUser.model.modelID, sessionID)选择模型或 provider。1288          const task = tasks.pop()12891290          if (task?.type === "subtask") {按条件进入分支。1291            yield* handleSubtask({ task, model, lastUser, sessionID, session, msgs })等待 Effect 结果。1292            continue1293          }12941295          if (task?.type === "compaction") {按条件进入分支。1296            const result = yield* compaction.process({等待 Effect 结果。1297              messages: msgs,1298              parentID: lastUser.id,1299              sessionID,1300              auto: task.auto,1301              overflow: task.overflow,1302            })1303            if (result === "stop") break按条件进入分支。1304            continue1305          }13061307          if (按条件进入分支。1308            lastFinished &&1309            lastFinished.summary !== true &&1310            (yield* compaction.isOverflow({ tokens: lastFinished.tokens, model }))等待 Effect 结果。1311          ) {1312            yield* compaction.create({ sessionID, agent: lastUser.agent, model: lastUser.model, auto: true })选择模型或 provider。1313            continue1314          }13151316          const agent = yield* agents.get(lastUser.agent)等待 Effect 结果。
每轮先重读消息,再判断是否退出 packages/opencode/src/session/prompt.ts:1248-1276

先看数据从哪里来,再看停止条件为什么不是单一布尔值。

1248        while (true) {agent 核心循环。1249          yield* status.set(sessionID, { type: "busy" })等待 Effect 结果。1250          yield* slog.info("loop", { step })等待 Effect 结果。12511252          let msgs = yield* MessageV2.filterCompactedEffect(sessionID)会话消息片段结构。12531254          const { user: lastUser, assistant: lastAssistant, finished: lastFinished, tasks } = MessageV2.latest(msgs)会话消息片段结构。12551256          if (!lastUser) throw new Error("No user message found in stream. This should never happen.")按条件进入分支。12571258          const lastAssistantMsg = msgs.findLast(1259            (msg) => msg.info.role === "assistant" && msg.info.id === lastAssistant?.id,1260          )1261          // Some providers return "stop" even when the assistant message contains tool calls.1262          // Keep the loop running so tool results can be sent back to the model.1263          // Skip provider-executed tool parts — those were fully handled within the1264          // provider's stream (e.g. DWS Agent Platform) and don't need a re-loop.1265          const hasToolCalls =1266            lastAssistantMsg?.parts.some((part) => part.type === "tool" && !part.metadata?.providerExecuted) ?? false选择模型或 provider。12671268          if (按条件进入分支。1269            lastAssistant?.finish &&1270            !["tool-calls"].includes(lastAssistant.finish) &&1271            !hasToolCalls &&1272            lastUser.id < lastAssistant.id1273          ) {1274            yield* slog.info("exiting loop")等待 Effect 结果。1275            break1276          }

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

packages/opencode/src/session/message-v2.ts packages/opencode/src/session/message-v2.ts:1013-1093
1013export function filterCompacted(msgs: Iterable<WithParts>) {对外暴露模块成员。1014  const result = [] as WithParts[]1015  const completed = new Set<string>()1016  let retain: MessageID | undefined1017  for (const msg of msgs) {遍历集合。1018    result.push(msg)1019    if (retain) {按条件进入分支。1020      if (msg.info.id === retain) break按条件进入分支。1021      continue1022    }1023    if (msg.info.role === "user" && completed.has(msg.info.id)) {按条件进入分支。1024      const part = msg.parts.find((item): item is CompactionPart => item.type === "compaction")1025      if (!part) continue按条件进入分支。1026      if (!part.tail_start_id) break按条件进入分支。1027      retain = part.tail_start_id1028      if (msg.info.id === retain) break按条件进入分支。1029      continue1030    }1031    if (msg.info.role === "user" && completed.has(msg.info.id) && msg.parts.some((part) => part.type === "compaction"))按条件进入分支。1032      break1033    if (msg.info.role === "assistant" && msg.info.summary && msg.info.finish && !msg.info.error)按条件进入分支。1034      completed.add(msg.info.parentID)1035  }1036  result.reverse()1037  const compactionIndex = result.findLastIndex(1038    (msg) =>1039      msg.info.role === "user" &&1040      msg.parts.some((item): item is CompactionPart => item.type === "compaction" && item.tail_start_id !== undefined),1041  )1042  const compaction = result[compactionIndex]1043  const part = compaction?.parts.find(1044    (item): item is CompactionPart => item.type === "compaction" && item.tail_start_id !== undefined,1045  )1046  const summaryIndex = compaction1047    ? result.findIndex(1048        (msg, index) =>1049          index > compactionIndex &&1050          msg.info.role === "assistant" &&1051          msg.info.summary &&1052          msg.info.parentID === compaction.info.id,1053      )1054    : -11055  const tailIndex = part?.tail_start_id ? result.findIndex((msg) => msg.info.id === part.tail_start_id) : -11056  if (tailIndex >= 0 && tailIndex < compactionIndex && summaryIndex > compactionIndex) {按条件进入分支。1057    return [返回给上一层。1058      ...result.slice(compactionIndex, summaryIndex + 1),1059      ...result.slice(tailIndex, compactionIndex),1060      ...result.slice(summaryIndex + 1),1061    ]1062  }1063  return result返回给上一层。1064}10651066export const filterCompactedEffect = Effect.fnUntraced(function* (sessionID: SessionID) {Effect 异步工作流。1067  return filterCompacted(stream(sessionID))返回给上一层。1068})10691070// filterCompacted reorders messages for model consumption1071// ([compaction-user, summary, ...retained tail..., continue-user]), so array1072// position is not chronological. Derive each binding by max id (MessageID1073// is monotonic via MessageID.ascending) so a pre-compaction overflowing tail1074// assistant doesn't get mistaken for the most recent turn. tasks are1075// compaction/subtask parts attached to user messages newer than the latest1076// finished assistant — i.e. unprocessed work.1077export function latest(msgs: WithParts[]) {对外暴露模块成员。1078  let user: User | undefined1079  let assistant: Assistant | undefined1080  let finished: Assistant | undefined1081  for (const msg of msgs) {遍历集合。1082    const info = msg.info1083    if (info.role === "user" && (!user || info.id > user.id)) user = info按条件进入分支。1084    if (info.role === "assistant" && (!assistant || info.id > assistant.id)) assistant = info按条件进入分支。1085    if (info.role === "assistant" && info.finish && (!finished || info.id > finished.id)) finished = info按条件进入分支。1086  }1087  const tasks = msgs.flatMap((m) =>1088    finished && m.info.id <= finished.id1089      ? []1090      : m.parts.filter((p): p is CompactionPart | SubtaskPart => p.type === "compaction" || p.type === "subtask"),1091  )1092  return { user, assistant, finished, tasks }返回给上一层。1093}

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

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

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

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

packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:1287-1325
1287          const model = yield* getModel(lastUser.model.providerID, lastUser.model.modelID, sessionID)选择模型或 provider。1288          const task = tasks.pop()12891290          if (task?.type === "subtask") {按条件进入分支。1291            yield* handleSubtask({ task, model, lastUser, sessionID, session, msgs })等待 Effect 结果。1292            continue1293          }12941295          if (task?.type === "compaction") {按条件进入分支。1296            const result = yield* compaction.process({等待 Effect 结果。1297              messages: msgs,1298              parentID: lastUser.id,1299              sessionID,1300              auto: task.auto,1301              overflow: task.overflow,1302            })1303            if (result === "stop") break按条件进入分支。1304            continue1305          }13061307          if (按条件进入分支。1308            lastFinished &&1309            lastFinished.summary !== true &&1310            (yield* compaction.isOverflow({ tokens: lastFinished.tokens, model }))等待 Effect 结果。1311          ) {1312            yield* compaction.create({ sessionID, agent: lastUser.agent, model: lastUser.model, auto: true })选择模型或 provider。1313            continue1314          }13151316          const agent = yield* agents.get(lastUser.agent)等待 Effect 结果。1317          if (!agent) {按条件进入分支。1318            const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)等待 Effect 结果。1319            const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""1320            const error = new NamedError.Unknown({ message: `Agent not found: "${lastUser.agent}".${hint}` })1321            yield* bus.publish(Session.Event.Error, { sessionID, error: error.toObject() })广播状态变化。1322            throw error失败时抛出错误。1323          }1324          const maxSteps = agent.steps ?? Infinity1325          const isLastStep = step >= maxSteps
packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:1332-1365
1332          const msg: MessageV2.Assistant = {会话消息片段结构。1333            id: MessageID.ascending(),1334            parentID: lastUser.id,1335            role: "assistant",1336            mode: agent.name,1337            agent: agent.name,1338            variant: lastUser.model.variant,1339            path: { cwd: ctx.directory, root: ctx.worktree },1340            cost: 0,1341            tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },1342            modelID: model.id,选择模型或 provider。1343            providerID: model.providerID,选择模型或 provider。1344            time: { created: Date.now() },1345            sessionID,1346          }1347          yield* sessions.updateMessage(msg)等待 Effect 结果。13481349          const finalizeInterruptedAssistant = Effect.gen(function* () {Effect 异步工作流。1350            if (msg.time.completed) return按条件进入分支。1351            msg.error ??= MessageV2.fromError(new DOMException("Aborted", "AbortError"), {会话消息片段结构。1352              providerID: msg.providerID,选择模型或 provider。1353              aborted: true,1354            })1355            msg.time.completed = Date.now()1356            yield* sessions.updateMessage(msg)等待 Effect 结果。1357          })13581359          const handle = yield* processor等待 Effect 结果。1360            .create({1361              assistantMessage: msg,1362              sessionID,1363              model,1364            })1365            .pipe(Effect.onInterrupt(() => finalizeInterruptedAssistant))Effect 异步工作流。

接着准备三类输入:

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

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

packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:1372-1428
1372            const tools = yield* SessionTools.resolve({等待 Effect 结果。1373              agent,1374              session,1375              model,1376              processor: handle,1377              bypassAgentCheck,1378              messages: msgs,1379              promptOps,1380            }).pipe(1381              Effect.provideService(Plugin.Service, plugin),调用插件扩展点。1382              Effect.provideService(Permission.Service, permission),Effect 异步工作流。1383              Effect.provideService(ToolRegistry.Service, registry),Effect 异步工作流。1384              Effect.provideService(MCP.Service, mcp),Effect 异步工作流。1385              Effect.provideService(Truncate.Service, truncate),Effect 异步工作流。1386            )13871388            if (lastUser.format?.type === "json_schema") {按条件进入分支。1389              tools["StructuredOutput"] = createStructuredOutputTool({1390                schema: lastUser.format.schema,定义并校验数据形状。1391                onSuccess(output) {1392                  structured = output1393                },1394              })1395            }13961397            if (step === 1)按条件进入分支。1398              yield* summary.summarize({ sessionID, messageID: lastUser.id }).pipe(Effect.ignore, Effect.forkIn(scope))Effect 异步工作流。13991400            if (step > 1 && lastFinished) {按条件进入分支。1401              for (const m of msgs) {遍历集合。1402                if (m.info.role !== "user" || m.info.id <= lastFinished.id) continue按条件进入分支。1403                for (const p of m.parts) {遍历集合。1404                  if (p.type !== "text" || p.ignored || p.synthetic) continue按条件进入分支。1405                  if (!p.text.trim()) continue按条件进入分支。1406                  p.text = [1407                    "<system-reminder>",1408                    "The user sent the following message:",1409                    p.text,1410                    "",1411                    "Please address this message and continue with your tasks.",1412                    "</system-reminder>",1413                  ].join("\n")1414                }1415              }1416            }14171418            yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })调用插件扩展点。14191420            const [skills, env, instructions, modelMsgs] = yield* Effect.all([Effect 异步工作流。1421              sys.skills(agent),1422              sys.environment(model),1423              instruction.system().pipe(Effect.orDie),Effect 异步工作流。1424              MessageV2.toModelMessagesEffect(msgs, model),会话消息片段结构。1425            ])1426            const system = [...env, ...instructions, ...(skills ? [skills] : [])]1427            const format = lastUser.format ?? { type: "text" as const }1428            if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)按条件进入分支。

最后,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:1429-1440

packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:1429-1440
1429            const result = yield* handle.process({等待 Effect 结果。1430              user: lastUser,1431              agent,1432              permission: session.permission,1433              sessionID,1434              parentSessionID: session.parentID,1435              system,1436              messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [])],1437              tools,1438              model,1439              toolChoice: format.type === "json_schema" ? "required" : undefined,1440            })

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:75-115

packages/opencode/src/session/tools.ts packages/opencode/src/session/tools.ts:75-115
75  for (const item of yield* registry.tools({等待 Effect 结果。76    modelID: ModelID.make(input.model.api.id),选择模型或 provider。77    providerID: input.model.providerID,选择模型或 provider。78    agent: input.agent,79  })) {80    const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))定义并校验数据形状。81    tools[item.id] = tool({82      description: item.description,83      inputSchema: jsonSchema(schema),84      execute(args, options) {工具真正执行入口。85        return run.promise(返回给上一层。86          Effect.gen(function* () {Effect 异步工作流。87            const ctx = context(args, options)88            yield* plugin.trigger(调用插件扩展点。89              "tool.execute.before",90              { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },91              { args },92            )93            const result = yield* item.execute(args, ctx)工具真正执行入口。94            const output = {95              ...result,96              attachments: result.attachments?.map((attachment) => ({97                ...attachment,98                id: PartID.ascending(),99                sessionID: ctx.sessionID,100                messageID: input.processor.message.id,101              })),102            }103            yield* plugin.trigger(调用插件扩展点。104              "tool.execute.after",105              { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args },106              output,107            )108            if (options.abortSignal?.aborted) {按条件进入分支。109              yield* input.processor.completeToolCall(options.toolCallId, output)等待 Effect 结果。110            }111            return output返回给上一层。112          }),113        )114      },115    })
registry 工具被包装成可执行的 AI SDK tool packages/opencode/src/session/tools.ts:75-115

重点看 schema、execute 与 plugin hook 三个边界。

75  for (const item of yield* registry.tools({等待 Effect 结果。76    modelID: ModelID.make(input.model.api.id),选择模型或 provider。77    providerID: input.model.providerID,选择模型或 provider。78    agent: input.agent,79  })) {80    const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))定义并校验数据形状。81    tools[item.id] = tool({82      description: item.description,83      inputSchema: jsonSchema(schema),84      execute(args, options) {工具真正执行入口。85        return run.promise(返回给上一层。86          Effect.gen(function* () {Effect 异步工作流。87            const ctx = context(args, options)88            yield* plugin.trigger(调用插件扩展点。89              "tool.execute.before",90              { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },91              { args },92            )93            const result = yield* item.execute(args, ctx)工具真正执行入口。94            const output = {95              ...result,96              attachments: result.attachments?.map((attachment) => ({97                ...attachment,98                id: PartID.ascending(),99                sessionID: ctx.sessionID,100                messageID: input.processor.message.id,101              })),102            }103            yield* plugin.trigger(调用插件扩展点。104              "tool.execute.after",105              { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args },106              output,107            )108            if (options.abortSignal?.aborted) {按条件进入分支。109              yield* input.processor.completeToolCall(options.toolCallId, output)等待 Effect 结果。110            }111            return output返回给上一层。112          }),113        )114      },115    })

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

packages/opencode/src/session/tools.ts packages/opencode/src/session/tools.ts:42-72
42  const context = (args: Record<string, unknown>, options: ToolExecutionOptions): Tool.Context => ({43    sessionID: input.session.id,44    abort: options.abortSignal!,45    messageID: input.processor.message.id,46    callID: options.toolCallId,47    extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps: input.promptOps },选择模型或 provider。48    agent: input.agent.name,49    messages: input.messages,50    metadata: (val) =>51      input.processor.updateToolCall(options.toolCallId, (match) => {52        if (!["running", "pending"].includes(match.state.status)) return match按条件进入分支。53        return {返回给上一层。54          ...match,55          state: {56            title: val.title,57            metadata: val.metadata,58            status: "running",59            input: args,60            time: { start: Date.now() },61          },62        }63      }),64    ask: (req) =>65      permission66        .ask({67          ...req,68          sessionID: input.session.id,69          tool: { messageID: input.processor.message.id, callID: options.toolCallId },70          ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),71        })72        .pipe(Effect.orDie),Effect 异步工作流。

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

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

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

packages/opencode/src/session/llm.ts packages/opencode/src/session/llm.ts:85-204
85    const run = Effect.fn("LLM.run")(function* (input: StreamRequest) {Effect 异步工作流。86      const l = log87        .clone()88        .tag("providerID", input.model.providerID)选择模型或 provider。89        .tag("modelID", input.model.id)选择模型或 provider。90        .tag("session.id", input.sessionID)91        .tag("small", (input.small ?? false).toString())92        .tag("agent", input.agent.name)93        .tag("mode", input.agent.mode)94      l.info("stream", {95        modelID: input.model.id,选择模型或 provider。96        providerID: input.model.providerID,选择模型或 provider。97      })9899      const [language, cfg, item, info] = yield* Effect.all(Effect 异步工作流。100        [101          provider.getLanguage(input.model),选择模型或 provider。102          config.get(),读取运行配置。103          provider.getProvider(input.model.providerID),选择模型或 provider。104          auth.get(input.model.providerID),选择模型或 provider。105        ],106        { concurrency: "unbounded" },107      )108109      // TODO: move this to a proper hook110      const isOpenaiOauth = item.id === "openai" && info?.type === "oauth"111112      const system: string[] = []113      system.push(114        [115          // use agent prompt otherwise provider prompt116          ...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),选择模型或 provider。117          // any custom prompt passed into this call118          ...input.system,119          // any custom prompt from last user message120          ...(input.user.system ? [input.user.system] : []),121        ]122          .filter((x) => x)123          .join("\n"),124      )125126      const header = system[0]127      yield* plugin.trigger(调用插件扩展点。128        "experimental.chat.system.transform",129        { sessionID: input.sessionID, model: input.model },选择模型或 provider。130        { system },131      )132      // rejoin to maintain 2-part structure for caching if header unchanged133      if (system.length > 2 && system[0] === header) {按条件进入分支。134        const rest = system.slice(1)135        system.length = 0136        system.push(header, rest.join("\n"))137      }138139      const variant =140        !input.small && input.model.variants && input.user.model.variant141          ? input.model.variants[input.user.model.variant]142          : {}143      const base = input.small144        ? ProviderTransform.smallOptions(input.model)选择模型或 provider。145        : ProviderTransform.options({选择模型或 provider。146            model: input.model,选择模型或 provider。147            sessionID: input.sessionID,148            providerOptions: item.options,选择模型或 provider。149          })150      const options = mergeOptions(mergeOptions(mergeOptions(base, input.model.options), input.agent.options), variant)151      if (isOpenaiOauth) {按条件进入分支。152        options.instructions = system.join("\n")153      }154155      const isWorkflow = language instanceof GitLabWorkflowLanguageModel156      const messages = isOpenaiOauth157        ? input.messages158        : isWorkflow159          ? input.messages160          : [161              ...system.map(162                (x): ModelMessage => ({163                  role: "system",164                  content: x,165                }),166              ),167              ...input.messages,168            ]169170      const params = yield* plugin.trigger(调用插件扩展点。171        "chat.params",172        {173          sessionID: input.sessionID,174          agent: input.agent.name,175          model: input.model,选择模型或 provider。176          provider: item,选择模型或 provider。177          message: input.user,178        },179        {180          temperature: input.model.capabilities.temperature181            ? (input.agent.temperature ?? ProviderTransform.temperature(input.model))选择模型或 provider。182            : undefined,183          topP: input.agent.topP ?? ProviderTransform.topP(input.model),选择模型或 provider。184          topK: ProviderTransform.topK(input.model),选择模型或 provider。185          maxOutputTokens: ProviderTransform.maxOutputTokens(input.model, flags.outputTokenMax),选择模型或 provider。186          options,187        },188      )189190      const { headers } = yield* plugin.trigger(调用插件扩展点。191        "chat.headers",192        {193          sessionID: input.sessionID,194          agent: input.agent.name,195          model: input.model,选择模型或 provider。196          provider: item,选择模型或 provider。197          message: input.user,198        },199        {200          headers: {},201        },202      )203204      const tools = resolveTools(input)
packages/opencode/src/session/llm.ts packages/opencode/src/session/llm.ts:352-468
352      if (flags.experimentalNativeLlm) {按条件进入分支。353        const native = LLMNativeRuntime.stream({354          model: input.model,选择模型或 provider。355          provider: item,选择模型或 provider。356          auth: info,357          llmClient,358          isOpenaiOauth,359          system,360          messages,361          tools: sortedTools,362          toolChoice: input.toolChoice,363          temperature: params.temperature,364          topP: params.topP,365          topK: params.topK,366          maxOutputTokens: params.maxOutputTokens,367          providerOptions: params.options,选择模型或 provider。368          headers: requestHeaders,369          abort: input.abort,370        })371        if (native.type === "supported") {按条件进入分支。372          yield* Effect.logInfo("llm runtime selected").pipe(Effect 异步工作流。373            Effect.annotateLogs({Effect 异步工作流。374              "llm.runtime": "native",375              "llm.provider": input.model.providerID,选择模型或 provider。376              "llm.model": input.model.id,377            }),378          )379          return {返回给上一层。380            type: "native" as const,381            stream: native.stream,382          }383        }384        yield* Effect.logInfo("llm runtime selected").pipe(Effect 异步工作流。385          Effect.annotateLogs({Effect 异步工作流。386            "llm.runtime": "ai-sdk",387            "llm.provider": input.model.providerID,选择模型或 provider。388            "llm.model": input.model.id,389            "llm.native_unsupported_reason": native.reason,390          }),391        )392        l.info("native runtime unavailable; falling back to ai-sdk", { reason: native.reason })393      }394395      yield* Effect.logInfo("llm runtime selected").pipe(Effect 异步工作流。396        Effect.annotateLogs({Effect 异步工作流。397          "llm.runtime": "ai-sdk",398          "llm.provider": input.model.providerID,选择模型或 provider。399          "llm.model": input.model.id,400        }),401      )402      return {返回给上一层。403        type: "ai-sdk" as const,404        result: streamText({向模型发起请求。405          onError(error) {406            l.error("stream error", {407              error,408            })409          },410          async experimental_repairToolCall(failed) {411            const lower = failed.toolCall.toolName.toLowerCase()412            if (lower !== failed.toolCall.toolName && sortedTools[lower]) {按条件进入分支。413              l.info("repairing tool call", {414                tool: failed.toolCall.toolName,415                repaired: lower,416              })417              return {返回给上一层。418                ...failed.toolCall,419                toolName: lower,420              }421            }422            return {返回给上一层。423              ...failed.toolCall,424              input: JSON.stringify({425                tool: failed.toolCall.toolName,426                error: failed.error.message,427              }),428              toolName: "invalid",429            }430          },431          temperature: params.temperature,432          topP: params.topP,433          topK: params.topK,434          providerOptions: ProviderTransform.providerOptions(input.model, params.options),选择模型或 provider。435          activeTools: Object.keys(sortedTools).filter((x) => x !== "invalid"),436          tools: sortedTools,437          toolChoice: input.toolChoice,438          maxOutputTokens: params.maxOutputTokens,439          abortSignal: input.abort,440          headers: requestHeaders,441          maxRetries: input.retries ?? 0,442          messages,443          model: wrapLanguageModel({选择模型或 provider。444            model: language,选择模型或 provider。445            middleware: [446              {447                specificationVersion: "v3" as const,448                async transformParams(args) {449                  if (args.type === "stream") {按条件进入分支。450                    // @ts-expect-error451                    args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)选择模型或 provider。452                  }453                  return args.params返回给上一层。454                },455              },456            ],457          }),458          experimental_telemetry: {459            isEnabled: cfg.experimental?.openTelemetry,460            functionId: "session.llm",461            tracer: telemetryTracer,462            metadata: {463              userId: cfg.username ?? "unknown",464              sessionId: input.sessionID,465            },466          },467        }),468      }

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

packages/opencode/src/session/llm.ts packages/opencode/src/session/llm.ts:471-493
471    const stream: Interface["stream"] = (input) =>472      Stream.scoped(473        Stream.unwrap(474          Effect.gen(function* () {Effect 异步工作流。475            const ctrl = yield* Effect.acquireRelease(Effect 异步工作流。476              Effect.sync(() => new AbortController()),用于中断运行任务。477              (ctrl) => Effect.sync(() => ctrl.abort()),Effect 异步工作流。478            )479480            const result = yield* run({ ...input, abort: ctrl.signal })等待 Effect 结果。481482            if (result.type === "native") return result.stream按条件进入分支。483484            const state = LLMAISDK.adapterState()485            return Stream.fromAsyncIterable(result.result.fullStream, (e) =>返回给上一层。486              e instanceof Error ? e : new Error(String(e)),487            ).pipe(488              Stream.mapEffect((event) => LLMAISDK.toLLMEvents(state, event)),489              Stream.flatMap((events) => Stream.fromIterable(events)),490            )491          }),492        ),493      )
packages/opencode/src/session/llm/ai-sdk.ts packages/opencode/src/session/llm/ai-sdk.ts:61-252
61export function toLLMEvents(对外暴露模块成员。62  state: ReturnType<typeof adapterState>,63  event: AISDKEvent,64): Effect.Effect<ReadonlyArray<LLMEvent>, unknown> {Effect 异步工作流。65  switch (event.type) {66    case "start":67      return Effect.succeed([])Effect 异步工作流。6869    case "start-step":70      return Effect.succeed([LLMEvent.stepStart({ index: state.step })])Effect 异步工作流。7172    case "finish-step":73      return Effect.sync(() => [Effect 异步工作流。74        LLMEvent.stepFinish({75          index: state.step++,76          reason: finishReason(event.finishReason),77          usage: usage(event.usage),78          providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。79        }),80      ])8182    case "finish":83      return Effect.sync(() => {Effect 异步工作流。84        const events = [85          LLMEvent.finish({86            reason: finishReason(event.finishReason),87            usage: usage(event.totalUsage),88            providerMetadata: "providerMetadata" in event ? providerMetadata(event.providerMetadata) : undefined,选择模型或 provider。89          }),90        ]91        // Reset so the adapter can be reused for a follow-up stream without leaking92        // counters or block IDs. adapterState() is the single source of truth for shape.93        Object.assign(state, adapterState())94        return events返回给上一层。95      })9697    case "text-start":98      return Effect.sync(() => {Effect 异步工作流。99        state.currentTextID = currentTextID(state, event.id)100        return [返回给上一层。101          LLMEvent.textStart({102            id: state.currentTextID,103            providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。104          }),105        ]106      })107108    case "text-delta":109      return Effect.succeed([Effect 异步工作流。110        LLMEvent.textDelta({111          id: currentTextID(state, event.id),112          text: event.text,113          providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。114        }),115      ])116117    case "text-end":118      return Effect.sync(() => {Effect 异步工作流。119        const id = currentTextID(state, event.id)120        state.currentTextID = undefined121        return [返回给上一层。122          LLMEvent.textEnd({123            id,124            providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。125          }),126        ]127      })128129    case "reasoning-start":130      return Effect.sync(() => {Effect 异步工作流。131        state.currentReasoningID = currentReasoningID(state, event.id)132        return [返回给上一层。133          LLMEvent.reasoningStart({134            id: state.currentReasoningID,135            providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。136          }),137        ]138      })139140    case "reasoning-delta":141      return Effect.succeed([Effect 异步工作流。142        LLMEvent.reasoningDelta({143          id: currentReasoningID(state, event.id),144          text: event.text,145          providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。146        }),147      ])148149    case "reasoning-end":150      return Effect.sync(() => {Effect 异步工作流。151        const id = currentReasoningID(state, event.id)152        state.currentReasoningID = undefined153        return [返回给上一层。154          LLMEvent.reasoningEnd({155            id,156            providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。157          }),158        ]159      })160161    case "tool-input-start":162      return Effect.sync(() => {Effect 异步工作流。163        state.toolNames[event.id] = event.toolName164        return [返回给上一层。165          LLMEvent.toolInputStart({166            id: event.id,167            name: event.toolName,168            providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。169          }),170        ]171      })172173    case "tool-input-delta":174      return Effect.succeed([Effect 异步工作流。175        LLMEvent.toolInputDelta({176          id: event.id,177          name: state.toolNames[event.id] ?? "unknown",178          text: event.delta ?? "",179        }),180      ])181182    case "tool-input-end":183      return Effect.succeed([Effect 异步工作流。184        LLMEvent.toolInputEnd({185          id: event.id,186          name: state.toolNames[event.id] ?? "unknown",187          providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。188        }),189      ])190191    case "tool-call":192      return Effect.sync(() => {Effect 异步工作流。193        state.toolNames[event.toolCallId] = event.toolName194        return [返回给上一层。195          LLMEvent.toolCall({196            id: event.toolCallId,197            name: event.toolName,198            input: event.input,199            providerExecuted: "providerExecuted" in event ? event.providerExecuted : undefined,选择模型或 provider。200            providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。201          }),202        ]203      })204205    case "tool-result":206      return Effect.sync(() => {Effect 异步工作流。207        const name = state.toolNames[event.toolCallId] ?? "unknown"208        delete state.toolNames[event.toolCallId]209        return [返回给上一层。210          LLMEvent.toolResult({211            id: event.toolCallId,212            name,213            result: ToolResultValue.make(event.output),214            providerExecuted: "providerExecuted" in event ? event.providerExecuted : undefined,选择模型或 provider。215            providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。216          }),217        ]218      })219220    case "tool-error":221      return Effect.sync(() => {Effect 异步工作流。222        const name = state.toolNames[event.toolCallId] ?? ("toolName" in event ? event.toolName : "unknown")223        delete state.toolNames[event.toolCallId]224        return [返回给上一层。225          LLMEvent.toolError({226            id: event.toolCallId,227            name,228            message: errorMessage(event.error),229            error: event.error,230            providerMetadata: providerMetadata(event.providerMetadata),选择模型或 provider。231          }),232        ]233      })234235    case "error":236      return Effect.fail(event.error)Effect 异步工作流。237238    case "abort":239    case "source":240    case "file":241    case "raw":242    case "tool-output-denied":243    case "tool-approval-request":244      return Effect.succeed([])Effect 异步工作流。245246    default: {247      const _exhaustive: never = event248      void _exhaustive249      return Effect.succeed([])Effect 异步工作流。250    }251  }252}

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:779-795

packages/opencode/src/session/processor.ts packages/opencode/src/session/processor.ts:779-795
779      const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) {处理模型流事件。780        slog.info("process")781        ctx.needsCompaction = false782        ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true读取运行配置。783784        return yield* Effect.gen(function* () {Effect 异步工作流。785          yield* Effect.gen(function* () {Effect 异步工作流。786            ctx.currentText = undefined787            ctx.reasoningMap = {}788            yield* status.set(ctx.sessionID, { type: "busy" })等待 Effect 结果。789            const stream = llm.stream(streamInput)790791            yield* stream.pipe(等待 Effect 结果。792              Stream.tap((event) => handleEvent(event)),793              Stream.takeUntil(() => ctx.needsCompaction),794              Stream.runDrain,795            )

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

  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.ts:232-279packages/opencode/src/session/processor.ts:349-421packages/opencode/src/session/processor.ts:451-500packages/opencode/src/session/processor.ts:169-193

packages/opencode/src/session/processor.ts packages/opencode/src/session/processor.ts:232-279
232      const ensureToolCall = Effect.fn("SessionProcessor.ensureToolCall")(function* (input: {处理模型流事件。233        id: string234        name: string235        providerExecuted?: boolean选择模型或 provider。236      }) {237        const existing = yield* readToolCall(input.id)等待 Effect 结果。238        if (existing) {按条件进入分支。239          if (!input.providerExecuted || existing.part.metadata?.providerExecuted) return existing选择模型或 provider。240          const part = yield* session.updatePart({等待 Effect 结果。241            ...existing.part,242            metadata: { ...existing.part.metadata, providerExecuted: true },选择模型或 provider。243          })244          ctx.toolcalls[input.id] = {245            ...existing.call,246            partID: part.id,247            messageID: part.messageID,把流事件写回消息。248            sessionID: part.sessionID,249          }250          return { call: ctx.toolcalls[input.id], part }返回给上一层。251        }252        // TODO(v2): Temporary dual-write while migrating session messages to v2 events.253        if (flags.experimentalEventSystem) {按条件进入分支。254          yield* events.publish(SessionEvent.Tool.Input.Started, {广播状态变化。255            sessionID: ctx.sessionID,256            callID: input.id,257            name: input.name,258            timestamp: DateTime.makeUnsafe(Date.now()),259          })260        }261        const part = yield* session.updatePart({等待 Effect 结果。262          id: PartID.ascending(),263          messageID: ctx.assistantMessage.id,把流事件写回消息。264          sessionID: ctx.assistantMessage.sessionID,265          type: "tool",266          tool: input.name,267          callID: input.id,268          state: { status: "pending", input: {}, raw: "" },269          metadata: input.providerExecuted ? { providerExecuted: true } : undefined,选择模型或 provider。270        } satisfies MessageV2.ToolPart)会话消息片段结构。271        ctx.toolcalls[input.id] = {272          done: yield* Deferred.make<void>(),等待 Effect 结果。273          partID: part.id,274          messageID: part.messageID,把流事件写回消息。275          sessionID: part.sessionID,276          inputEnded: false,277        }278        return { call: ctx.toolcalls[input.id], part }返回给上一层。279      })
packages/opencode/src/session/processor.ts packages/opencode/src/session/processor.ts:349-421
349          case "tool-input-start":350            if (ctx.assistantMessage.summary) {按条件进入分支。351              throw new Error(`Tool call not allowed while generating summary: ${value.name}`)失败时抛出错误。352            }353            yield* ensureToolCall(value)等待 Effect 结果。354            return返回给上一层。355356          case "tool-input-delta":357            // AI SDK emits a final `tool-call` with the parsed `input`; accumulating358            // delta fragments into `state.raw` is redundant work for no current consumer.359            return返回给上一层。360361          case "tool-input-end": {362            const toolCall = yield* ensureToolCall(value)等待 Effect 结果。363            // TODO(v2): Temporary dual-write while migrating session messages to v2 events.364            if (flags.experimentalEventSystem) {按条件进入分支。365              yield* events.publish(SessionEvent.Tool.Input.Ended, {广播状态变化。366                sessionID: ctx.sessionID,367                callID: value.id,368                text: "",369                timestamp: DateTime.makeUnsafe(Date.now()),370              })371            }372            ctx.toolcalls[value.id] = { ...toolCall.call, inputEnded: true }373            return返回给上一层。374          }375376          case "tool-call": {377            if (ctx.assistantMessage.summary) {按条件进入分支。378              throw new Error(`Tool call not allowed while generating summary: ${value.name}`)失败时抛出错误。379            }380            const toolCall = yield* ensureToolCall(value)等待 Effect 结果。381            const input = toolInput(value.input)382            if (!toolCall.call.inputEnded) {按条件进入分支。383              // TODO(v2): Temporary dual-write while migrating session messages to v2 events.384              if (flags.experimentalEventSystem) {按条件进入分支。385                yield* events.publish(SessionEvent.Tool.Input.Ended, {广播状态变化。386                  sessionID: ctx.sessionID,387                  callID: value.id,388                  text: "",389                  timestamp: DateTime.makeUnsafe(Date.now()),390                })391              }392            }393            // TODO(v2): Temporary dual-write while migrating session messages to v2 events.394            if (flags.experimentalEventSystem) {按条件进入分支。395              yield* events.publish(SessionEvent.Tool.Called, {广播状态变化。396                sessionID: ctx.sessionID,397                callID: value.id,398                tool: value.name,399                input,400                provider: {选择模型或 provider。401                  executed: toolCall.part.metadata?.providerExecuted === true,选择模型或 provider。402                  ...(value.providerMetadata ? { metadata: value.providerMetadata } : {}),选择模型或 provider。403                },404                timestamp: DateTime.makeUnsafe(Date.now()),405              })406            }407            yield* updateToolCall(value.id, (match) => ({等待 Effect 结果。408              ...match,409              tool: value.name,410              state:411                match.state.status === "running"412                  ? { ...match.state, input }413                  : {414                      status: "running",415                      input,416                      time: { start: Date.now() },417                    },418              metadata: match.metadata?.providerExecuted选择模型或 provider。419                ? { ...value.providerMetadata, providerExecuted: true }选择模型或 provider。420                : value.providerMetadata,选择模型或 provider。421            }))
packages/opencode/src/session/processor.ts packages/opencode/src/session/processor.ts:451-500
451          case "tool-result": {452            const toolCall = yield* readToolCall(value.id)等待 Effect 结果。453            const rawOutput = toolResultOutput(value)454            const normalized = yield* Effect.forEach(rawOutput.attachments ?? [], (attachment) =>Effect 异步工作流。455              attachment.mime.startsWith("image/")456                ? image.normalize(attachment).pipe(457                    Effect.catchIf(Effect 异步工作流。458                      (error) => error instanceof Image.ResizerUnavailableError,459                      () => Effect.succeed(attachment),Effect 异步工作流。460                    ),461                    Effect.exit,Effect 异步工作流。462                  )463                : Effect.succeed(Exit.succeed<MessageV2.FilePart>(attachment)),会话消息片段结构。464            )465            const omitted = normalized.filter(Exit.isFailure).length466            const attachments = normalized.filter(Exit.isSuccess).map((item) => item.value)467            const output = {468              ...rawOutput,469              output:470                omitted === 0471                  ? rawOutput.output472                  : `${rawOutput.output}\n\n[${omitted} image${omitted === 1 ? "" : "s"} omitted: could not be resized below the image size limit.]`,473              attachments: attachments.length ? attachments : undefined,474            }475            // TODO(v2): Temporary dual-write while migrating session messages to v2 events.476            if (flags.experimentalEventSystem) {按条件进入分支。477              yield* events.publish(SessionEvent.Tool.Success, {广播状态变化。478                sessionID: ctx.sessionID,479                callID: value.id,480                structured: output.metadata,481                content: [482                  {483                    type: "text",484                    text: output.output,485                  },486                  ...(output.attachments?.map((item: MessageV2.FilePart) => ({会话消息片段结构。487                    type: "file" as const,488                    uri: item.url,489                    mime: item.mime,490                    name: item.filename,491                  })) ?? []),492                ],493                provider: {选择模型或 provider。494                  executed: value.providerExecuted === true || toolCall?.part.metadata?.providerExecuted === true,选择模型或 provider。495                },496                timestamp: DateTime.makeUnsafe(Date.now()),497              })498            }499            yield* completeToolCall(value.id, output)等待 Effect 结果。500            return返回给上一层。
packages/opencode/src/session/processor.ts packages/opencode/src/session/processor.ts:169-193
169      const completeToolCall = Effect.fn("SessionProcessor.completeToolCall")(function* (处理模型流事件。170        toolCallID: string,171        output: {172          title: string173          metadata: Record<string, any>174          output: string175          attachments?: MessageV2.FilePart[]会话消息片段结构。176        },177      ) {178        const match = yield* readToolCall(toolCallID)等待 Effect 结果。179        if (!match || match.part.state.status !== "running") return按条件进入分支。180        yield* session.updatePart({等待 Effect 结果。181          ...match.part,182          state: {183            status: "completed",184            input: match.part.state.input,185            output: output.output,186            metadata: output.metadata,187            title: output.title,188            time: { start: match.part.state.time.start, end: Date.now() },189            attachments: output.attachments,190          },191        })192        yield* settleToolCall(toolCallID)等待 Effect 结果。193      })
tool-call 与 tool-result 如何落成 ToolPart packages/opencode/src/session/processor.ts:376-500

关注 running 与 completed 的状态转换,而不是逐行背事件代码。

376          case "tool-call": {377            if (ctx.assistantMessage.summary) {按条件进入分支。378              throw new Error(`Tool call not allowed while generating summary: ${value.name}`)失败时抛出错误。379            }380            const toolCall = yield* ensureToolCall(value)等待 Effect 结果。381            const input = toolInput(value.input)382            if (!toolCall.call.inputEnded) {按条件进入分支。383              // TODO(v2): Temporary dual-write while migrating session messages to v2 events.384              if (flags.experimentalEventSystem) {按条件进入分支。385                yield* events.publish(SessionEvent.Tool.Input.Ended, {广播状态变化。386                  sessionID: ctx.sessionID,387                  callID: value.id,388                  text: "",389                  timestamp: DateTime.makeUnsafe(Date.now()),390                })391              }392            }393            // TODO(v2): Temporary dual-write while migrating session messages to v2 events.394            if (flags.experimentalEventSystem) {按条件进入分支。395              yield* events.publish(SessionEvent.Tool.Called, {广播状态变化。396                sessionID: ctx.sessionID,397                callID: value.id,398                tool: value.name,399                input,400                provider: {选择模型或 provider。401                  executed: toolCall.part.metadata?.providerExecuted === true,选择模型或 provider。402                  ...(value.providerMetadata ? { metadata: value.providerMetadata } : {}),选择模型或 provider。403                },404                timestamp: DateTime.makeUnsafe(Date.now()),405              })406            }407            yield* updateToolCall(value.id, (match) => ({等待 Effect 结果。408              ...match,409              tool: value.name,410              state:411                match.state.status === "running"412                  ? { ...match.state, input }413                  : {414                      status: "running",415                      input,416                      time: { start: Date.now() },417                    },418              metadata: match.metadata?.providerExecuted选择模型或 provider。419                ? { ...value.providerMetadata, providerExecuted: true }选择模型或 provider。420                : value.providerMetadata,选择模型或 provider。421            }))422423            const parts = MessageV2.parts(ctx.assistantMessage.id)把流事件写回消息。424            const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD)把流事件写回消息。425426            if (按条件进入分支。427              recentParts.length !== DOOM_LOOP_THRESHOLD ||428              !recentParts.every(429                (part) =>430                  part.type === "tool" &&431                  part.tool === value.name &&432                  part.state.status !== "pending" &&433                  JSON.stringify(part.state.input) === JSON.stringify(input),434              )435            ) {436              return返回给上一层。437            }438439            const agent = yield* agents.get(ctx.assistantMessage.agent)等待 Effect 结果。440            yield* permission.ask({进入权限审批。441              permission: "doom_loop",442              patterns: [value.name],443              sessionID: ctx.assistantMessage.sessionID,444              metadata: { tool: value.name, input },445              always: [value.name],446              ruleset: agent.permission,447            })448            return返回给上一层。449          }450451          case "tool-result": {452            const toolCall = yield* readToolCall(value.id)等待 Effect 结果。453            const rawOutput = toolResultOutput(value)454            const normalized = yield* Effect.forEach(rawOutput.attachments ?? [], (attachment) =>Effect 异步工作流。455              attachment.mime.startsWith("image/")456                ? image.normalize(attachment).pipe(457                    Effect.catchIf(Effect 异步工作流。458                      (error) => error instanceof Image.ResizerUnavailableError,459                      () => Effect.succeed(attachment),Effect 异步工作流。460                    ),461                    Effect.exit,Effect 异步工作流。462                  )463                : Effect.succeed(Exit.succeed<MessageV2.FilePart>(attachment)),会话消息片段结构。464            )465            const omitted = normalized.filter(Exit.isFailure).length466            const attachments = normalized.filter(Exit.isSuccess).map((item) => item.value)467            const output = {468              ...rawOutput,469              output:470                omitted === 0471                  ? rawOutput.output472                  : `${rawOutput.output}\n\n[${omitted} image${omitted === 1 ? "" : "s"} omitted: could not be resized below the image size limit.]`,473              attachments: attachments.length ? attachments : undefined,474            }475            // TODO(v2): Temporary dual-write while migrating session messages to v2 events.476            if (flags.experimentalEventSystem) {按条件进入分支。477              yield* events.publish(SessionEvent.Tool.Success, {广播状态变化。478                sessionID: ctx.sessionID,479                callID: value.id,480                structured: output.metadata,481                content: [482                  {483                    type: "text",484                    text: output.output,485                  },486                  ...(output.attachments?.map((item: MessageV2.FilePart) => ({会话消息片段结构。487                    type: "file" as const,488                    uri: item.url,489                    mime: item.mime,490                    name: item.filename,491                  })) ?? []),492                ],493                provider: {选择模型或 provider。494                  executed: value.providerExecuted === true || toolCall?.part.metadata?.providerExecuted === true,选择模型或 provider。495                },496                timestamp: DateTime.makeUnsafe(Date.now()),497              })498            }499            yield* completeToolCall(value.id, output)等待 Effect 结果。500            return返回给上一层。

注意:本章没有追进具体 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:787-820

packages/opencode/src/session/message-v2.ts packages/opencode/src/session/message-v2.ts:787-820
787        if (part.type === "tool") {按条件进入分支。788          toolNames.add(part.tool)789          if (part.state.status === "completed") {按条件进入分支。790            const outputText = part.state.time.compacted791              ? "[Old tool result content cleared]"792              : truncateToolOutput(part.state.output, options?.toolOutputMaxChars)793            const attachments = part.state.time.compacted || options?.stripMedia ? [] : (part.state.attachments ?? [])794795            // For providers that don't support media in tool results, extract media files796            // (images, PDFs) to be sent as a separate user message797            const mediaAttachments = attachments.filter((a) => isMedia(a.mime))798            const extractedMedia = mediaAttachments.filter((a) => !supportsMediaInToolResult(a))799            if (extractedMedia.length > 0) {按条件进入分支。800              media.push(...extractedMedia)801            }802            const finalAttachments = attachments.filter((a) => !isMedia(a.mime) || supportsMediaInToolResult(a))803804            const output =805              finalAttachments.length > 0806                ? {807                    text: outputText,808                    attachments: finalAttachments,809                  }810                : outputText811812            assistantMessage.parts.push({813              type: ("tool-" + part.tool) as `tool-${string}`,814              state: "output-available",815              toolCallId: part.callID,816              input: part.state.input,817              output,818              ...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),选择模型或 provider。819              ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),选择模型或 provider。820            })
completed ToolPart 被转换成下一轮模型输入 packages/opencode/src/session/message-v2.ts:787-820

这段代码是工具结果能够被模型再次看见的直接证据。

787        if (part.type === "tool") {按条件进入分支。788          toolNames.add(part.tool)789          if (part.state.status === "completed") {按条件进入分支。790            const outputText = part.state.time.compacted791              ? "[Old tool result content cleared]"792              : truncateToolOutput(part.state.output, options?.toolOutputMaxChars)793            const attachments = part.state.time.compacted || options?.stripMedia ? [] : (part.state.attachments ?? [])794795            // For providers that don't support media in tool results, extract media files796            // (images, PDFs) to be sent as a separate user message797            const mediaAttachments = attachments.filter((a) => isMedia(a.mime))798            const extractedMedia = mediaAttachments.filter((a) => !supportsMediaInToolResult(a))799            if (extractedMedia.length > 0) {按条件进入分支。800              media.push(...extractedMedia)801            }802            const finalAttachments = attachments.filter((a) => !isMedia(a.mime) || supportsMediaInToolResult(a))803804            const output =805              finalAttachments.length > 0806                ? {807                    text: outputText,808                    attachments: finalAttachments,809                  }810                : outputText811812            assistantMessage.parts.push({813              type: ("tool-" + part.tool) as `tool-${string}`,814              state: "output-available",815              toolCallId: part.callID,816              input: part.state.input,817              output,818              ...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),选择模型或 provider。819              ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),选择模型或 provider。820            })

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

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

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

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

packages/opencode/src/session/processor.ts packages/opencode/src/session/processor.ts:618-683
618          case "text-start":619            if (!ctx.assistantMessage.summary) {按条件进入分支。620              // TODO(v2): Temporary dual-write while migrating session messages to v2 events.621              if (flags.experimentalEventSystem) {按条件进入分支。622                yield* events.publish(SessionEvent.Text.Started, {广播状态变化。623                  sessionID: ctx.sessionID,624                  timestamp: DateTime.makeUnsafe(Date.now()),625                })626              }627            }628            ctx.currentText = {629              id: PartID.ascending(),630              messageID: ctx.assistantMessage.id,把流事件写回消息。631              sessionID: ctx.assistantMessage.sessionID,632              type: "text",633              text: "",634              time: { start: Date.now() },635              metadata: value.providerMetadata,选择模型或 provider。636            }637            yield* session.updatePart(ctx.currentText)等待 Effect 结果。638            return返回给上一层。639640          case "text-delta":641            if (!ctx.currentText) return按条件进入分支。642            ctx.currentText.text += value.text643            if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata选择模型或 provider。644            yield* session.updatePartDelta({等待 Effect 结果。645              sessionID: ctx.currentText.sessionID,646              messageID: ctx.currentText.messageID,把流事件写回消息。647              partID: ctx.currentText.id,648              field: "text",649              delta: value.text,650            })651            return返回给上一层。652653          case "text-end":654            if (!ctx.currentText) return按条件进入分支。655            // oxlint-disable-next-line no-self-assign -- reactivity trigger656            ctx.currentText.text = ctx.currentText.text657            ctx.currentText.text = (yield* plugin.trigger(调用插件扩展点。658              "experimental.text.complete",659              {660                sessionID: ctx.sessionID,661                messageID: ctx.assistantMessage.id,把流事件写回消息。662                partID: ctx.currentText.id,663              },664              { text: ctx.currentText.text },665            )).text666            if (!ctx.assistantMessage.summary) {按条件进入分支。667              // TODO(v2): Temporary dual-write while migrating session messages to v2 events.668              if (flags.experimentalEventSystem) {按条件进入分支。669                yield* events.publish(SessionEvent.Text.Ended, {广播状态变化。670                  sessionID: ctx.sessionID,671                  text: ctx.currentText.text,672                  timestamp: DateTime.makeUnsafe(Date.now()),673                })674              }675            }676            {677              const end = Date.now()678              ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end }679            }680            if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata选择模型或 provider。681            yield* session.updatePart(ctx.currentText)等待 Effect 结果。682            ctx.currentText = undefined683            return返回给上一层。

模型 step 结束时,processor 记录 finish reason、tokens、cost 和 snapshot;若上下文溢出则标记 needsCompaction。来源:packages/opencode/src/session/processor.ts:554-615

packages/opencode/src/session/processor.ts packages/opencode/src/session/processor.ts:554-615
554          case "step-finish": {555            const completedSnapshot = yield* snapshot.track()等待 Effect 结果。556            yield* Effect.forEach(Object.keys(ctx.reasoningMap), finishReasoning)Effect 异步工作流。557            const usage = Session.getUsage({558              model: ctx.model,选择模型或 provider。559              usage: value.usage ?? new Usage({}),560              metadata: value.providerMetadata,选择模型或 provider。561            })562            if (!ctx.assistantMessage.summary) {按条件进入分支。563              // TODO(v2): Temporary dual-write while migrating session messages to v2 events.564              if (flags.experimentalEventSystem) {按条件进入分支。565                yield* events.publish(SessionEvent.Step.Ended, {广播状态变化。566                  sessionID: ctx.sessionID,567                  finish: value.reason,568                  cost: usage.cost,569                  tokens: usage.tokens,570                  snapshot: completedSnapshot,571                  timestamp: DateTime.makeUnsafe(Date.now()),572                })573              }574            }575            ctx.assistantMessage.finish = value.reason576            ctx.assistantMessage.cost += usage.cost577            ctx.assistantMessage.tokens = usage.tokens578            yield* session.updatePart({等待 Effect 结果。579              id: PartID.ascending(),580              reason: value.reason,581              snapshot: completedSnapshot,582              messageID: ctx.assistantMessage.id,把流事件写回消息。583              sessionID: ctx.assistantMessage.sessionID,584              type: "step-finish",585              tokens: usage.tokens,586              cost: usage.cost,587            })588            yield* session.updateMessage(ctx.assistantMessage)等待 Effect 结果。589            if (ctx.snapshot) {按条件进入分支。590              const patch = yield* snapshot.patch(ctx.snapshot)把流事件写回消息。591              if (patch.files.length) {把流事件写回消息。592                yield* session.updatePart({等待 Effect 结果。593                  id: PartID.ascending(),594                  messageID: ctx.assistantMessage.id,把流事件写回消息。595                  sessionID: ctx.sessionID,596                  type: "patch",把流事件写回消息。597                  hash: patch.hash,把流事件写回消息。598                  files: patch.files,把流事件写回消息。599                })600              }601              ctx.snapshot = undefined602            }603            yield* summary等待 Effect 结果。604              .summarize({605                sessionID: ctx.sessionID,606                messageID: ctx.assistantMessage.parentID,把流事件写回消息。607              })608              .pipe(Effect.ignore, Effect.forkIn(scope))Effect 异步工作流。609            if (按条件进入分支。610              !ctx.assistantMessage.summary &&611              isOverflow({ cfg: yield* config.get(), tokens: usage.tokens, model: ctx.model })读取运行配置。612            ) {613              ctx.needsCompaction = true614            }615            return返回给上一层。

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

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

来源:packages/opencode/src/session/processor.ts:844-846

packages/opencode/src/session/processor.ts packages/opencode/src/session/processor.ts:844-846
844          if (ctx.needsCompaction) return "compact"按条件进入分支。845          if (ctx.blocked || ctx.assistantMessage.error) return "stop"按条件进入分支。846          return "continue"返回给上一层。

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

packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:1268-1276
1268          if (按条件进入分支。1269            lastAssistant?.finish &&1270            !["tool-calls"].includes(lastAssistant.finish) &&1271            !hasToolCalls &&1272            lastUser.id < lastAssistant.id1273          ) {1274            yield* slog.info("exiting loop")等待 Effect 结果。1275            break1276          }
packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:1476-1481
1476          if (outcome === "break") break按条件进入分支。1477          continue1478        }14791480        yield* compaction.prune({ sessionID }).pipe(Effect.ignore, Effect.forkIn(scope))Effect 异步工作流。1481        return yield* lastAssistant(sessionID)等待 Effect 结果。

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

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

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:1258-1276

packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:1258-1276
1258          const lastAssistantMsg = msgs.findLast(1259            (msg) => msg.info.role === "assistant" && msg.info.id === lastAssistant?.id,1260          )1261          // Some providers return "stop" even when the assistant message contains tool calls.1262          // Keep the loop running so tool results can be sent back to the model.1263          // Skip provider-executed tool parts — those were fully handled within the1264          // provider's stream (e.g. DWS Agent Platform) and don't need a re-loop.1265          const hasToolCalls =1266            lastAssistantMsg?.parts.some((part) => part.type === "tool" && !part.metadata?.providerExecuted) ?? false选择模型或 provider。12671268          if (按条件进入分支。1269            lastAssistant?.finish &&1270            !["tool-calls"].includes(lastAssistant.finish) &&1271            !hasToolCalls &&1272            lastUser.id < lastAssistant.id1273          ) {1274            yield* slog.info("exiting loop")等待 Effect 结果。1275            break1276          }

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

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

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

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

packages/opencode/src/session/processor.ts packages/opencode/src/session/processor.ts:195-212
195      const failToolCall = Effect.fn("SessionProcessor.failToolCall")(function* (toolCallID: string, error: unknown) {处理模型流事件。196        const match = yield* readToolCall(toolCallID)等待 Effect 结果。197        if (!match || match.part.state.status !== "running") return false按条件进入分支。198        yield* session.updatePart({等待 Effect 结果。199          ...match.part,200          state: {201            status: "error",202            input: match.part.state.input,203            error: errorMessage(error),204            time: { start: match.part.state.time.start, end: Date.now() },205          },206        })207        if (error instanceof Permission.RejectedError || error instanceof Question.RejectedError) {按条件进入分支。208          ctx.blocked = ctx.shouldBreak209        }210        yield* settleToolCall(toolCallID)等待 Effect 结果。211        return true返回给上一层。212      })
packages/opencode/src/session/processor.ts packages/opencode/src/session/processor.ts:779-846
779      const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) {处理模型流事件。780        slog.info("process")781        ctx.needsCompaction = false782        ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true读取运行配置。783784        return yield* Effect.gen(function* () {Effect 异步工作流。785          yield* Effect.gen(function* () {Effect 异步工作流。786            ctx.currentText = undefined787            ctx.reasoningMap = {}788            yield* status.set(ctx.sessionID, { type: "busy" })等待 Effect 结果。789            const stream = llm.stream(streamInput)790791            yield* stream.pipe(等待 Effect 结果。792              Stream.tap((event) => handleEvent(event)),793              Stream.takeUntil(() => ctx.needsCompaction),794              Stream.runDrain,795            )796          }).pipe(797            Effect.onInterrupt(() =>Effect 异步工作流。798              Effect.gen(function* () {Effect 异步工作流。799                aborted = true800                if (!ctx.assistantMessage.error) {按条件进入分支。801                  yield* halt(new DOMException("Aborted", "AbortError"))等待 Effect 结果。802                }803              }),804            ),805            Effect.catchCauseIf(Effect 异步工作流。806              (cause) => !Cause.hasInterruptsOnly(cause),807              (cause) => Effect.fail(Cause.squash(cause)),Effect 异步工作流。808            ),809            Effect.retry(Effect 异步工作流。810              SessionRetry.policy({811                provider: input.model.providerID,选择模型或 provider。812                parse,813                set: (info) => {814                  // TODO(v2): Temporary dual-write while migrating session messages to v2 events.815                  const event = flags.experimentalEventSystem816                    ? events.publish(SessionEvent.Retried, {广播状态变化。817                        sessionID: ctx.sessionID,818                        attempt: info.attempt,819                        error: {820                          message: info.message,把流事件写回消息。821                          isRetryable: true,822                        },823                        timestamp: DateTime.makeUnsafe(Date.now()),824                      })825                    : Effect.voidEffect 异步工作流。826                  return event.pipe(返回给上一层。827                    Effect.andThen(Effect 异步工作流。828                      status.set(ctx.sessionID, {829                        type: "retry",830                        attempt: info.attempt,831                        message: info.message,把流事件写回消息。832                        action: info.action,833                        next: info.next,834                      }),835                    ),836                  )837                },838              }),839            ),840            Effect.catch(halt),Effect 异步工作流。841            Effect.ensuring(cleanup()),Effect 异步工作流。842          )843844          if (ctx.needsCompaction) return "compact"按条件进入分支。845          if (ctx.blocked || ctx.assistantMessage.error) return "stop"按条件进入分支。846          return "continue"返回给上一层。

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

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

packages/opencode/src/session/processor.ts packages/opencode/src/session/processor.ts:609-615
609            if (按条件进入分支。610              !ctx.assistantMessage.summary &&611              isOverflow({ cfg: yield* config.get(), tokens: usage.tokens, model: ctx.model })读取运行配置。612            ) {613              ctx.needsCompaction = true614            }615            return返回给上一层。
packages/opencode/src/session/processor.ts packages/opencode/src/session/processor.ts:750-756
750      const halt = Effect.fn("SessionProcessor.halt")(function* (e: unknown) {处理模型流事件。751        slog.error("process", { error: errorMessage(e), stack: e instanceof Error ? e.stack : undefined })752        const error = parse(e)753        if (MessageV2.ContextOverflowError.isInstance(error)) {会话消息片段结构。754          ctx.needsCompaction = true755          yield* bus.publish(Session.Event.Error, { sessionID: ctx.sessionID, error })广播状态变化。756          return返回给上一层。
packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:1461-1471
1461            if (result === "stop") return "break" as const按条件进入分支。1462            if (result === "compact") {按条件进入分支。1463              yield* compaction.create({等待 Effect 结果。1464                sessionID,1465                agent: lastUser.agent,1466                model: lastUser.model,选择模型或 provider。1467                auto: true,1468                overflow: !handle.message.finish,1469              })1470            }1471            return "continue" as const返回给上一层。

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

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

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

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

packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:1485-1489
1485    const loop: (input: LoopInput) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.loop")(function* (会话消息片段结构。1486      input: LoopInput,1487    ) {1488      return yield* state.ensureRunning(input.sessionID, lastAssistant(input.sessionID), runLoop(input.sessionID))agent 核心循环。1489    })
packages/opencode/src/session/run-state.ts packages/opencode/src/session/run-state.ts:34-93
34    const state = yield* InstanceState.make(等待 Effect 结果。35      Effect.fn("SessionRunState.state")(function* () {Effect 异步工作流。36        const scope = yield* Scope.Scope等待 Effect 结果。37        const runners = new Map<SessionID, Runner.Runner<MessageV2.WithParts>>()会话消息片段结构。38        yield* Effect.addFinalizer(Effect 异步工作流。39          Effect.fnUntraced(function* () {Effect 异步工作流。40            yield* Effect.forEach(runners.values(), (runner) => runner.cancel, {Effect 异步工作流。41              concurrency: "unbounded",42              discard: true,43            })44            runners.clear()45          }),46        )47        return { runners, scope }返回给上一层。48      }),49    )5051    const runner = Effect.fn("SessionRunState.runner")(function* (Effect 异步工作流。52      sessionID: SessionID,53      onInterrupt: Effect.Effect<MessageV2.WithParts>,会话消息片段结构。54    ) {55      const data = yield* InstanceState.get(state)等待 Effect 结果。56      const existing = data.runners.get(sessionID)57      if (existing) return existing按条件进入分支。58      const next = Runner.make<MessageV2.WithParts>(data.scope, {会话消息片段结构。59        onIdle: Effect.gen(function* () {Effect 异步工作流。60          data.runners.delete(sessionID)61          yield* status.set(sessionID, { type: "idle" })等待 Effect 结果。62        }),63        onBusy: status.set(sessionID, { type: "busy" }),64        onInterrupt,65      })66      data.runners.set(sessionID, next)67      return next返回给上一层。68    })6970    const assertNotBusy = Effect.fn("SessionRunState.assertNotBusy")(function* (sessionID: SessionID) {Effect 异步工作流。71      const data = yield* InstanceState.get(state)等待 Effect 结果。72      const existing = data.runners.get(sessionID)73      if (existing?.busy) yield* busyError(sessionID)等待 Effect 结果。74    })7576    const cancel = Effect.fn("SessionRunState.cancel")(function* (sessionID: SessionID) {Effect 异步工作流。77      yield* cancelBackgroundJobs(background, sessionID)等待 Effect 结果。78      const data = yield* InstanceState.get(state)等待 Effect 结果。79      const existing = data.runners.get(sessionID)80      if (!existing || !existing.busy) {按条件进入分支。81        yield* status.set(sessionID, { type: "idle" })等待 Effect 结果。82        return返回给上一层。83      }84      yield* existing.cancel等待 Effect 结果。85    })8687    const ensureRunning = Effect.fn("SessionRunState.ensureRunning")(function* (Effect 异步工作流。88      sessionID: SessionID,89      onInterrupt: Effect.Effect<MessageV2.WithParts>,会话消息片段结构。90      work: Effect.Effect<MessageV2.WithParts>,会话消息片段结构。91    ) {92      return yield* (yield* runner(sessionID, onInterrupt)).ensureRunning(work)等待 Effect 结果。93    })

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

OpenCode 有两类可见护栏:

  • agent 的 steps 形成最大步数;到最后一步时,loop 给模型追加 MAX_STEPS 提示。来源:packages/opencode/src/session/prompt.ts:1316-1325packages/opencode/src/session/prompt.ts:1429-1437

    packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:1316-1325
    1316          const agent = yield* agents.get(lastUser.agent)等待 Effect 结果。1317          if (!agent) {按条件进入分支。1318            const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)等待 Effect 结果。1319            const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""1320            const error = new NamedError.Unknown({ message: `Agent not found: "${lastUser.agent}".${hint}` })1321            yield* bus.publish(Session.Event.Error, { sessionID, error: error.toObject() })广播状态变化。1322            throw error失败时抛出错误。1323          }1324          const maxSteps = agent.steps ?? Infinity1325          const isLastStep = step >= maxSteps
    packages/opencode/src/session/prompt.ts packages/opencode/src/session/prompt.ts:1429-1437
    1429            const result = yield* handle.process({等待 Effect 结果。1430              user: lastUser,1431              agent,1432              permission: session.permission,1433              sessionID,1434              parentSessionID: session.parentID,1435              system,1436              messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [])],1437              tools,
  • processor 发现最近 3 个 part 都是同名、同输入的工具调用时,请求 doom_loop 权限。来源:packages/opencode/src/session/processor.ts:33packages/opencode/src/session/processor.ts:423-447

    packages/opencode/src/session/processor.ts packages/opencode/src/session/processor.ts:33
    33const DOOM_LOOP_THRESHOLD = 3
    packages/opencode/src/session/processor.ts packages/opencode/src/session/processor.ts:423-447
    423            const parts = MessageV2.parts(ctx.assistantMessage.id)把流事件写回消息。424            const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD)把流事件写回消息。425426            if (按条件进入分支。427              recentParts.length !== DOOM_LOOP_THRESHOLD ||428              !recentParts.every(429                (part) =>430                  part.type === "tool" &&431                  part.tool === value.name &&432                  part.state.status !== "pending" &&433                  JSON.stringify(part.state.input) === JSON.stringify(input),434              )435            ) {436              return返回给上一层。437            }438439            const agent = yield* agents.get(ctx.assistantMessage.agent)等待 Effect 结果。440            yield* permission.ask({进入权限审批。441              permission: "doom_loop",442              patterns: [value.name],443              sessionID: ctx.assistantMessage.sessionID,444              metadata: { tool: value.name, input },445              always: [value.name],446              ruleset: agent.permission,447            })

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

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:36

packages/opencode/src/session/processor.ts packages/opencode/src/session/processor.ts:36
36export type Result = "compact" | "stop" | "continue"定义数据结构约束。

它在这里扮演轻量状态机事件,作用接近 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:787-820,不要用“框架自动处理”代替解释。

packages/opencode/src/session/message-v2.ts packages/opencode/src/session/message-v2.ts:787-820
787        if (part.type === "tool") {按条件进入分支。788          toolNames.add(part.tool)789          if (part.state.status === "completed") {按条件进入分支。790            const outputText = part.state.time.compacted791              ? "[Old tool result content cleared]"792              : truncateToolOutput(part.state.output, options?.toolOutputMaxChars)793            const attachments = part.state.time.compacted || options?.stripMedia ? [] : (part.state.attachments ?? [])794795            // For providers that don't support media in tool results, extract media files796            // (images, PDFs) to be sent as a separate user message797            const mediaAttachments = attachments.filter((a) => isMedia(a.mime))798            const extractedMedia = mediaAttachments.filter((a) => !supportsMediaInToolResult(a))799            if (extractedMedia.length > 0) {按条件进入分支。800              media.push(...extractedMedia)801            }802            const finalAttachments = attachments.filter((a) => !isMedia(a.mime) || supportsMediaInToolResult(a))803804            const output =805              finalAttachments.length > 0806                ? {807                    text: outputText,808                    attachments: finalAttachments,809                  }810                : outputText811812            assistantMessage.parts.push({813              type: ("tool-" + part.tool) as `tool-${string}`,814              state: "output-available",815              toolCallId: part.callID,816              input: part.state.input,817              output,818              ...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),选择模型或 provider。819              ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),选择模型或 provider。820            })
  1. 入门:从 packages/opencode/src/cli/cmd/run.ts:791-798 追到 SessionPrompt.prompt,写下跨过的入口边界。
packages/opencode/src/cli/cmd/run.ts packages/opencode/src/cli/cmd/run.ts:791-798
791          const model = pick(args.model)792          const result = await client.session.prompt({把输入交给会话主流程。793            sessionID,794            agent,795            model,796            variant: args.variant,797            parts: [...files, { type: "text", text: message }],798          })
  1. 进阶:给 pending -> running -> completed 的每条边标出对应 processor case。
  2. 辨析:说明 SessionTools.resolveLLM.streamSessionProcessor.process 三者为什么不能合并成“工具模块”。
  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 工具究竟从哪里来?一个 readeditshell 工具怎样声明 schema、申请权限、截断输出并返回附件?这正是“Tool 调用系统”要拆开的下一层。