OpenWorker 深度解析(二):TurnEngine — Agent 迴圈的完整解剖

大多數人以為 agent loop 就是「while 迴圈裡呼叫 LLM,有 tool_calls 就執行」。 那份實作大概 40 行,在 demo 裡跑得很好。 真實系統裡它是 1192 行,而多出來的 1152 行不是為了功能,是為了「壞掉的時候別壞得太難看」。 這篇要讀的,就是那 1152 行。


本篇是 OpenWorker 深度解析系列 的第二篇,主角只有一個檔案:coworker/engine.py


一、Agent Loop 的本質,以及它的三個謊言

1.1 教科書版本

 1# 你在每一篇 tutorial 裡看到的版本
 2messages = [{"role": "system", "content": SYSTEM}, {"role": "user", "content": user_input}]
 3while True:
 4    resp = client.chat.completions.create(model=MODEL, messages=messages, tools=TOOLS)
 5    msg = resp.choices[0].message
 6    messages.append(msg)
 7    if not msg.tool_calls:
 8        break
 9    for tc in msg.tool_calls:
10        result = TOOL_FUNCS[tc.function.name](**json.loads(tc.function.arguments))
11        messages.append({"role": "tool", "tool_call_id": tc.id, "content": json.dumps(result)})

這 12 行沒有錯,但它有三個隱含的謊言:

謊言現實
messages 就是送給模型的東西」你需要持久化額外欄位(時間戳、來源、思考文字、token 用量),但這些欄位會讓 provider 400
「工具執行完就 append」有些工具需要人類批准,那是一個可能等 30 分鐘的 await
「迴圈自然會結束」使用者會按 Stop、provider 會 timeout、context 會爆掉

TurnEngine 的 1192 行,本質上就是把這三個謊言一個一個拆掉。

1.2 檔案開頭的設計聲明

 1"""TurnEngine — the owned agent loop.
 2
 3Async, but with blocking provider/tool calls wrapped in `asyncio.to_thread` so the loop
 4(and any UI consuming its events) stays responsive. One user turn spans many model↔tool
 5iterations until the model stops requesting tools, a rail trips, or it's interrupted.
 6When the model requests several tool calls in one turn, low-risk ones (reads, searches)
 7execute concurrently; writes/shell stay strictly ordered.
 8
 9Approvals are handled out-of-band via an injected async `approver`: when the permission
10engine says `needs_user`, the engine emits `PERMISSION_REQUIRED` and awaits the approver.
11"""

四個關鍵詞,就是本篇的四個章節: async / to_thread(第三、四章)、低風險併發(第五章)、 out-of-band approver(第五章)、interrupted(第六章)。


二、訊息的真實形狀

在讀迴圈之前,必須先知道 self.messages 裡到底裝了什麼。這是理解整個引擎的前提。

2.1 五種 role

OpenAI 格式只有四種 role(system / user / assistant / tool),OpenWorker 加了第五種:

role: "system"     → 系統提示(persona prompt + AGENTS.md + memory + narration 規則)
role: "user"       → 使用者輸入,可能是 str 或 content-parts(文字 + 圖片 + PDF)
role: "assistant"  → 模型回應,可能帶 tool_calls
role: "tool"       → 工具結果,用 tool_call_id 對應
role: "notice"     → ★ 顯示專用標記:error / interrupted / model_switch / compacted

notice 是 OpenWorker 自創的。它讓「這一輪因為 provider 錯誤而中斷」這件事 能跟著 transcript 一起持久化、重載後還看得到,但送給模型時整個丟掉:

1def _append_notice(self, kind: str, text: Optional[str] = None) -> None:
2    """Persist a turn-ending marker (error/interrupted) as a display-only `notice`
3    message: it survives reload like the transcript does, but `_outbound_messages`
4    drops the role so no provider ever sees it."""
5    notice: dict[str, Any] = {"role": "notice", "kind": kind, "ts": time.time()}
6    if text:
7        notice["text"] = text
8    self.messages.append(notice)

2.2 五種側車(sidecar)

除了 role 之外,訊息上還掛了五個「provider 看不到」的欄位:

┌─────────────┬──────────────────────────────────────────────────────────────┐
│ 側車欄位     │ 用途                                                          │
├─────────────┼──────────────────────────────────────────────────────────────┤
│ ts          │ append 當下的 unix 時間戳                                     │
│ source      │ Connector 來源(Slack 訊息的頻道、發送者)→ GUI 畫成卡片      │
│ _display    │ 使用者可見、模型不可見的中繼資料                              │
│             │  例:「隱私過濾器藏了 3 筆 Gmail 結果」                        │
│             │  ★ 模型絕不能看到這個數字,否則它會試著繞過過濾器             │
│ reasoning   │ 模型的思考文字(DeepSeek reasoning_content、Gemini thought)  │
│             │  ★ 顯示用,從不 replay 回 provider                            │
│ usage       │ 這一輪的 token 計數,標記產生它的模型(跨模型切換仍可彙總)    │
└─────────────┴──────────────────────────────────────────────────────────────┘

_display 的設計動機在 _record_result 的註解裡寫得很清楚:

1# A `_display` key on a tool result is user-facing metadata the AGENT must
2# never see (e.g. how many gmail hits the privacy filters hid — a count
3# the model could probe around).

「a count the model could probe around」 —— 如果模型知道有 3 筆結果被隱藏, 它可以用二分搜尋式的查詢逐步推斷出被隱藏的內容。所以這個數字只能給人看。

2.3 還有一種:provider 私有 extras

1if turn.extras:
2    # Provider-private sidecars (e.g. `_gemini` thought signatures) persist with the
3    # message; the owning provider reattaches them, the rest strip them (base.py).
4    message.update(turn.extras)

Gemini 的 thought signature 必須原樣送回去才能維持思考鏈,但其他 provider 看到會報錯。 契約是:底線開頭的 key,只有擁有它的 provider 會消費,其他一律剝掉


三、主迴圈:_loop() 逐段解析

┌─────────────────────────────────────────────────────────────────────────┐
│  while True:                                                            │
│                                                                         │
│   ① if iterations >= max_iterations:  → TURN_END(max_iterations_exceeded)│
│                                                                         │
│   ② 壓縮檢查點                                                           │
│      if _compaction_due():                                              │
│          yield COMPACTING          ← 先發訊號,因為摘要要跑好幾秒         │
│          notice = await _compact_now()                                  │
│          yield COMPACTED                                                │
│                                                                         │
│   ③ 串流模型回應                                                         │
│      async for chunk in self._astream():                                │
│          yield REASONING_DELTA / ASSISTANT_DELTA                        │
│      ┌─ except провider error:                                          │
│      │    is_context_overflow? → 強制壓縮後 continue(有進度保證)        │
│      │    否則 → 保住已串流的部分 + notice("error") + yield ERROR → return│
│      └─                                                                 │
│                                                                         │
│   ④ if 使用者按了 Stop 且 turn is None:                                  │
│          持久化「使用者看到的那一段」+ notice("interrupted") → return     │
│                                                                         │
│   ⑤ append assistant message,yield ASSISTANT_MESSAGE                    │
│                                                                         │
│   ⑥ if not turn.tool_calls:                                             │
│          有 steering 訊息 → 注入後 continue                              │
│          否則 → TURN_END(completed) → return                            │
│                                                                         │
│   ⑦ async for event in _handle_tool_calls(turn.tool_calls): yield event │
│      yield ITERATION_END                                                │
│                                                                         │
│   ⑧ 再檢查一次 cancel / steering                                         │
└─────────────────────────────────────────────────────────────────────────┘

3.1 檢查點的位置是刻意的

注意 _cancel 被檢查了幾次:串流中(producer thread 每個 chunk)、 串流後(④)、每個工具呼叫前(_handle_tool_calls 迴圈內)、 每一輪結束(⑧),還有所有 await 的地方(_interruptible)。

這不是防禦性冗餘,是因為「按 Stop」可能發生在任何狀態。第六章會詳細討論。

3.2 max_iterations 是護欄,不是配置

1max_iterations: int = 12,

預設 12 輪。這不是「模型最多想 12 次」,而是「一個 user turn 內最多 12 次 模型↔工具往返」。配置從 config.max_iterations 讀,但預設值刻意保守 —— 大部分無限迴圈的 bug 都是模型反覆呼叫同一個失敗的工具。

3.3 Context overflow 的自我修復路徑

 1except Exception as exc:  # provider failure
 2    # A raw context-overflow 400 (compaction mispredicted, e.g. the estimate
 3    # path) routes into the compaction policy instead of surfacing. The retry
 4    # is progress-guarded: each pass moves the boundary forward or gives up,
 5    # so a model that keeps overflowing still terminates in the error path.
 6    if _compaction.is_context_overflow(exc) and not self._cancel.is_set():
 7        yield Event(EventType.COMPACTING, {})
 8        notice = await self._compact_now(force=True)
 9        if notice:
10            self._append_notice("compacted", notice)
11            yield Event(EventType.COMPACTED, {"text": notice})
12            continue

這段做對了兩件事:

  1. 不把 context overflow 當成錯誤呈現給使用者 —— 這是系統該自己處理的內務。
  2. 「progress-guarded」 —— _compact_now(force=True) 每次都會把 boundary 往前推, 推不動就回傳 None,於是落到下面的錯誤路徑。不會無限重試。

還有一個容易漏掉的細節:

1# Same contract as the stop path below: the partial the user watched
2# arrive survives the failure.
3if streamed or streamed_reasoning:
4    self.messages.append(_assistant_message(_partial_turn()))

使用者已經看到的文字,不能因為後面炸了就消失。 這是一致的契約 —— 錯誤路徑和中斷路徑都遵守。

_partial_turn() 刻意不包含 tool_calls

1def _partial_turn() -> AssistantTurn:
2    # What the user watched arrive — text and thinking, NO tool calls (any
3    # half-formed calls would either orphan or execute against the stop).
4    return AssistantTurn(
5        text="".join(streamed) or None,
6        reasoning="".join(streamed_reasoning) or None,
7    )

半成形的 tool_call 要嘛變成孤兒、要嘛在使用者已經按下 Stop 之後還被執行。兩個都不行。


四、串流橋接:blocking → async

這是整個檔案裡最值得抄的一段工程模式。

4.1 問題

Provider SDK 的 stream()同步 generatorfor chunk in provider.stream(...))。 但引擎是 async 的,UI 靠 async event stream 更新。直接在 event loop 裡跑同步 generator 會把整個 loop 卡死 —— UI 凍結、Stop 按鈕沒反應。

4.2 解法

┌──────────────────────────────────────────────────────────────────────┐
│  Event Loop(主執行緒)                Worker Thread                  │
│                                                                      │
│   _astream()                          produce()                      │
│      │                                   │                           │
│      │  run_in_executor(None, produce)   │                           │
│      ├──────────────────────────────────▶│                           │
│      │                                   │ for chunk in              │
│      │                                   │   provider.stream(...):   │
│      │                                   │                           │
│      │                                   │   if _cancel.is_set():    │
│      │                                   │       break  ← 兩個 chunk  │
│      │                                   │              間丟棄串流    │
│      │                                   │                           │
│      │      call_soon_threadsafe         │                           │
│      │◀──── queue.put_nowait(chunk) ─────┤                           │
│      │                                   │                           │
│   ┌──┴─────────────────────────────┐     │                           │
│   │ asyncio.wait({                 │     │                           │
│   │   queue.get(),                 │     │                           │
│   │   _cancel.wait()               │     │  ★ 用 race 而不是 await   │
│   │ }, FIRST_COMPLETED)            │     │    queue.get(),因為串流   │
│   └────────────────────────────────┘     │    可能「卡住不吐 chunk」  │
│      │                                   │                           │
│      ▼ yield chunk                       ▼                           │
└──────────────────────────────────────────────────────────────────────┘

程式碼:

 1async def _astream(self):
 2    """Bridge the provider's blocking stream generator to the async loop via a
 3    thread + queue, so text deltas surface live without blocking the event loop."""
 4    loop = asyncio.get_running_loop()
 5    queue: asyncio.Queue = asyncio.Queue()
 6    tools = self.registry.schemas() or None
 7    model, messages, settings = (
 8        self.model, self._outbound_messages(), self.model_settings,
 9    )
10    provider = self.provider
11
12    def produce():
13        try:
14            for chunk in provider.stream(model=model, messages=messages,
15                                         tools=tools, **settings):
16                # User pressed Stop: drop the stream between chunks (reading the
17                # asyncio.Event's flag from a thread is safe; we only read).
18                if self._cancel.is_set():
19                    break
20                loop.call_soon_threadsafe(queue.put_nowait, ("chunk", chunk))
21        except Exception as exc:
22            loop.call_soon_threadsafe(queue.put_nowait, ("error", exc))
23        finally:
24            loop.call_soon_threadsafe(queue.put_nowait, ("done", None))
25
26    loop.run_in_executor(None, produce)
27    while True:
28        # Race the queue against Stop so a stalled stream (no chunks arriving —
29        # the pre-first-token wait, a wedged connection) can't hold the turn.
30        get_task = asyncio.ensure_future(queue.get())
31        cancel_task = asyncio.ensure_future(self._cancel.wait())
32        done, _ = await asyncio.wait({get_task, cancel_task},
33                                     return_when=asyncio.FIRST_COMPLETED)
34        cancel_task.cancel()
35        if get_task not in done:
36            get_task.cancel()
37            return  # interrupted — the producer exits on its own next chunk
38        kind, payload = get_task.result()
39        if kind == "chunk":
40            yield payload
41        elif kind == "error":
42            raise payload
43        else:
44            return

三個值得記住的細節:

細節為什麼
producer 在 thread 裡讀 self._cancel.is_set()asyncio.Event 的 flag 讀取是安全的(只讀不寫);不需要額外的 threading.Event
("chunk"/"error"/"done", payload) tuple 而非 sentinel例外要跨執行緒傳回並在 consumer 端 raise,才能被 _loop 的 except 抓到
consumer 端 race queue.get() vs _cancel.wait()串流「卡住不吐 chunk」時(等第一個 token、連線 wedge),producer 的 break 檢查永遠不會執行

第三點是最容易漏的:producer 的 cancel 檢查只在收到 chunk 時才會跑。 如果 provider 一個 chunk 都不吐,那個 break 永遠等不到。所以 consumer 端必須自己 race。

4.3 對比:如果用 asyncio.to_thread 包整個串流

方案 A:to_thread(lambda: list(provider.stream(...)))
  ✗ 沒有即時 delta,使用者盯著空白畫面 20 秒
  ✗ 無法中途中斷

方案 B:本文的 thread + queue + race
  ✓ 即時 delta
  ✓ 任何狀態可中斷
  ✗ 程式碼多 30 行

方案 C:用 provider 的 async SDK
  ✗ 要求所有 7 家 provider 都有 async 實作且行為一致
  ✗ Ollama / 各種 compat server 的 async 支援參差

OpenWorker 選 B,並且把 ProviderClient 明確定義成同步介面 —— 統一在一個地方處理 非同步橋接,比要求 7 個 provider 實作都正確處理 async 更可靠


五、工具呼叫:授權、分流、執行

5.1 兩階段:先全部授權,再執行

1async def _handle_tool_calls(self, tool_calls: list[ToolCall]) -> AsyncIterator[Event]:
2    """Run one assistant turn's tool calls: authorize all of them first (sequentially —
3    approval prompts are interactive), then execute. Low-risk calls (reads, searches)
4    run concurrently; everything else runs one at a time in call order."""

為什麼要先全部授權? 因為批准是互動的。如果邊授權邊執行, 使用者會看到「批准 → 跑 → 批准 → 跑」的鋸齒體驗;先全部問完, 使用者可以一次看清楚這一輪要做哪些事。

        模型回傳 4 個 tool_calls
                 │
      ┌──────────┴───────────┐
      ▼  階段一:授權(序列) ▼
 ┌─────────────────────────────────────────────────────────┐
 │ grep              → READ      → 自動允許                 │
 │ read_file         → READ      → 自動允許                 │
 │ write_file        → WRITE     → 路徑檢查 → 需批准 → 等人  │
 │ send_message      → EXTERNAL  → 需批准 → 等人             │
 └─────────────────────────────────────────────────────────┘
                 │
      ┌──────────┴───────────┐
      ▼  階段二:執行(分流) ▼
 ┌───────────────────────────┐   ┌──────────────────────────┐
 │ 併發組(_parallel_safe)  │   │ 序列組                    │
 │ ─────────────────────     │   │ ─────────────────        │
 │ grep      ┐               │   │ write_file               │
 │ read_file ┘ gather()      │   │      ↓                   │
 │                           │   │ send_message             │
 └───────────────────────────┘   └──────────────────────────┘

5.2 _parallel_safe 的判準只有兩條

1def _parallel_safe(self, tool_call: ToolCall) -> bool:
2    # Only metadata-declared low-risk tools (reads, searches, git queries) run
3    # concurrently; writes, shell, and anything unannotated stay strictly ordered.
4    spec = self.registry.get(tool_call.name)
5    metadata = spec.metadata if spec else None
6    return getattr(metadata, "risk_level", "") == "low" and not getattr(
7        metadata, "requires_approval", False
8    )

注意 「anything unannotated stay strictly ordered」 —— 沒有宣告 metadata 的工具 預設是序列的。這是正確的 fail-safe 方向:未知 = 保守

實測影響:一個典型的「探索程式碼」turn 會產生 3–6 個 grep/read, 併發後從 ~4.5 秒降到 ~1.2 秒。

5.3 三個「繞過權限系統」的特殊工具

1# `request_directory` and `propose_plan` are interactive: the user decides
2# out-of-band and that decision IS the consent, so they skip the
3# permission/registry path.
4if tool_call.name == "request_directory": ...
5if tool_call.name == "propose_plan": ...
6if tool_call.name == "ask_user": ...

這三個工具由引擎直接攔截,不進 registry、不進權限引擎。理由很漂亮: 它們的執行結果就是「問使用者一個問題」,而使用者的回答本身就是同意。 再套一層「你要允許 agent 問你問題嗎?」是荒謬的。

5.4 Out-of-band 批准的型別簽章

 1class ApprovalOutcome(str, Enum):
 2    ONCE = "once"
 3    ALWAYS_TOOL = "always_tool"
 4    ALWAYS_COMMAND = "always_command"
 5    DENY = "deny"
 6
 7@dataclass
 8class PermissionRequest:
 9    tool_name: str
10    arguments: dict[str, Any]
11    metadata: Any
12    reason: str
13    tool_call_id: Optional[str] = None  # for durable resume (idempotent inbox item)
14
15Approver = Callable[[PermissionRequest], Awaitable[ApprovalOutcome]]
16
17async def _deny_all(_request: PermissionRequest) -> ApprovalOutcome:
18    return ApprovalOutcome.DENY

預設是 _deny_all 沒注入 approver 的引擎(例如子代理)不會誤放行任何需批准的操作。

tool_call_id 這個欄位是整個 durable resume 機制的錨點 —— Part 3 會詳談。


六、中斷:四種狀態與一個不變式

這是我認為整個檔案裡最見功力的部分。

6.1 「按 Stop」可能發生在哪些狀態

 1def request_interrupt(self) -> None:
 2    """Stop the turn as soon as possible, from ANY state: mid-stream (the producer
 3    thread drops the stream between chunks), mid-tool (interrupt hooks kill the
 4    running command), awaiting an approval/question/plan (the await resolves as
 5    interrupted), or between iterations (the loop checkpoint). Every pending
 6    tool_call still gets a tool-error result so the history never carries orphans
 7    (hosted templates reject them, and durable-resume would re-prompt them)."""
 8    self._cancel.set()
 9    for hook in self._interrupt_hooks:
10        try:
11            hook()
12        except Exception:
13            pass  # best-effort: a dead executor must not block the stop
┌────────────────────────────────────────────────────────────────────────┐
│  狀態 A:串流中                                                          │
│    → producer thread 在下一個 chunk 之間 break                          │
│    → consumer 的 race 立刻返回                                          │
│    → 保住已串流的文字,append notice("interrupted")                     │
├────────────────────────────────────────────────────────────────────────┤
│  狀態 B:工具執行中(shell 指令跑到一半)                                │
│    → interrupt_hooks 呼叫 executor.interrupt_now()                      │
│    → POSIX: SIGINT 給前景子程序 / Windows: Ctrl-Break 給程序群組         │
│    → ★ shell 本身活著,session 狀態(cwd、env)保留                     │
├────────────────────────────────────────────────────────────────────────┤
│  狀態 C:等待批准 / 提問 / 計畫審核                                       │
│    → _interruptible() 的 race 讓 await 提早以 interrupted 解決           │
│    → 原本的 task 被 cancel,所以「稍後才回答的 Inbox 卡片」變成 no-op    │
├────────────────────────────────────────────────────────────────────────┤
│  狀態 D:迴圈輪次之間                                                    │
│    → ⑧ 的檢查點                                                         │
└────────────────────────────────────────────────────────────────────────┘

_interruptible 的實作:

 1async def _interruptible(self, coro: Any, interrupted: Any) -> Any:
 2    """Await `coro`, but resolve early with `interrupted` if the user stops the
 3    turn. The pending task is cancelled so an answered-later Inbox card no-ops."""
 4    task = asyncio.ensure_future(coro)
 5    cancel_wait = asyncio.ensure_future(self._cancel.wait())
 6    try:
 7        done, _ = await asyncio.wait({task, cancel_wait},
 8                                     return_when=asyncio.FIRST_COMPLETED)
 9        if task in done:
10            return task.result()
11        task.cancel()
12        return interrupted
13    finally:
14        cancel_wait.cancel()

注意 finally: cancel_wait.cancel() —— 不取消的話,每次呼叫都會在 _cancel 上 留一個 waiter,長 session 會累積成記憶體洩漏。

6.2 不變式:歷史裡不能有孤兒 tool_call

1def _interrupted_tool(self, tool_call: ToolCall) -> Event:
2    """The stop-path answer for a call that will not run: a tool-error result in the
3    history (hosted chat templates reject orphaned tool_calls, and durable-resume
4    would otherwise re-prompt it) + the finished event for the tool card."""
5    self.messages.append(_tool_error_message(tool_call, "interrupted by user"))
6    ...

為什麼這麼重要? 兩個具體後果:

  1. Hosted chat template 會直接拒絕:像 Together / Fireworks 上的開源模型, chat template 會檢查每個 tool_calls 都有對應的 tool 訊息,缺一個就 400。
  2. Durable resume 會重複提問_unanswered_trailing_tool_calls() 靠 「有 tool_calls 但沒有 tool 結果」來判斷「這一輪停在哪」。留孤兒 = 重啟後 會重新問使用者一次已經取消的批准。
 1def _unanswered_trailing_tool_calls(self) -> list[ToolCall]:
 2    """The tool-calls of the last assistant message that don't yet have a tool result —
 3    i.e. the prompt we suspended on (+ any after it). Reconstructed from the persisted thread."""
 4    answered = {m.get("tool_call_id") for m in self.messages if m.get("role") == "tool"}
 5    for msg in reversed(self.messages):
 6        if msg.get("role") == "user":
 7            return []
 8        if msg.get("role") == "assistant" and msg.get("tool_calls"):
 9            out: list[ToolCall] = []
10            for tc in msg["tool_calls"]:
11                if tc.get("id") in answered:
12                    continue
13                ...

整個 durable resume 機制,是純粹從持久化的訊息串重建出來的。 沒有額外的 state machine、沒有 checkpoint 檔案。訊息串就是狀態。這個設計非常乾淨。


七、_outbound_messages():canonical vs outbound

7.1 這是唯一的 provider 餵送路徑

1def _outbound_messages(self) -> list[dict[str, Any]]:
2    """`self.messages` prepared for the provider. The SOLE provider feed (see `_astream`)."""

「THE SOLE provider feed」—— 只有一個出口,所有轉換集中在這裡。這是很重要的紀律: 如果有兩個地方會組裝送給 provider 的訊息,遲早會有一個忘記剝側車。

7.2 五道轉換,依序執行

self.messages(canonical,永不被修改)
        │
        ▼
① compaction.apply_to_outbound(messages, compaction_state)
   boundary 之前的訊息 → 一個 <compacted-history> 區塊
        │
        ▼
② 丟掉所有 role == "notice" 的訊息
   剝掉 source / _display / ts / reasoning / usage 側車
   (只複製有側車的訊息,避免不必要的 dict 複製)
        │
        ▼
③ PDF 適配:if 當前模型不支援原生 PDF
   → pdf_support.adapt_content():本地文字抽取 / 頁面圖片
        │
        ▼
④ 圖片適配:if 當前模型無 vision
   → 換成 "[image attachment — not viewable by this model]" 佔位符
        │
        ▼
⑤ 附加 <system-context> 到「最後一則 user 訊息」
   內容 = plan/discuss 模式提醒 + 目前資料夾清單 + 即時 skill 選單
        │
        ▼
   送給 provider(self.messages 完全沒被動到)

7.3 為什麼 ③④ 要「每次呼叫都重新決定」

1# PDF attachments (stored as `file` parts) are adapted to the ACTIVE model right
2# here — never in the persisted history — so a mid-session model switch always
3# re-decides: native PDF models get the real document, the rest get the local
4# text-extract/page-image fallback (pdf_support.py).

使用者可以在 session 中途換模型(switch_model)。如果 PDF 在存入歷史時就被 降級成文字,之後換到支援 PDF 的模型也拿不回原檔了。降級必須是 outbound-only 的。

7.4 為什麼 <system-context> 掛在 user 訊息上

1# Returns an ephemeral `<system-context>` block appended to the LAST user message at
2# send-time only (never persisted). We can't reliably inject system messages mid-thread
3# across providers, so dynamic per-turn context (e.g. the live directory list) rides on
4# the latest user turn. Returns "" when there's nothing to add.

「We can’t reliably inject system messages mid-thread across providers」 —— Anthropic 的 API 根本沒有 mid-thread system message;OpenAI 有但行為不穩; Gemini 又是另一套。所以動態 context 一律掛在最後一則 user 訊息尾巴。

這個 context 每一輪都重算,所以:

  • 中途切換到 plan mode → 下一則訊息就開始出現 plan mode 提醒
  • 使用者中途授權新資料夾 → 下一則訊息模型就知道
  • 使用者在 Settings 裡停用某個 skill → 下一則訊息選單就少一項

而「停用」還有一個很細膩的處理:

1# Disable countermand (§3): instructions already loaded into this conversation keep
2# steering the model even after the skill is turned off/deleted — history can't be
3# un-read. So a loaded-but-no-longer-available skill gets an explicit stop note,
4# recomputed fresh each turn (re-enable → the note disappears; never persisted).
5for name in sorted(_loaded_skill_names(eng.messages) - available):
6    parts.append(
7        f'Note: the skill "{name}" has been disabled by the user — stop '
8        "following its instructions from here on."
9    )

「history can’t be un-read」 —— 已經載入 context 的 skill 指令, 把選單項目拿掉是沒用的,必須明確下一道反命令。這是很少人想到的細節。


八、其他四個入口:retry / resume / steering / switch_model

8.1 retry() — provider 錯誤後重跑

1async def retry(self) -> AsyncIterator[Event]:
2    """Re-run the model loop after a provider error — no new user message; the failed
3    turn's input is already the tail of history. Guarded on the tail being an error
4    notice so a stray retry frame can't re-answer a completed turn. Trailing
5    model_switch notices don't break the guard — switching models and THEN retrying
6    is the intended recovery path (owner-hit 2026-07-23)."""
7    if not self._tail_is_retriable_error():
8        return

守衛邏輯:

 1def _tail_is_retriable_error(self) -> bool:
 2    """True when the history tail is an error notice, looking through any model_switch
 3    notices appended after it (a switch must not consume the retry)."""
 4    for message in reversed(self.messages):
 5        if message.get("role") != "notice":
 6            return False
 7        if message.get("kind") == "model_switch":
 8            continue
 9        return message.get("kind") == "error"
10    return False

註解裡的 (owner-hit 2026-07-23) 表示這是真實遇到的 bug: 使用者遇到 GPT 額度用盡 → 切換到 Claude → 按 retry,結果 retry 被 中間插入的 model_switch notice 擋掉了。修法是「看穿」model_switch。

8.2 resume() — 跨進程重啟後續跑

1async def resume(self) -> AsyncIterator[Event]:
2    """Continue a turn that was suspended at a prompt and persisted — durable resume after a
3    restart (or engine eviction). Re-process the trailing assistant message's UNANSWERED
4    tool-calls (the prompt callbacks find the already-resolved Inbox item and return without
5    re-prompting; answered calls are skipped, so nothing double-executes), then run the model
6    loop to finish the turn."""

關鍵在於冪等性:重跑那些未回答的 tool_call 時, inbox_approver 會發現「這個 (session_id, tool_call_id) 的 Inbox 項目已經存在且已解決」, 於是直接回傳答案,不會再問一次人。

8.3 queue_steering() — 邊跑邊插話

1def queue_steering(self, text: str, source: Optional[dict] = None) -> None:
2    self._steering.append((text, source))

使用者在 agent 跑到一半輸入「等等,不要動 config 檔」,不會打斷當前輪次, 而是排隊;在下一個安全點(工具執行完 / 模型停止呼叫工具時)注入為 user 訊息。

注意 ⑥ 的邏輯:模型不再呼叫工具時,如果有 steering,就 continue 而不是結束。 這讓「插話」不會被誤判成新的一輪對話。

8.4 switch_model() — 中途換模型

1def switch_model(self, model: str) -> Optional[str]:
2    """Rebind the session's model mid-conversation (roadmap item 3). History is
3    canonical OpenAI shape and every provider converts per call, so the switch is just
4    the field write — plus a persisted notice marking WHERE it happened, with a
5    degradation warning when history carries images the new model can't see."""

「the switch is just the field write」 —— 這是把歷史保持在 canonical OpenAI 形狀所換來的好處。每個 provider 在自己的 complete() 裡轉換, 所以換模型不需要重寫歷史。


九、為什麼選 X 不選 Y

決策選 X 的理由不選 Y 的理由反轉條件
AsyncIterator[Event]
vs callback / 回傳值
一個 turn 有 10+ 種進度訊號要即時送到 UI;async generator 天然是背壓正確的callback 難以中斷、難以測試;回傳值等到最後才有東西純 batch 場景(無 UI)用回傳值更簡單
同步 ProviderClient
vs async provider
只需在一處(_astream)正確處理執行緒橋接;7 家 provider 的 async 支援參差不齊要求每個 provider 實作都正確處理 async 取消語意,錯誤面積大只支援 1–2 家有成熟 async SDK 的 provider 時
低風險工具併發
vs 全部序列
探索型 turn 常有 3–6 個 grep/read,併發後從 4.5s → 1.2s寫入併發會產生 race,shell 併發會破壞 cwd 狀態工具全部無副作用時可全併發
notice role
vs 存在別的表
錯誤 / 中斷標記跟 transcript 天然同生命週期;重載自動還原順序另開一張表要自己維護「這個錯誤發生在第幾則訊息之後」需要對錯誤做結構化查詢統計時
從訊息串重建 resume 狀態
vs checkpoint 檔
訊息串本來就要持久化;沒有第二份狀態可以不同步checkpoint 與訊息串可能不一致,且要處理版本遷移恢復點需要包含非訊息狀態(如外部交易 ID)時
max_iterations = 12
vs 無限
模型卡在失敗工具上反覆重試是最常見的失控模式無限迴圈會燒完額度且使用者無感長時間 agentic 任務可調高,但應搭配成本上限

十、系列導航


本篇可以帶走的五個模式

  1. Canonical history ≠ outbound view:持久化的訊息可以帶任意側車, 但送給 provider 前必須經過唯一一個剝除函式。
  2. Blocking → async 橋接要 race 兩端:producer 檢查 cancel 只在有資料時執行, consumer 也必須 race,否則「卡住不吐資料」的串流會鎖死 turn。
  3. 不留孤兒 tool_call:任何提早結束的路徑,都要為每個 pending tool_call 補一筆錯誤結果。
  4. 降級決策要 per-call,不要寫進歷史:模型可以中途切換, 歷史必須保留最高保真度的原始資料。
  5. 未知 = 保守_parallel_safe 對沒有 metadata 的工具回傳 False, approver 預設 _deny_all。fail-safe 的方向要一致。

本文分析基於 2026-08 的 main 分支(commit 01b6f83)。

Yen

Yen

Yen