跳到主要内容

01 · Agent 主循环

本章讲清 opencode 最核心的那个东西:一次对话是怎么从 user 消息,变成模型自主读写文件、跑命令,直到任务做完的。

1. 它要解决的小问题

大模型本身只会"输出文本"。要让它真能改代码,你得搭一个循环:把模型的输出解析出"它想调哪个工具"→ 真去执行 → 把结果再喂回去 → 让它根据结果继续。这个 agent loop 是所有编码 agent 的心脏,opencode 的实现住在 session/prompt.tsrunLoop

2. 思路 / 直觉

把一轮模型交互叫一个 step。一个 step 里,模型可能:只说话(纯文本)、或说话 + 调几个工具。循环的判断很简单:

  • 模型这一步还想调工具(finishtool-calls,或消息里确实有未完成的工具 part)→ 执行工具,继续下一轮。
  • 模型这一步说完了正事(finishstop 之类,且没有待办工具)→ 收敛,break
┌──────────────────────── while(true) ────────────────────────┐
│ │
│ 取历史消息 ──► 选模型/agent ──► 组装 system + tools │
│ │ │
│ ▼ │
│ processor.process(streamInput) │
│ │ (流式:文本 / 推理 / 工具调用 / 工具结果) │
│ ▼ │
│ 结果 = continue / stop / compact │
│ │ │
│ ├─ 模型还要调工具 ─────────────────► 回到顶部 continue │
│ └─ 正常结束 & 无待办工具 ──────────► break ────────────┘
│ │
└──────────────────────────────────────────────────────────────

怎么读:循环每轮跑一个 step;"还要调工具"就再转一圈,把工具结果带给模型;真说完了才跳出。

3. 主线代码走读

3.1 循环骨架

runLoop 是个 while (true),每轮先把会话历史拉出来,算出"最后一条 user / assistant 消息",再判断是否该退出:

// packages/opencode/src/session/prompt.ts:1088 起,示意精简
while (true) {
let msgs = yield* MessageV2.filterCompactedEffect(sessionID) // 取(压缩后)历史
const { user: lastUser, assistant: lastAssistant } = MessageV2.latest(msgs)

// 退出条件:上一条 assistant 正常结束 & 没有待办工具 & user 早于它
if (lastAssistant?.finish && !['tool-calls'].includes(lastAssistant.finish)
&& !hasToolCalls && lastUser.id < lastAssistant.id) {
break
}
step++
// ... 组装上下文 + 调 processor(见下)
}

退出判断的关键在 prompt.ts:1106-1130:即便 provider 错误地把 finish 标成 stop 而消息里其实有工具调用,hasToolCalls 也会兜住,让循环继续把工具结果送回模型。这是个真实踩过的坑(prompt.ts:1103-1105 的注释明说"Some providers return 'stop' even when the assistant message contains tool calls")。

3.2 组装上下文 + 起 processor

每轮新建一条 assistant 消息,创建一个 processor handle,解析出本轮可用工具,再拼系统提示:

// prompt.ts:1213 起,示意精简
const handle = yield* processor.create({ assistantMessage: msg, sessionID, model })

const tools = yield* SessionTools.resolve({ agent, session, model, processor: handle, messages: msgs, ... })
const [skills, env, instructions, mcpInstructions, modelMsgs] = yield* Effect.all([
sys.skills(agent), sys.environment(model), instruction.system(),
sys.mcp(agent, session.permission), MessageV2.toModelMessagesEffect(msgs, model),
])
const system = [...env, ...instructions, ...(mcpInstructions ? [mcpInstructions] : []), ...(skills ? [skills] : [])]

const result = yield* handle.process({ user: lastUser, agent, system, messages: modelMsgs, tools, model, ... })
  • 工具集由 SessionTools.resolve(session/tools.ts)按当前 agent / 模型解析,并给每个工具包一层权限 ask
  • 系统提示是分块数组:环境信息 + 指令文件 + MCP 指令 + 技能清单,而不是一坨字符串。环境块由 SystemPrompt.environment 生成(session/system.ts:58),含 cwd、worktree、平台、日期。
  • 系统提示按模型选模板:system.ts:26provider() 根据 model id 选 anthropic.txt / gpt.txt / gemini.txt / beast.txt 等不同基底 prompt。

3.3 processor:把流落成 part

handle.processprocessor.ts:625process,它订阅 llm.stream(...) 的事件流,逐个交给 handleEvent:

// processor.ts:638 起,示意精简
const stream = llm.stream(streamInput)
yield* stream.pipe(
Stream.tap((event) => handleEvent(event)),
Stream.takeUntil(() => ctx.needsCompaction), // 中途若判定要压缩,提前停
Stream.runDrain,
)

handleEvent(processor.ts:276)是一台事件状态机,对每种事件做一件事:

事件它做什么
text-start / text-delta / text-end累积助手可见文本,流式更新一个 text part
reasoning-*同理累积"思考"内容到 reasoning part
tool-input-start/delta/end创建/更新一个 tool part(状态 pending)
tool-call把 part 置为 running,并做 doom-loop 检测(见 04 章)
tool-result工具完成,写回 output/metadata,处理图片附件
tool-error工具失败,part 置 error
step-start / step-finish记录快照、用量、成本;step-finish 还判断是否要压缩

注意:工具的实际执行不在 processor 里手写调用。默认路径下是 AI SDK 的 streamText 自己执行工具(tools: prepared.tools 传进去),processor 只是把"工具被调用了/工具有结果了"这些事件落成会话状态。llm.ts:318 把工具列表喂给 streamText,执行由 SDK owns。

3.4 LLM 流的归一

llm.stream(llm.ts:357)做一件关键的适配:无论底层是 AI SDK 还是实验性原生 runtime,都输出同一种 LLMEvent 流。AI SDK 那条路把 result.fullStreamLLMAISDK.toLLMEvents 转换:

// llm.ts:373 起,示意精简
return Stream.fromAsyncIterable(result.result.fullStream, ...).pipe(
Stream.mapEffect((event) => LLMAISDK.toLLMEvents(state, event)), // SDK 事件 → 统一 LLMEvent
Stream.flatMap((events) => Stream.fromIterable(events)),
)

session/llm/ai-sdk.tstoLLMEvents 是个大 switch,把 AI SDK 的 text-delta/tool-call/finish-step 等逐一映射成 opencode 自己的事件类型(ai-sdk.ts:76)。这层适配让 processor 完全不关心底层用的是哪家 SDK。

4. 关键细节 / 坑

  • step 上限。 agent 可配 steps(agent.ts:54);prompt.ts:1178-1180isLastStep,到达上限时往消息里追加一条 MAX_STEPS_PROMPT 提示模型收尾。
  • 首步副作用。 step === 1 时会 fork 出"生成标题"和"生成摘要"两个后台任务(prompt.ts:11331251),不阻塞主循环。
  • 中断处理。 整个 process 用 Effect.onInterrupt 兜底:被取消时把未完成的 assistant 消息标记为 aborted(prompt.ts:1203-1211processor.ts:646),把还在 running 的工具 part 标记 interrupted(processor.ts:575-591)。
  • 重试。 provider 报错走 SessionRetry.policy(processor.ts:658,定义在 session/retry.ts),期间会把会话状态置 retry 并广播。
  • 结构化输出。 若 user 指定了 json_schema 格式,循环会注入一个 StructuredOutput 工具并把 toolChoice 设为 required(prompt.ts:1242-12481284),模型必须用它返回结果。

5. 代码地图

主题文件路径符号名
主循环packages/opencode/src/session/prompt.tsrunLoop, loop
退出/工具判定packages/opencode/src/session/prompt.tshasToolCalls, isOrphanedInterruptedTool
流处理器服务packages/opencode/src/session/processor.tsService, layer
事件状态机packages/opencode/src/session/processor.tshandleEvent, ensureToolCall, completeToolCall
process 主体 + 重试/中断packages/opencode/src/session/processor.tsprocess, halt, cleanup
provider 流packages/opencode/src/session/llm.tsstream, run
AI SDK 事件归一packages/opencode/src/session/llm/ai-sdk.tstoLLMEvents, adapterState
系统提示组装packages/opencode/src/session/system.tsprovider, environment, skills