数据截至 (上游 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、todos | kun/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 |
TurnService | turn 生命周期的唯一写入口:start / finish / interrupt / steer / rewind | kun/src/services/turn-service-core.ts:218 |
SteeringQueue | turn 进行中的插话缓冲区 | kun/src/loop/steering-queue.ts:20 |
InflightTracker | 在跑的模型/工具工作的登记簿,保证异常也清账 | kun/src/loop/inflight-tracker.ts |
ApprovalGate / UserInputGate | 两个"挂起—被 HTTP 唤醒"的闸门 | kun/src/ports/approval-gate.ts、kun/src/ports/user-input-gate.ts |
ToolStormBreaker | turn 内的交互式提问熔断器(防反复骚扰用户) | kun/src/loop/tool-storm-breaker.ts:20 |
GoalTurnCoordinator / GoalResumeCoordinator | goal 的进度记账与跨 turn 自动续跑退避 | kun/src/loop/goal-turn-coordinator.ts、kun/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. 主循环:loop 和 modelStep 到底在循环什么
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 |
墙钟 maxWallTimeMs | 24 小时 | turn-limits.ts:21、agent-loop-execution.ts:99-117 |
每步工具调用数 maxToolCallsPerStep | 10,000 | turn-limits.ts:22 |
| 模型不叫工具 | 最常见的正常结束 | round-outcome-coordinator.ts:110 |
| 成本预算耗尽 | 预算闸门拒绝或分发返回 budget_exhausted | kun/src/loop/turn-budget-gate.ts:96、round-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_start | agent-loop-turn-lifecycle.ts:159、:163、:196 | turn 级:跑启动钩子、建熔断器、吃完插话 |
input_received | model-step-preparation-service.ts:109 | step 开始,带 stepIndex |
input_cached | model-step-preparation-service.ts:189-194 | 前缀易变内容检测结果 |
input_routed | model-step-preparation-service.ts:251-254 | 选定 model / reasoningEffort |
input_compressed | model-step-service.ts:442 | 压缩后的历史条数 |
input_remembered | model-step-preparation-service.ts:635 | 注入的记忆条数、指令条数 |
pre_send / post_send | model-round-engine.ts:218、:231 | 请求出门前后 |
response_received | model-round-engine.ts:448 | 带 stopReason 和工具调用数 |
标签表在 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/ls 里 | PARALLEL_READ_ONLY_TOOL_NAMES,tool-dispatch-policy.ts:4 |
且 toolKind 是 tool_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/steer → TurnService.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)按顺序做五件事:
- 先在 thread 写入锁内把 turn 状态改成
aborted(Graph turn 还要先取消它的草稿和运行,:269-281) clearRuntimeTurnState—— abort 信号发出去、清运行态- 发
turn_aborted事件 - 按
discard决定是丢弃该 turn 生成的 item(只留用户消息)还是封存(把未完成的 item 收成 aborted) - 持久化失败时照样 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:117、kun/src/server/runtime-restart-reconciliation.ts:38)。
两个细节:
- 扫描带
includeSide: true。委派子 agent 跑在隐藏的 side thread 上,不带这个参数就永远扫不到,父 thread 的delegate_taskitem 会永久卡住(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/:id | kun/src/ports/approval-gate.ts:9 |
UserInputGate | 模型主动用 user_input 工具提问 | POST /v1/user-inputs/:id | kun/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收成expired、user_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) |
| 5 | stopReason === '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 刻意只开放 complete 和 blocked 两个枚举值(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不受熔断器约束(:17STORM_EXEMPT_TOOL_NAMES)——连问三个相似问题是合理的。 - 写操作会清空只读记录。 一旦出现文件改动类调用,窗口里所有只读记录被清掉(
:40-42、:66clearReadOnlyEntries)。因为文件变了,再读一次同一个文件就不再是重复,而是必要的复核。
熔断器是 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(预算耗尽;或原地复读且全程没干活) | 不续跑 |
其余(completed 或 failed 但目标未了) | 交给协调器排一次退避续跑 |
协调器返回 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-42 的 PLAN_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 里的四件事
runTurn 的 finally(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.clear、toolStormBreakers.delete、modelRoundEngine.clearTurn、roundOutcome.clearTurn、goalTurns.clearTurn、skillRuntime.clearTurnActivation、turnFailures.delete、telemetry.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.ts | AgentLoopTurnLifecycle.runTurn |
| 主循环 | kun/src/loop/agent-loop-execution.ts | AgentLoopExecution.loop |
| 单步:装配 → 发送 → 流式 → 判定 | kun/src/loop/agent-loop-base.ts、kun/src/loop/model-step-service.ts | AgentLoopBase.modelStep、ModelStepService.run |
| 工具分发(顺序 / 并发批) | kun/src/loop/agent-loop-base.ts、kun/src/loop/tool-call-dispatcher.ts、kun/src/loop/tool-dispatch-policy.ts | dispatchToolCalls、isParallelSafeToolCall、isParallelDelegationCall、DEFAULT_MAX_PARALLEL_READ_ONLY_TOOL_CALLS |
| 工具执行三层包裹 | kun/src/loop/tool-execution-service.ts | executeSafely、execute、isRecoverableToolDispatchError |
| 结果落盘与计划回写 | kun/src/loop/tool-execution-service.ts | persistResult、afterResultPersisted、persistSuppressed |
| 挂起等用户回答 | kun/src/loop/interactive-tool-bridge.ts、kun/src/loop/continuation-instructions.ts | InteractiveToolBridge.awaitUserInput、userInputUnavailableInstruction |
| 目标续跑指令与审计 | kun/src/loop/continuation-instructions.ts | goalContinuationInstruction、todoContinuationInstruction、allowedToolNamesWithGuiStateTools |
| 复读检测 | kun/src/loop/continuation-instructions.ts、kun/src/loop/round-outcome-recovery-phase.ts | isRepeatedNoToolAssistantText、charBigramDiceSimilarity、goalNoToolRecoveryInstruction |
| 空回复恢复 | kun/src/loop/continuation-instructions.ts、kun/src/loop/round-outcome-recovery-phase.ts | emptyPostToolRecoveryInstruction、EMPTY_POST_TOOL_MAX_RECOVERY_STEPS |
| Plan 模式收窄与逃生口 | kun/src/loop/plan-mode.ts、kun/src/loop/round-outcome-required-tool-phase.ts | PLAN_MODE_INSTRUCTION、resolvePlanModeToolSpecs、PLAN_READ_ONLY_TOOL_NAMES、isPlanClarifyingQuestion、isStalePlanContext |
| 验证建议与标题升级 | kun/src/loop/plan-mode.ts、kun/src/loop/thread-title-service.ts、kun/src/loop/thread-title-policy.ts | turnHasUnverifiedSourceChanges、verificationSuggestionInstruction、ThreadTitleService.generateAfterTurn、canUpgradeThreadTitle |
| 续跑决策与埋点 | kun/src/loop/goal-turn-coordinator.ts、kun/src/loop/goal-resume-coordinator.ts、kun/src/loop/agent-loop-base.ts | GoalTurnCoordinator.evaluateResume、goalResumeKey、recordPipelineStage、TurnBudgetGate |
| 插话缓冲 | kun/src/loop/steering-queue.ts | SteeringQueue.enqueue / drain / setTurn |
| 在跑工作的登记簿 | kun/src/loop/inflight-tracker.ts | InflightTracker.run |
| 重复调用熔断器 | kun/src/loop/tool-storm-breaker.ts | ToolStormBreaker.inspect、stableStringify、STORM_EXEMPT_TOOL_NAMES |
| 跨 turn 退避续跑 | kun/src/loop/goal-resume-coordinator.ts | GoalResumeCoordinator.noteGoalTurnSettled、resumeInterrupted、fire |
| turn 生命周期唯一写入口 | kun/src/services/turn-service.ts | startTurn、steerTurn、interruptTurn、finishTurn、rewindThread、reconcileOrphanedTurns、applyItem |
| 事件出口(先落盘后广播) | kun/src/services/runtime-event-recorder.ts | RuntimeEventRecorder.record、nextSeq |
| turn / item / 事件契约 | kun/src/contracts/ | TurnSchema、TurnItem、RuntimeEventKind、PipelineStage |
| 事件重放成投影 | kun/src/domain/runtime-event-reducer.ts | applyRuntimeEvent、replayRuntimeEvents |
| GUI 状态工具 | kun/src/adapters/tool/ | goal-tools.ts、todo-tools.ts |
| 挂起闸门端口与内存实现 | kun/src/ports/、kun/src/adapters/ | ApprovalGate、UserInputGate、InMemoryUserInputGate |
| HTTP 侧唤醒 | kun/src/server/routes/ | turns.ts 的 steerTurn/interruptTurn、user-inputs.ts 的 resolveUserInput |
接着读: 想知道每一轮请求里到底放了什么、怎么省钱 → 03-cache-first-context.md;想知道 toolHost.execute 里发生了什么、三道闸门(沙箱 / 审批 / 钩子)怎么把关 → 04-tools-and-gates.md;想知道进程边界与协议 → 01-runtime-boundary.md;全景与阅读顺序回 index.md。