Compare commits

..

9 Commits

Author SHA1 Message Date
Jordan
d475845c27 Ajustes de links en docs 2026-06-22 14:04:28 +01:00
Jordan
941040d534 Ajustes de docs headfield 2026-06-22 14:01:45 +01:00
Jordan
037bc81936 Reportar modelo real en no-streaming + prewarm de mcp-server-fetch
- engine.py: process_message ahora incluye model/modelUsage en el dict de
  retorno (no solo en el evento SSE), para que el camino no-streaming
  (cronjobs -> _report_usage) reporte el modelo real a consumo_acaicode en
  vez de "unknown".
- Dockerfile: precalentar `uvx mcp-server-fetch` en build (como appuser) para
  que la cache de uv quede en la imagen y el MCP fetch no se quede sin arrancar
  por timeout en frio tras un rebuild.
- mcp.json: startup_timeout de fetch 15 -> 30s como margen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 13:20:51 +01:00
Jordan
882d578960 Reconexión: persistir 'executing' + objetivo al inicio del turno
Para que un reattach (tras recargar el frontend a mitad de turno) detecte que
hay un turno en curso, se persiste status=EXECUTING + current_objective ANTES
de la ejecución larga (el estado final lo sigue guardando el finally). Además
get_session expone el objetivo desde metadata mientras status==executing, ya
que current_task aún no está persistido durante el turno.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 16:44:36 +01:00
Jordan
651d61b096 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>
2026-06-20 13:48:19 +01:00
Jordan
9d11a59fb8 upload_record_image: aceptar ruta relativa del proyecto (sin base64)
Para una imagen local/pegada desde vscode: guardarla en una carpeta
sincronizada NO truncada (cms/uploads/chat/ o cms/uploads/generated/),
dejar que el sync la suba a test y pasar su RUTA RELATIVA como imageUrl.
El server lee los bytes de disco vía resolve_image_source — cero base64
por el contexto del modelo, cero URLs localhost inalcanzables.

- Validación relajada: además de http(s) y ruta absoluta, se acepta ruta
  relativa del proyecto (sin esquema, sin "..", <=512 chars, charset de
  ruta) → sigue rechazando data-URI/base64 crudo.
- Descripciones de upload_record_image / replace_record_image actualizadas
  con el flujo correcto.
- resolve_image_source y el aislamiento de entorno: sin cambios (la ruta
  relativa la resuelve por modo+stub, igual para chat y vscode).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 20:14:16 +01:00
Jordan
5dc2dbcf4a analyze/upload vía /api/image-bytes + MCP HTTP (vscode) forzado a test
Imágenes:
- analyze_image y upload resuelven los bytes por el endpoint Python
  /api/image-bytes (pythonGetBinary). analyze_image enruta los dominios
  forge (env ACAI_FORGE_DOMAIN) al endpoint en vez de fetch directo (que
  daba ECONNREFUSED 127.0.0.1 dentro del container).

Aislamiento de entorno (vscode = solo test):
- resolveCurrentModeOverride(): sesión MCP HTTP (mcpSessionId presente) →
  "local"; stdio (chat/cron) → ACAI_MODE_OVERRIDE de entorno. Lo usan los
  builders de headers (pythonServerClient, files/helpers) → toda tool del
  MCP HTTP manda X-Acai-Mode: local.
- httpServer.resolveProjectCredentials fuerza forceMode:"local" al resolver
  project-info → la sesión obtiene web_url/api_web_url forge-local y opera
  siempre contra test, nunca producción.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:11:50 +01:00
Jordan
5883473e92 Runtime IA: modelo dinámico, razonamiento, coste por modelo y visión nativa
- Resolución dinámica del modelo por sesión (model_resolver): override de
  usuario (metadata) → default global (Redis db 0 acai:config:ai:*) → fallback.
  Mapea a string litellm; LiteLLMAdapter respeta el modelo por request y
  enruta openrouter/* con OPENROUTER_API_KEY del entorno.
- Razonamiento: reasoning_effort por sesión en ModelConfig/AgentProfile,
  aplicado al agente y al planner.
- Coste: cost.py calcula por modelo (catálogo OpenRouter/DeepSeek en Redis →
  litellm → fijo) y emite tarifas + modelo usado en EXECUTION_COMPLETED.
- Visión nativa: imágenes como bloques image_url en el turno del usuario
  (TaskState.image_attachments → Context Engine → adapter), con persistencia
  en recent_messages y conteo de tokens de imagen (~1500).
- El turno no se pierde al cancelar: se persiste el mensaje del usuario + marca
  de interrupción para que un "vuelve a intentarlo" tenga contexto.
- Fix analyze_image: preservar el subdirectorio de usuario del chat-upload
  (basename descartaba "<user>/" → not found).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 14:47:55 +01:00
Jordan Diaz
4543300101 Fix upload_image_to_assets 404 en Forge (header Host) + guard data-URI
- saveFileBuilder (fileBuilder.js) hacía POST directo a viewer_functions.php
  sin header Host -> en Forge (api_web_url interno http://web:80) Apache
  servía el vhost por defecto -> 404. Ahora delega en
  AcaiHttpClient.postViewerAction, que resuelve api_web_url + Host:
  forge_host (igual que el resto de tools). Pasa credentials completo.
- upload_record_image: rechaza data-URI/base64 con error claro (antes
  derivaba el nombre del base64 -> "File name too long" en mcp_respond.php).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 09:29:17 +00:00
35 changed files with 1604 additions and 190 deletions

View File

@@ -56,6 +56,13 @@ USER appuser
# Descargar Chromium como appuser (queda en ~/.cache/ms-playwright/)
RUN cd mcp-server && npx playwright install chromium
# Precalentar mcp-server-fetch como appuser: uvx descarga ~43 paquetes la
# primera vez, lo que en frio supera el startup_timeout del MCP. Lo dejamos
# cacheado en ~/.cache/uv dentro de la imagen para que arranque rapido en
# runtime (igual que Chromium). El server lee stdin; con </dev/null sale tras
# instalar. `|| true` para no romper el build si sale != 0.
RUN timeout 180 uvx mcp-server-fetch </dev/null >/dev/null 2>&1 || true
EXPOSE 8000
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -55,12 +55,9 @@ Reglas obligatorias:
Genera 2 variables: la estándar y `_tag` con la etiqueta elegida (h1…h6).
```html
<{{ titulo_tag | default('h2') }}
data-field-type="headfield"
data-field-label="Título Sección"
class="text-3xl font-bold">
<p data-field-type="headfield" data-field-label="Titulo" >
Título de la sección
</{{ titulo_tag | default('h2') }}>
</p>
```
### textbox
@@ -84,9 +81,10 @@ Editor de texto enriquecido. Acceder con `| raw` para no escapar el HTML.
### link
El campo `enlace` de Acai ya incluye las barras necesarias — nunca añadas barras extra.
Genera 2 variables: la estándar y `_anchor` con el anchor del enlace.
```html
<a data-field-type="link" data-field-label="Enlace Principal" href="#">
<a data-field-type="link" data-field-label="Enlace">
Haz clic aquí
</a>
```

2
evals/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
# Los logs de sesión contienen contenido real de proyectos de cliente.
logs/

43
evals/README.md Normal file
View File

@@ -0,0 +1,43 @@
# Evals del agente acai-code
Harness para evaluar el comportamiento del agente IA (`acai`) montando una
landing real con módulos gestionables, capturando cada turno (thinking, tool
calls, resultados, errores). Sirve para **comparar entre modelos** y discernir
si un fallo es del **modelo** o de la **documentación/KB** (mismo flujo, mismo
proyecto, distinto modelo → ¿cambian los errores?).
## Cómo correrlo
1. Elige el modelo activo en el **Forge Admin Panel → ventana de IA** (provider +
modelo + reasoning). El catálogo OpenRouter se auto-repuebla en runtime aunque
caduque (ver `orchestrator/cost.py: _get_catalog`).
2. Usa un proyecto **en modo TEST** (no producción) — el agente escribe módulos/
records reales en la copia forge-local. Nunca corras esto contra producción.
3. Lanza cada turno con el driver, reutilizando el `session_id` que devuelve el
primer turno para mantener la MISMA conversación:
```bash
NET=acai-vscode-plugin_acai-net # red docker del compose
docker run --rm --network $NET \
-v "$PWD/agenticSystem/evals:/data" -v "$PWD/agenticSystem/evals/logs:/logs" \
-e EVAL_PROJECT=empleo.cocosolution.com \
-w /data acai-vscode-plugin-agentic \
python /data/driver.py "Móntame una sección de beneficios con 3 tarjetas"
# turno 2 (reusa el SESSION_ID del turno 1):
docker run ... python /data/driver.py "Ahora una sección de equipo con fotos y enlaces" "<SESSION_ID>"
```
- El log completo (en vivo) se acumula en `evals/logs/session.log`.
- El driver autentica con `X-Acai-User` hiteando `app:9091` directo en la red
interna (somos superadmin en infra de confianza).
## Métricas que captura
- nº de tool calls, errores (`success:false`, HTTP_4xx), tools repetidas (señal
de bucle), tokens de input/output (coste del thrashing).
## Resultados
Ver [`results-landing-build.md`](./results-landing-build.md) — un apartado por
modelo, para comparar.

148
evals/driver.py Normal file
View File

@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Driver de evaluación del agente acai-code (chat agentic).
Manda UN mensaje de usuario al chat, consume el SSE, loguea EN VIVO cada
tool/resultado/error y resume el turno. Reutiliza session_id para mantener la
MISMA conversación a lo largo de varios turnos.
Uso (dentro de la red docker, hitea `app` directo con auth interna X-Acai-User):
docker run --rm --network <proj>_acai-net \\
-v "$PWD/agenticSystem/evals:/data" -v "$PWD/agenticSystem/evals/logs:/logs" \\
-w /data acai-vscode-plugin-agentic \\
python /data/driver.py "<mensaje del usuario>" "<session_id opcional>"
Variables de entorno opcionales: EVAL_PROJECT (slug), EVAL_USER (default superadmin).
Sirve para comparar el comportamiento/errores del MISMO flujo entre distintos
modelos (cambia el modelo activo en el admin panel y repite). Ver README.md.
"""
import os
import sys
import json
import time
import urllib.request
APP = os.environ.get("EVAL_APP", "http://app:9091")
USER = os.environ.get("EVAL_USER", "superadmin")
PROJECT = os.environ.get("EVAL_PROJECT", "empleo.cocosolution.com")
LOG = os.environ.get("EVAL_LOG", "/logs/session.log")
msg = sys.argv[1]
session_id = sys.argv[2] if len(sys.argv) > 2 else ""
def log(s):
with open(LOG, "a") as f:
f.write(s + "\n")
f.flush()
body = {"project": PROJECT, "message": msg, "agent_id": "acai", "plan_mode": "off"}
if session_id:
body["session_id"] = session_id
req = urllib.request.Request(
APP + "/api/agentic/chat",
data=json.dumps(body).encode(),
headers={"Content-Type": "application/json", "X-Acai-User": USER},
method="POST",
)
log("\n" + "=" * 80)
log("[{}] >>> USER: {}".format(time.strftime("%H:%M:%S"), msg))
sid = session_id
text_parts = []
thinking_chars = 0
tool_calls = []
tool_results = {}
errors = []
usage = {}
seen = {}
# IMPORTANTE: el agentic re-emite el snapshot `assistant` con TODOS los bloques
# acumulados tras cada tool (reconciliación, claude_format.py). Hay que
# deduplicar por `tool_use` id o se cuenta el mismo tool decenas de veces.
seen_ids = set()
try:
resp = urllib.request.urlopen(req, timeout=1200)
except Exception as e:
log("!!! HTTP ERROR: {}".format(e))
print("HTTP_ERROR", e)
sys.exit(1)
for raw in resp:
line = raw.decode("utf-8", "replace").rstrip("\r\n")
if not line.startswith("data: "):
continue
payload = line[6:].strip()
if not payload:
continue
try:
ev = json.loads(payload)
except Exception:
continue
t = ev.get("type")
if t == "session":
sid = ev.get("session_id") or sid
elif t == "stream_event":
e = ev.get("event", {})
et = e.get("type")
if et == "content_block_delta":
d = e.get("delta", {})
if d.get("type") == "text_delta" or "text" in d:
text_parts.append(d.get("text", ""))
elif d.get("type") == "thinking_delta":
thinking_chars += len(d.get("thinking", ""))
elif t == "assistant":
for blk in ev.get("message", {}).get("content", []):
if blk.get("type") != "tool_use":
continue
bid = blk.get("id") or ""
if bid and bid in seen_ids:
continue # snapshot de reconciliación re-emite bloques ya vistos
if bid:
seen_ids.add(bid)
name = blk.get("name", "?")
inp = json.dumps(blk.get("input", {}), ensure_ascii=False)
sig = name + "|" + inp[:200]
seen[sig] = seen.get(sig, 0) + 1 # repeticiones REALES (mismo tool+input, otro id)
tool_calls.append((name, inp, bid))
rep = " [REPETIDA x{}]".format(seen[sig]) if seen[sig] >= 2 else ""
log(" [{}] TOOL {} {}{}".format(time.strftime("%H:%M:%S"), name, inp[:300], rep))
elif t == "tool_result":
tid = ev.get("tool_use_id")
content = ev.get("content")
cstr = content if isinstance(content, str) else json.dumps(content, ensure_ascii=False)
is_err = bool(ev.get("is_error")) or ('"success": false' in cstr) or ('"success":false' in cstr)
tool_results[tid] = (is_err, cstr[:500])
log(" ->{} {}".format(" [ERROR]" if is_err else " ok", cstr[:300]))
if is_err:
errors.append("TOOL_ERROR: " + cstr[:300])
elif t == "result":
usage = ev.get("usage", {}) or {}
if ev.get("content") and not text_parts:
text_parts.append(ev["content"])
elif t == "error":
errors.append("STREAM_ERROR: " + str(ev.get("error")))
log(" !! STREAM_ERROR: " + str(ev.get("error"))[:300])
elif t == "done":
break
full_text = "".join(text_parts)
repeated = {s: c for s, c in seen.items() if c >= 2}
log("[ASSISTANT] " + full_text[:1500])
log("[resumen] tools={} errores={} repetidas={} thinking~{}c usage in={} out={}".format(
len(tool_calls), len(errors), len(repeated), thinking_chars,
usage.get("input_tokens"), usage.get("output_tokens")))
print("SESSION_ID={}".format(sid))
print("TOOLS={} ERRORS={} REPEATED={}".format(len(tool_calls), len(errors), len(repeated)))
for (name, inp, tid) in tool_calls:
res = tool_results.get(tid)
print(" - {}{} {}".format(name, " [ERR]" if (res and res[0]) else "", inp[:110]))
for e in errors:
print(" !! " + e[:220])
print("ASSISTANT:", full_text[:1400])
print("USAGE in={} out={}".format(usage.get("input_tokens"), usage.get("output_tokens")))

View File

@@ -0,0 +1,156 @@
# Resultados — eval "montar landing" (acai-code)
Flujo fijo de 3 turnos sobre el proyecto **empleo.cocosolution.com (en TEST)**:
1. **T1** — sección sencilla: "Beneficios" con 3 tarjetas (icono, título, texto).
2. **T2** — módulo complejo: "Conoce al equipo", multi-registro v2, 3 personas con
**foto generada + nombre + puesto + testimonio + enlace LinkedIn**.
3. **T3** — edición: cambiar el puesto de una persona y borrar otra tarjeta.
Objetivo: comparar entre modelos para ver si los fallos son **del modelo** o de la
**KB/docs** (mismo flujo → si todos fallan igual, es la doc; si solo uno, es el modelo).
## Comparativa entre modelos
> ⚠️ **Corrección metodológica (importante).** Mi primera versión contaba los `tool_use`
> de los snapshots `assistant` del SSE. El agentic **re-emite el snapshot con todos los
> bloques acumulados tras cada tool** (`claude_format.py:_build_assistant_snapshot`), así que
> el mismo tool se contaba muchas veces → **los conteos de tool calls estaban inflados**
> (p.ej. "30 generate_image" cuando el `consumo_acaicode` real era **3**). El driver ya
> deduplica por id. **Solo son fiables: tokens de `result.usage`, `consumo_acaicode`, y el
> razonamiento del propio modelo.** Abajo solo se usan esas señales.
| Modelo | Fecha | Tareas OK | Tokens in (3 turnos) | Resolvió record de página | Calidad observada (razonamiento) |
|---|---|---|---|---|---|
| `openrouter/moonshotai/kimi-k2.7-code` (medium) | 2026-06-20 | 3/3 | **~2,66M** | **NO — alucinó `num=1`** | Actúa, falla, reintenta. Edita código a ciegas (`line_replace` no casa). Mucho thrashing. |
| `deepseek/deepseek-v4-pro` (high) | 2026-06-20 | 3/3 | **~649k** | `num=267` ✅ | Explora a fondo y acierta. 0 errores. Maneja ambigüedad (Laura→Elena). |
| `z-ai/glm-5.2` (high) | 2026-06-20 | 3/3 | **~720k** | `num=267` ✅ | Sólido. Autocorrige (Twig `=``==`; fotos cruzadas al borrar). Maneja ambigüedad. |
Imágenes generadas (de `consumo_acaicode`): **3 por turno en los 3 modelos** — correcto, una por
persona. **No hubo sobre-generación** (era artefacto de medición).
## Conclusión modelo vs KB (3 modelos, mismo flujo, misma KB)
- **Señal autoritativa = tokens.** kimi gasta **~4× más** (~2,66M vs ~650720k) para la MISMA
tarea → reintentos/thrashing reales (cada step reenvía contexto). Es el indicador más fiable.
- **`num=1` alucinado → MODELO (kimi).** Deepseek **y** GLM, con la **misma KB**, resolvieron el
record de la página correctamente (lo dicen en su propio razonamiento). Kimi no. **Definitivo:
es kimi, no la documentación.**
- **NO hay evidencia de un problema de KB en el flujo multi-registro/imágenes.** Lo que parecía
sobre-generación (×30) era mi bug de conteo; los tres modelos generaron 3 imágenes (correcto).
**Retirada** la "acción de KB #1" anterior.
- **Bug real encontrado por GLM:** al borrar un registro de un módulo multi-registro, el sistema
reutiliza nums y **las fotos quedan cruzadas**; GLM lo detectó y corrigió. Merece revisar el
flujo delete/reorder (plataforma).
- **Calidad de modelo:** kimi es claramente el más flojo; **deepseek-v4-pro y GLM-5.2 (high) son
sólidos y comparables**.
**Acciones sugeridas:** (1) **no usar kimi-k2.7 como default**; deepseek-v4-pro o GLM-5.2 (high)
son buenos. (2) Revisar el bug delete→fotos cruzadas. (3) (Opcional) re-medir con el driver
deduplicado si se quieren conteos exactos de tool calls; las conclusiones por tokens no cambian.
---
## kimi-k2.7-code — 2026-06-20
**Veredicto:** entrega las 3 tareas, pero con **mucho thrashing** y errores recurrentes
de los que **no aprende dentro del turno**.
### Por turno
- **T1 (beneficios):** completado. Reutilizó un módulo de tarjetas existente. Errores:
`acai_line_replace``HTTP_409 "Search block not found"` (edita código a ciegas) y
acceso a `record num=1` inexistente en `apartados`. Se recuperó. **1,77M tokens input**
(acumulado de ~9 steps por los reintentos).
- **T2 (equipo, multi-registro v2):** completado (módulo `conocealequipo_coco`, 3 personas,
fotos generadas, enlaces LinkedIn en nueva pestaña). Pero **`add_module_to_record` ×11
sobre el mismo módulo** (bucle en el workflow multi-registro; idempotente, devolvió el
mismo `sectionId` → NO duplicó en la página) y **2 ciclos de generación de imágenes**
(6 `generate_image` para 3 personas). 606k tokens.
- **T3 (edición):** completado limpio (**0 errores**), Carlos→CTO + Laura eliminada. Pero
**7× `list_record_uploads`** redundante. 284k tokens.
### Inventario de errores (sesión completa)
| Error | Veces | Diagnóstico |
|---|---:|---|
| `Record num=1 not found in 'apartados'` | **52** | Alucina el record de la página (real = `num=267`). **No aprende** del error y reintenta con `num=1`. |
| `Search block not found` (HTTP_409, `acai_line_replace`) | 22 | Genera bloques de búsqueda que no casan con el fichero; edita código sin verlo bien. |
| `add_module_to_record` mismo módulo | 11 | Bucle en el workflow multi-registro v2. |
- 139 tool calls · ~74 `success:false` · 148 llamadas marcadas como repetidas.
### ¿Modelo o KB? (hipótesis a confirmar con otros modelos)
- **`num=1` (×52):** huele a **KB** — falta una regla clara de "obtén el `num` real de la
página con `list_table_records` antes de operar; nunca asumas num=1". Si otros modelos
caen igual → es la doc.
- **multi-registro v2 (bucle):** probablemente **KB** — falta un doc de "cómo añadir N
registros a un módulo repetible".
- **`line_replace` a ciegas:** mezcla — la KB debería exigir `acai_view` previo y casar
exacto.
### Notas de contexto / coste (P0)
- **Cero overflow** en los 3 turnos pese a 1,77M tokens acumulados/turno → no se rompió.
- La ventana real de kimi es **262144** (256k). El catálogo OpenRouter había **caducado**
(TTL 1h) → al principio se usó budget estático; tras el self-heal (`cost.py`) ya resuelve
la ventana real. Coste real de kimi: ~$0.61 in / $3.07 out por 1M.
---
## deepseek-v4-pro (high) — 2026-06-20
**Veredicto: ELEGIDO** (mejor relación calidad/precio). 3/3 tareas, **0 errores**, eficiente y
**cauto ante acciones destructivas ambiguas**.
### Re-medición con driver deduplicado (números AUTORITATIVOS, baseline limpio)
| Turno | Tool calls (reales) | Errores | `generate_image` | Tokens in |
|---|---:|---:|---:|---:|
| T1 beneficios | **19** | 0 | 3 | 264k |
| T2 equipo (multi-registro) | **14** | 0 | 3 | 320k |
| T3 edición ambigua | **1** | 0 | 0 | 77k |
| **Total** | **34** | **0** | 6 | **~661k** |
- Imágenes = 3 por módulo (correcto, coincide con `consumo_acaicode`). **Sin thrashing** — los
"135/77/30" de abajo eran del artefacto de conteo, ya corregido.
- **T3 (lo mejor):** ante "quita a Laura Gómez" (no existía; sus personas eran Marina/Carlos/
Lucía), **no adivinó: paró y preguntó** a quién borrar, ofreciendo ya el cambio claro
(Carlos→CTO). Cautela correcta con un borrado ambiguo.
### Por turno (medición ANTIGUA — inflada por el artefacto, ver banner arriba)
- **T1 (beneficios):** 135 tools, **0 err**, 238k tok. Exploró el proyecto (tablas, records,
módulos), **resolvió bien `apartados num=267`**, localizó un módulo de referencia
(`sobrenosotrosbeneficios_8pjhao`) y creó un módulo nuevo con `multiv2`. Renderizó OK.
- **T2 (equipo, multi-registro v2):** 77 tools, **0 err**, 183k tok. Módulo `conocealequipo_j8m3k7`
con 3 personas, fotos y enlaces. **Pero 30 `generate_image` + 8 `add_module_to_record`**
para 3 personas → mismo thrashing del workflow multi-registro/imágenes que kimi (peor en
imágenes).
- **T3 (edición):** 27 tools, **0 err**, 228k tok. Sus personas eran Marina/Carlos/Elena;
ante "quita a Laura" razonó *"no existe Laura, asumo que es Elena"* y la quitó + Carlos→CTO.
Manejo inteligente de la ambigüedad.
- Totales: **239 tools, 0 errores, ~649k tok input** (4× más barato que kimi pese a más calls).
- Ventana real deepseek-v4-pro: **1.000.000**. Coste catálogo: ~$0.435 in / $0.87 out por 1M.
---
## glm-5.2 (high) — 2026-06-20 (baseline limpio)
**Veredicto:** 3/3 tareas. Comportamiento sólido y con **muy buena autocorrección**.
Mismo perfil que deepseek (explora y acierta), no aluciona el record de la página.
### Por turno
- **T1 (beneficios):** 90 tools, 250k tok. Resolvió `apartados num=267` bien. Escribió el
template Twig con `=` en un `c-if` (en vez de `==`) → fallos de `acai_write`/compilación,
pero **se autodiagnosticó** ("el compilador no convierte `=` en este contexto") y lo arregló.
- **T2 (equipo, multi-registro v2):** 77 tools, **0 err**, 232k tok. Módulo `equipococotalento_k8e2qr`.
**30 `generate_image` + 8 `add_module`** para 3 personas — idéntico a deepseek.
- **T3 (edición):** 35 tools, **0 err**, 237k tok. Sus personas eran Diego/Laura Méndez/Carmen;
infirió bien la petición. Detectó que al borrar un registro **las fotos quedaron cruzadas**
(reuso de nums 22726/22727) y las **reemplazó correctamente**.
- Totales: ~202 tools, errores solo en T1 (recuperados), ~720k tok input.
- Ventana real glm-5.2: **1.048.576**. Coste catálogo: ~$1.2 in / $4.1 out por 1M.
### Limpieza pendiente
Tras el reset, empleo (en TEST) tiene solo los módulos de la prueba de GLM:
- módulo de beneficios (`multiv2`) + `equipococotalento_k8e2qr`.
Borrar si no se quieren conservar, y **revertir empleo a producción**.

View File

@@ -11,10 +11,15 @@ import { LOCAL_SERVER_URL, getLocalServerHeaders } from "../config/index.js";
* automaticamente; en modo stdio no se propaga y la logica original se
* mantiene.
*/
export async function fetchProjectInfo(projectName, acaiUser = null) {
export async function fetchProjectInfo(projectName, acaiUser = null, opts = {}) {
const params = typeof projectName === "string" ? { project: projectName } : (projectName || {});
const headers = getLocalServerHeaders();
if (acaiUser) headers["X-Acai-User"] = acaiUser;
// forceMode: fuerza el modo efectivo con el que el server Python resuelve el
// web_url/api_web_url del proyecto. Lo usa el transporte MCP HTTP (plugin VS
// Code) para fijar "local" → la sesion entera apunta al web forge-local
// (test), nunca a produccion, sea cual sea el mode del .acai.
if (opts.forceMode) headers["X-Acai-Mode"] = opts.forceMode;
const response = await axios.get(`${LOCAL_SERVER_URL}/api/project-info`, {
params,
headers,

View File

@@ -76,7 +76,12 @@ const verifyJwt = (token) => {
const resolveProjectCredentials = async (projectName, acaiUser = null) => {
try {
const info = await fetchProjectInfo(projectName, acaiUser);
// El transporte MCP HTTP es exclusivo de clientes externos (plugin VS
// Code Acai Forge). Por politica solo pueden operar sobre TEST: forzamos
// mode=local al resolver el proyecto, de modo que web_url/api_web_url
// apunten al web forge-local y TODA la sesion (records, modules, git,
// media...) use el destino de test, nunca produccion.
const info = await fetchProjectInfo(projectName, acaiUser, { forceMode: "local" });
if (!info.success) {
throw new Error(info.error || "Failed to resolve project info");
}

View File

@@ -3,7 +3,7 @@ import path from "path";
import { LOCAL_SERVER_URL, getLocalServerHeaders } from "../../config/index.js";
import { getCurrentSessionId } from "../../utils/sessionContext.js";
import { getMcpSessionCredentials } from "../../auth/credentials.js";
import { resolveCurrentAcaiUser } from "../helpers/sessionHelpers.js";
import { resolveCurrentAcaiUser, resolveCurrentModeOverride } from "../helpers/sessionHelpers.js";
/**
* Resuelve `project_dir` para la tool en curso.
@@ -38,7 +38,7 @@ export function getCurrentProjectInfo() {
export async function callLocalFileEndpoint(method, endpoint, payload = null, query = null) {
const headers = getLocalServerHeaders();
const authHeader = process.env.ACAI_AUTH_HEADER || "";
const mode = process.env.ACAI_MODE_OVERRIDE || process.env.ACAI_MODE || "";
const mode = resolveCurrentModeOverride();
const role = process.env.ACAI_ROLE_OVERRIDE || "";
if (authHeader) headers["Authorization"] = authHeader;

View File

@@ -1,11 +1,13 @@
import axios from "axios";
import { AcaiHttpClient } from "./acaiHttpClient.js";
/**
* Helper to save files using saveFileBuilder action
* Helper to save files using saveFileBuilder action.
* Delega en AcaiHttpClient.postViewerAction, que construye la URL con
* api_web_url + el header Host (forge_host) y aplica assertSafeCmsTarget.
* Used by multiple tools (save.js, saveGeneralSection.js, write.js, etc.)
*
*
* @param {Object} params
* @param {string} params.web_url - URL base del sitio (ej: http://localhost:PORT)
* @param {Object} params.credentials - Target completo (web_url, api_web_url, forge_host, mode)
* @param {string} params.token - Session token
* @param {string} params.tokenHash - Token hash
* @param {string} params.path - Folder path (e.g., '/modulos/mymodule/')
@@ -14,7 +16,7 @@ import axios from "axios";
* @returns {Promise<Object>} Response from the API
*/
export async function saveFileBuilder({
web_url,
credentials,
token,
tokenHash,
path,
@@ -26,12 +28,7 @@ export async function saveFileBuilder({
return null;
}
const viewerUrl = web_url + '/cms/lib/viewer_functions.php';
const payload = {
action_ws: 'saveFileBuilder',
token: token,
tokenHash: tokenHash,
fileName: fileName,
content: content,
rawDataSended: rawDataSended,
@@ -39,14 +36,17 @@ export async function saveFileBuilder({
path: path
};
console.error(`[saveFileBuilder] URL: ${viewerUrl}`);
console.error(`[saveFileBuilder] Path: ${path}`);
console.error(`[saveFileBuilder] Content length: ${content.length} chars`);
try {
const response = await axios.post(viewerUrl, payload, {
headers: { "Content-Type": "application/json" }
});
const response = await AcaiHttpClient.postViewerAction(
credentials,
'saveFileBuilder',
payload,
token,
tokenHash
);
console.error(`[saveFileBuilder] Response for ${fileName}:`, JSON.stringify(response.data, null, 2));
@@ -69,7 +69,7 @@ export async function saveFileBuilder({
* Helper to save multiple files at once
*
* @param {Object} params
* @param {string} params.web_url - URL base del sitio (ej: http://localhost:PORT)
* @param {Object} params.credentials - Target completo (web_url, api_web_url, forge_host, mode)
* @param {string} params.token - Session token
* @param {string} params.tokenHash - Token hash
* @param {string} params.path - Folder path (e.g., '/modulos/mymodule/')
@@ -77,7 +77,7 @@ export async function saveFileBuilder({
* @returns {Promise<Object>} Results for each file
*/
export async function saveMultipleFiles({
web_url,
credentials,
token,
tokenHash,
path,
@@ -88,7 +88,7 @@ export async function saveMultipleFiles({
for (const [fileName, content] of Object.entries(files)) {
if (content) {
results[fileName] = await saveFileBuilder({
web_url,
credentials,
token,
tokenHash,
path,

View File

@@ -1,5 +1,5 @@
import axios from "axios";
import { resolveCurrentAcaiUser } from "./sessionHelpers.js";
import { resolveCurrentAcaiUser, resolveCurrentModeOverride } from "./sessionHelpers.js";
const PYTHON_BASE = `http://app:${process.env.ACAI_PORT || 9091}`;
@@ -11,7 +11,7 @@ const PYTHON_BASE = `http://app:${process.env.ACAI_PORT || 9091}`;
*/
function buildPythonHeaders(extra = {}) {
const authHeader = process.env.ACAI_AUTH_HEADER || "";
const mode = process.env.ACAI_MODE_OVERRIDE || process.env.ACAI_MODE || "";
const mode = resolveCurrentModeOverride();
const role = process.env.ACAI_ROLE_OVERRIDE || "";
const acaiUser = resolveCurrentAcaiUser();
@@ -43,3 +43,20 @@ export async function pythonGet(path, params = null, timeout = 30000) {
});
return response.data;
}
/**
* GET binario al server Python (p.ej. /api/image-bytes). Devuelve
* { buffer: Buffer, mimeType: string }. Lanza si el status no es 2xx.
*/
export async function pythonGetBinary(path, params = null, timeout = 30000) {
const response = await axios.get(`${PYTHON_BASE}${path}`, {
params: params || undefined,
headers: buildPythonHeaders(),
responseType: "arraybuffer",
timeout,
maxContentLength: Infinity,
});
const mimeType = (response.headers?.["content-type"] || "").split(";")[0].trim()
|| "application/octet-stream";
return { buffer: Buffer.from(response.data), mimeType };
}

View File

@@ -25,3 +25,23 @@ export function resolveCurrentAcaiUser() {
const creds = getMcpSessionCredentials(sessionId);
return creds?.acai_user || null;
}
/**
* Modo efectivo (X-Acai-Mode) para las llamadas al server Python.
*
* Regla de seguridad: una sesion MCP HTTP (mcpSessionId presente) es SIEMPRE un
* cliente externo — en la practica el plugin VS Code Acai Forge — y solo puede
* operar sobre TEST. Por eso forzamos "local" pase lo que pase el .acai del
* proyecto. El server Python honra este header para decidir el destino real
* (BD y ficheros), de modo que vscode nunca toca produccion.
*
* Las sesiones stdio (chat del dashboard / cronjobs) NO tienen mcpSessionId:
* mantienen el override de entorno (ACAI_MODE_OVERRIDE), que puede ser
* "production" cuando corresponde (chat en modo produccion, cron de prod).
*
* @returns {string} "local" | "production" | "" (vacio = usar .acai)
*/
export function resolveCurrentModeOverride() {
if (getCurrentSessionId()) return "local";
return process.env.ACAI_MODE_OVERRIDE || process.env.ACAI_MODE || "";
}

View File

@@ -1,13 +1,38 @@
import { z } from "zod";
import axios from "axios";
import fs from "fs";
import path from "path";
import { withAuth } from "../../auth/index.js";
import { handleToolError } from "../helpers/errorHandler.js";
import { withAuthParams } from "../helpers/authSchema.js";
import { resolveCurrentProjectDir } from "../files/helpers.js";
import { pythonGetBinary } from "../helpers/pythonServerClient.js";
const GEMINI_ENDPOINT = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent";
const CHAT_UPLOADS_DIR = "/opt/acai/chat-uploads";
// Hosts locales NO alcanzables por HTTP desde este container (localhost = el
// propio agentic). Para esas refs y rutas del proyecto, los bytes los resuelve
// el server Python (/api/image-bytes), que decide disco (standalone) vs fetch de
// producción (Acai / stub local).
const LOCAL_HOSTS = ["localhost", "127.0.0.1", "acai-app", "acai-web", "web", "host.docker.internal"];
// Dominio forge del entorno (forge.acai.test en local, forge.acaisuite.com en
// prod). Los hosts forge resuelven (dnsmasq) a 127.0.0.1, que dentro de este
// container es él mismo → un fetch directo da ECONNREFUSED, sea http o https.
// Por eso NO son "remotos": sus bytes los resuelve el server Python
// (/api/image-bytes), que para un proyecto Acai trae el stub desde producción.
// Env-driven, igual que is_local_project_host en Python.
const FORGE_DOMAIN = (process.env.ACAI_FORGE_DOMAIN || "forge.acaisuite.com").toLowerCase();
/**
* ¿Es un host que debe resolverse vía el server Python (no alcanzable / no
* conviene un fetch directo desde este container)? Cubre loopback/hosts Docker
* internos y el dominio forge del entorno.
*/
function isLocalResolvableHost(hostname) {
if (!hostname) return false;
const h = hostname.toLowerCase();
if (LOCAL_HOSTS.includes(h)) return true;
if (FORGE_DOMAIN && (h === FORGE_DOMAIN || h.endsWith("." + FORGE_DOMAIN))) return true;
return false;
}
const DEFAULT_PROMPT = "Describe esta imagen detalladamente, mencionando elementos visuales, texto, layout y proposito aparente.";
/**
@@ -38,51 +63,20 @@ function detectMimeType(filename, buffer) {
}
/**
* Resuelve una URL de chat-preview a una ruta local segura dentro de CHAT_UPLOADS_DIR.
* Acepta `/api/chat-preview?file=xxx` o variantes con host.
*/
function resolveChatPreviewPath(imageUrl) {
let qs;
try {
// Permite tanto absolutas como relativas
const u = imageUrl.startsWith("http")
? new URL(imageUrl)
: new URL(imageUrl, "http://placeholder.local");
if (!u.pathname.startsWith("/api/chat-preview")) return null;
qs = u.searchParams;
} catch {
return null;
}
const fileParam = qs.get("file");
if (!fileParam) return null;
// Sanitizar: evitar traversal — solo nombre base permitido
const safeName = path.basename(fileParam);
if (!safeName || safeName === "." || safeName === "..") return null;
return path.join(CHAT_UPLOADS_DIR, safeName);
}
/**
* Carga la imagen como { mimeType, base64 } desde URL publica o chat-preview local.
* Carga la imagen como { mimeType, base64 }.
* - URL remota real (host público) → fetch directo por HTTP.
* - Adjunto de chat, ruta del proyecto, o URL con host local → los bytes los
* resuelve el server Python (/api/image-bytes): disco para standalone, fetch
* de producción para imágenes Acai cuyo fichero local es un stub.
*/
async function loadImage(imageUrl) {
// Caso 1: chat-preview local
const localPath = resolveChatPreviewPath(imageUrl);
if (localPath) {
if (!fs.existsSync(localPath)) {
throw new Error(`Local chat upload not found: ${path.basename(localPath)}`);
}
const buffer = fs.readFileSync(localPath);
return {
mimeType: detectMimeType(localPath, buffer),
base64: buffer.toString("base64"),
};
}
let parsed = null;
try { parsed = new URL(imageUrl); } catch { parsed = null; }
const isRemote = parsed
&& (parsed.protocol === "http:" || parsed.protocol === "https:")
&& parsed.hostname && !isLocalResolvableHost(parsed.hostname);
// Caso 2: URL publica http(s)
if (imageUrl.startsWith("http://") || imageUrl.startsWith("https://")) {
if (isRemote) {
const response = await axios.get(imageUrl, {
responseType: "arraybuffer",
timeout: 30000,
@@ -93,19 +87,21 @@ async function loadImage(imageUrl) {
const mimeType = headerMime && headerMime.startsWith("image/")
? headerMime
: detectMimeType(imageUrl.split("?")[0], buffer);
return {
mimeType,
base64: buffer.toString("base64"),
};
return { mimeType, base64: buffer.toString("base64") };
}
throw new Error("Unsupported image_url. Use http(s):// or /api/chat-preview?file=...");
const project = path.basename(resolveCurrentProjectDir() || "");
if (!project) {
throw new Error("No hay proyecto activo para resolver la imagen.");
}
const { buffer, mimeType } = await pythonGetBinary("/api/image-bytes", { project, ref: imageUrl });
return { mimeType, base64: buffer.toString("base64") };
}
export function registerAnalyzeImageTool(server) {
server.tool(
"analyze_image",
"Analiza una imagen usando Gemini Vision. Util cuando el usuario adjunta una imagen, despues de un screenshot de Playwright, o para describir cualquier imagen accesible via URL. Devuelve descripcion text del contenido visual.",
"Analiza una imagen usando Gemini Vision. Usala SOLO para imagenes que NO puedes ver directamente (p.ej. una URL/imagen del CMS que no esta adjunta a la conversacion, o un screenshot de Playwright). Si la imagen ya esta adjunta y visible en el mensaje del usuario, descríbela tú mismo SIN llamar a esta tool. Devuelve descripcion text del contenido visual.",
withAuthParams({
image_url: z.string().describe("URL de la imagen. Acepta URL publica http(s):// o ruta relativa /api/chat-preview?file=..."),
prompt: z.string().optional().describe("Que quieres saber de la imagen. Default: descripcion detallada."),

View File

@@ -5,7 +5,7 @@ import { withAuth, getSessionCredentials } from "../../auth/index.js";
import { handleToolError, validateRequired, handleApiResponse } from "../helpers/errorHandler.js";
import { withAuthParams } from "../helpers/authSchema.js";
import { AcaiHttpClient } from "../helpers/acaiHttpClient.js";
import { pythonPost } from "../helpers/pythonServerClient.js";
import { pythonPost, pythonGetBinary } from "../helpers/pythonServerClient.js";
import { resolveCurrentProjectDir } from "../files/helpers.js";
/**
@@ -34,79 +34,43 @@ async function mcpPost(target, actionWs, payload, token, tokenHash) {
* null si la URL no es local (usar imageUrl directamente)
*/
async function resolveLocalImageAsBase64(imageUrl) {
const LOCAL_HOSTS = ["localhost", "127.0.0.1", "acai-app", "host.docker.internal"];
const LOCAL_HOSTS = ["localhost", "127.0.0.1", "acai-app", "acai-web", "web", "host.docker.internal"];
// Caso 1: Path absoluto del filesystem (e.g. /opt/acai/webs/.../cms/uploads/x.jpg)
if (typeof imageUrl === "string" && imageUrl.startsWith("/") && !imageUrl.startsWith("//")) {
try {
if (fs.existsSync(imageUrl) && fs.statSync(imageUrl).isFile()) {
const buffer = fs.readFileSync(imageUrl);
return {
fileBase64: buffer.toString("base64"),
fileName: path.basename(imageUrl),
};
}
} catch (error) {
console.error(`[upload] Failed to read filesystem path ${imageUrl}: ${error.message}`);
}
return null;
// URL http(s) con host NO local → es una URL pública real: usar tal cual (null).
if (typeof imageUrl === "string" && /^https?:\/\//i.test(imageUrl)) {
let parsed;
try { parsed = new URL(imageUrl); } catch { return null; }
if (!LOCAL_HOSTS.includes(parsed.hostname)) return null;
}
// Caso 2: URL HTTP — verificar si es local
let parsed;
// Ruta del proyecto, host local o chat-preview → los bytes los resuelve el
// server Python (/api/image-bytes): disco para standalone, fetch de producción
// para imágenes Acai cuyo fichero local es un stub.
const project = path.basename(resolveCurrentProjectDir() || "");
if (!project) return null;
try {
parsed = new URL(imageUrl);
} catch {
return null;
}
if (!LOCAL_HOSTS.includes(parsed.hostname)) {
return null;
}
// Intento A: descargar via HTTP (funciona cuando el host local es alcanzable)
try {
const axios = (await import("axios")).default;
const response = await axios.get(imageUrl, {
responseType: "arraybuffer",
timeout: 30000,
});
const fileBase64 = Buffer.from(response.data).toString("base64");
const pathname = parsed.pathname || "/image.jpg";
const fileName = pathname.split("/").pop() || "image.jpg";
return { fileBase64, fileName };
} catch (httpError) {
console.error(`[upload] HTTP fetch failed for ${imageUrl}: ${httpError.message}. Trying filesystem fallback.`);
}
// Intento B: resolver el pathname contra ACAI_PROJECT_DIR y leer del disco
const projectDir = resolveCurrentProjectDir();
if (projectDir && parsed.pathname) {
const { buffer } = await pythonGetBinary("/api/image-bytes", { project, ref: imageUrl });
let fileName = "image.jpg";
try {
const localPath = path.join(projectDir, parsed.pathname);
if (fs.existsSync(localPath) && fs.statSync(localPath).isFile()) {
const buffer = fs.readFileSync(localPath);
return {
fileBase64: buffer.toString("base64"),
fileName: path.basename(localPath),
};
}
} catch (error) {
console.error(`[upload] Filesystem fallback failed for ${imageUrl}: ${error.message}`);
}
const p = imageUrl.startsWith("/") ? imageUrl : new URL(imageUrl).pathname;
fileName = (p.split("?")[0].split("/").pop()) || "image.jpg";
} catch { /* keep default */ }
return { fileBase64: buffer.toString("base64"), fileName };
} catch (error) {
console.error(`[upload] /api/image-bytes falló para ${imageUrl}: ${error.message}`);
return null;
}
return null;
}
export function registerUploadRecordImageTool(server) {
server.tool(
"upload_record_image",
"Upload an image to a specific record field in Acai CMS. MANDATORY: before calling this tool, you MUST call get_table_schema with minimal=true to find the EXACT upload field name. Look for fields with type='upload'. NEVER guess field names. Table names WITHOUT 'cms_' prefix. recordId is 'num', never 'id'. If the URL came from generate_image, prefer uploadUrl (or fullUrl) over dockerUrl.",
"Upload an image to a specific record field in Acai CMS. MANDATORY: before calling this tool, you MUST call get_table_schema with minimal=true to find the EXACT upload field name. Look for fields with type='upload'. NEVER guess field names. Table names WITHOUT 'cms_' prefix. recordId is 'num', never 'id'. If the URL came from generate_image, prefer uploadUrl (or fullUrl) over dockerUrl. For a LOCAL or pasted image (a file on your machine, no public URL): save it into the synced project folder cms/uploads/chat/<name>.ext, wait for the sync to push it, then pass its PROJECT-RELATIVE path (e.g. 'cms/uploads/chat/foto.png') as imageUrl. NEVER pass a data-URI/base64 nor spin up a localhost server.",
withAuthParams({
tableName: z.string().describe("Table name without 'cms_' prefix (e.g., 'productos')"),
recordId: z.string().describe("Record 'num' (primary key)"),
fieldName: z.string().describe("EXACT field name from the schema. MUST match a field with type 'upload' from get_table_schema or get_module_config_vars. Do NOT guess."),
imageUrl: z.string().describe("URL of the image to upload"),
imageUrl: z.string().describe("Image to upload: an http(s) URL, OR a project-relative path to a file already synced to the project (e.g. 'cms/uploads/chat/foto.png'). For local/pasted images use the relative-path form. NOT a data-URI or base64."),
alt: z.string().optional().describe("Alt text for the image (optional)"),
}),
{ readOnlyHint: false, destructiveHint: false },
@@ -119,6 +83,39 @@ export function registerUploadRecordImageTool(server) {
);
if (validationError) return validationError;
// Aceptamos: URL http(s), ruta absoluta del servidor, o RUTA
// RELATIVA del proyecto (p.ej. "cms/uploads/chat/foto.png"). Para
// una imagen local/pegada el flujo correcto es guardarla en una
// carpeta sincronizada NO truncada (cms/uploads/chat/ o
// cms/uploads/generated/), dejar que el sync la suba a test y pasar
// aquí su ruta relativa: el server lee los bytes de disco vía
// resolve_image_source (sin base64 por el modelo).
// Seguimos rechazando data-URI / base64 crudo: derivar el nombre
// de un base64 gigante revienta file_put_contents ("File name too
// long"). El tope de longitud + charset de ruta lo descartan.
const trimmedImage = imageUrl.trim();
const isHttpUrl = /^https?:\/\//i.test(trimmedImage);
const isAbsPath = trimmedImage.startsWith("/") && !trimmedImage.startsWith("//");
const isRelPath = !isHttpUrl && !isAbsPath
&& !/^[a-z][a-z0-9+.-]*:/i.test(trimmedImage) // sin esquema (data:, file:...)
&& !trimmedImage.includes("..")
&& trimmedImage.length <= 512
&& /^[\w./ -]+$/.test(trimmedImage); // charset de ruta (no base64)
if (!isHttpUrl && !isAbsPath && !isRelPath) {
return {
content: [{
type: "text",
text: JSON.stringify({
error: "imageUrl debe ser una URL http(s) o una ruta relativa del proyecto " +
"(p.ej. 'cms/uploads/chat/foto.png'), no un data-URI ni base64 crudo. " +
"Para una imagen local/pegada: guárdala en cms/uploads/chat/ (sincronizada a test), " +
"espera a que el sync la suba y pasa su ruta relativa."
}, null, 2)
}],
isError: true,
};
}
const projectSlug = path.basename(resolveCurrentProjectDir());
// Intentar via Python server (tiene sync + optimizacion)
@@ -238,7 +235,7 @@ export function registerUploadRecordImageTool(server) {
recordId: z.string().describe("Record 'num' (primary key)"),
fieldName: z.string().describe("Upload field name"),
uploadId: z.string().describe("Upload ID to replace (get from list_record_uploads)"),
imageUrl: z.string().describe("URL of the new image to upload"),
imageUrl: z.string().describe("New image: an http(s) URL, OR a project-relative path to a file already synced (e.g. 'cms/uploads/chat/foto.png'). For local/pasted images use the relative-path form. NOT a data-URI or base64."),
alt: z.string().optional().describe("Alt text for the image (optional)"),
}),
{ readOnlyHint: false, destructiveHint: false },

View File

@@ -154,7 +154,7 @@ export function registerUploadImageToAssetsTool(server) {
// Upload using saveFileBuilder
const uploadResult = await saveFileBuilder({
web_url: credentials.api_web_url || credentials.web_url,
credentials,
token: credentials.token,
tokenHash: credentials.tokenHash,
path: assetsPath,

View File

@@ -19,7 +19,7 @@
"command": "uvx",
"args": ["mcp-server-fetch"],
"timeout": 30,
"startup_timeout": 15
"startup_timeout": 30
}
}
}

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.
@@ -57,6 +66,10 @@ class ModelConfig:
max_tokens: int = 4096
temperature: float = 0.3
stop_sequences: list[str] = field(default_factory=list)
# Nivel de razonamiento (minimal|low|medium|high). Vacío = sin razonamiento
# explícito. LiteLLM lo traduce por proveedor; modelos que no lo soportan lo
# ignoran (litellm.drop_params=True).
reasoning_effort: str = ""
extra: dict[str, Any] = field(default_factory=dict)

View File

@@ -59,9 +59,25 @@ class LiteLLMAdapter(OpenAIAdapter):
async def _acreate(self, kwargs: dict[str, Any]):
kwargs = dict(kwargs)
kwargs["model"] = self._litellm_model
if self._api_key:
kwargs["api_key"] = self._api_key
if self._api_base:
kwargs["api_base"] = self._api_base
# Respetar el model_id por request (resuelto dinámicamente en
# send_message). Solo se honra si trae prefijo de proveedor
# ("deepseek/...", "openrouter/..."); cualquier otro valor (default
# no-litellm, vacío) cae al modelo por defecto del adapter — preserva el
# comportamiento previo para llamadas internas (planner, completions).
model = kwargs.get("model") or ""
if "/" not in model:
model = self._litellm_model
kwargs["model"] = model
if model.startswith("openrouter/"):
# OpenRouter: LiteLLM enruta con OPENROUTER_API_KEY del entorno y su
# base propia. NO forzar api_key/api_base del proxy DeepSeek — lo
# sobreescribirían y romperían el routing.
kwargs.pop("api_key", None)
kwargs.pop("api_base", None)
else:
if self._api_key:
kwargs["api_key"] = self._api_key
if self._api_base:
kwargs["api_base"] = self._api_base
return await litellm.acompletion(**kwargs)

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,
@@ -77,6 +123,8 @@ class OpenAIAdapter(ModelAdapter):
"stream": True,
"stream_options": {"include_usage": True},
}
if getattr(config, "reasoning_effort", ""):
kwargs["reasoning_effort"] = config.reasoning_effort
if tools:
kwargs["tools"] = self._format_tools(tools)
@@ -266,6 +314,8 @@ class OpenAIAdapter(ModelAdapter):
"temperature": config.temperature,
"messages": self._to_openai_messages(messages),
}
if getattr(config, "reasoning_effort", ""):
kwargs["reasoning_effort"] = config.reasoning_effort
if tools:
kwargs["tools"] = self._format_tools(tools)
# Fuerza al modelo a usar un tool concreto para garantizar JSON por schema
@@ -277,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 ""
@@ -428,8 +485,9 @@ class OpenAIAdapter(ModelAdapter):
if tool_calls:
m["tool_calls"] = tool_calls
out.append(m)
else: # user (puede traer tool_result blocks)
else: # user (puede traer tool_result blocks, texto e imágenes)
text_parts = []
image_blocks: list[dict[str, Any]] = []
for b in content:
if not isinstance(b, dict):
continue
@@ -442,7 +500,18 @@ class OpenAIAdapter(ModelAdapter):
})
elif t == "text":
text_parts.append(b.get("text", ""))
if text_parts:
elif t == "image_url":
# Visión nativa: preservar el bloque en formato multimodal OpenAI.
image_blocks.append({"type": "image_url", "image_url": b.get("image_url") or {}})
if image_blocks:
# Content como lista de bloques (texto + imágenes).
parts: list[dict[str, Any]] = []
joined = "\n".join(p for p in text_parts if p)
if joined:
parts.append({"type": "text", "text": joined})
parts.extend(image_blocks)
out.append({"role": "user", "content": parts})
elif text_parts:
out.append({"role": "user", "content": "\n".join(text_parts)})
# Guard defensivo: el compactor ya garantiza el invariante tool_use ↔
# tool_result (`_enforce_tool_pairing`), pero si algo se escapa el

View File

@@ -46,6 +46,10 @@ class SendMessageRequest(BaseModel):
message: str
stream: bool = False
agent_id: str | None = None
# Imágenes para visión nativa: bloques listos para el modelo
# {"type":"image_url","image_url":{"url":"data:<mime>;base64,..."}}. Solo se
# envían cuando el modelo activo es multimodal (lo decide acai-app).
attachments: list[dict[str, Any]] | None = None
# 'off' (default): la tool acai_plan no se expone al modelo, ejecuta directo.
# 'force': system prompt obliga a llamar acai_plan antes de ejecutar.
# 'auto' (legacy): se trata como 'off'. UI: toggle en ChatPanel.
@@ -335,6 +339,25 @@ async def send_message(
if not agent_profile:
agent_profile = agent_reg.get(agent_reg.default_agent_id)
# Resolución dinámica del modelo (Fase 2): override por-usuario (metadata de
# la sesión) → default global (Redis acai:config:ai:*). Si resuelve, se
# inyecta en una COPIA del profile para no mutar el del registry (singleton).
if agent_profile is not None:
from ..orchestrator.model_resolver import resolve_session_model
resolved = await resolve_session_model(session)
update = {}
if resolved.get("model_id"):
update["model_id"] = resolved["model_id"]
if resolved.get("reasoning_effort"):
update["reasoning_effort"] = resolved["reasoning_effort"]
if update:
agent_profile = agent_profile.model_copy(update=update)
logger.info(
"Session %s: modelo resuelto -> %s (reasoning=%s)",
session_id, update.get("model_id", "(default)"),
update.get("reasoning_effort", "off"),
)
# Plan mode controlado por el usuario desde el toggle del ChatPanel.
# 'auto' (default): heuristica del modelo trivial-vs-complex.
# 'force': el agente DEBE llamar acai_plan como primera accion.
@@ -359,7 +382,7 @@ async def send_message(
if body.stream:
task = asyncio.create_task(
_execute_and_persist(orchestrator, storage, session, body.message)
_execute_and_persist(orchestrator, storage, session, body.message, body.attachments)
)
_running_executions[session_id] = task
# Auto-limpieza del registro al terminar (solo si seguimos siendo la
@@ -377,11 +400,11 @@ async def send_message(
"stream_url": f"/sessions/{session_id}/stream",
}
result = await _execute_and_persist(orchestrator, storage, session, body.message)
result = await _execute_and_persist(orchestrator, storage, session, body.message, body.attachments)
return result
async def _execute_and_persist(orchestrator, storage, session, message) -> dict[str, Any]:
async def _execute_and_persist(orchestrator, storage, session, message, attachments=None) -> dict[str, Any]:
# Acquire exclusive lock — prevents concurrent execution on same session
async with storage.session_lock(session.session_id) as acquired:
if not acquired:
@@ -391,8 +414,18 @@ async def _execute_and_persist(orchestrator, storage, session, message) -> dict[
"status": "busy",
}
# Persistir 'executing' + el objetivo ANTES de la ejecución larga, para que
# un reattach (tras recargar el frontend a mitad de turno) detecte que hay
# un turno en curso. El estado final lo guarda el `finally`.
try:
result = await orchestrator.process_message(session, message)
session.status = SessionStatus.EXECUTING
session.metadata["current_objective"] = message
await storage.update_session(session)
except Exception as e:
logger.warning("No se pudo persistir 'executing' al arrancar: %s", e)
try:
result = await orchestrator.process_message(session, message, attachments)
return result
except asyncio.CancelledError:
# Ejecución abortada por el usuario (stop) o preemptada por un
@@ -401,6 +434,24 @@ async def _execute_and_persist(orchestrator, storage, session, message) -> dict[
# que el `await task` de la cancelación complete. El `finally`
# persiste el estado y el `session_lock` se libera al salir.
logger.info("Execution cancelled for session %s", session.session_id)
# Persistir el turno del usuario aunque se cancele: si no, un
# "vuelve a intentarlo" posterior se queda sin contexto de lo pedido.
# Guardamos su mensaje (+ imagen) y un marcador de interrupción para
# mantener la alternancia user/assistant.
try:
task = session.current_task
if task and (task.objective or "").strip():
session.recent_messages = orchestrator._append_recent_messages(
session.recent_messages,
message=task.objective,
conversation=[{
"role": "assistant",
"content": "[Respuesta interrumpida por el usuario antes de completarse]",
}],
image_attachments=task.image_attachments,
)
except Exception:
logger.exception("No se pudo persistir el turno cancelado")
session.status = SessionStatus.ACTIVE
session.current_task = None
raise
@@ -537,11 +588,20 @@ async def get_session(session_id: str) -> SessionResponse:
"status": plan.get("status", "active"),
}
# Durante el turno el current_task aún no está persistido (begin_task corre en
# process_message; solo se guarda en el finally). Para que un reattach sepa el
# objetivo, lo exponemos desde metadata mientras status==executing.
ct = session.current_task.model_dump() if session.current_task else None
if ct is None and session.status == SessionStatus.EXECUTING:
_obj = session.metadata.get("current_objective")
if _obj:
ct = {"objective": _obj}
return SessionResponse(
session_id=session.session_id,
status=session.status.value,
turn_count=session.turn_count,
current_task=session.current_task.model_dump() if session.current_task else None,
current_task=ct,
completed_tasks=session.completed_tasks,
created_at=session.created_at.isoformat(),
updated_at=session.updated_at.isoformat(),

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

@@ -890,6 +890,10 @@ class ContextCompactor:
elif btype == "tool_result":
tc = block.get("content", "")
tokens += estimate_tokens(tc if isinstance(tc, str) else str(tc))
elif btype == "image_url":
# Una imagen ~1500 tokens. NO medir el base64 como texto, que
# lo contaría como ~30k y reventaría presupuestos/trim.
tokens += 1500
else:
tokens += estimate_tokens(str(block))
else:

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]
@@ -924,8 +947,19 @@ class ContextEngine:
messages.append({"role": "user", "content": "\n".join(history_lines)})
messages.append({"role": "assistant", "content": "Entendido, tengo el contexto del historial. ¿En qué puedo ayudarte ahora?"})
# Current user message
messages.append({"role": "user", "content": user_content})
# Current user message — con imágenes adjuntas (visión nativa) si las hay.
# En ese caso el content pasa a ser lista de bloques [texto, image_url...].
image_attachments = []
if session.current_task and getattr(session.current_task, "image_attachments", None):
image_attachments = [
b for b in session.current_task.image_attachments if isinstance(b, dict)
]
if image_attachments:
content_blocks = [{"type": "text", "text": user_content}]
content_blocks.extend(image_attachments)
messages.append({"role": "user", "content": content_blocks})
else:
messages.append({"role": "user", "content": user_content})
# Append real conversation (assistant messages + tool results from current step)
if conversation:
@@ -1037,6 +1071,10 @@ class ContextEngine:
elif btype == "tool_result":
tc = block.get("content", "")
total += estimate_tokens(tc if isinstance(tc, str) else str(tc))
elif btype == "image_url":
# Heurística conservadora: una imagen ~1500 tokens (no se
# cuenta el base64 como texto, que infla muchísimo).
total += 1500
else:
total += estimate_tokens(str(block))
return total

View File

@@ -20,6 +20,7 @@ class AgentProfile(BaseModel):
allowed_tools: list[str] = Field(default_factory=list)
model_id: str | None = None
planner_model_id: str | None = None # override del modelo solo para el sub-loop del planner
reasoning_effort: str | None = None # nivel de razonamiento (minimal|low|medium|high) resuelto por sesión
temperature: float | None = None
max_tokens: int | None = None
context_sections: list[str] = Field(

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

@@ -46,6 +46,9 @@ class TaskState(BaseModel):
task_id: str = Field(default_factory=lambda: uuid.uuid4().hex[:12])
objective: str
# Imágenes adjuntas a la petición actual (visión nativa). Cada item es un
# bloque listo para el modelo: {"type":"image_url","image_url":{"url":"data:..."}}.
image_attachments: list[dict[str, Any]] = Field(default_factory=list)
status: TaskStatus = TaskStatus.PENDING
plan: list[TaskStep] = Field(default_factory=list)
current_step_index: int = 0
@@ -94,8 +97,8 @@ class SessionState(BaseModel):
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
metadata: dict[str, Any] = Field(default_factory=dict)
def begin_task(self, objective: str) -> TaskState:
task = TaskState(objective=objective)
def begin_task(self, objective: str, image_attachments: list[dict[str, Any]] | None = None) -> TaskState:
task = TaskState(objective=objective, image_attachments=image_attachments or [])
self.current_task = task
self.status = SessionStatus.EXECUTING
self.turn_count += 1

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.
@@ -93,6 +122,7 @@ class BaseAgent:
model_id=self.profile.model_id or "",
max_tokens=self.profile.max_tokens or 4096,
temperature=self.profile.temperature or 0.3,
reasoning_effort=self.profile.reasoning_effort or "",
)
# Snapshot del numero de tool_executions ya acumulados ANTES del

257
src/orchestrator/cost.py Normal file
View File

@@ -0,0 +1,257 @@
"""Cálculo de coste por modelo (Fase 2).
Prioridad de fuentes de precio (para que el coste registrado en
`consumo_acaicode` coincida con lo que muestra el Forge Admin Panel):
1. Catálogo OpenRouter cacheado por el panel en Redis db 0
(`acai:config:ai:models_cache:openrouter` → price_in_1m / price_out_1m).
2. Price map de LiteLLM (conoce muchos modelos deepseek/, anthropic/, etc.).
3. Coste fijo de `settings` (comportamiento previo).
"""
from __future__ import annotations
import asyncio
import json
import logging
import time
import urllib.request
import redis.asyncio as redis
from ..config import settings
logger = logging.getLogger(__name__)
# Caches de catálogo que publica el Forge Admin Panel en Redis db 0, por proveedor.
# El id se guarda SIN el prefijo de proveedor de litellm (p.ej.
# "moonshotai/kimi-k2.7-code", "deepseek-v4-pro").
_CACHE_KEYS = {
"openrouter": "acai:config:ai:models_cache:openrouter",
"deepseek": "acai:config:ai:models_cache:deepseek",
}
_CONFIG_DB = 0
_cfg_redis: "redis.Redis | None" = None
def _get_cfg_redis() -> "redis.Redis":
global _cfg_redis
if _cfg_redis is None:
_cfg_redis = redis.Redis(
host=settings.redis_host,
port=settings.redis_port,
db=_CONFIG_DB,
password=settings.redis_password or None,
decode_responses=True,
)
return _cfg_redis
# --- 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]
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 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: %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:
pin = m.get("price_in_1m")
pout = m.get("price_out_1m")
if pin is not None and pout is not None:
return (float(pin), float(pout))
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.
Devuelve {"cost_usd", "input_cost_1m", "output_cost_1m"} — el coste total y
las tarifas por 1M tokens REALMENTE aplicadas (se almacenan en
`consumo_acaicode.input_cost_1M` / `output_cost_1M`).
"""
input_tokens = int(input_tokens or 0)
output_tokens = int(output_tokens or 0)
def _result(in_1m: float, out_1m: float) -> dict:
return {
"cost_usd": (input_tokens / 1_000_000) * in_1m + (output_tokens / 1_000_000) * out_1m,
"input_cost_1m": round(in_1m, 6),
"output_cost_1m": round(out_1m, 6),
}
# 1. Precio del catálogo OpenRouter (fuente que muestra el admin).
prices = await _catalog_price_per_1m(model_id)
if prices:
return _result(prices[0], prices[1])
# 2. Price map de LiteLLM (deepseek/, anthropic/, etc.).
if model_id and "/" in model_id:
try:
import litellm
prompt_cost, completion_cost = litellm.cost_per_token(
model=model_id,
prompt_tokens=input_tokens,
completion_tokens=output_tokens,
)
total = (prompt_cost or 0.0) + (completion_cost or 0.0)
if total > 0:
# Derivar tarifa por 1M a partir del coste por-token de litellm.
in_1m = (prompt_cost / input_tokens) * 1_000_000 if input_tokens else 0.0
out_1m = (completion_cost / output_tokens) * 1_000_000 if output_tokens else 0.0
return {
"cost_usd": total,
"input_cost_1m": round(in_1m, 6),
"output_cost_1m": round(out_1m, 6),
}
except Exception as e:
logger.warning("cost_per_token(%s) falló, uso coste fijo: %s", model_id, e)
# 3. Coste fijo configurado.
return _result(settings.cost_per_1m_input, settings.cost_per_1m_output)

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
@@ -52,11 +52,16 @@ class OrchestratorEngine:
self,
session: SessionState,
message: str,
image_attachments: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Process a user message. Single agent execution with timeout."""
"""Process a user message. Single agent execution with timeout.
`image_attachments`: bloques image_url (visión nativa) para el turno del
usuario, cuando el modelo activo es multimodal.
"""
try:
return await asyncio.wait_for(
self._run(session, message),
self._run(session, message, image_attachments),
timeout=settings.max_execution_timeout_seconds,
)
except asyncio.TimeoutError:
@@ -70,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:
@@ -86,6 +105,7 @@ class OrchestratorEngine:
self,
session: SessionState,
message: str,
image_attachments: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Execute: message → agent → response."""
@@ -113,8 +133,8 @@ class OrchestratorEngine:
f"Peticion del usuario:\n{message}"
)
# Create task
task = session.begin_task(objective=message)
# Create task (con imágenes adjuntas si las hay — visión nativa)
task = session.begin_task(objective=message, image_attachments=image_attachments)
task.status = TaskStatus.EXECUTING
# Reset del contador de invocaciones de `acai_plan` por turno (Fase 5).
@@ -154,6 +174,9 @@ class OrchestratorEngine:
session.recent_messages,
message=message,
conversation=result.get("conversation", []),
image_attachments=(
session.current_task.image_attachments if session.current_task else None
),
)
session.task_history.append(
@@ -182,13 +205,18 @@ class OrchestratorEngine:
task.status = TaskStatus.COMPLETED
session.complete_task()
# Calculate cost
# Calculate cost — por modelo realmente usado (Fase 2). El model_id
# efectivo vive en el agent_profile (resuelto en send_message).
total_input = usage.get("input_tokens", 0)
total_output = usage.get("output_tokens", 0)
cost_usd = (
(total_input / 1_000_000) * settings.cost_per_1m_input
+ (total_output / 1_000_000) * settings.cost_per_1m_output
model_used = (
self.agent_profile.model_id
or settings.litellm_model
or settings.default_model_id
)
from .cost import compute_cost
cost_info = await compute_cost(model_used, total_input, total_output)
cost_usd = cost_info["cost_usd"]
await self.sse.emit(
EventType.EXECUTION_COMPLETED,
@@ -201,6 +229,19 @@ class OrchestratorEngine:
"status": "completed",
"usage": usage,
"total_cost_usd": round(cost_usd, 6),
# Modelo + tarifas usadas → se propagan a consumo_acaicode via
# _report_usage (columnas input_cost_1M / output_cost_1M).
"model": model_used,
"modelUsage": {
model_used: {
"inputTokens": total_input,
"outputTokens": total_output,
"costUSD": round(cost_usd, 6),
"inputCost1M": cost_info["input_cost_1m"],
"outputCost1M": cost_info["output_cost_1m"],
"reasoningEffort": self.agent_profile.reasoning_effort or "",
}
},
},
session_id=session.session_id,
)
@@ -226,6 +267,21 @@ class OrchestratorEngine:
"status": "completed",
"usage": usage,
"total_cost_usd": round(cost_usd, 6),
# Modelo + tarifas realmente usadas. Se incluyen tambien aqui (ademas
# del evento SSE EXECUTION_COMPLETED) para que el camino NO streaming
# (cronjobs -> _report_usage) reporte el modelo correcto a
# consumo_acaicode en vez de "unknown".
"model": model_used,
"modelUsage": {
model_used: {
"inputTokens": total_input,
"outputTokens": total_output,
"costUSD": round(cost_usd, 6),
"inputCost1M": cost_info["input_cost_1m"],
"outputCost1M": cost_info["output_cost_1m"],
"reasoningEffort": self.agent_profile.reasoning_effort or "",
}
},
}
def _error_result(self, session: SessionState, error: str) -> dict[str, Any]:
@@ -246,13 +302,21 @@ class OrchestratorEngine:
existing: list[dict[str, Any]],
message: str,
conversation: list[dict[str, Any]],
image_attachments: list[dict[str, Any]] | None = None,
) -> list[dict[str, Any]]:
merged = [OrchestratorEngine._sanitize_recent_message(m) for m in existing]
merged = [m for m in merged if m]
current_turn: list[dict[str, Any]] = []
if message.strip():
current_turn.append({"role": "user", "content": message})
if message.strip() or image_attachments:
if image_attachments:
# Guardar el turno con la imagen como bloques para que PERSISTA
# en el contexto de turnos siguientes (visión nativa multimodal).
content_blocks = [{"type": "text", "text": message}]
content_blocks.extend(image_attachments)
current_turn.append({"role": "user", "content": content_blocks})
else:
current_turn.append({"role": "user", "content": message})
for message_obj in conversation:
sanitized = OrchestratorEngine._sanitize_recent_message(message_obj)

View File

@@ -0,0 +1,113 @@
"""Resolución dinámica del modelo IA por sesión (Fase 2).
Prioridad:
1. Override por-usuario: `session.metadata["ai_provider"|"ai_model"]`. Lo
inyecta acai-app via self-read del WS (`getAcaiCodeUserAiModel`) al crear
la sesión.
2. Default global: Redis `acai:config:ai:provider` / `acai:config:ai:model`,
que escribe el Forge Admin Panel. OJO: esas keys NO llevan el prefijo
`agentic` (son globales del stack Acai).
3. Sin configuración → None: no se toca el modelo y el adapter usa su default
(comportamiento previo).
Solo aplica cuando el provider activo es `litellm` — los providers del catálogo
(openrouter, deepseek) se enrutan por LiteLLM. Para claude/openai no se toca.
"""
from __future__ import annotations
import logging
import redis.asyncio as redis
from ..config import settings
logger = logging.getLogger(__name__)
# Keys del Forge Admin Panel (globales, SIN prefijo agentic).
_GLOBAL_PROVIDER_KEY = "acai:config:ai:provider"
_GLOBAL_MODEL_KEY = "acai:config:ai:model"
_GLOBAL_REASONING_KEY = "acai:config:ai:reasoning_effort"
# Niveles de razonamiento válidos (lo demás se ignora → sin razonamiento).
_VALID_EFFORTS = {"minimal", "low", "medium", "high"}
# El Forge Admin Panel escribe la config global en Redis db 0 (REDIS_DB=0 del
# admin). El agentic usa db 1 para sus sesiones, así que para leer la config
# global necesitamos una conexión dedicada a db 0 (misma instancia Redis).
_GLOBAL_CONFIG_DB = 0
_global_redis: "redis.Redis | None" = None
def _get_global_redis() -> "redis.Redis":
global _global_redis
if _global_redis is None:
_global_redis = redis.Redis(
host=settings.redis_host,
port=settings.redis_port,
db=_GLOBAL_CONFIG_DB,
password=settings.redis_password or None,
decode_responses=True,
)
return _global_redis
def to_litellm_model(provider: str | None, model: str | None) -> str:
"""Mapea {provider, model} del catálogo a un model string de LiteLLM."""
provider = (provider or "").strip().lower()
model = (model or "").strip()
if not model:
return ""
if provider == "openrouter":
# Los ids de OpenRouter ya vienen como "vendor/name" → prefijo openrouter/.
return model if model.startswith("openrouter/") else f"openrouter/{model}"
if provider == "deepseek":
return model if model.startswith("deepseek/") else f"deepseek/{model}"
# Provider desconocido: respetar el id tal cual (puede traer ya su prefijo).
return model
def _norm_effort(value) -> str | None:
v = (value or "").strip().lower()
return v if v in _VALID_EFFORTS else None
async def resolve_session_model(session) -> dict:
"""Resuelve modelo + razonamiento efectivos para la sesión.
Devuelve {"model_id": str|None, "reasoning_effort": str|None}. El effort se
toma de la MISMA fuente que el modelo (override de usuario o default global),
para que sean coherentes. model_id None = sin override (adapter usa default).
"""
none = {"model_id": None, "reasoning_effort": None}
if settings.default_model_provider != "litellm":
return none
# 1. Override por-usuario (metadata de la sesión).
meta = getattr(session, "metadata", None) or {}
provider = meta.get("ai_provider")
model = meta.get("ai_model")
if provider and model:
return {
"model_id": to_litellm_model(provider, model) or None,
"reasoning_effort": _norm_effort(meta.get("ai_reasoning_effort")),
}
# 2. Default global (Redis db 0, keys sin prefijo agentic).
try:
gr = _get_global_redis()
provider = await gr.get(_GLOBAL_PROVIDER_KEY)
model = await gr.get(_GLOBAL_MODEL_KEY)
effort = await gr.get(_GLOBAL_REASONING_KEY)
except Exception as e: # pragma: no cover - defensivo
logger.warning("resolve_session_model: lectura Redis falló: %s", e)
return none
if provider and model:
return {
"model_id": to_litellm_model(provider, model) or None,
"reasoning_effort": _norm_effort(effort),
}
# 3. Sin configuración → sin override.
return none

View File

@@ -217,6 +217,8 @@ async def run_planner_subloop(
max_tokens=settings.planner_max_tokens or 16000,
# Temperatura mas baja que el agente principal — queremos JSON limpio.
temperature=0.1,
# Mismo nivel de razonamiento resuelto por sesión que el agente principal.
reasoning_effort=agent_profile.reasoning_effort or "",
)
tool_defs = _build_planner_tools(mcp)

View File

@@ -363,6 +363,8 @@ class ClaudeFormatEmitter:
"cache_creation_input_tokens": 0,
},
"total_cost_usd": data.get("total_cost_usd", 0),
# Modelo usado → acai-app lo registra en consumo_acaicode.
"modelUsage": data.get("modelUsage", {}),
})
# Done

View File

@@ -65,6 +65,128 @@ class TestSettingsBudget:
assert cfg.effective_context_budget == 172_000
assert cfg.effective_compaction_threshold == 137_600
def test_budget_for_window_small_and_large(self):
cfg = Settings(
context_max_tokens=0,
model_max_output_tokens=4_096,
context_reserve_ratio=0.10,
_env_file=None,
)
# 32k: window - max_output - 10% reserve
assert cfg.budget_for_window(32_000) == 32_000 - 4_096 - 3_200
# 1M: budget mucho mayor (no compacta innecesariamente)
assert cfg.budget_for_window(1_000_000) == 1_000_000 - 4_096 - 100_000
# ventana inválida → fallback al budget estático
assert cfg.budget_for_window(0) == cfg.effective_context_budget
def test_compaction_threshold_for_uses_ratio(self):
cfg = Settings(
compaction_threshold_tokens=0,
compaction_threshold_ratio=0.80,
_env_file=None,
)
assert cfg.compaction_threshold_for(100_000) == 80_000
class TestContextWindowResolution:
def test_resolve_window_from_catalog(self, monkeypatch):
import json
from src.orchestrator import cost
cost._window_cache.clear()
class _FakeRedis:
async def get(self, key):
return json.dumps([
{"id": "kimi-k2.7-code", "context_length": 256_000},
{"id": "otro", "context_length": 32_000},
])
monkeypatch.setattr(cost, "_get_cfg_redis", lambda: _FakeRedis())
w = asyncio.run(cost.resolve_context_window("openrouter/kimi-k2.7-code"))
assert w == 256_000
# segunda llamada usa cache (no peta aunque cambie el fake)
assert asyncio.run(cost.resolve_context_window("openrouter/kimi-k2.7-code")) == 256_000
def test_resolve_window_miss_is_none_or_int(self, monkeypatch):
from src.orchestrator import cost
cost._window_cache.clear()
class _FakeRedis:
async def get(self, key):
return None
monkeypatch.setattr(cost, "_get_cfg_redis", lambda: _FakeRedis())
w = asyncio.run(cost.resolve_context_window("openrouter/modelo-inexistente-xyz"))
assert w is None or isinstance(w, int)
def test_resolve_window_ignores_non_litellm_ids(self):
from src.orchestrator import cost
cost._window_cache.clear()
assert asyncio.run(cost.resolve_context_window("sin-prefijo")) is None
assert asyncio.run(cost.resolve_context_window(None)) is None
def test_resolve_window_self_heals_when_catalog_missing(self, monkeypatch):
"""Si el catálogo OpenRouter caducó, se repuebla en runtime (self-heal)."""
from src.orchestrator import cost
cost._window_cache.clear()
cost._or_last_refresh[0] = 0.0 # desactivar cooldown para el test
store = {}
class _FakeRedis:
async def get(self, key):
return store.get(key)
async def set(self, key, val, ex=None):
store[key] = val
monkeypatch.setattr(cost, "_get_cfg_redis", lambda: _FakeRedis())
monkeypatch.setattr(
cost, "_fetch_openrouter_catalog_sync",
lambda: [{"id": "moonshotai/kimi-x", "context_length": 262_144,
"price_in_1m": 0.6, "price_out_1m": 3.0}],
)
w = asyncio.run(cost.resolve_context_window("openrouter/moonshotai/kimi-x"))
assert w == 262_144
# quedó repoblado en el cache para futuras lecturas
assert "acai:config:ai:models_cache:openrouter" in store
class TestModelAwareBudget:
def test_build_context_uses_model_window_budget(self, monkeypatch):
from src.orchestrator import cost
async def _fake_window(model_id):
return 40_000
monkeypatch.setattr(cost, "resolve_context_window", _fake_window)
session = SessionState(immutable_rules=["No romper"])
session.begin_task("hola")
agent = AgentProfile(role="acai", name="Acai", system_prompt="Haz el trabajo.")
pkg = asyncio.run(
ContextEngine().build_context(
session=session, agent=agent, model_id="openrouter/m"
)
)
assert pkg.budget_tokens == settings.budget_for_window(40_000)
def test_budget_override_wins(self):
session = SessionState(immutable_rules=["No romper"])
session.begin_task("hola")
agent = AgentProfile(role="acai", name="Acai", system_prompt="Haz el trabajo.")
pkg = asyncio.run(
ContextEngine().build_context(
session=session, agent=agent, budget_override=12_345
)
)
assert pkg.budget_tokens == 12_345
class TestContextEngine:
def test_build_context_keeps_task_history_and_current_task(self):

View File

@@ -0,0 +1,110 @@
"""Test de integración contra sesiones REALES de Redis (db 1).
Valida el budget por-ventana y la compactación sobre las conversaciones reales
del agentic (las que los usuarios mantienen abiertas), no sobre fixtures
sintéticos. Es OPT-IN: se salta si no hay Redis disponible o no hay sesiones,
para no acoplar la suite a datos de cliente ni romper en CI.
Ejecutar contra el Redis real:
docker run --rm --network acai-net \\
-v "$PWD/agenticSystem/src:/app/src" -v "$PWD/agenticSystem/tests:/app/tests" \\
-e AGENTIC_REDIS_HOST=redis -w /app acai-vscode-plugin-agentic \\
sh -lc "pip install -q pytest pytest-asyncio; python -m pytest tests/test_context_real_session.py -q"
"""
from __future__ import annotations
import asyncio
import enum
import json
import sys
import types
import pytest
if not hasattr(enum, "StrEnum"):
class _CompatStrEnum(str, enum.Enum):
pass
enum.StrEnum = _CompatStrEnum
for _name, _attr in (("anthropic", "AsyncAnthropic"), ("openai", "AsyncOpenAI")):
if _name not in sys.modules:
_stub = types.ModuleType(_name)
setattr(_stub, _attr, type("_Stub", (), {}))
sys.modules[_name] = _stub
from src.config import settings
from src.context.compactor import estimate_tokens
from src.context.engine import ContextEngine
from src.models.agent import AgentProfile
from src.models.session import SessionState
def _load_largest_real_session():
"""Mayor sesión real de Redis db 1, o None si no hay acceso/sesiones."""
try:
import redis
r = redis.Redis(
host=settings.redis_host,
port=settings.redis_port,
db=1,
password=settings.redis_password or None,
decode_responses=True,
socket_connect_timeout=2,
)
keys = [
k for k in r.scan_iter("agentic:session:*")
if not k.endswith((":events", ":artifacts"))
]
if not keys:
return None
biggest = max(keys, key=lambda k: r.strlen(k))
raw = r.get(biggest)
return json.loads(raw) if raw else None
except Exception:
return None
def test_real_session_compacts_under_model_window(monkeypatch):
data = _load_largest_real_session()
if not data or not data.get("recent_messages"):
pytest.skip("sin Redis/sesiones reales disponibles")
rm = data["recent_messages"]
raw_tokens = sum(estimate_tokens(json.dumps(m)) for m in rm)
from src.orchestrator import cost
async def _fake_window(model_id):
return 32_000
monkeypatch.setattr(cost, "resolve_context_window", _fake_window)
session = SessionState(
immutable_rules=data.get("immutable_rules") or ["No romper"],
project_profile=data.get("project_profile") or {},
task_history=data.get("task_history") or [],
recent_messages=rm,
)
session.begin_task("Sigamos con lo anterior")
agent = AgentProfile(
role="acai",
name="Acai",
system_prompt="Haz el trabajo.",
context_sections=["immutable_rules", "task_state"],
)
pkg = asyncio.run(
ContextEngine().build_context(
session=session, agent=agent, conversation=rm, model_id="openrouter/x"
)
)
# Budget derivado de la ventana REAL del modelo (32k), no del fijo de 120k/200k.
assert pkg.budget_tokens == settings.budget_for_window(32_000)
# La sesión real se compactó de verdad (no se reenvía cruda).
assert pkg.total_token_estimate < raw_tokens
# Y el resultado cabe en el budget del modelo → no habría overflow.
assert pkg.total_token_estimate <= pkg.budget_tokens

View File

@@ -0,0 +1,93 @@
"""Tests de recuperación ante overflow de ventana de contexto.
Cubre: detección del error de context-length del proveedor, y el envoltorio del
adapter que lo traduce a `ContextOverflowError` (dominio) tanto si salta al
iniciar el stream como durante la iteración.
"""
from __future__ import annotations
import asyncio
import enum
import sys
import types
import pytest
if not hasattr(enum, "StrEnum"):
class _CompatStrEnum(str, enum.Enum):
pass
enum.StrEnum = _CompatStrEnum
if "anthropic" not in sys.modules:
anthropic_stub = types.ModuleType("anthropic")
anthropic_stub.AsyncAnthropic = type("_AsyncAnthropic", (), {})
sys.modules["anthropic"] = anthropic_stub
if "openai" not in sys.modules:
openai_stub = types.ModuleType("openai")
openai_stub.AsyncOpenAI = type("_AsyncOpenAI", (), {})
sys.modules["openai"] = openai_stub
from src.adapters.base import ContextOverflowError
from src.adapters.openai_adapter import OpenAIAdapter, _is_context_overflow
class TestOverflowDetection:
def test_detects_by_message(self):
assert _is_context_overflow(
Exception("This model's maximum context length is 8192 tokens, however you requested 9000")
)
assert _is_context_overflow(Exception("context_length_exceeded"))
assert _is_context_overflow(Exception("Please reduce the length of the messages"))
def test_does_not_flag_unrelated_errors(self):
assert not _is_context_overflow(Exception("rate limit exceeded"))
assert not _is_context_overflow(Exception("invalid api key"))
def test_detects_by_type_name(self):
class ContextWindowExceededError(Exception):
pass
assert _is_context_overflow(ContextWindowExceededError("boom"))
class TestStreamWrapperMapsOverflow:
def _make_adapter(self):
# Saltamos __init__ (no necesitamos el cliente AsyncOpenAI: parcheamos
# _stream_impl). Así el test no depende del stub de openai.
return OpenAIAdapter.__new__(OpenAIAdapter)
def test_overflow_at_stream_init_becomes_domain_error(self, monkeypatch):
adapter = self._make_adapter()
async def _impl(messages, tools=None, config=None):
raise RuntimeError("maximum context length is 32768 tokens")
yield # noqa: hace de esto un async generator
monkeypatch.setattr(adapter, "_stream_impl", _impl)
async def _run():
async for _ in adapter.stream([{"role": "user", "content": "hola"}]):
pass
with pytest.raises(ContextOverflowError):
asyncio.run(_run())
def test_non_overflow_error_propagates_unchanged(self, monkeypatch):
adapter = self._make_adapter()
async def _impl(messages, tools=None, config=None):
raise RuntimeError("connection reset by peer")
yield
monkeypatch.setattr(adapter, "_stream_impl", _impl)
async def _run():
async for _ in adapter.stream([{"role": "user", "content": "hola"}]):
pass
with pytest.raises(RuntimeError) as exc:
asyncio.run(_run())
assert not isinstance(exc.value, ContextOverflowError)