Compresión (compression) de contexto (context) y MoA
Responsabilidad
Dos tareas: ① cuando el historial se acerca al límite de contexto del provider, resumir/podar mensajes viejos para liberar presupuesto (budget) (context_compressor); ② opcionalmente, agregar la salida de varios modelos vía una ruta Mixture-of-Agents (moa_loop). Ambas se invocan dentro del bucle principal; son los componentes de soporte que «permiten que las conversaciones largas sigan corriendo» y «hacen las respuestas más estables».
Archivos clave
módulo context_compressor— entrada de compresión (unas 3700 líneas)estimación de presupuesto:190-242—_estimate_msg_budget_tokens/_serialized_length_for_budgetlimpieza de mensajes:136-155—_fresh_compaction_message_copy/_strip_persistence_markersextracción de llamadas a herramienta (tool) y menciones de ruta:337-360—_extract_tool_call_name_and_args/_collect_path_mentionsrecorte de contenido:472-515—_append_text_to_content/_strip_image_parts_from_partsmoa_loop— agregación Mixture-of-AgentsIterationBudget— reembolso de compresión y enlace con el presupuesto
Flujo de datos
- El bucle principal evalúa cada turno (turn) si hace falta comprimir (ver
_should_run_preflight_estimate:213). - Al alcanzar el umbral, llama a context_compressor:
- estima los tokens que ocupa cada mensaje (
agent/context_compressor.py:419) - hace una copia limpia de los mensajes viejos (
agent/context_compressor.py:136), quitando marcas de persistencia (agent/context_compressor.py:155) - conserva nombres/argumentos de llamadas a herramienta y menciones de ruta, para que el resumen no pierda operaciones clave (
agent/context_compressor.py:337) - recorta fragmentos de imagen y contenido excesivamente largo (
agent/context_compressor.py:472)
- estima los tokens que ocupa cada mensaje (
- El «nuevo espacio» liberado por la compresión se reembolsa parcialmente vía
iteration_budget.refund()(agent/iteration_budget.py), permitiendo al bucle principal correr unos turnos más. - Si MoA está activo,
moa_loopllama en paralelo a varios providers y los agrega, como mejora o árbitro de la respuesta principal.
La estimación del presupuesto es la base de la decisión de comprimir. Aquí está el núcleo de agent/context_compressor.py:419-437:
def _estimate_msg_budget_tokens(msg: dict) -> int:
"""Counts the full ``tool_call`` envelope, not just ``function.arguments``
— counting only the arguments undercounted parallel-tool turns by 2-15x
(#28053). Also counts provider replay fields (``codex_reasoning_items``)."""
content_len = _content_length_for_budget(msg.get("content") or "")
tokens = content_len // _CHARS_PER_TOKEN + 10 # +10 for role/key overhead
for tc in msg.get("tool_calls") or []:
if isinstance(tc, dict):
tokens += len(str(tc)) // _CHARS_PER_TOKEN
for key in _REPLAY_BUDGET_KEYS:
tokens += _serialized_length_for_budget(msg.get(key)) // _CHARS_PER_TOKEN
return tokensDos pozos por los que ya se pasó quedan en el comentario: ① contar solo function.arguments subestima entre 2x y 15x en turnos con llamadas paralelas a herramientas; ② no contar campos de replay como codex_reasoning_items deja mensajes assistant «que parecen pequeños pero son enormes» mal protegidos: la compresión termina demasiado cerca del límite y se vuelve a comprimir una y otra vez (#55572).
La limpieza y el stripping de marcas de persistencia están en agent/context_compressor.py:136-160:
def _fresh_compaction_message_copy(msg: Dict[str, Any]) -> Dict[str, Any]:
"""Shallow .copy() propagates ``_db_persisted``; new child session then
skips every row (#57491). Strip at every copy site."""
fresh = msg.copy()
fresh.pop(_DB_PERSISTED_MARKER, None)
return fresh
def _strip_persistence_markers(messages: List[Dict[str, Any]]) -> None:
"""Terminal sweep — invariant holds regardless of intermediate copy sites."""
for msg in messages:
if isinstance(msg, dict):
msg.pop(_DB_PERSISTED_MARKER, None)«Dos líneas de defensa»: _fresh_compaction_message_copy elimina la marca en cada punto de copia; _strip_persistence_markers hace un sweep completo al final de compress(). La primera declara intención; la segunda es un respaldo estructural. El recorte de imágenes (en agent/context_compressor.py:497-515, _strip_image_parts_from_parts) funciona igual: reemplaza los parts de image/image_url/input_image por un placeholder de texto; cuando no hay imagen, devuelve None para que el llamador se salte la sustitución y no genere un list nuevo sin sentido en cada mensaje.
Motivo de diseño
¿Por qué «estimación» en lugar de un conteo exacto de tokens? El tokenizer del provider no es público (sobre todo los campos ocultos de los modelos de reasoning); tiktoken es lento y no precisa. El módulo usa una fórmula fija de estimación por caracteres + 10 caracteres de overhead, suficiente para decidir «¿comprimir o no?» y «¿cómo proteger la cola?» de forma conservadora — no necesita ser exacto, solo usar la misma regla que la estimación preflight (ver _should_run_preflight_estimate en turn_context). Si las dos reglas divergen, aparece el deadlock de «preflight dice que no hace falta, compresión interna dice que no progresa».
¿Por qué conservar el nombre de la llamada a herramienta y las menciones de ruta? Cuando un LLM resume mensajes viejos, suele tragarse operaciones clave del estilo «se llamó a write_file(/tmp/foo.py)». El turno siguiente lee «se habló de un archivo», pero no sabe a qué ruta se escribió, y repite la creación o lee en el lugar equivocado. _extract_tool_call_name_and_args / _collect_path_mentions pegan la información dura junto al summary; el coste son unas líneas extra de tokens, el beneficio es que las llamadas a herramienta posteriores no se quedan amnésicas.
El refund se devuelve a iteration_budget y no se añade directamente al presupuesto para que max_iterations no quede en pura decoración — si cada compresión sumara turnos, una conversación larga podría correr indefinidamente. El refund solo devuelve las iteraciones de llamada a herramienta que la propia compresión ahorró — «el tope sigue siendo el tope».
Límites y fallos
- Compresión que no progresa:
_compression_made_progressexige que el conteo de tokens baje >5% para considerar que hay progreso. Un turno que solo ahorra 2% se marca como «sin progreso» para evitar dar vueltas en vacío; el bucle principal en ese caso hace auto-reset y abre sesión nueva. - Marca de persistencia residual: si la compresión produce un objeto con
_db_persisted, elstate.dbde la child session nueva lo salta y el transcript compactado se pierde por completo en la DB (#57491). El sweep terminal al final decompress()lo cubre. - El summary se lee como nueva instrucción: un modelo débil interpreta el
## Active Taskdel summary como una petición de usuario a la que responder (#11475,#14521)._SUMMARY_END_MARKERañade un separador explícito; en casos límite de alternancia se envuelve además con_MERGED_PRIOR_CONTEXT_HEADER/_MERGED_SUMMARY_DELIMITERpara evitar ambigüedad. - Revivir un prefijo histórico:
_HISTORICAL_SUMMARY_PREFIXESguarda prefijos de versiones anteriores. Al hacer resume a un lineage nuevo, los prefijos viejos se eliminan a la vez, para que una directiva stale no se quede empotrada en el body y siga secuestrando las respuestas.
Resumen
El compresor es el salvavidas de las conversaciones largas; la decisión central es «qué descartar, qué conservar»: prioriza las llamadas a herramienta y las menciones de ruta, porque perderlas dejaría «amnésicos» a los turnos siguientes. MoA es una mejora ortogonal, activable. Ninguno de los dos cambia la estructura del bucle principal; solo se insertan en el momento adecuado.