Skip to content

turn_context & Prompt Assembly

源码版本v2026.7.20

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

Data Flow

  1. Each turn the main loop calls build_turn_context:268 to produce a TurnContext.
  2. compose_user_api_content (agent/turn_context.py:44) pieces the user's raw input into API content.
  3. 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 via append_notes_to_multimodal_content.
  4. substitute_api_content (agent/turn_context.py:79) substitutes placeholders; drop_stale_api_content cleans stale content.
  5. 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.
  6. The assembled api_messages enters API 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:

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 ""

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_content returns None for non-string content, so when the user sends an image this turn, sidecar-style notes silently vanish. append_notes_to_multimodal_content catches 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_idx re-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_estimate only 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_notes wraps 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.

Unofficial community learning site. Content based on the MIT-licensed NousResearch/hermes-agent source.