turn_context et assemblage du prompt
Responsabilité
Avant chaque appel API, assemble « messages d'historique + entrée utilisateur du tour (turn) + contexte (context) additionnel côté passerelle (gateway) + marqueurs de compression (compression) » en api_messages envoyé au provider. TurnContext est le conteneur produit pour ce tour, build_turn_context en est l'entrée d'assemblage. Il injecte aussi dans le contenu multimodal les notes collectées par la passerelle.
Fichiers clés
class TurnContext:242-268— dataclass du contexte du tourdef build_turn_context:268-400— entrée d'assemblagecompose_user_api_content:44-79— assemblage du contenu du message utilisateursubstitute_api_content:79-101— substitution de placeholdersconsume_gateway_turn_context_notes:124-164— consomme les notes additionnelles de la passerelle et les injecte dans le contenu multimodalreanchor_current_turn_user_idx:164-210— repositionne l'index du message utilisateur du tour (correction post-compression)estimation progression compression:190-242—_compression_made_progress/_should_run_preflight_estimateprompt_builder— assemblage du prompt système (référencé ici)
Flux de données
- La boucle principale appelle
build_turn_context:268à chaque tour et obtient unTurnContext. compose_user_api_content(agent/turn_context.py:44) assemble l'entrée utilisateur brute en contenu API.- Si la passerelle a accumulé des notes (pièces jointes, indices de contexte),
consume_gateway_turn_context_notes(agent/turn_context.py:124) les récupère et les injecte viaappend_notes_to_multimodal_content. substitute_api_content(agent/turn_context.py:79) remplace les placeholders ;drop_stale_api_contentnettoie le contenu périmé.- Si le tour précédent a déclenché une compression,
reanchor_current_turn_user_idx(agent/turn_context.py:164) corrige la position du message utilisateur du tour dans l'historique. - Les
api_messagesassemblés entrent danssous-boucle de retry API:1227.
TurnContext est une dataclass ordinaire, qui regroupe les valeurs calculées par le prologue (champs clés dans agent/turn_context.py:242-268) : user_message est le message du tour après sanitization ; original_user_message conserve le texte brut pour la mémoire et la recherche dans les logs (les nudge ne s'y injectent pas) ; conversation_history peut être mise à None par une compression preflight — la compression ouvre une nouvelle session (session), l'ancien history n'est plus réutilisable ; active_system_prompt est le prompt système caché pour ce tour, potentiellement reconstruit par la compression.
La logique de consommation des notes passerelle est dans 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 ""Une fois lues, on remet à zéro — c'est un pop one-shot, pas un peek. Même si l'agent est mis en cache et réutilisé, les notes non consommées du tour précédent ne fuient pas dans le suivant.
Mot de conception
Pourquoi extraire le prologue en build_turn_context ? À l'origine, toute la préparation « une fois par tour » (guard stdio, reset du compteur de retry, sanitization du user message, injection todo/nudge, construction du prompt système, compression preflight, hook pre_llm_call, préfetch mémoire externe, persistance crash-récup) était inline en tête de run_conversation, près de 130 lignes. Une fois extrait en fonction, la boucle principale ne contient plus que « boucle + retry », lisible sans être interrompue par la logique de préparation ; le prologue devient testable isolément et réutilisable par le chemin sous-agent.
Pourquoi les notes passent-elles par un sidecar du message utilisateur plutôt que par le system prompt ? Le prompt système doit rester byte-stable d'un tour à l'autre — dès qu'il change, le prefix caché précédent est invalidé, et le prompt caching côté provider saute. La passerelle déplace donc le contenu volatile (première auto-présentation, bascule de canal vocal, hint temporaire d'auto-reset) hors du system prompt, et le délivre via le sidecar api_content du message utilisateur — chaque tour peut emporter de nouveaux faits sans casser le hit rate du cache.
_compression_made_progress utilise 5 % comme seuil matériel pour empêcher la rotation à vide : un tour qui compacte 220 messages en 220 mais abaisse les tokens de 288k à 183k serait faussement signalé « ne progresse plus » si l'on ne juge que sur le nombre de lignes, et déclencherait un auto-reset.
Limites et échecs
- Messages multimodal qui perdent les notes :
compose_user_api_contentrenvoieNonepour les contenus non-chaîne ; si l'utilisateur envoie une image, le sidecar notes est silencieusement perdu.append_notes_to_multimodal_contentrattrape en appendant les notes comme text part directement — au prix d'une persistance dans le transcript ; la passerelle ne doit y mettre que du contenu vraiment destiné à être persisté. - Dérive de l'index du message utilisateur après compression : la compacte fusionne les anciens messages en summary, la position du message utilisateur du tour dans
messagesbouge.reanchor_current_turn_user_idxrescanne la liste pour retrouver l'indice ; sans repositionnement, le résultat d'un appel d'outil (tool) serait réinjecté au mauvais endroit. - Peu de messages mais très gros : l'ancienne version de
_should_run_preflight_estimatene regardait que le nombre de messages, et quelques énormes images base64 ne déclenchaient jamais la compression, partant direct au hard overflow (#27405). La nouvelle version ajoute une branche d'estimation char-based, pour que les gros messages tombent aussi dans le seuil. - Notes stale résiduelles :
consume_gateway_turn_context_notesattrape l'échec d'écriture par try/except — même si l'attribut agent est une property en lecture seule, l'échec de remise à zéro n'interrompt pas l'assemblage.
Résumé
turn_context est la « fabrique d'instantanés par tour » : il fige un contexte dynamique en une structure envoyable. Il travaille de pair avec la compression de contexte — une fois la compression effectuée, turn_context est chargé de réaligner les pointeurs.