流处理器与工具调用生命周期
处理器如何把 LLMEvent 流翻译成 Part:工具调用的生命周期、步末结算与溢出判定、重试、死循环检测与中断收尾。
主循环每一步都新建一条 assistant 消息,并为它创建一个处理器句柄(packages/opencode/src/session/processor.ts)。句柄的核心方法是 process(streamInput):把 LLM 层吐出的事件流逐个翻译成 Part 写进这条消息,最后告诉循环三选一的结果——compact、stop 或 continue。
先拍快照
create 做的第一件事是拍文件快照。注释解释了为什么不能等 step-start 事件再拍(processor.ts:99):
Pre-capture snapshot before the LLM stream starts. The AI SDK may execute tools internally before emitting start-step events, so capturing inside the event handler can be too late.
AI SDK 可能在发出 start-step 之前就在内部执行了工具,等事件到了再拍,文件也许已经被改过。快照的用途见快照与撤销。
process 的骨架
processor.ts:650,删减后:
yield* Effect.gen(function* () {
// ...
yield* status.set(ctx.sessionID, { type: "busy" })
const stream = llm.stream(streamInput)
yield* stream.pipe(
Stream.tap((event) => handleEvent(event)),
Stream.takeUntil(() => ctx.needsCompaction),
Stream.runDrain,
)
}).pipe(
Effect.onInterrupt(() =>
// ...
),
// ...
Effect.retry(
SessionRetry.policy({
// ...
}),
),
Effect.catch(halt),
Effect.ensuring(cleanup()),
)
if (ctx.needsCompaction) return "compact"
if (ctx.blocked || ctx.assistantMessage.error) return "stop"
return "continue"几层包装各司其职:takeUntil 让某个 step-finish 一旦判定上下文超限就立刻停止消费;用户中断时标记 aborted 并按 AbortError 走 halt;其他错误交给重试策略,重试会重跑整个流;重试耗尽或不可重试则由 halt 记录错误;cleanup 无论成败都会执行。
事件到 Part 的映射
handleEvent(processor.ts:278)是一个大 switch:
| LLMEvent | 处理 |
|---|---|
text-start、text-delta、text-end | 建 text Part;增量走不落库的 updatePartDelta;结束时经插件钩子改写后整段写入 |
reasoning-start、reasoning-delta、reasoning-end | 同上,生成 reasoning Part,按 ID 可多段并存 |
tool-input-start、tool-input-delta、tool-input-end | ensureToolCall:首次出现时建 pending 的 tool Part |
tool-call | 参数到齐,改为 running,并做死循环检测 |
tool-result、tool-error | 改为 completed 或 error |
step-start | 写 step-start Part,带快照哈希 |
step-finish | 结算用量,写 step-finish 与 patch Part,判断溢出 |
provider-error | 直接抛错,交给重试或 halt |
另有两道防线:正在生成压缩摘要时,任何工具调用都直接抛错;没有对应 start 的 reasoning 增量则被静默丢弃。
工具调用的一生
处理器并不执行工具。执行发生在 AI SDK(或原生链路)调用 session/tools.ts 包装的 execute 时,处理器只记账:每个调用在 ctx.toolcalls 里登记一个 Deferred。
失败原因若是权限或提问被用户拒绝,处理器置 ctx.blocked = ctx.shouldBreak,本步结束后返回 stop;配置了 experimental.continue_loop_on_deny 时则不停,模型会带着“被拒绝”的结果继续。用户中断时流已断开、等不来 tool-result,所以 tools.ts 在工具执行完后检查 abortSignal,自己调用 completeToolCall 补记结果。
死循环检测
processor.ts:353,删减后(阈值 DOOM_LOOP_THRESHOLD = 3):
const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD)
if (
recentParts.length !== DOOM_LOOP_THRESHOLD ||
!recentParts.every(
(part) =>
part.type === "tool" &&
part.tool === value.name &&
part.state.status !== "pending" &&
JSON.stringify(part.state.input) === JSON.stringify(input),
)
) {
return
}
const agent = yield* agents.get(ctx.assistantMessage.agent)
yield* permission.ask({
permission: "doom_loop",
// ...
})当前消息最后 3 个 Part 都是同名工具、参数 JSON 完全一致且都已离开 pending,就发起一次 doom_loop 权限询问。内置 agent 的默认规则里 doom_loop 为 ask(packages/opencode/src/agent/agent.ts:121),即把“要不要继续”交给用户。注意检测范围只是当前这条 assistant 消息,而主循环每一步都会新建消息。
步末结算
step-finish 分支(processor.ts:435)依次:再拍一次快照;收尾所有 reasoning;若 Anthropic 报告丢弃了思考块就记警告(注释说这意味着历史在签名块背后被改动过);用 Session.getUsage 算用量与费用;写 step-finish Part;与步首快照比较,有改动就写 patch Part;fork 出差异统计;最后判断溢出。
getUsage(session.ts:338)的细节:AI SDK v6 起 inputTokens 已含缓存命中,代码显式减去缓存读写;output 扣掉推理 token,推理按输出单价计费(标了 TODO);单价支持按上下文长度分档;全程用 decimal.js 计算。
cost 是累加,tokens 却是覆盖——消息上保留的是最后一步的用量,溢出判断用的正是它。
溢出判定
packages/opencode/src/session/overflow.ts:10:
export function usable(input: { cfg: ConfigV1.Info; model: Provider.Model; outputTokenMax?: number }) {
const context = input.model.limit.context
if (context === 0) return 0
const reserved =
input.cfg.compaction?.reserved ??
Math.min(COMPACTION_BUFFER, ProviderTransform.maxOutputTokens(input.model, input.outputTokenMax))
return input.model.limit.input
? Math.max(0, input.model.limit.input - reserved)
: Math.max(0, context - ProviderTransform.maxOutputTokens(input.model, input.outputTokenMax))
}模型声明了独立的输入上限时,可用量 = 输入上限 − 预留(默认取 2 万与最大输出的较小者);否则 = 上下文窗口 − 最大输出(模型输出上限与 32000 取小)。isOverflow 在关闭自动压缩或窗口未知时恒为假,否则比较本步 token 总量是否达到可用量。判定为真就置 needsCompaction,process 返回 compact,后续见上下文压缩。
重试
retry.ts 最多重试 5 次;间隔从 2 秒起每次翻倍,加最多 25% 的随机抖动,没有响应头时封顶 30 秒;响应头里有 retry-after-ms 或 retry-after(秒数或 HTTP 日期)时以它为准。
ContextOverflowError 永不重试,因为它该走压缩。APIError 在 SDK 标记可重试、状态码为 5xx、或消息与响应体命中一组正则(429、rate limit、overloaded、各类网络错误与超时)时重试。每次重试前,会话状态被设为 retry,带上尝试次数、原因和下次重试时间。
halt 与 cleanup
halt 用 MessageV2.fromError 把异常归类成消息错误。ContextOverflowError 是特例:关闭了自动压缩就记为错误并置 idle,否则置 needsCompaction,让本步返回 compact。
cleanup 补写剩余的 patch Part,给未结束的 text 与 reasoning 补上结束时间,给未完成的工具调用最多 250 毫秒收尾;仍未完成的一律改成 error,错误文本为“Tool execution aborted”,并打上 metadata.interrupted = true。这个标记在两处被读取:主循环判断结束时忽略这类“孤儿”调用;toModelMessages 把仍处于 pending 或 running 的调用补成错误结果,注释写明 Anthropic 要求每个 tool_use 都有对应的 tool_result。
下一篇:系统提示词与指令文件——每一步发给模型的 system 由哪些部分拼成。