前幾天看 HolmesGPT 時,我們已經看過一個典型 Agent loop:
Model
↓
Tool Call
↓
Tool Result
↓
Model
Codex 本質上也沒有脫離這個模式。
假設我在 Codex 裡輸入:
login API 的測試失敗了,幫我找出原因並修掉。
對使用者來說只是一句話,但 Codex 內部可能是:
User
↓
Turn
↓
Model #1
↓
exec pytest
↓
test failed
↓
Model #2
↓
read login.py
↓
Model #3
↓
edit file
↓
Model #4
↓
exec pytest
↓
tests passed
↓
Model #5
↓
Final response
所以一個 Turn 可能包含多次 Model sampling、多次 Tool execution,直到 Model 不再要求 Tool,這個 Turn 才結束。
OpenAI 怎麼把這個常見的 Model → Tool → Result → Model loop,工程化成一套可長時間運作、可接不同 Client、可動態重建 Tool 與 Runtime Context 的 Agent Runtime。
這一層真正值得看的,不只是「Client 和 Core 分開」。
更準確地說,Codex 把 Client 與 Agent Runtime 的互動做成:
Client → Submission → Codex Core
Client ← Event ← Codex Core
技術上,Submission 比較像「Client 要 Core 做什麼」的 command;Event 則是 Core 執行過程中持續送出的狀態事件。
直接走一個例子就很清楚。
假設使用者輸入:
幫我跑 pytest,看看哪個測試壞掉。
在 Codex Core 裡,Submission 的結構很單純:
codex-rs/core/src/session/submission.rs
pub(crate) struct Submission {
pub id: String,
pub op: Op,
...
}
這次使用者輸入會進入 Op::TurnInput:
codex-rs/protocol/src/protocol.rs
Op::TurnInput {
request: ...,
mode: ...,
reply: ...,
}
也就是 Client 並不是呼叫:
run pytest
而是告訴 Core:
這裡有一個新的 TurnInput,請開始處理。
Session 背後有:
codex-rs/core/src/session/handlers.rs
submission_loop(...)
它持續接收 Submission,再依 Op 分派。
這次收到 TurnInput 後,後面會建立正常的 Agent task,最後進入 run_turn()。
到這裡,Client 的工作其實已經結束。
接下來:
呼叫 Model
執行 Tool
跑 pytest
取得 stdout
繼續 Model sampling
都是 Codex Core 自己處理。
假設 Model 決定執行:
pytest
Client 可能依序收到:
TurnStarted
ExecCommandBegin
command = pytest
ExecCommandOutputDelta
FAILED test_login.py
ExecCommandEnd
exit_code = 1
AgentMessage
test_login.py 失敗,接下來檢查 login service。
...
TurnComplete
這些 Event type 都定義在:codex-rs/protocol/src/protocol.rs
例如:
EventMsg::TurnStarted
EventMsg::ExecCommandBegin
EventMsg::ExecCommandOutputDelta
EventMsg::ExecCommandEnd
EventMsg::AgentMessage
EventMsg::TurnComplete
官方 thread-manager-sample 也直接示範 Client 如何:
codex-rs/thread-manager-sample/src/main.rs
thread.next_event().await
持續讀取這些 Event。
因此 CLI 畫面之所以可以即時顯示:
Running pytest...
FAILED test_login.py
Checking login_service.py...
不是 CLI 自己理解 Agent 的執行流程。
它只是收到 Core 發出的 Event,再決定怎麼呈現。
這也是事件式設計最實際的地方。
假設 pytest 跑到一半,使用者按下中止。
Client 不需要等原本的 Turn 回傳。
它可以再送:
Op::Interrupt
而 Op::Interrupt 的註解也直接指出,Core 會以:
EventMsg::TurnAborted
回應。
所以這不是傳統的:
request
↓
等待
↓
response
而是:
Client Codex Core
TurnInput ───────────────►
◄─────────────── TurnStarted
◄─────────────── ExecCommandBegin
◄─────────────── ExecCommandOutputDelta
Interrupt ────────────────►
◄─────────────── TurnAborted
這才是這一節真正的重點。
Codex 把 Agent 與 Client 的互動設計成持續的雙向事件流,而不是一次 request / response。
因此 Client 可以在 Agent 長時間工作時:
這種 event-driven 架構不是 Codex 獨有,但在 Codex 裡它是很明確的 Core boundary,也正是後面 App Server 能把同一套 Agent Runtime 接給其他 Client 的基礎。
StepContext 這個名字很抽象。
先看一個例子。
假設 Codex 正在做:
幫我把 login test 修好。
Model 第一次決定:
先跑 pytest。
Codex 執行完,得到:
FAILED test_login.py
接下來要再問 Model:
下一步要做什麼?
這時 Codex 不會只沿用 Turn 一開始那份固定設定,而是重新確認:
現在用哪個 Model?
現在在哪個 Environment?
現在 MCP 有哪些連線?
現在 Model 看得到哪些 Tool?
現在 AGENTS.md 有哪些規則?
確認完,才送出下一次 Model request。
這一份「這次 Model call 要使用的 Runtime 狀態」,Codex 裡叫:StepContext
位置:codex-rs/core/src/session/step_context.rs
可以把它想成:
第一次問 Model
→ 拍一張現在 Runtime 的照片
→ Model 決定跑 pytest
pytest 執行完
第二次問 Model
→ 再拍一張現在 Runtime 的照片
→ Model 決定讀 login.py
這樣做的原因很簡單:Agent 一個任務可能跑幾十秒甚至幾分鐘,中間環境可能改變。
例如:
MCP 重新連線
某個 Tool 被啟用
Environment 改變
設定被更新
如果後面的 Model call 還拿著舊狀態,就可能發生:
Model 以為某個 Tool 還能用,
但 Runtime 實際上已經不同。
所以 Codex 把 Runtime snapshot 的邊界做到每一次 Model request,而不只是 Turn。
StepContext 裡有一個:
codex-rs/core/src/tools/router.rs
tool_router: Arc<ToolRouter>
它負責回答兩件事:
這次 Model 看得到哪些 Tool?
這些 Tool 真正要交給哪個 Runtime 執行?
例如這次 Model 可以看到:
exec_command
read_file
write_file
Model 回:
exec_command("pytest")
Model 本身不會真的去執行 shell。
Codex 會把這個 Tool Call 轉成自己的 ToolCall,再交給對應 runtime。
簡單來說:
Model:
我要用 exec_command("pytest")
Codex Runtime:
找到 exec_command 的實作
→ 真正執行 pytest
→ 把結果送回 Model
ToolRouter 裡最重要的兩塊是:
model_visible_specs
→ 這次 Model 看得到哪些 Tool
ToolRegistry
→ 這些 Tool 真正由哪個 runtime 執行
相關 code:
codex-rs/core/src/tools/router.rs
codex-rs/core/src/tools/spec_plan.rs
其中:
build_tool_router(...)
會根據當下的:
Model
Environment
MCP
Tool Policy
Dynamic Tools
整理出這一次 sampling 使用的 Tool plan。
也就是:
準備下一次 Model call
↓
重新確認 Runtime
↓
建立 StepContext
↓
整理這次可見的 Tools
↓
Model 做下一步判斷
Tool plan 被放進每一次 Model request 的 StepContext 裡,會跟著當下的 Environment、MCP 與設定一起重新確認。
回到最開始:
login API 的 test 掛了,幫我修掉。
整段可以簡化成:
Client
│
│ Submission: TurnInput
▼
Codex Core
│
├── Event: TurnStarted
│
▼
準備問 Model 下一步
│
├── 重新確認 Environment
├── 重新確認 MCP
├── 重新確認 Tools
└── 建立 StepContext
│
▼
Model
│
│ exec_command("pytest")
▼
Codex Runtime 執行 Tool
│
├── Event: ExecCommandBegin
├── Event: ExecCommandOutputDelta
└── Event: ExecCommandEnd
│
▼
再準備下一次 Model call
│
└── 重新建立當下 StepContext
│
▼
Model 繼續下一步
│
...
▼
Final Response
│
└── Event: TurnComplete
▼
Client
這張圖其實就把今天兩個重點放在一起:
如果只看核心循環:
Model
↓
Tool
↓
Observation
↓
Model
Codex 沒有什麼特別。
HolmesGPT 也有。
ReAct 本來就在做。
Codex 真正值得研究的是:
它怎麼把一個普通 Agent loop 拆成清楚的 runtime boundary。
最後可以濃縮成:
Client
│
│ Submission
▼
Codex Core
│
├── 每次 Model call 前重新確認 Runtime
│ ├── Environment
│ ├── MCP
│ └── Tools
│
▼
Model ↔ Tool Runtime
│
│ Event
▼
Client
這才是我認為 Codex 第一篇最值得留下來的結論:
Codex 的 Agent loop 本身並不特別;真正值得看的,是它把 Client/Core 互動做成雙向 protocol,並在每次 Model request 前重新整理當下 Runtime。