Agent 核心循环
eec0843ce422
Agent 生成档案
Section titled “Agent 生成档案”- 章节 ID:
03-agent-core-loop - 章节摘要:沿一次“读取文件再回答”的典型源码路径,理解 OpenCode 如何以 session 消息为账本,执行工具、回填结果,并决定继续、压缩或停止。
- 教程版本:
eec0843c - 源码基线:
eec0843ce42298080569ca31a6455bc3f699d213 - 章节元数据:/versions/eec0843c/data/chapters.json
- 源码映射:/versions/eec0843c/data/source-map.json
主要源码路径
Section titled “主要源码路径”packages/opencode/src/session/prompt.tspackages/opencode/src/session/processor.tspackages/opencode/src/session/run-state.tspackages/opencode/src/session/tools.tspackages/opencode/src/session/llm.tspackages/opencode/src/session/llm/ai-sdk.tspackages/opencode/src/session/message-v2.tspackages/opencode/src/cli/cmd/run.tspackages/opencode/src/server/routes/instance/httpapi/handlers/session.ts
本章以 OpenCode 源码版本
eec0843ce422为证据基线。我们用一个典型场景追踪源码:模型第一次只发出读取package.json的工具调用时,OpenCode 怎样执行工具、拿到结果,再让模型给出最终回答?这是一条由源码证明的执行路径,不是一次真实会话的运行录屏。
0. 本章学习目标
Section titled “0. 本章学习目标”学完这一章,你应该能够:
- 画出
prompt -> runLoop -> SessionProcessor -> LLM -> tool -> message history的位置图。 - 区分“一次模型请求”“一次流事件处理”和“跨请求的 agent 循环”。
- 沿源码解释一个
read类工具调用如何经历pending -> running -> completed。 - 根据源码判断循环为什么继续、何时停止、何时转去压缩上下文。
- 说清哪些是所有 tool-using agent 都需要的最小机制,哪些是 OpenCode 的产品层。
- 为自己的 mini agent 设计一个可恢复、可观察的最小循环。
1. 一句话讲明白
Section titled “1. 一句话讲明白”OpenCode 的 Agent 核心循环,是一个以 session 消息为账本的调度循环:每轮从账本重建上下文,把模型的文本和工具事件再写回账本,然后依据最新状态继续、压缩或停止。
本章只追一个中心问题:
为什么模型第一次没有直接回答,而只是调用工具,OpenCode 最后仍能交付完整答案?
答案不在某个“神奇的 Agent 类”里,而在三段协作中:SessionPrompt.runLoop 负责跨轮调度,LLM.stream 负责接入模型流,SessionProcessor 负责把流事件落成可再次读取的消息状态。证据见 packages/opencode/src/session/prompt.ts:1240-1481、packages/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 | vSessionPrompt.prompt 记入 user message | vSessionPrompt.loop 管理同一 session 的 runner | vSessionPrompt.runLoop 每轮重读账本并调度 | | | | | +--> SessionTools.resolve 准备可执行工具 | +-----------> MessageV2 转换模型上下文 vSessionProcessor.process 消费统一的 LLMEvent | vLLM.stream provider / runtime 适配 | +--> text events ------> TextPart +--> tool-call --------> ToolPart running +--> tool-result --------> ToolPart completed | +--> 下一轮重新进入模型上下文重要边界如下:
| 模块 | 它负责什么 | 它不负责什么 |
|---|---|---|
SessionPrompt.runLoop | 跨模型请求调度、分支与退出 | 不解析每个 provider 的事件格式 |
LLM.stream | 组装模型请求,屏蔽 native / AI SDK 差异 | 不决定整个 session 何时完成 |
SessionProcessor | 把 LLMEvent 写成 message parts | 不选择本轮 agent 和 model |
SessionTools.resolve | 把 registry/MCP 工具包装为可执行工具并接入权限 | 不决定模型会调用哪一个工具 |
MessageV2 | 定义并转换可持久化的消息/part | 不执行工具 |
这张图同时划开两个层次:
- 通用 Agent 内核:读状态 -> 调模型 -> 执行动作 -> 写结果 -> 判断是否继续。
- OpenCode 产品层:权限、插件、MCP、subtask、compaction、snapshot、结构化输出、provider 兼容等。
先掌握内核,再把产品层一层层加回来。
4. 最小机制:先看 12 行伪代码
Section titled “4. 最小机制:先看 12 行伪代码”先不要读 Effect、schema 和 provider 兼容代码。最短但仍符合真实控制流的机制是:
1async function agentLoop(sessionID: string) {定义一段可复用逻辑。2 while (true) {持续循环到退出条件。3 const history = await loadCompactedHistory(sessionID)4 if (isFinished(history)) break按条件进入分支。56 const request = await prepareModelRequest(history)7 for await (const event of llmStream(request)) {消费异步流。8 await persistEventAsMessagePart(event)9 }1011 const outcome = inspectPersistedState()12 if (outcome === "stop") break按条件进入分支。13 if (outcome === "compact") await enqueueCompaction(sessionID)按条件进入分支。14 }15}
这不是 OpenCode 源码的逐行翻译,而是从 runLoop、process 和 handleEvent 抽出的教学骨架。OpenCode 的真实实现还要处理 subtask、权限拒绝、重试、中断和 provider 差异。
4.1 不要把三种“循环”混在一起
Section titled “4.1 不要把三种“循环”混在一起”| 层次 | 真实标识符 | 一次循环处理什么 | 结束意味着什么 |
|---|---|---|---|
| session 外循环 | SessionPrompt.runLoop 的 while (true) | 一次完整模型请求及其结果 | 整个用户任务暂时完成或失败 |
| stream 消费 | Stream.runDrain | 一个 LLMEvent | 本次模型流已消费完 |
| 工具执行 | AI SDK tool 的 execute | 一个具体工具调用 | 工具结果产生,但 Agent 未必完成 |
“工具执行结束”不等于“Agent 结束”。它通常只是把缺失事实补进账本,等待 session 外循环发起下一次模型请求。
5. 读源码前,只补两个概念
Section titled “5. 读源码前,只补两个概念”5.1 Message 是一页,Part 是页内的记录
Section titled “5.1 Message 是一页,Part 是页内的记录”MessageV2.WithParts 由 info 和 parts 组成:
1export type WithParts = {定义数据结构约束。2 info: Info3 parts: Part[]4}
来源:packages/opencode/src/session/message-v2.ts: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真实类型由 ToolStatePending、ToolStateRunning、ToolStateCompleted、ToolStateError 组成,并以 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;只有 noReply 为 true 时才只记账不运行。
1const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)Effect 异步工作流。2yield* revert.cleanup(session)等待 Effect 结果。3const message = yield* createUserMessage(input)等待 Effect 结果。4yield* sessions.touch(input.sessionID)等待 Effect 结果。5// ...把 input.tools 转成 session permission...6if (input.noReply === true) return message按条件进入分支。7return yield* loop({ sessionID: input.sessionID })等待 Effect 结果。
来源:packages/opencode/src/session/prompt.ts: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-731、packages/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-1325、packages/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 异步工作流。
接着准备三类输入:
SessionTools.resolve(...):把 registry 与 MCP 工具包装成模型可调用的工具。sys.environment、instruction.system、sys.skills:组成 system 内容。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-204、packages/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.toLLMEvents 把 tool-call、tool-result、text 等事件转换为统一的 LLMEvent。来源:packages/opencode/src/session/llm.ts:471-493、packages/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 )
对我们的例子,关键事件是:
tool-input-start:ensureToolCall创建pending的ToolPart。tool-call:processor 写入解析后的参数,状态转为running。- AI SDK 调用上一节注册的
execute,实际执行 read 类工具。 tool-result:processor 提取输出与附件,调用completeToolCall,状态转为completed。
证据分别位于 packages/opencode/src/session/processor.ts:232-279、packages/opencode/src/session/processor.ts:349-421、packages/opencode/src/session/processor.ts:451-500、packages/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-1276、packages/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. 决定系统可靠性的五个分支
Section titled “7. 决定系统可靠性的五个分支”最小循环很短,工程质量却藏在停止条件和失败路径里。
7.1 有 finish 为什么还不能立刻停止
Section titled “7.1 有 finish 为什么还不能立刻停止”一些 provider 可能返回 stop,但 assistant message 中仍包含需要回送给模型的 tool call。OpenCode 会检查最近 assistant 的非 provider-executed tool parts;只有 finish 不是 tool-calls、不存在这类 tool part,而且 assistant 新于 user 时才退出。来源:packages/opencode/src/session/prompt.ts: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-212、packages/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"返回给上一层。
这是已由控制流证明的行为。至于某个具体工具需要哪些权限,必须继续查看该工具实现,不能从核心循环推断。
7.3 上下文太长不是普通 stop
Section titled “7.3 上下文太长不是普通 stop”当 step 使用量溢出,或捕获到 ContextOverflowError,processor 设置 needsCompaction,返回 compact。runLoop 随后创建 compaction 工作,再继续循环。来源:packages/opencode/src/session/processor.ts:609-615、packages/opencode/src/session/processor.ts:750-756、packages/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.ensureRunning。SessionRunState 按 sessionID 保存 runner,并统一处理 busy、idle、cancel 与 interrupt。来源:packages/opencode/src/session/prompt.ts:1485-1489、packages/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 锁。
7.5 怎样防止无限行动
Section titled “7.5 怎样防止无限行动”OpenCode 有两类可见护栏:
-
agent 的
steps形成最大步数;到最后一步时,loop 给模型追加MAX_STEPS提示。来源:packages/opencode/src/session/prompt.ts:1316-1325、packages/opencode/src/session/prompt.ts:1429-1437。packages/opencode/src/session/prompt.ts
packages/opencode/src/session/prompt.ts:1316-13251316 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 >= maxStepspackages/opencode/src/session/prompt.ts
packages/opencode/src/session/prompt.ts:1429-14371429 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:33、packages/opencode/src/session/processor.ts:423-447。packages/opencode/src/session/processor.ts
packages/opencode/src/session/processor.ts:3333const DOOM_LOOP_THRESHOLD = 3packages/opencode/src/session/processor.ts
packages/opencode/src/session/processor.ts:423-447423 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 输出统一 LLMEvent | processor 直接处理各 SDK event | session 层稳定;需要维护 adapter |
| 工具在哪里执行 | tool wrapper 的 execute | loop 手写 switch(toolName) | registry、MCP、插件可扩展;调用链更长 |
| 何时判断继续 | processor 返回局部结果,外循环综合历史 | LLM gateway 直接决定整个任务结束 | 职责更清晰;停止逻辑分布在两处 |
| 超长上下文怎么办 | 把 compaction 作为待处理工作再入循环 | 直接丢弃旧消息或失败 | 保留任务连续性;增加消息重排与最新状态判断难度 |
这些“为什么”有两种证据强度:表中选择本身由源码直接证明;收益与代价是基于结构作出的设计解释,不是源码注释中的原话。
9. TypeScript / Effect:只学会挡路的三处
Section titled “9. TypeScript / Effect:只学会挡路的三处”9.1 Effect.gen 与 yield*
Section titled “9.1 Effect.gen 与 yield*”1Effect.gen(function* () {Effect 异步工作流。2 const model = yield* getModel(/* ... */)等待 Effect 结果。3 const result = yield* handle.process(/* ... */)等待 Effect 结果。4})
这里不是用 generator 产出序列,而是用近似同步的写法组合 Effect。可以临时类比 Reactor 链或带依赖/错误通道的 CompletableFuture;但 Effect 还编码环境、错误和资源作用域,不能等同于普通 future。
9.2 字符串联合类型
Section titled “9.2 字符串联合类型”1export type Result = "compact" | "stop" | "continue"定义数据结构约束。
来源:packages/opencode/src/session/processor.ts:36
packages/opencode/src/session/processor.ts
packages/opencode/src/session/processor.ts:36
36export type Result = "compact" | "stop" | "continue"定义数据结构约束。
它在这里扮演轻量状态机事件,作用接近 Java enum,但运行时仍是字符串。
9.3 discriminated union
Section titled “9.3 discriminated union”ToolState 的每个成员都有不同的 status。检查 part.state.status === "completed" 后,TypeScript 就能缩小到带 output 的状态。它接近 Java sealed hierarchy,但 OpenCode 的 Schema 还提供运行时数据边界。
10. 可以带走的方法
Section titled “10. 可以带走的方法”方法一:把 Agent 写成“状态推进器”,不要写成超长回调
Section titled “方法一:把 Agent 写成“状态推进器”,不要写成超长回调”每一轮只做四步:读稳定状态、准备请求、执行一步、写回结果。下一轮从存储状态恢复,而不是依赖上一次函数栈里的隐式变量。
验证问题:进程在工具完成后、第二次模型请求前中断,你的系统能否仅凭已保存数据继续?
方法二:先统一事件,再更新领域状态
Section titled “方法二:先统一事件,再更新领域状态”provider adapter 先把外部事件统一为内部事件;processor 再把内部事件转换成 TextPart、ToolPart。这样 provider 变化不会直接污染 session 模型。
验证问题:接入第二家模型 provider 时,你需要修改业务状态机,还是只需增加/调整 adapter?
方法三:把停止条件写成“没有未完成工作”
Section titled “方法三:把停止条件写成“没有未完成工作””不要只写 finish === "stop"。同时检查未闭合工具调用、错误、权限阻塞、压缩任务和最大步数。
验证问题:模型声称停止,但刚刚发出了工具调用,你的 loop 会丢掉结果吗?
11. 费曼复述与练习阶梯
Section titled “11. 费曼复述与练习阶梯”11.1 60 秒复述
Section titled “11.1 60 秒复述”不要看上文,用自己的话补全:
OpenCode 先把 ______ 写进 session。
runLoop每轮从 ______ 重建上下文。模型产生 tool call 后,______ 执行工具,______ 把事件写成 ToolPart。下一轮由 ______ 把 completed ToolPart 转成模型消息。只有当 ______ 时,外循环才结束。
如果你在“工具结果怎样再次进入模型”处卡住,请回看 packages/opencode/src/session/message-v2.ts: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 })
11.2 读源码练习
Section titled “11.2 读源码练习”- 入门:从
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 })
- 进阶:给
pending -> running -> completed的每条边标出对应 processor case。 - 辨析:说明
SessionTools.resolve、LLM.stream、SessionProcessor.process三者为什么不能合并成“工具模块”。 - 失败路径:把工具权限拒绝和上下文溢出的状态迁移分别画出来。
11.3 小实现
Section titled “11.3 小实现”写一个内存版 mini agent:
messages用数组保存;- 只有一个
readFakeFile工具; - 假模型第一次返回 tool call,第二次返回 text;
- 每次模型调用前必须从
messages重建输入; - 加入一个明确的最大步数,以及“同工具同参数连续 3 次”的检查点。
验收标准不是“打印出了答案”,而是你能在日志里看到两次模型请求和完整的工具状态迁移。
12. 最后复盘:把整台机器重新装回去
Section titled “12. 最后复盘:把整台机器重新装回去”本章的唯一内核是:
读 session -> 调模型 -> 执行工具 -> 写回 part -> 再读 session -> 结束或继续围绕它,OpenCode 又加上了:
SessionRunState管理 session runner 与中断;LLM.stream适配 provider/runtime;SessionProcessor将流事件变成可观察、可恢复的 parts;SessionTools.resolve接入 registry、MCP、权限和插件;- compaction、subtask、structured output 与 doom-loop 检查处理产品级边界。
你现在应该能回答中心问题:模型第一次只调用工具时,OpenCode 不是等待“模型自己继续”,而是把工具结果写入 session,再由外循环重建下一次模型请求。
下一章自然要追问:SessionTools.resolve 收到的 registry 工具究竟从哪里来?一个 read、edit 或 shell 工具怎样声明 schema、申请权限、截断输出并返回附件?这正是“Tool 调用系统”要拆开的下一层。