turn_context 與提示 (prompt) 組裝
職責
在每一輪 API 呼叫前,把「歷史訊息 + 本輪使用者輸入 + 網關 (gateway) 側附加上下文 (context) + 壓縮 (compression) 標記」裝配成發給 provider 的 api_messages。TurnContext 是這輪產物的容器,build_turn_context 是裝配入口。它還負責把網關收集的筆記(notes)注入到多模態內容裡。
關鍵檔案
class TurnContext:242-268— 本輪上下文資料類別def build_turn_context:268-400— 裝配入口compose_user_api_content:44-79— 使用者訊息內容組裝substitute_api_content:79-101— 占位符替換consume_gateway_turn_context_notes:124-164— 消費網關側附加 notes 並注入多模態內容reanchor_current_turn_user_idx:164-210— 重定位本輪使用者訊息索引(壓縮後修正)壓縮進度估計:190-242—_compression_made_progress/_should_run_preflight_estimateprompt_builder— 系統提示組裝(本頁引用)
資料流
- 主迴圈每輪呼叫
build_turn_context:268,得到一個TurnContext。 compose_user_api_content(agent/turn_context.py:44)把使用者原始輸入拼成 API 內容。- 若網關側累積了 notes(來自附件、上下文提示),
consume_gateway_turn_context_notes(agent/turn_context.py:124)取出並經append_notes_to_multimodal_content注入。 substitute_api_content(agent/turn_context.py:79)替換占位符;drop_stale_api_content清理過期內容。- 若上一輪發生過壓縮,
reanchor_current_turn_user_idx(agent/turn_context.py:164)修正本輪使用者訊息在歷史中的位置。 - 裝配好的
api_messages進入API 呼叫重試子迴圈:1227。
TurnContext 是普通 dataclass,把 prologue 算出來的值捆成一份(關鍵欄位見 agent/turn_context.py:242-268):user_message 是消毒後的本輪訊息;original_user_message 保留原始文字用於記憶與日誌查詢(nudge 不會注入進去);conversation_history 可能被 preflight 壓縮置 None——壓縮會開新會話,舊 history 不能再用;active_system_prompt 是本輪快取的系統提示,也可能被壓縮重建。
網關 notes 的消費邏輯見 agent/turn_context.py:124-145:
def consume_gateway_turn_context_notes(agent: Any) -> str:
"""Pop the gateway's per-turn must-deliver notes off the agent (one-shot).
... so the composed system prompt stays byte-stable turn-over-turn.
... this consumes them so a cached agent can never replay a stale note."""
notes = getattr(agent, "_gateway_turn_context_notes", "") or ""
if hasattr(agent, "_gateway_turn_context_notes"):
try:
agent._gateway_turn_context_notes = ""
except Exception:
pass
return notes if isinstance(notes, str) else ""取完立即清零——這是個一次性 pop,不是 peek。即便 agent 被快取複用,上一輪沒消費完的 notes 也不會洩漏到下一輪。
設計動機
為什麼把 prologue 抽成 build_turn_context?原來所有「每輪一次」的準備(stdio 守衛、retry 計數器重置、user message 消毒、todo/nudge 注入、系統提示建構、preflight 壓縮、pre_llm_call 鉤子、外部記憶預取、崩潰復原持久化)都內嵌在 run_conversation 開頭,接近 130 行。抽成函式後,主迴圈只剩「迴圈 + 重試」,讀主迴圈時不必被準備邏輯打斷;prologue 還可以單獨測試,subagent 路徑也能複用。
為什麼 notes 走使用者訊息旁路而不是塞進 system prompt?系統提示在多輪之間需要 byte 穩定——一旦改變,前面快取的 prefix 就失效,provider 側的 prompt caching 直接報銷。網關把易變內容(首次自我介紹、語音通道切換、自動 reset 的臨時提示)從 system prompt 裡挪出來,透過使用者訊息的 api_content sidecar 投遞,既讓每輪都能帶上新事實,又保住了快取命中率。
_compression_made_progress 用 5% 作為 material 閾值,是為了防多輪壓縮空轉:某輪把 220 條訊息壓成 220 條但 token 從 288k 降到 183k,按行數判斷會誤報「壓不動了」然後觸發 auto-reset。
邊界與失敗
- 多模態訊息遺失 notes:
compose_user_api_content對非字串內容回傳None,使用者這輪發圖片時 sidecar 形式的 notes 會靜默遺失。append_notes_to_multimodal_content兜底把 notes 作為 text part 直接 append,代價是這部分內容會被持久化進 transcript——網關側必須只放真正需要落盤的內容。 - 壓縮後使用者訊息索引漂移:壓縮把舊訊息合成 summary,本輪使用者訊息在
messages裡的位置會變。reanchor_current_turn_user_idx重新掃一遍 list 找到下標;不重定位會讓工具呼叫 (tool dispatch) 結果回填找錯位置。 - 少量但超大訊息:
_should_run_preflight_estimate舊版只看訊息條數,幾條巨型 base64 圖片永遠不會觸發壓縮,直接撞 hard overflow(#27405)。新版加了 char-based 估算分支,大訊息也能命中閾值。 - stale notes 殘留:
consume_gateway_turn_context_notes用 try/except 兜住寫入失敗——即便 agent 屬性是唯讀 property,也不會因清零失敗而中斷裝配。
小結
turn_context 是「每輪的快照工廠」:把動態上下文固化為一個可發送的結構。它和上下文壓縮協同——壓縮改寫歷史後,turn_context 負責把指標重新對齊。