- 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>
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>
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>
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>
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>
- 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>
- 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>
- 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>
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>
- 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>
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>
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>
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>
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>
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>
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>