三層 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 加三個布爾。