P0 contexto: ventana por modelo + recuperación ante overflow + self-heal del catálogo

Que las conversaciones largas no se rompan ni gasten de más:

Ventana de contexto por modelo (antes: budget estático 120k/200k para todos):
- cost.resolve_context_window: lee context_length del catálogo OpenRouter/DeepSeek
  en Redis, con fallback a litellm. config.budget_for_window deriva el budget de
  la ventana real (window - max_output - reserve). build_context lo aplica por
  turno (param model_id) en vez del fijo de settings.
- Self-heal del catálogo OpenRouter: el admin panel lo cachea con TTL 1h y solo lo
  repuebla al abrir su ventana de IA → en runtime caducaba y se perdían ventana y
  precio. Ahora cost._get_catalog lo refresca solo (fetch público, mismo shape,
  cooldown 5min, TTL 24h). Arregla también el coste (caía al fijo).

Recuperación ante overflow:
- adapters.base.ContextOverflowError; openai_adapter traduce el error de
  context-length del proveedor (init e iteración del stream).
- base.py: retry proactivo que recompacta hasta caber en la ventana ANTES de
  llamar al LLM; si ni así cabe → error accionable (no rompe la sesión).
- engine.py: mensaje user-facing claro (modelo + ventana).

Tests: ventana/budget, self-heal (mockeado), overflow, y sesión REAL de Redis. 106 verdes.

evals/: harness para evaluar al agente acai-code (driver + README + resultados).
Comparativa kimi vs deepseek vs glm (deepseek-v4-pro high = mejor calidad/precio).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jordan
2026-06-20 13:48:19 +01:00
parent 9d11a59fb8
commit 651d61b096
15 changed files with 997 additions and 36 deletions

View File

@@ -7,6 +7,15 @@ from dataclasses import dataclass, field
from typing import Any, AsyncIterator
class ContextOverflowError(Exception):
"""El contexto excede la ventana del modelo (proveedor lo rechazó).
Excepción de dominio para desacoplar el orquestador de litellm: los adapters
la lanzan al detectar un error de context-length, y el loop del agente decide
si reintentar con compactación más agresiva o devolver un error accionable.
"""
@dataclass
class StreamChunk:
"""A single chunk from a streaming model response.

View File

@@ -9,10 +9,36 @@ from typing import Any, AsyncIterator
from openai import AsyncOpenAI
from ..config import settings
from .base import ModelAdapter, ModelConfig, ModelResponse, StreamChunk
from .base import (
ContextOverflowError,
ModelAdapter,
ModelConfig,
ModelResponse,
StreamChunk,
)
logger = logging.getLogger(__name__)
# Señales de que el proveedor rechazó por ventana de contexto. Detectamos por
# tipo (litellm.ContextWindowExceededError) y por mensaje (openai.BadRequestError
# u otros 400), sin acoplar el adapter a litellm con un import duro.
_CONTEXT_OVERFLOW_MARKERS = (
"context_length_exceeded",
"maximum context length",
"context window",
"context length",
"too many tokens",
"reduce the length",
"prompt is too long",
)
def _is_context_overflow(exc: Exception) -> bool:
if type(exc).__name__ in ("ContextWindowExceededError",):
return True
msg = str(getattr(exc, "message", "") or exc).lower()
return any(marker in msg for marker in _CONTEXT_OVERFLOW_MARKERS)
def _estimate_usage(messages: list[dict[str, Any]], output_text: str) -> dict[str, int]:
"""Estimacion de tokens cuando el proveedor no entrega usage (p.ej. LiteLLM
@@ -62,6 +88,26 @@ class OpenAIAdapter(ModelAdapter):
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
config: ModelConfig | None = None,
) -> AsyncIterator[StreamChunk]:
"""Envoltorio que traduce errores de ventana de contexto del proveedor a
`ContextOverflowError` (dominio), tanto si saltan al iniciar el stream
como durante la primera iteración. El loop del agente lo usa para
reintentar con compactación agresiva si aún no emitió nada."""
try:
async for chunk in self._stream_impl(messages, tools, config):
yield chunk
except ContextOverflowError:
raise
except Exception as e:
if _is_context_overflow(e):
raise ContextOverflowError(str(getattr(e, "message", "") or e)) from e
raise
async def _stream_impl(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
config: ModelConfig | None = None,
) -> AsyncIterator[StreamChunk]:
config = config or ModelConfig(
model_id=settings.default_model_id,
@@ -281,7 +327,14 @@ class OpenAIAdapter(ModelAdapter):
"function": {"name": force_tool},
}
response = await self._acreate(kwargs)
try:
response = await self._acreate(kwargs)
except ContextOverflowError:
raise
except Exception as e:
if _is_context_overflow(e):
raise ContextOverflowError(str(getattr(e, "message", "") or e)) from e
raise
choice = response.choices[0]
content = choice.message.content or ""

View File

@@ -155,5 +155,24 @@ class Settings(BaseSettings):
return min(self.compaction_threshold_tokens, self.effective_context_budget)
return max(1, int(self.effective_context_budget * self.compaction_threshold_ratio))
def budget_for_window(self, window: int, max_output: int | None = None) -> int:
"""Budget de contexto para la ventana REAL del modelo activo.
Misma fórmula que `effective_context_budget` (`window - max_output -
reserve`) pero parametrizada por la ventana del modelo del turno. Si la
ventana no es válida, cae al budget estático. Un override explícito
(`context_max_tokens`) siempre manda (lo aplica el caller)."""
if window <= 0:
return self.effective_context_budget
out = self.model_max_output_tokens if max_output is None else max_output
reserve = int(window * self.context_reserve_ratio)
return max(1, window - max(0, out) - max(0, reserve))
def compaction_threshold_for(self, budget: int) -> int:
"""Umbral de compactación para un budget dado (ratio configurable)."""
if self.compaction_threshold_tokens > 0:
return min(self.compaction_threshold_tokens, budget)
return max(1, int(budget * self.compaction_threshold_ratio))
settings = Settings()

View File

@@ -66,13 +66,35 @@ class ContextEngine:
artifacts: list[ArtifactSummary] | None = None,
conversation: list[dict[str, Any]] | None = None,
extra_instructions: str = "",
model_id: str | None = None,
budget_override: int | None = None,
) -> ContextPackage:
"""Build a full ContextPackage for the given agent and session.
The conversation parameter contains real assistant/tool messages
with complete tool results. These go into the messages array,
not the system prompt — like professional agentic tools.
El budget de contexto se deriva de la VENTANA REAL del modelo activo
(`model_id`, formato litellm) vía catálogo/litellm; `budget_override`
fuerza un budget menor (retry agresivo ante overflow).
"""
# Budget del turno: override (retry) → override duro de settings →
# ventana del modelo → fallback estático. Umbral derivado del budget.
from ..orchestrator.cost import resolve_context_window
if budget_override is not None and budget_override > 0:
budget = budget_override
elif settings.context_max_tokens > 0:
budget = settings.context_max_tokens
else:
window = await resolve_context_window(model_id) if model_id else None
budget = (
settings.budget_for_window(window)
if window
else settings.effective_context_budget
)
threshold = settings.compaction_threshold_for(budget)
sections: list[ContextSection] = []
allowed = set(agent.context_sections)
@@ -140,7 +162,7 @@ class ContextEngine:
raw_message_tokens = sum(self._estimate_message_tokens(m) for m in messages)
pre_compaction_section_tokens = sum(estimate_tokens(s.content) for s in sections)
pre_compaction_total = pre_compaction_section_tokens + raw_message_tokens
section_budget = max(1, settings.effective_context_budget - raw_message_tokens)
section_budget = max(1, budget - raw_message_tokens)
# Compact sections only when the full prompt is approaching the target.
section_compaction = {
@@ -155,8 +177,8 @@ class ContextEngine:
}
system_prompt = self._assemble_system_prompt(sections)
system_prompt_tokens = estimate_tokens(system_prompt)
hard_message_budget = max(1, settings.effective_context_budget - system_prompt_tokens)
target_message_budget = max(1, settings.effective_compaction_threshold - system_prompt_tokens)
hard_message_budget = max(1, budget - system_prompt_tokens)
target_message_budget = max(1, threshold - system_prompt_tokens)
message_budget = min(hard_message_budget, target_message_budget)
conversation_compaction = {
"budget_tokens": message_budget,
@@ -170,7 +192,7 @@ class ContextEngine:
}
total_tokens = system_prompt_tokens + raw_message_tokens
if total_tokens > settings.effective_compaction_threshold:
if total_tokens > threshold:
messages, conversation_compaction = self.compactor.compact_conversation(
messages,
max_tokens=message_budget,
@@ -181,10 +203,10 @@ class ContextEngine:
self._estimate_message_tokens(m) for m in messages
)
if total_tokens > settings.effective_context_budget:
if total_tokens > budget:
section_budget = max(
1,
settings.effective_context_budget
budget
- sum(self._estimate_message_tokens(m) for m in messages),
)
sections, section_compaction = self.compactor.compact_sections(
@@ -197,10 +219,10 @@ class ContextEngine:
self._estimate_message_tokens(m) for m in messages
)
if total_tokens > settings.effective_context_budget:
if total_tokens > budget:
hard_message_budget = max(
1,
settings.effective_context_budget - system_prompt_tokens,
budget - system_prompt_tokens,
)
messages, conversation_compaction = self.compactor.compact_conversation(
messages,
@@ -217,6 +239,7 @@ class ContextEngine:
system_prompt=system_prompt,
messages=messages,
total_token_estimate=total_tokens,
budget_tokens=budget,
)
# Guardar contexto completo del último build (solo el último por sesión)
@@ -224,8 +247,8 @@ class ContextEngine:
"system_prompt": system_prompt,
"messages": messages,
"total_tokens": total_tokens,
"budget_tokens": settings.effective_context_budget,
"threshold_tokens": settings.effective_compaction_threshold,
"budget_tokens": budget,
"threshold_tokens": threshold,
"timestamp": time.time(),
}
@@ -258,8 +281,8 @@ class ContextEngine:
"user_message_preview": user_content[:200],
"artifacts_count": len(artifacts) if artifacts else 0,
"conversation_messages": conv_len,
"budget_tokens": settings.effective_context_budget,
"threshold_tokens": settings.effective_compaction_threshold,
"budget_tokens": budget,
"threshold_tokens": threshold,
"message_tokens": conversation_compaction.get("output_tokens", raw_message_tokens),
"message_tokens_before_compaction": raw_message_tokens,
"pre_compaction_tokens": pre_compaction_total,
@@ -268,7 +291,7 @@ class ContextEngine:
"message_budget_tokens": message_budget,
"section_compaction": section_compaction,
"conversation_compaction": conversation_compaction,
"over_budget": total_tokens > settings.effective_context_budget,
"over_budget": total_tokens > budget,
}
history = self._history[session.session_id]

View File

@@ -35,6 +35,10 @@ class ContextPackage(BaseModel):
system_prompt: str = ""
messages: list[dict[str, Any]] = Field(default_factory=list)
total_token_estimate: int = 0
# Budget de contexto (tokens) usado para construir/compactar este paquete —
# derivado de la ventana del modelo activo. Lo usa el loop del agente para
# compactar más agresivo si aún no cabe en la ventana.
budget_tokens: int = 0
def to_messages(self) -> list[dict[str, Any]]:
"""Produce the final messages list for the model adapter."""

View File

@@ -9,9 +9,10 @@ import time
import uuid
from typing import Any, AsyncIterator
from ...adapters.base import ModelAdapter, ModelConfig, StreamChunk
from ...adapters.base import ContextOverflowError, ModelAdapter, ModelConfig, StreamChunk
from ...config import settings
from ...context.engine import ContextEngine
from ..cost import resolve_context_window
from ...mcp.manager import MCPManager
from ...memory.store import MemoryStore
from ...models.agent import AgentProfile
@@ -73,13 +74,41 @@ class BaseAgent:
self._current_conversation = conversation
for step in range(max_steps):
# Build context with real conversation
ctx = await self.context.build_context(
session=session,
agent=self.profile,
artifacts=artifacts,
conversation=conversation,
# Build context with real conversation. El budget se deriva de la
# ventana REAL del modelo activo; si el contexto estimado no cabe ni
# tras compactar, reconstruimos con compactación más agresiva antes
# de llamar al LLM (evita una llamada condenada a fallar). Si ni así
# cabe → ContextOverflowError → error accionable (no rompe la sesión).
model_id = self.profile.model_id or ""
model_window = (
await resolve_context_window(model_id) if model_id else None
)
ctx = None
budget_override: int | None = None
for ctx_attempt in range(3): # intento normal + 2 compactaciones agresivas
ctx = await self.context.build_context(
session=session,
agent=self.profile,
artifacts=artifacts,
conversation=conversation,
model_id=model_id,
budget_override=budget_override,
)
if not model_window or ctx.total_token_estimate <= model_window:
break
# No cabe: compactar al 60% del budget usado en el siguiente intento.
base = ctx.budget_tokens or settings.effective_context_budget
budget_override = max(2048, int(base * 0.6))
else:
raise ContextOverflowError(
"El contexto ({} tokens) supera la ventana del modelo {} ({} "
"tokens). Acorta el mensaje o cambia a un modelo con más "
"contexto.".format(
ctx.total_token_estimate if ctx else "?",
model_id or "(desconocido)",
model_window,
)
)
# Prepare tool definitions. plan_mode "off" oculta acai_plan al
# modelo (toggle del UI desactivado). "force" la expone normalmente.

View File

@@ -10,8 +10,11 @@ Prioridad de fuentes de precio (para que el coste registrado en
from __future__ import annotations
import asyncio
import json
import logging
import time
import urllib.request
import redis.asyncio as redis
@@ -43,25 +46,105 @@ def _get_cfg_redis() -> "redis.Redis":
return _cfg_redis
async def _catalog_price_per_1m(model_id: str | None):
"""(price_in_1m, price_out_1m) del catálogo del panel, o None.
# --- Catálogo con self-heal -------------------------------------------------
# El catálogo OpenRouter lo publica el Forge Admin Panel con TTL de 1h y solo se
# repuebla al abrir su ventana de IA. En runtime (coste y ventana de contexto)
# eso es frágil: si caduca, perdemos precio Y context_length del modelo activo.
# Aquí lo repoblamos nosotros (fetch público a OpenRouter, mismo shape que el
# admin) cuando falta, con un cooldown para no martillear la API. DeepSeek es
# persistente (lo escribe el admin en el arranque) y no necesita self-heal.
_OPENROUTER_URL = "https://openrouter.ai/api/v1/models"
_OPENROUTER_TIMEOUT = 15
_OR_SELFHEAL_TTL = 86_400 # 24h: persiste bastante; el admin lo refresca aparte
_OR_REFRESH_COOLDOWN = 300 # como mucho un fetch / 5 min
_or_last_refresh = [0.0]
model_id viene en formato litellm ("<provider>/<id>"). Separamos el prefijo
de proveedor para elegir el cache y buscar por el id catalogado.
"""
if not model_id or "/" not in model_id:
return None
provider, _, raw_id = model_id.partition("/")
cache_key = _CACHE_KEYS.get(provider)
def _fetch_openrouter_catalog_sync() -> list[dict]:
"""GET público al catálogo OpenRouter, normalizado al MISMO shape que el
admin panel (id, context_length, price_*, supports_reasoning, supports_images).
Filtra a modelos con soporte `tools` (igual que el admin)."""
req = urllib.request.Request(_OPENROUTER_URL, method="GET")
req.add_header("Accept", "application/json")
with urllib.request.urlopen(req, timeout=_OPENROUTER_TIMEOUT) as resp:
payload = json.loads(resp.read().decode("utf-8"))
items = payload.get("data") if isinstance(payload, dict) else None
if not isinstance(items, list):
return []
out: list[dict] = []
for it in items:
if not isinstance(it, dict) or not it.get("id"):
continue
supported = it.get("supported_parameters") or []
if not isinstance(supported, list) or "tools" not in supported:
continue
pricing = it.get("pricing") or {}
try:
pin = float(pricing.get("prompt", 0) or 0) * 1_000_000
pout = float(pricing.get("completion", 0) or 0) * 1_000_000
except (TypeError, ValueError):
pin = pout = 0.0
try:
ctx = int(it.get("context_length") or 0)
except (TypeError, ValueError):
ctx = 0
mods = (it.get("architecture") or {}).get("input_modalities") or []
out.append({
"id": it.get("id"),
"name": it.get("name") or it.get("id"),
"context_length": ctx,
"price_in_1m": pin,
"price_out_1m": pout,
"supports_reasoning": "reasoning" in supported or "include_reasoning" in supported,
"supports_images": isinstance(mods, list) and "image" in mods,
})
return out
async def _get_catalog(provider: str | None) -> list[dict] | None:
"""Catálogo del proveedor desde Redis. Para OpenRouter, si falta (TTL
caducado) lo repuebla en runtime (self-heal con cooldown)."""
cache_key = _CACHE_KEYS.get(provider or "")
if not cache_key:
return None
try:
cached = await _get_cfg_redis().get(cache_key)
if not cached:
return None
models = json.loads(cached)
if cached:
data = json.loads(cached)
if isinstance(data, list):
return data
except Exception as e: # pragma: no cover - defensivo
logger.warning("catálogo %s no disponible para coste: %s", provider, e)
logger.warning("catálogo %s no disponible: %s", provider, e)
if provider != "openrouter":
return None
# Self-heal solo para OpenRouter, con cooldown para no martillear la API.
now = time.time()
if now - _or_last_refresh[0] < _OR_REFRESH_COOLDOWN:
return None
_or_last_refresh[0] = now
try:
models = await asyncio.to_thread(_fetch_openrouter_catalog_sync)
except Exception as e:
logger.warning("self-heal catálogo openrouter falló: %s", e)
return None
if models:
try:
await _get_cfg_redis().set(cache_key, json.dumps(models), ex=_OR_SELFHEAL_TTL)
logger.info("catálogo openrouter repoblado en runtime: %d modelos", len(models))
except Exception:
pass
return models
return None
async def _catalog_price_per_1m(model_id: str | None):
"""(price_in_1m, price_out_1m) del catálogo, o None. model_id en formato
litellm ("<provider>/<id>")."""
if not model_id or "/" not in model_id:
return None
provider, _, raw_id = model_id.partition("/")
models = await _get_catalog(provider)
if not models:
return None
for m in models:
if m.get("id") == raw_id:
@@ -72,6 +155,59 @@ async def _catalog_price_per_1m(model_id: str | None):
return None
# --- Ventana de contexto por modelo -----------------------------------------
# Cache en proceso con TTL corto: build_context resuelve la ventana en cada step
# del loop, y el catálogo cambia rara vez. Evita pegar a Redis 25x/turno.
_window_cache: dict[str, tuple[float, int | None]] = {}
_WINDOW_TTL = 60.0
async def resolve_context_window(model_id: str | None) -> int | None:
"""Ventana de contexto (tokens) del modelo activo.
Fuentes en orden: catálogo del Forge Admin Panel en Redis (`context_length`)
→ price/info map de LiteLLM (`max_input_tokens`/`max_tokens`) → None.
`model_id` viene en formato litellm ("<provider>/<id>").
"""
if not model_id or "/" not in model_id:
return None
now = time.time()
cached = _window_cache.get(model_id)
if cached and (now - cached[0]) < _WINDOW_TTL:
return cached[1]
window: int | None = None
# 1. Catálogo del panel (con self-heal para OpenRouter si caducó).
provider, _, raw_id = model_id.partition("/")
models = await _get_catalog(provider)
if models:
for m in models:
if m.get("id") == raw_id:
cl = m.get("context_length")
if isinstance(cl, int) and cl > 0:
window = cl
break
# 2. Fallback: LiteLLM conoce muchos modelos (deepseek/, anthropic/, ...).
if window is None:
try:
import litellm
info = litellm.get_model_info(model_id) or {}
for key in ("max_input_tokens", "max_tokens"):
v = info.get(key)
if isinstance(v, int) and v > 0:
window = v
break
except Exception:
pass
_window_cache[model_id] = (now, window)
return window
async def compute_cost(model_id: str | None, input_tokens: int, output_tokens: int) -> dict:
"""Coste de una ejecución para `model_id` y los tokens dados.

View File

@@ -11,7 +11,7 @@ import logging
import re
from typing import Any
from ..adapters.base import ModelAdapter
from ..adapters.base import ContextOverflowError, ModelAdapter
from ..config import settings
from ..context.engine import ContextEngine
from ..context.compactor import ContextCompactor, estimate_tokens
@@ -75,6 +75,20 @@ class OrchestratorEngine:
session_id=session.session_id,
)
return self._error_result(session, "Execution timed out")
except ContextOverflowError as e:
# El contexto no cabe en la ventana del modelo ni tras compactar al
# máximo. Mensaje accionable (no fallo genérico de plataforma): el
# usuario sabe qué hacer (acortar o cambiar de modelo).
logger.warning("Context overflow for session %s: %s", session.session_id, e)
if session.current_task:
session.current_task.mark_failed(str(e))
session.status = SessionStatus.ERROR
await self.sse.emit(
EventType.ERROR,
{"error": "context_overflow", "message": str(e)},
session_id=session.session_id,
)
return self._error_result(session, str(e))
except Exception as e:
logger.exception("Unhandled error for session %s", session.session_id)
if session.current_task: