跳到主要内容

数据截至 (上游 commit 0004b748b71c)

会话主循环:一次对话到底转了几圈

30 秒导读: 你在 Kilo Code 里敲一句话回车,模型可能读文件、改代码、跑命令,来回十几轮才停。这一章讲的就是"这十几轮是谁在转、怎么转、什么时候停"。核心是 packages/opencode/src/session/prompt.ts:1466runLoop —— 一个 while (true),每转一圈 = 一次模型调用 + 若干工具执行。

本章是这本文档的主线章。上一章 运行骨架 讲了服务图和 SQLite,本章讲这些零件怎么被一个循环串起来;工具的具体实现见 工具层与权限闸门,上下文超了怎么砍见 上下文经济学


1. 先建立直觉:循环,不是请求

一句话直觉

把一轮对话想成"打乒乓",不是"寄快递"。

寄快递是一来一回:你发请求,服务器回结果,结束。Kilo Code 是打乒乓:模型说"我要读这个文件",运行时把文件内容打回去,模型再说"我要改这一行",运行时改完再打回去……直到模型说"我说完了"。

每一次来回,Kilo Code 都做同一件事:

  1. 从数据库把这个会话的全部消息重新读一遍
  2. 判断"该停了吗"
  3. 没停就组一个新 prompt,发给模型
  4. 把模型的流式输出落成数据库里的 part
  5. 回到第 1 步

第 1 步是关键的反直觉点:循环不维护内存里的消息数组,每圈都从存储重新取MessageV2.filterCompactedEffectpackages/opencode/src/session/prompt.ts:1486)。这样工具执行期间写进去的任何东西——子任务的产出、压缩摘要、用户中途插的话——下一圈自动看得到。

这一章会回答的问题

问题在哪一节
谁能启动一轮对话§2 四个入口
循环每圈干了什么、什么时候停§3 runLoop 拆解
模型吐的 token 怎么变成数据库里的记录§4 SessionProcessor
不同厂商的流式事件怎么统一§5 LLM 服务层
按 Esc 打断之后发生什么§6 中断与恢复
subagent 是什么东西§7 子任务
网络抖了怎么办§8 重试与错误

2. 四个入口:谁能开启一轮

2.1 对外只有四个动词

SessionPrompt.Interface 一共暴露五个方法,四个能开启工作(packages/opencode/src/session/prompt.ts:139-150):

入口白话输入类型定义位置
prompt用户发了一条消息PromptInputprompt.ts:1308
loop不加新消息,直接接着转LoopInputprompt.ts:1782
shell用户自己跑了条命令,记进会话ShellInputprompt.ts:1811
command用户敲了个斜杠命令CommandInputprompt.ts:1823
cancel打断prompt.ts:169

HTTP 层是一一对应转发的:handle("prompt") / handle("command") / handle("shell") / abort 都在 packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts:467-475

2.2 它们最终都汇到同一个循环

prompt() command() shell() loop()
用户消息 /slash !bash 压缩后续跑
│ │ │ │
│ │ 展开模板 │ │
│ ▼ │ │
│ resolvePromptParts │ │
│ │ │ │
└────────┬────────┘ │ │
▼ ▼ │
写 user message shellImpl │
(createUserMessage) 单跑一条命令 │
│ 不调模型 │
▼ │
KiloSessionPromptQueue.enqueue ──────────────────────┤
(同会话串行排队) │
│ │
└──────────────► loop() ◄────────────────────┘


state.ensureRunning


runLoop() ← 本章主角

怎么读这张图: 从上往下是"谁触发"到"谁执行"。注意 shell 是唯一一条不进 runLoop 的路——它只是把一条用户手动执行的命令记进会话历史,不调模型(packages/opencode/src/session/prompt.ts:599 shellImpl)。

2.3 prompt:把一句话变成一条消息 + 一次排队

prompt 做的事按顺序是(prompt.ts:1308-1358):

  1. 先恢复残局 —— recoverDanglingAssistant / recoverProviderFinishError,见 §6.3
  2. 写 user 消息 —— createUserMessage(prompt.ts:758)解析 parts、决定 agent 和 model
  3. 清掉待答的问询 —— 新消息意味着旧的权限询问/建议已经过期,Suggestion.dismissAll + question.dismissAll
  4. 排队 —— KiloSessionPromptQueue.enqueue,然后调 loop

第 3 步的注释值得读,它说明了一个刻意不做的事:

// 真实源码 packages/opencode/src/session/prompt.ts:1334-1341(注释节选)
// Critically we never cancel the in-flight fiber here — that would abort the
// streamText call mid-tokens and cut off the assistant reply.

翻译: 用户在模型说话中途又发了一句,Kilo Code 不打断当前这次模型调用。它把新消息排到队列里,等这一步的 token 全部流完,再由循环内部的 hasFollowup 检查跳出去让新消息接管(§6.4)。

2.4 resolvePromptParts@file!bash$1 的展开

用户输入的一行字不是纯文本,里面可能夹着引用。展开分两处:

(a)@ 引用 → file / agent partresolvePromptParts,prompt.ts:176-270)

ConfigMarkdown.files(template) 扫出所有 @xxx,然后逐个判定:

// 示意,非源码 —— resolvePromptParts 的判定顺序
const alias = name.split("/")[0] // @docs/api.md → alias = "docs"
if (references.get(alias)) { // 命中配置的 reference 根目录
ensureContains(reference.path, target) // 越界就产出一段 problem 文本
push({ type: "file", url: ... })
} else if (fs.exists(resolve(worktree, name))) {
push({ type: "file", url: ... }) // 普通相对路径
} else if (agents.get(name)) {
push({ type: "agent", name }) // @reviewer 这种是切 agent
}

重点看越界检查AppFileSystem.contains(reference.path, targetPath) 拦掉 @docs/../../etc/passwd 这类逃逸,不是抛错而是塞一段说明性文本进 parts(prompt.ts:213-224)。

(b)$1 / $ARGUMENTS / !`cmd` → 命令模板展开command,prompt.ts:1837-1875)

四个正则定义在文件末尾(prompt.ts:2124-2128):

正则匹配什么展开成
placeholderRegex = /\$(\d+)/g$1 $2第 N 个参数;最后一个占位符吃掉剩余全部参数
"$ARGUMENTS" 字面量$ARGUMENTS整串原始参数
bashRegex = /!`([^`]+)`/g!`git diff`命令的真实输出
argsRegex切参数支持引号包裹和 [Image 1] 整体成一个 token

!`cmd` 的执行走 CommandTimeout.texts(prompt.ts:1867),带超时保护。展开完的模板再喂回 resolvePromptParts,所以模板里也能写 @file

还有一条分叉:如果命令绑定的 agent 是 subagent 模式,command 不产出文本 part,而是产出一个 subtask part(prompt.ts:1901-1913),交给 §7 的子任务通道。


3. runLoop:一圈到底做了什么

这是全章的核心。函数签名在 packages/opencode/src/session/prompt.ts:1466,主体是 prompt.ts:1395 的 while (true)

3.1 一圈的全貌

┌───────────────────────────────────────┐
│ ① 取消息(每圈都重新从 SQLite 读) │
└────────────────┬──────────────────────┘

┌───────────────────────────────────────┐
│ ② 该退出了吗?(finish / 有无工具调用) │──── 是 ──▶ break
└────────────────┬──────────────────────┘
▼ 否
┌───────────────────────────────────────┐
│ ③ 有待办插队吗?子任务 / 压缩 / 溢出 │──── 有 ──▶ 干完 continue
└────────────────┬──────────────────────┘
▼ 无
┌───────────────────────────────────────┐
│ ④ 组 system + tools + 消息,调模型 │
│ handle.process(...) │
└────────────────┬──────────────────────┘

┌───────────────────────────────────────┐
│ ⑤ 看返回值:continue / compact / stop │
└───────────────────────────────────────┘
│ │ │
continue compact stop
└──▶ 回 ① └──▶ 回 ① └──▶ break

怎么读: 从上到下是一圈的顺序,右侧箭头是出口。②③⑤ 是三个决策点,②⑤ 能终止循环,③ 是"先干别的再回来"。

3.2 ① 取消息:为什么每圈都重读

// 真实源码 packages/opencode/src/session/prompt.ts:1492-1494
let msgs = yield* MessageV2.filterCompactedEffect(sessionID)
msgs = KiloSessionPromptQueue.scope(sessionID, msgs) // kilocode_change
msgs = KiloSessionPrompt.trimBeforeLastSummary(msgs) // kilocode_change

三层过滤,各管一件事:

步骤干什么定义位置
filterCompactedEffect从最新往回读,遇到已完成的压缩点就停,并按 tail_start_id 重排保留尾部message-v2.ts:1134 filterCompacted
scope藏掉"排在我后面"的用户消息,避免把还没轮到的 prompt 混进本轮prompt-queue.ts:84
trimBeforeLastSummaryfilterCompacted 的漏:手动 /compact 产生的摘要,其父消息是普通文本用户消息,没有 compaction part,前者切不掉kilocode/session/prompt.ts:388

第三条的注释直接点名了它修的 bug:不切的话,"reference session ended up re-shipping multi-MB base-64 images on every turn"(kilocode/session/prompt.ts:382-383)。

一个隐藏坑:数组顺序不等于时间顺序。 filterCompacted 为了适配模型消费,把消息重排成 [压缩用户消息, 摘要, ...保留尾部..., 继续用户消息]。所以取"最后一条用户消息"不能用 findLast,得按时间比较:

// 真实源码 packages/opencode/src/session/prompt.ts:1404-1405
const latest = KiloSessionMessageOrder.latest(msgs)
const { user: lastUser, assistant: lastAssistant, finished: lastFinished, tasks } = latest

KiloSessionMessageOrder.latest(kilocode/session/message-order.ts:21)先比 time.created,同毫秒再比 filterCompacted 重排前用 WeakMap 记下的原始下标(message-order.ts:12 compare、message-v2.ts:1158 annotate)。

3.3 ② 退出判定:三个条件缺一不可

基本退出条件(prompt.ts:1451-1470):

// 真实源码 packages/opencode/src/session/prompt.ts:1451-1457(简化排版)
if (
lastAssistant?.finish &&
!["tool-calls"].includes(lastAssistant.finish) &&
!hasToolCalls &&
lastAssistant.parentID === lastUser.id &&
userBeforeAssistant
) { break }

五个条件读作:最后那条 assistant 消息,(1) 已经结束、(2) 结束原因不是"要调工具"、(3) 消息里也确实没有待处理的工具调用、(4) 它是回答当前这条用户消息的、(5) 用户消息在它之前。

其中 (4)(5) 是 Kilo 加的加固:多会话/排队场景下,"最后一条 assistant"可能属于别的轮次,光靠 ID 大小判断会错,所以改成比对 parentID + 按时间比较(prompt.ts:1414-1418)。

「为什么 stop 了还要继续转」——一个 provider 兼容补丁:

// 真实源码 packages/opencode/src/session/prompt.ts:1426-1428(注释)
// Some providers return "stop" even when the assistant message contains
// tool calls. Keep the loop running so tool results can be sent back to
// the model, but ignore cleanup-marked interrupted orphans.

有些厂商在返回工具调用时,finishReason 照样给 "stop" 而不是 "tool-calls"。如果只信 finish,循环会在工具结果还没回传给模型时就退出,对话卡死。所以加了 hasToolCalls 这条独立证据:

// 真实源码 packages/opencode/src/session/prompt.ts:1429-1432
const hasToolCalls =
lastAssistantMsg?.parts.some(
(part) => part.type === "tool" && !part.metadata?.providerExecuted && !isOrphanedInterruptedTool(part),
) ?? false

两个排除项各有来头:

  • providerExecuted —— 厂商自己在服务端执行的工具(如内置搜索),结果已经在流里了,不需要本地回传。
  • isOrphanedInterruptedTool —— 打断后 cleanup() 把没跑完的工具标成 { status: "error", metadata.interrupted: true }(processor.ts:919-928)。这些是尸体不是待办,若算进 hasToolCalls,循环会试图给一个永远回不来的工具补结果,触发厂商的 assistant-prefill 校验失败。判定函数只有三行(prompt.ts:104-108)。

第三个出口:plan 模式的硬停。 若上一轮调用了 plan_exit 工具,循环在发起下一次模型调用之前弹一个确认给用户(prompt.ts:1435-1448),用户选"继续"才 continue,否则 break。判定逻辑在 kilocode/session/prompt.ts:48 shouldAskPlanFollowup

3.4 ③ 待办插队:三种"先别调模型"

// 真实源码 packages/opencode/src/session/prompt.ts:1482
const task = tasks.pop()

tasks 来自 KiloSessionMessageOrder.latest:它取"最后一条已完成 assistant 之后"的所有用户消息里的 compactionsubtask part(message-order.ts:45-54)——也就是还没被处理的工作

待办类型做什么做完位置
subtask起一个子会话跑 subagentcontinueprompt.ts:1484-1487
compaction压缩历史生成摘要continue;返回 "stop" 则记错误并 breakprompt.ts:1489-1505
溢出自动压缩上一轮 token 超了,自动排一次压缩continueprompt.ts:1507-1530

第三种不是从 tasks 来的,是主动检查:compaction.isOverflow({ tokens: lastFinished.tokens, model })。它带一个熔断

// 真实源码 packages/opencode/src/session/prompt.ts:1513-1526(简化)
const guard = KiloSessionPrompt.guardCompactionAttempt({ sessionID, attempts: compactionAttempts, closeReasons, message: lastFinished })
if (guard.exhausted) { /* 写错误、发事件 */ break }
compactionAttempts++

compactionAttempts每轮(不是每会话)的计数器,在 runLoop 入口置零(prompt.ts:1388),上限 3(kilocode/session/prompt.ts:337 MAX_COMPACTION_ATTEMPTS)。注释解释了为什么是 3:一次正常溢出压缩 + 一次"摘要自己也溢出"的重试,够了;再多就是死循环。

3.5 ④ 组装并调用

到这一步才真正准备模型请求。组装分四块:

(a)步数上限。 agent.steps 没配就是无穷(prompt.ts:1540-1541)。到达上限时,在消息尾部追加一条 assistant 消息,内容是 max-steps.txt——一段"工具已禁用,只准用文字总结"的硬指令(prompt.ts:1682)。

(b)工具集。 SessionTools.resolve 汇总内置工具 + MCP 工具 + agent 过滤(prompt.ts:1588-1604,实现在 session/tools.ts:26)。权限规则先过一道 guardPermissions(prompt.ts:1677):ask / plan / architect 这三个模式下,会话级的 allow 不能覆盖 agent 自带的限制(kilocode/session/prompt.ts:136-147)。

(c)system prompt。 三段拼接(prompt.ts:1650-1670):

// 真实源码 packages/opencode/src/session/prompt.ts:1670
const system = [...env, ...instructions, ...(skills ? [skills] : [])]

(d)体积止损。 序列化后超过 REQUEST_PRUNE_BYTES = 1_250_000(约 1.25 MB,prompt.ts:98)就触发一次持久化裁剪,然后把 ①②③ 那套过滤重跑一遍(prompt.ts:1656-1668)。这段代码是全函数唯一一处"重复自己"的地方,因为裁剪改了数据库,内存里的 msgs 就过期了。

组好之后是唯一一次外呼:

// 真实源码 packages/opencode/src/session/prompt.ts:1673-1686(字段节选)
const result = yield* handle.process({
user: lastUser, agent,
permission: KiloSessionPrompt.guardPermissions({ agent, session }),
sessionID, parentSessionID: session.parentID,
system,
messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant", content: MAX_STEPS }] : [])],
tools, model,
toolChoice: format.type === "json_schema" ? "required" : undefined,
})

3.6 ⑤ 按返回值分流

handle.process 只返回三个值之一(processor.ts:41 Result),但 runLoop 在看这三个值之前先做了几层特判。完整的优先级:

顺序条件动作位置
1拿到了结构化输出(StructuredOutput 工具被调用)breakprompt.ts:1688-1693
2要求 json_schema 但模型没给StructuredOutputErrorbreakprompt.ts:1697-1704
3finish === "error" 且无 error 对象补错误、标记 closeReasons = "error"breakprompt.ts:1706-1711
4result === "stop"breakprompt.ts:1716-1719
5result === "compact"过熔断,排一次压缩,落到 6prompt.ts:1721-1744
6队列里有更新的用户消息closeReasons = "interrupted"breakprompt.ts:1749-1752
7非 compact 且 finish 仍为空补成 "unknown" 并落库,然后 continueprompt.ts:1764-1767

第 7 条是另一个 provider 兼容补丁,注释写得很直白(prompt.ts:1754-1763):有的厂商流结束了却不给终止原因(Anthropic 风格的 message_deltastop_reason: null 后面直接 message_stop)。finish 为空 → 下一圈 ②的退出判定第一个条件就不成立 → 永远转下去。所以兜底写死 "unknown",让退出判定能生效;而 ② 里对 "unknown" 的处理是"只要没工具调用就允许退出"(!["tool-calls"].includes(finish)"unknown" 为真)。

循环结束后只做两件事(prompt.ts:1778-1779):后台 fork 一次裁剪,然后返回最后一条 assistant 消息。


4. SessionProcessor:把 token 流变成数据库记录

4.1 它解决的小问题

模型返回的是一串增量事件text-delta 一个字一个字来,tool-input-delta 一段 JSON 一段 JSON 来。但 UI 要实时显示、崩溃要能恢复、下一圈要能重读——这些都要求落库

SessionProcessor 就是这个转换器:事件流进去,MessageV2.Part 出来,边流边写。

4.2 Handle:一次模型调用的句柄

processor.create(...) 返回一个 Handle(processor.ts:43-66),四个成员:

成员用途
message本次的 assistant 消息对象(getter,读的是可变的 ctx.assistantMessage
process(streamInput)真正跑一次流,返回 Result
updateToolCall / completeToolCall / metadata给工具执行方回写状态用
compactError()读出本次是否遇到上下文溢出(Kilo 加的)

内部状态集中在 ProcessorContext(processor.ts:90-104):正在写的文本 part、reasoning 映射表、活跃的 tool call 表、快照 id、needsCompaction / blocked 标志。一个 Handle 对应一次模型调用,不跨圈复用。

4.3 事件 → part 的映射

handleEvent(processor.ts:395)是一个大 switch。核心映射:

事件落成什么关键动作
text-start / -delta / -end一个 text partdelta 走 updatePartDelta 增量写,text-end 时过一遍 experimental.text.complete 插件(processor.ts:841)
reasoning-start / -delta / -end一个 reasoning part孤儿 delta(没有 start)直接丢弃(processor.ts:422)
tool-input-start一个 pending 状态的 tool partensureToolCall(processor.ts:321)建 part + 建 Deferred
tool-call同一个 part 转 running参数落库;触发 doom-loop 检查
tool-result同一个 part 转 completed图片附件先过 image.normalize 压尺寸,压不下去就在输出里注明"已省略 N 张图"(processor.ts:561-581)
tool-error同一个 part 转 error若错因是权限/问询被拒,置 ctx.blocked
step-start一个 step-start part顺手抓一次影子 git 快照
step-finish一个 step-finish part结算 token/cost、写 patch part、判断是否溢出

三段式的价值: 同一个 callID 对应同一个 part,状态从 pending → running → completed/error 就地迁移。UI 拿到 pending 就能先画出"正在调用 read 工具"的框,参数流完再填内容。

4.4 doom-loop 检测:模型卡碟了怎么办

模型偶尔会反复调用同一个工具、同样的参数,无限重复。检测很朴素(processor.ts:530-555):

// 示意,非源码 —— doom loop 判据
const recent = parts.slice(-3) // DOOM_LOOP_THRESHOLD = 3
const stuck = recent.length === 3 && recent.every(p =>
p.type === "tool" && p.tool === name &&
JSON.stringify(p.state.input) === JSON.stringify(input) // 参数逐字相同
)
if (stuck) askPermission("doom_loop") // 不是报错,是问用户

阈值常量 DOOM_LOOP_THRESHOLD = 3 在 processor.ts:38。注意它的处置方式:不是直接失败,而是走权限系统问用户(processor.ts:547-554),用户可以选"总是允许"继续跑。

4.5 process:流水线的五层管道

// 真实源码 packages/opencode/src/session/processor.ts:1003-1007
yield* stream.pipe(
Stream.tap((event) => handleEvent(event)),
Stream.takeUntil(() => ctx.needsCompaction),
Stream.runDrain,
)

外面还包了四层(processor.ts:1008-1058),从内到外:

llm.stream(...) ─── 事件流

├─ Stream.tap(handleEvent) 每个事件落库
├─ Stream.takeUntil(needsCompaction) 发现溢出立刻停流

┌──┴─────────────────────────────────────┐
│ Effect.onInterrupt → aborted=true │ 被打断
│ Effect.retry(SessionRetry.policy) │ 可重试错误退避重来
│ Effect.catch(halt) │ 不可重试 → 记错误
│ Effect.ensuring(cleanup()) │ 无论如何都收尾
└─────────────────────────────────────────┘

cleanup 是最重要的一层(processor.ts:875-938),它保证"流断在哪里,数据库都是自洽的":

  1. 补一个 patch part(如果有快照差异)
  2. 把没闭合的 text / reasoning part 补上 time.end
  3. 等所有活跃 tool call 的 Deferred每个最多等 250ms(processor.ts:909)
  4. 还没结束的 tool part 一律标成 { status: "error", error: "Tool execution aborted", metadata.interrupted: true }

第 4 步写下的 interrupted: true,正是 §3.3 里 isOrphanedInterruptedTool 要识别的那个标记——这里埋,那里挖

4.6 三个返回值怎么定

// 真实源码 packages/opencode/src/session/processor.ts:1060-1062
if (ctx.needsCompaction) return "compact"
if (ctx.blocked || ctx.assistantMessage.error) return "stop"
return "continue"
返回值什么时候runLoop 怎么办
compactstep-finish 判定 token 溢出(processor.ts:780-797),或 halt 收到 ContextOverflowError / 预检信号(processor.ts:940-956)排一次压缩再转一圈
stop用户拒了权限/问询/建议(ctx.blocked),或消息上有 error直接 break
continue其余情况回 ① 再转一圈

ctx.blocked 的赋值有个开关:ctx.blocked = ctx.shouldBreak,而 shouldBreak 取决于配置 experimental.continue_loop_on_deny(processor.ts:988)。默认拒绝权限就停;开了这个开关,拒绝之后循环继续跑,让模型换个法子。


5. LLM 服务层:把厂商差异挡在门外

5.1 一句话职责

LLM.Service 只做一件事:吃一个 StreamInput,吐一个 LLMEvent 流。 上游(processor)永远不知道底下是哪家厂商、走的哪条运行时。

// 真实源码 packages/opencode/src/session/llm.ts:61-63
export interface Interface {
readonly stream: (input: StreamInput) => Stream.Stream<LLMEvent, unknown>
}

StreamInput 的字段(llm.ts:41-55):system: string[] + messages: ModelMessage[] + tools + model + agent + permission + toolChoice + preflight。注意 system 和 messages 是分开的,因为不同厂商放系统提示的位置不一样。

5.2 两条运行时,一个出口

llm.stream(input)


LLMRequestPrep.prepare 统一预处理
│ (auth/headers/参数/消息变换)

┌─── 溢出预检 ───┐
│ 超阈值就抛 │ → PreflightError → processor 转 "compact"
└───────┬───────┘

experimentalNativeLlm ?
╱ ╲
不开 / 不支持 开且支持
│ │
▼ ▼
ai.streamText(...) LLMNativeRuntime.stream
│ │
▼ │
LLMAISDK.toLLMEvents │
│ │
└────────┬───────────┘

LLMEvent 流(统一)

怎么读: 从上到下,中间的菱形是运行时选择。两条路最后汇到同一种事件。选择是逐请求的——同一个会话的不同调用可以走不同的路(llm/AGENTS.md 第 36 行如此描述这个网关)。

代码上,run 返回一个带 tag 的对象,stream 再按 tag 分流:

// 真实源码 packages/opencode/src/session/llm.ts:433-443(节选)
if (result.type === "native") return result.stream
const state = LLMAISDK.adapterState()
return Stream.fromAsyncIterable(result.result.fullStream, ...).pipe(
Stream.mapEffect((event) => LLMAISDK.toLLMEvents(state, event)),
Stream.flatMap((events) => Stream.fromIterable(events)),
)

5.3 toLLMEvents:一个带状态的翻译器

packages/opencode/src/session/llm/ai-sdk.ts:80toLLMEvents 把 AI SDK 的 fullStream part 翻成 LLMEvent。它返回数组(一进可以零出或多出),并且需要一个 state(ai-sdk.ts:10 adapterState)。

state 存四样东西,全是为了补齐 AI SDK 不保证给的信息:

状态字段补什么
stepAI SDK 不给步序号,这里自己数(ai-sdk.ts:73、81)
currentTextID / currentReasoningID事件可能不带 id,就合成 text-0 / reasoning-0(ai-sdk.ts:54-62)
toolNamestool-result 事件只有 toolCallId 没有工具名,靠这张表从 tool-input-start 记的名字回填(ai-sdk.ts:180、210)

两个值得注意的处理:

  • "finish" 事件后重置 state(ai-sdk.ts:96 Object.assign(state, adapterState())),这样同一个 adapter 能复用于后续流而不泄漏计数器。
  • "error" 事件翻成 Effect.fail(ai-sdk.ts:239),于是它会被 processor 那层的 retry / halt 接住——错误不走事件通道,走 Effect 的失败通道。
  • "abort" / "source" / "file" / "raw" 等直接吞掉(ai-sdk.ts:241-247),返回空数组。

5.4 适配器的边界纪律

packages/opencode/src/session/llm/AGENTS.md 明确划了线(该文件第 3-9 行):llm.ts 独占"会话相关的事"——鉴权、配置、模型解析、插件、权限、遥测头、运行时选择;llm/ 目录下只放适配器,其中 native-request.ts 只负责把归一化输入降级成 LLMRequest不执行请求,也不许 import 会话服务。

这条纪律的可验证痕迹:llm.ts:339streamText(...) 调用只出现在 llm.ts 里,而 ai-sdk.ts 全文没有任何 @/session/* 的 import(除了 KiloRoutedModel 这个纯数据读写模块)。


6. 中断、恢复与状态机

6.1 谁保证"一个会话同时只跑一个循环"

答案是 SessionRunStatepackages/opencode/src/session/run-state.ts)。每个 sessionID 对应一个 Runner(run-state.ts:51-68),Runner 有个小状态机(effect/runner.ts:120-142):

当前状态ensureRunning(work) 的行为
Idle启动 work,返回它的结果
Running不启动新的,直接等现有那个的结果
Shell排到 shell 后面(转 ShellThenRun
ShellThenRun等已排的那个

所以 loop()幂等的:并发调十次,只跑一次,十个调用者拿到同一个结果。shell 走另一个入口 startShell,非 Idle 就直接返回 BusyError(run-state.ts:95-104)。

Runner 还挂了三个回调(run-state.ts:58-65):onBusy / onIdle 分别把 SessionStatus 置成 busy / idle,onInterrupt 在被取消时返回"最后一条 assistant 消息"作为兜底结果。

6.2 状态怎么对外广播

SessionStatus(session/status.ts)是一个 sessionID → 状态的内存表,四种状态(status.ts:9-39):

状态含义谁设的
idle空闲Runner 的 onIdleset 时顺手从表里删掉(status.ts:88-92)
busy正在跑runLoop 每圈开头(prompt.ts:1396)、process 开头(processor.ts:994)
retry重试等待中,带 attempt/message/下次时间retry policy 的 set 回调(processor.ts:1029-1053)
offline断网等待恢复(Kilo 加的)离线处理器

每次 set 都往 Bus 发 session.status 事件(status.ts:87),UI 就是订这个事件。

6.3 打断之后:三层善后

打断链路: cancel(sessionID)(prompt.ts:169-174)→ 清队列 → 中止 plan followup → state.cancel → Runner 中断 fiber。中断信号沿 Effect 树往下传,触发三层 finalizer:

Runner 中断 fiber

├─ runLoop 的 finalize (prompt.ts:1564-1572)
│ 给 assistant 消息补一个 AbortError + time.completed

├─ handle.process 的 onInterrupt (processor.ts:1009-1017)
│ aborted=true,走 halt 记 AbortError

└─ cleanup() (processor.ts:875)
闭合半截 part、把活跃工具标成 interrupted

finalize 的守卫是 if (msg.time.completed) return(prompt.ts:1565)——已经正常收尾过的就不重复写。

下次开始前的三层善后。 打断难免留下脏数据,所以 promptloop 入口都先跑两个恢复函数(prompt.ts:1313-1314、1787-1788):

函数修什么脏数据位置
recoverDanglingAssistant尾部是一条 空的 assistant(0 个 part、无 finish、无 error),删掉kilocode/session/prompt.ts:93
recoverProviderFinishError尾部 assistant 的 finish === "error" 但没有 error 对象、且带 step-finish reason=error,删掉kilocode/session/prompt.ts:113

两个函数都有同样的三重保险:只在 status === "idle" 时动手、只看最后两条消息、必须确认 tail.parentID === prev.info.id不删就会怎样: 下次请求的消息列表以一条空 assistant 结尾,Anthropic 系接口会当成 prefill 请求而拒绝。

6.4 排队打断:不切断 token 流的"软打断"

用户在模型说话中途又发一句,Kilo Code 的选择是排队而不是抢占。机制在 KiloSessionPromptQueue(kilocode/session/prompt-queue.ts):

时间 ──────────────────────────────────────────────▶

turn A ├─ step1 ─┤├─ step2 ─┤ ✗ 在这里 break
▲ │
用户插话 P2 ──────────┘ │ (P2 入队,latest=2)

turn B ├─ step1 ─┤ ...

三个模块协作:

函数干什么位置
enqueue用 Promise 链把同会话的 prompt 串成队列;启动时快照 activeSince = latestprompt-queue.ts:121
hasFollowuplatest > activeSince 即"我开始跑之后又来了新的"prompt-queue.ts:78
scope把排在当前 target 之后的用户消息从 msgs 里藏掉prompt-queue.ts:84

runLoop 只在一步跑完之后查一次 hasFollowup(prompt.ts:1749),所以当前这次 handle.process 的 token 和内联工具调用已经完整流完,什么都不会被切断。

scope 里还有一段值得单独看的注释(prompt-queue.ts:99-107):中途排队的用户消息,其 time_created 落在上一轮消息的中间(上一轮后续的 assistant step 是在排队事件之后才写的)。纯按时间排序会让排队的用户消息插在上一轮最终回复的前面,导致请求以 assistant 结尾——又是那个 prefill 校验。所以 scope 把 target 用户消息及其 assistant 强行搬到末尾。

6.5 一轮的开闭事件

loopensureRunning 外面包了一对事件(prompt.ts:1789-1806):进来发 TurnOpen,退出发 TurnCloseTurnClose 带一个 reason

// 真实源码 packages/opencode/src/kilocode/session/prompt.ts:318-330(简化)
const explicit = closeReasons.get(sessionID); closeReasons.delete(sessionID)
if (explicit) return explicit // runLoop 主动标的
if (Exit.isFailure(exit))
return Cause.hasInterruptsOnly(exit.cause) ? "interrupted" : "error"
return "completed"

closeReasonsrunLoop 外层的一个 Map(prompt.ts:1377),由循环体内部在几个点写入(如 §3.6 表格的第 3、5、6 行)。这是显式意图优先于 Exit 推断——比如"因为有新消息排队所以退出",Exit 看起来是成功,但语义上是 interrupted


7. 子任务:subagent 就是挂了 parentID 的子会话

7.1 核心结论先说

Kilo Code 的 subagent 不是什么特殊机制,就是"再开一个 session,把 parentID 指向当前 session,然后对它调一次 prompt"。

同一个 runLoop 在子会话里再跑一遍,只是 agent、model、工具集不同。

7.2 两层结构

父会话 runLoop

│ tasks 里发现 subtask part

handleSubtask prompt.ts:334
│ ① 造一条 assistant 消息(包装用)
│ ② 造一个 tool part,tool = "task",状态 running
│ ③ 调 taskTool.execute(...)

TaskTool tool/task.ts:103
│ ④ sessions.create({ parentID: ctx.sessionID, ... })
│ ⑤ ops.prompt({ sessionID: 子会话, agent, parts })

子会话的 runLoop(完整的一整轮)


最后一条 text part 作为返回值 ────▶ 写回父会话那个 tool part 的 output

第 ⑤ 步的 ops 就是 TaskPromptOps(tool/task.ts:23-27)——一个只有三个方法的窄接口:cancel / resolvePromptParts / promptSessionPrompt 自己构造它(prompt.ts:161-167)再通过 ctx.extra.promptOps 传进工具。

为什么绕这一圈: 工具层不能直接 import SessionPrompt(会循环依赖),所以反过来由 prompt 层注入一个最小能力集。

7.3 子会话继承什么

sessions.create 时算好权限(tool/task.ts:183-204):父会话权限 + 父 agent 规则 + subagent 自身规则,三者 merge。此外:

  • 调用时再关一批工具(tool/task.ts:257-262):question 永远关掉(子 agent 不能直接问用户);todowrite / task 看权限决定——KiloTask.nestedTask() 控制"子 agent 能不能再开子 agent"(tool/task.ts:144)。
  • 沙箱策略继承 SandboxPolicy.inherit(tool/task.ts:208)。
  • 成本回流:子会话花的钱通过 KiloCostPropagation.childCost 累加到父会话那条包装 assistant 消息上(prompt.ts:492-495),中途取消也算(prompt.ts:453-458)。

7.4 命令触发的子任务会多一步

如果 subtask 来自斜杠命令(task.command 有值),handleSubtask 结束后额外插一条合成用户消息(prompt.ts:532-549):

"Summarize the task tool output above and continue with your task."

这条消息 synthetic: true,UI 不显示,但它让父会话的循环有理由再转一圈去消化子任务的产出。


8. 重试与错误

8.1 什么错值得重试

retryable(session/retry.ts:68-116)是一张判定表:

错误重试?理由(源码注释)
ContextOverflowError重试没用,要压缩
Kilo 自家错误(isKiloError需要用户操作(登录/注册)
响应体含 FreeUsageLimitError重试同一个受限模型是徒劳,而且退避循环握着旧模型引用,用户在选择器里换模型也逃不出去
5xx(即使 SDK 没标 retryable)瞬时服务端故障
消息里有 rate limit / too many requests / exhausted / unavailable文本模式匹配兜底

8.2 等多久

delay(attempt, error)(retry.ts:34-65)的优先级:

  1. retry-after-ms 响应头 → 直接用
  2. retry-after 响应头 → 秒数或 HTTP 日期,换算成毫秒
  3. 有响应头但没有 retry-after → 指数退避 2000 × 2^(attempt-1)不封顶(只受 32 位 setTimeout 上限约束)
  4. 完全没有响应头 → 同样指数退避,但封顶 30 秒RETRY_MAX_DELAY_NO_HEADERS

3 和 4 的区别很微妙:厂商给了响应头说明它在正经沟通,愿意等久一点;什么都没有的裸错误就别死等。

8.3 断网是特殊情况

policy 里 Kilo 插了一个 offline 钩子(retry.ts:150-162):SessionNetwork.disconnected(error) 判定为断网时,不走退避,而是交给离线处理器,处理器返回 "retry" 才继续(并把 attempt 重置为 0、状态改成 "Reconnected"),返回 "blocked" / "aborted" 就终止。这对应 §6.2 里那个 offline 状态。

8.4 错误类型

session/message-error.ts 只定义三个共享错误:ProviderAuthErrorMessageOutputLengthErrorNamedError.Unknown。真正丰富的错误类型(APIErrorContextOverflowErrorStructuredOutputError)在 MessageV2 里,MessageV2.fromError 负责把任意 throw 值归一成可序列化的错误对象(processor.ts:162-166 的 parse)。


9. 巧妙之处(可借鉴的)

① 每圈重读存储,不缓存消息数组。 循环体完全无状态化,工具执行、子会话、压缩、用户插话写进数据库的任何东西下一圈自动生效,不需要任何"通知循环刷新"的机制。代价是每圈一次全量读,收益是消除了整整一类同步 bug。依据:prompt.ts:1492。

② 退出判定用双证据,不信单一字段。 finish 字段和 hasToolCalls 互为交叉验证,专治厂商乱给 stop。依据:prompt.ts:1426-1432。

③ 把"打断残留"标记出来而不是删掉。 cleanup 给中断的工具打 metadata.interrupted = true(processor.ts:925),退出判定专门认这个标记跳过它们(prompt.ts:104-108)。历史记录保持完整可审计,同时不会被误当成待办。

④ 软打断:排队而不抢占。 新消息不切断正在流的 token,只在两步之间的缝隙让位。用户体验上"回复不会被砍半截",工程上避免了半截消息的清理问题。依据:prompt.ts:1745-1752 + prompt-queue.ts:78。

⑤ 每轮独立的压缩熔断计数。 compactionAttemptsrunLoop 入口清零而不是全会话累计(prompt.ts:1388),既能拦住"压缩→还是溢出→再压缩"的死循环,又不会让一个长会话因为历史上压过几次就永久丧失压缩能力。上限 3 的理由写在注释里(kilocode/session/prompt.ts:333-337)。

⑥ 兜底 finish = "unknown" 不给终止原因的厂商会让循环永动,写死一个 "unknown" 落库就修好了——因为退出判定的排除项只有 "tool-calls""unknown" 天然可退出。三行代码,二十行注释。依据:prompt.ts:1754-1767。

⑦ 适配器 state 补齐厂商缺的元数据。 toolNames 表把 tool-input-start 里的工具名记下来,供后面只带 toolCallIdtool-result 回填。依据:ai-sdk.ts:166、210。


10. 边界与局限

  • 循环本身不管上下文裁剪算法。 它只负责"检测到溢出 → 排一次压缩 → 再转一圈",压缩怎么做在 session/compaction.ts(见 05)。
  • 步数默认无上限。 agent.steps ?? Infinity(prompt.ts:1540)。没配 steps 的 agent 理论上可以一直转,实际靠 token 溢出和 doom-loop 检测收敛。
  • doom-loop 判据是逐字比较。 参数差一个空格就绕过检测(processor.ts:540 的 JSON.stringify 比较)。它挡的是完全卡死,不是语义重复。
  • cleanup 等工具只等 250ms。 超时就强行标成 aborted(processor.ts:909)。慢工具在打断时会留下"其实还在跑但已被标记失败"的窗口。
  • closeReasons 是模块级 Map,不是会话状态。 跨进程/多实例场景下不共享(prompt.ts:1377)。
  • prompt.ts 单文件 2130 行。 循环、入口、子任务、shell、命令模板全在一个 Layer.effect 闭包里,靠 Effect.fn 命名切分。可读性靠命名撑着,没有物理隔离。
  • 原生运行时是实验特性。 默认走 AI SDK;KILO_EXPERIMENTAL_NATIVE_LLM=true 才启用,且只覆盖 OpenAI / OpenAI 兼容 / Anthropic API-key 三条路,其余自动回落(llm/AGENTS.md 第 87-90 行)。

11. 代码地图

主题文件路径符号名
四个入口的契约packages/opencode/src/session/prompt.tsInterface
主循环packages/opencode/src/session/prompt.tsrunLoop
循环外壳(turn 事件 + 幂等)packages/opencode/src/session/prompt.tsloop
用户消息入口packages/opencode/src/session/prompt.tsprompt, createUserMessage
@file 引用展开packages/opencode/src/session/prompt.tsresolvePromptParts
斜杠命令展开packages/opencode/src/session/prompt.tscommand, bashRegex, placeholderRegex, argsRegex
手动 shell 记录packages/opencode/src/session/prompt.tsshell, shellImpl
子任务派发packages/opencode/src/session/prompt.tshandleSubtask
中断遗留工具识别packages/opencode/src/session/prompt.tsisOrphanedInterruptedTool
输入 schemapackages/opencode/src/session/prompt.tsPromptInput, LoopInput, ShellInput, CommandInput
流处理器packages/opencode/src/session/processor.tsHandle, ProcessorContext, create, process
事件→part 转换packages/opencode/src/session/processor.tshandleEvent, ensureToolCall
工具 part 生命周期packages/opencode/src/session/processor.tsupdateToolCall, completeToolCall, failToolCall, settleToolCall
收尾与错误packages/opencode/src/session/processor.tscleanup, halt, Result, DOOM_LOOP_THRESHOLD
LLM 服务契约packages/opencode/src/session/llm.tsStreamInput, Interface, run, stream
AI SDK 事件归一packages/opencode/src/session/llm/ai-sdk.tstoLLMEvents, adapterState
适配器边界说明packages/opencode/src/session/llm/AGENTS.md
会话运行状态机packages/opencode/src/session/run-state.tsensureRunning, startShell, cancel
Runner 状态迁移packages/opencode/src/effect/runner.tsmake, ensureRunning, finishRun
状态广播packages/opencode/src/session/status.tsInfo, set
重试策略packages/opencode/src/session/retry.tspolicy, retryable, delay
共享错误类型packages/opencode/src/session/message-error.tsAuthError, OutputLengthError
排队打断packages/opencode/src/kilocode/session/prompt-queue.tsenqueue, hasFollowup, scope
中断残局恢复packages/opencode/src/kilocode/session/prompt.tsrecoverDanglingAssistant, recoverProviderFinishError
压缩熔断 / 关闭原因packages/opencode/src/kilocode/session/prompt.tsguardCompactionAttempt, MAX_COMPACTION_ATTEMPTS, resolveCloseReason
消息时序packages/opencode/src/kilocode/session/message-order.tslatest, compare, annotate
压缩投影packages/opencode/src/session/message-v2.tsfilterCompacted, filterCompactedEffect
子会话工具packages/opencode/src/tool/task.tsTaskTool, TaskPromptOps
HTTP 路由packages/opencode/src/server/routes/instance/httpapi/handlers/session.tsprompt, command, shell, abort