Compare commits
9 Commits
9277862e56
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d475845c27 | ||
|
|
941040d534 | ||
|
|
037bc81936 | ||
|
|
882d578960 | ||
|
|
651d61b096 | ||
|
|
9d11a59fb8 | ||
|
|
5dc2dbcf4a | ||
|
|
5883473e92 | ||
|
|
4543300101 |
@@ -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"]
|
||||
|
||||
@@ -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
2
evals/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Los logs de sesión contienen contenido real de proyectos de cliente.
|
||||
logs/
|
||||
43
evals/README.md
Normal file
43
evals/README.md
Normal 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
148
evals/driver.py
Normal 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")))
|
||||
156
evals/results-landing-build.md
Normal file
156
evals/results-landing-build.md
Normal 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 ~650–720k) 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**.
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 || "";
|
||||
}
|
||||
|
||||
@@ -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."),
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
2
mcp.json
2
mcp.json
@@ -19,7 +19,7 @@
|
||||
"command": "uvx",
|
||||
"args": ["mcp-server-fetch"],
|
||||
"timeout": 30,
|
||||
"startup_timeout": 15
|
||||
"startup_timeout": 30
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
257
src/orchestrator/cost.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
113
src/orchestrator/model_resolver.py
Normal file
113
src/orchestrator/model_resolver.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
110
tests/test_context_real_session.py
Normal file
110
tests/test_context_real_session.py
Normal 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
|
||||
93
tests/test_overflow_recovery.py
Normal file
93
tests/test_overflow_recovery.py
Normal 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)
|
||||
Reference in New Issue
Block a user