跳到主要内容

数据截至 (上游 commit 7538cc96774b)

Agent Loop:一个 turn 从出生到收尾

30 秒导读: 你在 Kun 的输入框敲一句话、按回车,到界面上停止转圈,中间发生的一切都在本章。主角是 AgentLoop.runTurn——它是一个默认没有步数上限的循环,靠"模型这一轮有没有叫工具"决定是否再转一圈。真正难的不是循环本身,是怎么在循环转着的时候插话、踩刹车、挂起等人回答,以及模型开始原地打转时怎么把它拽回来

本章只讲控制流主线。请求里到底塞了什么(缓存与上下文工程)见 03-cache-first-context.md;单个工具怎么执行、怎么鉴权见 04-tools-and-gates.md;HTTP 出口和订阅引擎那条岔路见 05-model-layer.md


1. 先把三个名词说死

读这一章要先认三个词。它们在 Kun 里是承重结构,不是随口的比喻。

名词一句话契约定义在
thread(会话)一次对话的容器,持有 workspace、model、审批策略、goal、todoskun/src/contracts/threads.ts
turn(回合)用户一次发言引发的一整段工作,可能包含几十次模型请求和工具调用kun/src/contracts/turns.ts:167 TurnSchema
item(条目)turn 里的最小可持久化单位:用户消息、助手文本、思考、工具调用、工具结果、审批、提问、压缩、错误kun/src/contracts/items.ts TurnItem

一句话直觉: thread 是一本书,turn 是一章,item 是一行。循环只对 item 追加,从不回头改写已完成的 item——这是后面所有"能中断、能重放、能续跑"的地基。

还有一个词:event(事件)。item 是状态,event 是状态变化的广播。所有 event 通过 RuntimeEventRecorder.record 出口,先落盘再发布(kun/src/services/runtime-event-recorder.ts:45,两行顺序见 :109-116)。这个"持久化先于发布"的顺序是刻意的:SSE 路由先重放已落盘的日志再接实时流,反过来做会让某条事件掉进"读完 backlog、还没订阅上"的缝里,永久丢失(细节见 01-runtime-boundary.md §3.4)。


2. 顶层全景:一个 turn 的骨架

先看主线。这张图从上到下是时间顺序,右侧是提前退出的岔路。

runTurn(threadId, turnId) kun/src/loop/agent-loop-turn-lifecycle.ts:29

├─① 取 AbortSignal ───────────► 没有 → failed;已 abort → aborted

├─② 订阅引擎岔路 ─────────────► SDK 接管该 provider → 整个 turn 交出去(见 05)

├─③ setup + TurnStart 钩子 ───► UserPromptSubmit 钩子拒绝 → failed

├─④ drainSteering ────────────► 把排队的插话写成 user item

├─⑤ loop() ───────────────────► 主循环,本章 §3

├─⑥ TurnFinalizer.settle ────► 落状态 + 发 turn_completed/failed/aborted

└─ finally ───────────────────► goal 计时/续跑 / 按 turn 清理 / TurnEnd 钩子

参与部件与各自的活:

部件干什么文件
AgentLoop(继承链顶端)控制流本身:循环、分发工具、纠偏、收尾kun/src/loop/agent-loop.ts:26
TurnServiceturn 生命周期的唯一写入口:start / finish / interrupt / steer / rewindkun/src/services/turn-service-core.ts:218
SteeringQueueturn 进行中的插话缓冲区kun/src/loop/steering-queue.ts:20
InflightTracker在跑的模型/工具工作的登记簿,保证异常也清账kun/src/loop/inflight-tracker.ts
ApprovalGate / UserInputGate两个"挂起—被 HTTP 唤醒"的闸门kun/src/ports/approval-gate.tskun/src/ports/user-input-gate.ts
ToolStormBreakerturn 内的交互式提问熔断器(防反复骚扰用户)kun/src/loop/tool-storm-breaker.ts:20
GoalTurnCoordinator / GoalResumeCoordinatorgoal 的进度记账与跨 turn 自动续跑退避kun/src/loop/goal-turn-coordinator.tskun/src/loop/goal-resume-coordinator.ts:70
RuntimeEventRecorder事件出口:编号、校验、先落盘后广播kun/src/services/runtime-event-recorder.ts:45

谁来叫 runTurn? HTTP 的 POST /v1/threads/:id/turns 先调 TurnService.startTurn 建 turn 记录、立刻返回 202,再不等待地触发 runtime.runTurn(kun/src/server/routes/register-thread-routes.ts:160-171)。也就是说,turn 的执行天生是异步后台任务,客户端靠 SSE 追进度。


3. 主循环:loopmodelStep 到底在循环什么

3.1 循环本体的形状

loop(kun/src/loop/agent-loop-execution.ts:20)抽出 Graph 编排的分支后,核心骨架还是那个形状:

loop() ← 每转一圈叫一个 step

├─ signal.aborted? ──yes──► 'aborted'

├─ 超步数 / 超墙钟? ──yes──► 记 turn_step_limit / turn_wall_time_limit → 'failed'

├─ drainSteering() ← 插话在这里进入历史

└─ modelStep(step) ─┬─ 'continue' ─► 回到圈首,step + 1
├─ 'stop' ─► 'completed'
├─ 'failed' ─► 'failed'
└─ 'aborted' ─► 'aborted'

写成教学代码就是这个形状:

// 示意,非源码:主循环只有一个形状
async function loop(signal) {
for (let step = 0; ; step += 1) { // 默认没有步数上限,但墙钟 24h 封顶
if (signal.aborted) return 'aborted'
await drainSteering() // 先吃掉排队的插话
const r = await modelStep(step) // 发一次请求 + 跑完这一批工具
if (r !== 'continue') return r // stop / failed / aborted 都是终点
}
}

重点看:唯一让循环继续的理由是 'continue',而 'continue' 基本只有一个来源——这一轮模型叫了工具、工具跑完了(kun/src/loop/round-outcome-coordinator.ts:140-143:249)。模型不叫工具,turn 就该结束了;§7 讲的那些纠偏,本质都是在"不叫工具"这个分支上抢救。

3.2 步数上限是可选配置,墙钟才是硬底线

for (let step = 0; ; step += 1) 本身没有写死步数上限,但循环每圈都会查 normalizeTurnLimits 归一出来的上限(kun/src/loop/agent-loop-execution.ts:77-117):

刹车默认值代码
步数上限 maxSteps不设(可在 config 的 runtime.turnLimits 里配)agent-loop-execution.ts:77-97,归一化在 kun/src/loop/turn-limits.ts:14
墙钟 maxWallTimeMs24 小时turn-limits.ts:21agent-loop-execution.ts:99-117
每步工具调用数 maxToolCallsPerStep10,000turn-limits.ts:22
模型不叫工具最常见的正常结束round-outcome-coordinator.ts:110
成本预算耗尽预算闸门拒绝或分发返回 budget_exhaustedkun/src/loop/turn-budget-gate.ts:96round-outcome-coordinator.ts:144
交互式提问熔断user_input 类问满 3 次被压制(§7.5)kun/src/loop/tool-storm-breaker.ts:20
AbortSignal用户点停 / 进程关停agent-loop-execution.ts:44-47 等多处

早期版本还有一条:工具目录发生破坏性漂移就直接停本轮。0.3.0 起改为「turn 内冻结目录、变更延迟到下一 turn」,这条刹车已经删除(判定规则见 03-cache-first-context.md §4.2,目录怎么被算出来见 04-tools-and-gates.md)。

3.3 modelStep 的一次心跳

modelStep(kun/src/loop/agent-loop-base.ts:349)现在只是个薄壳,转手交给 ModelStepService.run;但它内部的五段结构没变:

modelStep(step)

├─ 装配阶段 取 thread/turn → 预算闸门 → 载入并"治愈"历史 → 选模型
│ → 冻结工具目录 → 收窄(Plan 模式)→ 压缩上下文 → 拼 instructions
│ (kun/src/loop/model-step-preparation-service.ts)
├─ 发送阶段 组 ModelRequest → 埋点 pre_send / post_send

├─ 流式阶段 for await (chunk of model.stream) —— 文本/思考/工具调用/用量/错误
│ (kun/src/loop/model-round-engine.ts)
├─ 落盘阶段 persistAccumulatedResponse():把攒下的文本和思考写成 item
│ (model-round-engine.ts:132)
└─ 判定阶段 有工具调用 → dispatchToolCalls → 'continue'
没有工具调用 → 走 §7 的纠偏决策树 → 'stop' / 'continue' / 'failed'
(kun/src/loop/round-outcome-coordinator.ts)

一个容易忽略的细节:只在 stepIndex === 0 做一次历史治愈(kun/src/loop/model-step-preparation-service.ts:151-173,healLoadedHistoryItems)。理由写在注释里——turn 之内循环只会追加格式良好的 item,而治愈的深度比对每次要做两遍全量 stringify,太贵。这是"贵操作只在 turn 边界做一次"的典型。

另一个:流式阶段每收到一个 delta 就 events.record 一次,但item 只在流结束时落一次盘(persistAccumulatedResponse,kun/src/loop/model-round-engine.ts:133)。事件流是"给眼睛看的实时字幕",item 是"给模型看的最终历史",两者刻意不同频。中途 abort 时也会先落盘再返回 'aborted'(model-round-engine.ts:242:266),所以被打断的半截回答不会凭空消失。

3.4 埋点:11 个 pipeline stage

recordPipelineStage(kun/src/loop/agent-loop-base.ts:442)把一个 turn 的进度切成 11 个可观测的点,GUI 用它画流水线。

阶段打在哪含义
setup / pre_start / post_startagent-loop-turn-lifecycle.ts:159:163:196turn 级:跑启动钩子、建熔断器、吃完插话
input_receivedmodel-step-preparation-service.ts:109step 开始,带 stepIndex
input_cachedmodel-step-preparation-service.ts:189-194前缀易变内容检测结果
input_routedmodel-step-preparation-service.ts:251-254选定 model / reasoningEffort
input_compressedmodel-step-service.ts:442压缩后的历史条数
input_rememberedmodel-step-preparation-service.ts:635注入的记忆条数、指令条数
pre_send / post_sendmodel-round-engine.ts:218:231请求出门前后
response_receivedmodel-round-engine.ts:448stopReason 和工具调用数

标签表在 kun/src/loop/agent-loop-base.ts:42 PIPELINE_STAGE_LABELS,枚举在 kun/src/contracts/events.ts:93 PipelineStage(恰好 11 个值)。


4. 工具分发:dispatchToolCalls 的三层包裹

模型一轮可能吐出多个工具调用。dispatchToolCalls(kun/src/loop/agent-loop-base.ts:359)负责把这一串调用变成一串结果,批处理与排序的细节在 ToolCallDispatcher.dispatch(kun/src/loop/tool-call-dispatcher.ts:43)。

4.1 顺序执行是默认,并发是例外

calls[] ──► 逐个取 call

├─ 熔断器命中? ─yes─► 写一条 isError 结果,跳过(不计入 progress)

├─ 可并发? ─no─► 单个执行 → 落盘 → 取下一个

└─ yes ─► 攒一个"同类批"
├ 批上限:内置只读工具 ≤ 3;delegate_task 整批一起扇出
└ Promise.allSettled → 按原顺序逐个落盘

判定"可不可以并发"的是 classifyToolDispatchLane(kun/src/loop/tool-dispatch-policy.ts:29),条件相当保守:

条件为什么
审批策略不是 always / untrusted / never这三种会触发审批弹窗或阻断,扇出会让弹窗乱序
或者:调用是 delegate_task 且来自 delegation provider子 agent 是隔离运行,天然独立;真并发度由委派运行时的信号量兜
否则:工具名在 read/grep/glob/find/lsPARALLEL_READ_ONLY_TOOL_NAMES,tool-dispatch-policy.ts:4
toolKindtool_call(不是文件改动/命令执行)排除任何有副作用的
且 provider 是 built-in排除 MCP 等外部实现

批还要求同质:一批要么全是委派,要么全是内置只读,中间遇到不同类的就断批(collectParallelToolDispatchCandidates,tool-dispatch-policy.ts:70-92)。批上限也随之分叉:委派批取本轮全部调用数,只读批取 DEFAULT_MAX_PARALLEL_READ_ONLY_TOOL_CALLS = 3(:9:79-84)。

4.2 三层错误包裹,各拦各的

单个工具执行套了三层,越往外拦得越宽:

执行一个 call

├─ ToolExecutionService.executeSafely(...) ← 登记(可单独取消),catch 兜底
│ │
│ └─ execute(...) → toolHost.execute(...) ← 真正干活(见 04)
│ └ catch: "可恢复的分发错误" ← 工具不存在/未广告/被策略禁
│ → 变成 isError 结果 + 一句 guidance,模型可以据此改口

└─ executeSafely 外层 catch ← 工具处理器崩了
→ 变成 isError 结果(`tool_execution_failed`),turn 不死;只有 abort 允许继续往上抛

对应代码:executeSafely(kun/src/loop/tool-execution-service.ts:68)、可恢复错误判定 isRecoverableToolDispatchError(:411,靠错误消息前缀匹配)、批内上抛在 kun/src/loop/tool-call-dispatcher.ts:121

妙在哪: 分发层错误不是"报错给用户",而是写成一条模型能读到的工具结果,还附一段 guidance。Plan 模式下这段 guidance 尤其具体——"继续用只读工具,去调 create_plan,把完整方案放进 markdown 参数,别把这条消息抄进计划里"(tool-execution-service.ts:287-292)。这是把纠错做进了数据流,而不是做进异常流。

4.3 落盘与后置钩子

persistResult(kun/src/loop/tool-execution-service.ts:150)做两件事:把先前那条 tool_call item 的状态改成 completed/failed,再追加 tool_result item。然后 afterResultPersisted(:375)只为一件事存在——create_plan 成功后,把计划里的 Markdown 复选框同步成 thread todos(通过 onPlanWritten 回调,:391;GUI 侧的对接见 06-gui-product-layer.md §4.2)。同步失败只记一条 warning,不影响 turn。

被熔断器压制的调用走 persistSuppressed(kun/src/loop/tool-execution-service.ts:195):同样写一条 isError 结果 + 发 tool_storm_suppressed 事件,模型看到的是"这次提问被拦了,基于最新回答继续或改走普通文本"。


5. 打断路径:插话、踩刹车、崩溃后收尸

一个 turn 在跑的时候,外面可以对它做三件事。三件事走三条完全不同的路。

5.1 插话(steer):不打断,排队进历史

用户在 turn 跑着的时候又敲了一句话。POST /v1/threads/:id/turns/:turnId/steerTurnService.steerTurn(kun/src/services/turn-service-steering-operations.ts:62)→ 核心就是塞进 SteeringQueue、发一条 turn_steered 事件。它不碰循环。

循环自己在两个安全点取货:turn 开始前(kun/src/loop/agent-loop-turn-lifecycle.ts:195)和每圈开头(kun/src/loop/agent-loop-execution.ts:118)。drainSteering(kun/src/loop/agent-loop-base.ts:311)把每条插话变成一个正常的 user_message item 追加进历史——所以模型下一次请求就自然看见了它,不需要任何特殊通道。

队列本身按 turn 有界并防串扰:每 turn 最多 32 条 / 64KB(DEFAULT_MAX_STEERING_ENTRIES_PER_TURN/DEFAULT_MAX_STEERING_BYTES_PER_TURN,kun/src/loop/steering-queue.ts:17-18),turn 结算后封存(sealed),上一个 turn 没来得及消费的插话不会漏进新 turn。

// 示意,非源码:插话为什么不需要"打断"
// 写入侧(HTTP 线程)
steeringQueue.enqueue(turnId, { text: '等一下,先跑测试' })
// 读取侧(循环里,安全点)
for (const entry of steeringQueue.drain()) {
await turns.applyItem(threadId, makeUserItem({ ...entry, turnId }))
}
// 重点看:插话最终变成一条普通 user 消息,模型无需知道它是"插"进来的

5.2 中止(interrupt):AbortSignal 一路传

AbortController 由 turn 准入流程创建并存在 inflightTurns 里(kun/src/services/turn-service-core.ts:260)。runTurn 开头通过 getAbortController(turnId) 拿到 signal(kun/src/loop/agent-loop-turn-lifecycle.ts:75)——拿不到就直接 failed,这是"turn 已被别处收尾"的兜底。

interruptTurn(kun/src/services/turn-service-steering-operations.ts:252)按顺序做五件事:

  1. 先在 thread 写入锁内把 turn 状态改成 aborted(Graph turn 还要先取消它的草稿和运行,:269-281)
  2. clearRuntimeTurnState —— abort 信号发出去、清运行态
  3. turn_aborted 事件
  4. discard 决定是丢弃该 turn 生成的 item(只留用户消息)还是封存(把未完成的 item 收成 aborted)
  5. 持久化失败时照样 abort——重启对账稍后再收 durable 记录(:300-304 的注释)

signal 传到哪些地方?

落点行为
循环圈首kun/src/loop/agent-loop-execution.ts:44-47 直接返回 'aborted'
模型流中途kun/src/loop/model-round-engine.ts:242 先落盘已收到的文本,再返回
工具分发循环kun/src/loop/tool-call-dispatcher.ts:49 每个 call 前检查
工具上下文abortSignal 直接放进 ToolHostContext(kun/src/loop/tool-context-factory.ts),工具自己负责响应;前台调用还包了一层"可单独取消"的注册(kun/src/loop/tool-execution-service.ts:68-75)
等用户回答时kun/src/loop/interactive-tool-bridge.ts:116 注册 abort 监听,先把 gate 解成 cancelled 再 reject

最后一条最值得看:如果不主动 resolve(cancelled),gate 里那个 Promise 会永远挂着,工具永远不返回。

5.3 崩溃后收尸与回溯

进程重启会留下一批状态还是 running/queued 的孤儿 turn。reconcileOrphanedTurns(kun/src/services/turn-service-runtime-state-operations.ts:104)在服务开始监听之后后台扫一遍,把它们标成 failed,返回受影响的 threadId 列表(kun/src/server/runtime-server-start.ts:117kun/src/server/runtime-restart-reconciliation.ts:38)。

两个细节:

  • 扫描带 includeSide: true。委派子 agent 跑在隐藏的 side thread 上,不带这个参数就永远扫不到,父 thread 的 delegate_task item 会永久卡住(issue #621,注释在 turn-service-runtime-state-operations.ts:105-109)。
  • 已在本进程 inflight 的 turn 会跳过,所以这个扫描可以安全地在后台跑,不会误杀正在跑的 turn。

rewindThread(kun/src/services/turn-service-admission-operations.ts:487)是另一回事:把 thread 截断到某个 turn 之前,同时重写 session 里的 item 列表。它硬性要求 thread 不在 running——不允许一边跑一边回溯。


6. 暂停点:模型回合被挂起、由 HTTP 回答唤醒

这是 GUI agent 和纯 CLI agent 最不一样的地方:turn 的执行栈可以停在半空中,等一个 HTTP 请求把它推醒。

Kun 有两个这样的闸门,形状完全一样:

闸门谁调被谁唤醒端口定义
ApprovalGate工具执行前的审批(见 04)POST /v1/approvals/:idkun/src/ports/approval-gate.ts:9
UserInputGate模型主动用 user_input 工具提问POST /v1/user-inputs/:idkun/src/ports/user-input-gate.ts:32

以提问为例,时序是这样:

turn 的执行栈 HTTP 侧

│ 工具调 context.awaitUserInput(...)
├─► 写一条 user_input item(status=pending)
├─► 发 user_input_requested 事件 ──────────► GUI 弹出选项卡片

│ await gate.request(id) …… 挂起 ……
│ POST /v1/user-inputs/:id
│◄──── gate.resolve(id, 答案) ◄──────────────┘

├─► item 改成 submitted / cancelled
└─► 发 user_input_resolved 事件,工具拿到答案继续跑

代码分两层:InteractiveToolBridge.awaitUserInput(kun/src/loop/interactive-tool-bridge.ts:170)负责 item 与事件的进出场 + 挂起等待,abort 竞态也在同文件处理(:116)。

挂起的实现朴素得可爱——InMemoryUserInputGate.request 就是把 resolve 函数存进一个 Map,然后返回一个永不自行落地的 Promise(kun/src/adapters/in-memory-user-input-gate.ts:23-28)。HTTP 路由 resolveUserInput 通过 claimResolution 领走它再调掉(kun/src/server/routes/user-inputs.ts:17-51)。

三个易漏的细节:

  • 重复回答会 409。 回答要先 claimResolution 领号,领不到(pending 不存在或已被领)就翻成 conflict(user-inputs.ts:49-51);持久化事件失败还会释放领号回滚。
  • turn 收尾时会强制结算。 finalizeOpenItems/finalizePersistedOpenItems(kun/src/services/turn-service-core.ts:253-255)把还 pending 的 approval 收成 expireduser_input 收成 cancelled,其它 item 收成 turn 的最终状态。所以不会有"turn 已结束但界面还在等你点"的僵尸卡片。
  • 无人值守的 turn 根本不给这两个工具。 IM 桥接和 headless 运行会带 disableUserInput(kun/src/loop/turn-context-resolver.ts:171),此时执行上下文里干脆不挂 awaitUserInput 字段,工具靠"上下文里有没有这个能力"决定要不要把自己广告出去;同时注入一句 userInputUnavailableInstruction()(kun/src/loop/continuation-instructions.ts:377)告诉模型别许诺弹窗。这是"能力即上下文字段"的干净用法——工具侧的 shouldAdvertise 谓词见 04-tools-and-gates.md §3.4。

顺带一个反差:列工具目录时用的那份上下文里 awaitApproval: async () => 'allow' 是个桩(kun/src/loop/tool-discovery-context-factory.ts:81),真正执行时的上下文才接真闸门(kun/src/loop/tool-context-factory.ts:87)。列目录不该弹审批框——但这也意味着审批只在执行路径上生效,具体见 04-tools-and-gates.md §6.2。


7. 跑偏纠正:本项目最有料的一块

前面说了,'continue' 几乎只来自"叫了工具"。那模型没叫工具的时候,循环该怎么办?

朴素答案是"结束 turn"。但真实的长任务里,模型经常在没干完的时候就停下来:说一句"好的,我这就去改"然后什么也没做;改完文件后回一个空响应;被输出长度截断。Kun 在这个分支上堆了五道 turn 内的纠正(§7.2–§7.5,外加决策树里的截断警告),再加一道跨 turn 的续跑(§7.6)。

7.1 先看整棵决策树

这是 RoundOutcomeCoordinator.resolve(kun/src/loop/round-outcome-coordinator.ts:36)的判定顺序,从上到下,命中即停:

条件动作
1有软必调工具未满足(softRequiredToolName,如 Plan 模式的 create_plan)见 §8:物化成计划 / 判为提问 / 判为失败
2本 turn 改过文件 + 文本为空 + stopReason === 'stop'空回复恢复:最多补 2 次(§7.4)
3有活跃 goal + stopReason === 'stop'复读检测(§7.2/7.3)
4普通工具失败过 + 只剩"进度播报"式文本失败后恢复:有界,烧完判 failed(advancePostToolFailureRecovery,kun/src/loop/round-outcome-recovery-phase.ts:181)
5stopReason === 'length'output_truncated 警告 item,然后 stop
6其它正常 'stop',turn 完成

第 5 条值得单说:模型撞到输出上限被截断时,如果直接当成"干净完成",用户看到的是"它好像放弃了"。Kun 专门写一条 warning item 说明"这是被截断了,去调大 max output tokens 或让它分步"(recordOutputTruncated,kun/src/loop/round-outcome-recovery-phase.ts:330)。

7.2 目标续跑:把"未完成"写进每一轮的指令

Kun 有一个 goal(目标) 概念:thread 上挂一个长期目标,状态是 active 时,每一次模型请求都会被注入一段 goalContinuationInstruction(kun/src/loop/continuation-instructions.ts:8)。这段指令的内容值得逐条看,因为它几乎是"如何防止 agent 自我降标"的一份检查单:

  • 目标跨 turn 存在,这一轮结束不代表要把目标缩水到"现在能做完的那点"
  • 完成审计:判定达成前必须对照真实状态和每一条明确要求;证据弱、间接、缺失一律算没达成
  • 阻塞审计:同一个阻塞条件连续三个 goal turn 都在,才准报 blocked——第一次遇到阻塞不许报
  • 预算:只告诉 tokenBudget 总额;实时的 token/耗时用量刻意不注入——它每个 step 都变,会破坏缓存前缀,注释明说这笔钱由宿主侧的预算闸门来管(kun/src/loop/continuation-instructions.ts:49-52:73-75)

目标本身由 get_goal / create_goal / update_goal 三个工具读写(kun/src/adapters/tool/goal-tools.ts:5-12)。update_goal 的 schema 刻意只开放 completeblocked 两个枚举值(goal-tools.ts:111),运行时还再拦一次(:123-127)——暂停、恢复、预算限制这些状态只能由用户或系统改,模型碰不到,工具描述里也把这句写死了(:104)。同理还有 todo_list / todo_write(kun/src/adapters/tool/todo-tools.ts),todo 列表也会以 <thread_todos> 块注入每轮请求(kun/src/loop/continuation-instructions.ts:322 todoContinuationInstruction),并由 normalizeToolTodos 强制"最多一个 in_progress"(todo-tools.ts:99,多出来的降回 pending)。

有活跃 goal 时,get_goal/update_goal/todo_list/todo_write 会被强行加进允许工具名单(kun/src/loop/continuation-instructions.ts:384 allowedToolNamesWithGuiStateTools),哪怕当前 skill 收窄了工具集——否则模型就没法汇报状态了。

7.3 复读检测:为什么不能用 ===

目标续跑有个副作用:模型只要不叫工具就会被重新提问,于是可能陷入"我这就去做 X"的空转。

用相等判断抓不住它——同一句废话每次的标点、大小写、语序都略有不同。Kun 的做法是两级(kun/src/loop/continuation-instructions.ts:287 isRepeatedNoToolAssistantText):

// 示意,非源码:为什么不能用 ===
const a = normalize('好的,我这就去改。') // 转小写 + 去掉所有空白/标点/符号
const b = normalize('好的,我这就去改')
if (a === b) repeated = true // 一级:归一化后完全一致
else if (a.length >= 12 && b.length >= 12) // 太短的不判,避免误伤
repeated = dice(a, b) >= 0.85 // 二级:字符二元组 Dice 相似度

归一化用了 Unicode 属性类 [\s\p{P}\p{S}](:299 normalizeNoToolAssistantText),中英文标点一起清掉。相似度是字符二元组的 Dice 系数(:302 charBigramDiceSimilarity):把两串切成相邻字符对,统计重合数,2 × 重合 / (两串长度和 - 2)。阈值 0.85,最短长度 12(:137-138)。

抓到复读之后:

复读次数动作
第 1~3 次注入 goalNoToolRecoveryInstruction(n)(:152),返回 'continue' 再给一次机会
第 4 次写一条 goal_repetition_stop warning item,停 turn

上限常量是 GOAL_NO_TOOL_REPEAT_MAX_RECOVERY_STEPS = 3(:139)。恢复指令写得很直白:"别再重复同一句状态更新/承诺/总结了;真做完了就调 update_goal 报 complete;真被卡了就按严格审计报 blocked;否则拿出新的实质工作或调一个工具。"(:152-161)

7.4 空回复恢复

改完文件后模型回一个空响应,是另一种"停早了"。判定条件三个全中:stopReason === 'stop'、本轮文本为空、本 turn 有过 file_change 类的工具调用(kun/src/loop/round-outcome-coordinator.ts:72-84;create_plan 产出的改动被排除在外)。

恢复最多给 2 次(EMPTY_POST_TOOL_MAX_RECOVERY_STEPS = 2,kun/src/loop/continuation-instructions.ts:140-141):第 1 次注入续跑指令后 'continue';第 2 次进入 final-answer 模式——禁掉工具、要求立刻根据已有工具结果给出非空终答(emptyPostToolRecoveryInstruction,:163-179)。再空一次就判 failed,错误码 empty_post_tool_continuation(kun/src/loop/round-outcome-recovery-phase.ts:90-122)。

注意这里和复读处理的取向差异:复读到顶是 'stop'(turn 算完成),空回复到顶是 'failed'。因为"改了文件却说不清改了什么"是真的坏结果。

7.5 工具风暴熔断器

ToolStormBreaker(kun/src/loop/tool-storm-breaker.ts:30)盯的是另一种打转:同一个工具、同一份参数,反复调。

规则很小:滑动窗口 8 条,同名同参出现到第 3 次就压制(:14-15)。参数比对前先做键排序的稳定序列化(:78 stableStringify + canonicalize),所以 {a:1,b:2}{b:2,a:1} 算同一个调用。

两个精巧处:

  • 提问类工具豁免。 user_input / request_user_input 不受熔断器约束(:17 STORM_EXEMPT_TOOL_NAMES)——连问三个相似问题是合理的。
  • 写操作会清空只读记录。 一旦出现文件改动类调用,窗口里所有只读记录被清掉(:40-42:66 clearReadOnlyEntries)。因为文件变了,再读一次同一个文件就不再是重复,而是必要的复核。

熔断器是 turn 级的:runTurn 开头建、finally 里删(kun/src/loop/agent-loop-turn-lifecycle.ts:161:321)。新 turn 是新意图,不该继承上一轮的窗口。

7.6 跨 turn 续跑:GoalResumeCoordinator

前面五道都在 turn 之内。最后一道在 turn 之外:turn 都结束了,目标还是 active,怎么办?

GoalTurnCoordinator.evaluateResume(kun/src/loop/goal-turn-coordinator.ts:176,旧名 evaluateGoalResume)在 finally 里经 afterTerminal 调用(:101),决策表如下:

情形动作
没 goal,或 goal 不是 active清掉待定续跑,结束
turn 是 aborted(用户中止 / 关停)不续跑
turn 是 plan 模式不续跑
命中 goalResumeSuppressedByTurn(预算耗尽;或原地复读且全程没干活)不续跑
其余(completedfailed 但目标未了)交给协调器排一次退避续跑
协调器返回 exhausted把 goal 改成 blocked 并发一条说明

注意第四行的两半:预算耗尽再续跑只会立刻撞回同一堵墙(预算闸门拦下时顺手压制,kun/src/loop/model-step-service.ts:276);而复读停止但中途真改过文件的 turn被压制(kun/src/loop/round-outcome-recovery-phase.ts:318-320),因为"改完文件然后废话连篇"是最常见的场景,把它掐死等于把干到一半的活丢了。判定靠 turnMadeProgress 集合,它在工具分发时按工具名标记,get_goal/update_goal 不算数(kun/src/loop/goal-turn-coordinator.ts:24-27 GOAL_NON_PROGRESS_TOOL_NAMES、标记在 kun/src/loop/agent-loop-base.ts:398-400)。

协调器本身(goal-resume-coordinator.ts:70)做退避与再校验:

  • 指数退避:base × 2^(attempts-1),2 秒起、60 秒封顶(:51-52:121-124)
  • 有进展就清零 attempts,只有连续无进展才烧预算,默认 5 次(:50:115-116)
  • 定时器 unref(),不拖住进程退出(:64-66)
  • 开火时再校验一遍:目标 key 变了(目标被完成/清除/换了新目标)就丢弃这次续跑;thread 正忙就不重复启动(:167-183)

目标 key 是 threadId::createdAt::objective(kun/src/loop/goal-turn-coordinator.ts:291 goalResumeKey)——换目标就换 key,旧目标的待定续跑自然作废。

还有一条重启路径:进程重启把 turn 扫成 failed 后,resumeInterruptedGoals 只对这批受影响的 thread 尝试续跑(kun/src/loop/goal-turn-coordinator.ts:75),不会把无关 thread 上休眠的目标一并唤醒。

续跑用的提示语是常量 GOAL_RESUME_PROMPT(:17-21),并且会继承上一个 turn 的 disableUserInput(:230)——否则一个 IM 场景续跑出来的 turn 可能去等一个永远不会来的 GUI 回答,直接死锁。


8. Plan 模式:把工具面收窄成一条路

Plan 模式("先出方案再动手")在循环里是一次工具收窄 + 必调工具的组合。

8.1 收窄成一个常量过滤

resolvePlanModeToolSpecs(kun/src/loop/plan-mode.ts:60)是个纯函数,一张过滤表:

情形给模型的工具
不在 plan 模式原样,不收窄
plan 模式、计划未产出只读名单 read/ls/glob/grep/repo_map/git_inspect/lsp + web_search/web_fetch + 提问工具 + 生成类白名单(如 generate_image)+ create_plan
plan 模式、计划已产出同上,但 create_plan 撤下;改动类工具永不回归

早先的版本按 step 两段收窄(step > 0 只剩 create_plan),上游拆分后改成不过 step——调查工具全程可用,只有 create_plan 随"计划是否已产出"开关(plan-mode.ts:71-81;stepIndex 参数还留在签名里但已不再参与判定)。

bash 不在只读名单里(plan-mode.ts:31-42PLAN_READ_ONLY_TOOL_NAMES 没有它)——它能执行任意命令,而它的策略是 on-request,在 approvalPolicy: auto 下会被自动放行。旧版这里有一段注释把这条理由写死在代码旁,上游拆分后注释被删了,但排除本身不变。

提问工具(user_input / request_user_input,plan-mode.ts:47 PLAN_INTERACTIVE_TOOL_NAMES)留在名单里是为了让模型能在同一个 turn 里问清楚再写计划,而不是"抛一个问题然后结束 turn"(:43-46 的注释)。同一段注释还点明:这两个工具本身按 awaitUserInput 广告,所以 IM/headless 的 plan turn 上根本不会出现,那里走的是"散文提问然后停"的回退。

同名不同物提醒: 这里的收窄发生在 loop 侧;CapabilityRegistry 在目录侧另有一份七名字的 Plan 白名单(PLAN_MODE_ALLOWED_TOOL_NAMES),两者叠加生效,详见 04-tools-and-gates.md §3.3。

8.2 必调工具与两个逃生口

Plan turn 会把 create_plan 设为必调工具(softRequiredToolName,kun/src/loop/model-step-preparation-service.ts:420-432;旁边的注释直说 "Plan creation is deliberately a soft completion condition"——先调查、先提问、先停下问清楚都合法)。如果模型这一轮压根没叫工具,进入 resolveMissingSoftRequiredTool(kun/src/loop/round-outcome-required-tool-phase.ts:30)的分支,这里有两个逃生口:

逃生口一:判为提问,不物化。 isPlanClarifyingQuestion(kun/src/loop/plan-mode.ts:183)用三个必须同时成立的信号识别"这是在问用户,不是在给方案":没有 Markdown 标题(真计划按指令必须有 ## 分节)、最后两行有问号、命中选择类线索词(which / / 还是 / 请选择 …,:168 PLAN_CLARIFYING_CUE)。三条都中才判为提问,直接 'stop' 让用户回答。第三条是关键——一个正经计划结尾写"这样可以吗?"不会被误杀。

逃生口二:把散文物化成计划。 不是提问的话,把模型这段文本当作计划正文,由循环自己伪造一次 create_plan 调用并走正常分发(kun/src/loop/round-outcome-required-tool-phase.ts:57-113),item summary 写明 "Materialized assistant plan text into the required Kun plan."(:96)。

两个口都不成立(比如连文本都没有),才写 required_tool_missing 错误并 'failed'(:115 起)。

8.3 陈旧 plan 上下文

isStalePlanContext(kun/src/loop/plan-mode.ts:152)处理一个具体的 bug:会话 fork 出来的新 thread 保留了源 thread 的 workspace,但携带的 plan 上下文可能指向别处。这种上下文必须丢掉——否则 create_plan 会因 workspace 不匹配硬失败,或者强推一套 fork 后的历史根本满足不了的 plan-only 工具集。检测就是比对 workspace(kun/src/shared/gui-plan.ts:47 guiPlanWorkspaceMatches,在 plan-mode.ts:157 被调用),判定为陈旧就当普通 agent turn 跑。

注意路径:runtime 侧用的是 kun/src/shared/gui-plan.ts,GUI 侧另有一份同名的 src/shared/gui-plan.ts(见 06-gui-product-layer.md §4.1),两者行号不同,引用时必须写全路径。


9. 收尾:finally 里的四件事

runTurnfinally(kun/src/loop/agent-loop-turn-lifecycle.ts:305-340)顺序是刻意的:

finally
├─① goalTurns.afterTerminal finishElapsedTimer(累加 goal.timeUsedSeconds)→ evaluateResume(续跑决策)
├─② 按 turn 清理一串易失状态 自动路由、风暴熔断器、round 引擎与判定器状态、goal 记账、turnFailures…
└─③ runTurnEndHooks 观察型钩子,绝不允许抛

① 在 ② 之前是有原因的:续跑决策要读 turnMadeProgress 等按 turn 记账的状态,而 ② 正是清它们的地方。新注释把 ① 定位为 "post-settlement conveniences"——它即使迟到或失败,也不许掩盖已落盘的终局、更不许跳过 ② 的无条件清理(:307-309)。② 清的项目依次是 modelRouting.cleartoolStormBreakers.deletemodelRoundEngine.clearTurnroundOutcome.clearTurngoalTurns.clearTurnskillRuntime.clearTurnActivationturnFailures.deletetelemetry.clearPromptPressure(:319-330)。

9.1 三件成功路径上的收尾动作

验证建议(软提示,不强制)。 turnHasUnverifiedSourceChanges(kun/src/loop/plan-mode.ts:107)扫本 turn 的 item,找"最后一次源码改动"和"最后一次 verify_changes"的下标,前者更靠后就说明有未验证的改动。三个过滤条件让它只对代码生效:路径要匹配 /\.[cm]?[jt]sx?$/i、不能是 create_plan 产物、不能是失败的改动。命中就在下一轮注入一句 verificationSuggestionInstruction()(:136),措辞是"考虑跑一下"和"不适用就跳过"——写文档、写 HTML 的模式天然不匹配,不会被烦。

标题生成(fire-and-forget)。 第一条用户消息就位后(post_start 钩子跑完)就异步启动 ThreadTitleService.generateAfterTurn(kun/src/loop/thread-title-service.ts:24,启动点在 kun/src/loop/agent-loop-turn-lifecycle.ts:199-202),与主回答并行跑。三道闸门:只在还没有任何 completed turn 时跑(标题只服务第一个 turn)、标题必须可升级、生成完写回前再查一次防止用户改名把它挤掉(:33-34:65)。可升级的判定 canUpgradeThreadTitle(kun/src/loop/thread-title-policy.ts:19)读 titleAuto 三态——false 是用户改过的绝不覆盖、true 是客户端临时标题可以升级、缺失(老数据)则只覆盖占位标题(New Thread / 新会话 / Untitled / 未命名,:1 PLACEHOLDER_THREAD_TITLES)。

失败信息富化。 runTurn 的 catch 不直接抛原始消息,而是拼成 [Kun turn failed] turn=… thread=… model=… provider=… error=… stack=…(kun/src/loop/agent-loop-turn-lifecycle.ts:280-303,stack 只取前 3 行)。目的写在注释里:让界面显示"哪儿失败了",而不是一句光秃秃的 "Kun turn failed"(issue #26)。另有 rememberTurnFailure(kun/src/loop/agent-loop-base.ts:305)在流式阶段捕获模型侧错误,finishTurn 时连 code/severity 一起落进 turn 记录。

9.2 finishTurn 那边做了什么

TurnService.finishTurn(kun/src/services/turn-service-compaction-operations.ts:393)是唯一的收尾写入口,顺序同样重要:先在 thread 写锁内把 turn 与新 thread 状态一起落盘 → 清运行态(clearRuntimeTurnState)→ 把还开着的 item 结算掉(finalizePersistedOpenItems)→ 释放该 turn 的用量聚合(usage.endTurn)→ 发 turn_completed/failed/aborted 事件 → 有错误就补一条 error item。

thread 记录的所有写入都过 upsertThread(kun/src/services/turn-service-item-persistence-operations.ts:177),它走进 withThreadStoreMutation(kun/src/services/thread-mutation-coordinator.ts:11)按 threadId 串一条 Promise 链做每 thread 的写串行化——并发的 item 追加不会互相覆盖。


10. 边界与局限(诚实的部分)

局限说明
循环没有步数上限kun/src/loop/agent-loop-execution.ts:43。异常模型可以在成本预算允许的范围内一直转;没设成本预算的 thread 靠的是"模型总会停"这个假设
注释措辞陈旧一处goal-resume-coordinator.ts:5-6 提到 "per-turn model-step budget"——这个名字在代码里查无此物;如今最接近的实物是可选配置 runtime.turnLimits.maxSteps(kun/src/loop/turn-limits.ts:14),默认并不设限
可恢复分发错误靠字符串匹配isRecoverableToolDispatchError(kun/src/loop/tool-execution-service.ts:411)匹配 unknown tool: / is not provided by 等消息片段;上游改文案就会漏判
并发批里任一 rejected 就往上抛kun/src/loop/tool-call-dispatcher.ts:121 直接 throw result.reason;同批里排在它之后的已完成结果不会落盘(之前的已经逐个落过了)
提示词工程占比很高复读、空回复、验证建议、目标审计……相当一部分"纠偏"是往上下文里塞英文指令,效果依赖模型是否听话,代码只能保证"塞进去了"
复读阈值是硬编码0.85 / 12 字符 / 3 次(kun/src/loop/continuation-instructions.ts:137-139),不可配置
熔断器看不到语义只比工具名 + 参数字面量;换个等价写法的重复调用照样穿过去

11. 代码地图(导航索引)

主题文件符号
turn 主入口与 finally 收尾kun/src/loop/agent-loop-turn-lifecycle.tsAgentLoopTurnLifecycle.runTurn
主循环kun/src/loop/agent-loop-execution.tsAgentLoopExecution.loop
单步:装配 → 发送 → 流式 → 判定kun/src/loop/agent-loop-base.tskun/src/loop/model-step-service.tsAgentLoopBase.modelStepModelStepService.run
工具分发(顺序 / 并发批)kun/src/loop/agent-loop-base.tskun/src/loop/tool-call-dispatcher.tskun/src/loop/tool-dispatch-policy.tsdispatchToolCallsisParallelSafeToolCallisParallelDelegationCallDEFAULT_MAX_PARALLEL_READ_ONLY_TOOL_CALLS
工具执行三层包裹kun/src/loop/tool-execution-service.tsexecuteSafelyexecuteisRecoverableToolDispatchError
结果落盘与计划回写kun/src/loop/tool-execution-service.tspersistResultafterResultPersistedpersistSuppressed
挂起等用户回答kun/src/loop/interactive-tool-bridge.tskun/src/loop/continuation-instructions.tsInteractiveToolBridge.awaitUserInputuserInputUnavailableInstruction
目标续跑指令与审计kun/src/loop/continuation-instructions.tsgoalContinuationInstructiontodoContinuationInstructionallowedToolNamesWithGuiStateTools
复读检测kun/src/loop/continuation-instructions.tskun/src/loop/round-outcome-recovery-phase.tsisRepeatedNoToolAssistantTextcharBigramDiceSimilaritygoalNoToolRecoveryInstruction
空回复恢复kun/src/loop/continuation-instructions.tskun/src/loop/round-outcome-recovery-phase.tsemptyPostToolRecoveryInstructionEMPTY_POST_TOOL_MAX_RECOVERY_STEPS
Plan 模式收窄与逃生口kun/src/loop/plan-mode.tskun/src/loop/round-outcome-required-tool-phase.tsPLAN_MODE_INSTRUCTIONresolvePlanModeToolSpecsPLAN_READ_ONLY_TOOL_NAMESisPlanClarifyingQuestionisStalePlanContext
验证建议与标题升级kun/src/loop/plan-mode.tskun/src/loop/thread-title-service.tskun/src/loop/thread-title-policy.tsturnHasUnverifiedSourceChangesverificationSuggestionInstructionThreadTitleService.generateAfterTurncanUpgradeThreadTitle
续跑决策与埋点kun/src/loop/goal-turn-coordinator.tskun/src/loop/goal-resume-coordinator.tskun/src/loop/agent-loop-base.tsGoalTurnCoordinator.evaluateResumegoalResumeKeyrecordPipelineStageTurnBudgetGate
插话缓冲kun/src/loop/steering-queue.tsSteeringQueue.enqueue / drain / setTurn
在跑工作的登记簿kun/src/loop/inflight-tracker.tsInflightTracker.run
重复调用熔断器kun/src/loop/tool-storm-breaker.tsToolStormBreaker.inspectstableStringifySTORM_EXEMPT_TOOL_NAMES
跨 turn 退避续跑kun/src/loop/goal-resume-coordinator.tsGoalResumeCoordinator.noteGoalTurnSettledresumeInterruptedfire
turn 生命周期唯一写入口kun/src/services/turn-service.tsstartTurnsteerTurninterruptTurnfinishTurnrewindThreadreconcileOrphanedTurnsapplyItem
事件出口(先落盘后广播)kun/src/services/runtime-event-recorder.tsRuntimeEventRecorder.recordnextSeq
turn / item / 事件契约kun/src/contracts/TurnSchemaTurnItemRuntimeEventKindPipelineStage
事件重放成投影kun/src/domain/runtime-event-reducer.tsapplyRuntimeEventreplayRuntimeEvents
GUI 状态工具kun/src/adapters/tool/goal-tools.tstodo-tools.ts
挂起闸门端口与内存实现kun/src/ports/kun/src/adapters/ApprovalGateUserInputGateInMemoryUserInputGate
HTTP 侧唤醒kun/src/server/routes/turns.tssteerTurn/interruptTurnuser-inputs.tsresolveUserInput

接着读: 想知道每一轮请求里到底放了什么、怎么省钱 → 03-cache-first-context.md;想知道 toolHost.execute 里发生了什么、三道闸门(沙箱 / 审批 / 钩子)怎么把关 → 04-tools-and-gates.md;想知道进程边界与协议 → 01-runtime-boundary.md;全景与阅读顺序回 index.md