Skip to content

turn_context y composición del prompt

源码版本v2026.7.20

Responsabilidad

Antes de cada llamada a la API, ensambla «historial + entrada del usuario de este turno (turn) + contexto (context) adicional del gateway + marcas de compresión (compression)» en los api_messages que se envían al provider. TurnContext es el contenedor del producto de este turno; build_turn_context es la entrada de ensamblaje. También inyecta las notas que el gateway recogió dentro del contenido multimodal.

Archivos clave

Flujo de datos

  1. El bucle principal llama cada turno a build_turn_context:268, obteniendo un TurnContext.
  2. compose_user_api_content (agent/turn_context.py:44) ensambla la entrada cruda del usuario en contenido de API.
  3. Si el gateway ha acumulado notas (de adjuntos, indicaciones de contexto), consume_gateway_turn_context_notes (agent/turn_context.py:124) las extrae e inyecta vía append_notes_to_multimodal_content.
  4. substitute_api_content (agent/turn_context.py:79) sustituye placeholders; drop_stale_api_content limpia contenido caducado.
  5. Si el turno anterior sufrió compresión, reanchor_current_turn_user_idx (agent/turn_context.py:164) corrige la posición del mensaje de usuario actual en el historial.
  6. Los api_messages ensamblados entran al subbucle de reintentos de API:1227.

TurnContext es un dataclass común que agrupa en un solo objeto lo calculado por el prologue (campos clave en agent/turn_context.py:242-268): user_message es el mensaje del turno ya sanitizado; original_user_message conserva el texto crudo para consultas de memoria y logs (los nudge no se inyectan ahí); conversation_history puede quedar a None si el preflight comprimió — la compresión abre una sesión (session) nueva y el history viejo ya no sirve; active_system_prompt es el system prompt cacheado para este turno, que la compresción también puede reconstruir.

La lógica de consumo de las notas del gateway está en 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 ""

Tras leerlas, se pone a cero — es un pop de un solo uso, no un peek. Aunque el agent se cachee y se reutilice, las notas no consumidas en un turno no se filtran al siguiente.

Motivo de diseño

¿Por qué se extrajo el prologue a build_turn_context? Antes, todo el trabajo «una vez por turno» (guard de stdio, reset de contadores de retry, sanitización del user message, inyección de todo/nudge, construcción del system prompt, preflight de compresión, hook pre_llm_call, prefetch de memoria externa, persistencia para recovery de crash) estaba inline al principio de run_conversation, casi 130 líneas. Al extraerlo a una función, el bucle principal se queda con «bucle + retry»; leer el bucle ya no te interrumpe con lógica de preparación. El prologue se puede testear por separado y la ruta del subagent lo puede reutilizar.

¿Por qué las notas van por el sidecar del user message y no dentro del system prompt? El system prompt debe ser byte-stable entre turnos — en cuanto cambia, el prefijo cacheado se invalida y el prompt caching del provider se va al garete. El gateway saca del system prompt el contenido volátil (primera autopresentación, cambio de canal de voz, reset automático con prompts temporales) y lo entrega como sidecar del api_content del user message. Así, cada turno puede llevar hechos nuevos sin romper el hit rate de caché.

_compression_made_progress usa 5% como umbral material para evitar que la compresión dé vueltas en vacío durante varios turnos: un turno comprime 220 mensajes a 220 mensajes pero baja los tokens de 288k a 183k; si se juzgara por número de líneas, se reportaría «no progresa» y dispararía un auto-reset.

Límites y fallos

  • Mensajes multimodales pierden las notas: compose_user_api_content devuelve None para contenido no-string; si el usuario envía una imagen, las notas en sidecar se pierden sin ruido. append_notes_to_multimodal_content las añade como text part directamente, a costa de que ese contenido acaba persistido en el transcript — el gateway solo debe poner ahí lo que de verdad necesite quedar en disco.
  • Deriva del índice del user message tras compresión: la compresión sintetiza los mensajes viejos en un summary; la posición del mensaje del usuario actual dentro de messages cambia. reanchor_current_turn_user_idx recorre la lista de nuevo para encontrar el índice; sin ese reanclaje, el rellenado del resultado de la herramienta acabaría en el lugar equivocado.
  • Pocos mensajes pero enormes: la versión vieja de _should_run_preflight_estimate solo miraba el número de mensajes, así que unos cuantos base64 gigantes nunca disparaban la compresión y se iba al hard overflow (#27405). La nueva versión añade una rama de estimación por caracteres para que los mensajes grandes también disparen el umbral.
  • Notas stale que sobreviven: consume_gateway_turn_context_notes envuelve la escritura en try/except — incluso si el atributo del agent es una property read-only, el fallo de puesta a cero no rompe el ensamblaje.

Resumen

turn_context es la «fábrica de instantáneas por turno»: solidifica el contexto dinámico en una estructura enviable. Coordina con la compresión de contexto: tras reescribir el historial, turn_context recoloca los punteros para que vuelvan a apuntar correctamente.

Sitio de aprendizaje comunitario no oficial. Basado en el código fuente de NousResearch/hermes-agent (licencia MIT).