Compare commits

...

17 Commits

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 16:44:36 +01:00
Jordan
651d61b096 P0 contexto: ventana por modelo + recuperación ante overflow + self-heal del catálogo
Que las conversaciones largas no se rompan ni gasten de más:

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 09:29:17 +00:00
Jordan Diaz
9277862e56 read_doc: resolver docs por ACAI_PROJECT_DIR + knowledge load idempotente
- mcp-server _docsReader.js: resolveDocsDir → ACAI_DOCS_DIR /
  $ACAI_PROJECT_DIR/docs / /app/docs. Arregla DOC_NOT_FOUND en VSCode
  (HTTP MCP) y local; el .mcp.json ya inyecta ACAI_PROJECT_DIR
- routes.py: /knowledge/load idempotente — salta embeddings si el hash
  de contenido no cambió (clave Redis kbhash), para dispararlo libremente
  desde el botón de scaffold sin re-embeber

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 17:23:53 +00:00
Jordan Diaz
79ec267aa6 Compactor: garantizar emparejamiento tool_use/tool_result (sesiones largas bloqueadas)
Las sesiones largas con DeepSeek quedaban bloqueadas permanentemente con
400 "Messages with role 'tool' must be a response to a preceding message
with 'tool_calls'": el paso de ultimo recurso del compactor colapsaba
assistants con tool_use a un string placeholder dejando huerfanos los
tool_result del user siguiente.

- compactor: paso de ultimo recurso pair-aware + _enforce_tool_pairing
  como invariante final (matching por IDs, ambas direcciones, repara
  tambien historiales ya corruptos persistidos).
- openai_adapter: _repair_tool_sequence como guard defensivo del contrato
  del proveedor (tool huerfano -> user; tool_call sin respuesta -> fuera),
  con warning para detectar regresiones.
- recent_messages: trim por presupuesto de tokens al persistir
  (AGENTIC_RECENT_MESSAGES_MAX_TOKENS, default 60k) sin cortar pares;
  cierra el crecimiento sin limite que empujaba al paso destructivo.
- tests/test_tool_pairing_real.py: 23 tests que importan el codigo REAL
  (a diferencia de los tests standalone existentes). Suite completa: 92 ok.

Verificado offline contra los recent_messages reales de la sesion
bloqueada en prod: 0 violaciones con presupuesto normal y agresivo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:08:53 +00:00
Jordan Diaz
43337e8554 Hardening: lock de sesion atomico, monitor off por defecto, fix DeepSeek reasoning-only
- session_lock: token uuid + compare-and-delete (Lua), TTL > timeout de
  ejecucion; abort solo limpia el lock tras cancelacion confirmada.
  Evita doble ejecucion concurrente sobre la misma sesion.
- monitor HTTP (puerto 4545) deshabilitado salvo MCP_MONITOR_ENABLED=true
  y atado a 127.0.0.1; no se acumula historial en memoria si esta off.
- DeepSeek/LiteLLM: turnos que llegan solo con reasoning_content (sin
  content ni tool_calls) ya no rompen la sesion (400 'Invalid assistant
  message') ni se pintan como 'pensando': se promueven a texto en el
  historial y en el snapshot persistido.
- litellm pinneado a ==1.80.0 (builds reproducibles).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 15:17:52 +00:00
Jordan Diaz
6a03fdf284 Harden DeepSeek agent: LiteLLM adapter, DSML/reasoning/embeddings/error fixes
- LiteLLMAdapter (subclasses OpenAIAdapter via _acreate hook): routes DeepSeek
  through LiteLLM. Opt-in AGENTIC_DEFAULT_MODEL_PROVIDER=litellm. A/B beat the
  hand-rolled adapter (0 DSML, 0 parse-fails). Defensive chunk.usage getattr,
  token-estimate usage fallback for billing, quiet litellm logs.
- DSML parser: tolerate single/multi fullwidth pipes, honor string="true/false"
  typed args (openai_adapter fallback when DeepSeek leaks tool calls as text).
- Thinking mode: capture and round-trip reasoning_content across turns.
- Embeddings: dedicated AGENTIC_EMBEDDINGS_API_KEY (DeepSeek has no embeddings);
  disable cleanly when unset to avoid per-turn 401.
- claude_format: friendly generic error messages to the chat, raw only in logs.
- acai agent max_tokens 4096->16384 (whole-file writes no longer truncate);
  system.md size-based edit policy; strict tools opt-in (off).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:49:48 +00:00
Jordan Diaz
e34a39e3bf fix(adapter): ejecutar tool calls que DeepSeek emite como texto DSML
Tercer modo de fallo del conector OpenAI (distinto de followup_mode y de
finish_reason=stop): DeepSeek a veces emite las tool calls en su formato interno
DSML (<||DSML||tool_calls>…, con U+FF5C) como TEXTO en el content, en vez de
como tool_calls nativos. El endpoint OpenAI no lo convierte, asi que el adapter
lo trataba como texto y el agente "se paraba" mostrando DSML inerte (0 tools).

Fix en OpenAIAdapter.stream: reutiliza el parser del claude_adapter
(_parse_xml_tool_calls / _TOOL_CALL_OPEN_RE). Acumula el content; si detecta el
inicio de un tool call en texto deja de emitirlo al usuario (DSML no debe verse);
al cerrar el turno, si no hubo tool_calls nativos, parsea el content y emite los
tool calls encontrados como tool_use para que el engine los ejecute.

Validado: el DSML real de la sesion (2x acai_grep) se parsea correctamente.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 20:15:49 +00:00
Jordan Diaz
d6b04e4122 fix(adapter): no perder tool_calls cuando DeepSeek cierra con finish_reason=stop
Sintoma (solo con el conector OpenAI): el agente anuncia la accion en texto
("Voy a crear los modulos…") y se PARA sin ejecutarla — 0 tools.

Causa: el stream del OpenAIAdapter solo emitia los tool_calls acumulados cuando
choice.finish_reason == "tool_calls". Pero DeepSeek (endpoint OpenAI) a veces
cierra el stream con finish_reason="stop" AUNQUE haya emitido tool_calls; en ese
caso caiamos en el branch else (end_turn) y los tool_calls acumulados se
descartaban. base.py solo ejecuta al recibir finish_reason="tool_use", asi que
nunca se ejecutaban. Con el adapter Claude (Anthropic) el finish_reason venia
distinto, por eso solo aparecia tras el cambio de conector.

Fix: disparar los tool_use SIEMPRE que haya tool_calls acumulados al cerrar el
stream, sea cual sea el finish_reason.

Validado: "crea un modulo…" ahora ejecuta acai_write + check_module y completa,
en vez de pararse tras anunciar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 17:55:40 +00:00
Jordan Diaz
96b4542918 fix(mcp): el read loop ya no muere con respuestas grandes (screenshots)
Sintoma: "el agente se para cuando hace acciones". MCPClient._read_loop lee las
respuestas JSON-RPC con stdout.readline(), cuyo StreamReader tenia buffer de 1MB.
Una respuesta llega en UNA linea; playwright__browser_take_screenshot({fullPage:
true}) devuelve la imagen en base64 en esa linea y supera el limite →
asyncio.LimitOverrunError → el except Exception mataba el read loop y dejaba la
sesion MCP inservible (los turnos siguientes ejecutaban 0 tools).

Fix en dos capas:
- MCP_STREAM_LIMIT=64MB en create_subprocess_exec(limit=...) — cubre cualquier
  screenshot real.
- Read loop tolerante: captura (ValueError, LimitOverrunError), descarta solo esa
  respuesta re-sincronizando el stream hasta el \n (_drain_until_newline) y sigue
  vivo, en vez de matar toda la sesion MCP.

Validado: navegar + screenshot fullPage + glob ejecuta las 4 tools sin "read loop
error" y sin colapsar el contexto.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 17:38:19 +00:00
Jordan Diaz
454b51b45d fix(agentic): DeepSeek llama tools de forma fiable (conector OpenAI + followup_mode)
Dos bugs encadenados impedían que el agente ejecutara tools (emitía los tool
calls como texto sin ejecutarlos, y degradaba el contexto):

1. Conector: el OpenAIAdapter pasaba los mensajes en formato Anthropic (bloques
   tool_use/tool_result) que la API OpenAI de DeepSeek rechaza, y defaulteaba el
   modelo a "gpt-4o". Añade `_to_openai_messages()` (assistant.tool_use →
   tool_calls; user.tool_result → role:tool con tool_call_id) y `_blocks_text()`,
   y usa `settings.default_model_id`. Con esto DeepSeek devuelve tool_calls
   nativos vía https://api.deepseek.com (endpoint OpenAI), sin parsear texto y
   sin la degradación que sufría el endpoint Anthropic-compat.

2. followup_mode: `_classify_followup_mode` marcaba como "transform" cualquier
   PRIMER mensaje que contuviera un marker ("resumen", "estructura", "busca",
   "adapta"…), y `_get_allowed_tools` devuelve [] en modo transform → el agente
   se quedaba SIN tools. Un follow-up no tiene sentido sin turno anterior, así
   que ahora solo se clasifica si hay task_history/recent_messages.

claude_adapter: parser DSML/DeepSeek para tool calls como texto (fallback del
endpoint Anthropic-compat, ya no es la vía principal).

Validado: el prompt de análisis de estilos ("Guarda un resumen…") ahora explora
los módulos y escribe docs/project-styles.md vía save_project_styles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 11:01:54 +00:00
48 changed files with 3397 additions and 291 deletions

View File

@@ -56,6 +56,13 @@ USER appuser
# Descargar Chromium como appuser (queda en ~/.cache/ms-playwright/) # Descargar Chromium como appuser (queda en ~/.cache/ms-playwright/)
RUN cd mcp-server && npx playwright install chromium 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 EXPOSE 8000
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -4,7 +4,11 @@ description: "Agente genérico de Acai CMS: crea módulos, edita contenido, gest
icon: "code" icon: "code"
category: "development" category: "development"
temperature: 0.2 temperature: 0.2
max_tokens: 4096 # 16K de salida: cubre escribir un fichero entero (acai_write) + el razonamiento
# (thinking) en un solo turno. Con 4096 el JSON del tool_use se truncaba a mitad
# en ficheros medianos y el agente caia en micro-ediciones lentas. v4-pro soporta
# hasta 384K de salida, asi que 16K es conservador.
max_tokens: 16384
context_sections: context_sections:
- immutable_rules - immutable_rules
- project_profile - project_profile

View File

@@ -74,6 +74,26 @@ cms/data/schema/ # .ini.php — SOLO con tools de schema
14. **URL del proyecto**: `get_web_url` + `?pruebas=1` siempre. 14. **URL del proyecto**: `get_web_url` + `?pruebas=1` siempre.
15. **Operaciones destructivas**: confirma con el usuario antes de ejecutar. 15. **Operaciones destructivas**: confirma con el usuario antes de ejecutar.
# Eficiencia de edición (menos pasos Y menos tokens)
Elige la herramienta por el TAMAÑO del cambio. Ni micro-editar todo (muchos
pasos), ni reescribir el fichero entero por cada retoque (muchos tokens):
1. **Cambio pequeño o localizado** (un color, un valor, una regla, pocas zonas)
`acai-line-replace`. Barato: solo emites las líneas que cambian. NO
reescribas el fichero entero por un retoque.
2. **Creación o reescritura mayor** (cambias casi todo el fichero o lo creas de
cero) → UN solo `acai-write` del fichero completo. Reescribir entero por un
cambio pequeño desperdicia tokens; hazlo solo cuando de verdad cambia casi todo.
3. **Itera con `line-replace`, no con writes repetidos.** Tras ver el resultado
en el navegador, aplica los ajustes con `line-replace` puntuales. NO reescribas
el fichero completo en cada iteración de diseño.
4. **Cap de micro-ediciones.** Si te ves haciendo >4-5 `line-replace` sobre el
mismo fichero en un turno, para y reescríbelo entero de una vez (`acai-write`).
5. **NO hagas `acai-view` tras cada edición.** Ya tienes el contenido en contexto;
reléelo solo si una edición falló o dudas del estado real.
6. **Verificación visual al final, una sola pasada** — no tras cada retoque.
# Patrones canónicos (aplica por defecto) # Patrones canónicos (aplica por defecto)
- **Detalle de registro**: sección `custom-{tableName}` con `thisrecord.*`. - **Detalle de registro**: sección `custom-{tableName}` con `thisrecord.*`.

View File

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

2
evals/.gitignore vendored Normal file
View File

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

43
evals/README.md Normal file
View File

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

148
evals/driver.py Normal file
View File

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

View File

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

View File

@@ -11,10 +11,15 @@ import { LOCAL_SERVER_URL, getLocalServerHeaders } from "../config/index.js";
* automaticamente; en modo stdio no se propaga y la logica original se * automaticamente; en modo stdio no se propaga y la logica original se
* mantiene. * 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 params = typeof projectName === "string" ? { project: projectName } : (projectName || {});
const headers = getLocalServerHeaders(); const headers = getLocalServerHeaders();
if (acaiUser) headers["X-Acai-User"] = acaiUser; 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`, { const response = await axios.get(`${LOCAL_SERVER_URL}/api/project-info`, {
params, params,
headers, headers,

View File

@@ -15,6 +15,13 @@ export const CONFIG_FILE_PATH =
export const MCP_PORT = Number(process.env.MCP_PORT || 3000); export const MCP_PORT = Number(process.env.MCP_PORT || 3000);
export const MONITOR_PORT = Number(process.env.MCP_MONITOR_PORT || 4545); export const MONITOR_PORT = Number(process.env.MCP_MONITOR_PORT || 4545);
// El monitor HTTP (UI + POST /retry) queda DESACTIVADO por defecto. Solo se
// arranca si MCP_MONITOR_ENABLED === 'true' de forma explicita.
export const MONITOR_ENABLED =
String(process.env.MCP_MONITOR_ENABLED || "").toLowerCase() === "true";
// Por seguridad escucha solo en loopback salvo que se defina MCP_MONITOR_HOST.
export const MONITOR_HOST = process.env.MCP_MONITOR_HOST || "127.0.0.1";
// Compatibilidad: si alguien fuerza MCP_MONITOR_DISABLED tambien lo respetamos.
export const MONITOR_DISABLED = export const MONITOR_DISABLED =
String(process.env.MCP_MONITOR_DISABLED || "").toLowerCase() === "1" || String(process.env.MCP_MONITOR_DISABLED || "").toLowerCase() === "1" ||
String(process.env.MCP_MONITOR_DISABLED || "").toLowerCase() === "true"; String(process.env.MCP_MONITOR_DISABLED || "").toLowerCase() === "true";

View File

@@ -76,7 +76,12 @@ const verifyJwt = (token) => {
const resolveProjectCredentials = async (projectName, acaiUser = null) => { const resolveProjectCredentials = async (projectName, acaiUser = null) => {
try { 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) { if (!info.success) {
throw new Error(info.error || "Failed to resolve project info"); throw new Error(info.error || "Failed to resolve project info");
} }

View File

@@ -6,7 +6,7 @@
*/ */
// Load configuration first // Load configuration first
import { loadLocalConfigProfile, applyProfileToEnv } from "./config/index.js"; import { loadLocalConfigProfile, applyProfileToEnv, MONITOR_ENABLED, MONITOR_DISABLED } from "./config/index.js";
// Load and apply config profile (backward compatibility) // Load and apply config profile (backward compatibility)
const selectedProfile = loadLocalConfigProfile(); const selectedProfile = loadLocalConfigProfile();
@@ -30,8 +30,11 @@ import { registerResources } from "./resources/index.js";
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
setRegistrationFunctions({ registerPrompts, registerTools, registerResources }); setRegistrationFunctions({ registerPrompts, registerTools, registerResources });
// Create the shared request monitor (will be applied to each session server) // Create the shared request monitor (will be applied to each session server).
const requestMonitor = createRequestMonitor(); // Solo se crea si el monitor esta habilitado: asi no acumulamos historial en
// memoria ni envolvemos los handlers cuando la UI esta apagada (por defecto).
const monitorActive = MONITOR_ENABLED && !MONITOR_DISABLED;
const requestMonitor = monitorActive ? createRequestMonitor() : null;
// Create a server instance for retry functionality in the monitor UI // Create a server instance for retry functionality in the monitor UI
const server = createMcpServer(); const server = createMcpServer();

View File

@@ -2,7 +2,7 @@ import http from "node:http";
import fsPromises from "node:fs/promises"; import fsPromises from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { MONITOR_PORT, MONITOR_DISABLED } from "./config/index.js"; import { MONITOR_PORT, MONITOR_HOST, MONITOR_ENABLED, MONITOR_DISABLED } from "./config/index.js";
import { sessionCredentials } from "./auth/credentials.js"; import { sessionCredentials } from "./auth/credentials.js";
import { activeSessions } from "./httpServer.js"; import { activeSessions } from "./httpServer.js";
@@ -84,8 +84,8 @@ export function broadcastSessionsUpdate() {
* Start the monitor HTTP server * Start the monitor HTTP server
*/ */
export function startMonitorServer(requestMonitor, toolHandlers) { export function startMonitorServer(requestMonitor, toolHandlers) {
if (MONITOR_DISABLED) { if (!MONITOR_ENABLED || MONITOR_DISABLED) {
console.error("MCP monitor UI deshabilitada (MCP_MONITOR_DISABLED=1)."); console.error("[monitor] deshabilitado (MCP_MONITOR_ENABLED!=true)");
return null; return null;
} }
@@ -202,12 +202,12 @@ export function startMonitorServer(requestMonitor, toolHandlers) {
monitorServer.on("error", (error) => { monitorServer.on("error", (error) => {
console.warn( console.warn(
`[monitor] No se pudo iniciar la UI en el puerto ${MONITOR_PORT}: ${error.message}. Establece MCP_MONITOR_DISABLED=1 para ocultar este aviso.` `[monitor] No se pudo iniciar la UI en ${MONITOR_HOST}:${MONITOR_PORT}: ${error.message}. Desactiva MCP_MONITOR_ENABLED para ocultar este aviso.`
); );
}); });
monitorServer.listen(MONITOR_PORT, '0.0.0.0', () => { monitorServer.listen(MONITOR_PORT, MONITOR_HOST, () => {
console.error(`MCP monitor UI: http://0.0.0.0:${MONITOR_PORT}/monitor`); console.error(`MCP monitor UI: http://${MONITOR_HOST}:${MONITOR_PORT}/monitor`);
}); });
// Broadcast sessions + stats update every 2 seconds for real-time monitoring // Broadcast sessions + stats update every 2 seconds for real-time monitoring

View File

@@ -1,20 +1,41 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import { existsSync } from "node:fs";
import path from "node:path"; import path from "node:path";
/** /**
* Lectura directa de los markdown del knowledge base desde el filesystem. * Lectura directa de los markdown del knowledge base desde el filesystem.
* *
* El MCP server corre dentro del container `agentic` junto al FastAPI, asi * Orden de resolucion del directorio de docs:
* que los .md viven en `/app/docs/` (la imagen los copia ahi). * 1. `ACAI_DOCS_DIR` — override explicito por entorno (si esta definido y no vacio).
* * 2. `<ACAI_PROJECT_DIR>/docs` — caso principal: cada proyecto/web tiene su
* En caso de override por entorno, respeta `ACAI_DOCS_DIR`. En desarrollo * propio `docs/`. El `.mcp.json` inyecta `ACAI_PROJECT_DIR` (p.ej.
* fuera del container, fallback a paths relativos al cwd. * `/opt/acai/webs/<user>/<site>`), funciona tanto en local (VSCode) como
* en cloud (agentic).
* 3. `/app/docs` — fallback final: container `agentic` donde esta horneada la
* copia canonica de los .md.
*/ */
function dirExists(p) {
try {
return existsSync(p);
} catch {
return false;
}
}
function resolveDocsDir() { function resolveDocsDir() {
// 1. Override explicito
const override = process.env.ACAI_DOCS_DIR; const override = process.env.ACAI_DOCS_DIR;
if (override) return override; if (override && override.trim() !== "") return override;
// Container path
// 2. Docs del proyecto/web
const projectDir = process.env.ACAI_PROJECT_DIR;
if (projectDir && projectDir.trim() !== "") {
const projectDocs = path.join(projectDir, "docs");
if (dirExists(projectDocs)) return projectDocs;
}
// 3. Fallback al container agentic
return "/app/docs"; return "/app/docs";
} }

View File

@@ -3,7 +3,7 @@ import path from "path";
import { LOCAL_SERVER_URL, getLocalServerHeaders } from "../../config/index.js"; import { LOCAL_SERVER_URL, getLocalServerHeaders } from "../../config/index.js";
import { getCurrentSessionId } from "../../utils/sessionContext.js"; import { getCurrentSessionId } from "../../utils/sessionContext.js";
import { getMcpSessionCredentials } from "../../auth/credentials.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. * 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) { export async function callLocalFileEndpoint(method, endpoint, payload = null, query = null) {
const headers = getLocalServerHeaders(); const headers = getLocalServerHeaders();
const authHeader = process.env.ACAI_AUTH_HEADER || ""; 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 role = process.env.ACAI_ROLE_OVERRIDE || "";
if (authHeader) headers["Authorization"] = authHeader; if (authHeader) headers["Authorization"] = authHeader;

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,38 @@
import { z } from "zod"; import { z } from "zod";
import axios from "axios"; import axios from "axios";
import fs from "fs";
import path from "path"; import path from "path";
import { withAuth } from "../../auth/index.js"; import { withAuth } from "../../auth/index.js";
import { handleToolError } from "../helpers/errorHandler.js"; import { handleToolError } from "../helpers/errorHandler.js";
import { withAuthParams } from "../helpers/authSchema.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 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."; 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. * Carga la imagen como { mimeType, base64 }.
* Acepta `/api/chat-preview?file=xxx` o variantes con host. * - URL remota real (host público) → fetch directo por HTTP.
*/ * - Adjunto de chat, ruta del proyecto, o URL con host local → los bytes los
function resolveChatPreviewPath(imageUrl) { * resuelve el server Python (/api/image-bytes): disco para standalone, fetch
let qs; * de producción para imágenes Acai cuyo fichero local es un stub.
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.
*/ */
async function loadImage(imageUrl) { async function loadImage(imageUrl) {
// Caso 1: chat-preview local let parsed = null;
const localPath = resolveChatPreviewPath(imageUrl); try { parsed = new URL(imageUrl); } catch { parsed = null; }
if (localPath) { const isRemote = parsed
if (!fs.existsSync(localPath)) { && (parsed.protocol === "http:" || parsed.protocol === "https:")
throw new Error(`Local chat upload not found: ${path.basename(localPath)}`); && parsed.hostname && !isLocalResolvableHost(parsed.hostname);
}
const buffer = fs.readFileSync(localPath);
return {
mimeType: detectMimeType(localPath, buffer),
base64: buffer.toString("base64"),
};
}
// Caso 2: URL publica http(s) if (isRemote) {
if (imageUrl.startsWith("http://") || imageUrl.startsWith("https://")) {
const response = await axios.get(imageUrl, { const response = await axios.get(imageUrl, {
responseType: "arraybuffer", responseType: "arraybuffer",
timeout: 30000, timeout: 30000,
@@ -93,19 +87,21 @@ async function loadImage(imageUrl) {
const mimeType = headerMime && headerMime.startsWith("image/") const mimeType = headerMime && headerMime.startsWith("image/")
? headerMime ? headerMime
: detectMimeType(imageUrl.split("?")[0], buffer); : detectMimeType(imageUrl.split("?")[0], buffer);
return { return { mimeType, base64: buffer.toString("base64") };
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) { export function registerAnalyzeImageTool(server) {
server.tool( server.tool(
"analyze_image", "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({ withAuthParams({
image_url: z.string().describe("URL de la imagen. Acepta URL publica http(s):// o ruta relativa /api/chat-preview?file=..."), 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."), prompt: z.string().optional().describe("Que quieres saber de la imagen. Default: descripcion detallada."),

View File

@@ -5,7 +5,7 @@ import { withAuth, getSessionCredentials } from "../../auth/index.js";
import { handleToolError, validateRequired, handleApiResponse } from "../helpers/errorHandler.js"; import { handleToolError, validateRequired, handleApiResponse } from "../helpers/errorHandler.js";
import { withAuthParams } from "../helpers/authSchema.js"; import { withAuthParams } from "../helpers/authSchema.js";
import { AcaiHttpClient } from "../helpers/acaiHttpClient.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"; 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) * null si la URL no es local (usar imageUrl directamente)
*/ */
async function resolveLocalImageAsBase64(imageUrl) { 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) // URL http(s) con host NO local → es una URL pública real: usar tal cual (null).
if (typeof imageUrl === "string" && imageUrl.startsWith("/") && !imageUrl.startsWith("//")) { if (typeof imageUrl === "string" && /^https?:\/\//i.test(imageUrl)) {
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;
}
// Caso 2: URL HTTP — verificar si es local
let parsed; let parsed;
try { try { parsed = new URL(imageUrl); } catch { return null; }
parsed = new URL(imageUrl); if (!LOCAL_HOSTS.includes(parsed.hostname)) return null;
} catch {
return null;
}
if (!LOCAL_HOSTS.includes(parsed.hostname)) {
return null;
} }
// Intento A: descargar via HTTP (funciona cuando el host local es alcanzable) // 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 { try {
const axios = (await import("axios")).default; const { buffer } = await pythonGetBinary("/api/image-bytes", { project, ref: imageUrl });
const response = await axios.get(imageUrl, { let fileName = "image.jpg";
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) {
try { try {
const localPath = path.join(projectDir, parsed.pathname); const p = imageUrl.startsWith("/") ? imageUrl : new URL(imageUrl).pathname;
if (fs.existsSync(localPath) && fs.statSync(localPath).isFile()) { fileName = (p.split("?")[0].split("/").pop()) || "image.jpg";
const buffer = fs.readFileSync(localPath); } catch { /* keep default */ }
return { return { fileBase64: buffer.toString("base64"), fileName };
fileBase64: buffer.toString("base64"),
fileName: path.basename(localPath),
};
}
} catch (error) { } catch (error) {
console.error(`[upload] Filesystem fallback failed for ${imageUrl}: ${error.message}`); console.error(`[upload] /api/image-bytes falló para ${imageUrl}: ${error.message}`);
}
}
return null; return null;
}
} }
export function registerUploadRecordImageTool(server) { export function registerUploadRecordImageTool(server) {
server.tool( server.tool(
"upload_record_image", "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({ withAuthParams({
tableName: z.string().describe("Table name without 'cms_' prefix (e.g., 'productos')"), tableName: z.string().describe("Table name without 'cms_' prefix (e.g., 'productos')"),
recordId: z.string().describe("Record 'num' (primary key)"), 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."), 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)"), alt: z.string().optional().describe("Alt text for the image (optional)"),
}), }),
{ readOnlyHint: false, destructiveHint: false }, { readOnlyHint: false, destructiveHint: false },
@@ -119,6 +83,39 @@ export function registerUploadRecordImageTool(server) {
); );
if (validationError) return validationError; 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()); const projectSlug = path.basename(resolveCurrentProjectDir());
// Intentar via Python server (tiene sync + optimizacion) // Intentar via Python server (tiene sync + optimizacion)
@@ -238,7 +235,7 @@ export function registerUploadRecordImageTool(server) {
recordId: z.string().describe("Record 'num' (primary key)"), recordId: z.string().describe("Record 'num' (primary key)"),
fieldName: z.string().describe("Upload field name"), fieldName: z.string().describe("Upload field name"),
uploadId: z.string().describe("Upload ID to replace (get from list_record_uploads)"), 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)"), alt: z.string().optional().describe("Alt text for the image (optional)"),
}), }),
{ readOnlyHint: false, destructiveHint: false }, { readOnlyHint: false, destructiveHint: false },

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ pydantic-settings>=2.7.0,<3.0.0
redis[hiredis]>=5.2.0,<6.0.0 redis[hiredis]>=5.2.0,<6.0.0
anthropic>=0.42.0,<1.0.0 anthropic>=0.42.0,<1.0.0
openai>=1.60.0,<2.0.0 openai>=1.60.0,<2.0.0
litellm==1.80.0
httpx>=0.28.0,<1.0.0 httpx>=0.28.0,<1.0.0
sse-starlette>=2.2.0,<3.0.0 sse-starlette>=2.2.0,<3.0.0
tiktoken>=0.7.0,<1.0.0 tiktoken>=0.7.0,<1.0.0

View File

@@ -7,6 +7,15 @@ from dataclasses import dataclass, field
from typing import Any, AsyncIterator 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 @dataclass
class StreamChunk: class StreamChunk:
"""A single chunk from a streaming model response. """A single chunk from a streaming model response.
@@ -57,6 +66,10 @@ class ModelConfig:
max_tokens: int = 4096 max_tokens: int = 4096
temperature: float = 0.3 temperature: float = 0.3
stop_sequences: list[str] = field(default_factory=list) 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) extra: dict[str, Any] = field(default_factory=dict)

View File

@@ -17,20 +17,24 @@ from .base import ModelAdapter, ModelConfig, ModelResponse, StreamChunk
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Algunos fine-tunes (sobre todo MiniMax) ocasionalmente emiten las tool calls # Algunos fine-tunes (sobre todo MiniMax y DeepSeek) ocasionalmente emiten las
# como texto literal en lugar de usar los `tool_use` blocks nativos. Vistos # tool calls como texto literal en lugar de usar los `tool_use` blocks nativos.
# tres formatos: # Vistos cuatro formatos:
# 1) <minimax:tool_call><invoke name="X"><parameter name="P">V</parameter></invoke></minimax:tool_call> # 1) <minimax:tool_call><invoke name="X"><parameter name="P">V</parameter></invoke></minimax:tool_call>
# 2) <invoke name="X"><parameter name="P">V</parameter></invoke> (sin minimax wrapper) # 2) <invoke name="X"><parameter name="P">V</parameter></invoke> (sin minimax wrapper)
# 3) <tool_call>{"name":"X","parameters":{...}}{"name":"Y","parameters":{...}}</tool_call> # 3) <tool_call>{"name":"X","parameters":{...}}{"name":"Y","parameters":{...}}</tool_call>
# (multiples tool calls JSON-encoded dentro de un solo wrapper) # (multiples tool calls JSON-encoded dentro de un solo wrapper)
# 4) <DSMLtool_calls><DSMLinvoke name="X"><DSMLparameter name="P" string="true">V</DSMLparameter></DSMLinvoke></DSMLtool_calls>
# (formato DSML de DeepSeek — usa U+FF5C fullwidth vertical line como separador)
# #
# Cuando eso pasa el orquestador ve "texto" y la tool nunca se ejecuta — el # Cuando eso pasa el orquestador ve "texto" y la tool nunca se ejecuta — el
# usuario ve el markup crudo en el chat. Detectamos y convertimos a tool_use # usuario ve el markup crudo en el chat. Detectamos y convertimos a tool_use
# sintetico mientras streameamos. Es un parche defensivo: el caso normal # sintetico mientras streameamos. Es un parche defensivo: el caso normal
# (tool_use blocks) sigue por el camino estandar. # (tool_use blocks) sigue por el camino estandar.
_TOOL_CALL_OPEN_RE = re.compile( _TOOL_CALL_OPEN_RE = re.compile(
r"<(?:minimax:tool_call|invoke\s+name|tool_call\s*>)|\[TOOL_CALL\]", # `<` (U+FF5C) cubre cualquier special-token DeepSeek (DSML): <DSMLinvoke,
# <tool_calls, etc. Tolerante a 1+ pipes y a la presencia/ausencia de "DSML".
r"<(?:minimax:tool_call|invoke\s+name|tool_call[\s>]|use_mcp_tool|mm_special)|\[TOOL_CALL\]|<",
re.IGNORECASE, re.IGNORECASE,
) )
_INVOKE_RE = re.compile( _INVOKE_RE = re.compile(
@@ -65,6 +69,21 @@ _PERL_ARGS_BLOCK_RE = re.compile(
_PERL_KV_RE = re.compile( _PERL_KV_RE = re.compile(
r"--([a-zA-Z_][a-zA-Z0-9_]*)\s+(\"[^\"]*\"|\'[^\']*\'|-?\d+(?:\.\d+)?|true|false|null)", r"--([a-zA-Z_][a-zA-Z0-9_]*)\s+(\"[^\"]*\"|\'[^\']*\'|-?\d+(?:\.\d+)?|true|false|null)",
) )
# Formato 5 (DeepSeek DSML). Formato oficial V4-Pro: el marcador es `DSML`
# con UN pipe fullwidth (U+FF5C) a cada lado — <DSMLinvoke name="X"> ...
# <DSMLparameter name="P" string="true|false">V</DSMLparameter> ...
# </DSMLinvoke>. Hacemos el regex TOLERANTE: 1+ pipes y "DSML" opcional,
# para cubrir variantes entre versiones del modelo. El atributo `string`
# decide el tipo del valor: "true" = string crudo, "false" = valor JSON.
_DSML_INVOKE_RE = re.compile(
r"<+(?:DSML+)?invoke\s+name=\"([^\"]+)\"[^>]*>(.*?)</+(?:DSML+)?invoke\s*>",
re.IGNORECASE | re.DOTALL,
)
_DSML_PARAM_RE = re.compile(
r"<+(?:DSML+)?parameter\s+name=\"([^\"]+)\"([^>]*)>(.*?)</+(?:DSML+)?parameter\s*>",
re.IGNORECASE | re.DOTALL,
)
_DSML_STRING_ATTR_RE = re.compile(r"string\s*=\s*\"(true|false)\"", re.IGNORECASE)
def _safe_emit_split(buf: str) -> str: def _safe_emit_split(buf: str) -> str:
@@ -91,8 +110,8 @@ def _safe_emit_split(buf: str) -> str:
# Si el tail ya tiene `>` cerrado, es un tag normal — emitir todo. # Si el tail ya tiene `>` cerrado, es un tag normal — emitir todo.
if ">" in tail: if ">" in tail:
return buf return buf
# Si el tail puede ser inicio de tool_call/invoke/tool_call_json, retenerlo. # Si el tail puede ser inicio de tool_call/invoke/tool_call_json/dsml, retenerlo.
candidates = ("<minimax:tool_call", "<invoke", "<tool_call") candidates = ("<minimax:tool_call", "<invoke", "<tool_call", "<")
for cand in candidates: for cand in candidates:
if cand.startswith(tail.lower()) or tail.lower().startswith(cand[:len(tail)].lower()): if cand.startswith(tail.lower()) or tail.lower().startswith(cand[:len(tail)].lower()):
return buf[:idx] return buf[:idx]
@@ -212,6 +231,35 @@ def _parse_xml_tool_calls(text: str) -> list[dict[str, Any]]:
"arguments": args, "arguments": args,
}) })
# Formato 5 (DeepSeek DSML):
# <DSMLinvoke name="X"><DSMLparameter name="P" string="true">V</DSMLparameter></DSMLinvoke>
for m in _DSML_INVOKE_RE.finditer(text):
name = m.group(1).strip()
body = m.group(2)
args_dsml: dict[str, Any] = {}
for p in _DSML_PARAM_RE.finditer(body):
pname = p.group(1).strip()
attrs = p.group(2) or ""
raw_val = p.group(3)
sm = _DSML_STRING_ATTR_RE.search(attrs)
if sm and sm.group(1).lower() == "true":
# string="true": valor es string crudo — NO strip (preserva
# whitespace significativo, p.ej. contenido de ficheros).
args_dsml[pname] = raw_val
else:
# string="false" (o ausente): valor JSON (num/bool/array/obj/string).
# Si no parsea, cae a string sin tocar.
try:
args_dsml[pname] = json.loads(raw_val.strip())
except (json.JSONDecodeError, ValueError):
args_dsml[pname] = raw_val.strip()
if name:
calls.append({
"id": "xml_{}".format(uuid.uuid4().hex[:12]),
"name": name,
"arguments": args_dsml,
})
return calls return calls

View File

@@ -0,0 +1,83 @@
"""LiteLLM model adapter — spike para A/B contra el adapter OpenAI/DeepSeek nativo.
Reutiliza TODO el flujo de OpenAIAdapter (procesado de chunks, conversión de
mensajes, tools, fallback DSML) y solo cambia la llamada al modelo: en vez del
SDK de OpenAI, enruta por LiteLLM, que trae handling específico por proveedor
(DeepSeek incluido) y podría resolver de fábrica el DSML / reasoning_content que
hoy parcheamos a mano.
Activar con `AGENTIC_DEFAULT_MODEL_PROVIDER=litellm`. Modelo via
`AGENTIC_LITELLM_MODEL` (p.ej. "deepseek/deepseek-v4-pro"); si vacío, deriva de
`AGENTIC_DEFAULT_MODEL_ID`. Reusa `openai_api_key` / `openai_base_url` como
credenciales.
"""
from __future__ import annotations
import logging
from typing import Any
import litellm
from ..config import settings
from .openai_adapter import OpenAIAdapter
logger = logging.getLogger(__name__)
# Que LiteLLM descarte params no soportados por el proveedor en vez de petar.
litellm.drop_params = True
# Silenciar el spam INFO de litellm ("LiteLLM completion() model=...").
litellm.suppress_debug_info = True
logging.getLogger("LiteLLM").setLevel(logging.WARNING)
class LiteLLMAdapter(OpenAIAdapter):
"""Enruta las llamadas por LiteLLM, reutilizando el pipeline de OpenAIAdapter."""
def __init__(
self,
model: str | None = None,
api_key: str | None = None,
base_url: str | None = None,
) -> None:
# NO llamamos a super().__init__: no necesitamos el cliente AsyncOpenAI.
self._litellm_model = model or settings.litellm_model or self._derive_model()
self._api_key = api_key or settings.openai_api_key or None
self._api_base = base_url or settings.openai_base_url or None
# LiteLLM no entrega usage fiable en streaming → estimar para billing.
self._estimate_usage_fallback = True
logger.info(
"LiteLLMAdapter: model=%s api_base=%s",
self._litellm_model, self._api_base or "(default)",
)
@staticmethod
def _derive_model() -> str:
mid = settings.default_model_id or "deepseek-chat"
# Si ya trae prefijo de proveedor ("deepseek/...", "openai/..."), respetar.
return mid if "/" in mid else f"deepseek/{mid}"
async def _acreate(self, kwargs: dict[str, Any]):
kwargs = dict(kwargs)
# Respetar el model_id por request (resuelto dinámicamente en
# send_message). Solo se honra si trae prefijo de proveedor
# ("deepseek/...", "openrouter/..."); cualquier otro valor (default
# no-litellm, vacío) cae al modelo por defecto del adapter — preserva el
# comportamiento previo para llamadas internas (planner, completions).
model = kwargs.get("model") or ""
if "/" not in model:
model = self._litellm_model
kwargs["model"] = model
if model.startswith("openrouter/"):
# OpenRouter: LiteLLM enruta con OPENROUTER_API_KEY del entorno y su
# base propia. NO forzar api_key/api_base del proxy DeepSeek — lo
# sobreescribirían y romperían el routing.
kwargs.pop("api_key", None)
kwargs.pop("api_base", None)
else:
if self._api_key:
kwargs["api_key"] = self._api_key
if self._api_base:
kwargs["api_base"] = self._api_base
return await litellm.acompletion(**kwargs)

View File

@@ -9,10 +9,54 @@ from typing import Any, AsyncIterator
from openai import AsyncOpenAI from openai import AsyncOpenAI
from ..config import settings from ..config import settings
from .base import ModelAdapter, ModelConfig, ModelResponse, StreamChunk from .base import (
ContextOverflowError,
ModelAdapter,
ModelConfig,
ModelResponse,
StreamChunk,
)
logger = logging.getLogger(__name__) 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
streaming). Aproximada pero evita billing 0."""
from ..context.compactor import estimate_tokens
inp = 0
for m in messages:
c = m.get("content")
if isinstance(c, str):
inp += estimate_tokens(c)
elif isinstance(c, list):
for b in c:
if isinstance(b, dict):
inp += estimate_tokens(
b.get("text") or b.get("thinking") or str(b.get("content") or "")
)
return {"input_tokens": inp, "output_tokens": estimate_tokens(output_text or "")}
class OpenAIAdapter(ModelAdapter): class OpenAIAdapter(ModelAdapter):
"""Adapter for the OpenAI API (GPT-4o, o1, etc.).""" """Adapter for the OpenAI API (GPT-4o, o1, etc.)."""
@@ -25,6 +69,15 @@ class OpenAIAdapter(ModelAdapter):
if url: if url:
kwargs["base_url"] = url kwargs["base_url"] = url
self._client = AsyncOpenAI(**kwargs) self._client = AsyncOpenAI(**kwargs)
# El path nativo conserva el usage real del proveedor; subclases que no
# reciben usage fiable en streaming (LiteLLM) lo ponen a True para estimar.
self._estimate_usage_fallback = False
async def _acreate(self, kwargs: dict[str, Any]):
"""Hook de la llamada al modelo. Subclases (p.ej. LiteLLMAdapter) lo
sobreescriben para enrutar por otra librería sin tocar el resto del
flujo (procesado de chunks, tools, mensajes)."""
return await self._client.chat.completions.create(**kwargs)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Streaming # Streaming
@@ -35,6 +88,26 @@ class OpenAIAdapter(ModelAdapter):
messages: list[dict[str, Any]], messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None, tools: list[dict[str, Any]] | None = None,
config: ModelConfig | 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]: ) -> AsyncIterator[StreamChunk]:
config = config or ModelConfig( config = config or ModelConfig(
model_id=settings.default_model_id, model_id=settings.default_model_id,
@@ -43,43 +116,92 @@ class OpenAIAdapter(ModelAdapter):
) )
kwargs: dict[str, Any] = { kwargs: dict[str, Any] = {
"model": config.model_id or "gpt-4o", "model": config.model_id or settings.default_model_id or "gpt-4o",
"max_tokens": config.max_tokens, "max_tokens": config.max_tokens,
"temperature": config.temperature, "temperature": config.temperature,
"messages": messages, "messages": self._to_openai_messages(messages),
"stream": True, "stream": True,
"stream_options": {"include_usage": True}, "stream_options": {"include_usage": True},
} }
if getattr(config, "reasoning_effort", ""):
kwargs["reasoning_effort"] = config.reasoning_effort
if tools: if tools:
kwargs["tools"] = self._format_tools(tools) kwargs["tools"] = self._format_tools(tools)
stream = await self._client.chat.completions.create(**kwargs) stream = await self._acreate(kwargs)
# Fallback de tool-calls-en-texto: DeepSeek a veces emite las tool calls
# en su formato interno DSML como TEXTO (en el content) en vez de como
# tool_calls nativos. El endpoint OpenAI no lo convierte, asi que sin
# esto el agente "se para" mostrando DSML inerte. Reutilizamos el parser
# del claude_adapter.
from .claude_adapter import _parse_xml_tool_calls, _TOOL_CALL_OPEN_RE
tool_calls_acc: dict[int, dict[str, str]] = {} tool_calls_acc: dict[int, dict[str, str]] = {}
final_usage: dict[str, int] = {} final_usage: dict[str, int] = {}
usage_emitted = False # evita doble conteo si llega usage tras estimar
full_content = "" # content acumulado (para el fallback DSML)
full_reasoning = "" # razonamiento acumulado (para estimar usage)
emitted_chars = 0 # cuanto de full_content ya se emitio como delta
suppress_text = False # tras detectar un tool-call-en-texto, no emitir mas
# DeepSeek thinking mode: el razonamiento llega en `delta.reasoning_content`
# (antes del content). Lo acumulamos como un bloque `thinking` (block_index 0)
# para que el orquestador lo persista y `_to_openai_messages` lo reenvie como
# `reasoning_content` en el siguiente turno — DeepSeek lo exige en multi-turno
# con tool calls ("reasoning_content ... must be passed back to the API").
reasoning_seen = False
reasoning_sig_emitted = False
async for chunk in stream: async for chunk in stream:
# With include_usage, the last chunk has usage but no choices # With include_usage, the last chunk has usage but no choices.
if chunk.usage: # getattr: el chunk de LiteLLM (ModelResponseStream) no siempre trae
# el atributo `usage`; el del SDK OpenAI sí (None salvo el ultimo).
chunk_usage = getattr(chunk, "usage", None)
if chunk_usage:
final_usage = { final_usage = {
"input_tokens": chunk.usage.prompt_tokens or 0, "input_tokens": getattr(chunk_usage, "prompt_tokens", 0) or 0,
"output_tokens": chunk.usage.completion_tokens or 0, "output_tokens": getattr(chunk_usage, "completion_tokens", 0) or 0,
} }
choice = chunk.choices[0] if chunk.choices else None choice = chunk.choices[0] if chunk.choices else None
if not choice: if not choice:
# Usage-only chunk (last one with include_usage) — emit it # Usage-only chunk (last one with include_usage) — emit it
if final_usage: if final_usage and not usage_emitted:
yield StreamChunk(usage=final_usage) yield StreamChunk(usage=final_usage)
final_usage = {} # Only emit once usage_emitted = True
continue continue
delta = choice.delta delta = choice.delta
# Reasoning content (DeepSeek thinking mode). Llega como campo extra
# del delta; lo emitimos como thinking_delta en el bloque index 0.
reasoning_txt = getattr(delta, "reasoning_content", None) if delta else None
if reasoning_txt:
reasoning_seen = True
full_reasoning += reasoning_txt
yield StreamChunk(
thinking_delta=reasoning_txt,
block_type="thinking",
block_index=0,
)
# Text content # Text content
if delta and delta.content: if delta and delta.content:
yield StreamChunk(delta=delta.content) full_content += delta.content
if not suppress_text:
# Si arranca un tool call en texto (DSML/XML), emitimos lo
# previo y dejamos de emitir el resto (el DSML no debe verse).
m = _TOOL_CALL_OPEN_RE.search(full_content, emitted_chars)
if m:
suppress_text = True
if m.start() > emitted_chars:
yield StreamChunk(delta=full_content[emitted_chars:m.start()])
emitted_chars = len(full_content)
else:
yield StreamChunk(delta=full_content[emitted_chars:])
emitted_chars = len(full_content)
# Tool calls # Tool calls
if delta and delta.tool_calls: if delta and delta.tool_calls:
@@ -109,7 +231,31 @@ class OpenAIAdapter(ModelAdapter):
# Finish # Finish
if choice.finish_reason: if choice.finish_reason:
if choice.finish_reason == "tool_calls": # Cerrar el bloque de razonamiento (si lo hubo) con un signature
# sintetico: el orquestador descarta thinking blocks sin signature
# (proteccion para MiniMax/Anthropic). DeepSeek no usa signatures;
# este marcador solo evita el descarte y NUNCA se reenvia — en
# `_to_openai_messages` el bloque se mapea a `reasoning_content`.
if reasoning_seen and not reasoning_sig_emitted:
reasoning_sig_emitted = True
yield StreamChunk(
thinking_signature="deepseek-reasoning",
block_type="thinking",
block_index=0,
)
# Fallback de usage: algunos proveedores via LiteLLM no entregan el
# chunk de usage (o llega tras el break del orquestador) → billing 0.
# Estimamos por tokens para no infra-cobrar. Solo si el adapter lo
# pide (LiteLLM); el path nativo conserva el usage real del proveedor.
if self._estimate_usage_fallback and not final_usage and not usage_emitted:
final_usage = _estimate_usage(messages, full_content + "\n" + full_reasoning)
# IMPORTANTE: DeepSeek (endpoint OpenAI) a veces cierra el stream
# con finish_reason="stop" AUNQUE haya emitido tool_calls. Si nos
# fiamos solo de =="tool_calls" perdemos esos tool calls: el agente
# anuncia la accion en texto y "se para" sin ejecutarla. Por eso
# disparamos los tool_use SIEMPRE que haya tool calls acumulados,
# sea cual sea el finish_reason.
if tool_calls_acc:
for acc in tool_calls_acc.values(): for acc in tool_calls_acc.values():
yield StreamChunk( yield StreamChunk(
tool_call_id=acc["id"], tool_call_id=acc["id"],
@@ -118,15 +264,33 @@ class OpenAIAdapter(ModelAdapter):
finish_reason="tool_use", finish_reason="tool_use",
) )
# Emit usage after tool_use chunks # Emit usage after tool_use chunks
if final_usage: if final_usage and not usage_emitted:
yield StreamChunk(usage=final_usage) yield StreamChunk(usage=final_usage)
usage_emitted = True
else:
# Fallback: DeepSeek pudo emitir las tool calls como TEXTO
# (DSML/XML) en vez de nativas. Parseamos el content y, si hay
# tool calls, las ejecutamos igual; si no, cerramos el turno.
text_calls = _parse_xml_tool_calls(full_content) if full_content else []
if text_calls:
for c in text_calls:
yield StreamChunk(
tool_call_id=c["id"],
tool_name=c["name"],
tool_arguments=json.dumps(c.get("arguments", {}), ensure_ascii=False),
finish_reason="tool_use",
)
if final_usage and not usage_emitted:
yield StreamChunk(usage=final_usage)
usage_emitted = True
else: else:
yield StreamChunk( yield StreamChunk(
finish_reason="end_turn" finish_reason="end_turn"
if choice.finish_reason == "stop" if choice.finish_reason in ("stop", "tool_calls")
else choice.finish_reason, else choice.finish_reason,
usage=final_usage, usage=final_usage if not usage_emitted else {},
) )
usage_emitted = True
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Non-streaming # Non-streaming
@@ -145,11 +309,13 @@ class OpenAIAdapter(ModelAdapter):
) )
kwargs: dict[str, Any] = { kwargs: dict[str, Any] = {
"model": config.model_id or "gpt-4o", "model": config.model_id or settings.default_model_id or "gpt-4o",
"max_tokens": config.max_tokens, "max_tokens": config.max_tokens,
"temperature": config.temperature, "temperature": config.temperature,
"messages": messages, "messages": self._to_openai_messages(messages),
} }
if getattr(config, "reasoning_effort", ""):
kwargs["reasoning_effort"] = config.reasoning_effort
if tools: if tools:
kwargs["tools"] = self._format_tools(tools) kwargs["tools"] = self._format_tools(tools)
# Fuerza al modelo a usar un tool concreto para garantizar JSON por schema # Fuerza al modelo a usar un tool concreto para garantizar JSON por schema
@@ -161,7 +327,14 @@ class OpenAIAdapter(ModelAdapter):
"function": {"name": force_tool}, "function": {"name": force_tool},
} }
response = await self._client.chat.completions.create(**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] choice = response.choices[0]
content = choice.message.content or "" content = choice.message.content or ""
@@ -204,19 +377,242 @@ class OpenAIAdapter(ModelAdapter):
@staticmethod @staticmethod
def _format_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]: def _format_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Convert internal tool definitions to OpenAI function calling format.""" """Convert internal tool definitions to OpenAI function calling format.
Si `deepseek_strict_tools`, marca cada funcion con `strict: true` y limpia
del schema los keywords que DeepSeek strict NO soporta (minLength/maxLength/
minItems/maxItems), que de otro modo darian 400."""
strict = settings.deepseek_strict_tools
formatted: list[dict[str, Any]] = [] formatted: list[dict[str, Any]] = []
for tool in tools: for tool in tools:
formatted.append( params = tool.get("input_schema", tool.get("parameters", {"type": "object"}))
{ fn: dict[str, Any] = {
"type": "function",
"function": {
"name": tool["name"], "name": tool["name"],
"description": tool.get("description", ""), "description": tool.get("description", ""),
"parameters": tool.get( "parameters": OpenAIAdapter._sanitize_strict_schema(params) if strict else params,
"input_schema", tool.get("parameters", {"type": "object"}) }
), if strict:
fn["strict"] = True
formatted.append({"type": "function", "function": fn})
return formatted
# Keywords no soportados por DeepSeek strict mode (segun docs oficiales).
_STRICT_UNSUPPORTED_KEYS = ("minLength", "maxLength", "minItems", "maxItems")
@staticmethod
def _sanitize_strict_schema(schema: Any) -> Any:
"""Elimina recursivamente keywords no soportados por DeepSeek strict."""
if isinstance(schema, dict):
return {
k: OpenAIAdapter._sanitize_strict_schema(v)
for k, v in schema.items()
if k not in OpenAIAdapter._STRICT_UNSUPPORTED_KEYS
}
if isinstance(schema, list):
return [OpenAIAdapter._sanitize_strict_schema(x) for x in schema]
return schema
@staticmethod
def _blocks_text(content: Any) -> str:
"""Extrae texto plano de un content que puede ser str o lista de bloques."""
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for b in content:
if isinstance(b, dict):
parts.append(b.get("text") or b.get("content") or "")
else:
parts.append(str(b))
return "\n".join(p for p in parts if p)
return str(content)
def _to_openai_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Convierte los mensajes del formato interno (Anthropic-style, con bloques
`tool_use` / `tool_result`) al formato de la API OpenAI (`tool_calls` en el
assistant, mensajes `role: tool` con `tool_call_id`). El contexto se construye
en formato Anthropic, así que sin esto la API OpenAI de DeepSeek rechaza el
body ('unknown variant tool_use')."""
out: list[dict[str, Any]] = []
for msg in messages:
role = msg.get("role")
content = msg.get("content")
if role == "system":
out.append({"role": "system", "content": content if isinstance(content, str) else self._blocks_text(content)})
continue
if not isinstance(content, list):
out.append({"role": role, "content": content if isinstance(content, str) else str(content or "")})
continue
if role == "assistant":
text_parts: list[str] = []
tool_calls: list[dict[str, Any]] = []
reasoning_parts: list[str] = []
for b in content:
if not isinstance(b, dict):
continue
t = b.get("type")
if t == "text":
text_parts.append(b.get("text", ""))
elif t == "thinking":
# DeepSeek thinking mode: el razonamiento del turno debe
# reenviarse como `reasoning_content` (no como signature).
rc = b.get("thinking", "")
if rc:
reasoning_parts.append(rc)
elif t == "tool_use":
tool_calls.append({
"id": b.get("id", ""),
"type": "function",
"function": {
"name": b.get("name", ""),
"arguments": json.dumps(b.get("input", {}), ensure_ascii=False),
}, },
})
text_joined = "\n".join(p for p in text_parts if p)
m: dict[str, Any] = {"role": "assistant", "content": (text_joined or None)}
if reasoning_parts:
if not text_joined and not tool_calls:
# Quirk DeepSeek thinking: a veces emite TODA la respuesta
# en reasoning_content y cierra sin content ni tool_calls.
# Reenviar content=None sin tool_calls rompe la API
# ("content or tool_calls must be set"), asi que promovemos
# el reasoning a content (sin duplicarlo como reasoning_content).
m["content"] = "\n".join(reasoning_parts)
else:
m["reasoning_content"] = "\n".join(reasoning_parts)
if tool_calls:
m["tool_calls"] = tool_calls
out.append(m)
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
t = b.get("type")
if t == "tool_result":
out.append({
"role": "tool",
"tool_call_id": b.get("tool_use_id", ""),
"content": self._blocks_text(b.get("content")),
})
elif t == "text":
text_parts.append(b.get("text", ""))
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
# proveedor devuelve 400 y la sesion queda bloqueada. Cinturon y tirantes.
return self._repair_tool_sequence(out)
@staticmethod
def _repair_tool_sequence(out: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Garantiza el contrato OpenAI sobre la secuencia ya convertida:
- Todo `role: tool` debe responder a un tool_call_id del assistant
inmediatamente anterior (o de su bloque contiguo de tool messages).
Si no → se convierte a user con placeholder.
- Todo assistant con `tool_calls` debe tener respuesta para CADA id.
Los tool_calls sin respuesta se eliminan; si la lista queda vacia se
elimina la key (y se asegura `content` no-None — "content or
tool_calls must be set").
No deberia activarse nunca (el compactor repara antes); si se activa,
loguea warning para detectar regresiones del compactor.
"""
repaired: list[dict[str, Any]] = []
i = 0
n = len(out)
while i < n:
msg = out[i]
role = msg.get("role")
if role == "assistant" and msg.get("tool_calls"):
# Bloque contiguo de tool messages que responden a este assistant.
j = i + 1
block: list[dict[str, Any]] = []
while j < n and out[j].get("role") == "tool":
block.append(out[j])
j += 1
answered = {t.get("tool_call_id", "") for t in block}
kept_calls = [
tc for tc in msg["tool_calls"] if tc.get("id", "") in answered
]
dropped = [
tc for tc in msg["tool_calls"] if tc.get("id", "") not in answered
]
new_msg = dict(msg)
if dropped:
for tc in dropped:
logger.warning(
"repaired unanswered tool_call at index %d (tool_call_id=%s)",
i,
tc.get("id", ""),
)
if kept_calls:
new_msg["tool_calls"] = kept_calls
else:
new_msg.pop("tool_calls", None)
if new_msg.get("content") is None:
# Promover reasoning a content si existe (mismo
# criterio que el quirk DeepSeek de arriba); si no,
# placeholder para no enviar content=None sin tools.
rc = new_msg.pop("reasoning_content", None)
new_msg["content"] = rc or "[ASSISTANT COMPACTADO]"
repaired.append(new_msg)
valid_ids = {tc.get("id", "") for tc in kept_calls}
converted: list[dict[str, Any]] = []
for t in block:
if t.get("tool_call_id", "") in valid_ids:
repaired.append(t)
else:
logger.warning(
"repaired orphan tool message (tool_call_id=%s)",
t.get("tool_call_id", ""),
)
converted.append(
{
"role": "user",
"content": "[Resultado de herramienta (contexto compactado)]: "
+ str(t.get("content", ""))[:500],
} }
) )
return formatted # Los huerfanos convertidos van DESPUES del bloque de tools
# validos para no romper la contiguidad assistant → tools.
repaired.extend(converted)
i = j
continue
if role == "tool":
# Tool message sin assistant con tool_calls delante → huerfano.
logger.warning(
"repaired orphan tool message at index %d (tool_call_id=%s)",
i,
msg.get("tool_call_id", ""),
)
repaired.append(
{
"role": "user",
"content": "[Resultado de herramienta (contexto compactado)]: "
+ str(msg.get("content", ""))[:500],
}
)
i += 1
continue
repaired.append(msg)
i += 1
return repaired

View File

@@ -46,6 +46,10 @@ class SendMessageRequest(BaseModel):
message: str message: str
stream: bool = False stream: bool = False
agent_id: str | None = None 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. # 'off' (default): la tool acai_plan no se expone al modelo, ejecuta directo.
# 'force': system prompt obliga a llamar acai_plan antes de ejecutar. # 'force': system prompt obliga a llamar acai_plan antes de ejecutar.
# 'auto' (legacy): se trata como 'off'. UI: toggle en ChatPanel. # 'auto' (legacy): se trata como 'off'. UI: toggle en ChatPanel.
@@ -335,6 +339,25 @@ async def send_message(
if not agent_profile: if not agent_profile:
agent_profile = agent_reg.get(agent_reg.default_agent_id) 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. # Plan mode controlado por el usuario desde el toggle del ChatPanel.
# 'auto' (default): heuristica del modelo trivial-vs-complex. # 'auto' (default): heuristica del modelo trivial-vs-complex.
# 'force': el agente DEBE llamar acai_plan como primera accion. # 'force': el agente DEBE llamar acai_plan como primera accion.
@@ -359,7 +382,7 @@ async def send_message(
if body.stream: if body.stream:
task = asyncio.create_task( 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 _running_executions[session_id] = task
# Auto-limpieza del registro al terminar (solo si seguimos siendo la # 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", "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 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 # Acquire exclusive lock — prevents concurrent execution on same session
async with storage.session_lock(session.session_id) as acquired: async with storage.session_lock(session.session_id) as acquired:
if not acquired: if not acquired:
@@ -391,8 +414,18 @@ async def _execute_and_persist(orchestrator, storage, session, message) -> dict[
"status": "busy", "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: 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 return result
except asyncio.CancelledError: except asyncio.CancelledError:
# Ejecución abortada por el usuario (stop) o preemptada por un # 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` # que el `await task` de la cancelación complete. El `finally`
# persiste el estado y el `session_lock` se libera al salir. # persiste el estado y el `session_lock` se libera al salir.
logger.info("Execution cancelled for session %s", session.session_id) 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.status = SessionStatus.ACTIVE
session.current_task = None session.current_task = None
raise raise
@@ -427,9 +478,9 @@ async def _execute_and_persist(orchestrator, storage, session, message) -> dict[
async def abort_session(session_id: str) -> dict[str, Any]: async def abort_session(session_id: str) -> dict[str, Any]:
"""Cancela la ejecución en curso de una sesión (botón Stop del chat). """Cancela la ejecución en curso de una sesión (botón Stop del chat).
Cancela la tarea detached (liberando el session_lock), cierra el stream SSE Cancela la tarea detached (liberando el session_lock) y cierra el stream
de los suscriptores y limpia un posible lock huérfano. Idempotente: si no SSE de los suscriptores. Idempotente: si no hay nada en curso devuelve
hay nada en curso devuelve `no_active_execution` sin error. `no_active_execution` sin error.
""" """
storage = _get_storage() storage = _get_storage()
session = await storage.get_session(session_id) session = await storage.get_session(session_id)
@@ -452,8 +503,13 @@ async def abort_session(session_id: str) -> dict[str, Any]:
except Exception as e: except Exception as e:
logger.warning("Failed to close SSE stream on abort for %s: %s", session_id, e) logger.warning("Failed to close SSE stream on abort for %s: %s", session_id, e)
# Defensa: liberar un lock huérfano (p.ej. de una ejecución previa que crasheó # Limpiar el lock SOLO si cancelamos una ejecución de verdad: el `finally`
# antes de soltarlo) para no bloquear el siguiente mensaje hasta el TTL. # de la tarea cancelada puede no llegar a liberar el lock de forma fiable.
# `clear_session_lock` borra incondicional (sin conocer el token del lock),
# así que invocarlo sin cancelación confirmada borraría el lock de una
# ejecución síncrona (stream=false) aún viva — que no se registra en
# _running_executions — y permitiría una segunda ejecución concurrente.
if cancelled:
try: try:
await storage.clear_session_lock(session_id) await storage.clear_session_lock(session_id)
except Exception as e: except Exception as e:
@@ -532,11 +588,20 @@ async def get_session(session_id: str) -> SessionResponse:
"status": plan.get("status", "active"), "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( return SessionResponse(
session_id=session.session_id, session_id=session.session_id,
status=session.status.value, status=session.status.value,
turn_count=session.turn_count, 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, completed_tasks=session.completed_tasks,
created_at=session.created_at.isoformat(), created_at=session.created_at.isoformat(),
updated_at=session.updated_at.isoformat(), updated_at=session.updated_at.isoformat(),
@@ -781,22 +846,64 @@ async def _load_knowledge_from_dir(docs_path: str = "docs") -> dict[str, Any]:
docs_data.append((doc_id, title, content, summary, tags, priority, load_when)) docs_data.append((doc_id, title, content, summary, tags, priority, load_when))
# Generate embeddings in batch # Hash de contenido por doc — base del skip idempotente de embeddings.
import hashlib
def _embed_text(title, summary, content):
return f"{title}\n{summary}\n{content[:2000]}"
def _doc_hash(title, summary, content):
return hashlib.md5(_embed_text(title, summary, content).encode("utf-8")).hexdigest()
new_hashes = [_doc_hash(t, s, c) for _, t, c, s, _, _, _ in docs_data]
# Generate embeddings SOLO para docs nuevos o cuyo contenido cambió (skip
# idempotente): si el hash coincide con el guardado y ya existe el embedding
# en Redis, se reutiliza y NO se vuelve a llamar a la API. Esto permite que
# /knowledge/load se dispare libremente (botón de scaffold, etc.) sin re-embeber.
embeddings: list[Any] = [None] * len(docs_data)
already_embedded = [False] * len(docs_data)
has_embeddings = False
if settings.embeddings_enabled:
to_embed = [] # indices que hay que (re)embeber
for i, (doc_id, title, content, summary, _, _, _) in enumerate(docs_data):
try:
prev = await memory._r.get(memory._key("kbhash", "knowledge", doc_id))
if isinstance(prev, bytes):
prev = prev.decode("utf-8")
has_embed = await memory._r.exists(memory._key("embeddings", "knowledge", doc_id))
except Exception:
prev, has_embed = None, 0
if prev == new_hashes[i] and has_embed:
already_embedded[i] = True # sin cambios → reutiliza el embedding existente
else:
to_embed.append(i)
if to_embed:
from ..memory.embeddings import EmbeddingService from ..memory.embeddings import EmbeddingService
embed_service = EmbeddingService() embed_service = EmbeddingService()
embed_texts = [ embed_texts = [
f"{title}\n{summary}\n{content[:2000]}" _embed_text(docs_data[i][1], docs_data[i][3], docs_data[i][2])
for _, title, content, summary, _, _, _ in docs_data for i in to_embed
] ]
try: try:
embeddings = await embed_service.embed_batch(embed_texts) fresh = await embed_service.embed_batch(embed_texts)
for j, i in enumerate(to_embed):
embeddings[i] = fresh[j]
has_embeddings = True has_embeddings = True
logger.info("Generated %d embeddings for knowledge base", len(embeddings)) logger.info(
"Generated %d embeddings (%d sin cambios, omitidos)",
len(to_embed), len(docs_data) - len(to_embed),
)
except Exception as e: except Exception as e:
logger.warning("Failed to generate embeddings: %s — loading without semantic search", e) logger.warning("Failed to generate embeddings: %s — loading without semantic search", e)
embeddings = [None] * len(docs_data) embeddings = [None] * len(docs_data)
has_embeddings = False has_embeddings = False
else:
has_embeddings = True
logger.info("Knowledge sin cambios — no se regeneraron embeddings (%d docs)", len(docs_data))
else:
logger.info("Embeddings disabled (no AGENTIC_EMBEDDINGS_API_KEY) — KB loaded without semantic search")
# Limpia entradas huérfanas: docs que ya no existen en el filesystem. # Limpia entradas huérfanas: docs que ya no existen en el filesystem.
# Sin esto, los IDs antiguos (e.g. tras renombrar 'builder-fields' → # Sin esto, los IDs antiguos (e.g. tras renombrar 'builder-fields' →
@@ -807,9 +914,10 @@ async def _load_knowledge_from_dir(docs_path: str = "docs") -> dict[str, Any]:
for existing in existing_docs: for existing in existing_docs:
if existing.memory_id not in current_ids: if existing.memory_id not in current_ids:
await memory.delete_document(existing.memory_id, namespace="knowledge") await memory.delete_document(existing.memory_id, namespace="knowledge")
# Borra también el embedding asociado # Borra también el embedding asociado y el hash de contenido
embed_key = memory._key("embeddings", "knowledge", existing.memory_id) embed_key = memory._key("embeddings", "knowledge", existing.memory_id)
await memory._r.delete(embed_key) await memory._r.delete(embed_key)
await memory._r.delete(memory._key("kbhash", "knowledge", existing.memory_id))
removed.append(existing.memory_id) removed.append(existing.memory_id)
if removed: if removed:
logger.info("Removed %d stale knowledge docs: %s", len(removed), removed) logger.info("Removed %d stale knowledge docs: %s", len(removed), removed)
@@ -832,6 +940,11 @@ async def _load_knowledge_from_dir(docs_path: str = "docs") -> dict[str, Any]:
if embeddings[i] is not None: if embeddings[i] is not None:
await memory.store_embedding(doc_id, embeddings[i], namespace="knowledge") await memory.store_embedding(doc_id, embeddings[i], namespace="knowledge")
# Guarda el hash de contenido para el skip idempotente del próximo load
try:
await memory._r.set(memory._key("kbhash", "knowledge", doc_id), new_hashes[i])
except Exception:
pass
loaded.append({ loaded.append({
"id": doc_id, "id": doc_id,
@@ -840,7 +953,7 @@ async def _load_knowledge_from_dir(docs_path: str = "docs") -> dict[str, Any]:
"tags": tags[:5], "tags": tags[:5],
"priority": priority, "priority": priority,
"load_when": load_when, "load_when": load_when,
"embedded": embeddings[i] is not None, "embedded": embeddings[i] is not None or already_embedded[i],
}) })
logger.info("Loaded %d knowledge documents from %s (embeddings: %s)", len(loaded), docs_dir, has_embeddings) logger.info("Loaded %d knowledge documents from %s (embeddings: %s)", len(loaded), docs_dir, has_embeddings)

View File

@@ -32,6 +32,33 @@ class Settings(BaseSettings):
anthropic_base_url: str = "" # Custom base URL (for MiniMax Anthropic-compatible, etc.) anthropic_base_url: str = "" # Custom base URL (for MiniMax Anthropic-compatible, etc.)
openai_api_key: str = "" openai_api_key: str = ""
openai_base_url: str = "" # Custom base URL (for MiniMax, DeepInfra, etc.) openai_base_url: str = "" # Custom base URL (for MiniMax, DeepInfra, etc.)
# --- Embeddings (semantic search) ---
# Credenciales DEDICADAS para embeddings. Necesarias porque el chat usa
# `openai_api_key` apuntando a un endpoint compatible (p.ej. DeepSeek, que NO
# tiene API de embeddings). Si vacio, cae a `openai_api_key` por compat. El
# base_url vacio => OpenAI real (api.openai.com); NO hereda `openai_base_url`.
embeddings_api_key: str = ""
embeddings_base_url: str = ""
embeddings_model: str = "text-embedding-3-small"
# Spike LiteLLM: si default_model_provider=litellm, modelo a usar (formato
# litellm, p.ej. "deepseek/deepseek-v4-pro"). Vacío → deriva de default_model_id.
litellm_model: str = ""
@property
def effective_embeddings_key(self) -> str:
"""Key a usar para embeddings. Prioriza la dedicada; reutiliza la del
chat SOLO si el chat es OpenAI real (sin `openai_base_url` custom) — si
apunta a DeepSeek u otro proveedor, esa key no sirve para embeddings."""
if self.embeddings_api_key:
return self.embeddings_api_key
if not self.openai_base_url:
return self.openai_api_key
return ""
@property
def embeddings_enabled(self) -> bool:
return bool(self.effective_embeddings_key or self.embeddings_base_url)
default_model_provider: str = "claude" default_model_provider: str = "claude"
default_model_id: str = "claude-sonnet-4-20250514" default_model_id: str = "claude-sonnet-4-20250514"
# Modelo override SOLO para el sub-loop del planner (acai_plan). Si vacio, # Modelo override SOLO para el sub-loop del planner (acai_plan). Si vacio,
@@ -43,6 +70,11 @@ class Settings(BaseSettings):
planner_max_tokens: int = 16000 planner_max_tokens: int = 16000
max_tokens: int = 4096 max_tokens: int = 4096
temperature: float = 0.3 temperature: float = 0.3
# DeepSeek strict function calling (beta). OPT-IN (default False): exige schemas
# tipo OpenAI (additionalProperties:false, todos required, etc.) que los tools MCP
# actuales NO cumplen → da 400. Para activarlo: schemas compatibles + base_url
# https://api.deepseek.com/beta + AGENTIC_DEEPSEEK_STRICT_TOOLS=true.
deepseek_strict_tools: bool = False
# --- Context engine --- # --- Context engine ---
model_context_window: int = 0 # 0 = use legacy fixed budget / explicit override model_context_window: int = 0 # 0 = use legacy fixed budget / explicit override
@@ -70,6 +102,10 @@ class Settings(BaseSettings):
conversation_recent_raw_limit: int = 2 conversation_recent_raw_limit: int = 2
task_history_max_entries: int = 20 task_history_max_entries: int = 20
task_history_max_tokens: int = 1500 task_history_max_tokens: int = 1500
# Presupuesto de tokens para la ventana de recent_messages persistida en
# sesion. Sin esto crece sin limite y empuja al compactor a su paso
# destructivo (colapsar bloques perdiendo tool_use ids). 0 = sin limite.
recent_messages_max_tokens: int = 60_000
# --- MCP --- # --- MCP ---
mcp_config_path: str = "" # Path to mcp.json; empty = legacy single-server mode mcp_config_path: str = "" # Path to mcp.json; empty = legacy single-server mode
@@ -119,5 +155,24 @@ class Settings(BaseSettings):
return min(self.compaction_threshold_tokens, self.effective_context_budget) return min(self.compaction_threshold_tokens, self.effective_context_budget)
return max(1, int(self.effective_context_budget * self.compaction_threshold_ratio)) 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() settings = Settings()

View File

@@ -180,7 +180,13 @@ class ContextCompactor:
"raw_tool_results_kept": 0, "raw_tool_results_kept": 0,
} }
if total <= max_tokens: if total <= max_tokens:
return messages, meta # Aunque no haga falta compactar, garantizamos el invariante
# tool_use/tool_result (repara historiales ya rotos persistidos).
repaired = self._enforce_tool_pairing([dict(m) for m in messages])
meta["output_tokens"] = sum(
self._estimate_message_tokens(m) for m in repaired
)
return repaired, meta
compacted = [dict(m) for m in messages] compacted = [dict(m) for m in messages]
last_user_idx = max( last_user_idx = max(
@@ -343,20 +349,241 @@ class ContextCompactor:
message["content"] = "[USER CONTEXT COMPACTADO]" message["content"] = "[USER CONTEXT COMPACTADO]"
elif isinstance(content, list) and content: elif isinstance(content, list) and content:
# Anthropic-style: reemplazar lista entera por placeholder string. # Anthropic-style: reemplazar lista entera por placeholder string.
# Nota: pierde tool_use ids — solo aplicar al final como ultimo recurso. # Nota: colapsar pierde los tool_use/tool_result ids, asi que
# lo hacemos PAIR-AWARE (colapsar un lado del par colapsa el
# otro en la misma iteracion) y ademas `_enforce_tool_pairing`
# al final garantiza el invariante aunque algo se escape.
if role == "assistant": if role == "assistant":
message["content"] = "[ASSISTANT COMPACTADO]" message["content"] = "[ASSISTANT COMPACTADO]"
# Si este assistant tenia tool_use, colapsar tambien el
# user de tool_results que lo sigue (mismo par).
if self._blocks_have_type(content, "tool_use"):
nxt = idx + 1
if (
nxt < len(compacted)
and nxt != last_user_idx
and compacted[nxt].get("role") == "user"
and self._blocks_have_type(
compacted[nxt].get("content"), "tool_result"
)
):
compacted[nxt]["content"] = "[USER CONTEXT COMPACTADO]"
elif role == "user": elif role == "user":
message["content"] = "[USER CONTEXT COMPACTADO]" message["content"] = "[USER CONTEXT COMPACTADO]"
# Si este user llevaba tool_results, colapsar tambien el
# assistant anterior con sus tool_use (mismo par).
if self._blocks_have_type(content, "tool_result"):
prv = idx - 1
if (
prv >= 0
and compacted[prv].get("role") == "assistant"
and self._blocks_have_type(
compacted[prv].get("content"), "tool_use"
)
):
compacted[prv]["content"] = "[ASSISTANT COMPACTADO]"
else: else:
continue continue
total = sum(self._estimate_message_tokens(m) for m in compacted) total = sum(self._estimate_message_tokens(m) for m in compacted)
if total <= max_tokens: if total <= max_tokens:
break break
# Invariante final: tras toda la compactacion, reparar cualquier par
# tool_use/tool_result roto. Sin esto, un tool_result huerfano se emite
# como `role: tool` sin `tool_calls` previo y el proveedor devuelve 400
# ("Messages with role 'tool' must be a response to a preceding message
# with 'tool_calls'").
compacted = self._enforce_tool_pairing(compacted)
total = sum(self._estimate_message_tokens(m) for m in compacted)
meta["output_tokens"] = total meta["output_tokens"] = total
return compacted, meta return compacted, meta
# ------------------------------------------------------------------
# Invariante tool_use ↔ tool_result
# ------------------------------------------------------------------
@staticmethod
def _blocks_have_type(content: Any, block_type: str) -> bool:
"""True si `content` es una lista de bloques con alguno del tipo dado."""
if not isinstance(content, list):
return False
return any(
isinstance(b, dict) and b.get("type") == block_type for b in content
)
@staticmethod
def _tool_use_ids(message: dict[str, Any]) -> set[str]:
"""IDs de tool calls emitidos por un assistant (bloques `tool_use`
estilo Anthropic y/o `tool_calls` estilo OpenAI legacy)."""
ids: set[str] = set()
content = message.get("content")
if isinstance(content, list):
for b in content:
if isinstance(b, dict) and b.get("type") == "tool_use":
ids.add(str(b.get("id", "")))
for tc in message.get("tool_calls") or []:
if isinstance(tc, dict):
ids.add(str(tc.get("id", "")))
ids.discard("")
return ids
def _enforce_tool_pairing(
self, messages: list[dict[str, Any]]
) -> list[dict[str, Any]]:
"""Repara el invariante tool_use ↔ tool_result en ambas direcciones.
La compactacion puede colapsar el content de un assistant (perdiendo sus
bloques `tool_use`) mientras el user siguiente conserva sus `tool_result`,
o al reves. El matching es por IDs (`tool_use.id` vs `tool_result.tool_use_id`
y `tool_calls[].id` vs `tool_call_id`), no solo por adyacencia, asi que
tambien repara desajustes parciales (p.ej. 3 tool_use vs 2 tool_result).
- tool_result sin tool_use previo → bloque text placeholder.
- tool_use sin tool_result siguiente → se elimina el bloque (thinking/text
se conservan; si el content queda vacio, placeholder string).
- `role: tool` legacy sin assistant con `tool_calls` → user placeholder.
"""
repaired: list[dict[str, Any]] = []
for idx, msg in enumerate(messages):
role = msg.get("role", "")
content = msg.get("content")
if role == "assistant":
tool_ids = self._tool_use_ids(msg)
if not tool_ids:
repaired.append(msg)
continue
# IDs respondidos: user con tool_results inmediato y/o run
# contiguo de mensajes legacy `role: tool`.
answered: set[str] = set()
j = idx + 1
if (
j < len(messages)
and messages[j].get("role") == "user"
and isinstance(messages[j].get("content"), list)
):
for b in messages[j]["content"]:
if isinstance(b, dict) and b.get("type") == "tool_result":
answered.add(str(b.get("tool_use_id", "")))
j += 1
while j < len(messages) and messages[j].get("role") == "tool":
answered.add(str(messages[j].get("tool_call_id", "")))
j += 1
unanswered = tool_ids - answered
if not unanswered:
repaired.append(msg)
continue
# Eliminar los tool_use/tool_calls sin respuesta.
new_msg = dict(msg)
if isinstance(content, list):
new_content = [
b
for b in content
if not (
isinstance(b, dict)
and b.get("type") == "tool_use"
and str(b.get("id", "")) in unanswered
)
]
if not new_content:
new_msg["content"] = "[ASSISTANT COMPACTADO]"
else:
new_msg["content"] = new_content
if isinstance(new_msg.get("tool_calls"), list):
kept_calls = [
tc
for tc in new_msg["tool_calls"]
if isinstance(tc, dict)
and str(tc.get("id", "")) not in unanswered
]
if kept_calls:
new_msg["tool_calls"] = kept_calls
else:
new_msg.pop("tool_calls", None)
if not new_msg.get("content"):
new_msg["content"] = "[ASSISTANT COMPACTADO]"
repaired.append(new_msg)
continue
if role == "user" and self._blocks_have_type(content, "tool_result"):
# IDs disponibles en el assistant inmediatamente anterior
# (YA reparado — usar `repaired[-1]` refleja los tool_use que
# sobrevivieron, no los del mensaje original).
available: set[str] = set()
if repaired and repaired[-1].get("role") == "assistant":
available = self._tool_use_ids(repaired[-1])
new_content: list[Any] = []
orphaned = False
for b in content:
if (
isinstance(b, dict)
and b.get("type") == "tool_result"
and str(b.get("tool_use_id", "")) not in available
):
orphaned = True
# Fusionar placeholders consecutivos en un unico bloque text.
if not (
new_content
and isinstance(new_content[-1], dict)
and new_content[-1].get("type") == "text"
and new_content[-1].get("text")
== "[Resultado de herramienta compactado]"
):
new_content.append(
{
"type": "text",
"text": "[Resultado de herramienta compactado]",
}
)
continue
new_content.append(b)
if not orphaned:
repaired.append(msg)
continue
new_msg = dict(msg)
only_placeholders = all(
isinstance(b, dict)
and b.get("type") == "text"
and b.get("text") == "[Resultado de herramienta compactado]"
for b in new_content
)
if not new_content or only_placeholders:
new_msg["content"] = "[Resultado de herramienta compactado]"
else:
new_msg["content"] = new_content
repaired.append(new_msg)
continue
if role == "tool":
# Legacy: el assistant anterior (saltando otros `role: tool`
# contiguos) debe tener este tool_call_id en sus tool_calls.
prev_assistant: dict[str, Any] | None = None
for prev in reversed(repaired):
if prev.get("role") == "tool":
continue
if prev.get("role") == "assistant":
prev_assistant = prev
break
call_id = str(msg.get("tool_call_id", ""))
valid = (
prev_assistant is not None
and call_id in self._tool_use_ids(prev_assistant)
)
if valid:
repaired.append(msg)
else:
repaired.append(
{
"role": "user",
"content": "[Resultado de herramienta compactado]",
}
)
continue
repaired.append(msg)
return repaired
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Internals # Internals
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -663,6 +890,10 @@ class ContextCompactor:
elif btype == "tool_result": elif btype == "tool_result":
tc = block.get("content", "") tc = block.get("content", "")
tokens += estimate_tokens(tc if isinstance(tc, str) else str(tc)) 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: else:
tokens += estimate_tokens(str(block)) tokens += estimate_tokens(str(block))
else: else:

View File

@@ -66,13 +66,35 @@ class ContextEngine:
artifacts: list[ArtifactSummary] | None = None, artifacts: list[ArtifactSummary] | None = None,
conversation: list[dict[str, Any]] | None = None, conversation: list[dict[str, Any]] | None = None,
extra_instructions: str = "", extra_instructions: str = "",
model_id: str | None = None,
budget_override: int | None = None,
) -> ContextPackage: ) -> ContextPackage:
"""Build a full ContextPackage for the given agent and session. """Build a full ContextPackage for the given agent and session.
The conversation parameter contains real assistant/tool messages The conversation parameter contains real assistant/tool messages
with complete tool results. These go into the messages array, with complete tool results. These go into the messages array,
not the system prompt — like professional agentic tools. 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] = [] sections: list[ContextSection] = []
allowed = set(agent.context_sections) allowed = set(agent.context_sections)
@@ -140,7 +162,7 @@ class ContextEngine:
raw_message_tokens = sum(self._estimate_message_tokens(m) for m in messages) 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_section_tokens = sum(estimate_tokens(s.content) for s in sections)
pre_compaction_total = pre_compaction_section_tokens + raw_message_tokens 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. # Compact sections only when the full prompt is approaching the target.
section_compaction = { section_compaction = {
@@ -155,8 +177,8 @@ class ContextEngine:
} }
system_prompt = self._assemble_system_prompt(sections) system_prompt = self._assemble_system_prompt(sections)
system_prompt_tokens = estimate_tokens(system_prompt) system_prompt_tokens = estimate_tokens(system_prompt)
hard_message_budget = max(1, settings.effective_context_budget - system_prompt_tokens) hard_message_budget = max(1, budget - system_prompt_tokens)
target_message_budget = max(1, settings.effective_compaction_threshold - system_prompt_tokens) target_message_budget = max(1, threshold - system_prompt_tokens)
message_budget = min(hard_message_budget, target_message_budget) message_budget = min(hard_message_budget, target_message_budget)
conversation_compaction = { conversation_compaction = {
"budget_tokens": message_budget, "budget_tokens": message_budget,
@@ -170,7 +192,7 @@ class ContextEngine:
} }
total_tokens = system_prompt_tokens + raw_message_tokens 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, conversation_compaction = self.compactor.compact_conversation(
messages, messages,
max_tokens=message_budget, max_tokens=message_budget,
@@ -181,10 +203,10 @@ class ContextEngine:
self._estimate_message_tokens(m) for m in messages self._estimate_message_tokens(m) for m in messages
) )
if total_tokens > settings.effective_context_budget: if total_tokens > budget:
section_budget = max( section_budget = max(
1, 1,
settings.effective_context_budget budget
- sum(self._estimate_message_tokens(m) for m in messages), - sum(self._estimate_message_tokens(m) for m in messages),
) )
sections, section_compaction = self.compactor.compact_sections( sections, section_compaction = self.compactor.compact_sections(
@@ -197,10 +219,10 @@ class ContextEngine:
self._estimate_message_tokens(m) for m in messages self._estimate_message_tokens(m) for m in messages
) )
if total_tokens > settings.effective_context_budget: if total_tokens > budget:
hard_message_budget = max( hard_message_budget = max(
1, 1,
settings.effective_context_budget - system_prompt_tokens, budget - system_prompt_tokens,
) )
messages, conversation_compaction = self.compactor.compact_conversation( messages, conversation_compaction = self.compactor.compact_conversation(
messages, messages,
@@ -217,6 +239,7 @@ class ContextEngine:
system_prompt=system_prompt, system_prompt=system_prompt,
messages=messages, messages=messages,
total_token_estimate=total_tokens, total_token_estimate=total_tokens,
budget_tokens=budget,
) )
# Guardar contexto completo del último build (solo el último por sesión) # Guardar contexto completo del último build (solo el último por sesión)
@@ -224,8 +247,8 @@ class ContextEngine:
"system_prompt": system_prompt, "system_prompt": system_prompt,
"messages": messages, "messages": messages,
"total_tokens": total_tokens, "total_tokens": total_tokens,
"budget_tokens": settings.effective_context_budget, "budget_tokens": budget,
"threshold_tokens": settings.effective_compaction_threshold, "threshold_tokens": threshold,
"timestamp": time.time(), "timestamp": time.time(),
} }
@@ -258,8 +281,8 @@ class ContextEngine:
"user_message_preview": user_content[:200], "user_message_preview": user_content[:200],
"artifacts_count": len(artifacts) if artifacts else 0, "artifacts_count": len(artifacts) if artifacts else 0,
"conversation_messages": conv_len, "conversation_messages": conv_len,
"budget_tokens": settings.effective_context_budget, "budget_tokens": budget,
"threshold_tokens": settings.effective_compaction_threshold, "threshold_tokens": threshold,
"message_tokens": conversation_compaction.get("output_tokens", raw_message_tokens), "message_tokens": conversation_compaction.get("output_tokens", raw_message_tokens),
"message_tokens_before_compaction": raw_message_tokens, "message_tokens_before_compaction": raw_message_tokens,
"pre_compaction_tokens": pre_compaction_total, "pre_compaction_tokens": pre_compaction_total,
@@ -268,7 +291,7 @@ class ContextEngine:
"message_budget_tokens": message_budget, "message_budget_tokens": message_budget,
"section_compaction": section_compaction, "section_compaction": section_compaction,
"conversation_compaction": conversation_compaction, "conversation_compaction": conversation_compaction,
"over_budget": total_tokens > settings.effective_context_budget, "over_budget": total_tokens > budget,
} }
history = self._history[session.session_id] history = self._history[session.session_id]
@@ -583,6 +606,16 @@ class ContextEngine:
async def _semantic_rank(self, query: str) -> list[tuple[str, float]]: async def _semantic_rank(self, query: str) -> list[tuple[str, float]]:
"""Rank knowledge docs by cosine similarity. Returns (doc_id, score).""" """Rank knowledge docs by cosine similarity. Returns (doc_id, score)."""
# Sin credencial de embeddings no tiene sentido intentar la llamada (daria
# 401 en cada turno). Se desactiva limpiamente con un aviso unico.
if not settings.embeddings_enabled:
if not getattr(self, "_embed_disabled_warned", False):
logger.warning(
"Embeddings disabled (no AGENTIC_EMBEDDINGS_API_KEY) — "
"semantic search off, loading all docs"
)
self._embed_disabled_warned = True
return []
try: try:
if not self._embed_service: if not self._embed_service:
self._embed_service = EmbeddingService() self._embed_service = EmbeddingService()
@@ -914,7 +947,18 @@ class ContextEngine:
messages.append({"role": "user", "content": "\n".join(history_lines)}) 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?"}) messages.append({"role": "assistant", "content": "Entendido, tengo el contexto del historial. ¿En qué puedo ayudarte ahora?"})
# Current user message # 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}) messages.append({"role": "user", "content": user_content})
# Append real conversation (assistant messages + tool results from current step) # Append real conversation (assistant messages + tool results from current step)
@@ -936,7 +980,22 @@ class ContextEngine:
else: else:
base_user_content = "Awaiting task assignment." base_user_content = "Awaiting task assignment."
followup_mode = self._classify_followup_mode(base_user_content) # Un follow-up (transform/fetch_more/ambiguous) SOLO tiene sentido si hay
# un turno anterior al que referirse. En una sesión fresca / primer mensaje
# no hay nada que transformar, así que NO clasificamos: de lo contrario un
# primer prompt que casualmente contenga un marker ("resumen", "estructura",
# "busca", "adapta"…) se marcaría como `transform` y `_get_allowed_tools`
# devolvería [] — el agente se quedaría SIN tools y emitiría los tool calls
# como texto sin ejecutarlos (caso real: el prompt de análisis de estilos
# que dice "Guarda un resumen…").
has_prior_turn = bool(session.task_history) or bool(
getattr(session, "recent_messages", [])
)
followup_mode = (
self._classify_followup_mode(base_user_content)
if has_prior_turn
else "none"
)
resolved_context = "" resolved_context = ""
if session.task_history and followup_mode != "none": if session.task_history and followup_mode != "none":
resolved_context = self._build_followup_resolution(session.task_history[-1]) resolved_context = self._build_followup_resolution(session.task_history[-1])
@@ -1012,6 +1071,10 @@ class ContextEngine:
elif btype == "tool_result": elif btype == "tool_result":
tc = block.get("content", "") tc = block.get("content", "")
total += estimate_tokens(tc if isinstance(tc, str) else str(tc)) 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: else:
total += estimate_tokens(str(block)) total += estimate_tokens(str(block))
return total return total

View File

@@ -54,7 +54,11 @@ async def lifespan(app: FastAPI):
await redis_storage.connect() await redis_storage.connect()
# 2. Initialize model adapter # 2. Initialize model adapter
if settings.default_model_provider == "openai": if settings.default_model_provider == "litellm":
from .adapters.litellm_adapter import LiteLLMAdapter
model_adapter = LiteLLMAdapter()
logger.info("Using LiteLLM adapter (model: %s)", settings.litellm_model or settings.default_model_id)
elif settings.default_model_provider == "openai":
model_adapter = OpenAIAdapter() model_adapter = OpenAIAdapter()
logger.info("Using OpenAI adapter (model: %s)", settings.default_model_id) logger.info("Using OpenAI adapter (model: %s)", settings.default_model_id)
else: else:

View File

@@ -18,6 +18,15 @@ from ..models.tools import ToolDefinition
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Buffer maximo (bytes) del StreamReader para leer las respuestas JSON-RPC del
# MCP por stdout. Una respuesta llega en UNA sola linea; tools como el
# screenshot fullPage de Playwright devuelven la imagen en base64 en esa linea
# y superan de largo el 64KB por defecto de asyncio (y el 1MB que teniamos),
# lanzando LimitOverrunError que mataba el read loop y dejaba la sesion MCP
# inservible (el agente "se paraba" al hacer acciones). 64MB cubre cualquier
# screenshot real; por encima, el read loop descarta esa respuesta y sigue vivo.
MCP_STREAM_LIMIT = 64 * 1024 * 1024
class MCPClientError(Exception): class MCPClientError(Exception):
pass pass
@@ -74,7 +83,7 @@ class MCPClient:
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
env=self._env, env=self._env,
limit=1024 * 1024, # 1MB buffer for large MCP responses limit=MCP_STREAM_LIMIT, # buffer grande para respuestas MCP (screenshots base64)
) )
self._running = True self._running = True
self._reader_task = asyncio.create_task(self._read_loop()) self._reader_task = asyncio.create_task(self._read_loop())
@@ -225,14 +234,30 @@ class MCPClient:
if not self._process or not self._process.stdout: if not self._process or not self._process.stdout:
return return
stdout = self._process.stdout
try: try:
while self._running: while self._running:
line = await self._process.stdout.readline() try:
line = await stdout.readline()
except (ValueError, asyncio.LimitOverrunError):
# Una respuesta JSON-RPC supero el buffer (p.ej. screenshot
# fullPage de Playwright en base64 por encima de 64MB). Antes
# esto mataba el read loop y dejaba TODA la sesion MCP muerta
# (el agente se "paraba" en la siguiente accion). Ahora
# descartamos solo esa respuesta, re-sincronizamos el stream
# y seguimos vivos para las demas tools.
logger.warning(
"MCP [%s]: respuesta supera el buffer (%d MB), se descarta y se continua",
self.name, MCP_STREAM_LIMIT // (1024 * 1024),
)
await self._drain_until_newline(stdout)
continue
if not line: if not line:
logger.warning("MCP server stdout closed") logger.warning("MCP server stdout closed")
break break
line_str = line.decode().strip() line_str = line.decode(errors="replace").strip()
if not line_str: if not line_str:
continue continue
@@ -251,6 +276,21 @@ class MCPClient:
finally: finally:
self._running = False self._running = False
async def _drain_until_newline(self, stdout: asyncio.StreamReader) -> None:
"""Consume bytes del stream hasta el proximo salto de linea para
re-sincronizar tras un LimitOverrunError (la respuesta sobredimensionada
se descarta). `read()` no usa separador, asi que no vuelve a disparar el
overrun y va vaciando el buffer hasta liberar la linea gigante."""
while self._running:
try:
chunk = await stdout.read(65536)
except Exception:
return
if not chunk:
return
if b"\n" in chunk:
return
def _handle_message(self, message: dict[str, Any]) -> None: def _handle_message(self, message: dict[str, Any]) -> None:
"""Route an incoming JSON-RPC message.""" """Route an incoming JSON-RPC message."""
msg_id = message.get("id") msg_id = message.get("id")

View File

@@ -25,12 +25,19 @@ class EmbeddingService:
def __init__( def __init__(
self, self,
api_key: str | None = None, api_key: str | None = None,
model: str = DEFAULT_MODEL, model: str | None = None,
) -> None: ) -> None:
self._client = AsyncOpenAI( # Credenciales dedicadas de embeddings. Fallback a openai_api_key por
api_key=api_key or settings.openai_api_key, # compat. El base_url solo se aplica si se configura explicitamente
) # `embeddings_base_url`; vacio => OpenAI real (api.openai.com). NO se
self._model = model # hereda `openai_base_url` (que apunta al chat, p.ej. DeepSeek sin
# endpoint de embeddings).
key = api_key or settings.effective_embeddings_key
kwargs: dict[str, Any] = {"api_key": key}
if settings.embeddings_base_url:
kwargs["base_url"] = settings.embeddings_base_url
self._client = AsyncOpenAI(**kwargs)
self._model = model or settings.embeddings_model or DEFAULT_MODEL
async def embed(self, text: str) -> list[float]: async def embed(self, text: str) -> list[float]:
"""Generate embedding for a single text.""" """Generate embedding for a single text."""

View File

@@ -20,6 +20,7 @@ class AgentProfile(BaseModel):
allowed_tools: list[str] = Field(default_factory=list) allowed_tools: list[str] = Field(default_factory=list)
model_id: str | None = None model_id: str | None = None
planner_model_id: str | None = None # override del modelo solo para el sub-loop del planner 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 temperature: float | None = None
max_tokens: int | None = None max_tokens: int | None = None
context_sections: list[str] = Field( context_sections: list[str] = Field(

View File

@@ -35,6 +35,10 @@ class ContextPackage(BaseModel):
system_prompt: str = "" system_prompt: str = ""
messages: list[dict[str, Any]] = Field(default_factory=list) messages: list[dict[str, Any]] = Field(default_factory=list)
total_token_estimate: int = 0 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]]: def to_messages(self) -> list[dict[str, Any]]:
"""Produce the final messages list for the model adapter.""" """Produce the final messages list for the model adapter."""

View File

@@ -46,6 +46,9 @@ class TaskState(BaseModel):
task_id: str = Field(default_factory=lambda: uuid.uuid4().hex[:12]) task_id: str = Field(default_factory=lambda: uuid.uuid4().hex[:12])
objective: str 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 status: TaskStatus = TaskStatus.PENDING
plan: list[TaskStep] = Field(default_factory=list) plan: list[TaskStep] = Field(default_factory=list)
current_step_index: int = 0 current_step_index: int = 0
@@ -94,8 +97,8 @@ class SessionState(BaseModel):
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
metadata: dict[str, Any] = Field(default_factory=dict) metadata: dict[str, Any] = Field(default_factory=dict)
def begin_task(self, objective: str) -> TaskState: def begin_task(self, objective: str, image_attachments: list[dict[str, Any]] | None = None) -> TaskState:
task = TaskState(objective=objective) task = TaskState(objective=objective, image_attachments=image_attachments or [])
self.current_task = task self.current_task = task
self.status = SessionStatus.EXECUTING self.status = SessionStatus.EXECUTING
self.turn_count += 1 self.turn_count += 1

View File

@@ -9,9 +9,10 @@ import time
import uuid import uuid
from typing import Any, AsyncIterator 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 ...config import settings
from ...context.engine import ContextEngine from ...context.engine import ContextEngine
from ..cost import resolve_context_window
from ...mcp.manager import MCPManager from ...mcp.manager import MCPManager
from ...memory.store import MemoryStore from ...memory.store import MemoryStore
from ...models.agent import AgentProfile from ...models.agent import AgentProfile
@@ -73,12 +74,40 @@ class BaseAgent:
self._current_conversation = conversation self._current_conversation = conversation
for step in range(max_steps): for step in range(max_steps):
# Build context with real 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( ctx = await self.context.build_context(
session=session, session=session,
agent=self.profile, agent=self.profile,
artifacts=artifacts, artifacts=artifacts,
conversation=conversation, 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 # Prepare tool definitions. plan_mode "off" oculta acai_plan al
@@ -93,6 +122,7 @@ class BaseAgent:
model_id=self.profile.model_id or "", model_id=self.profile.model_id or "",
max_tokens=self.profile.max_tokens or 4096, max_tokens=self.profile.max_tokens or 4096,
temperature=self.profile.temperature or 0.3, temperature=self.profile.temperature or 0.3,
reasoning_effort=self.profile.reasoning_effort or "",
) )
# Snapshot del numero de tool_executions ya acumulados ANTES del # Snapshot del numero de tool_executions ya acumulados ANTES del
@@ -289,6 +319,34 @@ class BaseAgent:
# If no tool calls, we're done # If no tool calls, we're done
if not tool_calls: if not tool_calls:
# Quirk DeepSeek thinking: a veces el modelo emite TODA su
# respuesta como reasoning y cierra el turno sin text ni
# tool_use. Si el turno termina SOLO con bloques thinking,
# promovemos el thinking a un bloque text en el snapshot que
# se persiste — asi el UI no lo muestra como "pensando" al
# recargar y el siguiente turno no rompe con
# "content or tool_calls must be set".
if turn_blocks and all(b.get("type") == "thinking" for b in turn_blocks):
promoted = "\n".join(
b.get("thinking", "") for b in turn_blocks if b.get("thinking")
)
turn_blocks = [{"type": "text", "text": promoted}]
accumulated_content += promoted
if promoted and self.profile.stream_deltas:
# Emision en vivo via AGENT_DELTA normal: el
# ClaudeFormatEmitter cierra el thinking block abierto
# (content_block_stop) y abre un text block nuevo con
# su propio indice (start/delta/stop), asi que el
# protocolo de bloques no se rompe.
await self.sse.emit(
EventType.AGENT_DELTA,
{
"agent": self.profile.role,
"delta": promoted,
"step": step,
},
session_id=session.session_id,
)
if turn_blocks: if turn_blocks:
conversation.append({"role": "assistant", "content": turn_blocks}) conversation.append({"role": "assistant", "content": turn_blocks})
elif full_text: elif full_text:

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

@@ -0,0 +1,257 @@
"""Cálculo de coste por modelo (Fase 2).
Prioridad de fuentes de precio (para que el coste registrado en
`consumo_acaicode` coincida con lo que muestra el Forge Admin Panel):
1. Catálogo OpenRouter cacheado por el panel en Redis db 0
(`acai:config:ai:models_cache:openrouter` → price_in_1m / price_out_1m).
2. Price map de LiteLLM (conoce muchos modelos deepseek/, anthropic/, etc.).
3. Coste fijo de `settings` (comportamiento previo).
"""
from __future__ import annotations
import asyncio
import json
import logging
import time
import urllib.request
import redis.asyncio as redis
from ..config import settings
logger = logging.getLogger(__name__)
# Caches de catálogo que publica el Forge Admin Panel en Redis db 0, por proveedor.
# El id se guarda SIN el prefijo de proveedor de litellm (p.ej.
# "moonshotai/kimi-k2.7-code", "deepseek-v4-pro").
_CACHE_KEYS = {
"openrouter": "acai:config:ai:models_cache:openrouter",
"deepseek": "acai:config:ai:models_cache:deepseek",
}
_CONFIG_DB = 0
_cfg_redis: "redis.Redis | None" = None
def _get_cfg_redis() -> "redis.Redis":
global _cfg_redis
if _cfg_redis is None:
_cfg_redis = redis.Redis(
host=settings.redis_host,
port=settings.redis_port,
db=_CONFIG_DB,
password=settings.redis_password or None,
decode_responses=True,
)
return _cfg_redis
# --- Catálogo con self-heal -------------------------------------------------
# El catálogo OpenRouter lo publica el Forge Admin Panel con TTL de 1h y solo se
# repuebla al abrir su ventana de IA. En runtime (coste y ventana de contexto)
# eso es frágil: si caduca, perdemos precio Y context_length del modelo activo.
# Aquí lo repoblamos nosotros (fetch público a OpenRouter, mismo shape que el
# admin) cuando falta, con un cooldown para no martillear la API. DeepSeek es
# persistente (lo escribe el admin en el arranque) y no necesita self-heal.
_OPENROUTER_URL = "https://openrouter.ai/api/v1/models"
_OPENROUTER_TIMEOUT = 15
_OR_SELFHEAL_TTL = 86_400 # 24h: persiste bastante; el admin lo refresca aparte
_OR_REFRESH_COOLDOWN = 300 # como mucho un fetch / 5 min
_or_last_refresh = [0.0]
def _fetch_openrouter_catalog_sync() -> list[dict]:
"""GET público al catálogo OpenRouter, normalizado al MISMO shape que el
admin panel (id, context_length, price_*, supports_reasoning, supports_images).
Filtra a modelos con soporte `tools` (igual que el admin)."""
req = urllib.request.Request(_OPENROUTER_URL, method="GET")
req.add_header("Accept", "application/json")
with urllib.request.urlopen(req, timeout=_OPENROUTER_TIMEOUT) as resp:
payload = json.loads(resp.read().decode("utf-8"))
items = payload.get("data") if isinstance(payload, dict) else None
if not isinstance(items, list):
return []
out: list[dict] = []
for it in items:
if not isinstance(it, dict) or not it.get("id"):
continue
supported = it.get("supported_parameters") or []
if not isinstance(supported, list) or "tools" not in supported:
continue
pricing = it.get("pricing") or {}
try:
pin = float(pricing.get("prompt", 0) or 0) * 1_000_000
pout = float(pricing.get("completion", 0) or 0) * 1_000_000
except (TypeError, ValueError):
pin = pout = 0.0
try:
ctx = int(it.get("context_length") or 0)
except (TypeError, ValueError):
ctx = 0
mods = (it.get("architecture") or {}).get("input_modalities") or []
out.append({
"id": it.get("id"),
"name": it.get("name") or it.get("id"),
"context_length": ctx,
"price_in_1m": pin,
"price_out_1m": pout,
"supports_reasoning": "reasoning" in supported or "include_reasoning" in supported,
"supports_images": isinstance(mods, list) and "image" in mods,
})
return out
async def _get_catalog(provider: str | None) -> list[dict] | None:
"""Catálogo del proveedor desde Redis. Para OpenRouter, si falta (TTL
caducado) lo repuebla en runtime (self-heal con cooldown)."""
cache_key = _CACHE_KEYS.get(provider or "")
if not cache_key:
return None
try:
cached = await _get_cfg_redis().get(cache_key)
if cached:
data = json.loads(cached)
if isinstance(data, list):
return data
except Exception as e: # pragma: no cover - defensivo
logger.warning("catálogo %s no disponible: %s", provider, e)
if provider != "openrouter":
return None
# Self-heal solo para OpenRouter, con cooldown para no martillear la API.
now = time.time()
if now - _or_last_refresh[0] < _OR_REFRESH_COOLDOWN:
return None
_or_last_refresh[0] = now
try:
models = await asyncio.to_thread(_fetch_openrouter_catalog_sync)
except Exception as e:
logger.warning("self-heal catálogo openrouter falló: %s", e)
return None
if models:
try:
await _get_cfg_redis().set(cache_key, json.dumps(models), ex=_OR_SELFHEAL_TTL)
logger.info("catálogo openrouter repoblado en runtime: %d modelos", len(models))
except Exception:
pass
return models
return None
async def _catalog_price_per_1m(model_id: str | None):
"""(price_in_1m, price_out_1m) del catálogo, o None. model_id en formato
litellm ("<provider>/<id>")."""
if not model_id or "/" not in model_id:
return None
provider, _, raw_id = model_id.partition("/")
models = await _get_catalog(provider)
if not models:
return None
for m in models:
if m.get("id") == raw_id:
pin = m.get("price_in_1m")
pout = m.get("price_out_1m")
if pin is not None and pout is not None:
return (float(pin), float(pout))
return None
# --- Ventana de contexto por modelo -----------------------------------------
# Cache en proceso con TTL corto: build_context resuelve la ventana en cada step
# del loop, y el catálogo cambia rara vez. Evita pegar a Redis 25x/turno.
_window_cache: dict[str, tuple[float, int | None]] = {}
_WINDOW_TTL = 60.0
async def resolve_context_window(model_id: str | None) -> int | None:
"""Ventana de contexto (tokens) del modelo activo.
Fuentes en orden: catálogo del Forge Admin Panel en Redis (`context_length`)
→ price/info map de LiteLLM (`max_input_tokens`/`max_tokens`) → None.
`model_id` viene en formato litellm ("<provider>/<id>").
"""
if not model_id or "/" not in model_id:
return None
now = time.time()
cached = _window_cache.get(model_id)
if cached and (now - cached[0]) < _WINDOW_TTL:
return cached[1]
window: int | None = None
# 1. Catálogo del panel (con self-heal para OpenRouter si caducó).
provider, _, raw_id = model_id.partition("/")
models = await _get_catalog(provider)
if models:
for m in models:
if m.get("id") == raw_id:
cl = m.get("context_length")
if isinstance(cl, int) and cl > 0:
window = cl
break
# 2. Fallback: LiteLLM conoce muchos modelos (deepseek/, anthropic/, ...).
if window is None:
try:
import litellm
info = litellm.get_model_info(model_id) or {}
for key in ("max_input_tokens", "max_tokens"):
v = info.get(key)
if isinstance(v, int) and v > 0:
window = v
break
except Exception:
pass
_window_cache[model_id] = (now, window)
return window
async def compute_cost(model_id: str | None, input_tokens: int, output_tokens: int) -> dict:
"""Coste de una ejecución para `model_id` y los tokens dados.
Devuelve {"cost_usd", "input_cost_1m", "output_cost_1m"} — el coste total y
las tarifas por 1M tokens REALMENTE aplicadas (se almacenan en
`consumo_acaicode.input_cost_1M` / `output_cost_1M`).
"""
input_tokens = int(input_tokens or 0)
output_tokens = int(output_tokens or 0)
def _result(in_1m: float, out_1m: float) -> dict:
return {
"cost_usd": (input_tokens / 1_000_000) * in_1m + (output_tokens / 1_000_000) * out_1m,
"input_cost_1m": round(in_1m, 6),
"output_cost_1m": round(out_1m, 6),
}
# 1. Precio del catálogo OpenRouter (fuente que muestra el admin).
prices = await _catalog_price_per_1m(model_id)
if prices:
return _result(prices[0], prices[1])
# 2. Price map de LiteLLM (deepseek/, anthropic/, etc.).
if model_id and "/" in model_id:
try:
import litellm
prompt_cost, completion_cost = litellm.cost_per_token(
model=model_id,
prompt_tokens=input_tokens,
completion_tokens=output_tokens,
)
total = (prompt_cost or 0.0) + (completion_cost or 0.0)
if total > 0:
# Derivar tarifa por 1M a partir del coste por-token de litellm.
in_1m = (prompt_cost / input_tokens) * 1_000_000 if input_tokens else 0.0
out_1m = (completion_cost / output_tokens) * 1_000_000 if output_tokens else 0.0
return {
"cost_usd": total,
"input_cost_1m": round(in_1m, 6),
"output_cost_1m": round(out_1m, 6),
}
except Exception as e:
logger.warning("cost_per_token(%s) falló, uso coste fijo: %s", model_id, e)
# 3. Coste fijo configurado.
return _result(settings.cost_per_1m_input, settings.cost_per_1m_output)

View File

@@ -11,10 +11,10 @@ import logging
import re import re
from typing import Any from typing import Any
from ..adapters.base import ModelAdapter from ..adapters.base import ContextOverflowError, ModelAdapter
from ..config import settings from ..config import settings
from ..context.engine import ContextEngine from ..context.engine import ContextEngine
from ..context.compactor import estimate_tokens from ..context.compactor import ContextCompactor, estimate_tokens
from ..mcp.manager import MCPManager from ..mcp.manager import MCPManager
from ..memory.store import MemoryStore from ..memory.store import MemoryStore
from ..models.agent import AgentProfile from ..models.agent import AgentProfile
@@ -52,11 +52,16 @@ class OrchestratorEngine:
self, self,
session: SessionState, session: SessionState,
message: str, message: str,
image_attachments: list[dict[str, Any]] | None = None,
) -> dict[str, Any]: ) -> 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: try:
return await asyncio.wait_for( return await asyncio.wait_for(
self._run(session, message), self._run(session, message, image_attachments),
timeout=settings.max_execution_timeout_seconds, timeout=settings.max_execution_timeout_seconds,
) )
except asyncio.TimeoutError: except asyncio.TimeoutError:
@@ -70,6 +75,20 @@ class OrchestratorEngine:
session_id=session.session_id, session_id=session.session_id,
) )
return self._error_result(session, "Execution timed out") 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: except Exception as e:
logger.exception("Unhandled error for session %s", session.session_id) logger.exception("Unhandled error for session %s", session.session_id)
if session.current_task: if session.current_task:
@@ -86,6 +105,7 @@ class OrchestratorEngine:
self, self,
session: SessionState, session: SessionState,
message: str, message: str,
image_attachments: list[dict[str, Any]] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Execute: message → agent → response.""" """Execute: message → agent → response."""
@@ -113,8 +133,8 @@ class OrchestratorEngine:
f"Peticion del usuario:\n{message}" f"Peticion del usuario:\n{message}"
) )
# Create task # Create task (con imágenes adjuntas si las hay — visión nativa)
task = session.begin_task(objective=message) task = session.begin_task(objective=message, image_attachments=image_attachments)
task.status = TaskStatus.EXECUTING task.status = TaskStatus.EXECUTING
# Reset del contador de invocaciones de `acai_plan` por turno (Fase 5). # Reset del contador de invocaciones de `acai_plan` por turno (Fase 5).
@@ -154,6 +174,9 @@ class OrchestratorEngine:
session.recent_messages, session.recent_messages,
message=message, message=message,
conversation=result.get("conversation", []), conversation=result.get("conversation", []),
image_attachments=(
session.current_task.image_attachments if session.current_task else None
),
) )
session.task_history.append( session.task_history.append(
@@ -182,13 +205,18 @@ class OrchestratorEngine:
task.status = TaskStatus.COMPLETED task.status = TaskStatus.COMPLETED
session.complete_task() 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_input = usage.get("input_tokens", 0)
total_output = usage.get("output_tokens", 0) total_output = usage.get("output_tokens", 0)
cost_usd = ( model_used = (
(total_input / 1_000_000) * settings.cost_per_1m_input self.agent_profile.model_id
+ (total_output / 1_000_000) * settings.cost_per_1m_output 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( await self.sse.emit(
EventType.EXECUTION_COMPLETED, EventType.EXECUTION_COMPLETED,
@@ -201,6 +229,19 @@ class OrchestratorEngine:
"status": "completed", "status": "completed",
"usage": usage, "usage": usage,
"total_cost_usd": round(cost_usd, 6), "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, session_id=session.session_id,
) )
@@ -226,6 +267,21 @@ class OrchestratorEngine:
"status": "completed", "status": "completed",
"usage": usage, "usage": usage,
"total_cost_usd": round(cost_usd, 6), "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]: def _error_result(self, session: SessionState, error: str) -> dict[str, Any]:
@@ -246,12 +302,20 @@ class OrchestratorEngine:
existing: list[dict[str, Any]], existing: list[dict[str, Any]],
message: str, message: str,
conversation: list[dict[str, Any]], conversation: list[dict[str, Any]],
image_attachments: list[dict[str, Any]] | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
merged = [OrchestratorEngine._sanitize_recent_message(m) for m in existing] merged = [OrchestratorEngine._sanitize_recent_message(m) for m in existing]
merged = [m for m in merged if m] merged = [m for m in merged if m]
current_turn: list[dict[str, Any]] = [] current_turn: list[dict[str, Any]] = []
if message.strip(): 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}) current_turn.append({"role": "user", "content": message})
for message_obj in conversation: for message_obj in conversation:
@@ -260,7 +324,76 @@ class OrchestratorEngine:
current_turn.append(sanitized) current_turn.append(sanitized)
merged.extend(current_turn) merged.extend(current_turn)
return merged return OrchestratorEngine._trim_recent_messages(merged)
@staticmethod
def _trim_recent_messages(
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Recorta recent_messages a un presupuesto de tokens eliminando
mensajes ENTEROS desde el principio (los mas antiguos).
Dos reglas para no romper el invariante tool_use ↔ tool_result:
- Nunca cortar dentro de un par: si se elimina un assistant con
tool_use, se eliminan tambien sus tool_results (user carrier o run
de mensajes legacy `role: tool`).
- El primer mensaje resultante nunca puede ser un carrier de
tool_result ni un `role: tool`.
Mantiene siempre al menos los ultimos 4 mensajes aunque excedan el
presupuesto.
"""
budget = settings.recent_messages_max_tokens
if budget <= 0 or not messages:
return messages
estimate = ContextCompactor._estimate_message_tokens
total = sum(estimate(m) for m in messages)
if total <= budget:
return messages
def _is_tool_result_carrier(msg: dict[str, Any]) -> bool:
if msg.get("role") == "tool":
return True
if msg.get("role") != "user":
return False
content = msg.get("content")
return isinstance(content, list) and any(
isinstance(b, dict) and b.get("type") == "tool_result"
for b in content
)
def _has_tool_use(msg: dict[str, Any]) -> bool:
if msg.get("role") != "assistant":
return False
if msg.get("tool_calls"):
return True
content = msg.get("content")
return isinstance(content, list) and any(
isinstance(b, dict) and b.get("type") == "tool_use"
for b in content
)
min_keep = 4
n = len(messages)
start = 0
while total > budget and start < n - min_keep:
end = start + 1
if _has_tool_use(messages[start]):
# Arrastrar los tool_results del par (no cortar dentro de el).
while end < n and _is_tool_result_carrier(messages[end]):
end += 1
if n - end < min_keep:
break # Eliminar el par completo invadiria los ultimos min_keep
for k in range(start, end):
total -= estimate(messages[k])
start = end
trimmed = messages[start:]
# El primer mensaje nunca puede ser un tool_result sin su tool_use.
while trimmed and _is_tool_result_carrier(trimmed[0]):
trimmed.pop(0)
return trimmed
@staticmethod @staticmethod
def _sanitize_recent_message(message: dict[str, Any]) -> dict[str, Any]: def _sanitize_recent_message(message: dict[str, Any]) -> dict[str, Any]:

View File

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

View File

@@ -217,6 +217,8 @@ async def run_planner_subloop(
max_tokens=settings.planner_max_tokens or 16000, max_tokens=settings.planner_max_tokens or 16000,
# Temperatura mas baja que el agente principal — queremos JSON limpio. # Temperatura mas baja que el agente principal — queremos JSON limpio.
temperature=0.1, 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) tool_defs = _build_planner_tools(mcp)

View File

@@ -12,6 +12,7 @@ from __future__ import annotations
import json import json
import logging import logging
import uuid
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Any, AsyncIterator from typing import Any, AsyncIterator
@@ -127,14 +128,26 @@ class RedisStorage:
# Execution lock (prevents concurrent messages on same session) # Execution lock (prevents concurrent messages on same session)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Compare-and-delete atómico: solo borra el lock si el valor coincide con
# el token de quien lo adquirió. Evita que una ejecución cuyo lock expiró
# por TTL borre en su `finally` el lock que ya adquirió otra petición.
_UNLOCK_LUA = (
"if redis.call('get', KEYS[1]) == ARGV[1] then "
"return redis.call('del', KEYS[1]) else return 0 end"
)
@asynccontextmanager @asynccontextmanager
async def session_lock( async def session_lock(
self, session_id: str, timeout: int = 300 self, session_id: str, timeout: int | None = None
) -> AsyncIterator[bool]: ) -> AsyncIterator[bool]:
"""Acquire an exclusive execution lock for a session. """Acquire an exclusive execution lock for a session.
Uses SETNX with auto-expiry to prevent deadlocks if the process Uses SETNX with auto-expiry to prevent deadlocks if the process
crashes mid-execution. crashes mid-execution. El TTL es mayor que el timeout global de
ejecución para que el lock no expire (y otra petición lo robe)
mientras la ejecución original sigue viva. Cada adquisición guarda
un token único como valor y la liberación es compare-and-delete
(Lua), de modo que solo el dueño puede borrar el lock.
Usage: Usage:
async with storage.session_lock(session_id) as acquired: async with storage.session_lock(session_id) as acquired:
@@ -142,20 +155,34 @@ class RedisStorage:
raise HTTPException(409, "Session busy") raise HTTPException(409, "Session busy")
# ... execute ... # ... execute ...
""" """
if timeout is None:
timeout = int(settings.max_execution_timeout_seconds) + 60
key = self._key("session", session_id, "lock") key = self._key("session", session_id, "lock")
acquired = await self.client.set(key, "1", nx=True, ex=timeout) token = uuid.uuid4().hex
acquired = await self.client.set(key, token, nx=True, ex=timeout)
try: try:
yield bool(acquired) yield bool(acquired)
finally: finally:
if acquired: if acquired:
await self.client.delete(key) released = await self.client.eval(self._UNLOCK_LUA, 1, key, token)
if not released:
# El lock expiró por TTL y/o lo posee otra petición — no
# tocamos nada, pero lo dejamos registrado.
logger.warning(
"session_lock for %s no longer owned at release "
"(expired or taken over)",
session_id,
)
async def clear_session_lock(self, session_id: str) -> None: async def clear_session_lock(self, session_id: str) -> None:
"""Borra el lock de ejecución de una sesión de forma incondicional. """Borra el lock de ejecución de una sesión de forma incondicional.
Usado por el endpoint de abort para liberar un lock huérfano (de una OJO: borra sin conocer el token del dueño, así que se salta el
ejecución previa que crasheó antes de soltarlo) y no bloquear el compare-and-delete de `session_lock`. SOLO debe invocarse cuando se
siguiente mensaje hasta que expire el TTL. ha confirmado que la ejecución dueña del lock fue cancelada (ver
`abort_session` en routes.py): la tarea cancelada puede no ejecutar
su `finally` de liberación de forma fiable, y en ese caso no hay
riesgo de borrar el lock de una ejecución viva.
""" """
key = self._key("session", session_id, "lock") key = self._key("session", session_id, "lock")
await self.client.delete(key) await self.client.delete(key)

View File

@@ -19,6 +19,71 @@ from .sse import EventType, SSEEmitter
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_GENERIC_ERROR = (
"Ha ocurrido un error procesando tu mensaje. Vuelve a intentarlo en unos momentos."
)
# Patrones que el frontend interpreta por sí mismo (login / sesión expirada).
# No los genericamos para no romper esas detecciones.
_PASSTHROUGH_PATTERNS = (
"not logged in",
"login required",
"authentication required",
"no conversation found",
)
def friendly_error_message(raw: str, code: str = "") -> str:
"""Traduce un error crudo (proveedor/excepción) a un mensaje genérico y
localizado para el usuario final, sin filtrar detalles internos.
Devuelve el texto original sin tocar para los casos de auth/sesión que el
frontend ya gestiona por contenido.
"""
raw = raw or ""
text = "{} {}".format(code or "", raw).lower()
# Auth / sesión: dejar pasar el texto original (lo maneja el frontend)
if any(p in text for p in _PASSTHROUGH_PATTERNS):
return raw
# Timeout de ejecución
if "timeout" in text or "timed out" in text:
return (
"La tarea tardó demasiado en completarse. Prueba a dividirla en "
"pasos más pequeños o vuelve a intentarlo."
)
# Saldo insuficiente / facturación del proveedor (402)
if (
"402" in text
or "insufficient balance" in text
or "insufficient_quota" in text
or "billing" in text
):
return (
"El asistente no está disponible en este momento. Inténtalo de "
"nuevo en unos minutos."
)
# Credenciales del proveedor inválidas (401)
if (
"401" in text
or "invalid_api_key" in text
or "incorrect api key" in text
or "invalid api key" in text
):
return (
"El asistente no está disponible temporalmente por un problema de "
"configuración. Estamos trabajando en ello."
)
# Límite de peticiones (429)
if "429" in text or "rate limit" in text or "rate_limit" in text:
return (
"Hay mucha demanda en este momento. Espera unos segundos y vuelve "
"a intentarlo."
)
return _GENERIC_ERROR
class ClaudeFormatEmitter: class ClaudeFormatEmitter:
"""Emits events in Claude Code CLI SSE format. """Emits events in Claude Code CLI SSE format.
@@ -298,13 +363,18 @@ class ClaudeFormatEmitter:
"cache_creation_input_tokens": 0, "cache_creation_input_tokens": 0,
}, },
"total_cost_usd": data.get("total_cost_usd", 0), "total_cost_usd": data.get("total_cost_usd", 0),
# Modelo usado → acai-app lo registra en consumo_acaicode.
"modelUsage": data.get("modelUsage", {}),
}) })
# Done # Done
self._push(session_id, {"type": "done"}) self._push(session_id, {"type": "done"})
elif event_type == EventType.ERROR: elif event_type == EventType.ERROR:
error_msg = data.get("message", str(data.get("error", "Unknown error"))) raw_msg = data.get("message", str(data.get("error", "Unknown error")))
user_msg = friendly_error_message(raw_msg, str(data.get("error", "")))
# El error real (detalles del proveedor) solo va al log, nunca al cliente.
logger.warning("Session %s error (raw): %s", session_id, raw_msg)
# Close any open block # Close any open block
self._close_text_block(session_id) self._close_text_block(session_id)
@@ -312,7 +382,7 @@ class ClaudeFormatEmitter:
self._push(session_id, { self._push(session_id, {
"type": "result", "type": "result",
"is_error": True, "is_error": True,
"result": error_msg, "result": user_msg,
"usage": {"input_tokens": 0, "output_tokens": 0, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0}, "usage": {"input_tokens": 0, "output_tokens": 0, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0},
"total_cost_usd": 0, "total_cost_usd": 0,
}) })

View File

@@ -65,6 +65,128 @@ class TestSettingsBudget:
assert cfg.effective_context_budget == 172_000 assert cfg.effective_context_budget == 172_000
assert cfg.effective_compaction_threshold == 137_600 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: class TestContextEngine:
def test_build_context_keeps_task_history_and_current_task(self): def test_build_context_keeps_task_history_and_current_task(self):
@@ -294,11 +416,27 @@ class TestTaskHistoryTrim:
class TestConversationCompaction: class TestConversationCompaction:
def test_compactor_preserves_last_user_and_compacts_old_tool_results(self): def test_compactor_preserves_last_user_and_compacts_old_tool_results(self):
compactor = ContextCompactor(max_tokens=999999) compactor = ContextCompactor(max_tokens=999999)
# Los assistants llevan sus tool_calls: sin ellos los `role: tool`
# serian huerfanos y `_enforce_tool_pairing` los convertiria a user.
messages = [ messages = [
{"role": "user", "content": "Contexto anterior " * 10}, {"role": "user", "content": "Contexto anterior " * 10},
{"role": "assistant", "content": "Voy a revisar el modulo ahora mismo. " * 6}, {
"role": "assistant",
"content": "Voy a revisar el modulo ahora mismo. " * 6,
"tool_calls": [
{"id": "tool-1", "type": "function",
"function": {"name": "t", "arguments": "{}"}},
],
},
{"role": "tool", "tool_call_id": "tool-1", "content": "resultado antiguo\n" * 80}, {"role": "tool", "tool_call_id": "tool-1", "content": "resultado antiguo\n" * 80},
{"role": "assistant", "content": "He visto el resultado anterior. " * 6}, {
"role": "assistant",
"content": "He visto el resultado anterior. " * 6,
"tool_calls": [
{"id": "tool-2", "type": "function",
"function": {"name": "t", "arguments": "{}"}},
],
},
{"role": "tool", "tool_call_id": "tool-2", "content": "resultado reciente\n" * 80}, {"role": "tool", "tool_call_id": "tool-2", "content": "resultado reciente\n" * 80},
{"role": "user", "content": "Este es el ultimo mensaje del usuario y debe quedar intacto."}, {"role": "user", "content": "Este es el ultimo mensaje del usuario y debe quedar intacto."},
] ]
@@ -358,9 +496,18 @@ class TestConversationCompaction:
def test_compactor_only_touches_user_messages_as_last_resort(self): def test_compactor_only_touches_user_messages_as_last_resort(self):
compactor = ContextCompactor(max_tokens=999999) compactor = ContextCompactor(max_tokens=999999)
# tool_calls en el assistant para que el `role: tool` no sea huerfano
# (el invariante `_enforce_tool_pairing` convertiria un huerfano a user).
messages = [ messages = [
{"role": "user", "content": "Contexto previo del usuario " * 8}, {"role": "user", "content": "Contexto previo del usuario " * 8},
{"role": "assistant", "content": "Respuesta previa del asistente " * 6}, {
"role": "assistant",
"content": "Respuesta previa del asistente " * 6,
"tool_calls": [
{"id": "tool-1", "type": "function",
"function": {"name": "t", "arguments": "{}"}},
],
},
{"role": "tool", "tool_call_id": "tool-1", "content": "resultado viejo\n" * 80}, {"role": "tool", "tool_call_id": "tool-1", "content": "resultado viejo\n" * 80},
{"role": "user", "content": "Ultimo mensaje del usuario"}, {"role": "user", "content": "Ultimo mensaje del usuario"},
] ]

View File

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

View File

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

View File

@@ -0,0 +1,585 @@
"""Tests de REGRESION REAL del invariante tool_use ↔ tool_result.
A diferencia del resto de tests (que replican logica), este archivo importa el
codigo REAL de src/. Cubre el bug de produccion: sesiones largas (~130k tokens)
donde `compact_conversation` colapsaba assistants a "[ASSISTANT COMPACTADO]"
perdiendo los bloques `tool_use`, dejando tool_results huerfanos que el adapter
emitia como `role: tool` sin `tool_calls` → 400 del proveedor en cada reintento.
Requiere las dependencias de src/ (pydantic, Python 3.11+). Si no estan
disponibles (p.ej. host con Python 3.10), el modulo entero se salta — ejecutar
dentro del container: `docker exec acai-agentic python3 -m pytest ...`.
"""
import pytest
try:
from src.context.compactor import ContextCompactor
except Exception as e: # pragma: no cover - entorno sin deps de src/
pytest.skip(f"src/ no importable en este entorno: {e}", allow_module_level=True)
# =====================================================================
# Helper de validacion reutilizable
# =====================================================================
def collect_tool_use_ids(message: dict) -> set:
"""IDs de tool calls de un assistant (Anthropic blocks + OpenAI legacy)."""
ids = set()
content = message.get("content")
if isinstance(content, list):
for b in content:
if isinstance(b, dict) and b.get("type") == "tool_use":
ids.add(str(b.get("id", "")))
for tc in message.get("tool_calls") or []:
if isinstance(tc, dict):
ids.add(str(tc.get("id", "")))
ids.discard("")
return ids
def assert_tool_pairing_ok(messages: list) -> None:
"""Valida el invariante completo sobre una lista de mensajes internos:
- Todo tool_result (block) referencia un tool_use del assistant anterior.
- Todo tool_use (block) tiene su tool_result en el mensaje siguiente.
- Todo `role: tool` legacy responde a un tool_call del assistant previo.
"""
for i, msg in enumerate(messages):
role = msg.get("role")
content = msg.get("content")
if role == "user" and isinstance(content, list):
result_ids = {
str(b.get("tool_use_id", ""))
for b in content
if isinstance(b, dict) and b.get("type") == "tool_result"
}
if result_ids:
assert i > 0, f"msg[{i}]: tool_result al inicio de la conversacion"
prev = messages[i - 1]
assert prev.get("role") == "assistant", (
f"msg[{i}]: tool_result sin assistant inmediatamente anterior"
)
available = collect_tool_use_ids(prev)
orphans = result_ids - available
assert not orphans, (
f"msg[{i}]: tool_result huerfanos {orphans} "
f"(assistant previo solo tiene {available})"
)
if role == "assistant":
tool_ids = collect_tool_use_ids(msg)
if tool_ids:
answered = set()
j = i + 1
if (
j < len(messages)
and messages[j].get("role") == "user"
and isinstance(messages[j].get("content"), list)
):
for b in messages[j]["content"]:
if isinstance(b, dict) and b.get("type") == "tool_result":
answered.add(str(b.get("tool_use_id", "")))
j += 1
while j < len(messages) and messages[j].get("role") == "tool":
answered.add(str(messages[j].get("tool_call_id", "")))
j += 1
unanswered = tool_ids - answered
assert not unanswered, (
f"msg[{i}]: tool_use sin respuesta {unanswered}"
)
if role == "tool":
prev_assistant = None
for k in range(i - 1, -1, -1):
if messages[k].get("role") == "tool":
continue
if messages[k].get("role") == "assistant":
prev_assistant = messages[k]
break
assert prev_assistant is not None, (
f"msg[{i}]: role tool sin assistant previo"
)
call_id = str(msg.get("tool_call_id", ""))
assert call_id in collect_tool_use_ids(prev_assistant), (
f"msg[{i}]: role tool con tool_call_id={call_id} no presente "
f"en el assistant previo"
)
def make_turn(n: int, payload_chars: int = 4000) -> list:
"""Genera un turno completo: user → assistant(thinking+text+tool_use) →
user(tool_result). Payloads grandes para forzar la compactacion."""
tid = f"call_{n}"
return [
{"role": "user", "content": f"Peticion {n}: " + ("x" * payload_chars)},
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "razonando " * (payload_chars // 10)},
{"type": "text", "text": f"Voy a ejecutar la tool del turno {n}."},
{
"type": "tool_use",
"id": tid,
"name": "acai_get_records",
"input": {"tableName": f"tabla_{n}"},
},
],
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": tid,
"content": "resultado " * (payload_chars // 10),
}
],
},
]
# =====================================================================
# (a) compact_conversation end-to-end: el paso de ultimo recurso ya no
# deja tool_results huerfanos ni tool_use sin respuesta
# =====================================================================
class TestCompactConversationPairing:
def test_last_resort_does_not_orphan_tool_results(self):
compactor = ContextCompactor()
messages = []
for n in range(12):
messages.extend(make_turn(n, payload_chars=6000))
messages.append({"role": "user", "content": "ultima peticion del usuario"})
# Presupuesto minusculo: fuerza TODOS los pasos incluida la colapsa
# de listas a placeholder string (el paso que causaba el bug).
compacted, meta = compactor.compact_conversation(messages, max_tokens=300)
assert meta["output_tokens"] < meta["input_tokens"]
assert_tool_pairing_ok(compacted)
def test_moderate_budget_keeps_pairing(self):
compactor = ContextCompactor()
messages = []
for n in range(8):
messages.extend(make_turn(n, payload_chars=3000))
messages.append({"role": "user", "content": "peticion final"})
compacted, _ = compactor.compact_conversation(messages, max_tokens=2000)
assert_tool_pairing_ok(compacted)
def test_under_budget_passthrough_keeps_pairing(self):
compactor = ContextCompactor()
messages = make_turn(1, payload_chars=50)
compacted, meta = compactor.compact_conversation(messages, max_tokens=100_000)
assert meta["messages_compacted"] == 0
assert_tool_pairing_ok(compacted)
# Los tool_use/tool_result originales se conservan intactos
assert collect_tool_use_ids(compacted[1]) == {"call_1"}
def test_last_user_message_preserved(self):
compactor = ContextCompactor()
messages = []
for n in range(10):
messages.extend(make_turn(n, payload_chars=5000))
final = "esta es la peticion actual que NO debe perderse"
messages.append({"role": "user", "content": final})
compacted, _ = compactor.compact_conversation(messages, max_tokens=300)
assert compacted[-1]["content"] == final
# =====================================================================
# (b) _enforce_tool_pairing directo
# =====================================================================
class TestEnforceToolPairing:
def setup_method(self):
self.compactor = ContextCompactor()
def test_collapsed_assistant_with_orphan_tool_results(self):
"""Assistant colapsado a string + user con tool_results → los
tool_result se convierten en placeholder."""
messages = [
{"role": "assistant", "content": "[ASSISTANT COMPACTADO]"},
{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "call_a", "content": "datos"},
{"type": "tool_result", "tool_use_id": "call_b", "content": "mas datos"},
],
},
]
repaired = self.compactor._enforce_tool_pairing(messages)
assert_tool_pairing_ok(repaired)
# Solo placeholders → content string (fusionados en uno)
assert repaired[1]["role"] == "user"
assert repaired[1]["content"] == "[Resultado de herramienta compactado]"
def test_orphan_tool_results_mixed_with_text(self):
"""tool_result huerfano junto a un bloque text → placeholder en lista,
el text se conserva."""
messages = [
{"role": "assistant", "content": "[ASSISTANT COMPACTADO]"},
{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "call_a", "content": "datos"},
{"type": "text", "text": "y ademas haz esto"},
],
},
]
repaired = self.compactor._enforce_tool_pairing(messages)
assert_tool_pairing_ok(repaired)
content = repaired[1]["content"]
assert isinstance(content, list)
types = [b.get("type") for b in content]
assert types == ["text", "text"]
assert content[0]["text"] == "[Resultado de herramienta compactado]"
assert content[1]["text"] == "y ademas haz esto"
def test_partial_id_mismatch_drops_unanswered_tool_use(self):
"""Assistant con 3 tool_use, user con solo 2 tool_result → se elimina
el tool_use sin respuesta, thinking/text intactos."""
messages = [
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "pensando"},
{"type": "text", "text": "ejecuto tres tools"},
{"type": "tool_use", "id": "c1", "name": "t1", "input": {}},
{"type": "tool_use", "id": "c2", "name": "t2", "input": {}},
{"type": "tool_use", "id": "c3", "name": "t3", "input": {}},
],
},
{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "c1", "content": "r1"},
{"type": "tool_result", "tool_use_id": "c3", "content": "r3"},
],
},
]
repaired = self.compactor._enforce_tool_pairing(messages)
assert_tool_pairing_ok(repaired)
assert collect_tool_use_ids(repaired[0]) == {"c1", "c3"}
types = [b.get("type") for b in repaired[0]["content"]]
assert "thinking" in types and "text" in types
def test_assistant_tool_use_with_no_results_at_all(self):
"""Assistant con tool_use y SIN user de resultados detras → se
eliminan los tool_use; si el content queda vacio, placeholder."""
messages = [
{
"role": "assistant",
"content": [
{"type": "tool_use", "id": "c9", "name": "t", "input": {}},
],
},
{"role": "user", "content": "otra cosa"},
]
repaired = self.compactor._enforce_tool_pairing(messages)
assert_tool_pairing_ok(repaired)
assert repaired[0]["content"] == "[ASSISTANT COMPACTADO]"
def test_legacy_orphan_role_tool_converted_to_user(self):
"""role:tool legacy cuyo assistant anterior no tiene tool_calls →
se convierte a user placeholder."""
messages = [
{"role": "assistant", "content": "[ASSISTANT COMPACTADO]"},
{"role": "tool", "tool_call_id": "call_x", "content": "salida tool"},
]
repaired = self.compactor._enforce_tool_pairing(messages)
assert_tool_pairing_ok(repaired)
assert repaired[1]["role"] == "user"
assert repaired[1]["content"] == "[Resultado de herramienta compactado]"
def test_legacy_valid_role_tool_untouched(self):
messages = [
{
"role": "assistant",
"content": "lanzo tool",
"tool_calls": [
{"id": "call_x", "type": "function",
"function": {"name": "t", "arguments": "{}"}},
],
},
{"role": "tool", "tool_call_id": "call_x", "content": "salida"},
]
repaired = self.compactor._enforce_tool_pairing(messages)
assert_tool_pairing_ok(repaired)
assert repaired[1]["role"] == "tool"
def test_well_paired_history_is_noop(self):
messages = make_turn(7, payload_chars=50)
repaired = self.compactor._enforce_tool_pairing(messages)
assert repaired == messages
# =====================================================================
# (c) Trim de recent_messages (OrchestratorEngine._trim_recent_messages)
# =====================================================================
orchestrator_engine = pytest.importorskip(
"src.orchestrator.engine",
reason="deps del orquestador (mcp, sse, redis) no disponibles",
)
OrchestratorEngine = orchestrator_engine.OrchestratorEngine
class TestTrimRecentMessages:
def _set_budget(self, monkeypatch, tokens: int):
from src.config import settings
monkeypatch.setattr(settings, "recent_messages_max_tokens", tokens)
def test_under_budget_untouched(self, monkeypatch):
self._set_budget(monkeypatch, 100_000)
messages = make_turn(0, payload_chars=100)
assert OrchestratorEngine._trim_recent_messages(list(messages)) == messages
def test_trims_oldest_whole_pairs(self, monkeypatch):
self._set_budget(monkeypatch, 500)
messages = []
for n in range(10):
messages.extend(make_turn(n, payload_chars=1000))
trimmed = OrchestratorEngine._trim_recent_messages(messages)
assert len(trimmed) < len(messages)
# Nunca se corta dentro de un par
assert_tool_pairing_ok(trimmed)
# El primer mensaje nunca es un carrier de tool_result ni role tool
first = trimmed[0]
assert first.get("role") != "tool"
if isinstance(first.get("content"), list):
assert not any(
isinstance(b, dict) and b.get("type") == "tool_result"
for b in first["content"]
)
# Se eliminan los mas antiguos: el final se conserva
assert trimmed[-1] == messages[-1]
def test_keeps_last_four_even_over_budget(self, monkeypatch):
self._set_budget(monkeypatch, 10) # presupuesto imposible
messages = []
for n in range(5):
messages.extend(make_turn(n, payload_chars=2000))
trimmed = OrchestratorEngine._trim_recent_messages(messages)
assert len(trimmed) >= 4
def test_pair_dragging_includes_legacy_tool_run(self, monkeypatch):
"""Un assistant legacy con tool_calls arrastra su run de role:tool."""
self._set_budget(monkeypatch, 300)
big = "y" * 3000
messages = [
{
"role": "assistant",
"content": big,
"tool_calls": [
{"id": "c1", "type": "function",
"function": {"name": "t", "arguments": "{}"}},
{"id": "c2", "type": "function",
"function": {"name": "t", "arguments": "{}"}},
],
},
{"role": "tool", "tool_call_id": "c1", "content": big},
{"role": "tool", "tool_call_id": "c2", "content": big},
{"role": "user", "content": "pregunta"},
{"role": "assistant", "content": "respuesta"},
{"role": "user", "content": "otra pregunta"},
{"role": "assistant", "content": "otra respuesta"},
]
trimmed = OrchestratorEngine._trim_recent_messages(messages)
# El par legacy entero (assistant + 2 tools) se elimino junto
assert trimmed[0] == {"role": "user", "content": "pregunta"}
assert_tool_pairing_ok(trimmed)
def test_append_recent_messages_applies_trim(self, monkeypatch):
self._set_budget(monkeypatch, 500)
existing = []
for n in range(10):
existing.extend(make_turn(n, payload_chars=1000))
merged = OrchestratorEngine._append_recent_messages(
existing, message="nueva peticion", conversation=[
{"role": "assistant", "content": "ok hecho"},
],
)
assert len(merged) < len(existing) + 2
assert merged[-1] == {"role": "assistant", "content": "ok hecho"}
assert_tool_pairing_ok(merged)
# =====================================================================
# (d) Guard defensivo del adapter (_repair_tool_sequence)
# =====================================================================
openai_mod = pytest.importorskip("openai", reason="SDK openai no instalado")
class TestRepairToolSequence:
@property
def repair(self):
from src.adapters.openai_adapter import OpenAIAdapter
return OpenAIAdapter._repair_tool_sequence
def test_valid_sequence_untouched(self):
msgs = [
{"role": "system", "content": "sys"},
{"role": "user", "content": "hola"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{"id": "c1", "type": "function",
"function": {"name": "t", "arguments": "{}"}},
],
},
{"role": "tool", "tool_call_id": "c1", "content": "resultado"},
{"role": "assistant", "content": "listo"},
]
assert self.repair(list(msgs)) == msgs
def test_orphan_tool_message_converted_to_user(self):
msgs = [
{"role": "assistant", "content": "[ASSISTANT COMPACTADO]"},
{"role": "tool", "tool_call_id": "c_orphan", "content": "datos " * 200},
]
out = self.repair(msgs)
assert out[1]["role"] == "user"
assert out[1]["content"].startswith(
"[Resultado de herramienta (contexto compactado)]: "
)
# Content truncado a 500 chars (+ prefijo)
assert len(out[1]["content"]) <= 500 + len(
"[Resultado de herramienta (contexto compactado)]: "
)
assert not any(m.get("role") == "tool" for m in out)
def test_unanswered_tool_calls_removed(self):
msgs = [
{
"role": "assistant",
"content": None,
"tool_calls": [
{"id": "c1", "type": "function",
"function": {"name": "t", "arguments": "{}"}},
{"id": "c2", "type": "function",
"function": {"name": "t", "arguments": "{}"}},
],
},
{"role": "tool", "tool_call_id": "c1", "content": "r1"},
{"role": "user", "content": "sigue"},
]
out = self.repair(msgs)
assert [tc["id"] for tc in out[0]["tool_calls"]] == ["c1"]
assert out[1] == {"role": "tool", "tool_call_id": "c1", "content": "r1"}
def test_all_tool_calls_unanswered_drops_key_and_sets_content(self):
msgs = [
{
"role": "assistant",
"content": None,
"tool_calls": [
{"id": "c1", "type": "function",
"function": {"name": "t", "arguments": "{}"}},
],
},
{"role": "user", "content": "sigue"},
]
out = self.repair(msgs)
assert "tool_calls" not in out[0]
assert out[0]["content"] # nunca None sin tool_calls
def test_reasoning_promoted_when_tool_calls_dropped(self):
"""No romper la promocion de reasoning a content del fix anterior."""
msgs = [
{
"role": "assistant",
"content": None,
"reasoning_content": "razonamiento del modelo",
"tool_calls": [
{"id": "c1", "type": "function",
"function": {"name": "t", "arguments": "{}"}},
],
},
{"role": "user", "content": "sigue"},
]
out = self.repair(msgs)
assert "tool_calls" not in out[0]
assert out[0]["content"] == "razonamiento del modelo"
assert "reasoning_content" not in out[0]
def test_mixed_orphan_in_tool_block(self):
"""Un huerfano en medio de un bloque de tools validos se convierte a
user DESPUES del bloque (no rompe la contiguidad assistant→tools)."""
msgs = [
{
"role": "assistant",
"content": None,
"tool_calls": [
{"id": "c1", "type": "function",
"function": {"name": "t", "arguments": "{}"}},
{"id": "c2", "type": "function",
"function": {"name": "t", "arguments": "{}"}},
],
},
{"role": "tool", "tool_call_id": "c1", "content": "r1"},
{"role": "tool", "tool_call_id": "huerfano", "content": "rx"},
{"role": "tool", "tool_call_id": "c2", "content": "r2"},
{"role": "user", "content": "sigue"},
]
out = self.repair(msgs)
roles = [m["role"] for m in out]
assert roles == ["assistant", "tool", "tool", "user", "user"]
assert out[1]["tool_call_id"] == "c1"
assert out[2]["tool_call_id"] == "c2"
assert out[3]["content"].startswith("[Resultado de herramienta")
class TestAdapterEndToEnd:
"""_to_openai_messages + guard sobre un historial roto realista."""
def test_collapsed_assistant_history_produces_valid_openai_sequence(self):
from src.adapters.openai_adapter import OpenAIAdapter
adapter = OpenAIAdapter.__new__(OpenAIAdapter) # sin cliente real
internal = [
{"role": "system", "content": "eres un agente"},
{"role": "user", "content": "haz algo"},
# Assistant colapsado por el compactor (perdio sus tool_use)
{"role": "assistant", "content": "[ASSISTANT COMPACTADO]"},
# …pero el user conserva sus tool_results (el bug de produccion)
{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "call_1", "content": "datos"},
],
},
{"role": "assistant", "content": "termine"},
{"role": "user", "content": "siguiente peticion"},
]
out = adapter._to_openai_messages(internal)
# Contrato OpenAI: ningun role:tool sin tool_calls previo
for i, m in enumerate(out):
if m.get("role") == "tool":
assert i > 0
prev = out[i - 1]
prev_ids = set()
k = i - 1
while k >= 0 and out[k].get("role") == "tool":
k -= 1
if k >= 0 and out[k].get("role") == "assistant":
prev_ids = {
tc.get("id") for tc in out[k].get("tool_calls") or []
}
assert m.get("tool_call_id") in prev_ids, (
f"role tool huerfano en out[{i}]"
)
# El tool_result huerfano acabo como user, no como role tool
assert not any(m.get("role") == "tool" for m in out)