turn_context とプロンプト組み立て
職務
毎ラウンドの 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— システムプロンプト (system prompt) 組み立て(本ページで参照)
データフロー
- 主ループは毎ラウンド
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 ""取り出したら即座にゼロにする——これは peek ではなくワンショットの pop だ。agent がキャッシュされて再利用されても、前のラウンドで消費しきれなかった notes が次ラウンドに漏れることはない。
設計動機
なぜ prologue を build_turn_context に抽出したのか? 元々「毎ラウンド一回」の準備(stdio ガード、retry カウンタのリセット、user message の消毒、todo/nudge の注入、システムプロンプト構築、preflight 圧縮、pre_llm_call フック、外部記憶のプリフェッチ、クラッシュリカバリの永続化)はすべて run_conversation 冒頭にインラインで約 130 行並んでいた。関数に抽出したことで、主ループは「ループ + リトライ」だけになり、主ループを読むときに準備ロジックで中断されない。prologue は単体テストもでき、subagent パスでも再利用できる。
なぜ notes はシステムプロンプトではなくユーザーメッセージのバイパスで運ぶのか? システムプロンプトはラウンド間で byte 安定である必要がある——一度でも変わると、前にキャッシュした prefix が無効になり、provider 側の prompt caching が即座に死ぬ。ゲートウェイは変わりやすい内容(初回の自己紹介、音声チャンネルの切り替え、自動 reset の一時ヒント)を system prompt から外に出し、ユーザーメッセージの api_content の sidecar として届ける。毎ラウンド新しい事実を載せつつ、キャッシュヒット率を保てる。
_compression_made_progress が material 閾値に 5% を使うのは、多段圧縮の空転を防ぐためだ。あるラウンドが 220 件のメッセージを 220 件のまま圧縮しつつ token を 288k から 183k に減らしても、行数で判定すると「これ以上縮まらない」と誤報して auto-reset を引き起こしてしまう。
境界と失敗
- マルチモーダルメッセージで notes が消える:
compose_user_api_contentは非文字列コンテンツに対してNoneを返す。ユーザーがこのラウンドで画像を送ったとき、sidecar 形式の notes は黙って失われる。append_notes_to_multimodal_contentは notes を text part として直接 append するフォールバック (fallback) で、代償としてこの部分は 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 がポインタを再整列させる。