三层 Turn Loop:谁有资格决定继续
你看到的是一轮对话。内部叠了任务壳、轮次、采样三层循环。各层只回答自己那一个问题,控制权每次只在一层。
任务壳 RegularTask
值班班长。问这一趟还活着吗。
轮次 run_turn
当班司机。问这一轮还要再采吗。
采样 sampling
检票口。问这一条流结束了吗。
空。插话先坐这里,当前采样看不见。
- 空闲则 spawn RegularTaskturn_input.rs L242
- 任务壳发 TurnStarted 后进 loopregular.rs L76
- 轮次开头按开关排 pendingturn.rs L305
- 采样 run_sampling_requestturn.rs L381
- 工具当时挂上 futurestream_events_utils.rs L326
- Completed 之后再 drainturn.rs L2539
- 重算 needs_follow_upturn.rs L423
- stop hook 带 prompt 则 continueturn.rs L525
- 任务壳再问 has_pending_inputregular.rs L86
- 忙碌则 Steered 写入 pendingturn_input.rs L207
你让 coding agent 改一个函数。屏幕上这是一轮对话:你说了一句,它忙了一阵,最后回「改完了」。
忙的时候其实叠了几件事。模型调了工具。你中途补了一句「测试用 pytest」。它写完助手消息后,Stop hook 说还没跑 linter,于是又采了一次样。
这几件事如果塞进同一个 while,就只能靠几个布尔抢出口。谁先检查、谁能打断谁,会变成口头约定。少一层,就少一个干净插口:插话、续跑和流重试会搅在一起。
Codex 拆成三层。任务壳 RegularTask::run 决定这一趟还要不要再开一轮。轮次 run_turn 决定工具续跑、插话和 hook 要不要继续。采样层只把一次模型流收到 Completed。
对外入口 start_or_steer_turn 自己不看会话空不空闲。返回值只表示 Core 接没接住这条输入,不等 hooks,也不等采样。空闲就 spawn RegularTask,忙碌就 Steered 写入 pending。三层循环从任务壳才开始转。
出处:codex-rs/core/src/codex_thread.rs 第 333 至 344 行 · codex-rs/core/src/session/turn_input.rs 第 1 至 9 行
任务壳发一次 TurnStarted,然后只要队列里还有待处理输入,就再调一次 run_turn。第二次进去时 next_input 为空,新消息从 input_queue 取。turn_id 钉死,界面不会再闪一次「新的一轮开始了」。
出处:codex-rs/core/src/tasks/regular.rs 第 76 至 90 行
轮次层把采样回来的两件事合成一个布尔:model_needs_follow_up || has_pending_input。为真就自己 continue。为假才跑 stop hook。hook 带 prompt 拦收工,这一层自己再转,任务壳和采样层都还没退。
出处:codex-rs/core/src/session/turn.rs 第 423 行 · 第 500 至 525 行
采样层自己还有两圈。外圈处理可重试错误。内圈消费一条 SSE 流。工具调用不等 Completed:OutputItemDone 当时就会挂上 future。流收到 Completed,先 drain_in_flight,再把结果交回 run_turn。
出处:codex-rs/core/src/stream_events_utils.rs 第 326 至 327 行 · codex-rs/core/src/session/turn.rs 第 2539 至 2584 行 · 第 2749 行
三层按「谁有资格决定继续」切开。采样层只看见这一次流,能重试,不能收整轮。轮次层看见工具、pending、预算和 hook,能续采样,不能重发 TurnStarted。任务壳看见任务还在、队列里是否还有活。
这不随文件怎么拆而变。换个语言重写,该问的还是三个问题:这一条流结束了吗,这一轮还要再采吗,这一趟任务还活着吗。
RegularTask 和 run_turn 都看 pending。同一句用户话如果两处都取,会转两圈。如果只留一处,就会把 hook 和后到消息挤进同一个出口。
两处问的是两件不同的事。
轮次层在采样刚刚结束时问,问的是「这一轮还要不要再采一次」。任务壳在 run_turn 已经 break 之后问,问的是「这一趟任务还要不要再进一次 run_turn」。前者把插话和工具结果留在同一个 turn_id 里。后者是界面已经可以收工、队列里又来了必须处理的输入。
去掉轮次层那一问:模型写出最终答案后,run_turn 会去跑 stop hook 并 break,中途那句「测试用 pytest」只能等任务壳再进一次 run_turn。功能上还能补上,只是多一次函数返回,stop hook 会在插话进模型之前先跑一轮。
去掉任务壳那一问:run_turn 因 should_stop 返回后,任务直接结束。队列里后到的用户消息要么消失,要么等会话变空闲,由 maybe_start_turn_for_pending_work 换一个 turn_id 新开任务。TurnStarted 会再闪一次,回放里变成两个 turn 桶。
stop hook 返回 block 且带 prompt,控制权留在轮次层。采样层早已返回。任务壳还在等这次 run_turn。block 是轮次层内部续跑。stop 是轮次层把控制权交回任务壳。
出处:codex-rs/core/src/session/turn.rs 第 509 至 537 行 · codex-rs/hooks/src/events/stop.rs 第 67 至 74 行
源码没有单独写「为什么要问两次」,下面从实现反推。hook 续跑时控制权留在轮次层,任务壳看不见。hook 放行后,任务壳才有机会接手「收工瞬间又来的那一句」。两个问题发生在不同时刻,所以要问两次。这跟具体语言、具体 hook 协议无关。
DSH:按语义切成 Turn、Step、Inbox
DSH 的外圈是 kick:while (await this.turn()) {}。turn() 自己再套一层 while (true),每一圈先 preStep,再 step()。第三层不是第三条 while。Inbox 是两条数组:next-turn 和 next-step。调用方在入队时选 followup、steer 还是 inject。
两边都叫三层,切分维度不同。DSH 按语义:Turn 是一轮完整工作,Step 是一次模型请求加工具,Inbox 是说话时机。Codex 按生命周期:任务壳问这一趟还活着吗,轮次问这一轮还要再采吗,采样问这一条流结束了吗。DSH 因此能从 session 事件重放两条队列。Codex 因此能把流重试、取消、end_turn 各自关在采样层,TurnStarted 只闪一次。
Claude Code:单层 while 加状态袋
主循环在 query.ts。可变状态放进一个 state 对象,循环体顶部解构,continue 处写回整袋。needsFollowUp 只由助手消息里的 tool_use 块点亮。没有 follow-up 时,同一层接着做压缩、stop hook、进入下一 turn。turnCount 加一,transition 写成 next_turn,回到 while (true) 顶部。
续跑、压缩、stop hook 收成同一袋状态。改一处 continue,要同时核对 stopHookActive、turnCount 和 transition。Codex 把这三件事分给三层,DSH 把插话分给 Inbox,两边都不必在同一个布尔上抢门。
把任务壳那一问改成恒为假
模型开始调工具时,你补了一句「顺便列出当前目录」。先按三层推演:任务壳、轮次、采样各转几圈,这句后续由哪一层取走。
然后把 regular.rs 第 86 行的 has_pending_input 改成恒为假,让任务壳在第一次 run_turn 返回后立刻结束。这句后续是消失、等到下一轮,还是由 maybe_start_turn_for_pending_work 换一个 turn_id。
while 加三个布尔。