Skip to content

turn_context とプロンプト組み立て

源码版本v2026.7.20

職務

毎ラウンドの API 呼び出し前に、「履歴メッセージ + このラウンドのユーザー入力 + ゲートウェイ (gateway) 側付加コンテキスト (context) + 圧縮 (compression) マーカー」を組み立て、provider に送る api_messages を生成する。TurnContext がこのラウンドの産物コンテナ、build_turn_context が組み立て入口で、ゲートウェイが集めた notes をマルチモーダル内容に注入する役も持つ。

主要ファイル

データフロー

  1. 主ループは毎ラウンド build_turn_context:268 を呼び、TurnContext を得る。
  2. compose_user_api_content(agent/turn_context.py:44) がユーザー生入力を API 内容に組み立てる。
  3. ゲートウェイ側に notes が蓄積されていれば(添付、コンテキストヒント)、consume_gateway_turn_context_notes(agent/turn_context.py:124) が取り出し、append_notes_to_multimodal_content で注入する。
  4. substitute_api_content(agent/turn_context.py:79) がプレースホルダーを置換し、drop_stale_api_content が古い内容を掃除する。
  5. 前のラウンドで圧縮が起きていれば、reanchor_current_turn_user_idx(agent/turn_context.py:164) がこのラウンドのユーザーメッセージの履歴中位置を修正する。
  6. 組み立てられた api_messagesAPI 呼び出しリトライ副ループ: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 にある。

python
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 がポインタを再整列させる。

非公式コミュニティ学習サイト。MIT ライセンスの NousResearch/hermes-agent ソースに基づく。