turn_context & Prompt Assembly
Responsibility
Before each API call, assemble "history messages + this turn's user input + gateway-side attached context + compression markers" into the api_messages sent to the provider. TurnContext is the container for this turn's output; build_turn_context is the assembly entry. It also injects notes collected by the gateway into multimodal content.
Key Files
class TurnContext:242-268— per-turn context dataclassdef build_turn_context:268-400— assembly entrycompose_user_api_content:44-79— user message content assemblysubstitute_api_content:79-101— placeholder substitutionconsume_gateway_turn_context_notes:124-164— consume gateway-side attached notes and inject into multimodal contentreanchor_current_turn_user_idx:164-210— re-anchor this turn's user message index (post-compression fix)compression progress estimate:190-242—_compression_made_progress/_should_run_preflight_estimateprompt_builder— system prompt assembly (referenced by this page)
Data Flow
- Each turn the main loop calls
build_turn_context:268to produce aTurnContext. compose_user_api_content(agent/turn_context.py:44) pieces the user's raw input into API content.- If the gateway side has accumulated notes (from attachments, context hints),
consume_gateway_turn_context_notes(agent/turn_context.py:124) pulls them out and injects them viaappend_notes_to_multimodal_content. substitute_api_content(agent/turn_context.py:79) substitutes placeholders;drop_stale_api_contentcleans stale content.- If the previous turn triggered compression,
reanchor_current_turn_user_idx(agent/turn_context.py:164) fixes this turn's user message position in history. - The assembled
api_messagesentersAPI call retry sub-loop:1227.
TurnContext is a plain dataclass bundling values computed by the prologue (key fields at agent/turn_context.py:242-268): user_message is the sanitized message for this turn; original_user_message keeps the raw text for memory and log lookup (nudge isn't injected into it); conversation_history may be set to None by preflight compression — compression opens a new session, so the old history can't be reused; active_system_prompt is this turn's cached system prompt, also possibly rebuilt by compression.
The gateway-notes consume logic is at 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 ""It clears immediately after reading — this is a one-shot pop, not a peek. Even if the agent is reused from a cache, notes that weren't consumed last turn won't leak into the next.
Design Motivation
Why pull the prologue out into build_turn_context? Originally all the "once-per-turn" prep (stdio guard, retry counter reset, user-message sanitization, todo/nudge injection, system prompt build, preflight compression, pre_llm_call hook, external memory prefetch, crash-recovery persistence) was inlined at the top of run_conversation, nearly 130 lines. Extracting it into a function leaves the main loop as just "loop + retry" — when you read the main loop you're not interrupted by prep logic; the prologue can also be unit-tested in isolation and reused by the subagent path.
Why do notes take the user-message sidecar instead of going into the system prompt? The system prompt has to stay byte-stable across turns — once it changes, the previously cached prefix is invalidated and prompt caching on the provider side is wiped out. The gateway moves volatile content (first-turn self-introduction, voice-channel switch, auto-reset temporary hints) out of the system prompt and ships it via a sidecar on the user message's api_content. Each turn can carry fresh facts without sacrificing cache hit rate.
_compression_made_progress uses 5% as the material threshold to prevent compression from spinning across turns: one turn compresses 220 messages into 220 messages but drops tokens from 288k to 183k — by line count alone it would falsely report "no progress" and trigger an auto-reset.
Boundaries and Failure
- Multimodal messages lose notes:
compose_user_api_contentreturnsNonefor non-string content, so when the user sends an image this turn, sidecar-style notes silently vanish.append_notes_to_multimodal_contentcatches them by appending the notes directly as a text part — the cost is that this content ends up persisted into the transcript, so the gateway side must only put things here that truly need to land on disk. - User-message index drift after compression: Compression folds old messages into a summary, shifting this turn's user message position inside
messages.reanchor_current_turn_user_idxre-scans the list to find the new index; without re-anchoring, tool-call result backfill would target the wrong slot. - Few-but-huge messages: The old
_should_run_preflight_estimateonly looked at message count, so a handful of giant base64 images never triggered compression and ran straight into a hard overflow (#27405). The new version adds a char-based estimate branch so large messages also hit the threshold. - Stale notes residue:
consume_gateway_turn_context_noteswraps the write in try/except — even if the agent attribute is a read-only property, a failed clear won't abort the assembly.
Summary
turn_context is "a per-turn snapshot factory": it freezes dynamic context into a sendable structure. It works in tandem with Context Compression — once compression rewrites history, turn_context re-aligns the pointers.