Compare commits

...

26 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
Jordan Diaz
9854960c7c fix(mcp): límite LRU de sesiones MCP — evita degradación del contexto
Las sesiones MCP no se limpiaban (registry._sessions crecía sin límite); cada
una arranca 3 subprocesos stdio (acai-code, playwright+chromium, fetch) con sus
read-loops. Con varias vivas a la vez, las sesiones nuevas recibían cada vez
menos contexto/tools al modelo, hasta que el modelo dejaba de recibir tools y
emitía los tool calls como texto sin ejecutarlos (~300 input_tokens). Esto
degradaba el chat a lo largo del día hasta reiniciar el container.

Fix: MAX_ACTIVE_MCP_SESSIONS=2 con evicción LRU (touch last_used en
create/get_for_session, _evict_lru destruye las menos usadas). Seguro porque
send_message reconecta el MCP de una sesión evictada si vuelve a usarse.
Validado: 1 sesión viva era estable, 6 colapsaban; con cap=2, 7 sesiones
secuenciales se mantienen estables (40-115K tokens, tools OK).

Mitigación, no cura de fondo: el motivo por el que N managers vivos degradan
(probable: chromium de playwright) queda pendiente para subir el umbral.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 09:13:44 +00:00
Jordan Diaz
36318c61ea fix(chat): permitir abortar/preemptar ejecución en curso de una sesión
Antes, al parar el agente y mandar un mensaje nuevo, la ejecución previa
seguía viva reteniendo el session_lock: el mensaje nuevo recibía "busy" y el
stream mostraba la ejecución anterior. La tarea detached (create_task) no se
guardaba en ningún sitio y era imposible cancelarla.

- _running_executions: registro de la tarea asyncio por session_id.
- _cancel_running_execution(): cancela y espera a que libere el lock.
- send_message: preempt — un mensaje nuevo cancela la ejecución previa.
- _execute_and_persist: maneja CancelledError dejando la sesión en ACTIVE.
- POST /sessions/{id}/abort: cancela, cierra el stream SSE y limpia el lock.
- RedisStorage.clear_session_lock(): libera locks huérfanos.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:50:46 +00:00
Jordan Diaz
c5c001468f Ajustes 2026-05-15 19:15:04 +00:00
Jordan Diaz
f7c6e65c0b ajustes 2026-05-15 08:36:39 +00:00
Jordan Diaz
b3ca343798 Ajustes de estructura 2026-05-10 21:27:47 +00:00
Jordan Diaz
5e64bbdfc8 Ajustes de estructura 2026-05-10 18:47:08 +00:00
Jordan Diaz
44cb956f95 Ajustes 2026-05-08 21:31:28 +00:00
Jordan Diaz
0dabba5442 ajustes en docs generales 2026-05-06 07:20:48 +00:00
Jordan Diaz
06ce51a9c1 Mas cosas 2026-05-06 07:07:57 +00:00
75 changed files with 6674 additions and 545 deletions

View File

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

View File

@@ -0,0 +1,20 @@
# Contrato de ejecución (común a todos los agentes Acai Forge)
## Idioma
Responde SIEMPRE en español. Toda comunicación con el usuario, comentarios en código y mensajes de error en español; identificadores técnicos (nombres de tabla, campo, módulo) en el caso original.
## Mecanismo de tools
Para invocar herramientas usa EXCLUSIVAMENTE el mecanismo nativo de tool_use del API. NUNCA escribas tool calls como texto: ni `<tool_call>`, ni `[TOOL_CALL]`, ni `<minimax:tool_call>`, ni `<invoke>`, ni `{tool => ...}`, ni pseudocódigo similar. Si lo escribes, el sistema NO lo ejecutará y el usuario solo verá el markup crudo.
## Eficiencia
- NO repitas llamadas a herramientas con argumentos idénticos. Si necesitas el mismo dato, reutilízalo del último resultado.
- Si ya tienes la información necesaria para responder, genera la respuesta final SIN tool calls adicionales.
- Mantén las respuestas enfocadas en el paso actual, no expliques contexto irrelevante.
## Contexto
- Los resultados de herramientas se incluyen completos en la conversación reciente.
- Los turnos anteriores pueden estar compactados como resúmenes — confía en ellos.
- Tu razonamiento previo (thinking blocks) se conserva entre turnos: úsalo, no repitas el análisis.
## Confirmación de operaciones destructivas
Operaciones irreversibles (`delete_*`, `dropData`, `dropColumn`, `newTableName`, `newFieldName`, `regenerate_enlaces` sin alias, `set_global_libraries`, `set_layout_field`, `delete_module` con `inUse=true`) requieren confirmación explícita del usuario antes de ejecutarse.

View File

@@ -4,7 +4,11 @@ description: "Agente genérico de Acai CMS: crea módulos, edita contenido, gest
icon: "code"
category: "development"
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:
- immutable_rules
- project_profile
@@ -12,4 +16,9 @@ context_sections:
- task_state
allowed_tools: []
model_id: null
# planner_model_id: null # null → usa AGENTIC_PLANNER_MODEL_ID del .env
stream_deltas: true
kb_load_strategy: top_n
kb_max_tokens: 4000
kb_top_n: 2
has_planner_tool: true

View File

@@ -1,161 +1,110 @@
Eres el asistente de desarrollo de Acai CMS. Ayudas al usuario sobre su web Acai: crear y editar módulos, gestionar páginas y registros, configurar tablas, escribir hooks, ajustar header/footer/librerías y subir contenido. Hablas y respondes **siempre en español**.
Eres el **asistente de desarrollo de Acai CMS**. Trabajas en un chat conversacional continuo: el usuario te hace peticiones de muy distinto alcance dentro de la misma sesión, y el contexto del proyecto se va acumulando turno a turno. Tu misión es resolver cada petición con el mínimo número de pasos posibles, **reutilizando** lo que ya sabes del proyecto y los turnos anteriores.
# Identidad y rol
<!-- PLANNER_SECTION_START -->
# Cuándo planificar y cuándo ejecutar directo
Actúas como un desarrollador senior experto en Acai CMS. Antes de cualquier acción no trivial:
1. Identifica qué área toca (módulo, página, tabla, hook, layout, registro, media).
2. Si dudas del detalle de esa área, **lee la doc correspondiente** del knowledge base — la mayoría ya están cargadas; las que no, léelas con la tool `read_doc`.
3. Antes de crear archivos consulta los nombres y campos reales (no inventes nombres de tabla, de campo, de módulo o de hook).
4. Usa la tool adecuada en cada paso. Las tools de archivos `acai-write` / `acai-line-replace` sobre `index-base.tpl` **compilan automáticamente** — no necesitas `compile_module` salvo recuperación manual.
Antes de actuar, juzga el alcance de la petición. Hay dos modos de operación:
# Estructura del proyecto
**Modo directo (default)** — ejecuta tools de cambio sin más:
- La petición se resuelve con ≤3 tool calls de modificación.
- Toca un solo dominio (un módulo, un campo, un layout, un registro).
- No crea tablas nuevas ni schemas nuevos.
- No hace cambios destructivos cross-archivo.
- Es una iteración sobre algo que ya existe en este chat ("ahora más oscuro", "y añade un sticky", "ese título cámbialo a X").
**Modo planificación** — llama PRIMERO la tool `acai_plan(objective, scope?)`:
- Construir una landing entera, una tienda, un módulo nuevo con tabla + hook + frontend juntos.
- Refactor amplio (clonar módulo, migrar de uno a otro, mover layout).
- Cambio cross-cutting con riesgo (modificar todos los módulos que cumplen una condición).
- Petición ambigua donde necesitas leer estado primero para decidir el plan correcto.
Si dudas: si la petición describe **un único cambio concreto y obvio**, modo directo. Si describe **un objetivo compuesto** (varios verbos, varias entidades, varios archivos), modo planificación.
NUNCA llames `acai_plan` para "muéstrame X", "lista Y", "abre Z" — esas son lookups, modo directo siempre.
# Cómo usar `acai_plan`
Llamada: `acai_plan({"objective": "<descripción en español>", "scope": "<restricciones opcionales>"})`.
Recibirás como tool_result un JSON con:
- `steps[]`: lista de pasos con `id`, `description`, `agent_action`, `files_touched`, `tables_touched`, `depends_on`.
- `risks[]`: cosas que pueden fallar.
- `files_touched[]`, `tables_touched[]`: agregados.
Tras recibirlo:
1. **Lee el plan completo** en una pasada y verifica que tiene sentido.
2. **Ejecuta los steps en orden** respetando `depends_on`. Por cada step ejecuta su `agent_action` (1-3 tool calls reales).
3. Tras cada step, da una recap de 1-2 líneas al usuario. NO repitas el plan entero.
4. Si a media ejecución descubres que un step es inviable, ajusta sobre la marcha. Solo replanifica (`acai_plan` otra vez) si el descubrimiento invalida >2 steps siguientes.
El plan persiste en `Active Plan` (lo verás en el contexto) hasta que termines o el usuario cambie de tema. Si retomas el mismo objetivo en un turno futuro, continúa por el `→ Step N` actual.
Si el usuario te corrige a media ejecución ("no, mejor no toques el header"): ajusta los steps afectados y continúa con los demás. Si la corrección invalida el plan, llama `acai_plan_advance({"abandon": true})` y empieza de nuevo.
<!-- PLANNER_SECTION_END -->
# Estructura del proyecto Acai (referencia mínima)
```
template/estandar/modulos/<module-id>/
├── index-base.tpl # source — EDITA SOLO ESTE
├── index.tpl # autogenerado — NO TOCAR
├── index-twig.tpl # autogenerado — NO TOCAR
├── builder.json # autogenerado — NO TOCAR
├── style.css # estático (sin Twig)
├── script.js # estático (sin Twig)
└── hook.php # opcional — hook propio del módulo
hooks/hooks.<id>.php # hooks globales
cms/data/schema/ # schemas de tablas (.ini.php)
cms/lib/plugins/builder_saas/layout.json # PROHIBIDO editar directamente
cms/data/schema/ # .ini.php — SOLO con tools de schema
```
# Reglas inmutables
# Reglas duras (no negociables)
1. **Antes de cualquier área, lee la doc correspondiente** — hazlo con `read_doc` si no la tienes ya cargada en el knowledge base.
2. **NUNCA uses `mkdir`.** Usa `acai-write` directamente para crear el primer archivo — el directorio padre se crea solo.
3. En los módulos **solo editas `index-base.tpl`**. `index.tpl`, `index-twig.tpl` y `builder.json` son autogenerados por la compilación.
4. Editar `index-base.tpl` con `acai-write` o `acai-line-replace` **dispara compilación automática**. `compile_module` solo para recuperación manual.
5. **`script.js` y `style.css` son archivos estáticos.** NO uses sintaxis Twig ni atributos builder dentro. Pasa valores dinámicos vía atributos `data-*` desde `index-base.tpl`.
6. **Twig usa filtros con `|`**, nunca funciones (`'tabla' | get()`, no `get('tabla')`).
7. **Tablas siempre sin prefijo `cms_`** en tools, Twig y `CmsApi`. Excepción: `queryDB` y el `middleWare` de `set_hook_middleware` sí llevan `cms_`.
8. **Primary key siempre `num`**, nunca `id`. Foreign keys con sufijo `_num` (`categoria_num`).
9. **Upload fields son arrays**: `imagen[0].urlPath`, no `imagen`.
10. **Twig concatena con `~`**: `'value=' ~ variable`.
11. **El campo `enlace` ya incluye barras** — NUNCA modifiques un `enlace` existente salvo petición explícita del usuario.
12. **NUNCA modifiques `controlador`** de un registro existente — define si la página es Builder o Standard.
13. **NUNCA inventes nombres de campo o tabla.** Confirma con `get_table_schema` antes de usarlos.
14. **NUNCA edites directamente** `cms/lib/plugins/builder_saas/layout.json`, `template/estandar/modulos/custom-header-twig/*` ni `template/estandar/modulos/custom-footer-twig/*`. Usa `get_layout_field` / `set_layout_field`.
15. **Para textos editables/traducibles** usa `| translate` (resuelve sobre la tabla `textos_generales`). NUNCA crees archivos JSON, `.po` ni sistemas i18n externos.
16. **Detalle de registros** se resuelve con sección general `template/estandar/modulos/custom-{tableName}/`. NO crees página por registro en `apartados`. NO uses ni configures `_detailPage` (no existe).
17. **`c-if` usa `=` (un igual). `{% if %}` usa `==` (doble igual).**
18. **Checkbox guarda `1` o `0` (número)**, nunca `true` / `false`.
19. **Para URLs del sitio** usa `get_web_url` siempre + `?pruebas=1`. Nunca `localhost:8080` ni dominios de producción.
20. **Operaciones destructivas** (`delete_*`, `dropData`, `dropColumn`, `newTableName`, `newFieldName`, `regenerate_enlaces` sin alias, `set_global_libraries`, `set_layout_field`, `delete_module` con `inUse=true`): pide confirmación al usuario antes de ejecutar.
1. **NUNCA `mkdir`.** Usa `acai-write` directamente — el directorio se crea solo.
2. **Solo edita `index-base.tpl`** de los módulos. Los `.tpl` y `.json` autogenerados NO se tocan.
3. `acai-write` / `acai-line-replace` sobre `index-base.tpl` **compilan automáticamente**.
4. **`script.js` y `style.css` son estáticos** — no Twig dentro. Pasa valores con `data-*`.
5. **Tablas sin `cms_`** en tools y Twig. En `queryDB` y `set_hook_middleware` SÍ con `cms_`.
6. **Primary key `num`**, foreign keys `*_num`. **Upload fields son arrays**: `imagen[0].urlPath`.
7. **Twig concatena con `~`**. **`c-if` usa `=`, `{% if %}` usa `==`**. **Checkbox: `1`/`0`**.
8. **`enlace` ya incluye barras** — no lo toques. **NUNCA modifiques `controlador`** de un registro existente.
9. **NUNCA inventes** nombres de campo / tabla / módulo. Si dudas: `get_table_schema`, `acai-glob`, `acai-grep`.
10. **Layout y libs**: `get_layout_field` / `set_layout_field`. NUNCA `acai-write` sobre `custom-header-twig/*` ni `cms/lib/plugins/builder_saas/layout.json`.
11. **Texto traducible**: filtro `| translate` (tabla `textos_generales`). NUNCA i18n externo.
12. **Detalle de registros**: sección general `template/estandar/modulos/custom-{tableName}/index-base.tpl` con `thisrecord.*`. NO página por registro en `apartados`. NO `_detailPage`.
13. **Schemas (.ini.php)** solo con tools (`create_table`, `create_field`, etc.).
14. **URL del proyecto**: `get_web_url` + `?pruebas=1` siempre.
15. **Operaciones destructivas**: confirma con el usuario antes de ejecutar.
# Decision tree — qué hacer según la intención del usuario
# Eficiencia de edición (menos pasos Y menos tokens)
| Intención | Secuencia canónica |
|-----------|--------------------|
| **Crear módulo nuevo** | (lee `01-builder-fields`, si JS `07-css-js-conventions`, si hook `06-hooks-and-cmsapi`) → `acai-write index-base.tpl` (compila) → `add_module_to_record``set_module_config_vars` → imágenes con `uploadFields``navigate_browser` |
| **Editar módulo** | `get_module_config_vars``acai-view``acai-line-replace``set_module_config_vars` si cambian valores |
| **Cambiar variables de un módulo** | `get_module_config_vars` (estado actual) → `set_module_config_vars` |
| **Subir imagen al módulo** | Tras `set_module_config_vars`, usa `uploadFields` directamente → `upload_record_image` (`tableName: "builder_custom"`, `recordId` y `fieldName` del `uploadFields`) |
| **Crear tabla nueva** | Pregunta `enlace`/`seoMetas``create_table``create_field` por cada campo → si `enlace=true`, crea sección general `custom-{tableName}/index-base.tpl` |
| **Crear detalle de registro** | `acai-write template/estandar/modulos/custom-{tableName}/index-base.tpl` con `thisrecord.*`. NUNCA dupliques páginas en `apartados` |
| **Editar header / footer** | `get_layout_field({ field: "header" })` → modificar → `set_layout_field`. NUNCA `acai-write` sobre `custom-header-twig/*` |
| **CSS o JS global** | `get_layout_field({ field: "style" \| "javascript" })``set_layout_field` |
| **Añadir librería externa** | `list_global_libraries``add_global_library({ section: "top" \| "bottom", url })` |
| **Crear hook** | `acai-write` el `.php` → si es global y debe auto-ejecutarse: `set_hook_middleware` |
| **Buscar archivos / texto** | `acai-glob` (paths) / `acai-grep` (contenido) |
| **Listar/buscar registros** | `list_table_records` con `where`/`order`/`limit`/`fields` |
| **Crear/actualizar registro** | `get_table_schema` para ver campos → `create_or_update_record` |
| **Borrar registros** | `delete_table_records` (destructivo — confirma) |
| **Ver páginas del sitio** | `list_table_records` sobre `apartados` |
| **Ver módulos de una página** | `list_page_modules` |
| **Mover/ocultar módulos** | `reorder_module` / `toggle_module_visibility` |
| **Generar imagen IA** | `generate_image` → en Forge usa `uploadUrl` o `fullUrl` (no `dockerUrl`) → `upload_record_image` |
| **Token expirado (403)** | `refresh_acai_token` y reintenta |
| **Necesito una doc puntual** | `read_doc({ name: "05-tables-and-fields", section: "..." })` o `list_docs()` |
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):
# Mapa de documentación
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.
El knowledge base carga las docs más relevantes a tu tarea por similitud semántica. Si una doc no está cargada (la verás en "Other Available Docs") o necesitas una sección específica, usa `read_doc({ name, section? })`.
# Patrones canónicos (aplica por defecto)
| Doc | Cubre |
|-----|-------|
| `01-builder-fields` | Campos editables (`data-field-type`), atributos Acai (`c-if`, `c-for`, `c-class`), `<set>`, `c-form`, componentes built-in |
| `02-twig` | Filtros Twig (`get`, `queryDB`, `hook`, `module`, `imagec`, `translate`, `raw`...), operadores, ejemplos |
| `03-modules-and-sections` | Módulos vs secciones generales, `thisrecord`, `multiv2`, convención `custom-{tableName}` |
| `04-pages-and-records` | Builder vs Standard, tipos de tabla por `menuType`, `apartados`, reglas sobre `enlace`/`controlador` |
| `05-tables-and-fields` | Tools de schema (`create_table`, `create_field`, `update_field`...), tipos de campo, props, casos destructivos |
| `06-hooks-and-cmsapi` | Hooks PHP (global / módulo), `CmsApi`/`CocoDB`, hook middleware |
| `07-css-js-conventions` | Tailwind+BEM, scoping con clase raíz, Vue 3, componentes nativos, `script.js`/`style.css` estáticos |
| `08-layout-and-libraries` | `get_layout_field`/`set_layout_field`, librerías globales (top/bottom), regla crítica de no editar layout.json |
| `09-mcp-tools-reference` | Inventario completo de tools + workflows canónicos paso a paso |
| `10-production-patterns` | Patrones reales reutilizables (cabecera, zigzag, FAQ, formulario, detalle, gallery) |
| `11-quick-reference` | Cheat sheet con todas las reglas, tipos, filtros, formatos |
- **Detalle de registro**: sección `custom-{tableName}` con `thisrecord.*`.
- **Form contacto/postulación**: `c-form` (inserta + email auto). Tabla propia solo si el usuario quiere admin.
- **Tabla "publicable"** (noticias, vacantes, blog): `fecha_publicacion`, `fecha_expiracion?`, `visible` (checkbox).
- **Form embebido**: `<form_postular :vacante_num="thisrecord.num"></form_postular>`.
Si vas a crear o editar algo y no recuerdas exactamente cómo, **prefiere leer la doc** (`read_doc`) antes que adivinar.
# Mapa de docs (pide con `read_doc` lo que necesites)
# Patrones de diseño canónicos
`01-builder-fields`, `02-twig`, `03-modules-and-sections`, `04-pages-and-records`, `05-tables-and-fields`, `06-hooks-and-cmsapi`, `07-css-js-conventions`, `08-layout-and-libraries`, `09-mcp-tools-reference`, `10-production-patterns`, `11a-decision-table`, `11b-rules-cheat-sheet`, `12-glossary`. La KB ya carga 1-2 docs relevantes a tu turno; las que no, léelas con `read_doc({name, section?})`.
Aplica estos patrones **por defecto** sin preguntar; desvíate solo si el usuario lo pide explícitamente.
# Estilo
## Detalle de registros — Sección General `custom-{tableName}`
Toda tabla con campo `enlace` (vacantes, productos, noticias, servicios) tiene automáticamente una sección general que el CMS renderiza al acceder a la URL de cualquier registro. El módulo se llama **literalmente** `custom-{tableName}` (ej. `custom-vacantes`).
Flujo correcto:
1. `create_table` con `enlace=true`
2. `create_field` para cada campo
3. `acai-write` sobre `template/estandar/modulos/custom-{tableName}/index-base.tpl` con `thisrecord.*`
4. (Opcional) Módulo de listado `{tableName}_listado_xxxxxx`
5. (Opcional) Página índice `/{tableName}/` en `apartados` (Builder) con el listado dentro
Reglas duras:
- NO crees una página por registro en `apartados`.
- NO uses `_detailPage` (no existe).
- NO construyas URLs con query params (`?id=5`).
- NO uses hooks para cargar el registro — `thisrecord` ya está disponible.
- El nombre del módulo **debe** ser `custom-{tableName}` exacto.
## Formularios — `c-form`
Para contacto, postulación, cualquier form estándar: usa `c-form` (inserta en BD + envía email automáticamente). NO construyas POST/hook custom si `c-form` cubre el caso. Solo crea tabla propia (`postulaciones`) si quieres gestionar esos registros desde el admin.
## Campos típicos de tablas "publicables"
Cuando creas una tabla con `enlace` (noticias, vacantes, blog), añade por defecto:
- `fecha_publicacion` (date) — ordenar y filtrar
- `fecha_expiracion` (date, opcional) — ocultar registro al caducar
- `visible` (checkbox) — control manual
NO añadas un campo "estado" calculado si ya tienes `visible` + fechas.
## Schema (.ini.php) — NUNCA editar a mano
Los `cms/data/schema/*.ini.php` se modifican **exclusivamente** con las tools de schema: `create_table`, `update_table_metadata`, `delete_table`, `reorder_tables`, `create_field`, `update_field`, `delete_field`, `reorder_fields`. NO uses `acai-write` ni `acai-line-replace` sobre estos archivos:
- Saltarías validaciones (regex, tipos, etc.)
- No invalidas la cache de schemas — el frontend ve schema viejo
- No sincronizas con MySQL (no crea/borra columnas reales)
- Puedes romper el formato INI con un escape mal puesto
Para subcampos de un `multitext`, llama a `update_field` con `props.descriptionjson` como **string JSON** del array `[{id_campo, nombre_campo, tipo}, ...]`. La tool docu lo explica.
## Formularios embebidos en detalles
Si un detalle necesita un formulario (postular, pedir info), embebe el módulo del formulario **dentro** de la sección general pasándole el `num`:
```html
<form_postular :vacante_num="thisrecord.num"></form_postular>
```
NO pongas el formulario como sección suelta del listado.
# Acai Core (web-base)
El workspace del proyecto contiene solo la **capa de personalización** (módulos, hooks, schemas, uploads). El core del CMS (routing, render engine, admin, APIs) vive en un directorio separado llamado **web-base**, montado como volumen Docker. NO modifiques archivos de `web-base` — son compartidos entre proyectos.
# Comportamiento esperado
- Comunicación clara, breve y en **español**.
- Antes de un cambio relevante, **anuncia en una frase** lo que vas a hacer y luego ejecuta.
- Tras una acción no trivial, deja una recapitulación de 12 líneas de qué se hizo y qué pasos quedan.
- Si una operación es destructiva o irreversible, **confirma con el usuario** primero.
- Si te falta un dato concreto (qué tabla, qué módulo, qué página), pregúntalo. NO adivines.
- Cuando completes una tarea visible, llama a `navigate_browser` con el enlace correspondiente para que el usuario vea el resultado.
Conciso, español, primera persona. Sin auto-descripción. Sin emojis. Antes de un cambio relevante, anuncia en una frase qué vas a hacer; tras la acción, recap de 1-2 líneas. Reutiliza tu thinking previo: no repitas análisis ya hechos en turnos anteriores. Si te falta un dato concreto del usuario (qué color, cuántos servicios, qué nombre), pregúntalo — no inventes.

View File

@@ -0,0 +1,64 @@
Eres el **planificador interno** del agente Acai. Has sido invocado dentro de un sub-loop con un único trabajo: producir un plan de ejecución estructurado en JSON. NO ejecutas cambios. NO escribes archivos. NO modificas datos.
# Tu único output
Un objeto JSON con esta forma:
```
{
"objective": "string (eco del input)",
"steps": [
{
"id": 1,
"description": "string en español, una línea",
"agent_action": "tool principal y argumentos clave",
"files_touched": ["..."],
"tables_touched": ["..."],
"depends_on": []
}
],
"risks": ["string"],
"files_touched": ["agregado dedup"],
"tables_touched": ["agregado dedup"],
"estimated_steps": 5,
"notes": "opcional, caveats"
}
```
Lo emites como un único bloque de texto al final, sin texto adicional alrededor más allá de ese JSON. El bloque debe parsearse con `json.loads`.
# Cómo planificas
1. **Comprende el objetivo**. Identifica si es una landing, módulo, hook, refactor, datos, auditoría, o combinación.
2. **Investiga lo mínimo necesario** con tools de lectura: `acai-glob`, `acai-grep`, `acai-view`, `list_table_records`, `get_table_schema`, `list_page_modules`, `get_module_config_vars`, `get_layout_field`, `list_global_libraries`, `read_doc`, `list_docs`. NO investigues por curiosidad — investiga solo si lo que descubras cambia el plan.
3. **Granularidad**: cada step debe ser ejecutable con 1-3 tool calls del agente principal. NO juntes "crea tabla y crea módulo y crea hook" en un solo step. Sí junta "crea N campos de la misma tabla con `create_field`".
4. **Identifica nombres exactos**: tablas, campos, módulos, archivos. Si no estás seguro, léelo (no inventes). Si tras leer no existe (es un nombre nuevo), inclúyelo en `tables_touched` o `files_touched` con el nombre que propones.
5. **Dependencias**: usa `depends_on` solo cuando un step requiere el output de otro (p.ej. crear módulo después de crear tabla con `enlace`). No metas todo como cadena lineal innecesaria.
6. **Risks**: 2-5 elementos en español, una línea cada uno. Cosas como "campo X puede no existir en Y", "el módulo `header` ya tiene navegación dinámica que entrará en conflicto", "el hook ya está registrado en el middleware".
# Qué NO haces
- NO escribes archivos (`acai-write`, `acai-line-replace`).
- NO creas registros (`create_or_update_record`, `create_table`, `create_field`).
- NO modificas configuración (`set_module_config_vars`, `set_layout_field`).
- NO compilas, no subes imágenes, no ejecutas hooks.
- NO llamas `acai_plan` (sería recursivo).
# Reglas para un buen plan
- Si el objetivo es trivial (1 tool call obvia), produces un plan de 1 step y ya. NO inflas.
- NO especules sobre lo que el usuario "podría querer también". Solo planifica lo pedido.
- Si el `scope` del input restringe ("no toques el header"), respétalo en risks o exclúyelo de steps.
- Si la petición es ambigua (no sabes qué tabla, qué campo), incluye un step inicial "preguntar al usuario por X, Y" con `agent_action: "ask_user"`.
- **Sé conciso en `description`** — una línea, verbo en infinitivo, lo justo para que el ejecutor sepa qué hace.
# Patrones obligatorios (no los olvides)
Estas combinaciones de tools van SIEMPRE juntas. Si incluyes la primera sin la segunda, el resultado queda incompleto y el usuario verá la página/registro vacío. Inclúyelas como steps explícitos en el plan:
- **`add_module_to_record``set_module_config_vars`**: cada módulo añadido necesita un step posterior que rellene su contenido (título, texto, imágenes, etc.) usando el `sectionId` devuelto. Sin esto el módulo aparece vacío en la página. Excepción única: cuando el usuario pide explícitamente "deja el contenido por defecto".
- **Crear página Builder = 3+ steps mínimo**: (1) `create_or_update_record` en `apartados`, (2) por cada módulo: `add_module_to_record`, (3) por cada módulo: `set_module_config_vars`. Si la página tiene 2 módulos, son 5 steps (1 record + 2 add + 2 config), no 3.
- **Campo `upload` con imagen → `upload_record_image` o `generate_image`** tras el `set_module_config_vars` que devuelve `uploadFields`. Sin esto la imagen no aparece.
- **`create_table``create_field` (N veces)**: una tabla recién creada no tiene campos custom. Lista cada campo como un step propio o agrúpalos en uno.
- **Reutiliza antes de crear módulos custom**: ANTES de planificar la creación de un módulo nuevo (`acai-write` sobre `template/estandar/modulos/X/index-base.tpl` + `builder.json`), incluye un step previo de búsqueda con `acai-glob template/estandar/modulos/*/builder.json` y/o `acai-grep` por palabras clave (hero, banner, formulario, galería, cta, testimonios...). Si encuentras uno que cubra el caso, planifica con `add_module_to_record` + `set_module_config_vars` en vez de crear uno nuevo. Solo planifica módulo custom si tras buscar confirmas que no existe nada parecido.
- **Crear módulo NUEVO custom = diseña HTML primero**: cuando el plan incluye crear un módulo desde cero, el primer step después de la búsqueda debe ser "diseñar el HTML/Tailwind del módulo" (estructura, secciones, slots editables), y solo después viene `acai-write` del `index-base.tpl` traduciendo ese HTML a Smarty con variables `{$variable}` para los textos/imágenes editables. NO planifiques escribir el `.tpl` directamente — el modelo es bueno con HTML/Tailwind, malo improvisando convenciones Smarty/Acai. Acompáñalo de los steps `acai-write` para `builder.json` (define `config_vars` y `uploadFields`) y `config-vars.html` si procede.

View File

@@ -13,3 +13,7 @@ context_sections:
allowed_tools: []
model_id: null
stream_deltas: true
kb_load_strategy: tags
kb_tags: [html, css, modules, twig]
kb_max_tokens: 3000
kb_top_n: 2

View File

@@ -13,3 +13,5 @@ context_sections:
allowed_tools: []
model_id: null
stream_deltas: true
kb_load_strategy: cheatsheet_only
kb_max_tokens: 1500

View File

@@ -13,3 +13,6 @@ context_sections:
allowed_tools: []
model_id: null
stream_deltas: true
kb_load_strategy: top_n
kb_max_tokens: 6000
kb_top_n: 3

View File

@@ -13,3 +13,5 @@ context_sections:
allowed_tools: []
model_id: null
stream_deltas: true
kb_load_strategy: glossary_only
kb_max_tokens: 1500

View File

@@ -8,8 +8,8 @@ max_tokens: 4096
context_sections:
- immutable_rules
- project_profile
- knowledge_base
- task_state
allowed_tools: []
model_id: null
stream_deltas: true
kb_load_strategy: none

View File

@@ -13,3 +13,7 @@ context_sections:
allowed_tools: []
model_id: null
stream_deltas: true
kb_load_strategy: tags
kb_tags: [twig, modules, html, builder]
kb_max_tokens: 3500
kb_top_n: 2

View File

@@ -1,3 +1,10 @@
---
title: "Campos editables del builder"
tags: [builder, twig, html, modules]
load_priority: 80
load_when: [always]
summary: "Atributos data-field-* (textfield, headfield, link, upload, list, multiv2, checkbox), c-if/c-for/c-class, c-form, componentes built-in del builder Acai."
---
# Builder Fields — Campos editables del index-base.tpl
Este documento define los campos editables que el usuario rellena desde el panel del builder de Acai. Cubre el atributo `data-field-type` con todos sus tipos (`textfield`, `headfield`, `textbox`, `wysiwyg`, `link`, `upload`, `uploadMulti`, `list`, `multiv2`, `checkbox`, `colorpicker`), la regla `data-field-label` → nombre de variable, los atributos Acai (`c-if`, `c-else`, `c-for`, `c-class`, `c-hidden`, `c-required`), el tag `<set>`, la inclusión de módulos, los formularios `c-form` y los componentes built-in. Léelo antes de crear o modificar cualquier `index-base.tpl`.
@@ -48,12 +55,9 @@ Reglas obligatorias:
Genera 2 variables: la estándar y `_tag` con la etiqueta elegida (h1…h6).
```html
<{{ titulo_tag | default('h2') }}
data-field-type="headfield"
data-field-label="Título Sección"
class="text-3xl font-bold">
<p data-field-type="headfield" data-field-label="Titulo" >
Título de la sección
</{{ titulo_tag | default('h2') }}>
</p>
```
### textbox
@@ -77,9 +81,10 @@ Editor de texto enriquecido. Acceder con `| raw` para no escapar el HTML.
### link
El campo `enlace` de Acai ya incluye las barras necesarias — nunca añadas barras extra.
Genera 2 variables: la estándar y `_anchor` con el anchor del enlace.
```html
<a data-field-type="link" data-field-label="Enlace Principal" href="#">
<a data-field-type="link" data-field-label="Enlace">
Haz clic aquí
</a>
```

View File

@@ -1,3 +1,10 @@
---
title: "Filtros Twig personalizados de Acai"
tags: [twig, filters, frontend]
load_priority: 70
load_when: [always]
summary: "Filtros Twig: get, hook, queryDB, translate, imagec, módulo, set; concatenación con ~; if con ==; reglas de uso vs c-if del builder."
---
# Twig — Filtros personalizados de Acai
Este documento describe los filtros Twig propios de Acai (`get`, `queryDB`, `hook`, `module`, `imagec`, `translate`) y los filtros estándar más usados (`raw`, `truncate`, `json_decode`, `split`, `filter`). Acai usa **filtros con pipe `|`**, nunca funciones. Léelo antes de escribir cualquier expresión Twig dentro de `index-base.tpl` o de una sección general. Cubre también la concatenación con `~`, los ternarios, el operador `default` y la diferencia entre `c-if` (=) y `{% if %}` (==).

View File

@@ -1,3 +1,10 @@
---
title: "Módulos y Secciones Generales"
tags: [modules, sections, structure, twig, html]
load_priority: 75
load_when: [always]
summary: "Módulos (carpetas en template/estandar/modulos), index-base.tpl, secciones generales (custom-{tableName}/), thisrecord, gestión de uploads de un registro."
---
# Módulos y Secciones Generales
Este documento explica el sistema modular de Acai: la diferencia entre **módulos** (componentes visuales reutilizables que el usuario coloca en páginas Builder) y **secciones generales** (plantillas ligadas a una tabla que se renderizan automáticamente al acceder al `enlace` de un registro). Cubre la estructura de archivos de un módulo, las reglas obligatorias sobre `index-base.tpl`, las variables globales (`section_id`, `interno`, `server.HTTP_HOST`, `loop`), la convención `custom-{tableName}` para detalles de registro, la inclusión de un módulo dentro de otro y el uso de `thisrecord` en secciones generales. Léelo antes de crear, mover o editar cualquier carpeta dentro de `template/estandar/modulos/`.
@@ -126,6 +133,26 @@ Particularidades:
- Foreign keys con sufijo `_num`: `thisrecord.categoria_num`
- Si la FK tiene relación cargada, también aparece como objeto: `thisrecord.categoria_bd[0].nombre`
### Gestionar la galería / uploads de un registro
Cuando el usuario pide "cambia la imagen de la vacante 12", "borra la 2ª foto de la galería", "reordena las imágenes" — **NO uses `upload_record_image` para reemplazar**: eso añade un upload nuevo encima sin borrar el viejo y deja basura. Usa el flow de gestión:
```
list_record_uploads({ tableName, recordId, fieldName })
→ [{ uploadId, urlPath, info1, ... }, ...]
```
Y según el caso:
| Acción del usuario | Tool a usar |
|---|---|
| Reemplazar una imagen concreta por otra | `replace_record_image({ tableName, recordId, fieldName, uploadId, imageUrl })` |
| Borrar una imagen | `delete_record_upload({ uploadId })` |
| Cambiar el orden | `reorder_record_uploads({ tableName, recordId, fieldName, uploadIds: [...] })` |
| Añadir una imagen NUEVA (sin tocar las existentes) | `upload_record_image({ tableName, recordId, fieldName, imageUrl })` |
En modo producción todas estas tools sincronizan automáticamente con el servidor real. Si solo conoces el `recordId` y necesitas saber qué `fieldName` tiene uploads, llama antes a `get_table_schema({ minimal: true })` y filtra los campos `type: "upload"`.
### Embeber formularios en el detalle
Si un detalle necesita un formulario (postular, pedir info), **embebe el módulo del formulario dentro de la sección general** pasándole el `num` del registro actual:

View File

@@ -1,3 +1,10 @@
---
title: "Páginas y Registros"
tags: [pages, apartados, records, structure]
load_priority: 70
load_when: [always]
summary: "Páginas Builder vs Standard, controlador, enlace, registros con num/PK, builder_custom, workflows de creación y edición."
---
# Páginas y Registros
Este documento explica cómo Acai modela las páginas del sitio: toda fila con campo `enlace` es una página, y según el campo `controlador` puede ser **Builder** (modular, contenido por módulos) o **Standard** (contenido directo en los campos del registro). Cubre los tipos de tabla por `menuType` (`category`, `multi`, `single`, `separador`), las particularidades de la tabla `apartados`, los campos de visibilidad (`visible_en_el_menu` vs `visible`), las reglas inviolables sobre `enlace` y `controlador`, y el patrón canónico para implementar el detalle de un registro vía sección general `custom-{tableName}`. Léelo antes de crear, modificar o eliminar cualquier registro de tabla con `enlace`.
@@ -122,6 +129,7 @@ Comprueba siempre qué campo tiene la tabla antes de cambiar visibilidad:
3. `set_module_config_vars` — actualizar valores.
4. O editar el template del módulo: `acai-view` + `acai-line-replace` sobre `index-base.tpl` (compila automáticamente).
5. `reorder_module` para mover módulos, `toggle_module_visibility` para ocultar/mostrar.
6. Para imágenes ya existentes en un campo `upload`: `list_record_uploads` para obtener `uploadId``replace_record_image` (sustituir), `delete_record_upload` (borrar) o `reorder_record_uploads` (reordenar). Para añadir uno nuevo, sigue siendo `upload_record_image`.
## Trabajar con páginas Standard

View File

@@ -1,3 +1,10 @@
---
title: "Schema management — tablas y campos"
tags: [tables, schema, fields, db]
load_priority: 80
load_when: [always]
summary: "create_table/update_table_metadata/delete_table/reorder_tables, create_field/update_field/delete_field/reorder_fields, regenerate_enlaces, tipos de campo."
---
# Tablas y Campos
Este documento explica cómo gestionar tablas y campos en Acai usando las tools del MCP. Cubre: cómo se almacena el schema (`cms/data/schema/{tabla}.ini.php`), los `menuType` (`multi`, `single`, `category`, `separador`), el flag `enlace` para tablas públicas, todos los tipos de campo (`textfield`, `textbox`, `wysiwyg`, `codigo`, `date`, `list`, `checkbox`, `upload`, `multitext`, `separator`), los props comunes (`isRequired`, `defaultValue`, `optionsType`, etc.), la diferencia entre operaciones reversibles e irreversibles (`dropData`, `dropColumn`, rename), y el flujo correcto para crear una funcionalidad nueva. Léelo antes de usar cualquier tool del grupo `tables/`.

View File

@@ -1,3 +1,10 @@
---
title: "Hooks PHP y CmsApi"
tags: [php, hooks, cmsapi, backend]
load_priority: 70
load_when: [always]
summary: "Hooks globales (hooks/hooks.X.php) y de módulo (hook.php), CmsApi::get/insert/update/delete con uploads/relations/translates, set_hook_middleware, auto-registro en layout.json."
---
# Hooks y CmsApi (server-side)
Este documento describe cómo crear y consumir hooks PHP en Acai (lógica server-side) y cómo usar `CmsApi` (alias de `CocoDB`) para acceder a la base de datos. Cubre las dos ubicaciones válidas para un hook (global en `hooks/hooks.<id>.php` o propio de módulo en `template/estandar/modulos/<id>/hook.php`), las cuatro formas de invocarlo (filtro Twig, etiqueta `<hook>`, JS `CmsApi.hook`, `c-form`), las reglas obligatorias (devolver array, no `echo`, no `exit`), la API completa de `CmsApi::get/insert/update/delete` con sus opciones (`uploads`, `relations`, `translates`, `groupBy`, `aggregates`), y la tool `set_hook_middleware` para que un hook global se ejecute automáticamente antes de renderizar páginas. Léelo antes de crear cualquier `.php` de hook.
@@ -287,6 +294,21 @@ set_hook_middleware({
- Crear el `.php` con `acai-write` **NO** activa middleware automáticamente — hay que llamar `set_hook_middleware` explícitamente.
- Lee `get_hook_middleware` antes de modificar para no sobrescribir configuraciones existentes.
### Auto-registro en `layout.json`
Cuando creas, renombras o borras un hook global con las tools de archivos, el backend mantiene `layout.json["hooks"]` sincronizado **automáticamente** — no necesitas tocarlo a mano:
| Acción del agente | Efecto en `layout.json["hooks"]` |
|---|---|
| `acai-write hooks/hooks.X.php` (fichero nuevo) | Añade entry con `endPoint: /hooks/X/`, `middleWare: []`, `entryParams: [{variable: entryVariable}]` |
| `acai-write` sobre un hook existente | Sin cambios (se preserva `middleWare`, `entryParams`, etc.) |
| `acai-delete hooks/hooks.X.php` | Quita la entry huérfana |
| `acai-rename hooks/hooks.X.php → hooks.Y.php` | Quita la vieja + añade la nueva con defaults |
**Implicación**: tras `acai-write` de un hook nuevo, ya está registrado. Si quieres que se ejecute como middleware (`["allurls"]` o records concretos), solo te falta `set_hook_middleware` para configurar ese campo. Para los hooks de módulo (`template/estandar/modulos/<id>/hook.php`) no hay registro en `layout.json` — se descubren por convención de path al ejecutar el módulo.
**Nunca edites `layout.json` directamente** para gestionar hooks. Eso compite con el sync del backend y deja el fichero inconsistente.
## Schemas y formato de datos al insertar
Antes de un `CmsApi::insert`/`update` o de un `create_or_update_record` desde MCP, consulta el schema (`get_table_schema`). Tipos de campo y formato esperado:

View File

@@ -1,3 +1,10 @@
---
title: "CSS y JavaScript — Convenciones"
tags: [css, js, frontend, conventions]
load_priority: 65
load_when: [always]
summary: "Tailwind primary, BEM scoped, data-* para pasar valores dinámicos a script.js (que es estático), CmsApi.hook desde JS, native components, Vue 3 builder."
---
# CSS y JavaScript — Convenciones del Módulo
Este documento define cómo escribir CSS, JavaScript y, cuando hace falta, Vue 3 dentro de un módulo Acai. Cubre la regla "Tailwind first" + BEM para CSS custom, las clases utilitarias propias de Acai (`transition3s`, `click-a-child`, `line-clamp2`, `lazyload`, `bg-main-color`, etc.), las CSS variables del tema (`--main-color`), el patrón obligatorio de **scoping** vía la clase raíz del módulo, la regla dura de que `script.js` y `style.css` son **archivos estáticos** (sin Twig dentro), cómo pasar valores dinámicos desde `index-base.tpl` a JS vía `data-*`, cuándo usar Vue 3 y cómo integrarlo evitando conflicto de delimiters con Twig, y los componentes nativos del builder (Carousel `c-tns-wrapper`, Lightbox, Breadcrumb, AOS, Lazy loading). Léelo antes de escribir cualquier `style.css` o `script.js`.

View File

@@ -1,3 +1,10 @@
---
title: "Layout global y librerías"
tags: [layout, header, footer, libraries]
load_priority: 60
load_when: [always]
summary: "header/footer/javascript/style en layout.json via get_layout_field/set_layout_field, librerías globales (CDN, npm), modos top/bottom, librerías AMP."
---
# Layout Global y Librerías Globales
Este documento explica cómo gestionar los **4 campos globales del proyecto** (`style` CSS global, `javascript` JS global, `header` Twig del header del sitio, `footer` Twig del footer) y las **librerías globales** (CSS/JS/fonts inyectadas en `<head>` o antes de `</body>`). Cubre la regla crítica de NO editar nunca `cms/lib/plugins/builder_saas/layout.json` ni los `.tpl` de `custom-header-twig` / `custom-footer-twig` directamente, las tools `get_layout_field` / `set_layout_field` (única vía válida para editar header/footer/style/javascript) y las tools `list_global_libraries` / `add_global_library` / `remove_global_library` / `set_global_libraries` para gestionar las URLs de librerías. Léelo antes de tocar cualquier cosa relacionada con header, footer, CSS global o librerías externas (jQuery, Vue CDN, Google Fonts, etc.).

View File

@@ -1,3 +1,10 @@
---
title: "Referencia maestra de tools MCP"
tags: [tools, reference, workflows]
load_priority: 50
load_when: [ranked]
summary: "Inventario completo de tools por categoría (archivos, módulos, registros, tablas, layout, libs, hooks, media, navegación, proyecto, git, auth, docs) y workflows canónicos."
---
# MCP Tools — Referencia Completa
Este documento es el **inventario canónico** de todas las tools MCP disponibles para el agente Acai. Está agrupado por categoría (archivos, módulos, registros, tablas, layout, librerías, hooks, media, navegación, proyecto, git, autenticación, docs) y describe para cada tool su propósito, parámetros clave, qué devuelve y cuándo usarla. Incluye además los **workflows canónicos** para las operaciones más comunes (crear módulo, editar módulo, crear funcionalidad nueva con tabla + detalle, gestionar imágenes de un módulo, editar header/footer, configurar middleware de hook). Léelo antes de cualquier tarea para elegir la secuencia correcta de tools.
@@ -97,7 +104,11 @@ Ver `06-hooks-and-cmsapi.md` para uso. Crear/editar el `.php` del hook se hace c
| Tool | Acción | Notas |
|------|--------|-------|
| `generate_image` | Genera imagen con IA y la guarda en `cms/uploads/generated/` | Devuelve `dockerUrl` y `uploadUrl`/`fullUrl`. **En Forge prefiere `uploadUrl`/`fullUrl`** sobre `dockerUrl` para `upload_record_image` |
| `upload_record_image` | Sube imagen a un campo de un registro | Necesita `tableName`, `recordId` (num), `fieldName` real (de relations o `uploadFields`) |
| `upload_record_image` | Sube imagen a un campo `upload` de un registro | Necesita `tableName`, `recordId` (num), `fieldName` real (de `uploadFields` o `get_table_schema`). Sincroniza a producción si el proyecto está en modo prod |
| `list_record_uploads` | Lista los uploads existentes de un campo de un registro | Devuelve cada upload con su `uploadId` (necesario para `replace`/`delete`/`reorder`) |
| `replace_record_image` | Reemplaza un upload existente por uno nuevo | Necesita `uploadId` (de `list_record_uploads`). Borra el viejo + sube el nuevo, ambos con sync a producción |
| `delete_record_upload` | Borra un upload concreto del campo | Necesita `uploadId`. Sincroniza el borrado a producción |
| `reorder_record_uploads` | Cambia el orden de los uploads de un campo | Lista de `uploadIds` en el orden deseado |
| `upload_image_to_assets` | Sube imagen a `/images/` del template (assets globales) | Acepta base64, data URI, URL. Permite resize/quality/format |
### Navegación
@@ -106,6 +117,21 @@ Ver `06-hooks-and-cmsapi.md` para uso. Crear/editar el `.php` del hook se hace c
|------|--------|
| `navigate_browser` | Navega el browser preview del usuario a un `enlace` (e.g. `/servicios/`) |
### Inspección de páginas (Playwright headless)
Tools del MCP `playwright`. El browser headless es del agente — el usuario NO ve lo que pasa aquí. Para que el USER vea algo, usa `navigate_browser`.
| Tool | Acción | Cuándo usarla |
|------|--------|---------------|
| `browser_navigate` | Carga una URL en el headless browser interno | Antes de cualquier otra `browser_*` |
| `browser_snapshot` | Devuelve el accessibility tree YAML (texto estructurado) | **Tool primaria de inspección**. La usas para leer la página. Ves DOM, roles, valores de inputs, enlaces, jerarquía |
| `browser_click`, `browser_fill_form`, `browser_press_key`, `browser_select_option` | Interacciones con elementos | Solo cuando necesitas simular interacción del usuario para reproducir un bug o validar un flow |
| `browser_console_messages` | Logs del console del browser | Para detectar errores JS |
| `browser_network_requests` | Lista de requests | Para detectar 404, fallos de fetch |
| `browser_take_screenshot` | Captura PNG | **Evítala**. El modelo NO procesa imágenes; el screenshot se descarta. Solo úsala si el usuario explícitamente pide "haz un screenshot para que lo vea yo" |
**Regla**: para auditar/inspeccionar/depurar UI, `browser_navigate``browser_snapshot`. NUNCA `browser_take_screenshot` esperando "ver" la página — el modelo es text-only y la imagen se pierde.
### Proyecto
| Tool | Acción |
@@ -118,7 +144,8 @@ Ver `06-hooks-and-cmsapi.md` para uso. Crear/editar el `.php` del hook se hace c
| Tool | Acción |
|------|--------|
| `list_git_log` | Lista los últimos commits para que el usuario elija un id de rollback |
| (rollback) | Tool de rescate; pide confirmación al usuario |
| `recover_previous_git` | Rollback rápido al commit anterior. Tool de rescate pide confirmación al usuario antes de ejecutar |
| `recover_git` | Rollback a un commit específico (por id, obtenido vía `list_git_log`). Pide confirmación al usuario |
### Autenticación
@@ -231,6 +258,27 @@ read_doc({ name: "05-tables-and-fields" }) // doc completo
read_doc({ name: "06-hooks-and-cmsapi", section: "Hook middleware" }) // sección por heading H2
```
### 11. Gestionar uploads existentes de un registro
Cuando el usuario pide "cambia la imagen de la vacante 12", "borra la foto X de la galería", "reordena las imágenes" — **NO uses `upload_record_image` para reemplazar**: eso añade un upload nuevo encima sin tocar el viejo y deja basura. Usa el flow de gestión:
```
list_record_uploads({ tableName, recordId, fieldName })
→ array de uploads, cada uno con su uploadId
```
Según lo que pida el usuario:
- **Reemplazar** una imagen concreta: `replace_record_image({ tableName, recordId, fieldName, uploadId, imageUrl, alt? })` — borra el viejo + sube el nuevo, ambos con sync a producción.
- **Borrar** una imagen: `delete_record_upload({ uploadId, table? })` — sync de borrado a producción.
- **Reordenar**: `reorder_record_uploads({ tableName, recordId, fieldName, uploadIds: [...] })` con la lista en el orden deseado.
Para AÑADIR un upload nuevo (sin reemplazar nada existente), usa `upload_record_image` directamente.
Notas:
- En modo producción todas estas tools sincronizan automáticamente con el servidor real (no solo modifican local).
- Si solo tienes el `recordId` y necesitas saber qué `fieldName` tiene uploads, llama antes a `get_table_schema({ minimal: true })` y filtra los campos `type: "upload"`.
## Reglas globales para todas las tools
1. **`tableName` siempre SIN prefijo `cms_`** (excepto en `queryDB` Twig y en el `middleWare` de `set_hook_middleware`).

View File

@@ -1,3 +1,10 @@
---
title: "Patrones de producción"
tags: [patterns, snippets, examples]
load_priority: 55
load_when: [always]
summary: "Snippets reales: header con menú, FAQ acordeón, zigzag de servicios, formularios con c-form, listados con filtros, structured data, breadcrumbs."
---
# Patrones de Producción
Este documento recoge patrones reales usados en módulos y secciones generales de proyectos Acai en producción. Cada patrón incluye el HTML/Twig listo para reutilizar y notas sobre cuándo aplicarlo. Cubre: cabecera de sección con colores configurables, layout zigzag (imagen + texto alternado), acordeón FAQ, formulario de contacto completo con `c-form`, compartir en redes sociales, sección general de detalle de producto, galería con carousel modo `gallery`. Léelo cuando vayas a crear un módulo y quieras evitar reinventar patrones que ya tienen una versión canónica testeada en producción.

110
docs/11a-decision-table.md Normal file
View File

@@ -0,0 +1,110 @@
---
title: "Tabla de decisión — qué tool usar para qué"
tags: [reference, decision, planner, tools]
load_priority: 90
load_when: [cheatsheet, planner_only]
summary: "Tabla decisional 'intención del usuario → tool MCP'. Es la guía rápida para enrutar peticiones sin leer toda la doc."
---
# Tabla de decisión — qué tool usar
Tabla decisional para mapear la intención del usuario a la herramienta correcta. Si tu petición encaja con varias filas, la primera que matchee es la canónica.
## Módulos (componentes visuales)
| Intención | Tool / workflow |
|---|---|
| Crear módulo nuevo | `acai-write` `index-base.tpl` (compila auto) → `add_module_to_record``set_module_config_vars` |
| Editar template de un módulo | `acai-view``acai-line-replace` (compila auto) |
| Ver datos actuales de un módulo en una página | `get_module_config_vars({ tableName, recordNum, sectionId })` |
| Cambiar valores de un módulo | `set_module_config_vars` |
| Reordenar módulos en una página | `reorder_module({ tableName, recordNum, sectionId, fromPosition, toPosition })` |
| Ocultar/mostrar un módulo | `toggle_module_visibility({ sectionId, visible })` |
| Eliminar instancia de módulo de una página | `remove_module_from_record({ sectionId })` |
| Borrar definición de módulo (carpeta entera) | `delete_module({ moduleId, inUse })` — destructivo, confirma con user |
| Comprobar dónde se usa un módulo | `check_module_usage({ moduleId })` |
| Preview de un módulo con datos de prueba | `check_module({ moduleId, vars })` |
| Datos de ejemplo persistentes para preview | `set_module_example_data({ moduleId, data })` |
## Registros / contenido
| Intención | Tool / workflow |
|---|---|
| Listar registros de una tabla | `list_table_records({ tableName, limit, where, orderBy, fields })` |
| Leer un registro concreto | `get_record({ tableName, recordNum })` |
| Crear/actualizar un registro | `create_or_update_record({ tableName, recordNum?, fields })` |
| Borrar registros | `delete_table_records({ tableName, where })` (destructivo, confirma) |
| Listar módulos en una página Builder | `list_page_modules({ tableName, recordNum })` |
## Imágenes y uploads
| Intención | Tool / workflow |
|---|---|
| Generar imagen con IA | `generate_image({ prompt, size? })` → recibe `uploadUrl`/`fullUrl` |
| Añadir imagen NUEVA a un campo upload | `upload_record_image({ tableName, recordNum, fieldName, imageUrl })` |
| Listar imágenes existentes de un campo upload | `list_record_uploads({ tableName, recordNum, fieldName })` → uploadId por imagen |
| Reemplazar imagen existente | `list_record_uploads``replace_record_image({ uploadId, imageUrl })` |
| Borrar una imagen | `list_record_uploads``delete_record_upload({ uploadId })` |
| Reordenar galería | `list_record_uploads``reorder_record_uploads({ uploadIds: [...] })` |
| Subir imagen a `/images/` (assets globales del template) | `upload_image_to_assets({ imageUrl, fileName })` |
## Tablas y campos (schema)
| Intención | Tool / workflow |
|---|---|
| Ver schema de una tabla | `get_table_schema({ tableName, minimal? })` |
| Listar todas las tablas del proyecto | `list_tables` |
| Crear tabla | `create_table({ tableName, displayName, menuType, enlace?, seoMetas? })` |
| Actualizar metadata de tabla | `update_table_metadata({ tableName, ... })` |
| Borrar tabla | `delete_table({ tableName, dropTable: true|false })` (destructivo) |
| Reordenar tablas en el menú admin | `reorder_tables({ order: [...] })` |
| Crear campo | `create_field({ tableName, fieldName, type, label, ... })` |
| Modificar campo (renombrar, cambiar tipo) | `update_field({ tableName, fieldName, newFieldName?, type?, ... })` |
| Borrar campo | `delete_field({ tableName, fieldName, dropColumn })` (destructivo) |
| Reordenar campos | `reorder_fields({ tableName, order: [...] })` |
| Regenerar enlaces (URLs) | `regenerate_enlaces({ tableName, generateAlias? })` (destructivo si no aliases) |
## Layout y librerías globales
| Intención | Tool / workflow |
|---|---|
| Ver header/footer/javascript/style/lo del layout | `get_layout_field({ field })` |
| Modificar header/footer/scripts globales | `set_layout_field({ field, value })` (destructivo, confirma) |
| Listar librerías cargadas | `list_global_libraries` |
| Añadir librería (CDN, npm) | `add_global_library({ url, position: "top"|"bottom" })` |
| Quitar librería | `remove_global_library({ url })` |
| Reescribir todo el array de librerías | `set_global_libraries({ libraries: [...] })` (destructivo) |
## Hooks PHP
| Intención | Tool / workflow |
|---|---|
| Crear hook global | `acai-write hooks/hooks.X.php` (auto-registra en layout.json) |
| Crear hook de módulo | `acai-write template/estandar/modulos/X/hook.php` |
| Borrar hook global | `acai-delete hooks/hooks.X.php` (auto-quita de layout.json) |
| Renombrar hook global | `acai-rename` o `acai-write` con nuevo nombre + `acai-delete` del viejo |
| Hook que se ejecuta antes de cada página | `set_hook_middleware({ hookEndPoint, middleWare: ["allurls"] })` |
| Hook que se ejecuta solo en una página | `set_hook_middleware({ hookEndPoint, middleWare: ["cms_apartados-87"] })` |
| Ver middleware de un hook | `get_hook_middleware({ hookEndPoint })` |
## Archivos y filesystem
| Intención | Tool / workflow |
|---|---|
| Buscar archivos por glob | `acai-glob({ pattern })` |
| Buscar texto en archivos | `acai-grep({ query, path?, type? })` |
| Leer un archivo | `acai-view({ file_path, start_line?, end_line? })` |
| Crear/sobrescribir un archivo | `acai-write({ file_path, content })` |
| Reemplazar líneas en un archivo | `acai-line-replace({ file_path, oldText, newText })` |
| Borrar un archivo | `acai-delete({ file_path })` (destructivo) |
## Proyecto y debugging
| Intención | Tool / workflow |
|---|---|
| URL del sitio (preview en desarrollo) | `get_web_url` (añade `?pruebas=1` siempre) |
| Navegar al browser preview del usuario | `navigate_browser({ enlace })` |
| Token JWT expirado (errores 403) | `refresh_acai_token` |
| Volver a una versión anterior | `list_git_log``recover_git({ id })` o `recover_previous_git` (destructivos, confirma) |
| Guardar estilos del proyecto en doc | `save_project_styles` |
| Necesito un doc no cargado | `read_doc({ name: "..." })` |
| Listado de docs disponibles | `list_docs` |

View File

@@ -1,6 +1,13 @@
# Quick Reference — Cheat sheet
---
title: "Reglas inmutables y cheat-sheet de tipos"
tags: [reference, rules, cheat]
load_priority: 90
load_when: [cheatsheet]
summary: "Reglas no negociables (cms_, num, _num, upload arrays, c-if/{% if %}), tipos de builder field, atributos Acai, filtros Twig, formato de datos para insert/update, errores comunes."
---
# Reglas inmutables y cheat-sheet
Este documento es un **resumen ejecutable** de las reglas críticas, los tipos de campo, los filtros Twig, los formatos de datos para insert/update y las variables globales. Es la **fuente única de verdad** para resolver dudas rápidas sin tener que abrir los docs largos. Léelo antes de cualquier operación cuando quieras refrescar las reglas; el resto de docs (`01``10`) profundizan en cada tema.
Resumen ejecutable de reglas críticas, tipos de campo, filtros y formatos de datos. Si tienes duda rápida, consulta esto antes de los docs largos.
## Reglas inmutables
@@ -80,7 +87,7 @@ Este documento es un **resumen ejecutable** de las reglas críticas, los tipos d
| `multitext` | String JSON | `"[{\"item\":\"valor\"}]"` |
| `upload` | NO enviar — usar `upload_record_image` después |
## Variables globales
## Variables globales en Twig
| Variable | Descripción |
|----------|-------------|
@@ -91,29 +98,6 @@ Este documento es un **resumen ejecutable** de las reglas críticas, los tipos d
| `loop.index is odd` / `is even` | Layouts alternados |
| `thisrecord` | Registro actual (solo en secciones generales) |
## Decisión rápida — qué tool usar
| Intención | Tool / workflow |
|-----------|-----------------|
| Crear módulo nuevo | `acai-write` `index-base.tpl``add_module_to_record``set_module_config_vars` |
| Editar template de módulo | `acai-view``acai-line-replace` |
| Ver datos de un módulo en una página | `get_module_config_vars` |
| Cambiar valores de un módulo | `set_module_config_vars` |
| Subir imagen a un módulo | Usa `uploadFields` de `set_module_config_vars``upload_record_image` (`tableName: "builder_custom"`) |
| Crear tabla nueva | `create_table` (pregunta `enlace`/`seoMetas` antes) → `create_field` |
| Crear detalle de registro | Sección general en `template/estandar/modulos/custom-{tableName}/` |
| Editar header / footer | `get_layout_field``set_layout_field` (NUNCA edites los `.tpl` directamente) |
| Añadir librería global | `list_global_libraries``add_global_library` (`top` o `bottom`) |
| Hook que se ejecuta antes de cada página | `acai-write` el `.php``set_hook_middleware({ middleWare: ["allurls"] })` |
| Generar imagen IA | `generate_image``upload_record_image` con `uploadUrl`/`fullUrl` |
| Buscar archivos | `acai-glob` |
| Buscar texto en archivos | `acai-grep` |
| URL del proyecto | `get_web_url` (añade `?pruebas=1`) |
| Navegar el preview del usuario | `navigate_browser` |
| Token JWT expirado (403) | `refresh_acai_token` |
| Necesito un doc no cargado | `read_doc({ name: "..." })` |
| Listado de docs | `list_docs()` |
## Errores comunes a evitar
- Editar `index.tpl`, `index-twig.tpl` o `builder.json` (autogenerados).
@@ -126,3 +110,4 @@ Este documento es un **resumen ejecutable** de las reglas críticas, los tipos d
- Crear archivos JSON de i18n (usa `| translate` + tabla `textos_generales`).
- Usar Twig dentro de `script.js` o `style.css` (estáticos — pasa valores via `data-*`).
- Llamar `mkdir` (usa `acai-write` directamente — crea el directorio padre).
- Usar `upload_record_image` para "reemplazar" una imagen existente (añade un upload nuevo encima — usa `replace_record_image`).

86
docs/12-glossary.md Normal file
View File

@@ -0,0 +1,86 @@
---
title: "Glosario de Acai CMS"
tags: [glossary, terms, planner]
load_priority: 85
load_when: [glossary, planner_only]
summary: "Definiciones cortas de términos clave de Acai CMS: sectionId, recordNum, apartados, builder_custom, custom-{tableName}, enlace, controlador, thisrecord, multiv2, c-form, hook middleware, JWT acai_token, web-base, template/estandar/, Builder vs Standard."
---
# Glosario Acai CMS
Definiciones cortas de los términos que aparecen en docs y prompts. Si te pierdes con un concepto, lo encuentras aquí en una línea.
## Estructura del proyecto
**`template/estandar/`** — directorio donde viven los archivos custom del proyecto: módulos, CSS/JS globales, imágenes del template. Lo que el desarrollador edita.
**`web-base`** — código compartido del CMS (motor de render, admin, APIs). Vive aparte y se monta como volumen Docker. **No tocar**.
**`apartados`** — tabla principal de páginas del sitio. Cada registro es una página. Tiene `enlace`, `controlador`, jerarquía padre-hijo (`parentNum`).
**`hooks/`** — directorio de hooks PHP globales. Convención: `hooks/hooks.<id>.php` → endpoint `/hooks/<id>/`.
## Páginas y registros
**Builder vs Standard** — modos de renderizado de una página. Lo decide el campo `controlador` del registro:
- **Builder**: `controlador.php` → contenido modular (módulos drag-drop).
- **Standard**: `controlador_tabla.php` → contenido en campos del registro (`content` HTML).
**`enlace`** — URL pública de un registro (con barras incluidas, ej. `/servicios/`). NUNCA modificar a posteriori (rompe SEO y enlaces internos).
**`controlador`** — campo que define Builder vs Standard. NUNCA modificar a posteriori.
**`recordNum` / `num`** — Primary key. Acai siempre usa `num` (entero), nunca `id`.
**`<table>_num`** — convención de foreign keys. `categoria_num` apunta al `num` de la tabla `categorias`.
## Módulos y secciones
**Módulo** — componente visual reutilizable. Vive en `template/estandar/modulos/<id>/`. Se coloca en páginas Builder vía drag-drop. Archivos: `index-base.tpl` (source), `style.css`, `script.js`, `hook.php` (opcional). El compilador genera `index.tpl`, `index-twig.tpl`, `builder.json`.
**Sección general** — módulo especial que el CMS enlaza por convención de nombre. Renderiza el detalle de un registro de una tabla con `enlace`. Convención: `template/estandar/modulos/custom-{tableName}/`. Recibe el registro como `thisrecord`.
**`custom-{tableName}`** — convención de nombre de la sección general. NO usar `_detailPage`, NO crear página por registro en `apartados`.
**`thisrecord`** — variable Twig disponible en secciones generales con el registro actual. Acceso a campos: `thisrecord.titulo`, `thisrecord.imagen[0].urlPath`, `thisrecord.categoria_num`.
**`builder_custom`** — tabla interna de Acai donde el CMS guarda los valores de los módulos. Cuando el usuario rellena un módulo en una página Builder, los valores se persisten ahí. El `recordNum` para `upload_record_image` cuando subes a un módulo es el `num` de la fila correspondiente en `builder_custom`.
**`sectionId`** — identificador único de una instancia de módulo en una página Builder. Lo devuelve `add_module_to_record`. **No es** el `recordNum` para uploads (eso es `num` de `builder_custom`).
**`multiv2`** — tipo de campo del builder que permite arrays de objetos repetidos (ej. lista de servicios con título + descripción + icono cada uno). Se itera con `c-for`.
## Layout global
**`layout.json`** — fichero (`cms/lib/plugins/builder_saas/layout.json`) con el header, footer, librerías globales, javascript/style globales, y los hooks registrados. **NUNCA editar a mano** — usar `set_layout_field` o las tools de hooks/librerías.
**Hook middleware** — un hook global puede configurarse para auto-ejecutarse antes de renderizar páginas: vacío (solo on-demand), `["allurls"]` (todas las páginas) o `["cms_<table>-<num>", ...]` (páginas específicas). Se configura con `set_hook_middleware`.
**Auto-registro de hooks** — cuando creas/borras un fichero `hooks/hooks.<X>.php` con `acai-write`/`acai-delete`, el backend sincroniza automáticamente la entrada en `layout.json["hooks"]`. NO tocar `layout.json` a mano.
## Builder UI
**`c-form`** — atributo que convierte un `<form>` en un formulario que persiste a una tabla del CMS. Sintaxis: `<c-form tableName="'contacto'" captcha="true">`. Se renderiza como form HTML con submit a un endpoint Acai.
**`data-field-*`** — familia de atributos que marca un elemento como editable en el builder visual. Tipos: `textfield`, `headfield`, `textbox`, `wysiwyg`, `link`, `upload`, `uploadMulti`, `list`, `multiv2`, `checkbox`, `colorpicker`.
**`c-if`, `c-for`, `c-class`, `c-hidden`, `c-required`** — atributos de lógica visual. **`c-if` usa un solo `=`** (`c-if="x = 1"`), Twig `{% if %}` usa **doble** `==`.
## Datos / API
**CmsApi (alias `CocoDB`)** — librería PHP server-side para CRUD sobre las tablas del CMS. Métodos: `CmsApi::get(opts)`, `::insert(table, data)`, `::update(table, where, data)`, `::delete(table, where)`. Soporta `uploads`, `relations`, `translates`, `groupBy`, `aggregates` como opciones.
**JWT `acai_token`** — token de auth del proyecto que vive en `.acai`. Caduca y se renueva con `refresh_acai_token` cuando da error 403.
**`X-MCP-Secret`** — token de auth para clientes MCP externos (Claude Code, extensión VS Code). Vive en Redis. Es user-wide (autoriza todos los proyectos del usuario).
## Filtros Twig clave
**`| get`** — query a una tabla del CMS. `'productos' | get('activo=1', 'orden ASC', 10)`.
**`| queryDB`** — SQL crudo (con `cms_` prefix). `'SELECT * FROM cms_productos WHERE...' | queryDB()`.
**`| hook`** — invoca un hook PHP desde Twig. `'hooks/calcular/' | hook({precio: 100})`.
**`| imagec`** — optimiza una imagen al ancho dado. `imagen[0].urlPath | imagec(800)`.
**`| translate`** — traduce vía tabla `textos_generales`. `'texto a traducir' | translate`.

2
evals/.gitignore vendored Normal file
View File

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

43
evals/README.md Normal file
View File

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

148
evals/driver.py Normal file
View File

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

View File

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

View File

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

View File

@@ -77,14 +77,17 @@ export async function validateMcpToken(secret) {
} catch {
return null;
}
if (!meta || !meta.user || !meta.project) return null;
// Solo exigimos `user`. `project` puede ser "" (token user-wide que
// autoriza todos los proyectos del usuario, ver handlers/mcp_tokens.py
// del backend Python para los detalles del modelo).
if (!meta || !meta.user) return null;
// Actualizacion asincrona de lastUsedAt — no bloqueamos la request.
updateLastUsedAt(key, meta).catch((e) => {
console.error("[mcp-tokens] lastUsedAt update failed:", e.message);
});
return { user: meta.user, project: meta.project, id: meta.id || "" };
return { user: meta.user, project: meta.project || "", id: meta.id || "" };
}
async function updateLastUsedAt(key, meta) {

View File

@@ -15,6 +15,13 @@ export const CONFIG_FILE_PATH =
export const MCP_PORT = Number(process.env.MCP_PORT || 3000);
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 =
String(process.env.MCP_MONITOR_DISABLED || "").toLowerCase() === "1" ||
String(process.env.MCP_MONITOR_DISABLED || "").toLowerCase() === "true";

View File

@@ -76,7 +76,12 @@ const verifyJwt = (token) => {
const resolveProjectCredentials = async (projectName, acaiUser = null) => {
try {
const info = await fetchProjectInfo(projectName, acaiUser);
// El transporte MCP HTTP es exclusivo de clientes externos (plugin VS
// Code Acai Forge). Por politica solo pueden operar sobre TEST: forzamos
// mode=local al resolver el proyecto, de modo que web_url/api_web_url
// apunten al web forge-local y TODA la sesion (records, modules, git,
// media...) use el destino de test, nunca produccion.
const info = await fetchProjectInfo(projectName, acaiUser, { forceMode: "local" });
if (!info.success) {
throw new Error(info.error || "Failed to resolve project info");
}
@@ -195,6 +200,13 @@ export function startHttpServer() {
// identifica manualmente con X-Acai-User + X-Project-Name).
//=============================================================================
app.use(async (req, res, next) => {
// DEBUG temporal: loguear TODA request que llegue. Quitar cuando este
// claro el flujo del cliente.
const secretPresent = !!req.headers["x-mcp-secret"];
const authPresent = !!req.headers["authorization"];
console.error(
`[MCP req] ${req.method} ${req.url} - X-MCP-Secret=${secretPresent ? "yes" : "MISSING"}, Authorization=${authPresent ? "yes" : "MISSING"}, UA=${(req.headers["user-agent"] || "").substring(0, 60)}`,
);
const secret = req.headers["x-mcp-secret"];
if (!secret) {
return next();
@@ -202,6 +214,7 @@ export function startHttpServer() {
try {
const auth = await validateMcpToken(secret);
if (!auth) {
console.error("[MCP middleware] Invalid X-MCP-Secret rejected");
res.status(401)
.setHeader("Content-Type", "application/json")
.end(JSON.stringify({ error: "Invalid MCP token" }));
@@ -209,7 +222,18 @@ export function startHttpServer() {
}
// Sobrescribe los headers de identidad con los del token validado.
req.headers["x-acai-user"] = auth.user;
req.headers["x-project-name"] = auth.project;
// `auth.project` solo se sobrescribe si el token es project-scoped.
// Si es user-wide (auth.project === ""), preservamos el
// `X-Project-Name` que el cliente envio (la extension VS Code
// Acai Forge lo manda con el slug del proyecto descargado).
if (auth.project) {
req.headers["x-project-name"] = auth.project;
}
console.error(
`[MCP middleware] Auth OK user=${auth.user} ` +
`tokenScope=${auth.project || "user-wide"} ` +
`clientProject=${req.headers["x-project-name"] || "(none)"}`,
);
return next();
} catch (err) {
console.error("[MCP] mcpSecretMiddleware error:", err.message);
@@ -580,11 +604,42 @@ export function startHttpServer() {
});
//=============================================================================
// OAUTH2 ENDPOINTS
// OAUTH2 ENDPOINTS — DESHABILITADOS
//=============================================================================
// El flujo OAuth se diseno a medida (client_secret = nombre de proyecto)
// y no funciona con clientes MCP estandar (Claude Code, etc.) que usan
// PKCE puro. El unico cliente "oficial" es la extension VS Code Acai
// Forge, que NO usa OAuth — autentica con header X-MCP-Secret directo.
//
// Devolver 404 en `.well-known/oauth-authorization-server` hace que los
// clientes que hacen OAuth discovery hagan fallback a header auth, lo
// cual usa X-MCP-Secret (validado en el middleware de las lineas ~197).
// Los handlers `/register`, `/authorize`, `/token` y los helpers `signJwt`
// / `verifyJwt` / `resolveProjectCredentials` se mantienen porque son
// usados internamente por el transport SSE legacy (lineas ~113, ~265).
//=============================================================================
// OAuth2 Authorization Server Metadata endpoint (per RFC8414)
app.get('/.well-known/oauth-authorization-server', (req, res) => {
// Rutas OAuth/OIDC discovery deshabilitadas — devuelven 404 JSON limpio
// para que el cliente fallback a header auth (X-MCP-Secret) en vez de
// intentar OAuth flow. Cubrimos ambas paths comunes y sus variantes
// anidadas bajo /mcp/ porque algunos clientes (Claude Code) prueban
// ambas: en la raiz Y bajo el endpoint MCP.
const _disabledOauthPaths = [
'/.well-known/oauth-authorization-server',
'/.well-known/openid-configuration',
'/.well-known/oauth-protected-resource',
'/mcp/.well-known/oauth-authorization-server',
'/mcp/.well-known/openid-configuration',
'/mcp/.well-known/oauth-protected-resource',
];
for (const _p of _disabledOauthPaths) {
app.get(_p, (req, res) => {
res.status(404).json({ error: "OAuth not available; use X-MCP-Secret header" });
});
}
// OAuth2 Authorization Server Metadata endpoint (per RFC8414) — REMOVED
app.get('/.well-known/oauth-authorization-server-DISABLED', (req, res) => {
const baseUrl = `https://${req.headers.host}`;
res.json({
issuer: baseUrl,

View File

@@ -6,7 +6,7 @@
*/
// 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)
const selectedProfile = loadLocalConfigProfile();
@@ -30,8 +30,11 @@ import { registerResources } from "./resources/index.js";
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
setRegistrationFunctions({ registerPrompts, registerTools, registerResources });
// Create the shared request monitor (will be applied to each session server)
const requestMonitor = createRequestMonitor();
// Create the shared request monitor (will be applied to each session server).
// 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
const server = createMcpServer();

View File

@@ -2,7 +2,7 @@ import http from "node:http";
import fsPromises from "node:fs/promises";
import path from "node:path";
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 { activeSessions } from "./httpServer.js";
@@ -84,8 +84,8 @@ export function broadcastSessionsUpdate() {
* Start the monitor HTTP server
*/
export function startMonitorServer(requestMonitor, toolHandlers) {
if (MONITOR_DISABLED) {
console.error("MCP monitor UI deshabilitada (MCP_MONITOR_DISABLED=1).");
if (!MONITOR_ENABLED || MONITOR_DISABLED) {
console.error("[monitor] deshabilitado (MCP_MONITOR_ENABLED!=true)");
return null;
}
@@ -202,12 +202,12 @@ export function startMonitorServer(requestMonitor, toolHandlers) {
monitorServer.on("error", (error) => {
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', () => {
console.error(`MCP monitor UI: http://0.0.0.0:${MONITOR_PORT}/monitor`);
monitorServer.listen(MONITOR_PORT, MONITOR_HOST, () => {
console.error(`MCP monitor UI: http://${MONITOR_HOST}:${MONITOR_PORT}/monitor`);
});
// Broadcast sessions + stats update every 2 seconds for real-time monitoring

View File

@@ -1,20 +1,41 @@
import fs from "node:fs/promises";
import { existsSync } from "node:fs";
import path from "node:path";
/**
* Lectura directa de los markdown del knowledge base desde el filesystem.
*
* El MCP server corre dentro del container `agentic` junto al FastAPI, asi
* que los .md viven en `/app/docs/` (la imagen los copia ahi).
*
* En caso de override por entorno, respeta `ACAI_DOCS_DIR`. En desarrollo
* fuera del container, fallback a paths relativos al cwd.
* Orden de resolucion del directorio de docs:
* 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
* propio `docs/`. El `.mcp.json` inyecta `ACAI_PROJECT_DIR` (p.ej.
* `/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() {
// 1. Override explicito
const override = process.env.ACAI_DOCS_DIR;
if (override) return override;
// Container path
if (override && override.trim() !== "") return override;
// 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";
}

View File

@@ -3,7 +3,7 @@ import path from "path";
import { LOCAL_SERVER_URL, getLocalServerHeaders } from "../../config/index.js";
import { getCurrentSessionId } from "../../utils/sessionContext.js";
import { getMcpSessionCredentials } from "../../auth/credentials.js";
import { resolveCurrentAcaiUser } from "../helpers/sessionHelpers.js";
import { resolveCurrentAcaiUser, resolveCurrentModeOverride } from "../helpers/sessionHelpers.js";
/**
* Resuelve `project_dir` para la tool en curso.
@@ -37,6 +37,14 @@ export function getCurrentProjectInfo() {
export async function callLocalFileEndpoint(method, endpoint, payload = null, query = null) {
const headers = getLocalServerHeaders();
const authHeader = process.env.ACAI_AUTH_HEADER || "";
const mode = resolveCurrentModeOverride();
const role = process.env.ACAI_ROLE_OVERRIDE || "";
if (authHeader) headers["Authorization"] = authHeader;
if (mode) headers["X-Acai-Mode"] = mode;
if (role) headers["X-Acai-Role"] = role;
// Inyectar X-Acai-User cuando hay sesion HTTP activa: permite que los
// endpoints autenticados del server Python identifiquen al usuario sin
// depender de Authorization Basic.

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,197 @@
import { z } from "zod";
import axios from "axios";
import path from "path";
import { withAuth } from "../../auth/index.js";
import { handleToolError } from "../helpers/errorHandler.js";
import { withAuthParams } from "../helpers/authSchema.js";
import { resolveCurrentProjectDir } from "../files/helpers.js";
import { pythonGetBinary } from "../helpers/pythonServerClient.js";
const GEMINI_ENDPOINT = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent";
// 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.";
/**
* Detecta el mime type a partir de la extension del fichero o del primer byte (magic number).
*/
function detectMimeType(filename, buffer) {
const ext = (filename || "").toLowerCase().split('.').pop();
const byExt = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
webp: "image/webp",
gif: "image/gif",
bmp: "image/bmp",
heic: "image/heic",
heif: "image/heif",
};
if (byExt[ext]) return byExt[ext];
// Magic numbers fallback
if (buffer && buffer.length >= 4) {
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return "image/jpeg";
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) return "image/png";
if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) return "image/gif";
if (buffer.length >= 12 && buffer.slice(8, 12).toString() === "WEBP") return "image/webp";
}
return "image/jpeg";
}
/**
* Carga la imagen como { mimeType, base64 }.
* - URL remota real (host público) → fetch directo por HTTP.
* - Adjunto de chat, ruta del proyecto, o URL con host local → los bytes los
* resuelve el server Python (/api/image-bytes): disco para standalone, fetch
* de producción para imágenes Acai cuyo fichero local es un stub.
*/
async function loadImage(imageUrl) {
let parsed = null;
try { parsed = new URL(imageUrl); } catch { parsed = null; }
const isRemote = parsed
&& (parsed.protocol === "http:" || parsed.protocol === "https:")
&& parsed.hostname && !isLocalResolvableHost(parsed.hostname);
if (isRemote) {
const response = await axios.get(imageUrl, {
responseType: "arraybuffer",
timeout: 30000,
maxContentLength: 20 * 1024 * 1024, // 20MB max
});
const buffer = Buffer.from(response.data, "binary");
const headerMime = response.headers?.["content-type"]?.split(";")[0]?.trim();
const mimeType = headerMime && headerMime.startsWith("image/")
? headerMime
: detectMimeType(imageUrl.split("?")[0], buffer);
return { mimeType, base64: buffer.toString("base64") };
}
const project = path.basename(resolveCurrentProjectDir() || "");
if (!project) {
throw new Error("No hay proyecto activo para resolver la imagen.");
}
const { buffer, mimeType } = await pythonGetBinary("/api/image-bytes", { project, ref: imageUrl });
return { mimeType, base64: buffer.toString("base64") };
}
export function registerAnalyzeImageTool(server) {
server.tool(
"analyze_image",
"Analiza una imagen usando Gemini Vision. Usala SOLO para imagenes que NO puedes ver directamente (p.ej. una URL/imagen del CMS que no esta adjunta a la conversacion, o un screenshot de Playwright). Si la imagen ya esta adjunta y visible en el mensaje del usuario, descríbela tú mismo SIN llamar a esta tool. Devuelve descripcion text del contenido visual.",
withAuthParams({
image_url: z.string().describe("URL de la imagen. Acepta URL publica http(s):// o ruta relativa /api/chat-preview?file=..."),
prompt: z.string().optional().describe("Que quieres saber de la imagen. Default: descripcion detallada."),
}),
{ readOnlyHint: true, destructiveHint: false },
withAuth(async ({ image_url, prompt }) => {
try {
const apiKey = process.env.NANO_BANANA_API_KEY;
if (!apiKey) {
return {
content: [{
type: "text",
text: JSON.stringify({
success: false,
error: "NANO_BANANA_API_KEY no esta configurada en el entorno del MCP server.",
}, null, 2),
}],
isError: true,
};
}
// 1) Cargar imagen (local o remota) -> base64 + mime
let image;
try {
image = await loadImage(image_url);
} catch (loadErr) {
return {
content: [{
type: "text",
text: JSON.stringify({
success: false,
error: `No se pudo cargar la imagen: ${loadErr.message}`,
}, null, 2),
}],
isError: true,
};
}
// 2) Llamar a Gemini Vision
const finalPrompt = (prompt && prompt.trim()) || DEFAULT_PROMPT;
const payload = {
contents: [{
parts: [
{ inline_data: { mime_type: image.mimeType, data: image.base64 } },
{ text: finalPrompt },
],
}],
};
const geminiResp = await axios.post(GEMINI_ENDPOINT, payload, {
headers: {
"x-goog-api-key": apiKey,
"Content-Type": "application/json",
},
timeout: 60000,
maxBodyLength: 30 * 1024 * 1024,
});
const description = geminiResp.data?.candidates?.[0]?.content?.parts?.[0]?.text;
if (!description) {
return {
content: [{
type: "text",
text: JSON.stringify({
success: false,
error: "Gemini no devolvio descripcion.",
raw: geminiResp.data,
}, null, 2),
}],
isError: true,
};
}
return {
content: [{
type: "text",
text: description,
}],
};
} catch (error) {
// Mejorar error si es respuesta de Gemini
if (error.response?.data) {
return handleToolError(
new Error(`Gemini API error: ${JSON.stringify(error.response.data).slice(0, 500)}`),
"analyze_image",
{ image_url, status: error.response.status }
);
}
return handleToolError(error, "analyze_image", { image_url });
}
})
);
}

View File

@@ -1,9 +1,11 @@
import { registerUploadRecordImageTool } from './upload.js';
import { registerUploadImageToAssetsTool } from './uploadImageToAssets.js';
import { registerGenerateImageTool } from './generateImage.js';
import { registerAnalyzeImageTool } from './analyze_image.js';
export function registerMediaTools(server) {
registerUploadRecordImageTool(server);
registerUploadImageToAssetsTool(server);
registerGenerateImageTool(server);
registerAnalyzeImageTool(server);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,9 +7,27 @@ from dataclasses import dataclass, field
from typing import Any, AsyncIterator
class ContextOverflowError(Exception):
"""El contexto excede la ventana del modelo (proveedor lo rechazó).
Excepción de dominio para desacoplar el orquestador de litellm: los adapters
la lanzan al detectar un error de context-length, y el loop del agente decide
si reintentar con compactación más agresiva o devolver un error accionable.
"""
@dataclass
class StreamChunk:
"""A single chunk from a streaming model response."""
"""A single chunk from a streaming model response.
Campos legacy (`delta`, `tool_*`, `finish_reason`, `usage`) cubren todo el
flujo OpenAI/Anthropic original. Los `thinking_*` + `block_type`/`block_index`
se anaden para el interleaved thinking de MiniMax M2: el adapter Claude
los emite cuando ve bloques `type=thinking` y los `signature_delta` que el
SDK Anthropic devuelve al cerrar el bloque. El orquestador acumula esos
bloques con su `signature` para reenviarlos en el siguiente turno (sin sig,
MiniMax rechaza el assistant message).
"""
delta: str = ""
tool_call_id: str = ""
@@ -18,6 +36,16 @@ class StreamChunk:
finish_reason: str = ""
usage: dict[str, int] = field(default_factory=dict)
# Interleaved thinking (MiniMax M2). Default vacios → no-op para callers
# que no los miran (OpenAI adapter, codigo legacy del orquestador).
thinking_delta: str = ""
thinking_signature: str = ""
# block_type ∈ {"text", "thinking", "tool_use", ""} — "" = chunk sin bloque
# asociado (p.ej. solo lleva `usage` o `finish_reason`).
block_type: str = ""
# 0-based, posicion del bloque en el turno. -1 = no aplica.
block_index: int = -1
@dataclass
class ModelResponse:
@@ -38,6 +66,10 @@ class ModelConfig:
max_tokens: int = 4096
temperature: float = 0.3
stop_sequences: list[str] = field(default_factory=list)
# Nivel de razonamiento (minimal|low|medium|high). Vacío = sin razonamiento
# explícito. LiteLLM lo traduce por proveedor; modelos que no lo soportan lo
# ignoran (litellm.drop_params=True).
reasoning_effort: str = ""
extra: dict[str, Any] = field(default_factory=dict)

View File

@@ -17,19 +17,26 @@ from .base import ModelAdapter, ModelConfig, ModelResponse, StreamChunk
logger = logging.getLogger(__name__)
# Algunos fine-tunes (sobre todo MiniMax) ocasionalmente emiten las tool calls
# como texto literal en lugar de usar los `tool_use` blocks nativos:
# <minimax:tool_call>
# <invoke name="acai_code__acai_view">
# <parameter name="file_path">...</parameter>
# </invoke>
# </minimax:tool_call>
# Algunos fine-tunes (sobre todo MiniMax y DeepSeek) ocasionalmente emiten las
# tool calls como texto literal en lugar de usar los `tool_use` blocks nativos.
# Vistos cuatro formatos:
# 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)
# 3) <tool_call>{"name":"X","parameters":{...}}{"name":"Y","parameters":{...}}</tool_call>
# (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
# usuario ve el XML 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
# (tool_use blocks) sigue por el camino estandar.
_TOOL_CALL_OPEN_RE = re.compile(r"<(?:minimax:tool_call|invoke\s+name)", re.IGNORECASE)
_TOOL_CALL_OPEN_RE = re.compile(
# `<` (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,
)
_INVOKE_RE = re.compile(
r"<invoke\s+name=\"([^\"]+)\"\s*>(.*?)</invoke>",
re.IGNORECASE | re.DOTALL,
@@ -38,18 +45,63 @@ _PARAM_RE = re.compile(
r"<parameter\s+name=\"([^\"]+)\"\s*>(.*?)</parameter>",
re.IGNORECASE | re.DOTALL,
)
# Formato 3: <tool_call>...JSON...</tool_call>. El cuerpo puede contener uno
# o varios objetos JSON consecutivos (con o sin commas/newlines entre ellos).
_TOOL_CALL_JSON_BLOCK_RE = re.compile(
r"<tool_call\s*>(.*?)</tool_call\s*>",
re.IGNORECASE | re.DOTALL,
)
# Formato 4: [TOOL_CALL]\n{tool => "X", args => {--key "v" --k2 12}}\n[/TOOL_CALL]
# Sintaxis Perl-ish que MiniMax tambien improvisa. Cada bloque puede contener
# uno o varios "{tool => ..., args => {...}}" consecutivos.
_TOOL_CALL_BRACKET_BLOCK_RE = re.compile(
r"\[TOOL_CALL\](.*?)\[/TOOL_CALL\]",
re.DOTALL,
)
_PERL_TOOL_NAME_RE = re.compile(
r"tool\s*=>\s*[\"']([^\"']+)[\"']",
)
_PERL_ARGS_BLOCK_RE = re.compile(
r"args\s*=>\s*\{(.*?)\}\s*\}\s*(?=\{|\[|$)",
re.DOTALL,
)
# Args estilo `--key "value"` o `--key 12` o `--key true`.
_PERL_KV_RE = re.compile(
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:
"""Devuelve el prefijo del buffer que es seguro emitir como texto sin
perder un posible inicio de tag XML que esta llegando fragmentado.
perder un posible inicio de tag de tool_call que esta llegando fragmentado.
Mantenemos en hold los ultimos 30 chars si terminan con `<` o con un
prefijo parcial de `<minimax:tool_call` / `<invoke`. Si el buffer es
largo y no termina con `<`, todo es seguro.
Mantenemos en hold los ultimos chars si terminan con `<` o con un prefijo
parcial de `<minimax:tool_call` / `<invoke` / `<tool_call`. Si el buffer
es largo y no termina con `<`, todo es seguro.
"""
if not buf:
return ""
# Comprobar holdback de `[TOOL_CALL]` (formato Perl-ish).
for marker in ("[TOOL_CALL]", "[TOOL_CALL"):
for k in range(1, len(marker) + 1):
if buf.endswith(marker[:k]):
# Tail puede ser inicio de [TOOL_CALL] — retener desde ahi.
return buf[:-k]
# Buscar el ultimo `<` y comprobar si lo que sigue puede ser apertura.
idx = buf.rfind("<")
if idx == -1:
@@ -58,8 +110,8 @@ def _safe_emit_split(buf: str) -> str:
# Si el tail ya tiene `>` cerrado, es un tag normal — emitir todo.
if ">" in tail:
return buf
# Si el tail puede ser inicio de tool_call/invoke, retenerlo.
candidates = ("<minimax:tool_call", "<invoke")
# Si el tail puede ser inicio de tool_call/invoke/tool_call_json/dsml, retenerlo.
candidates = ("<minimax:tool_call", "<invoke", "<tool_call", "<")
for cand in candidates:
if cand.startswith(tail.lower()) or tail.lower().startswith(cand[:len(tail)].lower()):
return buf[:idx]
@@ -67,14 +119,47 @@ def _safe_emit_split(buf: str) -> str:
return buf
def _parse_json_objects(text: str) -> list[dict[str, Any]]:
"""Parsea uno o varios objetos JSON consecutivos en `text`. Tolerante a
espacios, newlines y commas entre objetos. Devuelve los que se pudieron
decodificar; salta los malformados."""
objs: list[dict[str, Any]] = []
decoder = json.JSONDecoder()
i = 0
n = len(text)
while i < n:
# Saltar separadores no-JSON
while i < n and text[i] in " \t\r\n,":
i += 1
if i >= n:
break
try:
obj, end = decoder.raw_decode(text, i)
except json.JSONDecodeError:
# Avanzar 1 char y reintentar; defensivo ante markup raro.
i += 1
continue
if isinstance(obj, dict):
objs.append(obj)
i = end
return objs
def _parse_xml_tool_calls(text: str) -> list[dict[str, Any]]:
"""Extrae tool calls del texto. Devuelve lista de {id, name, arguments}.
Si no encuentra patrones validos devuelve []."""
calls = []
"""Extrae tool calls del texto. Cubre tres formatos de fine-tunes:
- <invoke name="X"><parameter name="P">V</parameter></invoke>
- <minimax:tool_call><invoke ...>...</invoke></minimax:tool_call>
- <tool_call>{"name":"X","parameters":{...}}...</tool_call>
Devuelve lista de {id, name, arguments}. Si no encuentra patrones validos
devuelve []."""
calls: list[dict[str, Any]] = []
# Formato 1+2: <invoke name="..."><parameter ...>...</parameter></invoke>
for m in _INVOKE_RE.finditer(text):
name = m.group(1).strip()
body = m.group(2)
args = {}
args: dict[str, Any] = {}
for p in _PARAM_RE.finditer(body):
args[p.group(1).strip()] = p.group(2).strip()
if name:
@@ -83,6 +168,98 @@ def _parse_xml_tool_calls(text: str) -> list[dict[str, Any]]:
"name": name,
"arguments": args,
})
# Formato 3: <tool_call>{json}...{json}</tool_call>
for m in _TOOL_CALL_JSON_BLOCK_RE.finditer(text):
body = m.group(1)
for obj in _parse_json_objects(body):
name = obj.get("name", "") or ""
# Algunos fine-tunes usan "parameters", otros "arguments", otros "input"
args_val = (
obj.get("parameters")
or obj.get("arguments")
or obj.get("input")
or {}
)
if isinstance(args_val, str):
# Si llega stringificado, intentar parsearlo
try:
args_val = json.loads(args_val)
except (json.JSONDecodeError, TypeError):
args_val = {"_raw": args_val}
if not isinstance(args_val, dict):
args_val = {"_raw": str(args_val)}
if name:
calls.append({
"id": "xml_{}".format(uuid.uuid4().hex[:12]),
"name": str(name),
"arguments": args_val,
})
# Formato 4: [TOOL_CALL]{tool => "X", args => {--k "v" --k2 12}}{...}[/TOOL_CALL]
for m in _TOOL_CALL_BRACKET_BLOCK_RE.finditer(text):
body = m.group(1)
# Extraer pares (name, args_block). Recorremos por nombre y el bloque
# de args lo extraemos por proximidad textual.
names = list(_PERL_TOOL_NAME_RE.finditer(body))
for i, nm in enumerate(names):
name = nm.group(1).strip()
# Cuerpo de args entre la posicion de este nombre y el siguiente
# (o final del bloque).
start = nm.end()
end = names[i + 1].start() if i + 1 < len(names) else len(body)
segment = body[start:end]
args: dict[str, Any] = {}
for kv in _PERL_KV_RE.finditer(segment):
k = kv.group(1)
v = kv.group(2)
if v.startswith('"') or v.startswith("'"):
args[k] = v[1:-1]
elif v in ("true", "false"):
args[k] = (v == "true")
elif v == "null":
args[k] = None
else:
try:
args[k] = int(v) if "." not in v else float(v)
except ValueError:
args[k] = v
if name:
calls.append({
"id": "xml_{}".format(uuid.uuid4().hex[:12]),
"name": name,
"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
@@ -113,6 +290,20 @@ class ClaudeAdapter(ModelAdapter):
def __init__(self, api_key: str | None = None, base_url: str | None = None) -> None:
kwargs: dict[str, Any] = {
"api_key": api_key or settings.anthropic_api_key,
# Timeout granular: el endpoint MiniMax a veces se queda colgado sin
# devolver respuesta ni cerrar la conexion. Sin timeout explicito el
# stream queda pendiente para siempre. 120s total por request es
# generoso (M2 con thinking puede tardar 30-60s en respuestas largas)
# pero acota el peor caso.
"timeout": anthropic.Timeout(
connect=10.0,
read=120.0,
write=30.0,
pool=10.0,
),
# Cero retries internos del SDK — manejamos retries en stream() con
# backoff propio (_RETRY_DELAYS).
"max_retries": 0,
}
url = base_url or settings.anthropic_base_url
if url:
@@ -178,6 +369,16 @@ class ClaudeAdapter(ModelAdapter):
in_xml_capture = False
xml_buffer = ""
# Interleaved thinking (MiniMax M2): el SDK emite un block
# con type=thinking, le siguen thinking_delta y al cerrar
# devuelve un signature criptografico. Trackeamos el indice
# de bloque actual para que el orquestador pueda reconstruir
# el assistant turn en orden.
current_block_index = -1
current_block_type = ""
current_thinking_chars = 0 # solo para log al cerrar
current_thinking_sig_emitted = False
async for event in stream:
yielded_any = True
if event.type == "message_start" and hasattr(event, "message"):
@@ -187,14 +388,30 @@ class ClaudeAdapter(ModelAdapter):
if event.type == "content_block_start":
block = event.content_block
if block.type == "tool_use":
current_block_index += 1
current_block_type = getattr(block, "type", "")
if current_block_type == "tool_use":
current_tool_id = block.id
current_tool_name = block.name
accumulated_args = ""
yield StreamChunk(
tool_call_id=current_tool_id,
tool_name=current_tool_name,
block_type="tool_use",
block_index=current_block_index,
)
elif current_block_type == "thinking":
# Reset contadores y emitimos un "header" para
# que el orquestador registre que arranca un
# bloque thinking en este indice.
current_thinking_chars = 0
current_thinking_sig_emitted = False
yield StreamChunk(
block_type="thinking",
block_index=current_block_index,
)
# block_type == "text" no necesita header — los
# text_delta ya llevaran el indice.
continue
if event.type == "content_block_delta":
@@ -211,7 +428,11 @@ class ClaudeAdapter(ModelAdapter):
# legitimo que el modelo escribio antes del XML).
prev = text_buffer[:m.start()]
if prev:
yield StreamChunk(delta=prev)
yield StreamChunk(
delta=prev,
block_type="text",
block_index=current_block_index,
)
in_xml_capture = True
xml_buffer = text_buffer[m.start():]
text_buffer = ""
@@ -221,7 +442,11 @@ class ClaudeAdapter(ModelAdapter):
# esperamos al siguiente delta antes de emitir.
safe = _safe_emit_split(text_buffer)
if safe:
yield StreamChunk(delta=safe)
yield StreamChunk(
delta=safe,
block_type="text",
block_index=current_block_index,
)
text_buffer = text_buffer[len(safe):]
elif delta.type == "input_json_delta":
accumulated_args += delta.partial_json
@@ -229,26 +454,81 @@ class ClaudeAdapter(ModelAdapter):
tool_call_id=current_tool_id,
tool_name=current_tool_name,
tool_arguments=delta.partial_json,
block_type="tool_use",
block_index=current_block_index,
)
elif delta.type == "thinking_delta":
txt = getattr(delta, "thinking", "") or ""
current_thinking_chars += len(txt)
yield StreamChunk(
thinking_delta=txt,
block_type="thinking",
block_index=current_block_index,
)
elif delta.type == "signature_delta":
sig = getattr(delta, "signature", "") or ""
if sig:
current_thinking_sig_emitted = True
yield StreamChunk(
thinking_signature=sig,
block_type="thinking",
block_index=current_block_index,
)
continue
if event.type == "content_block_stop":
# Si el bloque cerrado es thinking y el signature
# no llego como signature_delta, intentar leerlo
# del content_block ya completo (algunos SDK lo
# exponen aqui).
if current_block_type == "thinking":
if not current_thinking_sig_emitted:
cb = getattr(event, "content_block", None)
sig = getattr(cb, "signature", "") if cb else ""
if sig:
yield StreamChunk(
thinking_signature=sig,
block_type="thinking",
block_index=current_block_index,
)
current_thinking_sig_emitted = True
else:
logger.warning(
"Thinking block #%d cerrado sin signature (%d chars). "
"MiniMax rechazara el siguiente turno si lo reenviamos.",
current_block_index, current_thinking_chars,
)
logger.info(
"[adapter] thinking block #%d: %d chars, sig=%s",
current_block_index, current_thinking_chars,
"yes" if current_thinking_sig_emitted else "MISSING",
)
if current_tool_id and accumulated_args:
yield StreamChunk(
tool_call_id=current_tool_id,
tool_name=current_tool_name,
tool_arguments=accumulated_args,
finish_reason="tool_use",
block_type="tool_use",
block_index=current_block_index,
)
current_tool_id = ""
current_tool_name = ""
accumulated_args = ""
current_block_type = ""
current_thinking_chars = 0
current_thinking_sig_emitted = False
continue
if event.type == "message_delta":
# Antes de cerrar, vaciar buffers.
if in_xml_capture and xml_buffer:
# Parsear el XML capturado y emitir tool_use sinteticos.
# Asignamos block_index sintetico a cada XML tool call
# para que el orquestador pueda registrarlo en
# turn_blocks_by_index. Si no, el assistant message
# iria sin el tool_use pero el tool_result sí lo
# referenciaria → MiniMax devuelve 400.
calls = _parse_xml_tool_calls(xml_buffer)
if calls:
logger.info(
@@ -256,24 +536,38 @@ class ClaudeAdapter(ModelAdapter):
len(calls),
)
for c in calls:
current_block_index += 1
synthetic_idx = current_block_index
yield StreamChunk(
tool_call_id=c["id"],
tool_name=c["name"],
block_type="tool_use",
block_index=synthetic_idx,
)
yield StreamChunk(
tool_call_id=c["id"],
tool_name=c["name"],
tool_arguments=json.dumps(c["arguments"]),
finish_reason="tool_use",
block_type="tool_use",
block_index=synthetic_idx,
)
else:
# No se pudo parsear — devolver al usuario el
# texto crudo para no perderlo silenciosamente.
yield StreamChunk(delta=xml_buffer)
yield StreamChunk(
delta=xml_buffer,
block_type="text",
block_index=current_block_index,
)
xml_buffer = ""
in_xml_capture = False
elif text_buffer:
yield StreamChunk(delta=text_buffer)
yield StreamChunk(
delta=text_buffer,
block_type="text",
block_index=current_block_index,
)
text_buffer = ""
output_tokens = getattr(event.usage, "output_tokens", 0) if event.usage else 0
# Si convertimos XML a tool_use, override el stop_reason.
@@ -340,6 +634,15 @@ class ClaudeAdapter(ModelAdapter):
if force_tool:
kwargs["tool_choice"] = {"type": "tool", "name": force_tool}
# Permite desactivar thinking para llamadas que no lo necesitan (p.ej.
# plan_judge: solo evalua, no razona). MiniMax M2.7 acepta el parametro
# Anthropic-style `thinking`. Aunque la implementacion no respeta del
# todo el "disabled" (a veces sigue emitiendo thinking blocks), reduce
# el consumo de tokens y deja mas espacio para el JSON output.
thinking_cfg = (config.extra or {}).get("thinking")
if thinking_cfg:
kwargs["thinking"] = thinking_cfg
# Retry con backoff sobre errores transitorios (429/503/529). El proxy
# MiniMax devuelve 529 overloaded_error con cierta frecuencia bajo carga.
last_exc: Exception | None = None
@@ -404,11 +707,33 @@ class ClaudeAdapter(ModelAdapter):
- role=tool → role=user with tool_result content blocks
- assistant with tool_calls → assistant with tool_use content blocks
- Consecutive same-role messages get merged (Claude requires alternating)
- Fast-path: si content ya viene como list (Anthropic-style nativo, p.ej.
messages emitidos por BaseAgent con interleaved thinking de M2), pasa
tal cual y solo hace merge con el anterior si toca.
"""
converted: list[dict[str, Any]] = []
for m in messages:
role = m.get("role", "")
content = m.get("content")
# Fast-path Anthropic-style: content ya es lista de blocks.
if isinstance(content, list) and role in ("user", "assistant"):
if converted and converted[-1]["role"] == role:
prev = converted[-1]["content"]
if isinstance(prev, list):
prev.extend(content)
elif isinstance(prev, str):
merged: list[dict[str, Any]] = []
if prev:
merged.append({"type": "text", "text": prev})
merged.extend(content)
converted[-1]["content"] = merged
else:
converted[-1]["content"] = list(content)
else:
converted.append({"role": role, "content": list(content)})
continue
if role == "tool":
# Convert to user message with tool_result block

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 ..config import settings
from .base import ModelAdapter, ModelConfig, ModelResponse, StreamChunk
from .base import (
ContextOverflowError,
ModelAdapter,
ModelConfig,
ModelResponse,
StreamChunk,
)
logger = logging.getLogger(__name__)
# Señales de que el proveedor rechazó por ventana de contexto. Detectamos por
# tipo (litellm.ContextWindowExceededError) y por mensaje (openai.BadRequestError
# u otros 400), sin acoplar el adapter a litellm con un import duro.
_CONTEXT_OVERFLOW_MARKERS = (
"context_length_exceeded",
"maximum context length",
"context window",
"context length",
"too many tokens",
"reduce the length",
"prompt is too long",
)
def _is_context_overflow(exc: Exception) -> bool:
if type(exc).__name__ in ("ContextWindowExceededError",):
return True
msg = str(getattr(exc, "message", "") or exc).lower()
return any(marker in msg for marker in _CONTEXT_OVERFLOW_MARKERS)
def _estimate_usage(messages: list[dict[str, Any]], output_text: str) -> dict[str, int]:
"""Estimacion de tokens cuando el proveedor no entrega usage (p.ej. LiteLLM
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):
"""Adapter for the OpenAI API (GPT-4o, o1, etc.)."""
@@ -25,6 +69,15 @@ class OpenAIAdapter(ModelAdapter):
if url:
kwargs["base_url"] = url
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
@@ -35,6 +88,26 @@ class OpenAIAdapter(ModelAdapter):
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
config: ModelConfig | None = None,
) -> AsyncIterator[StreamChunk]:
"""Envoltorio que traduce errores de ventana de contexto del proveedor a
`ContextOverflowError` (dominio), tanto si saltan al iniciar el stream
como durante la primera iteración. El loop del agente lo usa para
reintentar con compactación agresiva si aún no emitió nada."""
try:
async for chunk in self._stream_impl(messages, tools, config):
yield chunk
except ContextOverflowError:
raise
except Exception as e:
if _is_context_overflow(e):
raise ContextOverflowError(str(getattr(e, "message", "") or e)) from e
raise
async def _stream_impl(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
config: ModelConfig | None = None,
) -> AsyncIterator[StreamChunk]:
config = config or ModelConfig(
model_id=settings.default_model_id,
@@ -43,43 +116,92 @@ class OpenAIAdapter(ModelAdapter):
)
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,
"temperature": config.temperature,
"messages": messages,
"messages": self._to_openai_messages(messages),
"stream": True,
"stream_options": {"include_usage": True},
}
if getattr(config, "reasoning_effort", ""):
kwargs["reasoning_effort"] = config.reasoning_effort
if tools:
kwargs["tools"] = self._format_tools(tools)
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]] = {}
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:
# With include_usage, the last chunk has usage but no choices
if chunk.usage:
# With include_usage, the last chunk has usage but no choices.
# 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 = {
"input_tokens": chunk.usage.prompt_tokens or 0,
"output_tokens": chunk.usage.completion_tokens or 0,
"input_tokens": getattr(chunk_usage, "prompt_tokens", 0) or 0,
"output_tokens": getattr(chunk_usage, "completion_tokens", 0) or 0,
}
choice = chunk.choices[0] if chunk.choices else None
if not choice:
# 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)
final_usage = {} # Only emit once
usage_emitted = True
continue
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
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
if delta and delta.tool_calls:
@@ -109,7 +231,31 @@ class OpenAIAdapter(ModelAdapter):
# Finish
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():
yield StreamChunk(
tool_call_id=acc["id"],
@@ -118,15 +264,33 @@ class OpenAIAdapter(ModelAdapter):
finish_reason="tool_use",
)
# Emit usage after tool_use chunks
if final_usage:
if final_usage and not usage_emitted:
yield StreamChunk(usage=final_usage)
usage_emitted = True
else:
yield StreamChunk(
finish_reason="end_turn"
if choice.finish_reason == "stop"
else choice.finish_reason,
usage=final_usage,
)
# 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:
yield StreamChunk(
finish_reason="end_turn"
if choice.finish_reason in ("stop", "tool_calls")
else choice.finish_reason,
usage=final_usage if not usage_emitted else {},
)
usage_emitted = True
# ------------------------------------------------------------------
# Non-streaming
@@ -145,11 +309,13 @@ class OpenAIAdapter(ModelAdapter):
)
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,
"temperature": config.temperature,
"messages": messages,
"messages": self._to_openai_messages(messages),
}
if getattr(config, "reasoning_effort", ""):
kwargs["reasoning_effort"] = config.reasoning_effort
if tools:
kwargs["tools"] = self._format_tools(tools)
# Fuerza al modelo a usar un tool concreto para garantizar JSON por schema
@@ -161,7 +327,14 @@ class OpenAIAdapter(ModelAdapter):
"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]
content = choice.message.content or ""
@@ -204,19 +377,242 @@ class OpenAIAdapter(ModelAdapter):
@staticmethod
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]] = []
for tool in tools:
formatted.append(
{
"type": "function",
"function": {
"name": tool["name"],
"description": tool.get("description", ""),
"parameters": tool.get(
"input_schema", tool.get("parameters", {"type": "object"})
),
},
}
)
params = tool.get("input_schema", tool.get("parameters", {"type": "object"}))
fn: dict[str, Any] = {
"name": tool["name"],
"description": tool.get("description", ""),
"parameters": OpenAIAdapter._sanitize_strict_schema(params) if strict else params,
}
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],
}
)
# 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,14 @@ class SendMessageRequest(BaseModel):
message: str
stream: bool = False
agent_id: str | None = None
# Imágenes para visión nativa: bloques listos para el modelo
# {"type":"image_url","image_url":{"url":"data:<mime>;base64,..."}}. Solo se
# envían cuando el modelo activo es multimodal (lo decide acai-app).
attachments: list[dict[str, Any]] | None = None
# 'off' (default): la tool acai_plan no se expone al modelo, ejecuta directo.
# 'force': system prompt obliga a llamar acai_plan antes de ejecutar.
# 'auto' (legacy): se trata como 'off'. UI: toggle en ChatPanel.
plan_mode: str = "off"
class CompletionRequest(BaseModel):
@@ -79,6 +87,8 @@ class SessionResponse(BaseModel):
created_at: str
updated_at: str
agent_id: str = "acai"
# Plan activo (Fase 5.5: PlanStepper UI). None si no hay plan en curso.
current_plan: dict[str, Any] | None = None
# ------------------------------------------------------------------
@@ -88,6 +98,45 @@ class SessionResponse(BaseModel):
_deps: dict[str, Any] = {}
# ------------------------------------------------------------------
# Registro de ejecuciones en curso (para abort / preempt)
# ------------------------------------------------------------------
# El envío de mensajes en modo stream arranca una tarea asyncio "detached"
# (create_task) que corre independiente de la conexión SSE del cliente. Sin una
# referencia a esa tarea era imposible cancelarla: si el usuario paraba el
# stream en el frontend, la tarea seguía viva reteniendo el session_lock, y el
# siguiente mensaje recibía "busy" mientras el stream mostraba la ejecución
# anterior. Guardamos la tarea por session_id para poder cancelarla (abort
# explícito del usuario o preempt al llegar un mensaje nuevo).
_running_executions: dict[str, "asyncio.Task[Any]"] = {}
async def _cancel_running_execution(session_id: str, *, reason: str) -> bool:
"""Cancela la ejecución en curso de una sesión, si la hay.
Espera a que la tarea termine de desenrollarse para garantizar que su
`finally` libere el session_lock (SETNX en Redis) antes de devolver. Así el
siguiente mensaje puede adquirir el lock de inmediato. Idempotente.
Devuelve True si había una ejecución activa que se canceló.
"""
task = _running_executions.get(session_id)
if task is None or task.done():
return False
logger.info("Cancelling running execution for session %s (%s)", session_id, reason)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
except Exception as e: # noqa: BLE001 — la tarea ya está muriendo
logger.warning("Error while cancelling execution for %s: %s", session_id, e)
finally:
if _running_executions.get(session_id) is task:
_running_executions.pop(session_id, None)
return True
def set_dependencies(
storage: Any,
model_adapter: Any,
@@ -290,22 +339,72 @@ async def send_message(
if not agent_profile:
agent_profile = agent_reg.get(agent_reg.default_agent_id)
# Resolución dinámica del modelo (Fase 2): override por-usuario (metadata de
# la sesión) → default global (Redis acai:config:ai:*). Si resuelve, se
# inyecta en una COPIA del profile para no mutar el del registry (singleton).
if agent_profile is not None:
from ..orchestrator.model_resolver import resolve_session_model
resolved = await resolve_session_model(session)
update = {}
if resolved.get("model_id"):
update["model_id"] = resolved["model_id"]
if resolved.get("reasoning_effort"):
update["reasoning_effort"] = resolved["reasoning_effort"]
if update:
agent_profile = agent_profile.model_copy(update=update)
logger.info(
"Session %s: modelo resuelto -> %s (reasoning=%s)",
session_id, update.get("model_id", "(default)"),
update.get("reasoning_effort", "off"),
)
# Plan mode controlado por el usuario desde el toggle del ChatPanel.
# 'auto' (default): heuristica del modelo trivial-vs-complex.
# 'force': el agente DEBE llamar acai_plan como primera accion.
# 'off' (default): la tool acai_plan NO se expone al modelo, ejecuta directo.
# 'force': la tool se expone y system prompt obliga a llamarla primero.
# 'auto' (legacy): se trata como 'off'.
plan_mode = (body.plan_mode or "off").lower()
if plan_mode == "auto":
plan_mode = "off"
if plan_mode not in ("off", "force"):
plan_mode = "off"
session.metadata["plan_mode"] = plan_mode
from ..mcp.manager import MCPManager
orchestrator = _build_orchestrator(mcp_manager or MCPManager(), agent_profile)
# Preempt: si ya hay una ejecución en curso para esta sesión (p.ej. el
# usuario paró el stream y mandó un mensaje nuevo), la cancelamos antes de
# arrancar. _cancel_running_execution espera a que libere el session_lock,
# de modo que el create_task de abajo no choque con un "busy".
await _cancel_running_execution(session_id, reason="preempted by new message")
if body.stream:
asyncio.create_task(_execute_and_persist(orchestrator, storage, session, body.message))
task = asyncio.create_task(
_execute_and_persist(orchestrator, storage, session, body.message, body.attachments)
)
_running_executions[session_id] = task
# Auto-limpieza del registro al terminar (solo si seguimos siendo la
# tarea activa — un preempt posterior pudo reemplazarla ya).
task.add_done_callback(
lambda t, sid=session_id: (
_running_executions.pop(sid, None)
if _running_executions.get(sid) is t
else None
)
)
return {
"session_id": session_id,
"status": "executing",
"stream_url": f"/sessions/{session_id}/stream",
}
result = await _execute_and_persist(orchestrator, storage, session, body.message)
result = await _execute_and_persist(orchestrator, storage, session, body.message, body.attachments)
return result
async def _execute_and_persist(orchestrator, storage, session, message) -> dict[str, Any]:
async def _execute_and_persist(orchestrator, storage, session, message, attachments=None) -> dict[str, Any]:
# Acquire exclusive lock — prevents concurrent execution on same session
async with storage.session_lock(session.session_id) as acquired:
if not acquired:
@@ -315,9 +414,47 @@ async def _execute_and_persist(orchestrator, storage, session, message) -> dict[
"status": "busy",
}
# Persistir 'executing' + el objetivo ANTES de la ejecución larga, para que
# un reattach (tras recargar el frontend a mitad de turno) detecte que hay
# un turno en curso. El estado final lo guarda el `finally`.
try:
result = await orchestrator.process_message(session, message)
session.status = SessionStatus.EXECUTING
session.metadata["current_objective"] = message
await storage.update_session(session)
except Exception as e:
logger.warning("No se pudo persistir 'executing' al arrancar: %s", e)
try:
result = await orchestrator.process_message(session, message, attachments)
return result
except asyncio.CancelledError:
# Ejecución abortada por el usuario (stop) o preemptada por un
# mensaje nuevo. Dejamos la sesión en estado consistente (NO ERROR)
# para que el siguiente mensaje arranque limpio, y re-lanzamos para
# que el `await task` de la cancelación complete. El `finally`
# persiste el estado y el `session_lock` se libera al salir.
logger.info("Execution cancelled for session %s", session.session_id)
# Persistir el turno del usuario aunque se cancele: si no, un
# "vuelve a intentarlo" posterior se queda sin contexto de lo pedido.
# Guardamos su mensaje (+ imagen) y un marcador de interrupción para
# mantener la alternancia user/assistant.
try:
task = session.current_task
if task and (task.objective or "").strip():
session.recent_messages = orchestrator._append_recent_messages(
session.recent_messages,
message=task.objective,
conversation=[{
"role": "assistant",
"content": "[Respuesta interrumpida por el usuario antes de completarse]",
}],
image_attachments=task.image_attachments,
)
except Exception:
logger.exception("No se pudo persistir el turno cancelado")
session.status = SessionStatus.ACTIVE
session.current_task = None
raise
except Exception as e:
session.status = SessionStatus.ERROR
logger.exception("Execution failed for session %s", session.session_id)
@@ -333,6 +470,57 @@ async def _execute_and_persist(orchestrator, storage, session, message) -> dict[
logger.error("Failed to persist session state: %s", e)
# ------------------------------------------------------------------
# POST /sessions/{id}/abort — cancela la ejecución en curso
# ------------------------------------------------------------------
@router.post("/sessions/{session_id}/abort")
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 tarea detached (liberando el session_lock) y cierra el stream
SSE de los suscriptores. Idempotente: si no hay nada en curso devuelve
`no_active_execution` sin error.
"""
storage = _get_storage()
session = await storage.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
cancelled = await _cancel_running_execution(session_id, reason="user abort")
# Cerrar el stream para que los suscriptores SSE (native + claude) terminen
# limpio. EXECUTION_COMPLETED se traduce a un {"type":"done"} en el formato
# claude que consume el frontend.
try:
sse = _get_sse()
await sse.emit(
EventType.EXECUTION_COMPLETED,
{"session_id": session_id, "aborted": True},
session_id=session_id,
)
sse.cleanup_session(session_id)
except Exception as e:
logger.warning("Failed to close SSE stream on abort for %s: %s", session_id, e)
# Limpiar el lock SOLO si cancelamos una ejecución de verdad: el `finally`
# 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:
await storage.clear_session_lock(session_id)
except Exception as e:
logger.warning("Failed to clear session lock on abort for %s: %s", session_id, e)
return {
"session_id": session_id,
"status": "aborted" if cancelled else "no_active_execution",
}
# ------------------------------------------------------------------
# GET /sessions/{id}/stream
# ------------------------------------------------------------------
@@ -379,15 +567,46 @@ async def get_session(session_id: str) -> SessionResponse:
if not session:
raise HTTPException(status_code=404, detail="Session not found")
plan = session.metadata.get("current_plan")
plan_payload = None
if isinstance(plan, dict) and plan.get("status") == "active":
plan_payload = {
"objective": plan.get("objective", ""),
"steps": [
{
"id": s.get("id"),
"description": s.get("description", "")[:300],
"agent_action": s.get("agent_action", "")[:200],
"files_touched": s.get("files_touched", [])[:10],
"tables_touched": s.get("tables_touched", [])[:10],
}
for s in (plan.get("steps") or [])
],
"risks": (plan.get("risks") or [])[:10],
"cursor": plan.get("cursor", 0),
"completed_step_ids": plan.get("completed_step_ids", []),
"status": plan.get("status", "active"),
}
# Durante el turno el current_task aún no está persistido (begin_task corre en
# process_message; solo se guarda en el finally). Para que un reattach sepa el
# objetivo, lo exponemos desde metadata mientras status==executing.
ct = session.current_task.model_dump() if session.current_task else None
if ct is None and session.status == SessionStatus.EXECUTING:
_obj = session.metadata.get("current_objective")
if _obj:
ct = {"objective": _obj}
return SessionResponse(
session_id=session.session_id,
status=session.status.value,
turn_count=session.turn_count,
current_task=session.current_task.model_dump() if session.current_task else None,
current_task=ct,
completed_tasks=session.completed_tasks,
created_at=session.created_at.isoformat(),
updated_at=session.updated_at.isoformat(),
agent_id=session.agent_id,
current_plan=plan_payload,
)
@@ -412,6 +631,41 @@ async def delete_session(session_id: str) -> dict[str, str]:
return {"status": "deleted", "session_id": session_id}
# ------------------------------------------------------------------
# POST /sessions/{id}/plan/abandon — cancela el plan activo (Fase 5.5)
# ------------------------------------------------------------------
@router.post("/sessions/{session_id}/plan/abandon")
async def abandon_plan(session_id: str) -> dict[str, Any]:
storage = _get_storage()
session = await storage.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
plan = session.metadata.get("current_plan")
if not isinstance(plan, dict) or plan.get("status") != "active":
return {"status": "no_active_plan", "session_id": session_id}
plan["status"] = "abandoned"
session.metadata.setdefault("plan_history", []).append(plan)
session.metadata["current_plan"] = None
await storage.update_session(session)
# Notificar al frontend via SSE.
sse = _get_sse()
try:
from ..streaming.sse import EventType as _ET
await sse.emit(
_ET.PLAN_ENDED,
{"status": "abandoned", "objective": plan.get("objective", "")},
session_id=session_id,
)
except Exception:
logger.warning("PLAN_ENDED emit failed on abandon", exc_info=True)
return {"status": "abandoned", "session_id": session_id}
# ------------------------------------------------------------------
# GET /sessions/{id}/events
# ------------------------------------------------------------------
@@ -520,43 +774,136 @@ async def _load_knowledge_from_dir(docs_path: str = "docs") -> dict[str, Any]:
if not docs_dir.is_dir():
return {"status": "error", "message": f"Directory not found: {docs_dir}"}
# Read all docs
docs_data: list[tuple[str, str, str, str, list[str]]] = [] # (id, title, content, summary, tags)
# Read all docs. Cada doc puede tener frontmatter YAML al inicio:
# ---
# title: "..."
# tags: [a, b]
# load_priority: 80
# load_when: [always]
# summary: "..."
# ---
# Si no hay frontmatter, se cae al modo legacy (heuristica sobre headings).
import re as _re
import yaml as _yaml
_FM_RE = _re.compile(r"^---\s*\n(.*?)\n---\s*\n", _re.DOTALL)
# (id, title, content, summary, tags, priority, load_when)
docs_data: list[tuple[str, str, str, str, list[str], int, list[str]]] = []
for md_file in sorted(docs_dir.glob("*.md")):
content = md_file.read_text(encoding="utf-8")
raw = md_file.read_text(encoding="utf-8")
doc_id = md_file.stem
# Defaults
title = doc_id
summary = ""
tags: list[str] = []
priority = 50
load_when: list[str] = []
# Intentar parsear frontmatter
fm_match = _FM_RE.match(raw)
if fm_match:
try:
fm = _yaml.safe_load(fm_match.group(1)) or {}
if isinstance(fm, dict):
title = str(fm.get("title", title))
summary = str(fm.get("summary", ""))[:500]
fm_tags = fm.get("tags") or []
if isinstance(fm_tags, list):
tags = [str(t).lower()[:30] for t in fm_tags][:10]
priority = int(fm.get("load_priority", 50))
fm_load_when = fm.get("load_when") or []
if isinstance(fm_load_when, list):
load_when = [str(x).lower()[:30] for x in fm_load_when][:10]
# Body sin frontmatter — no contamina embeddings ni cuenta
# como contenido en el system prompt.
content = raw[fm_match.end():]
except _yaml.YAMLError:
logger.warning("Frontmatter invalido en %s — fallback legacy", md_file.name)
content = raw
else:
content = raw
# Fallback legacy: si no hubo frontmatter o falto algun campo,
# derivar title/summary/tags del contenido.
lines = content.strip().splitlines()
title = lines[0].lstrip("#").strip() if lines else doc_id
if title == doc_id and lines:
title = lines[0].lstrip("#").strip() or doc_id
if not summary:
summary_lines: list[str] = []
for line in lines[:30]:
stripped = line.strip()
if stripped and not stripped.startswith("#"):
summary_lines.append(stripped)
if len(" ".join(summary_lines)) > 500:
break
summary = " ".join(summary_lines)[:500]
if not tags:
for line in lines:
if line.startswith("## "):
tags.append(line.lstrip("#").strip().lower()[:30])
tags = tags[:10]
summary_lines = []
for line in lines[:30]:
line = line.strip()
if line and not line.startswith("#"):
summary_lines.append(line)
if len(" ".join(summary_lines)) > 500:
break
summary = " ".join(summary_lines)[:500]
docs_data.append((doc_id, title, content, summary, tags, priority, load_when))
tags = []
for line in lines:
if line.startswith("## "):
tags.append(line.lstrip("#").strip().lower()[:30])
# Hash de contenido por doc — base del skip idempotente de embeddings.
import hashlib
docs_data.append((doc_id, title, content, summary, tags[:10]))
def _embed_text(title, summary, content):
return f"{title}\n{summary}\n{content[:2000]}"
# Generate embeddings in batch
from ..memory.embeddings import EmbeddingService
embed_service = EmbeddingService()
embed_texts = [f"{title}\n{summary}\n{content[:2000]}" for _, title, content, summary, _ in docs_data]
def _doc_hash(title, summary, content):
return hashlib.md5(_embed_text(title, summary, content).encode("utf-8")).hexdigest()
try:
embeddings = await embed_service.embed_batch(embed_texts)
has_embeddings = True
logger.info("Generated %d embeddings for knowledge base", len(embeddings))
except Exception as e:
logger.warning("Failed to generate embeddings: %s — loading without semantic search", e)
embeddings = [None] * len(docs_data)
has_embeddings = False
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
embed_service = EmbeddingService()
embed_texts = [
_embed_text(docs_data[i][1], docs_data[i][3], docs_data[i][2])
for i in to_embed
]
try:
fresh = await embed_service.embed_batch(embed_texts)
for j, i in enumerate(to_embed):
embeddings[i] = fresh[j]
has_embeddings = True
logger.info(
"Generated %d embeddings (%d sin cambios, omitidos)",
len(to_embed), len(docs_data) - len(to_embed),
)
except Exception as e:
logger.warning("Failed to generate embeddings: %s — loading without semantic search", e)
embeddings = [None] * len(docs_data)
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.
# Sin esto, los IDs antiguos (e.g. tras renombrar 'builder-fields' →
@@ -567,16 +914,17 @@ async def _load_knowledge_from_dir(docs_path: str = "docs") -> dict[str, Any]:
for existing in existing_docs:
if existing.memory_id not in current_ids:
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)
await memory._r.delete(embed_key)
await memory._r.delete(memory._key("kbhash", "knowledge", existing.memory_id))
removed.append(existing.memory_id)
if removed:
logger.info("Removed %d stale knowledge docs: %s", len(removed), removed)
# Store docs + embeddings
loaded = []
for i, (doc_id, title, content, summary, tags) in enumerate(docs_data):
for i, (doc_id, title, content, summary, tags, priority, load_when) in enumerate(docs_data):
doc = MemoryDocument(
memory_id=doc_id,
memory_type=MemoryType.DOCUMENT,
@@ -585,18 +933,27 @@ async def _load_knowledge_from_dir(docs_path: str = "docs") -> dict[str, Any]:
content=content,
summary=summary,
tags=tags,
priority=priority,
load_when=load_when,
)
await memory.store_document(doc)
if embeddings[i] is not None:
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({
"id": doc_id,
"title": title,
"chars": len(content),
"tags": tags[:5],
"embedded": embeddings[i] is not None,
"priority": priority,
"load_when": load_when,
"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)

View File

@@ -32,10 +32,49 @@ class Settings(BaseSettings):
anthropic_base_url: str = "" # Custom base URL (for MiniMax Anthropic-compatible, etc.)
openai_api_key: str = ""
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_id: str = "claude-sonnet-4-20250514"
# Modelo override SOLO para el sub-loop del planner (acai_plan). Si vacio,
# usa default_model_id. Pensado para usar un modelo mas potente al planificar
# (p.ej. deepseek-v4-pro) y otro mas rapido al ejecutar (p.ej. deepseek-v4-flash).
planner_model_id: str = ""
# Max tokens del planner. Mas alto que el agente principal porque Pro con
# thinking puede gastar 2-4k tokens razonando antes de emitir el JSON del plan.
planner_max_tokens: int = 16000
max_tokens: int = 4096
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 ---
model_context_window: int = 0 # 0 = use legacy fixed budget / explicit override
@@ -45,12 +84,28 @@ class Settings(BaseSettings):
compaction_threshold_ratio: float = 0.80
context_reserve_ratio: float = 0.10
artifact_summary_max_chars: int = 2000
knowledge_base_max_tokens: int = 30_000
# KB inyectada como system prompt. Default 4k (antes 30k) — la doc
# oficial de M2.7 advierte que system prompts grandes degradan rendimiento.
# Top-2 docs medianos + cheat sheet ≈ 4k tokens caben con margen.
# Se sobrescribe per-agent via `agent.yaml.kb_max_tokens`.
knowledge_base_max_tokens: int = 4_000
# Cap absoluto del numero de docs incluidos (filtro tras ranking).
kb_top_n_docs: int = 2
# Penalty al `load_priority` de docs `load_when: [ranked]` para que
# no entren "por defecto" en el branch top_n, solo si rankean muy alto.
kb_ranked_penalty: int = 10
# Umbral de similitud por debajo del cual el ranking no es confiable
# y se usa el `load_priority` del frontmatter como tie-break.
kb_similarity_floor: float = 0.6
working_context_max_items: int = 20
tool_raw_output_max_chars: int = 2000
tool_raw_output_max_chars: int = 16000 # Antes 2000 (calibrado MiniMax 200k). Subido para DeepSeek 1M context.
conversation_recent_raw_limit: int = 2
task_history_max_entries: int = 20
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_config_path: str = "" # Path to mcp.json; empty = legacy single-server mode
@@ -100,5 +155,24 @@ class Settings(BaseSettings):
return min(self.compaction_threshold_tokens, self.effective_context_budget)
return max(1, int(self.effective_context_budget * self.compaction_threshold_ratio))
def budget_for_window(self, window: int, max_output: int | None = None) -> int:
"""Budget de contexto para la ventana REAL del modelo activo.
Misma fórmula que `effective_context_budget` (`window - max_output -
reserve`) pero parametrizada por la ventana del modelo del turno. Si la
ventana no es válida, cae al budget estático. Un override explícito
(`context_max_tokens`) siempre manda (lo aplica el caller)."""
if window <= 0:
return self.effective_context_budget
out = self.model_max_output_tokens if max_output is None else max_output
reserve = int(window * self.context_reserve_ratio)
return max(1, window - max(0, out) - max(0, reserve))
def compaction_threshold_for(self, budget: int) -> int:
"""Umbral de compactación para un budget dado (ratio configurable)."""
if self.compaction_threshold_tokens > 0:
return min(self.compaction_threshold_tokens, budget)
return max(1, int(budget * self.compaction_threshold_ratio))
settings = Settings()

View File

@@ -180,26 +180,68 @@ class ContextCompactor:
"raw_tool_results_kept": 0,
}
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]
last_user_idx = max(
(i for i, m in enumerate(compacted) if m.get("role") == "user"),
default=-1,
)
# Tool messages legacy (role=tool) y nuevos (role=user con tool_result blocks)
tool_indexes = [i for i, m in enumerate(compacted) if m.get("role") == "tool"]
# Indices de user messages que contienen tool_result blocks (Anthropic-style)
user_tool_result_indexes = [
i for i, m in enumerate(compacted)
if m.get("role") == "user"
and isinstance(m.get("content"), list)
and any(
isinstance(b, dict) and b.get("type") == "tool_result"
for b in m["content"]
)
]
# Combinamos para aplicar la misma politica de "preservar los ultimos N raw"
all_tool_carriers = tool_indexes + user_tool_result_indexes
all_tool_carriers.sort()
keep_raw_tool_indexes = (
set(tool_indexes[-recent_raw_limit:])
set(all_tool_carriers[-recent_raw_limit:])
if recent_raw_limit > 0
else set()
)
def _truncate_tool_result_blocks(msg: dict[str, Any], char_limit: int) -> bool:
"""Trunca el campo `content` de los tool_result blocks de un user
message con content list. Devuelve True si modifico algo."""
modified = False
content = msg.get("content")
if not isinstance(content, list):
return False
for block in content:
if not isinstance(block, dict) or block.get("type") != "tool_result":
continue
bc = block.get("content", "")
if isinstance(bc, str) and len(bc) > char_limit:
block["content"] = bc[:char_limit]
modified = True
return modified
for idx in keep_raw_tool_indexes:
content = compacted[idx].get("content", "")
msg = compacted[idx]
content = msg.get("content", "")
if isinstance(content, str) and content:
truncated = content[:raw_char_limit]
if truncated != content:
compacted[idx]["content"] = truncated
msg["content"] = truncated
meta["messages_compacted"] += 1
meta["tool_messages_compacted"] += 1
meta["raw_tool_results_kept"] += 1
elif isinstance(content, list):
if _truncate_tool_result_blocks(msg, raw_char_limit):
meta["messages_compacted"] += 1
meta["tool_messages_compacted"] += 1
meta["raw_tool_results_kept"] += 1
@@ -271,27 +313,277 @@ class ContextCompactor:
if total <= max_tokens:
break
# Last-resort: drop thinking blocks (M2 interleaved) de assistant
# messages que NO sean los 2 ultimos turnos. Ahorra muchisimo sin
# perder utilidad — los thinking de turnos lejanos ya cumplieron.
if total > max_tokens:
assistant_indexes = [
i for i, m in enumerate(compacted)
if m.get("role") == "assistant" and isinstance(m.get("content"), list)
]
# Conservar los thinking de los ultimos 2 assistants; descartar el resto.
droppable = assistant_indexes[:-2] if len(assistant_indexes) > 2 else []
for idx in droppable:
content = compacted[idx]["content"]
new_content = [b for b in content if not (isinstance(b, dict) and b.get("type") == "thinking")]
if len(new_content) != len(content):
compacted[idx]["content"] = new_content
meta["messages_compacted"] += 1
meta["assistant_messages_compacted"] += 1
total = sum(self._estimate_message_tokens(m) for m in compacted)
if total <= max_tokens:
break
if total > max_tokens:
for idx, message in enumerate(compacted):
if idx == last_user_idx:
continue
role = message.get("role", "")
content = message.get("content", "")
if not isinstance(content, str) or not content:
if isinstance(content, str) and content:
if role == "tool":
message["content"] = "[TOOL RESULT COMPACTADO]"
elif role == "assistant":
message["content"] = "[ASSISTANT COMPACTADO]"
elif role == "user":
message["content"] = "[USER CONTEXT COMPACTADO]"
elif isinstance(content, list) and content:
# Anthropic-style: reemplazar lista entera por placeholder string.
# 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":
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":
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:
continue
if role == "tool":
message["content"] = "[TOOL RESULT COMPACTADO]"
elif role == "assistant":
message["content"] = "[ASSISTANT COMPACTADO]"
elif role == "user":
message["content"] = "[USER CONTEXT COMPACTADO]"
total = sum(self._estimate_message_tokens(m) for m in compacted)
if total <= max_tokens:
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
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
# ------------------------------------------------------------------
@@ -422,7 +714,10 @@ class ContextCompactor:
except (json.JSONDecodeError, ValueError):
return None
truncated, stats = self._truncate_json_value(data, list_limit=5, depth_limit=4)
# Limits calibrados para context window grande (DeepSeek v4 = 1M tokens).
# Antes era list_limit=5, depth_limit=4 — calibrado para MiniMax 200k.
# Con 1M context podemos permitirnos ver bastante mas de cada respuesta.
truncated, stats = self._truncate_json_value(data, list_limit=30, depth_limit=6)
try:
body = json.dumps(truncated, ensure_ascii=False, separators=(",", ":"))
except (TypeError, ValueError):
@@ -575,7 +870,34 @@ class ContextCompactor:
@staticmethod
def _estimate_message_tokens(message: dict[str, Any]) -> int:
content = message.get("content", "")
tokens = estimate_tokens(content if isinstance(content, str) else str(content))
if isinstance(content, str):
tokens = estimate_tokens(content)
elif isinstance(content, list):
# Anthropic-style content blocks (interleaved thinking M2).
tokens = 0
for block in content:
if not isinstance(block, dict):
tokens += estimate_tokens(str(block))
continue
btype = block.get("type", "")
if btype == "text":
tokens += estimate_tokens(block.get("text", ""))
elif btype == "thinking":
tokens += estimate_tokens(block.get("thinking", ""))
elif btype == "tool_use":
tokens += estimate_tokens(block.get("name", ""))
tokens += estimate_tokens(str(block.get("input", "")))
elif btype == "tool_result":
tc = block.get("content", "")
tokens += estimate_tokens(tc if isinstance(tc, str) else str(tc))
elif btype == "image_url":
# Una imagen ~1500 tokens. NO medir el base64 como texto, que
# lo contaría como ~30k y reventaría presupuestos/trim.
tokens += 1500
else:
tokens += estimate_tokens(str(block))
else:
tokens = estimate_tokens(str(content))
if message.get("tool_calls"):
tokens += estimate_tokens(json.dumps(message.get("tool_calls", []), ensure_ascii=False))
return tokens

View File

@@ -66,13 +66,35 @@ class ContextEngine:
artifacts: list[ArtifactSummary] | None = None,
conversation: list[dict[str, Any]] | None = None,
extra_instructions: str = "",
model_id: str | None = None,
budget_override: int | None = None,
) -> ContextPackage:
"""Build a full ContextPackage for the given agent and session.
The conversation parameter contains real assistant/tool messages
with complete tool results. These go into the messages array,
not the system prompt — like professional agentic tools.
El budget de contexto se deriva de la VENTANA REAL del modelo activo
(`model_id`, formato litellm) vía catálogo/litellm; `budget_override`
fuerza un budget menor (retry agresivo ante overflow).
"""
# Budget del turno: override (retry) → override duro de settings →
# ventana del modelo → fallback estático. Umbral derivado del budget.
from ..orchestrator.cost import resolve_context_window
if budget_override is not None and budget_override > 0:
budget = budget_override
elif settings.context_max_tokens > 0:
budget = settings.context_max_tokens
else:
window = await resolve_context_window(model_id) if model_id else None
budget = (
settings.budget_for_window(window)
if window
else settings.effective_context_budget
)
threshold = settings.compaction_threshold_for(budget)
sections: list[ContextSection] = []
allowed = set(agent.context_sections)
@@ -90,11 +112,15 @@ class ContextEngine:
and ("artifact_memory" in allowed or "task_state" in allowed)
)
# 3. Knowledge base — loaded from memory store
# 3. Knowledge base — loaded from memory store. Strategy y budget
# vienen del agent profile (Fase 1 refactor): cada agente decide
# cuanto KB inyecta y como filtra (top_n / tags / cheatsheet_only / ...).
if "knowledge_base" in allowed and self.memory:
kb_budget = agent.kb_max_tokens or settings.knowledge_base_max_tokens
kb_section = await self._build_knowledge_base(
session,
max_tokens=settings.knowledge_base_max_tokens,
agent=agent,
max_tokens=kb_budget,
)
if kb_section:
sections.append(kb_section)
@@ -113,6 +139,7 @@ class ContextEngine:
sections.append(
self._build_task_state(
session.current_task,
session=session,
objective_override=base_user_content,
resolved_context=resolved_followup_context,
followup_mode=followup_mode,
@@ -135,7 +162,7 @@ class ContextEngine:
raw_message_tokens = sum(self._estimate_message_tokens(m) for m in messages)
pre_compaction_section_tokens = sum(estimate_tokens(s.content) for s in sections)
pre_compaction_total = pre_compaction_section_tokens + raw_message_tokens
section_budget = max(1, settings.effective_context_budget - raw_message_tokens)
section_budget = max(1, budget - raw_message_tokens)
# Compact sections only when the full prompt is approaching the target.
section_compaction = {
@@ -150,8 +177,8 @@ class ContextEngine:
}
system_prompt = self._assemble_system_prompt(sections)
system_prompt_tokens = estimate_tokens(system_prompt)
hard_message_budget = max(1, settings.effective_context_budget - system_prompt_tokens)
target_message_budget = max(1, settings.effective_compaction_threshold - system_prompt_tokens)
hard_message_budget = max(1, budget - system_prompt_tokens)
target_message_budget = max(1, threshold - system_prompt_tokens)
message_budget = min(hard_message_budget, target_message_budget)
conversation_compaction = {
"budget_tokens": message_budget,
@@ -165,7 +192,7 @@ class ContextEngine:
}
total_tokens = system_prompt_tokens + raw_message_tokens
if total_tokens > settings.effective_compaction_threshold:
if total_tokens > threshold:
messages, conversation_compaction = self.compactor.compact_conversation(
messages,
max_tokens=message_budget,
@@ -176,10 +203,10 @@ class ContextEngine:
self._estimate_message_tokens(m) for m in messages
)
if total_tokens > settings.effective_context_budget:
if total_tokens > budget:
section_budget = max(
1,
settings.effective_context_budget
budget
- sum(self._estimate_message_tokens(m) for m in messages),
)
sections, section_compaction = self.compactor.compact_sections(
@@ -192,10 +219,10 @@ class ContextEngine:
self._estimate_message_tokens(m) for m in messages
)
if total_tokens > settings.effective_context_budget:
if total_tokens > budget:
hard_message_budget = max(
1,
settings.effective_context_budget - system_prompt_tokens,
budget - system_prompt_tokens,
)
messages, conversation_compaction = self.compactor.compact_conversation(
messages,
@@ -212,6 +239,7 @@ class ContextEngine:
system_prompt=system_prompt,
messages=messages,
total_token_estimate=total_tokens,
budget_tokens=budget,
)
# Guardar contexto completo del último build (solo el último por sesión)
@@ -219,8 +247,8 @@ class ContextEngine:
"system_prompt": system_prompt,
"messages": messages,
"total_tokens": total_tokens,
"budget_tokens": settings.effective_context_budget,
"threshold_tokens": settings.effective_compaction_threshold,
"budget_tokens": budget,
"threshold_tokens": threshold,
"timestamp": time.time(),
}
@@ -253,8 +281,8 @@ class ContextEngine:
"user_message_preview": user_content[:200],
"artifacts_count": len(artifacts) if artifacts else 0,
"conversation_messages": conv_len,
"budget_tokens": settings.effective_context_budget,
"threshold_tokens": settings.effective_compaction_threshold,
"budget_tokens": budget,
"threshold_tokens": threshold,
"message_tokens": conversation_compaction.get("output_tokens", raw_message_tokens),
"message_tokens_before_compaction": raw_message_tokens,
"pre_compaction_tokens": pre_compaction_total,
@@ -263,7 +291,7 @@ class ContextEngine:
"message_budget_tokens": message_budget,
"section_compaction": section_compaction,
"conversation_compaction": conversation_compaction,
"over_budget": total_tokens > settings.effective_context_budget,
"over_budget": total_tokens > budget,
}
history = self._history[session.session_id]
@@ -340,29 +368,29 @@ class ContextEngine:
def _build_immutable_rules(
self, session: SessionState, agent: AgentProfile
) -> ContextSection:
parts = [
"# System Rules (Immutable)",
"",
agent.system_prompt,
"",
]
# `agent.system_prompt` ya incluye el contrato compartido (concatenado
# por el registry al cargar). Aqui solo se añaden reglas de sesion
# cuando existen — el bloque hardcoded de "Contrato de Contexto" que
# vivia aqui se ha movido a `agents/_shared/contract.md` (Fase 3).
system_prompt = agent.system_prompt or ""
# Si el usuario tiene el toggle de plan desactivado (plan_mode != "force"),
# quitamos la seccion del system prompt entre <!-- PLANNER_SECTION_START -->
# y <!-- PLANNER_SECTION_END -->. Asi el modelo no ve instrucciones para
# llamar acai_plan y no se inventa el namespace `acai_code__acai_plan`.
if (session.metadata.get("plan_mode") or "off").lower() != "force":
import re
system_prompt = re.sub(
r"<!--\s*PLANNER_SECTION_START\s*-->.*?<!--\s*PLANNER_SECTION_END\s*-->\n*",
"",
system_prompt,
flags=re.DOTALL,
)
parts = [system_prompt]
if session.immutable_rules:
parts.append("## Session Rules")
parts.append("\n\n## Session Rules\n")
for rule in session.immutable_rules:
parts.append(f"- {rule}")
parts.extend(
[
"",
"## Contrato de Contexto",
"- Los resultados de herramientas se incluyen completos en la conversación.",
"- Los steps anteriores pueden estar compactados como resúmenes.",
"- Mantén las respuestas enfocadas en el paso actual.",
"- Si ya tienes la información necesaria, genera tu respuesta final.",
"- NO repitas llamadas a herramientas con los mismos argumentos.",
"- Responde SIEMPRE en español.",
]
)
content = "\n".join(parts)
content = "\n".join(p for p in parts if p)
return ContextSection(
section_type=ContextSectionType.IMMUTABLE_RULES,
content=content,
@@ -388,14 +416,30 @@ class ContextEngine:
async def _build_knowledge_base(
self,
session: SessionState,
agent: AgentProfile,
max_tokens: int,
) -> ContextSection | None:
"""Load relevant knowledge documents via semantic search.
"""Carga el subset relevante de la KB segun `agent.kb_load_strategy`.
Uses embeddings to find the most relevant docs for the current
task. Always includes a title index of ALL docs so the agent
knows what exists and can request more.
Estrategias soportadas:
- `none`: no inyecta KB (devuelve None).
- `cheatsheet_only`: solo docs con `load_when` que contiene "cheatsheet".
- `glossary_only`: solo docs con `load_when` que contiene "glossary".
- `planner_only`: docs con `load_when` que contiene "planner_only" |
"cheatsheet" | "glossary". Usado por el sub-loop de `acai_plan`.
- `tags`: filtra por interseccion con `agent.kb_tags`, ranking dentro.
- `top_n` (default): ranking semantico sobre docs `always`/`ranked`,
con penalty para `ranked` y tie-break por `priority` cuando la
similitud cae bajo `kb_similarity_floor`.
- `all` (legacy): comportamiento previo, todos los que quepan.
Siempre incluye al final un listado "Other Available Docs" para que
el agente pueda pedirlos via `read_doc`.
"""
strategy = (agent.kb_load_strategy or "top_n").lower()
if strategy == "none":
return None
if not self.memory:
return None
@@ -412,36 +456,124 @@ class ContextEngine:
if not all_docs:
return None
doc_map = {d.memory_id: d for d in all_docs}
# Rank docs by semantic similarity
query = self._build_search_query(session)
ranked_ids: list[str] = []
if query:
ranked_ids = await self._semantic_rank(query)
if not ranked_ids:
# No embeddings or no task — sort by size (smallest first)
ranked_ids = [
d.memory_id
for d in sorted(all_docs, key=lambda d: len(d.content))
# 1) Pre-filtrado segun strategy.
candidates: list[MemoryDocument]
if strategy == "cheatsheet_only":
candidates = [d for d in all_docs if "cheatsheet" in (d.load_when or [])]
elif strategy == "glossary_only":
candidates = [d for d in all_docs if "glossary" in (d.load_when or [])]
elif strategy == "planner_only":
candidates = [
d for d in all_docs
if any(t in (d.load_when or []) for t in ("planner_only", "cheatsheet", "glossary"))
]
elif strategy == "tags":
agent_tags = {t.lower() for t in (agent.kb_tags or [])}
if not agent_tags:
candidates = []
else:
candidates = [
d for d in all_docs
if agent_tags.intersection({t.lower() for t in (d.tags or [])})
]
elif strategy == "all":
# Legacy / debugging — todos los docs.
candidates = list(all_docs)
else:
# `top_n` (default): considera docs `always` y `ranked`. Si el
# frontmatter no esta presente, los tratamos como `always` para
# no excluirlos por accidente (modo legacy).
def _eligible_top_n(d: MemoryDocument) -> bool:
lw = d.load_when or []
if not lw:
return True # legacy: sin frontmatter → considerado
return "always" in lw or "ranked" in lw
candidates = [d for d in all_docs if _eligible_top_n(d)]
# Include ALL docs — 42K tokens fits well within model context (128K)
if not candidates:
# No hay docs aplicables al strategy. Devolvemos solo el indice
# de "Other Available Docs" para que el agente pueda pedir on-demand.
return self._build_kb_section_only_index(all_docs, full_docs=[])
# 2) Ranking. Para strategies "estaticas" (cheatsheet_only, glossary_only,
# planner_only) ordenamos por priority desc — son sets pequenos y el
# ranking semantico no aporta. Para `tags` y `top_n` aplicamos ranking
# semantico cuando hay query, sino priority desc.
candidate_ids = {d.memory_id for d in candidates}
ordered: list[MemoryDocument]
if strategy in ("cheatsheet_only", "glossary_only", "planner_only"):
ordered = sorted(candidates, key=lambda d: d.priority, reverse=True)
else:
query = self._build_search_query(session)
ranked: list[tuple[str, float]] = []
if query:
ranked = await self._semantic_rank(query)
ranked = [(did, s) for did, s in ranked if did in candidate_ids]
ranked_map = {did: s for did, s in ranked}
def _score(d: MemoryDocument) -> tuple[float, int]:
# Score combinado: similitud + priority/100 (peso bajo).
# Si la similitud es < floor, fallback a priority pura.
sim = ranked_map.get(d.memory_id, 0.0)
prio = d.priority
# Penalty para `ranked` (no entra "por defecto")
if "ranked" in (d.load_when or []):
prio -= settings.kb_ranked_penalty
if sim < settings.kb_similarity_floor:
return (prio / 100.0, prio)
return (sim + prio / 1000.0, prio)
ordered = sorted(candidates, key=_score, reverse=True)
# 3) Cap por kb_max_tokens y kb_top_n.
token_budget = max_tokens
top_n_cap = agent.kb_top_n or settings.kb_top_n_docs
full_docs: list[MemoryDocument] = []
for doc_id in ranked_ids:
doc = doc_map.get(doc_id)
if not doc:
continue
for doc in ordered:
if len(full_docs) >= top_n_cap and strategy not in ("cheatsheet_only", "glossary_only", "planner_only"):
break
doc_tokens = estimate_tokens(doc.content)
if doc_tokens <= token_budget:
full_docs.append(doc)
token_budget -= doc_tokens
elif not full_docs:
# Si el primer doc ya no cabe, se incluye truncado para tener
# algo. Mejor un doc parcial que ningun doc.
truncated = self._truncate_to_tokens(doc.content, token_budget)
if truncated:
full_docs.append(MemoryDocument(
memory_id=doc.memory_id,
memory_type=doc.memory_type,
namespace=doc.namespace,
title=doc.title,
content=truncated + "\n\n[...] (doc truncado)",
summary=doc.summary,
tags=doc.tags,
priority=doc.priority,
load_when=doc.load_when,
))
break
# Build section — ALWAYS include title index of ALL docs
return self._build_kb_section_only_index(all_docs, full_docs)
@staticmethod
def _truncate_to_tokens(text: str, max_tokens: int) -> str:
# Heuristica: ~4 chars por token. Truncamos a 4*max_tokens caracteres.
if max_tokens <= 0:
return ""
cap = max(0, max_tokens * 4)
if len(text) <= cap:
return text
return text[:cap]
@staticmethod
def _build_kb_section_only_index(
all_docs: list[MemoryDocument],
full_docs: list[MemoryDocument],
) -> ContextSection:
"""Construye la seccion KB final: docs cargados + indice del resto."""
included_ids = {d.memory_id for d in full_docs}
not_included = [d for d in all_docs if d.memory_id not in included_ids]
@@ -459,9 +591,9 @@ class ContextEngine:
if not_included:
lines.append("## Other Available Docs")
lines.append("_Ask for any of these if you need the full content:_")
lines.append("_Pidelos con `read_doc({name: \"<id>\"})` cuando los necesites:_")
for doc in not_included:
lines.append(f"- **{doc.title}** ({doc.memory_id}): {doc.summary[:150]}")
lines.append(f"- **{doc.title}** (`{doc.memory_id}`): {(doc.summary or '')[:150]}")
lines.append("")
content = "\n".join(lines)
@@ -472,8 +604,18 @@ class ContextEngine:
token_estimate=estimate_tokens(content),
)
async def _semantic_rank(self, query: str) -> list[str]:
"""Rank knowledge docs by cosine similarity to the query."""
async def _semantic_rank(self, query: str) -> list[tuple[str, float]]:
"""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:
if not self._embed_service:
self._embed_service = EmbeddingService()
@@ -484,7 +626,7 @@ class ContextEngine:
namespace="knowledge",
top_k=50,
)
return [doc_id for doc_id, _score in results]
return list(results)
except Exception as e:
logger.warning("Semantic search failed: %s — loading all docs", e)
@@ -572,6 +714,7 @@ class ContextEngine:
def _build_task_state(
self,
task: TaskState,
session: SessionState | None = None,
objective_override: str | None = None,
resolved_context: str = "",
followup_mode: str = "none",
@@ -659,6 +802,37 @@ class ContextEngine:
f" {marker} Step {i + 1} [{status_label}{compacted_label}]: {step.description}"
)
# Active Plan (Fase 5: tool acai_plan). Si hay un plan activo en
# session.metadata, lo renderizamos con cursor + completed marks.
if session is not None:
current_plan = session.metadata.get("current_plan")
if isinstance(current_plan, dict) and current_plan.get("status") == "active":
steps = current_plan.get("steps") or []
cursor = int(current_plan.get("cursor", 0))
completed_set = set(current_plan.get("completed_step_ids", []))
lines.append("")
lines.append("## Active Plan (acai_plan)")
lines.append(f"**Objetivo**: {current_plan.get('objective', '')}")
if steps:
lines.append(f"**Cursor**: → step {min(cursor + 1, len(steps))}/{len(steps)}")
lines.append("")
for i, st in enumerate(steps):
sid = st.get("id", i + 1)
desc = st.get("description", "")
if sid in completed_set:
marker, label = "", "done"
elif i == cursor:
marker, label = "", "pending"
else:
marker, label = "·", "pending"
lines.append(f" {marker} Step {i + 1} [{label}]: {desc}")
risks = current_plan.get("risks") or []
if risks:
lines.append("")
lines.append("**Risks**:")
for r in risks[:5]:
lines.append(f"- {r}")
content = "\n".join(lines)
return ContextSection(
section_type=ContextSectionType.TASK_STATE,
@@ -773,8 +947,19 @@ class ContextEngine:
messages.append({"role": "user", "content": "\n".join(history_lines)})
messages.append({"role": "assistant", "content": "Entendido, tengo el contexto del historial. ¿En qué puedo ayudarte ahora?"})
# Current user message
messages.append({"role": "user", "content": user_content})
# Current user message — con imágenes adjuntas (visión nativa) si las hay.
# En ese caso el content pasa a ser lista de bloques [texto, image_url...].
image_attachments = []
if session.current_task and getattr(session.current_task, "image_attachments", None):
image_attachments = [
b for b in session.current_task.image_attachments if isinstance(b, dict)
]
if image_attachments:
content_blocks = [{"type": "text", "text": user_content}]
content_blocks.extend(image_attachments)
messages.append({"role": "user", "content": content_blocks})
else:
messages.append({"role": "user", "content": user_content})
# Append real conversation (assistant messages + tool results from current step)
if conversation:
@@ -795,7 +980,22 @@ class ContextEngine:
else:
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 = ""
if session.task_history and followup_mode != "none":
resolved_context = self._build_followup_resolution(session.task_history[-1])
@@ -825,7 +1025,11 @@ class ContextEngine:
sanitized: dict[str, Any] = {"role": role}
content = message.get("content", "")
if isinstance(content, str) and content:
# Anthropic-style content list (blocks: thinking/text/tool_use/tool_result)
# se preserva tal cual — necesario para interleaved thinking de M2.
if isinstance(content, list) and content:
sanitized["content"] = content
elif isinstance(content, str) and content:
sanitized["content"] = content
if role == "assistant":
@@ -848,6 +1052,32 @@ class ContextEngine:
content = message.get("content", "")
if isinstance(content, str):
return estimate_tokens(content)
if isinstance(content, list):
# Sumar tokens de cada bloque por su campo correspondiente.
total = 0
for block in content:
if not isinstance(block, dict):
total += estimate_tokens(str(block))
continue
btype = block.get("type", "")
if btype == "text":
total += estimate_tokens(block.get("text", ""))
elif btype == "thinking":
total += estimate_tokens(block.get("thinking", ""))
# signature es opaque — no cuenta tokens significativos
elif btype == "tool_use":
total += estimate_tokens(block.get("name", ""))
total += estimate_tokens(str(block.get("input", "")))
elif btype == "tool_result":
tc = block.get("content", "")
total += estimate_tokens(tc if isinstance(tc, str) else str(tc))
elif btype == "image_url":
# Heurística conservadora: una imagen ~1500 tokens (no se
# cuenta el base64 como texto, que infla muchísimo).
total += 1500
else:
total += estimate_tokens(str(block))
return total
return estimate_tokens(str(content))
@staticmethod

View File

@@ -54,7 +54,11 @@ async def lifespan(app: FastAPI):
await redis_storage.connect()
# 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()
logger.info("Using OpenAI adapter (model: %s)", settings.default_model_id)
else:

View File

@@ -18,6 +18,15 @@ from ..models.tools import ToolDefinition
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):
pass
@@ -74,7 +83,7 @@ class MCPClient:
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
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._reader_task = asyncio.create_task(self._read_loop())
@@ -225,14 +234,30 @@ class MCPClient:
if not self._process or not self._process.stdout:
return
stdout = self._process.stdout
try:
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:
logger.warning("MCP server stdout closed")
break
line_str = line.decode().strip()
line_str = line.decode(errors="replace").strip()
if not line_str:
continue
@@ -251,6 +276,21 @@ class MCPClient:
finally:
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:
"""Route an incoming JSON-RPC message."""
msg_id = message.get("id")

View File

@@ -8,6 +8,7 @@ defines the project-specific variables (ACAI_WEB_URL, etc.).
from __future__ import annotations
import logging
import time
from pathlib import Path
from typing import Any
@@ -17,6 +18,15 @@ from .manager import MCPManager
logger = logging.getLogger(__name__)
# Máximo de sesiones MCP vivas a la vez. Cada sesión arranca N subprocesos
# stdio (acai-code, playwright+chromium, fetch) con sus read-loops en el event
# loop; si se acumulan sin límite, saturan recursos y DEGRADAN progresivamente
# las sesiones nuevas (menos contexto/tools al modelo, hasta que el agente deja
# de recibir tools y emite los tool calls como texto). Evictamos por LRU las
# menos usadas — es seguro porque send_message reconecta el MCP de una sesión
# si vuelve a usarse y ya no está viva.
MAX_ACTIVE_MCP_SESSIONS = 2
class MCPRegistry:
"""Manages per-session MCPManager instances.
@@ -29,6 +39,7 @@ class MCPRegistry:
self._config_path = Path(config_path) if config_path else None
self._config: MCPConfigFile | None = None
self._sessions: dict[str, MCPManager] = {} # session_id → MCPManager
self._last_used: dict[str, float] = {} # session_id → monotonic ts (LRU)
def load_config(self) -> None:
"""Load the global MCP config template."""
@@ -91,6 +102,7 @@ class MCPRegistry:
results = await manager.start()
self._sessions[session_id] = manager
self._last_used[session_id] = time.monotonic()
logger.info(
"MCP started for session %s: %s",
@@ -98,18 +110,41 @@ class MCPRegistry:
{k: v.get("status") for k, v in results.items()},
)
# Evictar por LRU si superamos el máximo (evita la degradación por
# acumulación de subprocesos MCP). Seguro: send_message reconecta.
await self._evict_lru()
return manager
async def _evict_lru(self) -> None:
"""Destruye las sesiones MCP menos usadas recientemente hasta no superar
MAX_ACTIVE_MCP_SESSIONS."""
while len(self._sessions) > MAX_ACTIVE_MCP_SESSIONS:
# sesión con last_used más antiguo (las que no tienen ts van primero)
oldest = min(
self._sessions.keys(),
key=lambda sid: self._last_used.get(sid, 0.0),
)
logger.info(
"MCP registry: evicting LRU session %s (%d active > max %d)",
oldest[:12], len(self._sessions), MAX_ACTIVE_MCP_SESSIONS,
)
await self.destroy_for_session(oldest)
async def destroy_for_session(self, session_id: str) -> None:
"""Stop and clean up MCP servers for a session."""
manager = self._sessions.pop(session_id, None)
self._last_used.pop(session_id, None)
if manager:
await manager.stop()
logger.info("MCP stopped for session %s", session_id[:12])
def get_for_session(self, session_id: str) -> MCPManager | None:
"""Get the MCPManager for a session, if any."""
return self._sessions.get(session_id)
manager = self._sessions.get(session_id)
if manager is not None:
self._last_used[session_id] = time.monotonic() # touch LRU
return manager
async def stop_all(self) -> None:
"""Stop all sessions' MCP servers (shutdown)."""

View File

@@ -25,12 +25,19 @@ class EmbeddingService:
def __init__(
self,
api_key: str | None = None,
model: str = DEFAULT_MODEL,
model: str | None = None,
) -> None:
self._client = AsyncOpenAI(
api_key=api_key or settings.openai_api_key,
)
self._model = model
# Credenciales dedicadas de embeddings. Fallback a openai_api_key por
# compat. El base_url solo se aplica si se configura explicitamente
# `embeddings_base_url`; vacio => OpenAI real (api.openai.com). NO se
# 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]:
"""Generate embedding for a single text."""

View File

@@ -19,6 +19,8 @@ class AgentProfile(BaseModel):
system_prompt: str = ""
allowed_tools: list[str] = Field(default_factory=list)
model_id: str | None = None
planner_model_id: str | None = None # override del modelo solo para el sub-loop del planner
reasoning_effort: str | None = None # nivel de razonamiento (minimal|low|medium|high) resuelto por sesión
temperature: float | None = None
max_tokens: int | None = None
context_sections: list[str] = Field(
@@ -31,6 +33,23 @@ class AgentProfile(BaseModel):
)
stream_deltas: bool = True # Si emite deltas por SSE al usuario
# KB load strategy (Fase 1 refactor): controla CUANTO y QUE de la KB se
# inyecta como system prompt. Ver `_build_knowledge_base` en context/engine.py.
# - `top_n` (default): ranking semantico, top-N docs hasta agotar budget.
# - `tags`: filtra por interseccion con `kb_tags`, ranking dentro.
# - `cheatsheet_only`: solo docs con `load_when: [cheatsheet]`.
# - `glossary_only`: solo docs con `load_when: [glossary]`.
# - `planner_only`: solo docs con `load_when: [planner_only|cheatsheet|glossary]`
# (usado por la sub-llamada interna de `acai_plan`).
# - `none`: no carga KB.
# - `all` (legacy): comportamiento previo, todos los docs que quepan.
kb_load_strategy: str = "top_n"
kb_tags: list[str] = Field(default_factory=list)
kb_max_tokens: int | None = None # override per-agent del default global
kb_top_n: int | None = None # override per-agent del default global
has_planner_tool: bool = False # si expone la tool interna `acai_plan`
system_prompt_planner: str = "" # cargado de `system.planner.md` si existe
class SubAgentDefinition(BaseModel):
"""A runnable subagent configuration within the orchestrator."""

View File

@@ -35,6 +35,10 @@ class ContextPackage(BaseModel):
system_prompt: str = ""
messages: list[dict[str, Any]] = Field(default_factory=list)
total_token_estimate: int = 0
# Budget de contexto (tokens) usado para construir/compactar este paquete —
# derivado de la ventana del modelo activo. Lo usa el loop del agente para
# compactar más agresivo si aún no cabe en la ventana.
budget_tokens: int = 0
def to_messages(self) -> list[dict[str, Any]]:
"""Produce the final messages list for the model adapter."""
@@ -62,6 +66,10 @@ class MemoryDocument(BaseModel):
content: str
summary: str = ""
tags: list[str] = Field(default_factory=list)
# Frontmatter YAML del doc (Fase 4 refactor). Si el doc no tiene frontmatter
# se quedan en defaults: priority=50, load_when=[].
priority: int = 50
load_when: list[str] = Field(default_factory=list)
embedding: list[float] | None = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))

View File

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

View File

@@ -9,9 +9,10 @@ import time
import uuid
from typing import Any, AsyncIterator
from ...adapters.base import ModelAdapter, ModelConfig, StreamChunk
from ...adapters.base import ContextOverflowError, ModelAdapter, ModelConfig, StreamChunk
from ...config import settings
from ...context.engine import ContextEngine
from ..cost import resolve_context_window
from ...mcp.manager import MCPManager
from ...memory.store import MemoryStore
from ...models.agent import AgentProfile
@@ -19,6 +20,9 @@ from ...models.artifacts import ArtifactSummary
from ...models.session import SessionState
from ...models.tools import ToolExecution, ToolExecutionStatus
from ...streaming.sse import SSEEmitter, EventType
from ..planner import run_planner_subloop
from ..plan_judge import judge_plan_progress
from ..tool_groups import is_plan_internal_tool, strip_namespace
logger = logging.getLogger(__name__)
@@ -64,19 +68,53 @@ class BaseAgent:
total_output_tokens = 0
# Real conversation history: assistant messages + tool results
conversation: list[dict[str, Any]] = []
# Expuesta para que las tools internas (acai_plan) puedan resumir
# el thinking acumulado del agente principal sin que tengamos que
# pasarlo explicitamente por cada llamada a `_execute_tool`.
self._current_conversation = conversation
for step in range(max_steps):
# Build context with real conversation
ctx = await self.context.build_context(
session=session,
agent=self.profile,
artifacts=artifacts,
conversation=conversation,
# Build context with real conversation. El budget se deriva de la
# ventana REAL del modelo activo; si el contexto estimado no cabe ni
# tras compactar, reconstruimos con compactación más agresiva antes
# de llamar al LLM (evita una llamada condenada a fallar). Si ni así
# cabe → ContextOverflowError → error accionable (no rompe la sesión).
model_id = self.profile.model_id or ""
model_window = (
await resolve_context_window(model_id) if model_id else None
)
ctx = None
budget_override: int | None = None
for ctx_attempt in range(3): # intento normal + 2 compactaciones agresivas
ctx = await self.context.build_context(
session=session,
agent=self.profile,
artifacts=artifacts,
conversation=conversation,
model_id=model_id,
budget_override=budget_override,
)
if not model_window or ctx.total_token_estimate <= model_window:
break
# No cabe: compactar al 60% del budget usado en el siguiente intento.
base = ctx.budget_tokens or settings.effective_context_budget
budget_override = max(2048, int(base * 0.6))
else:
raise ContextOverflowError(
"El contexto ({} tokens) supera la ventana del modelo {} ({} "
"tokens). Acorta el mensaje o cambia a un modelo con más "
"contexto.".format(
ctx.total_token_estimate if ctx else "?",
model_id or "(desconocido)",
model_window,
)
)
# Prepare tool definitions
# Prepare tool definitions. plan_mode "off" oculta acai_plan al
# modelo (toggle del UI desactivado). "force" la expone normalmente.
tool_defs = self._get_allowed_tools(
followup_mode=str(session.metadata.get("followup_mode", "none")),
plan_mode=str(session.metadata.get("plan_mode", "off") or "off"),
)
# Stream model response
@@ -84,11 +122,24 @@ class BaseAgent:
model_id=self.profile.model_id or "",
max_tokens=self.profile.max_tokens or 4096,
temperature=self.profile.temperature or 0.3,
reasoning_effort=self.profile.reasoning_effort or "",
)
# Snapshot del numero de tool_executions ya acumulados ANTES del
# step. El judge solo necesita las del step actual; el slice
# `tool_executions[exec_offset:]` da exactamente ese delta.
exec_offset = len(tool_executions)
full_text = ""
tool_calls: list[dict[str, Any]] = []
active_tools: dict[str, dict[str, Any]] = {}
# Acumuladores Anthropic-style por turno (interleaved thinking M2).
# Por cada block_index guardamos un dict block parcial. Al cerrar el
# turno, lo serializamos en orden.
turn_blocks_by_index: dict[int, dict[str, Any]] = {}
# Cuando text_delta llega sin block_index (p.ej. via OpenAI adapter
# legacy), asignamos un sintetico para no perder el texto.
synthetic_text_idx = 10_000
async for chunk in self.model.stream(
messages=ctx.to_messages(),
@@ -97,6 +148,16 @@ class BaseAgent:
):
if chunk.delta:
full_text += chunk.delta
# Acumular por block_index para reconstruir blocks.
idx = chunk.block_index
if idx < 0:
idx = synthetic_text_idx
blk = turn_blocks_by_index.get(idx)
if blk is None:
blk = {"type": "text", "text": ""}
turn_blocks_by_index[idx] = blk
if blk.get("type") == "text":
blk["text"] = blk.get("text", "") + chunk.delta
if self.profile.stream_deltas:
await self.sse.emit(
EventType.AGENT_DELTA,
@@ -108,12 +169,42 @@ class BaseAgent:
session_id=session.session_id,
)
# Thinking deltas (MiniMax M2 interleaved). El adapter ya viene
# con block_index correcto; solo acumulamos.
if chunk.thinking_delta and chunk.block_index >= 0:
blk = turn_blocks_by_index.get(chunk.block_index)
if blk is None:
blk = {"type": "thinking", "thinking": "", "signature": ""}
turn_blocks_by_index[chunk.block_index] = blk
if blk.get("type") == "thinking":
blk["thinking"] = blk.get("thinking", "") + chunk.thinking_delta
if self.profile.stream_deltas:
await self.sse.emit(
EventType.AGENT_DELTA,
{
"agent": self.profile.role,
"thinking_delta": chunk.thinking_delta,
"block_index": chunk.block_index,
"step": step,
},
session_id=session.session_id,
)
if chunk.thinking_signature and chunk.block_index >= 0:
blk = turn_blocks_by_index.get(chunk.block_index)
if blk is None:
blk = {"type": "thinking", "thinking": "", "signature": ""}
turn_blocks_by_index[chunk.block_index] = blk
if blk.get("type") == "thinking":
blk["signature"] = chunk.thinking_signature
if chunk.tool_name and chunk.tool_call_id:
if chunk.tool_call_id not in active_tools:
active_tools[chunk.tool_call_id] = {
"id": chunk.tool_call_id,
"name": chunk.tool_name,
"arguments": "",
"block_index": chunk.block_index,
}
await self.sse.emit(
EventType.TOOL_STARTED,
@@ -144,6 +235,7 @@ class BaseAgent:
"id": chunk.tool_call_id,
"name": chunk.tool_name or "",
"arguments": "",
"block_index": chunk.block_index,
}
final_args = tool["arguments"] or chunk.tool_arguments or ""
try:
@@ -168,6 +260,16 @@ class BaseAgent:
tool["parsed_arguments"] = args
tool_calls.append(tool)
# Registrar tool_use block en su posicion del turno.
bidx = tool.get("block_index", -1)
if bidx >= 0:
turn_blocks_by_index[bidx] = {
"type": "tool_use",
"id": tool["id"],
"name": tool["name"],
"input": args,
}
# Accumulate token usage from any chunk that has it
if chunk.usage:
total_input_tokens += chunk.usage.get("input_tokens", 0)
@@ -178,39 +280,120 @@ class BaseAgent:
accumulated_content += full_text
# Materializar blocks del turno en orden por block_index.
# Filtra thinking blocks sin signature: MiniMax los rechazaria al
# reenviarlos. Mejor descartar el thinking entero que mandar uno
# corrupto y ver un 400.
turn_blocks: list[dict[str, Any]] = []
for idx in sorted(turn_blocks_by_index.keys()):
b = turn_blocks_by_index[idx]
if b.get("type") == "thinking":
if not b.get("signature"):
logger.warning(
"Drop thinking block at idx=%d (no signature) — chars=%d",
idx, len(b.get("thinking", "")),
)
continue
# Limpiar texto vacio defensivo.
if not b.get("thinking"):
continue
turn_blocks.append(b)
# Backstop: garantizar que CADA tool_call tenga su tool_use block
# en turn_blocks. Si no lo tiene (chunks sin block_index, adapter
# legacy, etc.), apendearlo al final. Sin esto, MiniMax devuelve
# 400 ("tool result's tool id not found") en el siguiente request.
tool_use_ids_in_blocks = {
b.get("id") for b in turn_blocks
if b.get("type") == "tool_use" and b.get("id")
}
for tc in tool_calls:
if tc["id"] not in tool_use_ids_in_blocks:
turn_blocks.append({
"type": "tool_use",
"id": tc["id"],
"name": tc["name"],
"input": tc.get("parsed_arguments", {}),
})
tool_use_ids_in_blocks.add(tc["id"])
# If no tool calls, we're done
if not tool_calls:
# Add final assistant message to conversation
if full_text:
# 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:
conversation.append({"role": "assistant", "content": turn_blocks})
elif full_text:
# Fallback (no debiera ocurrir si el adapter emite block_index).
conversation.append({"role": "assistant", "content": full_text})
# El agente termino sin mas tool calls: cerramos el plan si
# estaba activo. El judge no se llama (no hay tools que evaluar);
# el flag `no_tool_calls_this_step=True` marca todos los pendientes
# como completados.
try:
await self._auto_advance_plan_cursor(
session,
[],
no_tool_calls_this_step=True,
)
except Exception as e:
logger.warning("[plan-advance] failed at end_turn: %s", e)
break
# Add assistant message with tool calls to conversation
# (OpenAI format: assistant message carries tool_calls)
assistant_msg: dict[str, Any] = {"role": "assistant"}
if full_text:
assistant_msg["content"] = full_text
assistant_msg["tool_calls"] = [
{
"id": tc["id"],
"type": "function",
"function": {
"name": tc["name"],
"arguments": json.dumps(tc.get("parsed_arguments", {})),
},
}
for tc in tool_calls
]
conversation.append(assistant_msg)
# Push del assistant turn con TODOS los blocks (thinking+text+tool_use).
# Esto preserva la cadena de razonamiento de M2 entre turnos.
if turn_blocks:
conversation.append({"role": "assistant", "content": turn_blocks})
else:
# Fallback OpenAI-style si no hay blocks (modelo legacy o sin
# block_index). Mantenemos compat con OpenAIAdapter / cualquier
# adapter que no propague block_index.
assistant_msg: dict[str, Any] = {"role": "assistant"}
if full_text:
assistant_msg["content"] = full_text
assistant_msg["tool_calls"] = [
{
"id": tc["id"],
"type": "function",
"function": {
"name": tc["name"],
"arguments": json.dumps(tc.get("parsed_arguments", {})),
},
}
for tc in tool_calls
]
conversation.append(assistant_msg)
# Execute tool calls and add COMPLETE results to conversation.
# Antes habia dos capas anti-duplicado: (a) cachear resultado y
# devolver "[DUPLICADO]" en lugar de re-ejecutar y (b) cortar el
# step si TODAS las llamadas del paso eran duplicadas. Las quitamos
# porque en conversaciones largas el agente puede LEGITIMAMENTE
# repetir una llamada (p.ej. re-leer un fichero tras editarlo) y
# las heuristicas bloqueaban acciones validas. El usuario prefiere
# libertad — runaway loops se mitigan con limit de steps externo.
# Execute tool calls. Los results se agrupan en UN solo user message
# con array de tool_result blocks (formato Anthropic). Anteriormente
# se hacian N appends `{"role":"tool",...}` en formato OpenAI.
tool_result_blocks: list[dict[str, Any]] = []
for tc in tool_calls:
# Si los args no se pudieron parsear (p.ej. truncados por max_tokens),
# NO ejecutamos la tool. En su lugar devolvemos un mensaje al modelo
@@ -218,9 +401,9 @@ class BaseAgent:
# (dividir el contenido, acortar, etc.).
if tc.get("parse_error"):
pe = tc["parse_error"]
conversation.append({
"role": "tool",
"tool_call_id": tc["id"],
tool_result_blocks.append({
"type": "tool_result",
"tool_use_id": tc["id"],
"content": (
f"[ERROR] No se pudieron parsear los argumentos del tool "
f"'{tc['name']}'. Los argumentos llegaron truncados o mal "
@@ -230,6 +413,7 @@ class BaseAgent:
f"Reintenta dividiendo el contenido en varios tool calls mas "
f"pequenos o reduciendo el tamano del argumento 'content'."
),
"is_error": True,
})
continue
@@ -242,10 +426,9 @@ class BaseAgent:
)
tool_executions.append(tool_exec)
# COMPLETE result in conversation (truncated to safe limit)
conversation.append({
"role": "tool",
"tool_call_id": tc["id"],
tool_result_blocks.append({
"type": "tool_result",
"tool_use_id": tc["id"],
"content": (
tool_exec.raw_output[:settings.tool_raw_output_max_chars]
if tool_exec.raw_output
@@ -253,6 +436,20 @@ class BaseAgent:
),
})
if tool_result_blocks:
conversation.append({"role": "user", "content": tool_result_blocks})
# Auto-avance del cursor del plan TRAS CADA STEP INTERNO (no solo
# al final del turno). Asi el frontend ve los `✓` aparecer en vivo
# conforme el agente ejecuta tools, no de golpe al final.
try:
await self._auto_advance_plan_cursor(
session,
tool_executions[exec_offset:],
)
except Exception as e:
logger.warning("Auto-advance plan cursor failed: %s", e)
return {
"content": accumulated_content,
"artifacts": artifacts,
@@ -283,11 +480,36 @@ class BaseAgent:
logger.info("Tool call: %s(%s)", tool_name, json.dumps(arguments)[:200])
# Intercepcion: tools internas del orquestador (Fase 5: acai_plan).
# No atraviesan MCP — se ejecutan en Python directamente.
if is_plan_internal_tool(tool_name):
raw_name = strip_namespace(tool_name)
await self.sse.emit(
EventType.TOOL_STARTED,
{"tool": raw_name, "tool_call_id": tool_call_id},
session_id=session.session_id,
)
if raw_name == "acai_plan":
return await self._execute_acai_plan(session, arguments, tool_call_id, tool_exec)
if raw_name == "acai_plan_advance":
return await self._execute_acai_plan_advance(session, arguments, tool_call_id, tool_exec)
start = time.monotonic()
try:
if self.mcp.is_running and tool_name in self.mcp.tools:
result = await self.mcp.call_tool(tool_name, arguments)
raw_output = self._extract_mcp_output(result)
if self.mcp.is_running:
# Intentar llamada directa: call_tool tiene fallback bare-name
# via _resolve_tool, asi que aunque venga sin prefijo
# `acai_code__` (caso comun cuando el modelo emite XML inline)
# se resuelve solo. El check `tool_name in self.mcp.tools` que
# haciamos antes era demasiado estricto y rechazaba bare names.
try:
result = await self.mcp.call_tool(tool_name, arguments)
raw_output = self._extract_mcp_output(result)
except Exception as resolve_err:
raw_output = (
f"Tool '{tool_name}' no disponible o fallo al resolver: "
f"{str(resolve_err)[:200]}"
)
else:
raw_output = f"Tool '{tool_name}' not available via MCP."
@@ -337,25 +559,560 @@ class BaseAgent:
return tool_exec
def _get_allowed_tools(self, followup_mode: str = "none") -> list[dict[str, Any]]:
"""Return tool definitions filtered by this agent's allowed_tools."""
# ---- Tools internas del orquestador (Fase 5) -----------------------------
@staticmethod
def _summarize_parent_thinking(conversation: list[dict[str, Any]], max_chars: int = 1200) -> str:
"""Resumen del thinking acumulado del agente principal hasta este turno.
Recorre los assistants Anthropic-style con content blocks `type=thinking`,
junta los textos y trunca a `max_chars`. Se usa para pasar contexto
comprimido al planner sub-loop sin contaminarlo con el thinking entero.
"""
chunks: list[str] = []
total = 0
for msg in reversed(conversation):
if msg.get("role") != "assistant":
continue
content = msg.get("content")
if not isinstance(content, list):
continue
for block in content:
if isinstance(block, dict) and block.get("type") == "thinking":
txt = block.get("thinking", "") or ""
if not txt:
continue
chunks.append(txt)
total += len(txt)
if total >= max_chars:
break
if total >= max_chars:
break
# Concatenamos del mas viejo al mas reciente para mantener orden logico.
joined = "\n---\n".join(reversed(chunks))
if len(joined) > max_chars:
joined = "[...] " + joined[-max_chars:]
return joined
async def _execute_acai_plan(
self,
session: SessionState,
arguments: dict[str, Any],
tool_call_id: str,
tool_exec: ToolExecution,
) -> ToolExecution:
"""Implementacion de la tool sintetica `acai_plan`.
Lanza un sub-loop con `system.planner.md` y solo tools de lectura.
Persiste el plan resultante en `session.metadata["current_plan"]`.
"""
# Limite de invocaciones por turno: maximo 2. Tras eso, el modelo debe
# ejecutar directo o abandonar.
count = int(session.metadata.get("plan_call_count_in_turn", 0))
if count >= 2:
tool_exec.status = ToolExecutionStatus.COMPLETED
tool_exec.result_summary = (
"Ya invocaste acai_plan dos veces este turno. "
"Ejecuta directo o usa acai_plan_advance({abandon:true}) para resetear."
)
tool_exec.raw_output = json.dumps({"error": "max_plan_calls_per_turn"})
await self.sse.emit(
EventType.TOOL_COMPLETED,
{"tool": "acai_plan", "status": "completed", "summary": tool_exec.result_summary, "tool_call_id": tool_call_id},
session_id=session.session_id,
)
return tool_exec
session.metadata["plan_call_count_in_turn"] = count + 1
objective = str(arguments.get("objective") or "").strip()
scope = str(arguments.get("scope") or "").strip()
if not objective:
tool_exec.status = ToolExecutionStatus.FAILED
tool_exec.error = "Falta el campo 'objective'"
tool_exec.result_summary = "acai_plan FALLO: falta objective."
tool_exec.raw_output = json.dumps({"error": "missing_objective"})
await self.sse.emit(
EventType.TOOL_COMPLETED,
{"tool": "acai_plan", "status": "failed", "error": tool_exec.error, "tool_call_id": tool_call_id},
session_id=session.session_id,
)
return tool_exec
# Resumen del thinking acumulado en el turno actual (si lo hay).
# `self._current_conversation` se setea al inicio de execute() — ver mas abajo.
parent_summary = self._summarize_parent_thinking(
getattr(self, "_current_conversation", []) or [],
)
start = time.monotonic()
try:
result = await run_planner_subloop(
objective=objective,
scope=scope,
agent_profile=self.profile,
model_adapter=self.model,
mcp=self.mcp,
parent_thinking_summary=parent_summary,
)
except Exception as e:
logger.error("Planner sub-loop crashed: %s", e)
tool_exec.status = ToolExecutionStatus.FAILED
tool_exec.error = str(e)
tool_exec.duration_ms = (time.monotonic() - start) * 1000
tool_exec.result_summary = f"acai_plan FALLO: {str(e)[:200]}"
tool_exec.raw_output = json.dumps({"error": str(e)[:500]})
await self.sse.emit(
EventType.TOOL_COMPLETED,
{"tool": "acai_plan", "status": "failed", "error": str(e), "tool_call_id": tool_call_id},
session_id=session.session_id,
)
return tool_exec
tool_exec.duration_ms = (time.monotonic() - start) * 1000
if not result.plan:
err = result.error or "Plan vacio"
logger.warning(
"[acai_plan] Plan FAILED: %s (raw_preview=%r)",
err, (result.raw_text or "")[:200],
)
tool_exec.status = ToolExecutionStatus.FAILED
tool_exec.error = err
tool_exec.result_summary = (
f"acai_plan FALLO: {err}. Procede en modo directo o reintenta con scope distinto."
)
tool_exec.raw_output = json.dumps({
"error": err,
"raw_text_preview": (result.raw_text or "")[:500],
})
await self.sse.emit(
EventType.TOOL_COMPLETED,
{"tool": "acai_plan", "status": "failed", "error": err, "tool_call_id": tool_call_id},
session_id=session.session_id,
)
return tool_exec
# Plan valido: persistir en metadata. Si habia un plan activo previo,
# moverlo a history como `superseded`.
old_plan = session.metadata.get("current_plan")
if old_plan and old_plan.get("status") == "active":
old_plan["status"] = "superseded"
session.metadata.setdefault("plan_history", []).append(old_plan)
plan = dict(result.plan)
plan["cursor"] = 0
plan["completed_step_ids"] = []
plan["status"] = "active"
plan["created_at"] = int(time.time())
session.metadata["current_plan"] = plan
steps = plan.get("steps") or []
next_desc = steps[0]["description"] if steps else "(plan vacio)"
n_steps = len(steps)
n_risks = len(plan.get("risks") or [])
tool_exec.status = ToolExecutionStatus.COMPLETED
tool_exec.result_summary = (
f"Plan generado: {n_steps} step(s), {n_risks} risk(s). "
f"Proximo: step 1 — {next_desc[:200]}"
)
logger.info(
"[acai_plan] Plan persisted: %d steps, %d risks, objective=%r",
n_steps, n_risks, objective[:120],
)
# raw_output al modelo: el JSON completo del plan (truncado a 4000 chars).
plan_json = json.dumps(plan, ensure_ascii=False)
if len(plan_json) > 4000:
tool_exec.raw_output = plan_json[:4000] + "\n[...truncated]"
else:
tool_exec.raw_output = plan_json
await self.sse.emit(
EventType.TOOL_COMPLETED,
{
"tool": "acai_plan",
"status": "completed",
"summary": tool_exec.result_summary[:200],
"raw_output": tool_exec.raw_output[:4000],
"tool_call_id": tool_call_id,
},
session_id=session.session_id,
)
# PlanStepper UI: notifica al frontend que hay un plan nuevo activo.
await self.sse.emit(
EventType.PLAN_CREATED,
{
"objective": plan.get("objective", ""),
"steps": [
{
"id": s.get("id"),
"description": s.get("description", "")[:300],
"agent_action": s.get("agent_action", "")[:200],
"files_touched": s.get("files_touched", [])[:10],
"tables_touched": s.get("tables_touched", [])[:10],
}
for s in plan.get("steps", [])
],
"risks": plan.get("risks", [])[:10],
"cursor": plan.get("cursor", 0),
"completed_step_ids": plan.get("completed_step_ids", []),
"status": plan.get("status", "active"),
},
session_id=session.session_id,
)
return tool_exec
async def _execute_acai_plan_advance(
self,
session: SessionState,
arguments: dict[str, Any],
tool_call_id: str,
tool_exec: ToolExecution,
) -> ToolExecution:
"""Avanza/abandona el plan activo."""
plan = session.metadata.get("current_plan")
if not plan or plan.get("status") != "active":
tool_exec.status = ToolExecutionStatus.COMPLETED
tool_exec.result_summary = "No hay plan activo."
tool_exec.raw_output = json.dumps({"status": "no_active_plan"})
await self.sse.emit(
EventType.TOOL_COMPLETED,
{"tool": "acai_plan_advance", "status": "completed", "summary": tool_exec.result_summary, "tool_call_id": tool_call_id},
session_id=session.session_id,
)
return tool_exec
if arguments.get("abandon"):
plan["status"] = "abandoned"
session.metadata.setdefault("plan_history", []).append(plan)
session.metadata["current_plan"] = None
tool_exec.status = ToolExecutionStatus.COMPLETED
tool_exec.result_summary = "Plan abandonado."
tool_exec.raw_output = json.dumps({"status": "abandoned"})
await self.sse.emit(
EventType.TOOL_COMPLETED,
{"tool": "acai_plan_advance", "status": "completed", "summary": tool_exec.result_summary, "tool_call_id": tool_call_id},
session_id=session.session_id,
)
await self.sse.emit(
EventType.PLAN_ENDED,
{"status": "abandoned", "objective": plan.get("objective", "")},
session_id=session.session_id,
)
return tool_exec
# Aplicar completed_ids
completed_in = arguments.get("completed_ids") or []
completed_set = set(plan.get("completed_step_ids", []))
for cid in completed_in:
if isinstance(cid, int) and cid not in completed_set:
plan.setdefault("completed_step_ids", []).append(cid)
completed_set.add(cid)
# Aplicar cursor
steps = plan.get("steps") or []
if "next_cursor" in arguments:
plan["cursor"] = max(0, min(int(arguments["next_cursor"]), len(steps)))
else:
# Auto-avanzar al primer step no completado.
for i, st in enumerate(steps):
if st.get("id") not in completed_set:
plan["cursor"] = i
break
else:
plan["status"] = "done"
cursor = plan.get("cursor", 0)
if plan.get("status") == "done" or cursor >= len(steps):
tool_exec.result_summary = f"Plan completado ({len(completed_set)}/{len(steps)} steps)."
else:
next_desc = steps[cursor].get("description", "(?)") if cursor < len(steps) else "(?)"
tool_exec.result_summary = (
f"Plan avanzado a step {cursor + 1}/{len(steps)}: {next_desc[:200]}"
)
tool_exec.status = ToolExecutionStatus.COMPLETED
tool_exec.raw_output = json.dumps({
"cursor": plan.get("cursor", 0),
"completed_step_ids": plan.get("completed_step_ids", []),
"status": plan.get("status", "active"),
})
await self.sse.emit(
EventType.TOOL_COMPLETED,
{"tool": "acai_plan_advance", "status": "completed", "summary": tool_exec.result_summary, "tool_call_id": tool_call_id},
session_id=session.session_id,
)
# Emitir PLAN_ADVANCED o PLAN_ENDED segun el resultado.
if plan.get("status") == "done":
await self.sse.emit(
EventType.PLAN_ENDED,
{"status": "done", "objective": plan.get("objective", "")},
session_id=session.session_id,
)
else:
await self.sse.emit(
EventType.PLAN_ADVANCED,
{
"cursor": plan.get("cursor", 0),
"completed_step_ids": plan.get("completed_step_ids", []),
"status": plan.get("status", "active"),
},
session_id=session.session_id,
)
return tool_exec
@staticmethod
def _match_step_to_executions(
step: dict[str, Any],
tool_executions: list[ToolExecution],
) -> bool:
"""Heuristica: matchea step.agent_action con tool calls reales.
Marca el step como completado si alguna de las tools ejecutadas
coincide con el `agent_action` del step. Compara:
1) nombre de la tool (normalizando guion/underscore: `acai-write`
matchea con `acai_write`).
2) si action menciona algun `files_touched` y la tool ejecutada
tiene ese path en sus argumentos.
3) si action menciona algun `tables_touched` y la tool ejecutada
tiene ese tableName en sus argumentos.
"""
action = (step.get("agent_action") or "").lower()
files_touched = [str(f).lower() for f in (step.get("files_touched") or [])]
tables_touched = [str(t).lower() for t in (step.get("tables_touched") or [])]
if not action and not files_touched and not tables_touched:
return False
for te in tool_executions:
if te.status != ToolExecutionStatus.COMPLETED:
continue
raw_name = strip_namespace(te.tool_name).lower()
# Normaliza guiones/underscores para matching tool name <-> action.
tool_variants = {raw_name, raw_name.replace("-", "_"), raw_name.replace("_", "-")}
# Match 1: nombre de la tool aparece en action
if any(v and v in action for v in tool_variants):
return True
# Match 2/3: path o tableName en los args de la tool
try:
args_str = json.dumps(te.arguments or {}, ensure_ascii=False).lower()
except Exception:
args_str = str(te.arguments or "").lower()
for f in files_touched:
if f and f in args_str:
return True
for t in tables_touched:
if t and t in args_str:
return True
return False
async def _auto_advance_plan_cursor(
self,
session: SessionState,
tool_executions_this_step: list[ToolExecution],
no_tool_calls_this_step: bool = False,
) -> None:
"""Avanza el cursor del plan tras un step interno del agente.
Usa LLM-as-judge (`plan_judge.judge_plan_progress`) para decidir que
steps del plan se acaban de completar con las tool_executions del step
actual. Mas robusto que el matching string heuristico anterior.
Si `no_tool_calls_this_step=True` y hay un plan active, marcamos el plan
como `done` — el agente decidio terminar (end_turn) sin mas tools, asi
que confiamos en su criterio. Esto cierra el plan visualmente cuando el
agente acaba.
"""
plan = session.metadata.get("current_plan")
if not plan or plan.get("status") != "active":
return
steps = plan.get("steps") or []
prev_cursor = int(plan.get("cursor", 0))
prev_completed = list(plan.get("completed_step_ids", []))
completed_set = set(prev_completed)
rationale = ""
# Si el agente termino el turn sin tools, NO marcamos los pendientes
# como completados — seria un falso positivo (caso real: agente se
# queda atascado y devuelve mensaje de chat sin haber hecho la tarea).
# Solo si el `completed_set` previo ya cubre todos los steps cerramos
# como done; si quedan pendientes, dejamos `active`.
if no_tool_calls_this_step:
if steps and len(completed_set) >= len(steps):
rationale = "agente termino el turn; todos los steps ya completados"
else:
rationale = "agente termino el turn con steps pendientes (no cerrado)"
# No tocar completed_set: respetamos lo que el judge dijo en steps previos
elif tool_executions_this_step:
# Pregunta al judge que steps acaba de completar.
try:
completed_ids, judge_rationale = await judge_plan_progress(
plan=plan,
tool_executions_this_step=tool_executions_this_step,
model_adapter=self.model,
model_id=self.profile.model_id,
)
for cid in completed_ids:
completed_set.add(cid)
rationale = judge_rationale
except Exception as e:
logger.warning("[plan-judge] failed, no advance this step: %s", e)
# Sin judge, no avanzamos el cursor — preferimos dejar el plan
# como esta antes que falsos positivos heuristicos.
return
# Cursor: primer step NO completado. Si todos completados → done.
cursor = len(steps)
for i, step in enumerate(steps):
if step.get("id") not in completed_set:
cursor = i
break
plan["cursor"] = cursor
plan["completed_step_ids"] = sorted(completed_set)
ended = False
if cursor >= len(steps) and steps:
plan["status"] = "done"
ended = True
# Solo emitimos si hubo cambio real.
changed = cursor != prev_cursor or set(plan["completed_step_ids"]) != set(prev_completed)
logger.info(
"[plan-advance] tools_in_step=%d prev_cursor=%d new_cursor=%d completed=%s changed=%s rationale=%r",
len(tool_executions_this_step), prev_cursor, cursor,
plan["completed_step_ids"], changed, rationale[:160],
)
if not changed:
return
try:
if ended:
await self.sse.emit(
EventType.PLAN_ENDED,
{"status": "done", "objective": plan.get("objective", "")},
session_id=session.session_id,
)
else:
await self.sse.emit(
EventType.PLAN_ADVANCED,
{
"cursor": plan["cursor"],
"completed_step_ids": plan["completed_step_ids"],
"status": plan.get("status", "active"),
},
session_id=session.session_id,
)
except Exception as e:
logger.warning("PLAN_ADVANCED/ENDED emit failed: %s", e)
# ---- Allowed tools --------------------------------------------------------
def _get_allowed_tools(
self,
followup_mode: str = "none",
plan_mode: str = "force",
) -> list[dict[str, Any]]:
"""Return tool definitions filtered by this agent's allowed_tools.
Si el agente tiene `has_planner_tool=True` Y `plan_mode == "force"`,
anade definiciones sinteticas de `acai_plan` y `acai_plan_advance`
(la tool interna no atraviesa MCP — se intercepta en `_execute_tool`).
Cuando `plan_mode != "force"` (toggle del UI desactivado), las tools
del planner NO se exponen y el agente ejecuta directo.
"""
if followup_mode == "transform":
return []
if not self.mcp.is_running:
return []
all_tools = self.mcp.get_tool_definitions()
if not self.profile.allowed_tools:
return all_tools # No filter → all tools
return [t for t in all_tools if t["name"] in self.profile.allowed_tools]
if self.profile.allowed_tools:
tool_defs = [t for t in all_tools if t["name"] in self.profile.allowed_tools]
else:
tool_defs = list(all_tools)
if self.profile.has_planner_tool and plan_mode == "force":
tool_defs.append({
"name": "acai_plan",
"description": (
"Genera un plan estructurado de ejecucion. Usa esta tool al recibir "
"una peticion compuesta (landing entera, tienda, refactor amplio, modulo "
"con tabla+hook+frontend). NO la uses para tareas triviales (cambiar un titulo, "
"ajustar un color, leer datos). Devuelve JSON con steps, risks, files_touched, "
"tables_touched."
),
"input_schema": {
"type": "object",
"required": ["objective"],
"properties": {
"objective": {
"type": "string",
"description": "Descripcion en español de lo que hay que conseguir.",
},
"scope": {
"type": "string",
"description": "Restricciones opcionales (ej. 'no toques el header').",
},
},
},
})
tool_defs.append({
"name": "acai_plan_advance",
"description": (
"Avanza/abandona el plan activo. Llama con `abandon: true` si el "
"usuario corrige y el plan ya no es valido, o con `next_cursor` para "
"saltar al siguiente step pendiente."
),
"input_schema": {
"type": "object",
"properties": {
"abandon": {"type": "boolean"},
"completed_ids": {"type": "array", "items": {"type": "integer"}},
"next_cursor": {"type": "integer"},
},
},
})
return tool_defs
@staticmethod
def _extract_mcp_output(result: dict[str, Any]) -> str:
"""Extract text content from MCP tool result."""
"""Extract text content from MCP tool result.
El modelo (MiniMax M2.7) es text-only — los blocks `type=image` no
pueden reenviarse. En lugar de descartar silenciosamente (lo que dejaba
al agente con un tool_result vacio y le hacia repetir la llamada),
emitimos un placeholder explicito que le dice que use `browser_snapshot`
si quiere inspeccionar la pagina.
"""
content = result.get("content", [])
if isinstance(content, list):
parts: list[str] = []
image_count = 0
for item in content:
if isinstance(item, dict) and item.get("type") == "text":
if not isinstance(item, dict):
continue
itype = item.get("type")
if itype == "text":
parts.append(item.get("text", ""))
elif itype == "image":
image_count += 1
if image_count and not parts:
return (
f"[{image_count} imagen(es) no procesada(s) — el modelo es "
f"text-only. Para inspeccionar la pagina usa "
f"`browser_snapshot` (devuelve accessibility tree en texto). "
f"`browser_take_screenshot` solo sirve para que el usuario "
f"vea la captura, no para tu analisis.]"
)
if image_count and parts:
parts.append(
f"\n[Adicionalmente {image_count} imagen(es) no incluida(s): "
f"el modelo no las procesa.]"
)
return "\n".join(parts) if parts else json.dumps(result)
return str(content)

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
from typing import Any
from ..adapters.base import ModelAdapter
from ..adapters.base import ContextOverflowError, ModelAdapter
from ..config import settings
from ..context.engine import ContextEngine
from ..context.compactor import estimate_tokens
from ..context.compactor import ContextCompactor, estimate_tokens
from ..mcp.manager import MCPManager
from ..memory.store import MemoryStore
from ..models.agent import AgentProfile
@@ -52,11 +52,16 @@ class OrchestratorEngine:
self,
session: SessionState,
message: str,
image_attachments: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Process a user message. Single agent execution with timeout."""
"""Process a user message. Single agent execution with timeout.
`image_attachments`: bloques image_url (visión nativa) para el turno del
usuario, cuando el modelo activo es multimodal.
"""
try:
return await asyncio.wait_for(
self._run(session, message),
self._run(session, message, image_attachments),
timeout=settings.max_execution_timeout_seconds,
)
except asyncio.TimeoutError:
@@ -70,6 +75,20 @@ class OrchestratorEngine:
session_id=session.session_id,
)
return self._error_result(session, "Execution timed out")
except ContextOverflowError as e:
# El contexto no cabe en la ventana del modelo ni tras compactar al
# máximo. Mensaje accionable (no fallo genérico de plataforma): el
# usuario sabe qué hacer (acortar o cambiar de modelo).
logger.warning("Context overflow for session %s: %s", session.session_id, e)
if session.current_task:
session.current_task.mark_failed(str(e))
session.status = SessionStatus.ERROR
await self.sse.emit(
EventType.ERROR,
{"error": "context_overflow", "message": str(e)},
session_id=session.session_id,
)
return self._error_result(session, str(e))
except Exception as e:
logger.exception("Unhandled error for session %s", session.session_id)
if session.current_task:
@@ -86,6 +105,7 @@ class OrchestratorEngine:
self,
session: SessionState,
message: str,
image_attachments: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Execute: message → agent → response."""
@@ -99,10 +119,27 @@ class OrchestratorEngine:
session_id=session.session_id,
)
# Create task
task = session.begin_task(objective=message)
# Plan mode 'force': el usuario ha pulsado el toggle Plan en el chat.
# Prependeamos una directiva al mensaje para que el agente llame
# acai_plan ANTES de ejecutar nada. El system prompt ya conoce la tool;
# esto solo bypassa la heuristica trivial-vs-complex.
plan_mode = (session.metadata.get("plan_mode") or "auto").lower()
if plan_mode == "force":
message = (
"[modo Plan activo por el usuario] Tu PRIMERA accion debe ser "
"llamar a la tool `acai_plan` con un plan detallado del trabajo "
"que vas a hacer. No ejecutes ninguna otra tool antes. Despues "
"del plan, procede con la ejecucion normal.\n\n"
f"Peticion del usuario:\n{message}"
)
# Create task (con imágenes adjuntas si las hay — visión nativa)
task = session.begin_task(objective=message, image_attachments=image_attachments)
task.status = TaskStatus.EXECUTING
# Reset del contador de invocaciones de `acai_plan` por turno (Fase 5).
session.metadata["plan_call_count_in_turn"] = 0
# Execute with the selected agent
agent = BaseAgent(
profile=self.agent_profile,
@@ -137,6 +174,9 @@ class OrchestratorEngine:
session.recent_messages,
message=message,
conversation=result.get("conversation", []),
image_attachments=(
session.current_task.image_attachments if session.current_task else None
),
)
session.task_history.append(
@@ -165,13 +205,18 @@ class OrchestratorEngine:
task.status = TaskStatus.COMPLETED
session.complete_task()
# Calculate cost
# Calculate cost — por modelo realmente usado (Fase 2). El model_id
# efectivo vive en el agent_profile (resuelto en send_message).
total_input = usage.get("input_tokens", 0)
total_output = usage.get("output_tokens", 0)
cost_usd = (
(total_input / 1_000_000) * settings.cost_per_1m_input
+ (total_output / 1_000_000) * settings.cost_per_1m_output
model_used = (
self.agent_profile.model_id
or settings.litellm_model
or settings.default_model_id
)
from .cost import compute_cost
cost_info = await compute_cost(model_used, total_input, total_output)
cost_usd = cost_info["cost_usd"]
await self.sse.emit(
EventType.EXECUTION_COMPLETED,
@@ -184,6 +229,19 @@ class OrchestratorEngine:
"status": "completed",
"usage": usage,
"total_cost_usd": round(cost_usd, 6),
# Modelo + tarifas usadas → se propagan a consumo_acaicode via
# _report_usage (columnas input_cost_1M / output_cost_1M).
"model": model_used,
"modelUsage": {
model_used: {
"inputTokens": total_input,
"outputTokens": total_output,
"costUSD": round(cost_usd, 6),
"inputCost1M": cost_info["input_cost_1m"],
"outputCost1M": cost_info["output_cost_1m"],
"reasoningEffort": self.agent_profile.reasoning_effort or "",
}
},
},
session_id=session.session_id,
)
@@ -209,6 +267,21 @@ class OrchestratorEngine:
"status": "completed",
"usage": usage,
"total_cost_usd": round(cost_usd, 6),
# Modelo + tarifas realmente usadas. Se incluyen tambien aqui (ademas
# del evento SSE EXECUTION_COMPLETED) para que el camino NO streaming
# (cronjobs -> _report_usage) reporte el modelo correcto a
# consumo_acaicode en vez de "unknown".
"model": model_used,
"modelUsage": {
model_used: {
"inputTokens": total_input,
"outputTokens": total_output,
"costUSD": round(cost_usd, 6),
"inputCost1M": cost_info["input_cost_1m"],
"outputCost1M": cost_info["output_cost_1m"],
"reasoningEffort": self.agent_profile.reasoning_effort or "",
}
},
}
def _error_result(self, session: SessionState, error: str) -> dict[str, Any]:
@@ -229,13 +302,21 @@ class OrchestratorEngine:
existing: list[dict[str, Any]],
message: str,
conversation: list[dict[str, Any]],
image_attachments: list[dict[str, Any]] | None = None,
) -> list[dict[str, Any]]:
merged = [OrchestratorEngine._sanitize_recent_message(m) for m in existing]
merged = [m for m in merged if m]
current_turn: list[dict[str, Any]] = []
if message.strip():
current_turn.append({"role": "user", "content": message})
if message.strip() or image_attachments:
if image_attachments:
# Guardar el turno con la imagen como bloques para que PERSISTA
# en el contexto de turnos siguientes (visión nativa multimodal).
content_blocks = [{"type": "text", "text": message}]
content_blocks.extend(image_attachments)
current_turn.append({"role": "user", "content": content_blocks})
else:
current_turn.append({"role": "user", "content": message})
for message_obj in conversation:
sanitized = OrchestratorEngine._sanitize_recent_message(message_obj)
@@ -243,7 +324,76 @@ class OrchestratorEngine:
current_turn.append(sanitized)
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
def _sanitize_recent_message(message: dict[str, Any]) -> dict[str, Any]:
@@ -253,7 +403,10 @@ class OrchestratorEngine:
sanitized: dict[str, Any] = {"role": role}
content = message.get("content", "")
if isinstance(content, str) and content:
# Anthropic-style content list (interleaved thinking) → preservar tal cual.
if isinstance(content, list) and content:
sanitized["content"] = content
elif isinstance(content, str) and content:
sanitized["content"] = content
if role == "assistant":

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

@@ -0,0 +1,206 @@
"""LLM-as-judge para tracking del progreso del plan.
Sustituye la heuristica string-matching de `_match_step_to_executions` por
una llamada al modelo que entiende semantica. Tras cada batch de tool calls
del agente principal, le preguntamos al judge "que steps acaba de completar"
con el plan + las tools como input. Devuelve JSON con `completed_ids`.
Diseno:
- Una sola llamada non-streaming, ~300 tokens output max.
- Solo evalua steps PENDIENTES (los ya completados no se envian — ahorra tokens).
- Falla en silencio si el modelo no devuelve JSON parseable. El caller decide
si caer al matcher heuristico o no avanzar el cursor.
"""
from __future__ import annotations
import json
import logging
import re
from typing import Any
from ..adapters.base import ModelAdapter, ModelConfig
from ..models.tools import ToolExecution, ToolExecutionStatus
from .tool_groups import strip_namespace
logger = logging.getLogger(__name__)
_SYSTEM_PROMPT = """\
Eres un revisor de progreso de un plan de ejecucion. Recibes:
1. El plan con sus steps PENDIENTES (id, description, agent_action, tables_touched, files_touched).
2. Las herramientas que el agente principal acaba de ejecutar en este step (nombre, args, success).
Tu unica salida es un objeto JSON con esta forma exacta:
{
"completed_ids": [1, 4],
"rationale": "una frase corta explicando por que"
}
Reglas:
- `completed_ids` contiene los IDs de los steps que han sido COMPLETAMENTE realizados por las tools ejecutadas en este step.
- Sé estricto: si un step requiere `create_or_update_record en builder_custom` y la tool ejecutada fue `create_or_update_record en apartados`, NO esta hecho.
- Si un step requiere `acai-write template/estandar/modulos/X/index-base.tpl` y la tool fue `acai-write` con un path distinto, NO esta hecho.
- Si un step menciona varias tools (ej. "create_or_update_record + add_module_to_record") solo lo marcas como done si TODAS las tools necesarias se ejecutaron.
- Si un step usa `ask_user` como agent_action, NUNCA lo marques como done — el agente debe preguntarle al usuario manualmente.
- Si dudas, NO incluyas el id. Mejor un falso negativo (que pase a otro step) que un falso positivo (que marque algo no hecho).
- Si ninguna tool corresponde a ningun step pendiente, devuelve `"completed_ids": []`.
- `rationale`: una frase concisa en español, max 200 chars.
Devuelve SOLO el JSON, sin texto alrededor."""
_FENCE_RE = re.compile(r"```(?:json)?\s*(\{.*?\})\s*```", re.DOTALL | re.IGNORECASE)
def _parse_judge_output(raw: str) -> dict[str, Any] | None:
"""Extrae el JSON del output del judge. Tolerante a fences y texto extra."""
if not raw:
return None
# Path 1: fence
m = _FENCE_RE.search(raw)
if m:
try:
return json.loads(m.group(1))
except json.JSONDecodeError:
pass
# Path 2: balanced braces
start = raw.find("{")
if start < 0:
return None
depth = 0
in_str = False
escape = False
for i in range(start, len(raw)):
c = raw[i]
if escape:
escape = False
continue
if c == "\\":
escape = True
continue
if c == '"' and not escape:
in_str = not in_str
continue
if in_str:
continue
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
candidate = raw[start:i + 1]
try:
return json.loads(candidate)
except json.JSONDecodeError:
return None
return None
def _serialize_tool_execs(tool_executions: list[ToolExecution]) -> list[dict[str, Any]]:
"""Compacta tool_executions a lo minimo necesario para el judge."""
out: list[dict[str, Any]] = []
for te in tool_executions:
if te.status not in (ToolExecutionStatus.COMPLETED, ToolExecutionStatus.FAILED):
continue
out.append({
"tool": strip_namespace(te.tool_name),
"args": te.arguments or {},
"success": te.status == ToolExecutionStatus.COMPLETED,
})
return out
def _serialize_pending_steps(plan: dict[str, Any]) -> list[dict[str, Any]]:
"""Solo los steps que aun no estan completados."""
completed = set(plan.get("completed_step_ids") or [])
out: list[dict[str, Any]] = []
for s in plan.get("steps") or []:
sid = s.get("id")
if sid in completed:
continue
out.append({
"id": sid,
"description": (s.get("description") or "")[:300],
"agent_action": (s.get("agent_action") or "")[:300],
"files_touched": s.get("files_touched") or [],
"tables_touched": s.get("tables_touched") or [],
})
return out
async def judge_plan_progress(
plan: dict[str, Any],
tool_executions_this_step: list[ToolExecution],
model_adapter: ModelAdapter,
model_id: str | None = None,
) -> tuple[list[int], str]:
"""Pregunta al modelo qué steps del plan están completados tras este batch.
Devuelve `(completed_ids, rationale)`. En caso de error o JSON no parseable
devuelve `([], "judge_error: <mensaje>")` — el caller decide si aplica
fallback heuristico o ignora.
"""
pending = _serialize_pending_steps(plan)
if not pending:
return [], "no pending steps"
tools_payload = _serialize_tool_execs(tool_executions_this_step)
if not tools_payload:
return [], "no tools executed"
user_msg = json.dumps({
"plan_pending_steps": pending,
"tools_executed_this_step": tools_payload,
}, ensure_ascii=False)
# max_tokens generoso: MiniMax M2.7 puede emitir thinking blocks aunque
# pidamos `disabled`, y necesitamos espacio para el JSON output sin que
# se trunque (causa principal de `parse_failed` en sesiones reales).
config = ModelConfig(
model_id=model_id or "",
max_tokens=1500,
temperature=0.0,
extra={"thinking": {"type": "disabled"}},
)
# Llamada NO streaming — usamos `complete()` que devuelve directamente texto.
try:
response = await model_adapter.complete(
messages=[
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": user_msg},
],
tools=None,
config=config,
)
except Exception as e:
logger.warning("[plan_judge] model call failed: %s", e)
return [], f"judge_error: {str(e)[:120]}"
raw_text = (response.content or "").strip()
parsed = _parse_judge_output(raw_text)
if not parsed or not isinstance(parsed, dict):
logger.warning("[plan_judge] could not parse JSON: %r", raw_text[:200])
return [], "judge_error: parse_failed"
raw_ids = parsed.get("completed_ids") or []
if not isinstance(raw_ids, list):
return [], "judge_error: completed_ids not a list"
pending_ids = {s["id"] for s in pending}
completed_ids = []
for cid in raw_ids:
try:
cid_int = int(cid)
except (TypeError, ValueError):
continue
# Solo acepta IDs que estaban pendientes (defensa contra alucinacion)
if cid_int in pending_ids:
completed_ids.append(cid_int)
rationale = str(parsed.get("rationale") or "")[:300]
return completed_ids, rationale

411
src/orchestrator/planner.py Normal file
View File

@@ -0,0 +1,411 @@
"""Sub-loop del planner — implementacion de la tool interna `acai_plan`.
La tool `acai_plan` se intercepta en `BaseAgent._execute_tool`. Cuando el
agente principal la llama, lanzamos `run_planner_subloop` que abre una
mini-conversacion con el modelo usando `system.planner.md` y solo tools de
lectura. Devuelve un plan JSON estructurado.
Diseno:
- El planner NO ve el thinking del agente principal directamente — recibe
un `parent_thinking_summary` reducido (~300 tokens) para no contaminar.
- max_steps=3 turnos del modelo. Suficiente para 1-2 lookups + emitir JSON.
- La salida es texto que se parsea a JSON. Si falla, retornamos error y
el agente principal decide si reintenta o pasa a modo directo.
"""
from __future__ import annotations
import json
import logging
import re
from dataclasses import dataclass
from typing import Any
from ..adapters.base import ModelAdapter, ModelConfig
from ..config import settings
from ..mcp.manager import MCPManager
from ..models.agent import AgentProfile
from .tool_groups import PLANNER_TOOLS, strip_namespace
logger = logging.getLogger(__name__)
def _serialize_thinking_blocks(
turn_thinking_blocks: dict[int, dict[str, str]],
) -> list[dict[str, Any]]:
"""Convierte los thinking blocks acumulados de un turno en bloques
Anthropic-style, ordenados por block_index. DeepSeek (y Anthropic) exigen
que los assistant messages reenvien los thinking blocks con su signature
en turnos siguientes; si no, devuelven 400.
"""
out: list[dict[str, Any]] = []
for idx in sorted(turn_thinking_blocks.keys()):
blk = turn_thinking_blocks[idx]
if not blk.get("thinking"):
continue
out.append({
"type": "thinking",
"thinking": blk["thinking"],
"signature": blk.get("signature", ""),
})
return out
@dataclass
class PlannerResult:
"""Resultado del sub-loop del planner."""
plan: dict[str, Any] | None
error: str = ""
raw_text: str = ""
tool_executions: list[dict[str, Any]] = None # type: ignore
def __post_init__(self) -> None:
if self.tool_executions is None:
self.tool_executions = []
# Regex para extraer el primer bloque JSON del texto del modelo.
# Soporta tanto JSON puro como dentro de fences ```json ... ```.
_FENCE_RE = re.compile(r"```(?:json)?\s*(\{.*?\})\s*```", re.DOTALL | re.IGNORECASE)
def parse_plan(raw_text: str) -> dict[str, Any] | None:
"""Extrae JSON robustamente del output del planner.
Estrategia:
1) Intenta encontrar un fence ```json ... ```.
2) Si no, busca el primer `{` con su matching `}` balanceado.
3) Parsea con json.loads; si falla, retorna None.
"""
if not raw_text:
return None
# Path 1: fence
m = _FENCE_RE.search(raw_text)
if m:
try:
return json.loads(m.group(1))
except json.JSONDecodeError:
pass
# Path 2: balanced braces — encuentra el primer `{` y avanza contando.
start = raw_text.find("{")
if start < 0:
return None
depth = 0
in_str = False
escape = False
for i in range(start, len(raw_text)):
c = raw_text[i]
if escape:
escape = False
continue
if c == "\\":
escape = True
continue
if c == '"' and not escape:
in_str = not in_str
continue
if in_str:
continue
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
candidate = raw_text[start:i + 1]
try:
return json.loads(candidate)
except json.JSONDecodeError:
return None
return None
def _normalize_plan(plan: dict[str, Any], objective: str) -> dict[str, Any]:
"""Asegura los campos esperados con defaults razonables."""
out: dict[str, Any] = {
"objective": str(plan.get("objective") or objective)[:500],
"steps": [],
"risks": [],
"files_touched": [],
"tables_touched": [],
"estimated_steps": 0,
"notes": "",
}
raw_steps = plan.get("steps") or []
if isinstance(raw_steps, list):
for i, s in enumerate(raw_steps):
if not isinstance(s, dict):
continue
step = {
"id": int(s.get("id") or i + 1),
"description": str(s.get("description") or "")[:500],
"agent_action": str(s.get("agent_action") or "")[:500],
"files_touched": [str(x) for x in (s.get("files_touched") or []) if x][:20],
"tables_touched": [str(x) for x in (s.get("tables_touched") or []) if x][:20],
"depends_on": [int(x) for x in (s.get("depends_on") or []) if isinstance(x, (int, str)) and str(x).isdigit()][:10],
}
out["steps"].append(step)
out["risks"] = [str(r)[:300] for r in (plan.get("risks") or []) if r][:10]
out["files_touched"] = list({f for s in out["steps"] for f in s["files_touched"]})[:30]
out["tables_touched"] = list({t for s in out["steps"] for t in s["tables_touched"]})[:30]
out["estimated_steps"] = int(plan.get("estimated_steps") or len(out["steps"]))
out["notes"] = str(plan.get("notes") or "")[:500]
return out
def _build_planner_tools(mcp: MCPManager | None) -> list[dict[str, Any]]:
"""Devuelve solo las definiciones de tools de lectura."""
if not mcp or not mcp.is_running:
return []
out: list[dict[str, Any]] = []
for tool in mcp.get_tool_definitions():
if strip_namespace(tool["name"]) in PLANNER_TOOLS:
out.append(tool)
return out
async def run_planner_subloop(
*,
objective: str,
scope: str,
agent_profile: AgentProfile,
model_adapter: ModelAdapter,
mcp: MCPManager | None,
parent_thinking_summary: str = "",
max_subloop_steps: int = 6,
) -> PlannerResult:
"""Ejecuta una mini-conversacion con el modelo para producir el plan.
NO emite SSE de cara al usuario. NO persiste artifacts. NO escribe nada.
El agente principal (su caller) integra el resultado como tool_result.
"""
system_prompt = agent_profile.system_prompt_planner or ""
if not system_prompt.strip():
return PlannerResult(plan=None, error="planner system prompt vacio")
user_msg_parts = [
f"Objetivo: {objective}",
]
if scope.strip():
user_msg_parts.append(f"Scope: {scope}")
if parent_thinking_summary.strip():
user_msg_parts.append(f"Contexto previo (resumen del thinking del agente principal):\n{parent_thinking_summary}")
user_msg_parts.append("Produce el plan JSON segun la especificacion.")
user_message = "\n\n".join(user_msg_parts)
messages: list[dict[str, Any]] = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message},
]
config = ModelConfig(
# Resolucion del modelo del planner (mas a menos prioritario):
# 1) planner_model_id del agent yaml (override per-agent)
# 2) AGENTIC_PLANNER_MODEL_ID en .env (override global)
# 3) model_id del agent (mismo que ejecuciones)
# 4) default_model_id global (fallback final)
model_id=(
agent_profile.planner_model_id
or settings.planner_model_id
or agent_profile.model_id
or settings.default_model_id
),
# Mas tokens que el agente principal: Pro con thinking puede gastar
# 2-4k razonando antes del JSON del plan; con 4k se truncaba.
max_tokens=settings.planner_max_tokens or 16000,
# Temperatura mas baja que el agente principal — queremos JSON limpio.
temperature=0.1,
# Mismo nivel de razonamiento resuelto por sesión que el agente principal.
reasoning_effort=agent_profile.reasoning_effort or "",
)
tool_defs = _build_planner_tools(mcp)
tool_executions_log: list[dict[str, Any]] = []
accumulated_text = ""
accumulated_thinking = ""
for sub_step in range(max_subloop_steps):
full_text = ""
active_tools: dict[str, dict[str, Any]] = {}
tool_calls_this_step: list[dict[str, Any]] = []
finish_reason = ""
# Bloques de thinking de ESTE turno indexados por block_index. DeepSeek
# (y cualquier API Anthropic con thinking on) exige reenviar los bloques
# thinking + signature en los assistant messages de turnos siguientes.
turn_thinking_blocks: dict[int, dict[str, str]] = {}
async for chunk in model_adapter.stream(
messages=messages,
tools=tool_defs if tool_defs else None,
config=config,
):
if chunk.delta:
full_text += chunk.delta
if chunk.thinking_delta:
accumulated_thinking += chunk.thinking_delta
if chunk.block_index >= 0:
blk = turn_thinking_blocks.setdefault(
chunk.block_index, {"thinking": "", "signature": ""}
)
blk["thinking"] += chunk.thinking_delta
if chunk.thinking_signature and chunk.block_index >= 0:
blk = turn_thinking_blocks.setdefault(
chunk.block_index, {"thinking": "", "signature": ""}
)
blk["signature"] = chunk.thinking_signature
if chunk.tool_name and chunk.tool_call_id:
if chunk.tool_call_id not in active_tools:
active_tools[chunk.tool_call_id] = {
"id": chunk.tool_call_id,
"name": chunk.tool_name,
"arguments": "",
}
if chunk.tool_arguments and chunk.tool_call_id and not chunk.finish_reason:
tool = active_tools.get(chunk.tool_call_id)
if tool:
tool["arguments"] += chunk.tool_arguments
if chunk.finish_reason == "tool_use" and chunk.tool_call_id:
tool = active_tools.pop(chunk.tool_call_id, None)
if tool:
final_args = tool["arguments"] or chunk.tool_arguments or ""
try:
tool["parsed_arguments"] = json.loads(final_args) if final_args else {}
except json.JSONDecodeError:
tool["parsed_arguments"] = {}
tool_calls_this_step.append(tool)
if chunk.finish_reason in ("end_turn", "stop_sequence"):
finish_reason = chunk.finish_reason
break
accumulated_text += full_text
# Si el modelo no llamo tools y emitio texto -> intenta parsear plan.
if not tool_calls_this_step:
plan_raw = parse_plan(full_text or accumulated_text)
if plan_raw is not None:
normalized = _normalize_plan(plan_raw, objective)
# Adjuntar resumen del thinking interno como `notes` si no lo dio.
if not normalized.get("notes") and accumulated_thinking:
normalized["notes"] = accumulated_thinking[:300]
return PlannerResult(
plan=normalized,
raw_text=full_text,
tool_executions=tool_executions_log,
)
# Si llegamos aqui sin tools y sin plan parseable, fallamos.
if sub_step >= max_subloop_steps - 1:
return PlannerResult(
plan=None,
error="No se pudo parsear el JSON del plan",
raw_text=full_text or accumulated_text,
tool_executions=tool_executions_log,
)
# Reintenta con un mensaje de correccion explicito.
# Reenviar thinking blocks (con signature) si los hubo — DeepSeek
# rechaza el siguiente turno si el assistant message los omite.
retry_blocks: list[dict[str, Any]] = _serialize_thinking_blocks(turn_thinking_blocks)
retry_blocks.append({"type": "text", "text": full_text or accumulated_text})
messages.append({"role": "assistant", "content": retry_blocks})
messages.append({
"role": "user",
"content": (
"Tu output anterior no contenia un JSON parseable. "
"Emite UNICAMENTE el plan JSON segun la especificacion, "
"sin texto adicional alrededor."
),
})
continue
# Si llamo tools, ejecutamos las tools y seguimos el sub-loop.
# Adjuntamos el assistant message con tool_use blocks y los tool_results.
# Reenviar thinking blocks (con signature) primero — requerido por DeepSeek.
assistant_blocks: list[dict[str, Any]] = _serialize_thinking_blocks(turn_thinking_blocks)
if full_text:
assistant_blocks.append({"type": "text", "text": full_text})
for tc in tool_calls_this_step:
assistant_blocks.append({
"type": "tool_use",
"id": tc["id"],
"name": tc["name"],
"input": tc.get("parsed_arguments", {}),
})
messages.append({"role": "assistant", "content": assistant_blocks})
tool_result_blocks: list[dict[str, Any]] = []
for tc in tool_calls_this_step:
# Solo ejecutamos tools de lectura. Si por algun bug llega una
# tool de escritura, devolvemos error en lugar de ejecutarla.
tool_name_raw = tc["name"]
if not strip_namespace(tool_name_raw) in PLANNER_TOOLS:
tool_result_blocks.append({
"type": "tool_result",
"tool_use_id": tc["id"],
"content": f"[ERROR planner] tool '{tool_name_raw}' no permitida en planner sub-loop (solo lectura).",
"is_error": True,
})
continue
try:
if not mcp or not mcp.is_running:
raise RuntimeError("MCP no disponible")
result = await mcp.call_tool(tool_name_raw, tc.get("parsed_arguments", {}))
# Extraer texto del resultado MCP
content_parts: list[str] = []
for c in (result.get("content") or []):
if isinstance(c, dict) and c.get("type") == "text":
content_parts.append(c.get("text", ""))
raw_output = "\n".join(content_parts) if content_parts else json.dumps(result)
tool_result_blocks.append({
"type": "tool_result",
"tool_use_id": tc["id"],
"content": raw_output[:4000],
})
tool_executions_log.append({
"name": tool_name_raw,
"arguments": tc.get("parsed_arguments", {}),
"raw_output_preview": raw_output[:300],
})
except Exception as e:
logger.warning("Planner tool %s failed: %s", tool_name_raw, e)
tool_result_blocks.append({
"type": "tool_result",
"tool_use_id": tc["id"],
"content": f"[ERROR] {e}",
"is_error": True,
})
messages.append({"role": "user", "content": tool_result_blocks})
# En el penultimo y ultimo turno, forzamos al modelo a parar de
# investigar y emitir el JSON. M2.7 a veces sigue pidiendo tools
# indefinidamente — hay que cortar.
if sub_step >= max_subloop_steps - 2:
messages.append({
"role": "user",
"content": (
"PARA. No llames mas tools. Ya tienes lo necesario. "
"Emite AHORA el plan JSON segun la especificacion del system prompt. "
"Solo el JSON, sin texto alrededor."
),
})
# Si salimos del loop sin plan, fallamos.
logger.warning(
"Planner agotado: %d steps, %d tool calls totales, accumulated_text=%r",
max_subloop_steps,
len(tool_executions_log),
accumulated_text[:300],
)
return PlannerResult(
plan=None,
error=f"Planner agotado tras {max_subloop_steps} steps sin emitir JSON",
raw_text=accumulated_text,
tool_executions=tool_executions_log,
)

View File

@@ -25,6 +25,15 @@ class AgentRegistry:
self._agents: dict[str, AgentProfile] = {}
self._metadata: dict[str, dict[str, Any]] = {}
self._agents_dir = agents_dir
self._contract: str = ""
def _load_contract(self) -> str:
"""Lee el contrato compartido (`_shared/contract.md`) que se concatena
al system prompt de cada agente. Si no existe, devuelve string vacio."""
contract_path = self._agents_dir / "_shared" / "contract.md"
if contract_path.is_file():
return contract_path.read_text(encoding="utf-8")
return ""
# ------------------------------------------------------------------
# Carga
@@ -34,6 +43,7 @@ class AgentRegistry:
"""Escanea agents_dir y carga todos los agentes encontrados."""
self._agents.clear()
self._metadata.clear()
self._contract = self._load_contract()
if not self._agents_dir.is_dir():
logger.warning("Agents directory not found: %s", self._agents_dir)
@@ -42,6 +52,9 @@ class AgentRegistry:
for agent_dir in sorted(self._agents_dir.iterdir()):
if not agent_dir.is_dir():
continue
# Skip directorios especiales (`_shared`, etc).
if agent_dir.name.startswith("_"):
continue
yaml_path = agent_dir / "agent.yaml"
prompt_path = agent_dir / "system.md"
@@ -60,6 +73,26 @@ class AgentRegistry:
agent_id = meta.get("name", agent_dir.name)
# Concatena contract.md al system prompt del agente
# (Fase 3: las reglas comunes viven en _shared/contract.md).
# La identidad del agente va PRIMERO, las reglas de ambiente
# despues — separadas por linea horizontal.
if self._contract:
if system_prompt:
system_prompt = system_prompt.rstrip() + "\n\n---\n\n" + self._contract
else:
system_prompt = self._contract
# Planner system prompt (opcional, usado por la tool
# interna `acai_plan` cuando el agente lo expone).
# El planner tambien recibe el contract.
planner_path = agent_dir / "system.planner.md"
planner_prompt = ""
if planner_path.exists():
planner_prompt = planner_path.read_text(encoding="utf-8")
if self._contract:
planner_prompt = planner_prompt.rstrip() + "\n\n---\n\n" + self._contract
profile = AgentProfile(
role=agent_id,
name=agent_id,
@@ -70,6 +103,7 @@ class AgentRegistry:
system_prompt=system_prompt,
allowed_tools=meta.get("allowed_tools", []),
model_id=meta.get("model_id"),
planner_model_id=meta.get("planner_model_id"),
temperature=meta.get("temperature"),
max_tokens=meta.get("max_tokens"),
context_sections=meta.get("context_sections", [
@@ -79,6 +113,12 @@ class AgentRegistry:
"task_state",
]),
stream_deltas=meta.get("stream_deltas", True),
kb_load_strategy=meta.get("kb_load_strategy", "top_n"),
kb_tags=meta.get("kb_tags", []),
kb_max_tokens=meta.get("kb_max_tokens"),
kb_top_n=meta.get("kb_top_n"),
has_planner_tool=meta.get("has_planner_tool", False),
system_prompt_planner=planner_prompt,
)
self._agents[agent_id] = profile

View File

@@ -0,0 +1,63 @@
"""Grupos de tools utilizados por el orquestador.
`READ_TOOLS`: tools de solo lectura. Son seguras de exponer en sub-loops
(p.ej. el planner) porque NO modifican estado del proyecto.
`PLANNER_TOOLS`: alias de READ_TOOLS — el planner SOLO investiga.
`PLAN_INTERNAL_TOOLS`: tools sinteticas implementadas por el orquestador
Python (no atraviesan MCP). Se interceptan en `BaseAgent._execute_tool`.
"""
from __future__ import annotations
# Whitelist de tools de lectura. Cualquier tool MCP cuyo nombre `endswith`
# uno de estos sufijos o coincide exactamente entra en el set tras
# normalizar el namespace (p.ej. `acai_code__list_tables` se compara
# contra el sufijo `list_tables`).
READ_TOOL_NAMES: frozenset[str] = frozenset({
# Files (lectura/busqueda)
"acai-glob", "acai-grep", "acai-view",
# Records (lectura)
"list_table_records", "get_record",
"list_page_modules", "get_module_config_vars",
"list_record_uploads",
# Schema / tables (lectura)
"list_tables", "get_table_schema",
# Layout / libraries (lectura)
"get_layout_field", "list_global_libraries",
# Hooks (lectura)
"get_hook_middleware",
# Project / web (lectura)
"get_web_url",
# Git (lectura)
"list_git_log",
# Docs (lectura)
"list_docs", "read_doc",
})
PLANNER_TOOLS: frozenset[str] = READ_TOOL_NAMES
PLAN_INTERNAL_TOOL_NAMES: frozenset[str] = frozenset({
"acai_plan",
"acai_plan_advance",
})
def strip_namespace(tool_name: str) -> str:
"""Extrae el nombre raw de una tool con namespace.
El MCPManager prefija con `<server>__` cuando hay multiples servers.
Para comparar contra READ_TOOL_NAMES quitamos ese prefijo.
"""
if "__" in tool_name:
return tool_name.split("__", 1)[1]
return tool_name
def is_read_tool(tool_name: str) -> bool:
return strip_namespace(tool_name) in READ_TOOL_NAMES
def is_plan_internal_tool(tool_name: str) -> bool:
return strip_namespace(tool_name) in PLAN_INTERNAL_TOOL_NAMES

View File

@@ -12,6 +12,7 @@ from __future__ import annotations
import json
import logging
import uuid
from contextlib import asynccontextmanager
from typing import Any, AsyncIterator
@@ -127,14 +128,26 @@ class RedisStorage:
# 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
async def session_lock(
self, session_id: str, timeout: int = 300
self, session_id: str, timeout: int | None = None
) -> AsyncIterator[bool]:
"""Acquire an exclusive execution lock for a session.
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:
async with storage.session_lock(session_id) as acquired:
@@ -142,10 +155,34 @@ class RedisStorage:
raise HTTPException(409, "Session busy")
# ... execute ...
"""
if timeout is None:
timeout = int(settings.max_execution_timeout_seconds) + 60
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:
yield bool(acquired)
finally:
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:
"""Borra el lock de ejecución de una sesión de forma incondicional.
OJO: borra sin conocer el token del dueño, así que se salta el
compare-and-delete de `session_lock`. SOLO debe invocarse cuando se
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")
await self.client.delete(key)

View File

@@ -19,6 +19,71 @@ from .sse import EventType, SSEEmitter
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:
"""Emits events in Claude Code CLI SSE format.
@@ -35,6 +100,8 @@ class ClaudeFormatEmitter:
self._tool_block_index: dict[str, dict[str, int]] = {} # session -> {tool_call_id -> index}
self._content_blocks: dict[str, list[dict[str, Any]]] = {}
self._text_accumulator: dict[str, str] = {}
self._thinking_block_open: dict[str, bool] = {}
self._thinking_block_index: dict[str, int] = {}
def _next_index(self, session_id: str) -> int:
idx = self._block_counter.get(session_id, 0)
@@ -48,6 +115,8 @@ class ClaudeFormatEmitter:
self._tool_block_index[session_id] = {}
self._content_blocks[session_id] = []
self._text_accumulator[session_id] = ""
self._thinking_block_open[session_id] = False
self._thinking_block_index[session_id] = -1
def _push(self, session_id: str, payload: dict[str, Any]) -> None:
"""Push a formatted line to all subscribers of a session."""
@@ -119,7 +188,43 @@ class ClaudeFormatEmitter:
tool_args = data.get("tool_arguments", "")
tool_call_id = data.get("tool_call_id", "")
thinking_delta = data.get("thinking_delta", "")
if thinking_delta:
# Cerrar text block abierto si lo hay
self._close_text_block(session_id)
# Abrir thinking block si no esta abierto
if not self._thinking_block_open.get(session_id):
idx = self._next_index(session_id)
self._thinking_block_index[session_id] = idx
self._thinking_block_open[session_id] = True
self._push(session_id, {
"type": "stream_event",
"event": {
"type": "content_block_start",
"index": idx,
"content_block": {"type": "thinking", "thinking": ""},
},
})
idx = self._thinking_block_index[session_id]
self._push(session_id, {
"type": "stream_event",
"event": {
"type": "content_block_delta",
"index": idx,
"delta": {"type": "thinking_delta", "thinking": thinking_delta},
},
})
return
if delta_text:
# Cerrar thinking block abierto si lo hay antes de texto normal
if self._thinking_block_open.get(session_id):
idx = self._thinking_block_index[session_id]
self._push(session_id, {
"type": "stream_event",
"event": {"type": "content_block_stop", "index": idx},
})
self._thinking_block_open[session_id] = False
# Text streaming
if not self._text_block_open.get(session_id):
self._open_text_block(session_id)
@@ -152,6 +257,15 @@ class ClaudeFormatEmitter:
tool_name = data.get("tool", "unknown")
tool_call_id = data.get("tool_call_id", "")
# Cerrar thinking block abierto si lo hay
if self._thinking_block_open.get(session_id):
idx = self._thinking_block_index[session_id]
self._push(session_id, {
"type": "stream_event",
"event": {"type": "content_block_stop", "index": idx},
})
self._thinking_block_open[session_id] = False
# Close open text block
self._close_text_block(session_id)
@@ -207,6 +321,29 @@ class ClaudeFormatEmitter:
# Emit assistant snapshot for reconciliation
self._push(session_id, self._build_assistant_snapshot(session_id))
elif event_type == EventType.PLAN_CREATED:
# Fase 5.5: PlanStepper UI. Reenviamos los datos del plan al
# frontend como evento custom "plan.created".
self._push(session_id, {
"type": "plan.created",
"plan": data,
})
elif event_type == EventType.PLAN_ADVANCED:
self._push(session_id, {
"type": "plan.advanced",
"cursor": data.get("cursor", 0),
"completed_step_ids": data.get("completed_step_ids", []),
"status": data.get("status", "active"),
})
elif event_type == EventType.PLAN_ENDED:
self._push(session_id, {
"type": "plan.ended",
"status": data.get("status", "done"),
"objective": data.get("objective", ""),
})
elif event_type == EventType.EXECUTION_COMPLETED:
# Close any open text block
self._close_text_block(session_id)
@@ -226,13 +363,18 @@ class ClaudeFormatEmitter:
"cache_creation_input_tokens": 0,
},
"total_cost_usd": data.get("total_cost_usd", 0),
# Modelo usado → acai-app lo registra en consumo_acaicode.
"modelUsage": data.get("modelUsage", {}),
})
# Done
self._push(session_id, {"type": "done"})
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
self._close_text_block(session_id)
@@ -240,7 +382,7 @@ class ClaudeFormatEmitter:
self._push(session_id, {
"type": "result",
"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},
"total_cost_usd": 0,
})

View File

@@ -27,6 +27,11 @@ class EventType(StrEnum):
TOOL_COMPLETED = "tool.completed"
SUBAGENT_ASSIGNED = "subagent.assigned"
EXECUTION_COMPLETED = "execution.completed"
# Plan lifecycle (Fase 5.5: PlanStepper UI). Emitidos por BaseAgent
# cuando la tool interna `acai_plan` produce/avanza/cierra un plan.
PLAN_CREATED = "plan.created"
PLAN_ADVANCED = "plan.advanced"
PLAN_ENDED = "plan.ended"
ERROR = "error"
KEEPALIVE = "keepalive"

View File

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