Compare commits
24 Commits
0dabba5442
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d475845c27 | ||
|
|
941040d534 | ||
|
|
037bc81936 | ||
|
|
882d578960 | ||
|
|
651d61b096 | ||
|
|
9d11a59fb8 | ||
|
|
5dc2dbcf4a | ||
|
|
5883473e92 | ||
|
|
4543300101 | ||
|
|
9277862e56 | ||
|
|
79ec267aa6 | ||
|
|
43337e8554 | ||
|
|
6a03fdf284 | ||
|
|
e34a39e3bf | ||
|
|
d6b04e4122 | ||
|
|
96b4542918 | ||
|
|
454b51b45d | ||
|
|
9854960c7c | ||
|
|
36318c61ea | ||
|
|
c5c001468f | ||
|
|
f7c6e65c0b | ||
|
|
b3ca343798 | ||
|
|
5e64bbdfc8 | ||
|
|
44cb956f95 |
@@ -56,6 +56,13 @@ USER appuser
|
|||||||
# Descargar Chromium como appuser (queda en ~/.cache/ms-playwright/)
|
# Descargar Chromium como appuser (queda en ~/.cache/ms-playwright/)
|
||||||
RUN cd mcp-server && npx playwright install chromium
|
RUN cd mcp-server && npx playwright install chromium
|
||||||
|
|
||||||
|
# Precalentar mcp-server-fetch como appuser: uvx descarga ~43 paquetes la
|
||||||
|
# primera vez, lo que en frio supera el startup_timeout del MCP. Lo dejamos
|
||||||
|
# cacheado en ~/.cache/uv dentro de la imagen para que arranque rapido en
|
||||||
|
# runtime (igual que Chromium). El server lee stdin; con </dev/null sale tras
|
||||||
|
# instalar. `|| true` para no romper el build si sale != 0.
|
||||||
|
RUN timeout 180 uvx mcp-server-fetch </dev/null >/dev/null 2>&1 || true
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
20
agents/_shared/contract.md
Normal file
20
agents/_shared/contract.md
Normal 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.
|
||||||
@@ -4,7 +4,11 @@ description: "Agente genérico de Acai CMS: crea módulos, edita contenido, gest
|
|||||||
icon: "code"
|
icon: "code"
|
||||||
category: "development"
|
category: "development"
|
||||||
temperature: 0.2
|
temperature: 0.2
|
||||||
max_tokens: 4096
|
# 16K de salida: cubre escribir un fichero entero (acai_write) + el razonamiento
|
||||||
|
# (thinking) en un solo turno. Con 4096 el JSON del tool_use se truncaba a mitad
|
||||||
|
# en ficheros medianos y el agente caia en micro-ediciones lentas. v4-pro soporta
|
||||||
|
# hasta 384K de salida, asi que 16K es conservador.
|
||||||
|
max_tokens: 16384
|
||||||
context_sections:
|
context_sections:
|
||||||
- immutable_rules
|
- immutable_rules
|
||||||
- project_profile
|
- project_profile
|
||||||
@@ -12,4 +16,9 @@ context_sections:
|
|||||||
- task_state
|
- task_state
|
||||||
allowed_tools: []
|
allowed_tools: []
|
||||||
model_id: null
|
model_id: null
|
||||||
|
# planner_model_id: null # null → usa AGENTIC_PLANNER_MODEL_ID del .env
|
||||||
stream_deltas: true
|
stream_deltas: true
|
||||||
|
kb_load_strategy: top_n
|
||||||
|
kb_max_tokens: 4000
|
||||||
|
kb_top_n: 2
|
||||||
|
has_planner_tool: true
|
||||||
|
|||||||
@@ -1,165 +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.
|
||||||
|
|
||||||
# Mecanismo de tools (CRÍTICO)
|
<!-- PLANNER_SECTION_START -->
|
||||||
|
# Cuándo planificar y cuándo ejecutar directo
|
||||||
|
|
||||||
Para invocar herramientas usa **EXCLUSIVAMENTE el mecanismo nativo de tool_use** del API. NUNCA escribas tool calls como texto en tu respuesta. En particular NO escribas marcadores como `<tool_call>`, `[TOOL_CALL]`, `<minimax:tool_call>`, `<invoke>`, `{tool => ...}`, `{name: ..., parameters: ...}` ni cualquier pseudocódigo similar dentro del campo `content` de texto. El sistema tiene soporte de tools incorporado — invócalas directamente. Si escribes una tool call como texto, **no se ejecutará** y el usuario solo verá el markup crudo.
|
Antes de actuar, juzga el alcance de la petición. Hay dos modos de operación:
|
||||||
|
|
||||||
# Identidad y rol
|
**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").
|
||||||
|
|
||||||
Actúas como un desarrollador senior experto en Acai CMS. Antes de cualquier acción no trivial:
|
**Modo planificación** — llama PRIMERO la tool `acai_plan(objective, scope?)`:
|
||||||
1. Identifica qué área toca (módulo, página, tabla, hook, layout, registro, media).
|
- Construir una landing entera, una tienda, un módulo nuevo con tabla + hook + frontend juntos.
|
||||||
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`.
|
- Refactor amplio (clonar módulo, migrar de uno a otro, mover layout).
|
||||||
3. Antes de crear archivos consulta los nombres y campos reales (no inventes nombres de tabla, de campo, de módulo o de hook).
|
- Cambio cross-cutting con riesgo (modificar todos los módulos que cumplen una condición).
|
||||||
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.
|
- Petición ambigua donde necesitas leer estado primero para decidir el plan correcto.
|
||||||
|
|
||||||
# Estructura del proyecto
|
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>/
|
template/estandar/modulos/<module-id>/
|
||||||
├── index-base.tpl # source — EDITA SOLO ESTE
|
├── index-base.tpl # source — EDITA SOLO ESTE
|
||||||
├── index.tpl # autogenerado — NO TOCAR
|
├── index.tpl # autogenerado — NO TOCAR
|
||||||
├── index-twig.tpl # autogenerado — NO TOCAR
|
|
||||||
├── builder.json # autogenerado — NO TOCAR
|
├── builder.json # autogenerado — NO TOCAR
|
||||||
├── style.css # estático (sin Twig)
|
├── style.css # estático (sin Twig)
|
||||||
├── script.js # estático (sin Twig)
|
├── script.js # estático (sin Twig)
|
||||||
└── hook.php # opcional — hook propio del módulo
|
└── hook.php # opcional — hook propio del módulo
|
||||||
|
|
||||||
hooks/hooks.<id>.php # hooks globales
|
hooks/hooks.<id>.php # hooks globales
|
||||||
cms/data/schema/ # schemas de tablas (.ini.php)
|
cms/data/schema/ # .ini.php — SOLO con tools de schema
|
||||||
cms/lib/plugins/builder_saas/layout.json # PROHIBIDO editar directamente
|
|
||||||
```
|
```
|
||||||
|
|
||||||
# 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.
|
1. **NUNCA `mkdir`.** Usa `acai-write` directamente — el directorio se crea solo.
|
||||||
2. **NUNCA uses `mkdir`.** Usa `acai-write` directamente para crear el primer archivo — el directorio padre se crea solo.
|
2. **Solo edita `index-base.tpl`** de los módulos. Los `.tpl` y `.json` autogenerados NO se tocan.
|
||||||
3. En los módulos **solo editas `index-base.tpl`**. `index.tpl`, `index-twig.tpl` y `builder.json` son autogenerados por la compilación.
|
3. `acai-write` / `acai-line-replace` sobre `index-base.tpl` **compilan automáticamente**.
|
||||||
4. Editar `index-base.tpl` con `acai-write` o `acai-line-replace` **dispara compilación automática**. `compile_module` solo para recuperación manual.
|
4. **`script.js` y `style.css` son estáticos** — no Twig dentro. Pasa valores con `data-*`.
|
||||||
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`.
|
5. **Tablas sin `cms_`** en tools y Twig. En `queryDB` y `set_hook_middleware` SÍ con `cms_`.
|
||||||
6. **Twig usa filtros con `|`**, nunca funciones (`'tabla' | get()`, no `get('tabla')`).
|
6. **Primary key `num`**, foreign keys `*_num`. **Upload fields son arrays**: `imagen[0].urlPath`.
|
||||||
7. **Tablas siempre sin prefijo `cms_`** en tools, Twig y `CmsApi`. Excepción: `queryDB` y el `middleWare` de `set_hook_middleware` sí llevan `cms_`.
|
7. **Twig concatena con `~`**. **`c-if` usa `=`, `{% if %}` usa `==`**. **Checkbox: `1`/`0`**.
|
||||||
8. **Primary key siempre `num`**, nunca `id`. Foreign keys con sufijo `_num` (`categoria_num`).
|
8. **`enlace` ya incluye barras** — no lo toques. **NUNCA modifiques `controlador`** de un registro existente.
|
||||||
9. **Upload fields son arrays**: `imagen[0].urlPath`, no `imagen`.
|
9. **NUNCA inventes** nombres de campo / tabla / módulo. Si dudas: `get_table_schema`, `acai-glob`, `acai-grep`.
|
||||||
10. **Twig concatena con `~`**: `'value=' ~ variable`.
|
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. **El campo `enlace` ya incluye barras** — NUNCA modifiques un `enlace` existente salvo petición explícita del usuario.
|
11. **Texto traducible**: filtro `| translate` (tabla `textos_generales`). NUNCA i18n externo.
|
||||||
12. **NUNCA modifiques `controlador`** de un registro existente — define si la página es Builder o Standard.
|
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. **NUNCA inventes nombres de campo o tabla.** Confirma con `get_table_schema` antes de usarlos.
|
13. **Schemas (.ini.php)** solo con tools (`create_table`, `create_field`, etc.).
|
||||||
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`.
|
14. **URL del proyecto**: `get_web_url` + `?pruebas=1` siempre.
|
||||||
15. **Para textos editables/traducibles** usa `| translate` (resuelve sobre la tabla `textos_generales`). NUNCA crees archivos JSON, `.po` ni sistemas i18n externos.
|
15. **Operaciones destructivas**: confirma con el usuario antes de ejecutar.
|
||||||
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.
|
|
||||||
|
|
||||||
# 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 |
|
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):
|
||||||
| **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()` |
|
|
||||||
|
|
||||||
# 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 |
|
- **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.
|
||||||
| `01-builder-fields` | Campos editables (`data-field-type`), atributos Acai (`c-if`, `c-for`, `c-class`), `<set>`, `c-form`, componentes built-in |
|
- **Tabla "publicable"** (noticias, vacantes, blog): `fecha_publicacion`, `fecha_expiracion?`, `visible` (checkbox).
|
||||||
| `02-twig` | Filtros Twig (`get`, `queryDB`, `hook`, `module`, `imagec`, `translate`, `raw`...), operadores, ejemplos |
|
- **Form embebido**: `<form_postular :vacante_num="thisrecord.num"></form_postular>`.
|
||||||
| `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 |
|
|
||||||
|
|
||||||
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}`
|
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.
|
||||||
|
|
||||||
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 1–2 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.
|
|
||||||
|
|||||||
64
agents/acai/system.planner.md
Normal file
64
agents/acai/system.planner.md
Normal 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.
|
||||||
@@ -13,3 +13,7 @@ context_sections:
|
|||||||
allowed_tools: []
|
allowed_tools: []
|
||||||
model_id: null
|
model_id: null
|
||||||
stream_deltas: true
|
stream_deltas: true
|
||||||
|
kb_load_strategy: tags
|
||||||
|
kb_tags: [html, css, modules, twig]
|
||||||
|
kb_max_tokens: 3000
|
||||||
|
kb_top_n: 2
|
||||||
|
|||||||
@@ -13,3 +13,5 @@ context_sections:
|
|||||||
allowed_tools: []
|
allowed_tools: []
|
||||||
model_id: null
|
model_id: null
|
||||||
stream_deltas: true
|
stream_deltas: true
|
||||||
|
kb_load_strategy: cheatsheet_only
|
||||||
|
kb_max_tokens: 1500
|
||||||
|
|||||||
@@ -13,3 +13,6 @@ context_sections:
|
|||||||
allowed_tools: []
|
allowed_tools: []
|
||||||
model_id: null
|
model_id: null
|
||||||
stream_deltas: true
|
stream_deltas: true
|
||||||
|
kb_load_strategy: top_n
|
||||||
|
kb_max_tokens: 6000
|
||||||
|
kb_top_n: 3
|
||||||
|
|||||||
@@ -13,3 +13,5 @@ context_sections:
|
|||||||
allowed_tools: []
|
allowed_tools: []
|
||||||
model_id: null
|
model_id: null
|
||||||
stream_deltas: true
|
stream_deltas: true
|
||||||
|
kb_load_strategy: glossary_only
|
||||||
|
kb_max_tokens: 1500
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ max_tokens: 4096
|
|||||||
context_sections:
|
context_sections:
|
||||||
- immutable_rules
|
- immutable_rules
|
||||||
- project_profile
|
- project_profile
|
||||||
- knowledge_base
|
|
||||||
- task_state
|
- task_state
|
||||||
allowed_tools: []
|
allowed_tools: []
|
||||||
model_id: null
|
model_id: null
|
||||||
stream_deltas: true
|
stream_deltas: true
|
||||||
|
kb_load_strategy: none
|
||||||
|
|||||||
@@ -13,3 +13,7 @@ context_sections:
|
|||||||
allowed_tools: []
|
allowed_tools: []
|
||||||
model_id: null
|
model_id: null
|
||||||
stream_deltas: true
|
stream_deltas: true
|
||||||
|
kb_load_strategy: tags
|
||||||
|
kb_tags: [twig, modules, html, builder]
|
||||||
|
kb_max_tokens: 3500
|
||||||
|
kb_top_n: 2
|
||||||
|
|||||||
@@ -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
|
# 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`.
|
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).
|
Genera 2 variables: la estándar y `_tag` con la etiqueta elegida (h1…h6).
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<{{ titulo_tag | default('h2') }}
|
<p data-field-type="headfield" data-field-label="Titulo" >
|
||||||
data-field-type="headfield"
|
|
||||||
data-field-label="Título Sección"
|
|
||||||
class="text-3xl font-bold">
|
|
||||||
Título de la sección
|
Título de la sección
|
||||||
</{{ titulo_tag | default('h2') }}>
|
</p>
|
||||||
```
|
```
|
||||||
|
|
||||||
### textbox
|
### textbox
|
||||||
@@ -77,9 +81,10 @@ Editor de texto enriquecido. Acceder con `| raw` para no escapar el HTML.
|
|||||||
### link
|
### link
|
||||||
|
|
||||||
El campo `enlace` de Acai ya incluye las barras necesarias — nunca añadas barras extra.
|
El campo `enlace` de Acai ya incluye las barras necesarias — nunca añadas barras extra.
|
||||||
|
Genera 2 variables: la estándar y `_anchor` con el anchor del enlace.
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<a data-field-type="link" data-field-label="Enlace Principal" href="#">
|
<a data-field-type="link" data-field-label="Enlace">
|
||||||
Haz clic aquí
|
Haz clic aquí
|
||||||
</a>
|
</a>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
# 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 %}` (==).
|
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 %}` (==).
|
||||||
|
|||||||
@@ -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
|
# 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/`.
|
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/`.
|
||||||
|
|||||||
@@ -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
|
# 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`.
|
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`.
|
||||||
|
|||||||
@@ -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
|
# 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/`.
|
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/`.
|
||||||
|
|||||||
@@ -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)
|
# 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.
|
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.
|
||||||
|
|||||||
@@ -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
|
# 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`.
|
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`.
|
||||||
|
|||||||
@@ -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
|
# 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.).
|
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.).
|
||||||
|
|||||||
@@ -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
|
# 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.
|
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.
|
||||||
@@ -110,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/`) |
|
| `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
|
### Proyecto
|
||||||
|
|
||||||
| Tool | Acción |
|
| Tool | Acción |
|
||||||
|
|||||||
@@ -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
|
# 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.
|
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
110
docs/11a-decision-table.md
Normal 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` |
|
||||||
@@ -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
|
## 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\"}]"` |
|
| `multitext` | String JSON | `"[{\"item\":\"valor\"}]"` |
|
||||||
| `upload` | NO enviar — usar `upload_record_image` después |
|
| `upload` | NO enviar — usar `upload_record_image` después |
|
||||||
|
|
||||||
## Variables globales
|
## Variables globales en Twig
|
||||||
|
|
||||||
| Variable | Descripción |
|
| Variable | Descripción |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
@@ -91,33 +98,6 @@ Este documento es un **resumen ejecutable** de las reglas críticas, los tipos d
|
|||||||
| `loop.index is odd` / `is even` | Layouts alternados |
|
| `loop.index is odd` / `is even` | Layouts alternados |
|
||||||
| `thisrecord` | Registro actual (solo en secciones generales) |
|
| `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"`) |
|
|
||||||
| Reemplazar imagen existente de un registro | `list_record_uploads` → `replace_record_image({ uploadId, imageUrl })` |
|
|
||||||
| Borrar imagen de un registro | `list_record_uploads` → `delete_record_upload({ uploadId })` |
|
|
||||||
| Reordenar galería de un registro | `list_record_uploads` → `reorder_record_uploads({ uploadIds: [...] })` |
|
|
||||||
| 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` |
|
|
||||||
| Volver a una versión anterior del proyecto | `list_git_log` → `recover_git({ id })` (o `recover_previous_git` para el commit anterior) — pide confirmación al usuario |
|
|
||||||
| Necesito un doc no cargado | `read_doc({ name: "..." })` |
|
|
||||||
| Listado de docs | `list_docs()` |
|
|
||||||
|
|
||||||
## Errores comunes a evitar
|
## Errores comunes a evitar
|
||||||
|
|
||||||
- Editar `index.tpl`, `index-twig.tpl` o `builder.json` (autogenerados).
|
- Editar `index.tpl`, `index-twig.tpl` o `builder.json` (autogenerados).
|
||||||
@@ -130,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`).
|
- 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-*`).
|
- 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).
|
- 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
86
docs/12-glossary.md
Normal 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
2
evals/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Los logs de sesión contienen contenido real de proyectos de cliente.
|
||||||
|
logs/
|
||||||
43
evals/README.md
Normal file
43
evals/README.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Evals del agente acai-code
|
||||||
|
|
||||||
|
Harness para evaluar el comportamiento del agente IA (`acai`) montando una
|
||||||
|
landing real con módulos gestionables, capturando cada turno (thinking, tool
|
||||||
|
calls, resultados, errores). Sirve para **comparar entre modelos** y discernir
|
||||||
|
si un fallo es del **modelo** o de la **documentación/KB** (mismo flujo, mismo
|
||||||
|
proyecto, distinto modelo → ¿cambian los errores?).
|
||||||
|
|
||||||
|
## Cómo correrlo
|
||||||
|
|
||||||
|
1. Elige el modelo activo en el **Forge Admin Panel → ventana de IA** (provider +
|
||||||
|
modelo + reasoning). El catálogo OpenRouter se auto-repuebla en runtime aunque
|
||||||
|
caduque (ver `orchestrator/cost.py: _get_catalog`).
|
||||||
|
2. Usa un proyecto **en modo TEST** (no producción) — el agente escribe módulos/
|
||||||
|
records reales en la copia forge-local. Nunca corras esto contra producción.
|
||||||
|
3. Lanza cada turno con el driver, reutilizando el `session_id` que devuelve el
|
||||||
|
primer turno para mantener la MISMA conversación:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NET=acai-vscode-plugin_acai-net # red docker del compose
|
||||||
|
docker run --rm --network $NET \
|
||||||
|
-v "$PWD/agenticSystem/evals:/data" -v "$PWD/agenticSystem/evals/logs:/logs" \
|
||||||
|
-e EVAL_PROJECT=empleo.cocosolution.com \
|
||||||
|
-w /data acai-vscode-plugin-agentic \
|
||||||
|
python /data/driver.py "Móntame una sección de beneficios con 3 tarjetas"
|
||||||
|
|
||||||
|
# turno 2 (reusa el SESSION_ID del turno 1):
|
||||||
|
docker run ... python /data/driver.py "Ahora una sección de equipo con fotos y enlaces" "<SESSION_ID>"
|
||||||
|
```
|
||||||
|
|
||||||
|
- El log completo (en vivo) se acumula en `evals/logs/session.log`.
|
||||||
|
- El driver autentica con `X-Acai-User` hiteando `app:9091` directo en la red
|
||||||
|
interna (somos superadmin en infra de confianza).
|
||||||
|
|
||||||
|
## Métricas que captura
|
||||||
|
|
||||||
|
- nº de tool calls, errores (`success:false`, HTTP_4xx), tools repetidas (señal
|
||||||
|
de bucle), tokens de input/output (coste del thrashing).
|
||||||
|
|
||||||
|
## Resultados
|
||||||
|
|
||||||
|
Ver [`results-landing-build.md`](./results-landing-build.md) — un apartado por
|
||||||
|
modelo, para comparar.
|
||||||
148
evals/driver.py
Normal file
148
evals/driver.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Driver de evaluación del agente acai-code (chat agentic).
|
||||||
|
|
||||||
|
Manda UN mensaje de usuario al chat, consume el SSE, loguea EN VIVO cada
|
||||||
|
tool/resultado/error y resume el turno. Reutiliza session_id para mantener la
|
||||||
|
MISMA conversación a lo largo de varios turnos.
|
||||||
|
|
||||||
|
Uso (dentro de la red docker, hitea `app` directo con auth interna X-Acai-User):
|
||||||
|
|
||||||
|
docker run --rm --network <proj>_acai-net \\
|
||||||
|
-v "$PWD/agenticSystem/evals:/data" -v "$PWD/agenticSystem/evals/logs:/logs" \\
|
||||||
|
-w /data acai-vscode-plugin-agentic \\
|
||||||
|
python /data/driver.py "<mensaje del usuario>" "<session_id opcional>"
|
||||||
|
|
||||||
|
Variables de entorno opcionales: EVAL_PROJECT (slug), EVAL_USER (default superadmin).
|
||||||
|
|
||||||
|
Sirve para comparar el comportamiento/errores del MISMO flujo entre distintos
|
||||||
|
modelos (cambia el modelo activo en el admin panel y repite). Ver README.md.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
APP = os.environ.get("EVAL_APP", "http://app:9091")
|
||||||
|
USER = os.environ.get("EVAL_USER", "superadmin")
|
||||||
|
PROJECT = os.environ.get("EVAL_PROJECT", "empleo.cocosolution.com")
|
||||||
|
LOG = os.environ.get("EVAL_LOG", "/logs/session.log")
|
||||||
|
|
||||||
|
msg = sys.argv[1]
|
||||||
|
session_id = sys.argv[2] if len(sys.argv) > 2 else ""
|
||||||
|
|
||||||
|
|
||||||
|
def log(s):
|
||||||
|
with open(LOG, "a") as f:
|
||||||
|
f.write(s + "\n")
|
||||||
|
f.flush()
|
||||||
|
|
||||||
|
|
||||||
|
body = {"project": PROJECT, "message": msg, "agent_id": "acai", "plan_mode": "off"}
|
||||||
|
if session_id:
|
||||||
|
body["session_id"] = session_id
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
APP + "/api/agentic/chat",
|
||||||
|
data=json.dumps(body).encode(),
|
||||||
|
headers={"Content-Type": "application/json", "X-Acai-User": USER},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
|
||||||
|
log("\n" + "=" * 80)
|
||||||
|
log("[{}] >>> USER: {}".format(time.strftime("%H:%M:%S"), msg))
|
||||||
|
|
||||||
|
sid = session_id
|
||||||
|
text_parts = []
|
||||||
|
thinking_chars = 0
|
||||||
|
tool_calls = []
|
||||||
|
tool_results = {}
|
||||||
|
errors = []
|
||||||
|
usage = {}
|
||||||
|
seen = {}
|
||||||
|
# IMPORTANTE: el agentic re-emite el snapshot `assistant` con TODOS los bloques
|
||||||
|
# acumulados tras cada tool (reconciliación, claude_format.py). Hay que
|
||||||
|
# deduplicar por `tool_use` id o se cuenta el mismo tool decenas de veces.
|
||||||
|
seen_ids = set()
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = urllib.request.urlopen(req, timeout=1200)
|
||||||
|
except Exception as e:
|
||||||
|
log("!!! HTTP ERROR: {}".format(e))
|
||||||
|
print("HTTP_ERROR", e)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
for raw in resp:
|
||||||
|
line = raw.decode("utf-8", "replace").rstrip("\r\n")
|
||||||
|
if not line.startswith("data: "):
|
||||||
|
continue
|
||||||
|
payload = line[6:].strip()
|
||||||
|
if not payload:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
ev = json.loads(payload)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
t = ev.get("type")
|
||||||
|
if t == "session":
|
||||||
|
sid = ev.get("session_id") or sid
|
||||||
|
elif t == "stream_event":
|
||||||
|
e = ev.get("event", {})
|
||||||
|
et = e.get("type")
|
||||||
|
if et == "content_block_delta":
|
||||||
|
d = e.get("delta", {})
|
||||||
|
if d.get("type") == "text_delta" or "text" in d:
|
||||||
|
text_parts.append(d.get("text", ""))
|
||||||
|
elif d.get("type") == "thinking_delta":
|
||||||
|
thinking_chars += len(d.get("thinking", ""))
|
||||||
|
elif t == "assistant":
|
||||||
|
for blk in ev.get("message", {}).get("content", []):
|
||||||
|
if blk.get("type") != "tool_use":
|
||||||
|
continue
|
||||||
|
bid = blk.get("id") or ""
|
||||||
|
if bid and bid in seen_ids:
|
||||||
|
continue # snapshot de reconciliación re-emite bloques ya vistos
|
||||||
|
if bid:
|
||||||
|
seen_ids.add(bid)
|
||||||
|
name = blk.get("name", "?")
|
||||||
|
inp = json.dumps(blk.get("input", {}), ensure_ascii=False)
|
||||||
|
sig = name + "|" + inp[:200]
|
||||||
|
seen[sig] = seen.get(sig, 0) + 1 # repeticiones REALES (mismo tool+input, otro id)
|
||||||
|
tool_calls.append((name, inp, bid))
|
||||||
|
rep = " [REPETIDA x{}]".format(seen[sig]) if seen[sig] >= 2 else ""
|
||||||
|
log(" [{}] TOOL {} {}{}".format(time.strftime("%H:%M:%S"), name, inp[:300], rep))
|
||||||
|
elif t == "tool_result":
|
||||||
|
tid = ev.get("tool_use_id")
|
||||||
|
content = ev.get("content")
|
||||||
|
cstr = content if isinstance(content, str) else json.dumps(content, ensure_ascii=False)
|
||||||
|
is_err = bool(ev.get("is_error")) or ('"success": false' in cstr) or ('"success":false' in cstr)
|
||||||
|
tool_results[tid] = (is_err, cstr[:500])
|
||||||
|
log(" ->{} {}".format(" [ERROR]" if is_err else " ok", cstr[:300]))
|
||||||
|
if is_err:
|
||||||
|
errors.append("TOOL_ERROR: " + cstr[:300])
|
||||||
|
elif t == "result":
|
||||||
|
usage = ev.get("usage", {}) or {}
|
||||||
|
if ev.get("content") and not text_parts:
|
||||||
|
text_parts.append(ev["content"])
|
||||||
|
elif t == "error":
|
||||||
|
errors.append("STREAM_ERROR: " + str(ev.get("error")))
|
||||||
|
log(" !! STREAM_ERROR: " + str(ev.get("error"))[:300])
|
||||||
|
elif t == "done":
|
||||||
|
break
|
||||||
|
|
||||||
|
full_text = "".join(text_parts)
|
||||||
|
repeated = {s: c for s, c in seen.items() if c >= 2}
|
||||||
|
log("[ASSISTANT] " + full_text[:1500])
|
||||||
|
log("[resumen] tools={} errores={} repetidas={} thinking~{}c usage in={} out={}".format(
|
||||||
|
len(tool_calls), len(errors), len(repeated), thinking_chars,
|
||||||
|
usage.get("input_tokens"), usage.get("output_tokens")))
|
||||||
|
|
||||||
|
print("SESSION_ID={}".format(sid))
|
||||||
|
print("TOOLS={} ERRORS={} REPEATED={}".format(len(tool_calls), len(errors), len(repeated)))
|
||||||
|
for (name, inp, tid) in tool_calls:
|
||||||
|
res = tool_results.get(tid)
|
||||||
|
print(" - {}{} {}".format(name, " [ERR]" if (res and res[0]) else "", inp[:110]))
|
||||||
|
for e in errors:
|
||||||
|
print(" !! " + e[:220])
|
||||||
|
print("ASSISTANT:", full_text[:1400])
|
||||||
|
print("USAGE in={} out={}".format(usage.get("input_tokens"), usage.get("output_tokens")))
|
||||||
156
evals/results-landing-build.md
Normal file
156
evals/results-landing-build.md
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
# Resultados — eval "montar landing" (acai-code)
|
||||||
|
|
||||||
|
Flujo fijo de 3 turnos sobre el proyecto **empleo.cocosolution.com (en TEST)**:
|
||||||
|
|
||||||
|
1. **T1** — sección sencilla: "Beneficios" con 3 tarjetas (icono, título, texto).
|
||||||
|
2. **T2** — módulo complejo: "Conoce al equipo", multi-registro v2, 3 personas con
|
||||||
|
**foto generada + nombre + puesto + testimonio + enlace LinkedIn**.
|
||||||
|
3. **T3** — edición: cambiar el puesto de una persona y borrar otra tarjeta.
|
||||||
|
|
||||||
|
Objetivo: comparar entre modelos para ver si los fallos son **del modelo** o de la
|
||||||
|
**KB/docs** (mismo flujo → si todos fallan igual, es la doc; si solo uno, es el modelo).
|
||||||
|
|
||||||
|
## Comparativa entre modelos
|
||||||
|
|
||||||
|
> ⚠️ **Corrección metodológica (importante).** Mi primera versión contaba los `tool_use`
|
||||||
|
> de los snapshots `assistant` del SSE. El agentic **re-emite el snapshot con todos los
|
||||||
|
> bloques acumulados tras cada tool** (`claude_format.py:_build_assistant_snapshot`), así que
|
||||||
|
> el mismo tool se contaba muchas veces → **los conteos de tool calls estaban inflados**
|
||||||
|
> (p.ej. "30 generate_image" cuando el `consumo_acaicode` real era **3**). El driver ya
|
||||||
|
> deduplica por id. **Solo son fiables: tokens de `result.usage`, `consumo_acaicode`, y el
|
||||||
|
> razonamiento del propio modelo.** Abajo solo se usan esas señales.
|
||||||
|
|
||||||
|
| Modelo | Fecha | Tareas OK | Tokens in (3 turnos) | Resolvió record de página | Calidad observada (razonamiento) |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `openrouter/moonshotai/kimi-k2.7-code` (medium) | 2026-06-20 | 3/3 | **~2,66M** | **NO — alucinó `num=1`** | Actúa, falla, reintenta. Edita código a ciegas (`line_replace` no casa). Mucho thrashing. |
|
||||||
|
| `deepseek/deepseek-v4-pro` (high) | 2026-06-20 | 3/3 | **~649k** | `num=267` ✅ | Explora a fondo y acierta. 0 errores. Maneja ambigüedad (Laura→Elena). |
|
||||||
|
| `z-ai/glm-5.2` (high) | 2026-06-20 | 3/3 | **~720k** | `num=267` ✅ | Sólido. Autocorrige (Twig `=`→`==`; fotos cruzadas al borrar). Maneja ambigüedad. |
|
||||||
|
|
||||||
|
Imágenes generadas (de `consumo_acaicode`): **3 por turno en los 3 modelos** — correcto, una por
|
||||||
|
persona. **No hubo sobre-generación** (era artefacto de medición).
|
||||||
|
|
||||||
|
## Conclusión modelo vs KB (3 modelos, mismo flujo, misma KB)
|
||||||
|
|
||||||
|
- **Señal autoritativa = tokens.** kimi gasta **~4× más** (~2,66M vs ~650–720k) para la MISMA
|
||||||
|
tarea → reintentos/thrashing reales (cada step reenvía contexto). Es el indicador más fiable.
|
||||||
|
- **`num=1` alucinado → MODELO (kimi).** Deepseek **y** GLM, con la **misma KB**, resolvieron el
|
||||||
|
record de la página correctamente (lo dicen en su propio razonamiento). Kimi no. **Definitivo:
|
||||||
|
es kimi, no la documentación.**
|
||||||
|
- **NO hay evidencia de un problema de KB en el flujo multi-registro/imágenes.** Lo que parecía
|
||||||
|
sobre-generación (×30) era mi bug de conteo; los tres modelos generaron 3 imágenes (correcto).
|
||||||
|
→ **Retirada** la "acción de KB #1" anterior.
|
||||||
|
- **Bug real encontrado por GLM:** al borrar un registro de un módulo multi-registro, el sistema
|
||||||
|
reutiliza nums y **las fotos quedan cruzadas**; GLM lo detectó y corrigió. Merece revisar el
|
||||||
|
flujo delete/reorder (plataforma).
|
||||||
|
- **Calidad de modelo:** kimi es claramente el más flojo; **deepseek-v4-pro y GLM-5.2 (high) son
|
||||||
|
sólidos y comparables**.
|
||||||
|
|
||||||
|
**Acciones sugeridas:** (1) **no usar kimi-k2.7 como default**; deepseek-v4-pro o GLM-5.2 (high)
|
||||||
|
son buenos. (2) Revisar el bug delete→fotos cruzadas. (3) (Opcional) re-medir con el driver
|
||||||
|
deduplicado si se quieren conteos exactos de tool calls; las conclusiones por tokens no cambian.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## kimi-k2.7-code — 2026-06-20
|
||||||
|
|
||||||
|
**Veredicto:** entrega las 3 tareas, pero con **mucho thrashing** y errores recurrentes
|
||||||
|
de los que **no aprende dentro del turno**.
|
||||||
|
|
||||||
|
### Por turno
|
||||||
|
- **T1 (beneficios):** completado. Reutilizó un módulo de tarjetas existente. Errores:
|
||||||
|
`acai_line_replace` → `HTTP_409 "Search block not found"` (edita código a ciegas) y
|
||||||
|
acceso a `record num=1` inexistente en `apartados`. Se recuperó. **1,77M tokens input**
|
||||||
|
(acumulado de ~9 steps por los reintentos).
|
||||||
|
- **T2 (equipo, multi-registro v2):** completado (módulo `conocealequipo_coco`, 3 personas,
|
||||||
|
fotos generadas, enlaces LinkedIn en nueva pestaña). Pero **`add_module_to_record` ×11
|
||||||
|
sobre el mismo módulo** (bucle en el workflow multi-registro; idempotente, devolvió el
|
||||||
|
mismo `sectionId` → NO duplicó en la página) y **2 ciclos de generación de imágenes**
|
||||||
|
(6 `generate_image` para 3 personas). 606k tokens.
|
||||||
|
- **T3 (edición):** completado limpio (**0 errores**), Carlos→CTO + Laura eliminada. Pero
|
||||||
|
**7× `list_record_uploads`** redundante. 284k tokens.
|
||||||
|
|
||||||
|
### Inventario de errores (sesión completa)
|
||||||
|
| Error | Veces | Diagnóstico |
|
||||||
|
|---|---:|---|
|
||||||
|
| `Record num=1 not found in 'apartados'` | **52** | Alucina el record de la página (real = `num=267`). **No aprende** del error y reintenta con `num=1`. |
|
||||||
|
| `Search block not found` (HTTP_409, `acai_line_replace`) | 22 | Genera bloques de búsqueda que no casan con el fichero; edita código sin verlo bien. |
|
||||||
|
| `add_module_to_record` mismo módulo | 11 | Bucle en el workflow multi-registro v2. |
|
||||||
|
|
||||||
|
- 139 tool calls · ~74 `success:false` · 148 llamadas marcadas como repetidas.
|
||||||
|
|
||||||
|
### ¿Modelo o KB? (hipótesis a confirmar con otros modelos)
|
||||||
|
- **`num=1` (×52):** huele a **KB** — falta una regla clara de "obtén el `num` real de la
|
||||||
|
página con `list_table_records` antes de operar; nunca asumas num=1". Si otros modelos
|
||||||
|
caen igual → es la doc.
|
||||||
|
- **multi-registro v2 (bucle):** probablemente **KB** — falta un doc de "cómo añadir N
|
||||||
|
registros a un módulo repetible".
|
||||||
|
- **`line_replace` a ciegas:** mezcla — la KB debería exigir `acai_view` previo y casar
|
||||||
|
exacto.
|
||||||
|
|
||||||
|
### Notas de contexto / coste (P0)
|
||||||
|
- **Cero overflow** en los 3 turnos pese a 1,77M tokens acumulados/turno → no se rompió.
|
||||||
|
- La ventana real de kimi es **262144** (256k). El catálogo OpenRouter había **caducado**
|
||||||
|
(TTL 1h) → al principio se usó budget estático; tras el self-heal (`cost.py`) ya resuelve
|
||||||
|
la ventana real. Coste real de kimi: ~$0.61 in / $3.07 out por 1M.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## deepseek-v4-pro (high) — 2026-06-20
|
||||||
|
|
||||||
|
**Veredicto: ELEGIDO** (mejor relación calidad/precio). 3/3 tareas, **0 errores**, eficiente y
|
||||||
|
**cauto ante acciones destructivas ambiguas**.
|
||||||
|
|
||||||
|
### Re-medición con driver deduplicado (números AUTORITATIVOS, baseline limpio)
|
||||||
|
| Turno | Tool calls (reales) | Errores | `generate_image` | Tokens in |
|
||||||
|
|---|---:|---:|---:|---:|
|
||||||
|
| T1 beneficios | **19** | 0 | 3 | 264k |
|
||||||
|
| T2 equipo (multi-registro) | **14** | 0 | 3 | 320k |
|
||||||
|
| T3 edición ambigua | **1** | 0 | 0 | 77k |
|
||||||
|
| **Total** | **34** | **0** | 6 | **~661k** |
|
||||||
|
|
||||||
|
- Imágenes = 3 por módulo (correcto, coincide con `consumo_acaicode`). **Sin thrashing** — los
|
||||||
|
"135/77/30" de abajo eran del artefacto de conteo, ya corregido.
|
||||||
|
- **T3 (lo mejor):** ante "quita a Laura Gómez" (no existía; sus personas eran Marina/Carlos/
|
||||||
|
Lucía), **no adivinó: paró y preguntó** a quién borrar, ofreciendo ya el cambio claro
|
||||||
|
(Carlos→CTO). Cautela correcta con un borrado ambiguo.
|
||||||
|
|
||||||
|
### Por turno (medición ANTIGUA — inflada por el artefacto, ver banner arriba)
|
||||||
|
- **T1 (beneficios):** 135 tools, **0 err**, 238k tok. Exploró el proyecto (tablas, records,
|
||||||
|
módulos), **resolvió bien `apartados num=267`**, localizó un módulo de referencia
|
||||||
|
(`sobrenosotrosbeneficios_8pjhao`) y creó un módulo nuevo con `multiv2`. Renderizó OK.
|
||||||
|
- **T2 (equipo, multi-registro v2):** 77 tools, **0 err**, 183k tok. Módulo `conocealequipo_j8m3k7`
|
||||||
|
con 3 personas, fotos y enlaces. **Pero 30 `generate_image` + 8 `add_module_to_record`**
|
||||||
|
para 3 personas → mismo thrashing del workflow multi-registro/imágenes que kimi (peor en
|
||||||
|
imágenes).
|
||||||
|
- **T3 (edición):** 27 tools, **0 err**, 228k tok. Sus personas eran Marina/Carlos/Elena;
|
||||||
|
ante "quita a Laura" razonó *"no existe Laura, asumo que es Elena"* y la quitó + Carlos→CTO.
|
||||||
|
Manejo inteligente de la ambigüedad.
|
||||||
|
|
||||||
|
- Totales: **239 tools, 0 errores, ~649k tok input** (4× más barato que kimi pese a más calls).
|
||||||
|
- Ventana real deepseek-v4-pro: **1.000.000**. Coste catálogo: ~$0.435 in / $0.87 out por 1M.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## glm-5.2 (high) — 2026-06-20 (baseline limpio)
|
||||||
|
|
||||||
|
**Veredicto:** 3/3 tareas. Comportamiento sólido y con **muy buena autocorrección**.
|
||||||
|
Mismo perfil que deepseek (explora y acierta), no aluciona el record de la página.
|
||||||
|
|
||||||
|
### Por turno
|
||||||
|
- **T1 (beneficios):** 90 tools, 250k tok. Resolvió `apartados num=267` bien. Escribió el
|
||||||
|
template Twig con `=` en un `c-if` (en vez de `==`) → fallos de `acai_write`/compilación,
|
||||||
|
pero **se autodiagnosticó** ("el compilador no convierte `=` en este contexto") y lo arregló.
|
||||||
|
- **T2 (equipo, multi-registro v2):** 77 tools, **0 err**, 232k tok. Módulo `equipococotalento_k8e2qr`.
|
||||||
|
**30 `generate_image` + 8 `add_module`** para 3 personas — idéntico a deepseek.
|
||||||
|
- **T3 (edición):** 35 tools, **0 err**, 237k tok. Sus personas eran Diego/Laura Méndez/Carmen;
|
||||||
|
infirió bien la petición. Detectó que al borrar un registro **las fotos quedaron cruzadas**
|
||||||
|
(reuso de nums 22726/22727) y las **reemplazó correctamente**.
|
||||||
|
|
||||||
|
- Totales: ~202 tools, errores solo en T1 (recuperados), ~720k tok input.
|
||||||
|
- Ventana real glm-5.2: **1.048.576**. Coste catálogo: ~$1.2 in / $4.1 out por 1M.
|
||||||
|
|
||||||
|
### Limpieza pendiente
|
||||||
|
Tras el reset, empleo (en TEST) tiene solo los módulos de la prueba de GLM:
|
||||||
|
- módulo de beneficios (`multiv2`) + `equipococotalento_k8e2qr`.
|
||||||
|
|
||||||
|
Borrar si no se quieren conservar, y **revertir empleo a producción**.
|
||||||
@@ -11,10 +11,15 @@ import { LOCAL_SERVER_URL, getLocalServerHeaders } from "../config/index.js";
|
|||||||
* automaticamente; en modo stdio no se propaga y la logica original se
|
* automaticamente; en modo stdio no se propaga y la logica original se
|
||||||
* mantiene.
|
* mantiene.
|
||||||
*/
|
*/
|
||||||
export async function fetchProjectInfo(projectName, acaiUser = null) {
|
export async function fetchProjectInfo(projectName, acaiUser = null, opts = {}) {
|
||||||
const params = typeof projectName === "string" ? { project: projectName } : (projectName || {});
|
const params = typeof projectName === "string" ? { project: projectName } : (projectName || {});
|
||||||
const headers = getLocalServerHeaders();
|
const headers = getLocalServerHeaders();
|
||||||
if (acaiUser) headers["X-Acai-User"] = acaiUser;
|
if (acaiUser) headers["X-Acai-User"] = acaiUser;
|
||||||
|
// forceMode: fuerza el modo efectivo con el que el server Python resuelve el
|
||||||
|
// web_url/api_web_url del proyecto. Lo usa el transporte MCP HTTP (plugin VS
|
||||||
|
// Code) para fijar "local" → la sesion entera apunta al web forge-local
|
||||||
|
// (test), nunca a produccion, sea cual sea el mode del .acai.
|
||||||
|
if (opts.forceMode) headers["X-Acai-Mode"] = opts.forceMode;
|
||||||
const response = await axios.get(`${LOCAL_SERVER_URL}/api/project-info`, {
|
const response = await axios.get(`${LOCAL_SERVER_URL}/api/project-info`, {
|
||||||
params,
|
params,
|
||||||
headers,
|
headers,
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ export const CONFIG_FILE_PATH =
|
|||||||
|
|
||||||
export const MCP_PORT = Number(process.env.MCP_PORT || 3000);
|
export const MCP_PORT = Number(process.env.MCP_PORT || 3000);
|
||||||
export const MONITOR_PORT = Number(process.env.MCP_MONITOR_PORT || 4545);
|
export const MONITOR_PORT = Number(process.env.MCP_MONITOR_PORT || 4545);
|
||||||
|
// El monitor HTTP (UI + POST /retry) queda DESACTIVADO por defecto. Solo se
|
||||||
|
// arranca si MCP_MONITOR_ENABLED === 'true' de forma explicita.
|
||||||
|
export const MONITOR_ENABLED =
|
||||||
|
String(process.env.MCP_MONITOR_ENABLED || "").toLowerCase() === "true";
|
||||||
|
// Por seguridad escucha solo en loopback salvo que se defina MCP_MONITOR_HOST.
|
||||||
|
export const MONITOR_HOST = process.env.MCP_MONITOR_HOST || "127.0.0.1";
|
||||||
|
// Compatibilidad: si alguien fuerza MCP_MONITOR_DISABLED tambien lo respetamos.
|
||||||
export const MONITOR_DISABLED =
|
export const MONITOR_DISABLED =
|
||||||
String(process.env.MCP_MONITOR_DISABLED || "").toLowerCase() === "1" ||
|
String(process.env.MCP_MONITOR_DISABLED || "").toLowerCase() === "1" ||
|
||||||
String(process.env.MCP_MONITOR_DISABLED || "").toLowerCase() === "true";
|
String(process.env.MCP_MONITOR_DISABLED || "").toLowerCase() === "true";
|
||||||
|
|||||||
@@ -76,7 +76,12 @@ const verifyJwt = (token) => {
|
|||||||
|
|
||||||
const resolveProjectCredentials = async (projectName, acaiUser = null) => {
|
const resolveProjectCredentials = async (projectName, acaiUser = null) => {
|
||||||
try {
|
try {
|
||||||
const info = await fetchProjectInfo(projectName, acaiUser);
|
// El transporte MCP HTTP es exclusivo de clientes externos (plugin VS
|
||||||
|
// Code Acai Forge). Por politica solo pueden operar sobre TEST: forzamos
|
||||||
|
// mode=local al resolver el proyecto, de modo que web_url/api_web_url
|
||||||
|
// apunten al web forge-local y TODA la sesion (records, modules, git,
|
||||||
|
// media...) use el destino de test, nunca produccion.
|
||||||
|
const info = await fetchProjectInfo(projectName, acaiUser, { forceMode: "local" });
|
||||||
if (!info.success) {
|
if (!info.success) {
|
||||||
throw new Error(info.error || "Failed to resolve project info");
|
throw new Error(info.error || "Failed to resolve project info");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// Load configuration first
|
// Load configuration first
|
||||||
import { loadLocalConfigProfile, applyProfileToEnv } from "./config/index.js";
|
import { loadLocalConfigProfile, applyProfileToEnv, MONITOR_ENABLED, MONITOR_DISABLED } from "./config/index.js";
|
||||||
|
|
||||||
// Load and apply config profile (backward compatibility)
|
// Load and apply config profile (backward compatibility)
|
||||||
const selectedProfile = loadLocalConfigProfile();
|
const selectedProfile = loadLocalConfigProfile();
|
||||||
@@ -30,8 +30,11 @@ import { registerResources } from "./resources/index.js";
|
|||||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
setRegistrationFunctions({ registerPrompts, registerTools, registerResources });
|
setRegistrationFunctions({ registerPrompts, registerTools, registerResources });
|
||||||
|
|
||||||
// Create the shared request monitor (will be applied to each session server)
|
// Create the shared request monitor (will be applied to each session server).
|
||||||
const requestMonitor = createRequestMonitor();
|
// Solo se crea si el monitor esta habilitado: asi no acumulamos historial en
|
||||||
|
// memoria ni envolvemos los handlers cuando la UI esta apagada (por defecto).
|
||||||
|
const monitorActive = MONITOR_ENABLED && !MONITOR_DISABLED;
|
||||||
|
const requestMonitor = monitorActive ? createRequestMonitor() : null;
|
||||||
|
|
||||||
// Create a server instance for retry functionality in the monitor UI
|
// Create a server instance for retry functionality in the monitor UI
|
||||||
const server = createMcpServer();
|
const server = createMcpServer();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import http from "node:http";
|
|||||||
import fsPromises from "node:fs/promises";
|
import fsPromises from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { MONITOR_PORT, MONITOR_DISABLED } from "./config/index.js";
|
import { MONITOR_PORT, MONITOR_HOST, MONITOR_ENABLED, MONITOR_DISABLED } from "./config/index.js";
|
||||||
import { sessionCredentials } from "./auth/credentials.js";
|
import { sessionCredentials } from "./auth/credentials.js";
|
||||||
import { activeSessions } from "./httpServer.js";
|
import { activeSessions } from "./httpServer.js";
|
||||||
|
|
||||||
@@ -84,8 +84,8 @@ export function broadcastSessionsUpdate() {
|
|||||||
* Start the monitor HTTP server
|
* Start the monitor HTTP server
|
||||||
*/
|
*/
|
||||||
export function startMonitorServer(requestMonitor, toolHandlers) {
|
export function startMonitorServer(requestMonitor, toolHandlers) {
|
||||||
if (MONITOR_DISABLED) {
|
if (!MONITOR_ENABLED || MONITOR_DISABLED) {
|
||||||
console.error("MCP monitor UI deshabilitada (MCP_MONITOR_DISABLED=1).");
|
console.error("[monitor] deshabilitado (MCP_MONITOR_ENABLED!=true)");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,12 +202,12 @@ export function startMonitorServer(requestMonitor, toolHandlers) {
|
|||||||
|
|
||||||
monitorServer.on("error", (error) => {
|
monitorServer.on("error", (error) => {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[monitor] No se pudo iniciar la UI en el puerto ${MONITOR_PORT}: ${error.message}. Establece MCP_MONITOR_DISABLED=1 para ocultar este aviso.`
|
`[monitor] No se pudo iniciar la UI en ${MONITOR_HOST}:${MONITOR_PORT}: ${error.message}. Desactiva MCP_MONITOR_ENABLED para ocultar este aviso.`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
monitorServer.listen(MONITOR_PORT, '0.0.0.0', () => {
|
monitorServer.listen(MONITOR_PORT, MONITOR_HOST, () => {
|
||||||
console.error(`MCP monitor UI: http://0.0.0.0:${MONITOR_PORT}/monitor`);
|
console.error(`MCP monitor UI: http://${MONITOR_HOST}:${MONITOR_PORT}/monitor`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Broadcast sessions + stats update every 2 seconds for real-time monitoring
|
// Broadcast sessions + stats update every 2 seconds for real-time monitoring
|
||||||
|
|||||||
@@ -1,20 +1,41 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lectura directa de los markdown del knowledge base desde el filesystem.
|
* Lectura directa de los markdown del knowledge base desde el filesystem.
|
||||||
*
|
*
|
||||||
* El MCP server corre dentro del container `agentic` junto al FastAPI, asi
|
* Orden de resolucion del directorio de docs:
|
||||||
* que los .md viven en `/app/docs/` (la imagen los copia ahi).
|
* 1. `ACAI_DOCS_DIR` — override explicito por entorno (si esta definido y no vacio).
|
||||||
*
|
* 2. `<ACAI_PROJECT_DIR>/docs` — caso principal: cada proyecto/web tiene su
|
||||||
* En caso de override por entorno, respeta `ACAI_DOCS_DIR`. En desarrollo
|
* propio `docs/`. El `.mcp.json` inyecta `ACAI_PROJECT_DIR` (p.ej.
|
||||||
* fuera del container, fallback a paths relativos al cwd.
|
* `/opt/acai/webs/<user>/<site>`), funciona tanto en local (VSCode) como
|
||||||
|
* en cloud (agentic).
|
||||||
|
* 3. `/app/docs` — fallback final: container `agentic` donde esta horneada la
|
||||||
|
* copia canonica de los .md.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
function dirExists(p) {
|
||||||
|
try {
|
||||||
|
return existsSync(p);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resolveDocsDir() {
|
function resolveDocsDir() {
|
||||||
|
// 1. Override explicito
|
||||||
const override = process.env.ACAI_DOCS_DIR;
|
const override = process.env.ACAI_DOCS_DIR;
|
||||||
if (override) return override;
|
if (override && override.trim() !== "") return override;
|
||||||
// Container path
|
|
||||||
|
// 2. Docs del proyecto/web
|
||||||
|
const projectDir = process.env.ACAI_PROJECT_DIR;
|
||||||
|
if (projectDir && projectDir.trim() !== "") {
|
||||||
|
const projectDocs = path.join(projectDir, "docs");
|
||||||
|
if (dirExists(projectDocs)) return projectDocs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fallback al container agentic
|
||||||
return "/app/docs";
|
return "/app/docs";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import path from "path";
|
|||||||
import { LOCAL_SERVER_URL, getLocalServerHeaders } from "../../config/index.js";
|
import { LOCAL_SERVER_URL, getLocalServerHeaders } from "../../config/index.js";
|
||||||
import { getCurrentSessionId } from "../../utils/sessionContext.js";
|
import { getCurrentSessionId } from "../../utils/sessionContext.js";
|
||||||
import { getMcpSessionCredentials } from "../../auth/credentials.js";
|
import { getMcpSessionCredentials } from "../../auth/credentials.js";
|
||||||
import { resolveCurrentAcaiUser } from "../helpers/sessionHelpers.js";
|
import { resolveCurrentAcaiUser, resolveCurrentModeOverride } from "../helpers/sessionHelpers.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resuelve `project_dir` para la tool en curso.
|
* Resuelve `project_dir` para la tool en curso.
|
||||||
@@ -37,6 +37,14 @@ export function getCurrentProjectInfo() {
|
|||||||
|
|
||||||
export async function callLocalFileEndpoint(method, endpoint, payload = null, query = null) {
|
export async function callLocalFileEndpoint(method, endpoint, payload = null, query = null) {
|
||||||
const headers = getLocalServerHeaders();
|
const headers = getLocalServerHeaders();
|
||||||
|
const authHeader = process.env.ACAI_AUTH_HEADER || "";
|
||||||
|
const 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
|
// Inyectar X-Acai-User cuando hay sesion HTTP activa: permite que los
|
||||||
// endpoints autenticados del server Python identifiquen al usuario sin
|
// endpoints autenticados del server Python identifiquen al usuario sin
|
||||||
// depender de Authorization Basic.
|
// depender de Authorization Basic.
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import axios from "axios";
|
import { AcaiHttpClient } from "./acaiHttpClient.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to save files using saveFileBuilder action
|
* Helper to save files using saveFileBuilder action.
|
||||||
|
* Delega en AcaiHttpClient.postViewerAction, que construye la URL con
|
||||||
|
* api_web_url + el header Host (forge_host) y aplica assertSafeCmsTarget.
|
||||||
* Used by multiple tools (save.js, saveGeneralSection.js, write.js, etc.)
|
* Used by multiple tools (save.js, saveGeneralSection.js, write.js, etc.)
|
||||||
*
|
*
|
||||||
* @param {Object} params
|
* @param {Object} params
|
||||||
* @param {string} params.web_url - URL base del sitio (ej: http://localhost:PORT)
|
* @param {Object} params.credentials - Target completo (web_url, api_web_url, forge_host, mode)
|
||||||
* @param {string} params.token - Session token
|
* @param {string} params.token - Session token
|
||||||
* @param {string} params.tokenHash - Token hash
|
* @param {string} params.tokenHash - Token hash
|
||||||
* @param {string} params.path - Folder path (e.g., '/modulos/mymodule/')
|
* @param {string} params.path - Folder path (e.g., '/modulos/mymodule/')
|
||||||
@@ -14,7 +16,7 @@ import axios from "axios";
|
|||||||
* @returns {Promise<Object>} Response from the API
|
* @returns {Promise<Object>} Response from the API
|
||||||
*/
|
*/
|
||||||
export async function saveFileBuilder({
|
export async function saveFileBuilder({
|
||||||
web_url,
|
credentials,
|
||||||
token,
|
token,
|
||||||
tokenHash,
|
tokenHash,
|
||||||
path,
|
path,
|
||||||
@@ -26,12 +28,7 @@ export async function saveFileBuilder({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewerUrl = web_url + '/cms/lib/viewer_functions.php';
|
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
action_ws: 'saveFileBuilder',
|
|
||||||
token: token,
|
|
||||||
tokenHash: tokenHash,
|
|
||||||
fileName: fileName,
|
fileName: fileName,
|
||||||
content: content,
|
content: content,
|
||||||
rawDataSended: rawDataSended,
|
rawDataSended: rawDataSended,
|
||||||
@@ -39,14 +36,17 @@ export async function saveFileBuilder({
|
|||||||
path: path
|
path: path
|
||||||
};
|
};
|
||||||
|
|
||||||
console.error(`[saveFileBuilder] URL: ${viewerUrl}`);
|
|
||||||
console.error(`[saveFileBuilder] Path: ${path}`);
|
console.error(`[saveFileBuilder] Path: ${path}`);
|
||||||
console.error(`[saveFileBuilder] Content length: ${content.length} chars`);
|
console.error(`[saveFileBuilder] Content length: ${content.length} chars`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(viewerUrl, payload, {
|
const response = await AcaiHttpClient.postViewerAction(
|
||||||
headers: { "Content-Type": "application/json" }
|
credentials,
|
||||||
});
|
'saveFileBuilder',
|
||||||
|
payload,
|
||||||
|
token,
|
||||||
|
tokenHash
|
||||||
|
);
|
||||||
|
|
||||||
console.error(`[saveFileBuilder] Response for ${fileName}:`, JSON.stringify(response.data, null, 2));
|
console.error(`[saveFileBuilder] Response for ${fileName}:`, JSON.stringify(response.data, null, 2));
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ export async function saveFileBuilder({
|
|||||||
* Helper to save multiple files at once
|
* Helper to save multiple files at once
|
||||||
*
|
*
|
||||||
* @param {Object} params
|
* @param {Object} params
|
||||||
* @param {string} params.web_url - URL base del sitio (ej: http://localhost:PORT)
|
* @param {Object} params.credentials - Target completo (web_url, api_web_url, forge_host, mode)
|
||||||
* @param {string} params.token - Session token
|
* @param {string} params.token - Session token
|
||||||
* @param {string} params.tokenHash - Token hash
|
* @param {string} params.tokenHash - Token hash
|
||||||
* @param {string} params.path - Folder path (e.g., '/modulos/mymodule/')
|
* @param {string} params.path - Folder path (e.g., '/modulos/mymodule/')
|
||||||
@@ -77,7 +77,7 @@ export async function saveFileBuilder({
|
|||||||
* @returns {Promise<Object>} Results for each file
|
* @returns {Promise<Object>} Results for each file
|
||||||
*/
|
*/
|
||||||
export async function saveMultipleFiles({
|
export async function saveMultipleFiles({
|
||||||
web_url,
|
credentials,
|
||||||
token,
|
token,
|
||||||
tokenHash,
|
tokenHash,
|
||||||
path,
|
path,
|
||||||
@@ -88,7 +88,7 @@ export async function saveMultipleFiles({
|
|||||||
for (const [fileName, content] of Object.entries(files)) {
|
for (const [fileName, content] of Object.entries(files)) {
|
||||||
if (content) {
|
if (content) {
|
||||||
results[fileName] = await saveFileBuilder({
|
results[fileName] = await saveFileBuilder({
|
||||||
web_url,
|
credentials,
|
||||||
token,
|
token,
|
||||||
tokenHash,
|
tokenHash,
|
||||||
path,
|
path,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { resolveCurrentAcaiUser } from "./sessionHelpers.js";
|
import { resolveCurrentAcaiUser, resolveCurrentModeOverride } from "./sessionHelpers.js";
|
||||||
|
|
||||||
const PYTHON_BASE = `http://app:${process.env.ACAI_PORT || 9091}`;
|
const PYTHON_BASE = `http://app:${process.env.ACAI_PORT || 9091}`;
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ const PYTHON_BASE = `http://app:${process.env.ACAI_PORT || 9091}`;
|
|||||||
*/
|
*/
|
||||||
function buildPythonHeaders(extra = {}) {
|
function buildPythonHeaders(extra = {}) {
|
||||||
const authHeader = process.env.ACAI_AUTH_HEADER || "";
|
const authHeader = process.env.ACAI_AUTH_HEADER || "";
|
||||||
const mode = process.env.ACAI_MODE_OVERRIDE || process.env.ACAI_MODE || "";
|
const mode = resolveCurrentModeOverride();
|
||||||
const role = process.env.ACAI_ROLE_OVERRIDE || "";
|
const role = process.env.ACAI_ROLE_OVERRIDE || "";
|
||||||
const acaiUser = resolveCurrentAcaiUser();
|
const acaiUser = resolveCurrentAcaiUser();
|
||||||
|
|
||||||
@@ -43,3 +43,20 @@ export async function pythonGet(path, params = null, timeout = 30000) {
|
|||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET binario al server Python (p.ej. /api/image-bytes). Devuelve
|
||||||
|
* { buffer: Buffer, mimeType: string }. Lanza si el status no es 2xx.
|
||||||
|
*/
|
||||||
|
export async function pythonGetBinary(path, params = null, timeout = 30000) {
|
||||||
|
const response = await axios.get(`${PYTHON_BASE}${path}`, {
|
||||||
|
params: params || undefined,
|
||||||
|
headers: buildPythonHeaders(),
|
||||||
|
responseType: "arraybuffer",
|
||||||
|
timeout,
|
||||||
|
maxContentLength: Infinity,
|
||||||
|
});
|
||||||
|
const mimeType = (response.headers?.["content-type"] || "").split(";")[0].trim()
|
||||||
|
|| "application/octet-stream";
|
||||||
|
return { buffer: Buffer.from(response.data), mimeType };
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,3 +25,23 @@ export function resolveCurrentAcaiUser() {
|
|||||||
const creds = getMcpSessionCredentials(sessionId);
|
const creds = getMcpSessionCredentials(sessionId);
|
||||||
return creds?.acai_user || null;
|
return creds?.acai_user || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modo efectivo (X-Acai-Mode) para las llamadas al server Python.
|
||||||
|
*
|
||||||
|
* Regla de seguridad: una sesion MCP HTTP (mcpSessionId presente) es SIEMPRE un
|
||||||
|
* cliente externo — en la practica el plugin VS Code Acai Forge — y solo puede
|
||||||
|
* operar sobre TEST. Por eso forzamos "local" pase lo que pase el .acai del
|
||||||
|
* proyecto. El server Python honra este header para decidir el destino real
|
||||||
|
* (BD y ficheros), de modo que vscode nunca toca produccion.
|
||||||
|
*
|
||||||
|
* Las sesiones stdio (chat del dashboard / cronjobs) NO tienen mcpSessionId:
|
||||||
|
* mantienen el override de entorno (ACAI_MODE_OVERRIDE), que puede ser
|
||||||
|
* "production" cuando corresponde (chat en modo produccion, cron de prod).
|
||||||
|
*
|
||||||
|
* @returns {string} "local" | "production" | "" (vacio = usar .acai)
|
||||||
|
*/
|
||||||
|
export function resolveCurrentModeOverride() {
|
||||||
|
if (getCurrentSessionId()) return "local";
|
||||||
|
return process.env.ACAI_MODE_OVERRIDE || process.env.ACAI_MODE || "";
|
||||||
|
}
|
||||||
|
|||||||
197
mcp-server/tools/media/analyze_image.js
Normal file
197
mcp-server/tools/media/analyze_image.js
Normal 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 });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { registerUploadRecordImageTool } from './upload.js';
|
import { registerUploadRecordImageTool } from './upload.js';
|
||||||
import { registerUploadImageToAssetsTool } from './uploadImageToAssets.js';
|
import { registerUploadImageToAssetsTool } from './uploadImageToAssets.js';
|
||||||
import { registerGenerateImageTool } from './generateImage.js';
|
import { registerGenerateImageTool } from './generateImage.js';
|
||||||
|
import { registerAnalyzeImageTool } from './analyze_image.js';
|
||||||
|
|
||||||
export function registerMediaTools(server) {
|
export function registerMediaTools(server) {
|
||||||
registerUploadRecordImageTool(server);
|
registerUploadRecordImageTool(server);
|
||||||
registerUploadImageToAssetsTool(server);
|
registerUploadImageToAssetsTool(server);
|
||||||
registerGenerateImageTool(server);
|
registerGenerateImageTool(server);
|
||||||
|
registerAnalyzeImageTool(server);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { withAuth, getSessionCredentials } from "../../auth/index.js";
|
|||||||
import { handleToolError, validateRequired, handleApiResponse } from "../helpers/errorHandler.js";
|
import { handleToolError, validateRequired, handleApiResponse } from "../helpers/errorHandler.js";
|
||||||
import { withAuthParams } from "../helpers/authSchema.js";
|
import { withAuthParams } from "../helpers/authSchema.js";
|
||||||
import { AcaiHttpClient } from "../helpers/acaiHttpClient.js";
|
import { AcaiHttpClient } from "../helpers/acaiHttpClient.js";
|
||||||
import { pythonPost } from "../helpers/pythonServerClient.js";
|
import { pythonPost, pythonGetBinary } from "../helpers/pythonServerClient.js";
|
||||||
import { resolveCurrentProjectDir } from "../files/helpers.js";
|
import { resolveCurrentProjectDir } from "../files/helpers.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,79 +34,43 @@ async function mcpPost(target, actionWs, payload, token, tokenHash) {
|
|||||||
* null si la URL no es local (usar imageUrl directamente)
|
* null si la URL no es local (usar imageUrl directamente)
|
||||||
*/
|
*/
|
||||||
async function resolveLocalImageAsBase64(imageUrl) {
|
async function resolveLocalImageAsBase64(imageUrl) {
|
||||||
const LOCAL_HOSTS = ["localhost", "127.0.0.1", "acai-app", "host.docker.internal"];
|
const LOCAL_HOSTS = ["localhost", "127.0.0.1", "acai-app", "acai-web", "web", "host.docker.internal"];
|
||||||
|
|
||||||
// Caso 1: Path absoluto del filesystem (e.g. /opt/acai/webs/.../cms/uploads/x.jpg)
|
// URL http(s) con host NO local → es una URL pública real: usar tal cual (null).
|
||||||
if (typeof imageUrl === "string" && imageUrl.startsWith("/") && !imageUrl.startsWith("//")) {
|
if (typeof imageUrl === "string" && /^https?:\/\//i.test(imageUrl)) {
|
||||||
try {
|
let parsed;
|
||||||
if (fs.existsSync(imageUrl) && fs.statSync(imageUrl).isFile()) {
|
try { parsed = new URL(imageUrl); } catch { return null; }
|
||||||
const buffer = fs.readFileSync(imageUrl);
|
if (!LOCAL_HOSTS.includes(parsed.hostname)) return null;
|
||||||
return {
|
|
||||||
fileBase64: buffer.toString("base64"),
|
|
||||||
fileName: path.basename(imageUrl),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[upload] Failed to read filesystem path ${imageUrl}: ${error.message}`);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Caso 2: URL HTTP — verificar si es local
|
// Ruta del proyecto, host local o chat-preview → los bytes los resuelve el
|
||||||
let parsed;
|
// server Python (/api/image-bytes): disco para standalone, fetch de producción
|
||||||
|
// para imágenes Acai cuyo fichero local es un stub.
|
||||||
|
const project = path.basename(resolveCurrentProjectDir() || "");
|
||||||
|
if (!project) return null;
|
||||||
try {
|
try {
|
||||||
parsed = new URL(imageUrl);
|
const { buffer } = await pythonGetBinary("/api/image-bytes", { project, ref: imageUrl });
|
||||||
} catch {
|
let fileName = "image.jpg";
|
||||||
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) {
|
|
||||||
try {
|
try {
|
||||||
const localPath = path.join(projectDir, parsed.pathname);
|
const p = imageUrl.startsWith("/") ? imageUrl : new URL(imageUrl).pathname;
|
||||||
if (fs.existsSync(localPath) && fs.statSync(localPath).isFile()) {
|
fileName = (p.split("?")[0].split("/").pop()) || "image.jpg";
|
||||||
const buffer = fs.readFileSync(localPath);
|
} catch { /* keep default */ }
|
||||||
return {
|
return { fileBase64: buffer.toString("base64"), fileName };
|
||||||
fileBase64: buffer.toString("base64"),
|
} catch (error) {
|
||||||
fileName: path.basename(localPath),
|
console.error(`[upload] /api/image-bytes falló para ${imageUrl}: ${error.message}`);
|
||||||
};
|
return null;
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[upload] Filesystem fallback failed for ${imageUrl}: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerUploadRecordImageTool(server) {
|
export function registerUploadRecordImageTool(server) {
|
||||||
server.tool(
|
server.tool(
|
||||||
"upload_record_image",
|
"upload_record_image",
|
||||||
"Upload an image to a specific record field in Acai CMS. MANDATORY: before calling this tool, you MUST call get_table_schema with minimal=true to find the EXACT upload field name. Look for fields with type='upload'. NEVER guess field names. Table names WITHOUT 'cms_' prefix. recordId is 'num', never 'id'. If the URL came from generate_image, prefer uploadUrl (or fullUrl) over dockerUrl.",
|
"Upload an image to a specific record field in Acai CMS. MANDATORY: before calling this tool, you MUST call get_table_schema with minimal=true to find the EXACT upload field name. Look for fields with type='upload'. NEVER guess field names. Table names WITHOUT 'cms_' prefix. recordId is 'num', never 'id'. If the URL came from generate_image, prefer uploadUrl (or fullUrl) over dockerUrl. For a LOCAL or pasted image (a file on your machine, no public URL): save it into the synced project folder cms/uploads/chat/<name>.ext, wait for the sync to push it, then pass its PROJECT-RELATIVE path (e.g. 'cms/uploads/chat/foto.png') as imageUrl. NEVER pass a data-URI/base64 nor spin up a localhost server.",
|
||||||
withAuthParams({
|
withAuthParams({
|
||||||
tableName: z.string().describe("Table name without 'cms_' prefix (e.g., 'productos')"),
|
tableName: z.string().describe("Table name without 'cms_' prefix (e.g., 'productos')"),
|
||||||
recordId: z.string().describe("Record 'num' (primary key)"),
|
recordId: z.string().describe("Record 'num' (primary key)"),
|
||||||
fieldName: z.string().describe("EXACT field name from the schema. MUST match a field with type 'upload' from get_table_schema or get_module_config_vars. Do NOT guess."),
|
fieldName: z.string().describe("EXACT field name from the schema. MUST match a field with type 'upload' from get_table_schema or get_module_config_vars. Do NOT guess."),
|
||||||
imageUrl: z.string().describe("URL of the image to upload"),
|
imageUrl: z.string().describe("Image to upload: an http(s) URL, OR a project-relative path to a file already synced to the project (e.g. 'cms/uploads/chat/foto.png'). For local/pasted images use the relative-path form. NOT a data-URI or base64."),
|
||||||
alt: z.string().optional().describe("Alt text for the image (optional)"),
|
alt: z.string().optional().describe("Alt text for the image (optional)"),
|
||||||
}),
|
}),
|
||||||
{ readOnlyHint: false, destructiveHint: false },
|
{ readOnlyHint: false, destructiveHint: false },
|
||||||
@@ -119,6 +83,39 @@ export function registerUploadRecordImageTool(server) {
|
|||||||
);
|
);
|
||||||
if (validationError) return validationError;
|
if (validationError) return validationError;
|
||||||
|
|
||||||
|
// Aceptamos: URL http(s), ruta absoluta del servidor, o RUTA
|
||||||
|
// RELATIVA del proyecto (p.ej. "cms/uploads/chat/foto.png"). Para
|
||||||
|
// una imagen local/pegada el flujo correcto es guardarla en una
|
||||||
|
// carpeta sincronizada NO truncada (cms/uploads/chat/ o
|
||||||
|
// cms/uploads/generated/), dejar que el sync la suba a test y pasar
|
||||||
|
// aquí su ruta relativa: el server lee los bytes de disco vía
|
||||||
|
// resolve_image_source (sin base64 por el modelo).
|
||||||
|
// Seguimos rechazando data-URI / base64 crudo: derivar el nombre
|
||||||
|
// de un base64 gigante revienta file_put_contents ("File name too
|
||||||
|
// long"). El tope de longitud + charset de ruta lo descartan.
|
||||||
|
const trimmedImage = imageUrl.trim();
|
||||||
|
const isHttpUrl = /^https?:\/\//i.test(trimmedImage);
|
||||||
|
const isAbsPath = trimmedImage.startsWith("/") && !trimmedImage.startsWith("//");
|
||||||
|
const isRelPath = !isHttpUrl && !isAbsPath
|
||||||
|
&& !/^[a-z][a-z0-9+.-]*:/i.test(trimmedImage) // sin esquema (data:, file:...)
|
||||||
|
&& !trimmedImage.includes("..")
|
||||||
|
&& trimmedImage.length <= 512
|
||||||
|
&& /^[\w./ -]+$/.test(trimmedImage); // charset de ruta (no base64)
|
||||||
|
if (!isHttpUrl && !isAbsPath && !isRelPath) {
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify({
|
||||||
|
error: "imageUrl debe ser una URL http(s) o una ruta relativa del proyecto " +
|
||||||
|
"(p.ej. 'cms/uploads/chat/foto.png'), no un data-URI ni base64 crudo. " +
|
||||||
|
"Para una imagen local/pegada: guárdala en cms/uploads/chat/ (sincronizada a test), " +
|
||||||
|
"espera a que el sync la suba y pasa su ruta relativa."
|
||||||
|
}, null, 2)
|
||||||
|
}],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const projectSlug = path.basename(resolveCurrentProjectDir());
|
const projectSlug = path.basename(resolveCurrentProjectDir());
|
||||||
|
|
||||||
// Intentar via Python server (tiene sync + optimizacion)
|
// Intentar via Python server (tiene sync + optimizacion)
|
||||||
@@ -238,7 +235,7 @@ export function registerUploadRecordImageTool(server) {
|
|||||||
recordId: z.string().describe("Record 'num' (primary key)"),
|
recordId: z.string().describe("Record 'num' (primary key)"),
|
||||||
fieldName: z.string().describe("Upload field name"),
|
fieldName: z.string().describe("Upload field name"),
|
||||||
uploadId: z.string().describe("Upload ID to replace (get from list_record_uploads)"),
|
uploadId: z.string().describe("Upload ID to replace (get from list_record_uploads)"),
|
||||||
imageUrl: z.string().describe("URL of the new image to upload"),
|
imageUrl: z.string().describe("New image: an http(s) URL, OR a project-relative path to a file already synced (e.g. 'cms/uploads/chat/foto.png'). For local/pasted images use the relative-path form. NOT a data-URI or base64."),
|
||||||
alt: z.string().optional().describe("Alt text for the image (optional)"),
|
alt: z.string().optional().describe("Alt text for the image (optional)"),
|
||||||
}),
|
}),
|
||||||
{ readOnlyHint: false, destructiveHint: false },
|
{ readOnlyHint: false, destructiveHint: false },
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ export function registerUploadImageToAssetsTool(server) {
|
|||||||
|
|
||||||
// Upload using saveFileBuilder
|
// Upload using saveFileBuilder
|
||||||
const uploadResult = await saveFileBuilder({
|
const uploadResult = await saveFileBuilder({
|
||||||
web_url: credentials.api_web_url || credentials.web_url,
|
credentials,
|
||||||
token: credentials.token,
|
token: credentials.token,
|
||||||
tokenHash: credentials.tokenHash,
|
tokenHash: credentials.tokenHash,
|
||||||
path: assetsPath,
|
path: assetsPath,
|
||||||
|
|||||||
2
mcp.json
2
mcp.json
@@ -19,7 +19,7 @@
|
|||||||
"command": "uvx",
|
"command": "uvx",
|
||||||
"args": ["mcp-server-fetch"],
|
"args": ["mcp-server-fetch"],
|
||||||
"timeout": 30,
|
"timeout": 30,
|
||||||
"startup_timeout": 15
|
"startup_timeout": 30
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ pydantic-settings>=2.7.0,<3.0.0
|
|||||||
redis[hiredis]>=5.2.0,<6.0.0
|
redis[hiredis]>=5.2.0,<6.0.0
|
||||||
anthropic>=0.42.0,<1.0.0
|
anthropic>=0.42.0,<1.0.0
|
||||||
openai>=1.60.0,<2.0.0
|
openai>=1.60.0,<2.0.0
|
||||||
|
litellm==1.80.0
|
||||||
httpx>=0.28.0,<1.0.0
|
httpx>=0.28.0,<1.0.0
|
||||||
sse-starlette>=2.2.0,<3.0.0
|
sse-starlette>=2.2.0,<3.0.0
|
||||||
tiktoken>=0.7.0,<1.0.0
|
tiktoken>=0.7.0,<1.0.0
|
||||||
|
|||||||
@@ -7,6 +7,15 @@ from dataclasses import dataclass, field
|
|||||||
from typing import Any, AsyncIterator
|
from typing import Any, AsyncIterator
|
||||||
|
|
||||||
|
|
||||||
|
class ContextOverflowError(Exception):
|
||||||
|
"""El contexto excede la ventana del modelo (proveedor lo rechazó).
|
||||||
|
|
||||||
|
Excepción de dominio para desacoplar el orquestador de litellm: los adapters
|
||||||
|
la lanzan al detectar un error de context-length, y el loop del agente decide
|
||||||
|
si reintentar con compactación más agresiva o devolver un error accionable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class StreamChunk:
|
class StreamChunk:
|
||||||
"""A single chunk from a streaming model response.
|
"""A single chunk from a streaming model response.
|
||||||
@@ -57,6 +66,10 @@ class ModelConfig:
|
|||||||
max_tokens: int = 4096
|
max_tokens: int = 4096
|
||||||
temperature: float = 0.3
|
temperature: float = 0.3
|
||||||
stop_sequences: list[str] = field(default_factory=list)
|
stop_sequences: list[str] = field(default_factory=list)
|
||||||
|
# Nivel de razonamiento (minimal|low|medium|high). Vacío = sin razonamiento
|
||||||
|
# explícito. LiteLLM lo traduce por proveedor; modelos que no lo soportan lo
|
||||||
|
# ignoran (litellm.drop_params=True).
|
||||||
|
reasoning_effort: str = ""
|
||||||
extra: dict[str, Any] = field(default_factory=dict)
|
extra: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,20 +17,24 @@ from .base import ModelAdapter, ModelConfig, ModelResponse, StreamChunk
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# Algunos fine-tunes (sobre todo MiniMax) ocasionalmente emiten las tool calls
|
# Algunos fine-tunes (sobre todo MiniMax y DeepSeek) ocasionalmente emiten las
|
||||||
# como texto literal en lugar de usar los `tool_use` blocks nativos. Vistos
|
# tool calls como texto literal en lugar de usar los `tool_use` blocks nativos.
|
||||||
# tres formatos:
|
# Vistos cuatro formatos:
|
||||||
# 1) <minimax:tool_call><invoke name="X"><parameter name="P">V</parameter></invoke></minimax:tool_call>
|
# 1) <minimax:tool_call><invoke name="X"><parameter name="P">V</parameter></invoke></minimax:tool_call>
|
||||||
# 2) <invoke name="X"><parameter name="P">V</parameter></invoke> (sin minimax wrapper)
|
# 2) <invoke name="X"><parameter name="P">V</parameter></invoke> (sin minimax wrapper)
|
||||||
# 3) <tool_call>{"name":"X","parameters":{...}}{"name":"Y","parameters":{...}}</tool_call>
|
# 3) <tool_call>{"name":"X","parameters":{...}}{"name":"Y","parameters":{...}}</tool_call>
|
||||||
# (multiples tool calls JSON-encoded dentro de un solo wrapper)
|
# (multiples tool calls JSON-encoded dentro de un solo wrapper)
|
||||||
|
# 4) <||DSML||tool_calls><||DSML||invoke name="X"><||DSML||parameter name="P" string="true">V</||DSML||parameter></||DSML||invoke></||DSML||tool_calls>
|
||||||
|
# (formato DSML de DeepSeek — usa U+FF5C fullwidth vertical line como separador)
|
||||||
#
|
#
|
||||||
# Cuando eso pasa el orquestador ve "texto" y la tool nunca se ejecuta — el
|
# Cuando eso pasa el orquestador ve "texto" y la tool nunca se ejecuta — el
|
||||||
# usuario ve el markup crudo en el chat. Detectamos y convertimos a tool_use
|
# usuario ve el markup crudo en el chat. Detectamos y convertimos a tool_use
|
||||||
# sintetico mientras streameamos. Es un parche defensivo: el caso normal
|
# sintetico mientras streameamos. Es un parche defensivo: el caso normal
|
||||||
# (tool_use blocks) sigue por el camino estandar.
|
# (tool_use blocks) sigue por el camino estandar.
|
||||||
_TOOL_CALL_OPEN_RE = re.compile(
|
_TOOL_CALL_OPEN_RE = re.compile(
|
||||||
r"<(?:minimax:tool_call|invoke\s+name|tool_call\s*>)|\[TOOL_CALL\]",
|
# `<|` (U+FF5C) cubre cualquier special-token DeepSeek (DSML): <|DSML|invoke,
|
||||||
|
# <|tool_calls, etc. Tolerante a 1+ pipes y a la presencia/ausencia de "DSML".
|
||||||
|
r"<(?:minimax:tool_call|invoke\s+name|tool_call[\s>]|use_mcp_tool|mm_special)|\[TOOL_CALL\]|<|",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
_INVOKE_RE = re.compile(
|
_INVOKE_RE = re.compile(
|
||||||
@@ -65,6 +69,21 @@ _PERL_ARGS_BLOCK_RE = re.compile(
|
|||||||
_PERL_KV_RE = re.compile(
|
_PERL_KV_RE = re.compile(
|
||||||
r"--([a-zA-Z_][a-zA-Z0-9_]*)\s+(\"[^\"]*\"|\'[^\']*\'|-?\d+(?:\.\d+)?|true|false|null)",
|
r"--([a-zA-Z_][a-zA-Z0-9_]*)\s+(\"[^\"]*\"|\'[^\']*\'|-?\d+(?:\.\d+)?|true|false|null)",
|
||||||
)
|
)
|
||||||
|
# Formato 5 (DeepSeek DSML). Formato oficial V4-Pro: el marcador es `|DSML|`
|
||||||
|
# con UN pipe fullwidth (U+FF5C) a cada lado — <|DSML|invoke name="X"> ...
|
||||||
|
# <|DSML|parameter name="P" string="true|false">V</|DSML|parameter> ...
|
||||||
|
# </|DSML|invoke>. Hacemos el regex TOLERANTE: 1+ pipes y "DSML" opcional,
|
||||||
|
# para cubrir variantes entre versiones del modelo. El atributo `string`
|
||||||
|
# decide el tipo del valor: "true" = string crudo, "false" = valor JSON.
|
||||||
|
_DSML_INVOKE_RE = re.compile(
|
||||||
|
r"<|+(?:DSML|+)?invoke\s+name=\"([^\"]+)\"[^>]*>(.*?)</|+(?:DSML|+)?invoke\s*>",
|
||||||
|
re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
_DSML_PARAM_RE = re.compile(
|
||||||
|
r"<|+(?:DSML|+)?parameter\s+name=\"([^\"]+)\"([^>]*)>(.*?)</|+(?:DSML|+)?parameter\s*>",
|
||||||
|
re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
_DSML_STRING_ATTR_RE = re.compile(r"string\s*=\s*\"(true|false)\"", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
def _safe_emit_split(buf: str) -> str:
|
def _safe_emit_split(buf: str) -> str:
|
||||||
@@ -91,8 +110,8 @@ def _safe_emit_split(buf: str) -> str:
|
|||||||
# Si el tail ya tiene `>` cerrado, es un tag normal — emitir todo.
|
# Si el tail ya tiene `>` cerrado, es un tag normal — emitir todo.
|
||||||
if ">" in tail:
|
if ">" in tail:
|
||||||
return buf
|
return buf
|
||||||
# Si el tail puede ser inicio de tool_call/invoke/tool_call_json, retenerlo.
|
# Si el tail puede ser inicio de tool_call/invoke/tool_call_json/dsml, retenerlo.
|
||||||
candidates = ("<minimax:tool_call", "<invoke", "<tool_call")
|
candidates = ("<minimax:tool_call", "<invoke", "<tool_call", "<|")
|
||||||
for cand in candidates:
|
for cand in candidates:
|
||||||
if cand.startswith(tail.lower()) or tail.lower().startswith(cand[:len(tail)].lower()):
|
if cand.startswith(tail.lower()) or tail.lower().startswith(cand[:len(tail)].lower()):
|
||||||
return buf[:idx]
|
return buf[:idx]
|
||||||
@@ -212,6 +231,35 @@ def _parse_xml_tool_calls(text: str) -> list[dict[str, Any]]:
|
|||||||
"arguments": args,
|
"arguments": args,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Formato 5 (DeepSeek DSML):
|
||||||
|
# <|DSML|invoke name="X"><|DSML|parameter name="P" string="true">V</|DSML|parameter></|DSML|invoke>
|
||||||
|
for m in _DSML_INVOKE_RE.finditer(text):
|
||||||
|
name = m.group(1).strip()
|
||||||
|
body = m.group(2)
|
||||||
|
args_dsml: dict[str, Any] = {}
|
||||||
|
for p in _DSML_PARAM_RE.finditer(body):
|
||||||
|
pname = p.group(1).strip()
|
||||||
|
attrs = p.group(2) or ""
|
||||||
|
raw_val = p.group(3)
|
||||||
|
sm = _DSML_STRING_ATTR_RE.search(attrs)
|
||||||
|
if sm and sm.group(1).lower() == "true":
|
||||||
|
# string="true": valor es string crudo — NO strip (preserva
|
||||||
|
# whitespace significativo, p.ej. contenido de ficheros).
|
||||||
|
args_dsml[pname] = raw_val
|
||||||
|
else:
|
||||||
|
# string="false" (o ausente): valor JSON (num/bool/array/obj/string).
|
||||||
|
# Si no parsea, cae a string sin tocar.
|
||||||
|
try:
|
||||||
|
args_dsml[pname] = json.loads(raw_val.strip())
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
args_dsml[pname] = raw_val.strip()
|
||||||
|
if name:
|
||||||
|
calls.append({
|
||||||
|
"id": "xml_{}".format(uuid.uuid4().hex[:12]),
|
||||||
|
"name": name,
|
||||||
|
"arguments": args_dsml,
|
||||||
|
})
|
||||||
|
|
||||||
return calls
|
return calls
|
||||||
|
|
||||||
|
|
||||||
@@ -586,6 +634,15 @@ class ClaudeAdapter(ModelAdapter):
|
|||||||
if force_tool:
|
if force_tool:
|
||||||
kwargs["tool_choice"] = {"type": "tool", "name": 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
|
# Retry con backoff sobre errores transitorios (429/503/529). El proxy
|
||||||
# MiniMax devuelve 529 overloaded_error con cierta frecuencia bajo carga.
|
# MiniMax devuelve 529 overloaded_error con cierta frecuencia bajo carga.
|
||||||
last_exc: Exception | None = None
|
last_exc: Exception | None = None
|
||||||
|
|||||||
83
src/adapters/litellm_adapter.py
Normal file
83
src/adapters/litellm_adapter.py
Normal 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)
|
||||||
@@ -9,10 +9,54 @@ from typing import Any, AsyncIterator
|
|||||||
from openai import AsyncOpenAI
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
from .base import ModelAdapter, ModelConfig, ModelResponse, StreamChunk
|
from .base import (
|
||||||
|
ContextOverflowError,
|
||||||
|
ModelAdapter,
|
||||||
|
ModelConfig,
|
||||||
|
ModelResponse,
|
||||||
|
StreamChunk,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Señales de que el proveedor rechazó por ventana de contexto. Detectamos por
|
||||||
|
# tipo (litellm.ContextWindowExceededError) y por mensaje (openai.BadRequestError
|
||||||
|
# u otros 400), sin acoplar el adapter a litellm con un import duro.
|
||||||
|
_CONTEXT_OVERFLOW_MARKERS = (
|
||||||
|
"context_length_exceeded",
|
||||||
|
"maximum context length",
|
||||||
|
"context window",
|
||||||
|
"context length",
|
||||||
|
"too many tokens",
|
||||||
|
"reduce the length",
|
||||||
|
"prompt is too long",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_context_overflow(exc: Exception) -> bool:
|
||||||
|
if type(exc).__name__ in ("ContextWindowExceededError",):
|
||||||
|
return True
|
||||||
|
msg = str(getattr(exc, "message", "") or exc).lower()
|
||||||
|
return any(marker in msg for marker in _CONTEXT_OVERFLOW_MARKERS)
|
||||||
|
|
||||||
|
|
||||||
|
def _estimate_usage(messages: list[dict[str, Any]], output_text: str) -> dict[str, int]:
|
||||||
|
"""Estimacion de tokens cuando el proveedor no entrega usage (p.ej. LiteLLM
|
||||||
|
streaming). Aproximada pero evita billing 0."""
|
||||||
|
from ..context.compactor import estimate_tokens
|
||||||
|
inp = 0
|
||||||
|
for m in messages:
|
||||||
|
c = m.get("content")
|
||||||
|
if isinstance(c, str):
|
||||||
|
inp += estimate_tokens(c)
|
||||||
|
elif isinstance(c, list):
|
||||||
|
for b in c:
|
||||||
|
if isinstance(b, dict):
|
||||||
|
inp += estimate_tokens(
|
||||||
|
b.get("text") or b.get("thinking") or str(b.get("content") or "")
|
||||||
|
)
|
||||||
|
return {"input_tokens": inp, "output_tokens": estimate_tokens(output_text or "")}
|
||||||
|
|
||||||
|
|
||||||
class OpenAIAdapter(ModelAdapter):
|
class OpenAIAdapter(ModelAdapter):
|
||||||
"""Adapter for the OpenAI API (GPT-4o, o1, etc.)."""
|
"""Adapter for the OpenAI API (GPT-4o, o1, etc.)."""
|
||||||
@@ -25,6 +69,15 @@ class OpenAIAdapter(ModelAdapter):
|
|||||||
if url:
|
if url:
|
||||||
kwargs["base_url"] = url
|
kwargs["base_url"] = url
|
||||||
self._client = AsyncOpenAI(**kwargs)
|
self._client = AsyncOpenAI(**kwargs)
|
||||||
|
# El path nativo conserva el usage real del proveedor; subclases que no
|
||||||
|
# reciben usage fiable en streaming (LiteLLM) lo ponen a True para estimar.
|
||||||
|
self._estimate_usage_fallback = False
|
||||||
|
|
||||||
|
async def _acreate(self, kwargs: dict[str, Any]):
|
||||||
|
"""Hook de la llamada al modelo. Subclases (p.ej. LiteLLMAdapter) lo
|
||||||
|
sobreescriben para enrutar por otra librería sin tocar el resto del
|
||||||
|
flujo (procesado de chunks, tools, mensajes)."""
|
||||||
|
return await self._client.chat.completions.create(**kwargs)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Streaming
|
# Streaming
|
||||||
@@ -35,6 +88,26 @@ class OpenAIAdapter(ModelAdapter):
|
|||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
tools: list[dict[str, Any]] | None = None,
|
tools: list[dict[str, Any]] | None = None,
|
||||||
config: ModelConfig | None = None,
|
config: ModelConfig | None = None,
|
||||||
|
) -> AsyncIterator[StreamChunk]:
|
||||||
|
"""Envoltorio que traduce errores de ventana de contexto del proveedor a
|
||||||
|
`ContextOverflowError` (dominio), tanto si saltan al iniciar el stream
|
||||||
|
como durante la primera iteración. El loop del agente lo usa para
|
||||||
|
reintentar con compactación agresiva si aún no emitió nada."""
|
||||||
|
try:
|
||||||
|
async for chunk in self._stream_impl(messages, tools, config):
|
||||||
|
yield chunk
|
||||||
|
except ContextOverflowError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
if _is_context_overflow(e):
|
||||||
|
raise ContextOverflowError(str(getattr(e, "message", "") or e)) from e
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def _stream_impl(
|
||||||
|
self,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
tools: list[dict[str, Any]] | None = None,
|
||||||
|
config: ModelConfig | None = None,
|
||||||
) -> AsyncIterator[StreamChunk]:
|
) -> AsyncIterator[StreamChunk]:
|
||||||
config = config or ModelConfig(
|
config = config or ModelConfig(
|
||||||
model_id=settings.default_model_id,
|
model_id=settings.default_model_id,
|
||||||
@@ -43,43 +116,92 @@ class OpenAIAdapter(ModelAdapter):
|
|||||||
)
|
)
|
||||||
|
|
||||||
kwargs: dict[str, Any] = {
|
kwargs: dict[str, Any] = {
|
||||||
"model": config.model_id or "gpt-4o",
|
"model": config.model_id or settings.default_model_id or "gpt-4o",
|
||||||
"max_tokens": config.max_tokens,
|
"max_tokens": config.max_tokens,
|
||||||
"temperature": config.temperature,
|
"temperature": config.temperature,
|
||||||
"messages": messages,
|
"messages": self._to_openai_messages(messages),
|
||||||
"stream": True,
|
"stream": True,
|
||||||
"stream_options": {"include_usage": True},
|
"stream_options": {"include_usage": True},
|
||||||
}
|
}
|
||||||
|
if getattr(config, "reasoning_effort", ""):
|
||||||
|
kwargs["reasoning_effort"] = config.reasoning_effort
|
||||||
if tools:
|
if tools:
|
||||||
kwargs["tools"] = self._format_tools(tools)
|
kwargs["tools"] = self._format_tools(tools)
|
||||||
|
|
||||||
stream = await self._client.chat.completions.create(**kwargs)
|
stream = await self._acreate(kwargs)
|
||||||
|
|
||||||
|
# Fallback de tool-calls-en-texto: DeepSeek a veces emite las tool calls
|
||||||
|
# en su formato interno DSML como TEXTO (en el content) en vez de como
|
||||||
|
# tool_calls nativos. El endpoint OpenAI no lo convierte, asi que sin
|
||||||
|
# esto el agente "se para" mostrando DSML inerte. Reutilizamos el parser
|
||||||
|
# del claude_adapter.
|
||||||
|
from .claude_adapter import _parse_xml_tool_calls, _TOOL_CALL_OPEN_RE
|
||||||
|
|
||||||
tool_calls_acc: dict[int, dict[str, str]] = {}
|
tool_calls_acc: dict[int, dict[str, str]] = {}
|
||||||
|
|
||||||
final_usage: dict[str, int] = {}
|
final_usage: dict[str, int] = {}
|
||||||
|
usage_emitted = False # evita doble conteo si llega usage tras estimar
|
||||||
|
full_content = "" # content acumulado (para el fallback DSML)
|
||||||
|
full_reasoning = "" # razonamiento acumulado (para estimar usage)
|
||||||
|
emitted_chars = 0 # cuanto de full_content ya se emitio como delta
|
||||||
|
suppress_text = False # tras detectar un tool-call-en-texto, no emitir mas
|
||||||
|
|
||||||
|
# DeepSeek thinking mode: el razonamiento llega en `delta.reasoning_content`
|
||||||
|
# (antes del content). Lo acumulamos como un bloque `thinking` (block_index 0)
|
||||||
|
# para que el orquestador lo persista y `_to_openai_messages` lo reenvie como
|
||||||
|
# `reasoning_content` en el siguiente turno — DeepSeek lo exige en multi-turno
|
||||||
|
# con tool calls ("reasoning_content ... must be passed back to the API").
|
||||||
|
reasoning_seen = False
|
||||||
|
reasoning_sig_emitted = False
|
||||||
|
|
||||||
async for chunk in stream:
|
async for chunk in stream:
|
||||||
# With include_usage, the last chunk has usage but no choices
|
# With include_usage, the last chunk has usage but no choices.
|
||||||
if chunk.usage:
|
# getattr: el chunk de LiteLLM (ModelResponseStream) no siempre trae
|
||||||
|
# el atributo `usage`; el del SDK OpenAI sí (None salvo el ultimo).
|
||||||
|
chunk_usage = getattr(chunk, "usage", None)
|
||||||
|
if chunk_usage:
|
||||||
final_usage = {
|
final_usage = {
|
||||||
"input_tokens": chunk.usage.prompt_tokens or 0,
|
"input_tokens": getattr(chunk_usage, "prompt_tokens", 0) or 0,
|
||||||
"output_tokens": chunk.usage.completion_tokens or 0,
|
"output_tokens": getattr(chunk_usage, "completion_tokens", 0) or 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
choice = chunk.choices[0] if chunk.choices else None
|
choice = chunk.choices[0] if chunk.choices else None
|
||||||
if not choice:
|
if not choice:
|
||||||
# Usage-only chunk (last one with include_usage) — emit it
|
# Usage-only chunk (last one with include_usage) — emit it
|
||||||
if final_usage:
|
if final_usage and not usage_emitted:
|
||||||
yield StreamChunk(usage=final_usage)
|
yield StreamChunk(usage=final_usage)
|
||||||
final_usage = {} # Only emit once
|
usage_emitted = True
|
||||||
continue
|
continue
|
||||||
|
|
||||||
delta = choice.delta
|
delta = choice.delta
|
||||||
|
|
||||||
|
# Reasoning content (DeepSeek thinking mode). Llega como campo extra
|
||||||
|
# del delta; lo emitimos como thinking_delta en el bloque index 0.
|
||||||
|
reasoning_txt = getattr(delta, "reasoning_content", None) if delta else None
|
||||||
|
if reasoning_txt:
|
||||||
|
reasoning_seen = True
|
||||||
|
full_reasoning += reasoning_txt
|
||||||
|
yield StreamChunk(
|
||||||
|
thinking_delta=reasoning_txt,
|
||||||
|
block_type="thinking",
|
||||||
|
block_index=0,
|
||||||
|
)
|
||||||
|
|
||||||
# Text content
|
# Text content
|
||||||
if delta and delta.content:
|
if delta and delta.content:
|
||||||
yield StreamChunk(delta=delta.content)
|
full_content += delta.content
|
||||||
|
if not suppress_text:
|
||||||
|
# Si arranca un tool call en texto (DSML/XML), emitimos lo
|
||||||
|
# previo y dejamos de emitir el resto (el DSML no debe verse).
|
||||||
|
m = _TOOL_CALL_OPEN_RE.search(full_content, emitted_chars)
|
||||||
|
if m:
|
||||||
|
suppress_text = True
|
||||||
|
if m.start() > emitted_chars:
|
||||||
|
yield StreamChunk(delta=full_content[emitted_chars:m.start()])
|
||||||
|
emitted_chars = len(full_content)
|
||||||
|
else:
|
||||||
|
yield StreamChunk(delta=full_content[emitted_chars:])
|
||||||
|
emitted_chars = len(full_content)
|
||||||
|
|
||||||
# Tool calls
|
# Tool calls
|
||||||
if delta and delta.tool_calls:
|
if delta and delta.tool_calls:
|
||||||
@@ -109,7 +231,31 @@ class OpenAIAdapter(ModelAdapter):
|
|||||||
|
|
||||||
# Finish
|
# Finish
|
||||||
if choice.finish_reason:
|
if choice.finish_reason:
|
||||||
if choice.finish_reason == "tool_calls":
|
# Cerrar el bloque de razonamiento (si lo hubo) con un signature
|
||||||
|
# sintetico: el orquestador descarta thinking blocks sin signature
|
||||||
|
# (proteccion para MiniMax/Anthropic). DeepSeek no usa signatures;
|
||||||
|
# este marcador solo evita el descarte y NUNCA se reenvia — en
|
||||||
|
# `_to_openai_messages` el bloque se mapea a `reasoning_content`.
|
||||||
|
if reasoning_seen and not reasoning_sig_emitted:
|
||||||
|
reasoning_sig_emitted = True
|
||||||
|
yield StreamChunk(
|
||||||
|
thinking_signature="deepseek-reasoning",
|
||||||
|
block_type="thinking",
|
||||||
|
block_index=0,
|
||||||
|
)
|
||||||
|
# Fallback de usage: algunos proveedores via LiteLLM no entregan el
|
||||||
|
# chunk de usage (o llega tras el break del orquestador) → billing 0.
|
||||||
|
# Estimamos por tokens para no infra-cobrar. Solo si el adapter lo
|
||||||
|
# pide (LiteLLM); el path nativo conserva el usage real del proveedor.
|
||||||
|
if self._estimate_usage_fallback and not final_usage and not usage_emitted:
|
||||||
|
final_usage = _estimate_usage(messages, full_content + "\n" + full_reasoning)
|
||||||
|
# IMPORTANTE: DeepSeek (endpoint OpenAI) a veces cierra el stream
|
||||||
|
# con finish_reason="stop" AUNQUE haya emitido tool_calls. Si nos
|
||||||
|
# fiamos solo de =="tool_calls" perdemos esos tool calls: el agente
|
||||||
|
# anuncia la accion en texto y "se para" sin ejecutarla. Por eso
|
||||||
|
# disparamos los tool_use SIEMPRE que haya tool calls acumulados,
|
||||||
|
# sea cual sea el finish_reason.
|
||||||
|
if tool_calls_acc:
|
||||||
for acc in tool_calls_acc.values():
|
for acc in tool_calls_acc.values():
|
||||||
yield StreamChunk(
|
yield StreamChunk(
|
||||||
tool_call_id=acc["id"],
|
tool_call_id=acc["id"],
|
||||||
@@ -118,15 +264,33 @@ class OpenAIAdapter(ModelAdapter):
|
|||||||
finish_reason="tool_use",
|
finish_reason="tool_use",
|
||||||
)
|
)
|
||||||
# Emit usage after tool_use chunks
|
# Emit usage after tool_use chunks
|
||||||
if final_usage:
|
if final_usage and not usage_emitted:
|
||||||
yield StreamChunk(usage=final_usage)
|
yield StreamChunk(usage=final_usage)
|
||||||
|
usage_emitted = True
|
||||||
else:
|
else:
|
||||||
yield StreamChunk(
|
# Fallback: DeepSeek pudo emitir las tool calls como TEXTO
|
||||||
finish_reason="end_turn"
|
# (DSML/XML) en vez de nativas. Parseamos el content y, si hay
|
||||||
if choice.finish_reason == "stop"
|
# tool calls, las ejecutamos igual; si no, cerramos el turno.
|
||||||
else choice.finish_reason,
|
text_calls = _parse_xml_tool_calls(full_content) if full_content else []
|
||||||
usage=final_usage,
|
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
|
# Non-streaming
|
||||||
@@ -145,11 +309,13 @@ class OpenAIAdapter(ModelAdapter):
|
|||||||
)
|
)
|
||||||
|
|
||||||
kwargs: dict[str, Any] = {
|
kwargs: dict[str, Any] = {
|
||||||
"model": config.model_id or "gpt-4o",
|
"model": config.model_id or settings.default_model_id or "gpt-4o",
|
||||||
"max_tokens": config.max_tokens,
|
"max_tokens": config.max_tokens,
|
||||||
"temperature": config.temperature,
|
"temperature": config.temperature,
|
||||||
"messages": messages,
|
"messages": self._to_openai_messages(messages),
|
||||||
}
|
}
|
||||||
|
if getattr(config, "reasoning_effort", ""):
|
||||||
|
kwargs["reasoning_effort"] = config.reasoning_effort
|
||||||
if tools:
|
if tools:
|
||||||
kwargs["tools"] = self._format_tools(tools)
|
kwargs["tools"] = self._format_tools(tools)
|
||||||
# Fuerza al modelo a usar un tool concreto para garantizar JSON por schema
|
# Fuerza al modelo a usar un tool concreto para garantizar JSON por schema
|
||||||
@@ -161,7 +327,14 @@ class OpenAIAdapter(ModelAdapter):
|
|||||||
"function": {"name": force_tool},
|
"function": {"name": force_tool},
|
||||||
}
|
}
|
||||||
|
|
||||||
response = await self._client.chat.completions.create(**kwargs)
|
try:
|
||||||
|
response = await self._acreate(kwargs)
|
||||||
|
except ContextOverflowError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
if _is_context_overflow(e):
|
||||||
|
raise ContextOverflowError(str(getattr(e, "message", "") or e)) from e
|
||||||
|
raise
|
||||||
choice = response.choices[0]
|
choice = response.choices[0]
|
||||||
|
|
||||||
content = choice.message.content or ""
|
content = choice.message.content or ""
|
||||||
@@ -204,19 +377,242 @@ class OpenAIAdapter(ModelAdapter):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _format_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
def _format_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
"""Convert internal tool definitions to OpenAI function calling format."""
|
"""Convert internal tool definitions to OpenAI function calling format.
|
||||||
|
|
||||||
|
Si `deepseek_strict_tools`, marca cada funcion con `strict: true` y limpia
|
||||||
|
del schema los keywords que DeepSeek strict NO soporta (minLength/maxLength/
|
||||||
|
minItems/maxItems), que de otro modo darian 400."""
|
||||||
|
strict = settings.deepseek_strict_tools
|
||||||
formatted: list[dict[str, Any]] = []
|
formatted: list[dict[str, Any]] = []
|
||||||
for tool in tools:
|
for tool in tools:
|
||||||
formatted.append(
|
params = tool.get("input_schema", tool.get("parameters", {"type": "object"}))
|
||||||
{
|
fn: dict[str, Any] = {
|
||||||
"type": "function",
|
"name": tool["name"],
|
||||||
"function": {
|
"description": tool.get("description", ""),
|
||||||
"name": tool["name"],
|
"parameters": OpenAIAdapter._sanitize_strict_schema(params) if strict else params,
|
||||||
"description": tool.get("description", ""),
|
}
|
||||||
"parameters": tool.get(
|
if strict:
|
||||||
"input_schema", tool.get("parameters", {"type": "object"})
|
fn["strict"] = True
|
||||||
),
|
formatted.append({"type": "function", "function": fn})
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return formatted
|
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
|
||||||
|
|||||||
@@ -46,6 +46,14 @@ class SendMessageRequest(BaseModel):
|
|||||||
message: str
|
message: str
|
||||||
stream: bool = False
|
stream: bool = False
|
||||||
agent_id: str | None = None
|
agent_id: str | None = None
|
||||||
|
# Imágenes para visión nativa: bloques listos para el modelo
|
||||||
|
# {"type":"image_url","image_url":{"url":"data:<mime>;base64,..."}}. Solo se
|
||||||
|
# envían cuando el modelo activo es multimodal (lo decide acai-app).
|
||||||
|
attachments: list[dict[str, Any]] | None = None
|
||||||
|
# 'off' (default): la tool acai_plan no se expone al modelo, ejecuta directo.
|
||||||
|
# '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):
|
class CompletionRequest(BaseModel):
|
||||||
@@ -79,6 +87,8 @@ class SessionResponse(BaseModel):
|
|||||||
created_at: str
|
created_at: str
|
||||||
updated_at: str
|
updated_at: str
|
||||||
agent_id: str = "acai"
|
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] = {}
|
_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(
|
def set_dependencies(
|
||||||
storage: Any,
|
storage: Any,
|
||||||
model_adapter: Any,
|
model_adapter: Any,
|
||||||
@@ -290,22 +339,72 @@ async def send_message(
|
|||||||
if not agent_profile:
|
if not agent_profile:
|
||||||
agent_profile = agent_reg.get(agent_reg.default_agent_id)
|
agent_profile = agent_reg.get(agent_reg.default_agent_id)
|
||||||
|
|
||||||
|
# Resolución dinámica del modelo (Fase 2): override por-usuario (metadata de
|
||||||
|
# la sesión) → default global (Redis acai:config:ai:*). Si resuelve, se
|
||||||
|
# inyecta en una COPIA del profile para no mutar el del registry (singleton).
|
||||||
|
if agent_profile is not None:
|
||||||
|
from ..orchestrator.model_resolver import resolve_session_model
|
||||||
|
resolved = await resolve_session_model(session)
|
||||||
|
update = {}
|
||||||
|
if resolved.get("model_id"):
|
||||||
|
update["model_id"] = resolved["model_id"]
|
||||||
|
if resolved.get("reasoning_effort"):
|
||||||
|
update["reasoning_effort"] = resolved["reasoning_effort"]
|
||||||
|
if update:
|
||||||
|
agent_profile = agent_profile.model_copy(update=update)
|
||||||
|
logger.info(
|
||||||
|
"Session %s: modelo resuelto -> %s (reasoning=%s)",
|
||||||
|
session_id, update.get("model_id", "(default)"),
|
||||||
|
update.get("reasoning_effort", "off"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Plan mode controlado por el usuario desde el toggle del ChatPanel.
|
||||||
|
# '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
|
from ..mcp.manager import MCPManager
|
||||||
orchestrator = _build_orchestrator(mcp_manager or MCPManager(), agent_profile)
|
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:
|
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 {
|
return {
|
||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
"status": "executing",
|
"status": "executing",
|
||||||
"stream_url": f"/sessions/{session_id}/stream",
|
"stream_url": f"/sessions/{session_id}/stream",
|
||||||
}
|
}
|
||||||
|
|
||||||
result = await _execute_and_persist(orchestrator, storage, session, body.message)
|
result = await _execute_and_persist(orchestrator, storage, session, body.message, body.attachments)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
async def _execute_and_persist(orchestrator, storage, session, message) -> dict[str, Any]:
|
async def _execute_and_persist(orchestrator, storage, session, message, attachments=None) -> dict[str, Any]:
|
||||||
# Acquire exclusive lock — prevents concurrent execution on same session
|
# Acquire exclusive lock — prevents concurrent execution on same session
|
||||||
async with storage.session_lock(session.session_id) as acquired:
|
async with storage.session_lock(session.session_id) as acquired:
|
||||||
if not acquired:
|
if not acquired:
|
||||||
@@ -315,9 +414,47 @@ async def _execute_and_persist(orchestrator, storage, session, message) -> dict[
|
|||||||
"status": "busy",
|
"status": "busy",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Persistir 'executing' + el objetivo ANTES de la ejecución larga, para que
|
||||||
|
# un reattach (tras recargar el frontend a mitad de turno) detecte que hay
|
||||||
|
# un turno en curso. El estado final lo guarda el `finally`.
|
||||||
try:
|
try:
|
||||||
result = await orchestrator.process_message(session, message)
|
session.status = SessionStatus.EXECUTING
|
||||||
|
session.metadata["current_objective"] = message
|
||||||
|
await storage.update_session(session)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("No se pudo persistir 'executing' al arrancar: %s", e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await orchestrator.process_message(session, message, attachments)
|
||||||
return result
|
return result
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# 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:
|
except Exception as e:
|
||||||
session.status = SessionStatus.ERROR
|
session.status = SessionStatus.ERROR
|
||||||
logger.exception("Execution failed for session %s", session.session_id)
|
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)
|
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
|
# GET /sessions/{id}/stream
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -379,15 +567,46 @@ async def get_session(session_id: str) -> SessionResponse:
|
|||||||
if not session:
|
if not session:
|
||||||
raise HTTPException(status_code=404, detail="Session not found")
|
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(
|
return SessionResponse(
|
||||||
session_id=session.session_id,
|
session_id=session.session_id,
|
||||||
status=session.status.value,
|
status=session.status.value,
|
||||||
turn_count=session.turn_count,
|
turn_count=session.turn_count,
|
||||||
current_task=session.current_task.model_dump() if session.current_task else None,
|
current_task=ct,
|
||||||
completed_tasks=session.completed_tasks,
|
completed_tasks=session.completed_tasks,
|
||||||
created_at=session.created_at.isoformat(),
|
created_at=session.created_at.isoformat(),
|
||||||
updated_at=session.updated_at.isoformat(),
|
updated_at=session.updated_at.isoformat(),
|
||||||
agent_id=session.agent_id,
|
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}
|
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
|
# 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():
|
if not docs_dir.is_dir():
|
||||||
return {"status": "error", "message": f"Directory not found: {docs_dir}"}
|
return {"status": "error", "message": f"Directory not found: {docs_dir}"}
|
||||||
|
|
||||||
# Read all docs
|
# Read all docs. Cada doc puede tener frontmatter YAML al inicio:
|
||||||
docs_data: list[tuple[str, str, str, str, list[str]]] = [] # (id, title, content, summary, tags)
|
# ---
|
||||||
|
# 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")):
|
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
|
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()
|
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 = []
|
docs_data.append((doc_id, title, content, summary, tags, priority, load_when))
|
||||||
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]
|
|
||||||
|
|
||||||
tags = []
|
# Hash de contenido por doc — base del skip idempotente de embeddings.
|
||||||
for line in lines:
|
import hashlib
|
||||||
if line.startswith("## "):
|
|
||||||
tags.append(line.lstrip("#").strip().lower()[:30])
|
|
||||||
|
|
||||||
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
|
def _doc_hash(title, summary, content):
|
||||||
from ..memory.embeddings import EmbeddingService
|
return hashlib.md5(_embed_text(title, summary, content).encode("utf-8")).hexdigest()
|
||||||
embed_service = EmbeddingService()
|
|
||||||
embed_texts = [f"{title}\n{summary}\n{content[:2000]}" for _, title, content, summary, _ in docs_data]
|
|
||||||
|
|
||||||
try:
|
new_hashes = [_doc_hash(t, s, c) for _, t, c, s, _, _, _ in docs_data]
|
||||||
embeddings = await embed_service.embed_batch(embed_texts)
|
|
||||||
has_embeddings = True
|
# Generate embeddings SOLO para docs nuevos o cuyo contenido cambió (skip
|
||||||
logger.info("Generated %d embeddings for knowledge base", len(embeddings))
|
# idempotente): si el hash coincide con el guardado y ya existe el embedding
|
||||||
except Exception as e:
|
# en Redis, se reutiliza y NO se vuelve a llamar a la API. Esto permite que
|
||||||
logger.warning("Failed to generate embeddings: %s — loading without semantic search", e)
|
# /knowledge/load se dispare libremente (botón de scaffold, etc.) sin re-embeber.
|
||||||
embeddings = [None] * len(docs_data)
|
embeddings: list[Any] = [None] * len(docs_data)
|
||||||
has_embeddings = False
|
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.
|
# Limpia entradas huérfanas: docs que ya no existen en el filesystem.
|
||||||
# Sin esto, los IDs antiguos (e.g. tras renombrar 'builder-fields' →
|
# Sin esto, los IDs antiguos (e.g. tras renombrar 'builder-fields' →
|
||||||
@@ -567,16 +914,17 @@ async def _load_knowledge_from_dir(docs_path: str = "docs") -> dict[str, Any]:
|
|||||||
for existing in existing_docs:
|
for existing in existing_docs:
|
||||||
if existing.memory_id not in current_ids:
|
if existing.memory_id not in current_ids:
|
||||||
await memory.delete_document(existing.memory_id, namespace="knowledge")
|
await memory.delete_document(existing.memory_id, namespace="knowledge")
|
||||||
# Borra también el embedding asociado
|
# Borra también el embedding asociado y el hash de contenido
|
||||||
embed_key = memory._key("embeddings", "knowledge", existing.memory_id)
|
embed_key = memory._key("embeddings", "knowledge", existing.memory_id)
|
||||||
await memory._r.delete(embed_key)
|
await memory._r.delete(embed_key)
|
||||||
|
await memory._r.delete(memory._key("kbhash", "knowledge", existing.memory_id))
|
||||||
removed.append(existing.memory_id)
|
removed.append(existing.memory_id)
|
||||||
if removed:
|
if removed:
|
||||||
logger.info("Removed %d stale knowledge docs: %s", len(removed), removed)
|
logger.info("Removed %d stale knowledge docs: %s", len(removed), removed)
|
||||||
|
|
||||||
# Store docs + embeddings
|
# Store docs + embeddings
|
||||||
loaded = []
|
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(
|
doc = MemoryDocument(
|
||||||
memory_id=doc_id,
|
memory_id=doc_id,
|
||||||
memory_type=MemoryType.DOCUMENT,
|
memory_type=MemoryType.DOCUMENT,
|
||||||
@@ -585,18 +933,27 @@ async def _load_knowledge_from_dir(docs_path: str = "docs") -> dict[str, Any]:
|
|||||||
content=content,
|
content=content,
|
||||||
summary=summary,
|
summary=summary,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
|
priority=priority,
|
||||||
|
load_when=load_when,
|
||||||
)
|
)
|
||||||
await memory.store_document(doc)
|
await memory.store_document(doc)
|
||||||
|
|
||||||
if embeddings[i] is not None:
|
if embeddings[i] is not None:
|
||||||
await memory.store_embedding(doc_id, embeddings[i], namespace="knowledge")
|
await memory.store_embedding(doc_id, embeddings[i], namespace="knowledge")
|
||||||
|
# Guarda el hash de contenido para el skip idempotente del próximo load
|
||||||
|
try:
|
||||||
|
await memory._r.set(memory._key("kbhash", "knowledge", doc_id), new_hashes[i])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
loaded.append({
|
loaded.append({
|
||||||
"id": doc_id,
|
"id": doc_id,
|
||||||
"title": title,
|
"title": title,
|
||||||
"chars": len(content),
|
"chars": len(content),
|
||||||
"tags": tags[:5],
|
"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)
|
logger.info("Loaded %d knowledge documents from %s (embeddings: %s)", len(loaded), docs_dir, has_embeddings)
|
||||||
|
|||||||
@@ -32,10 +32,49 @@ class Settings(BaseSettings):
|
|||||||
anthropic_base_url: str = "" # Custom base URL (for MiniMax Anthropic-compatible, etc.)
|
anthropic_base_url: str = "" # Custom base URL (for MiniMax Anthropic-compatible, etc.)
|
||||||
openai_api_key: str = ""
|
openai_api_key: str = ""
|
||||||
openai_base_url: str = "" # Custom base URL (for MiniMax, DeepInfra, etc.)
|
openai_base_url: str = "" # Custom base URL (for MiniMax, DeepInfra, etc.)
|
||||||
|
# --- Embeddings (semantic search) ---
|
||||||
|
# Credenciales DEDICADAS para embeddings. Necesarias porque el chat usa
|
||||||
|
# `openai_api_key` apuntando a un endpoint compatible (p.ej. DeepSeek, que NO
|
||||||
|
# tiene API de embeddings). Si vacio, cae a `openai_api_key` por compat. El
|
||||||
|
# base_url vacio => OpenAI real (api.openai.com); NO hereda `openai_base_url`.
|
||||||
|
embeddings_api_key: str = ""
|
||||||
|
embeddings_base_url: str = ""
|
||||||
|
embeddings_model: str = "text-embedding-3-small"
|
||||||
|
# Spike LiteLLM: si default_model_provider=litellm, modelo a usar (formato
|
||||||
|
# litellm, p.ej. "deepseek/deepseek-v4-pro"). Vacío → deriva de default_model_id.
|
||||||
|
litellm_model: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def effective_embeddings_key(self) -> str:
|
||||||
|
"""Key a usar para embeddings. Prioriza la dedicada; reutiliza la del
|
||||||
|
chat SOLO si el chat es OpenAI real (sin `openai_base_url` custom) — si
|
||||||
|
apunta a DeepSeek u otro proveedor, esa key no sirve para embeddings."""
|
||||||
|
if self.embeddings_api_key:
|
||||||
|
return self.embeddings_api_key
|
||||||
|
if not self.openai_base_url:
|
||||||
|
return self.openai_api_key
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def embeddings_enabled(self) -> bool:
|
||||||
|
return bool(self.effective_embeddings_key or self.embeddings_base_url)
|
||||||
|
|
||||||
default_model_provider: str = "claude"
|
default_model_provider: str = "claude"
|
||||||
default_model_id: str = "claude-sonnet-4-20250514"
|
default_model_id: str = "claude-sonnet-4-20250514"
|
||||||
|
# Modelo override SOLO para el sub-loop del planner (acai_plan). Si vacio,
|
||||||
|
# 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
|
max_tokens: int = 4096
|
||||||
temperature: float = 0.3
|
temperature: float = 0.3
|
||||||
|
# DeepSeek strict function calling (beta). OPT-IN (default False): exige schemas
|
||||||
|
# tipo OpenAI (additionalProperties:false, todos required, etc.) que los tools MCP
|
||||||
|
# actuales NO cumplen → da 400. Para activarlo: schemas compatibles + base_url
|
||||||
|
# https://api.deepseek.com/beta + AGENTIC_DEEPSEEK_STRICT_TOOLS=true.
|
||||||
|
deepseek_strict_tools: bool = False
|
||||||
|
|
||||||
# --- Context engine ---
|
# --- Context engine ---
|
||||||
model_context_window: int = 0 # 0 = use legacy fixed budget / explicit override
|
model_context_window: int = 0 # 0 = use legacy fixed budget / explicit override
|
||||||
@@ -45,12 +84,28 @@ class Settings(BaseSettings):
|
|||||||
compaction_threshold_ratio: float = 0.80
|
compaction_threshold_ratio: float = 0.80
|
||||||
context_reserve_ratio: float = 0.10
|
context_reserve_ratio: float = 0.10
|
||||||
artifact_summary_max_chars: int = 2000
|
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
|
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
|
conversation_recent_raw_limit: int = 2
|
||||||
task_history_max_entries: int = 20
|
task_history_max_entries: int = 20
|
||||||
task_history_max_tokens: int = 1500
|
task_history_max_tokens: int = 1500
|
||||||
|
# Presupuesto de tokens para la ventana de recent_messages persistida en
|
||||||
|
# sesion. Sin esto crece sin limite y empuja al compactor a su paso
|
||||||
|
# destructivo (colapsar bloques perdiendo tool_use ids). 0 = sin limite.
|
||||||
|
recent_messages_max_tokens: int = 60_000
|
||||||
|
|
||||||
# --- MCP ---
|
# --- MCP ---
|
||||||
mcp_config_path: str = "" # Path to mcp.json; empty = legacy single-server mode
|
mcp_config_path: str = "" # Path to mcp.json; empty = legacy single-server mode
|
||||||
@@ -100,5 +155,24 @@ class Settings(BaseSettings):
|
|||||||
return min(self.compaction_threshold_tokens, self.effective_context_budget)
|
return min(self.compaction_threshold_tokens, self.effective_context_budget)
|
||||||
return max(1, int(self.effective_context_budget * self.compaction_threshold_ratio))
|
return max(1, int(self.effective_context_budget * self.compaction_threshold_ratio))
|
||||||
|
|
||||||
|
def budget_for_window(self, window: int, max_output: int | None = None) -> int:
|
||||||
|
"""Budget de contexto para la ventana REAL del modelo activo.
|
||||||
|
|
||||||
|
Misma fórmula que `effective_context_budget` (`window - max_output -
|
||||||
|
reserve`) pero parametrizada por la ventana del modelo del turno. Si la
|
||||||
|
ventana no es válida, cae al budget estático. Un override explícito
|
||||||
|
(`context_max_tokens`) siempre manda (lo aplica el caller)."""
|
||||||
|
if window <= 0:
|
||||||
|
return self.effective_context_budget
|
||||||
|
out = self.model_max_output_tokens if max_output is None else max_output
|
||||||
|
reserve = int(window * self.context_reserve_ratio)
|
||||||
|
return max(1, window - max(0, out) - max(0, reserve))
|
||||||
|
|
||||||
|
def compaction_threshold_for(self, budget: int) -> int:
|
||||||
|
"""Umbral de compactación para un budget dado (ratio configurable)."""
|
||||||
|
if self.compaction_threshold_tokens > 0:
|
||||||
|
return min(self.compaction_threshold_tokens, budget)
|
||||||
|
return max(1, int(budget * self.compaction_threshold_ratio))
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
@@ -180,7 +180,13 @@ class ContextCompactor:
|
|||||||
"raw_tool_results_kept": 0,
|
"raw_tool_results_kept": 0,
|
||||||
}
|
}
|
||||||
if total <= max_tokens:
|
if total <= max_tokens:
|
||||||
return messages, meta
|
# Aunque no haga falta compactar, garantizamos el invariante
|
||||||
|
# tool_use/tool_result (repara historiales ya rotos persistidos).
|
||||||
|
repaired = self._enforce_tool_pairing([dict(m) for m in messages])
|
||||||
|
meta["output_tokens"] = sum(
|
||||||
|
self._estimate_message_tokens(m) for m in repaired
|
||||||
|
)
|
||||||
|
return repaired, meta
|
||||||
|
|
||||||
compacted = [dict(m) for m in messages]
|
compacted = [dict(m) for m in messages]
|
||||||
last_user_idx = max(
|
last_user_idx = max(
|
||||||
@@ -343,20 +349,241 @@ class ContextCompactor:
|
|||||||
message["content"] = "[USER CONTEXT COMPACTADO]"
|
message["content"] = "[USER CONTEXT COMPACTADO]"
|
||||||
elif isinstance(content, list) and content:
|
elif isinstance(content, list) and content:
|
||||||
# Anthropic-style: reemplazar lista entera por placeholder string.
|
# Anthropic-style: reemplazar lista entera por placeholder string.
|
||||||
# Nota: pierde tool_use ids — solo aplicar al final como ultimo recurso.
|
# Nota: colapsar pierde los tool_use/tool_result ids, asi que
|
||||||
|
# lo hacemos PAIR-AWARE (colapsar un lado del par colapsa el
|
||||||
|
# otro en la misma iteracion) y ademas `_enforce_tool_pairing`
|
||||||
|
# al final garantiza el invariante aunque algo se escape.
|
||||||
if role == "assistant":
|
if role == "assistant":
|
||||||
message["content"] = "[ASSISTANT COMPACTADO]"
|
message["content"] = "[ASSISTANT COMPACTADO]"
|
||||||
|
# Si este assistant tenia tool_use, colapsar tambien el
|
||||||
|
# user de tool_results que lo sigue (mismo par).
|
||||||
|
if self._blocks_have_type(content, "tool_use"):
|
||||||
|
nxt = idx + 1
|
||||||
|
if (
|
||||||
|
nxt < len(compacted)
|
||||||
|
and nxt != last_user_idx
|
||||||
|
and compacted[nxt].get("role") == "user"
|
||||||
|
and self._blocks_have_type(
|
||||||
|
compacted[nxt].get("content"), "tool_result"
|
||||||
|
)
|
||||||
|
):
|
||||||
|
compacted[nxt]["content"] = "[USER CONTEXT COMPACTADO]"
|
||||||
elif role == "user":
|
elif role == "user":
|
||||||
message["content"] = "[USER CONTEXT COMPACTADO]"
|
message["content"] = "[USER CONTEXT COMPACTADO]"
|
||||||
|
# Si este user llevaba tool_results, colapsar tambien el
|
||||||
|
# assistant anterior con sus tool_use (mismo par).
|
||||||
|
if self._blocks_have_type(content, "tool_result"):
|
||||||
|
prv = idx - 1
|
||||||
|
if (
|
||||||
|
prv >= 0
|
||||||
|
and compacted[prv].get("role") == "assistant"
|
||||||
|
and self._blocks_have_type(
|
||||||
|
compacted[prv].get("content"), "tool_use"
|
||||||
|
)
|
||||||
|
):
|
||||||
|
compacted[prv]["content"] = "[ASSISTANT COMPACTADO]"
|
||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
total = sum(self._estimate_message_tokens(m) for m in compacted)
|
total = sum(self._estimate_message_tokens(m) for m in compacted)
|
||||||
if total <= max_tokens:
|
if total <= max_tokens:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# Invariante final: tras toda la compactacion, reparar cualquier par
|
||||||
|
# tool_use/tool_result roto. Sin esto, un tool_result huerfano se emite
|
||||||
|
# como `role: tool` sin `tool_calls` previo y el proveedor devuelve 400
|
||||||
|
# ("Messages with role 'tool' must be a response to a preceding message
|
||||||
|
# with 'tool_calls'").
|
||||||
|
compacted = self._enforce_tool_pairing(compacted)
|
||||||
|
total = sum(self._estimate_message_tokens(m) for m in compacted)
|
||||||
|
|
||||||
meta["output_tokens"] = total
|
meta["output_tokens"] = total
|
||||||
return compacted, meta
|
return compacted, meta
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Invariante tool_use ↔ tool_result
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _blocks_have_type(content: Any, block_type: str) -> bool:
|
||||||
|
"""True si `content` es una lista de bloques con alguno del tipo dado."""
|
||||||
|
if not isinstance(content, list):
|
||||||
|
return False
|
||||||
|
return any(
|
||||||
|
isinstance(b, dict) and b.get("type") == block_type for b in content
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _tool_use_ids(message: dict[str, Any]) -> set[str]:
|
||||||
|
"""IDs de tool calls emitidos por un assistant (bloques `tool_use`
|
||||||
|
estilo Anthropic y/o `tool_calls` estilo OpenAI legacy)."""
|
||||||
|
ids: set[str] = set()
|
||||||
|
content = message.get("content")
|
||||||
|
if isinstance(content, list):
|
||||||
|
for b in content:
|
||||||
|
if isinstance(b, dict) and b.get("type") == "tool_use":
|
||||||
|
ids.add(str(b.get("id", "")))
|
||||||
|
for tc in message.get("tool_calls") or []:
|
||||||
|
if isinstance(tc, dict):
|
||||||
|
ids.add(str(tc.get("id", "")))
|
||||||
|
ids.discard("")
|
||||||
|
return ids
|
||||||
|
|
||||||
|
def _enforce_tool_pairing(
|
||||||
|
self, messages: list[dict[str, Any]]
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Repara el invariante tool_use ↔ tool_result en ambas direcciones.
|
||||||
|
|
||||||
|
La compactacion puede colapsar el content de un assistant (perdiendo sus
|
||||||
|
bloques `tool_use`) mientras el user siguiente conserva sus `tool_result`,
|
||||||
|
o al reves. El matching es por IDs (`tool_use.id` vs `tool_result.tool_use_id`
|
||||||
|
y `tool_calls[].id` vs `tool_call_id`), no solo por adyacencia, asi que
|
||||||
|
tambien repara desajustes parciales (p.ej. 3 tool_use vs 2 tool_result).
|
||||||
|
|
||||||
|
- tool_result sin tool_use previo → bloque text placeholder.
|
||||||
|
- tool_use sin tool_result siguiente → se elimina el bloque (thinking/text
|
||||||
|
se conservan; si el content queda vacio, placeholder string).
|
||||||
|
- `role: tool` legacy sin assistant con `tool_calls` → user placeholder.
|
||||||
|
"""
|
||||||
|
repaired: list[dict[str, Any]] = []
|
||||||
|
for idx, msg in enumerate(messages):
|
||||||
|
role = msg.get("role", "")
|
||||||
|
content = msg.get("content")
|
||||||
|
|
||||||
|
if role == "assistant":
|
||||||
|
tool_ids = self._tool_use_ids(msg)
|
||||||
|
if not tool_ids:
|
||||||
|
repaired.append(msg)
|
||||||
|
continue
|
||||||
|
# IDs respondidos: user con tool_results inmediato y/o run
|
||||||
|
# contiguo de mensajes legacy `role: tool`.
|
||||||
|
answered: set[str] = set()
|
||||||
|
j = idx + 1
|
||||||
|
if (
|
||||||
|
j < len(messages)
|
||||||
|
and messages[j].get("role") == "user"
|
||||||
|
and isinstance(messages[j].get("content"), list)
|
||||||
|
):
|
||||||
|
for b in messages[j]["content"]:
|
||||||
|
if isinstance(b, dict) and b.get("type") == "tool_result":
|
||||||
|
answered.add(str(b.get("tool_use_id", "")))
|
||||||
|
j += 1
|
||||||
|
while j < len(messages) and messages[j].get("role") == "tool":
|
||||||
|
answered.add(str(messages[j].get("tool_call_id", "")))
|
||||||
|
j += 1
|
||||||
|
unanswered = tool_ids - answered
|
||||||
|
if not unanswered:
|
||||||
|
repaired.append(msg)
|
||||||
|
continue
|
||||||
|
# Eliminar los tool_use/tool_calls sin respuesta.
|
||||||
|
new_msg = dict(msg)
|
||||||
|
if isinstance(content, list):
|
||||||
|
new_content = [
|
||||||
|
b
|
||||||
|
for b in content
|
||||||
|
if not (
|
||||||
|
isinstance(b, dict)
|
||||||
|
and b.get("type") == "tool_use"
|
||||||
|
and str(b.get("id", "")) in unanswered
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if not new_content:
|
||||||
|
new_msg["content"] = "[ASSISTANT COMPACTADO]"
|
||||||
|
else:
|
||||||
|
new_msg["content"] = new_content
|
||||||
|
if isinstance(new_msg.get("tool_calls"), list):
|
||||||
|
kept_calls = [
|
||||||
|
tc
|
||||||
|
for tc in new_msg["tool_calls"]
|
||||||
|
if isinstance(tc, dict)
|
||||||
|
and str(tc.get("id", "")) not in unanswered
|
||||||
|
]
|
||||||
|
if kept_calls:
|
||||||
|
new_msg["tool_calls"] = kept_calls
|
||||||
|
else:
|
||||||
|
new_msg.pop("tool_calls", None)
|
||||||
|
if not new_msg.get("content"):
|
||||||
|
new_msg["content"] = "[ASSISTANT COMPACTADO]"
|
||||||
|
repaired.append(new_msg)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if role == "user" and self._blocks_have_type(content, "tool_result"):
|
||||||
|
# IDs disponibles en el assistant inmediatamente anterior
|
||||||
|
# (YA reparado — usar `repaired[-1]` refleja los tool_use que
|
||||||
|
# sobrevivieron, no los del mensaje original).
|
||||||
|
available: set[str] = set()
|
||||||
|
if repaired and repaired[-1].get("role") == "assistant":
|
||||||
|
available = self._tool_use_ids(repaired[-1])
|
||||||
|
new_content: list[Any] = []
|
||||||
|
orphaned = False
|
||||||
|
for b in content:
|
||||||
|
if (
|
||||||
|
isinstance(b, dict)
|
||||||
|
and b.get("type") == "tool_result"
|
||||||
|
and str(b.get("tool_use_id", "")) not in available
|
||||||
|
):
|
||||||
|
orphaned = True
|
||||||
|
# Fusionar placeholders consecutivos en un unico bloque text.
|
||||||
|
if not (
|
||||||
|
new_content
|
||||||
|
and isinstance(new_content[-1], dict)
|
||||||
|
and new_content[-1].get("type") == "text"
|
||||||
|
and new_content[-1].get("text")
|
||||||
|
== "[Resultado de herramienta compactado]"
|
||||||
|
):
|
||||||
|
new_content.append(
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "[Resultado de herramienta compactado]",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
new_content.append(b)
|
||||||
|
if not orphaned:
|
||||||
|
repaired.append(msg)
|
||||||
|
continue
|
||||||
|
new_msg = dict(msg)
|
||||||
|
only_placeholders = all(
|
||||||
|
isinstance(b, dict)
|
||||||
|
and b.get("type") == "text"
|
||||||
|
and b.get("text") == "[Resultado de herramienta compactado]"
|
||||||
|
for b in new_content
|
||||||
|
)
|
||||||
|
if not new_content or only_placeholders:
|
||||||
|
new_msg["content"] = "[Resultado de herramienta compactado]"
|
||||||
|
else:
|
||||||
|
new_msg["content"] = new_content
|
||||||
|
repaired.append(new_msg)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if role == "tool":
|
||||||
|
# Legacy: el assistant anterior (saltando otros `role: tool`
|
||||||
|
# contiguos) debe tener este tool_call_id en sus tool_calls.
|
||||||
|
prev_assistant: dict[str, Any] | None = None
|
||||||
|
for prev in reversed(repaired):
|
||||||
|
if prev.get("role") == "tool":
|
||||||
|
continue
|
||||||
|
if prev.get("role") == "assistant":
|
||||||
|
prev_assistant = prev
|
||||||
|
break
|
||||||
|
call_id = str(msg.get("tool_call_id", ""))
|
||||||
|
valid = (
|
||||||
|
prev_assistant is not None
|
||||||
|
and call_id in self._tool_use_ids(prev_assistant)
|
||||||
|
)
|
||||||
|
if valid:
|
||||||
|
repaired.append(msg)
|
||||||
|
else:
|
||||||
|
repaired.append(
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "[Resultado de herramienta compactado]",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
repaired.append(msg)
|
||||||
|
return repaired
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Internals
|
# Internals
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -487,7 +714,10 @@ class ContextCompactor:
|
|||||||
except (json.JSONDecodeError, ValueError):
|
except (json.JSONDecodeError, ValueError):
|
||||||
return None
|
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:
|
try:
|
||||||
body = json.dumps(truncated, ensure_ascii=False, separators=(",", ":"))
|
body = json.dumps(truncated, ensure_ascii=False, separators=(",", ":"))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
@@ -660,6 +890,10 @@ class ContextCompactor:
|
|||||||
elif btype == "tool_result":
|
elif btype == "tool_result":
|
||||||
tc = block.get("content", "")
|
tc = block.get("content", "")
|
||||||
tokens += estimate_tokens(tc if isinstance(tc, str) else str(tc))
|
tokens += estimate_tokens(tc if isinstance(tc, str) else str(tc))
|
||||||
|
elif btype == "image_url":
|
||||||
|
# Una imagen ~1500 tokens. NO medir el base64 como texto, que
|
||||||
|
# lo contaría como ~30k y reventaría presupuestos/trim.
|
||||||
|
tokens += 1500
|
||||||
else:
|
else:
|
||||||
tokens += estimate_tokens(str(block))
|
tokens += estimate_tokens(str(block))
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -66,13 +66,35 @@ class ContextEngine:
|
|||||||
artifacts: list[ArtifactSummary] | None = None,
|
artifacts: list[ArtifactSummary] | None = None,
|
||||||
conversation: list[dict[str, Any]] | None = None,
|
conversation: list[dict[str, Any]] | None = None,
|
||||||
extra_instructions: str = "",
|
extra_instructions: str = "",
|
||||||
|
model_id: str | None = None,
|
||||||
|
budget_override: int | None = None,
|
||||||
) -> ContextPackage:
|
) -> ContextPackage:
|
||||||
"""Build a full ContextPackage for the given agent and session.
|
"""Build a full ContextPackage for the given agent and session.
|
||||||
|
|
||||||
The conversation parameter contains real assistant/tool messages
|
The conversation parameter contains real assistant/tool messages
|
||||||
with complete tool results. These go into the messages array,
|
with complete tool results. These go into the messages array,
|
||||||
not the system prompt — like professional agentic tools.
|
not the system prompt — like professional agentic tools.
|
||||||
|
|
||||||
|
El budget de contexto se deriva de la VENTANA REAL del modelo activo
|
||||||
|
(`model_id`, formato litellm) vía catálogo/litellm; `budget_override`
|
||||||
|
fuerza un budget menor (retry agresivo ante overflow).
|
||||||
"""
|
"""
|
||||||
|
# Budget del turno: override (retry) → override duro de settings →
|
||||||
|
# ventana del modelo → fallback estático. Umbral derivado del budget.
|
||||||
|
from ..orchestrator.cost import resolve_context_window
|
||||||
|
|
||||||
|
if budget_override is not None and budget_override > 0:
|
||||||
|
budget = budget_override
|
||||||
|
elif settings.context_max_tokens > 0:
|
||||||
|
budget = settings.context_max_tokens
|
||||||
|
else:
|
||||||
|
window = await resolve_context_window(model_id) if model_id else None
|
||||||
|
budget = (
|
||||||
|
settings.budget_for_window(window)
|
||||||
|
if window
|
||||||
|
else settings.effective_context_budget
|
||||||
|
)
|
||||||
|
threshold = settings.compaction_threshold_for(budget)
|
||||||
|
|
||||||
sections: list[ContextSection] = []
|
sections: list[ContextSection] = []
|
||||||
allowed = set(agent.context_sections)
|
allowed = set(agent.context_sections)
|
||||||
@@ -90,11 +112,15 @@ class ContextEngine:
|
|||||||
and ("artifact_memory" in allowed or "task_state" in allowed)
|
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:
|
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(
|
kb_section = await self._build_knowledge_base(
|
||||||
session,
|
session,
|
||||||
max_tokens=settings.knowledge_base_max_tokens,
|
agent=agent,
|
||||||
|
max_tokens=kb_budget,
|
||||||
)
|
)
|
||||||
if kb_section:
|
if kb_section:
|
||||||
sections.append(kb_section)
|
sections.append(kb_section)
|
||||||
@@ -113,6 +139,7 @@ class ContextEngine:
|
|||||||
sections.append(
|
sections.append(
|
||||||
self._build_task_state(
|
self._build_task_state(
|
||||||
session.current_task,
|
session.current_task,
|
||||||
|
session=session,
|
||||||
objective_override=base_user_content,
|
objective_override=base_user_content,
|
||||||
resolved_context=resolved_followup_context,
|
resolved_context=resolved_followup_context,
|
||||||
followup_mode=followup_mode,
|
followup_mode=followup_mode,
|
||||||
@@ -135,7 +162,7 @@ class ContextEngine:
|
|||||||
raw_message_tokens = sum(self._estimate_message_tokens(m) for m in messages)
|
raw_message_tokens = sum(self._estimate_message_tokens(m) for m in messages)
|
||||||
pre_compaction_section_tokens = sum(estimate_tokens(s.content) for s in sections)
|
pre_compaction_section_tokens = sum(estimate_tokens(s.content) for s in sections)
|
||||||
pre_compaction_total = pre_compaction_section_tokens + raw_message_tokens
|
pre_compaction_total = pre_compaction_section_tokens + raw_message_tokens
|
||||||
section_budget = max(1, settings.effective_context_budget - raw_message_tokens)
|
section_budget = max(1, budget - raw_message_tokens)
|
||||||
|
|
||||||
# Compact sections only when the full prompt is approaching the target.
|
# Compact sections only when the full prompt is approaching the target.
|
||||||
section_compaction = {
|
section_compaction = {
|
||||||
@@ -150,8 +177,8 @@ class ContextEngine:
|
|||||||
}
|
}
|
||||||
system_prompt = self._assemble_system_prompt(sections)
|
system_prompt = self._assemble_system_prompt(sections)
|
||||||
system_prompt_tokens = estimate_tokens(system_prompt)
|
system_prompt_tokens = estimate_tokens(system_prompt)
|
||||||
hard_message_budget = max(1, settings.effective_context_budget - system_prompt_tokens)
|
hard_message_budget = max(1, budget - system_prompt_tokens)
|
||||||
target_message_budget = max(1, settings.effective_compaction_threshold - system_prompt_tokens)
|
target_message_budget = max(1, threshold - system_prompt_tokens)
|
||||||
message_budget = min(hard_message_budget, target_message_budget)
|
message_budget = min(hard_message_budget, target_message_budget)
|
||||||
conversation_compaction = {
|
conversation_compaction = {
|
||||||
"budget_tokens": message_budget,
|
"budget_tokens": message_budget,
|
||||||
@@ -165,7 +192,7 @@ class ContextEngine:
|
|||||||
}
|
}
|
||||||
|
|
||||||
total_tokens = system_prompt_tokens + raw_message_tokens
|
total_tokens = system_prompt_tokens + raw_message_tokens
|
||||||
if total_tokens > settings.effective_compaction_threshold:
|
if total_tokens > threshold:
|
||||||
messages, conversation_compaction = self.compactor.compact_conversation(
|
messages, conversation_compaction = self.compactor.compact_conversation(
|
||||||
messages,
|
messages,
|
||||||
max_tokens=message_budget,
|
max_tokens=message_budget,
|
||||||
@@ -176,10 +203,10 @@ class ContextEngine:
|
|||||||
self._estimate_message_tokens(m) for m in messages
|
self._estimate_message_tokens(m) for m in messages
|
||||||
)
|
)
|
||||||
|
|
||||||
if total_tokens > settings.effective_context_budget:
|
if total_tokens > budget:
|
||||||
section_budget = max(
|
section_budget = max(
|
||||||
1,
|
1,
|
||||||
settings.effective_context_budget
|
budget
|
||||||
- sum(self._estimate_message_tokens(m) for m in messages),
|
- sum(self._estimate_message_tokens(m) for m in messages),
|
||||||
)
|
)
|
||||||
sections, section_compaction = self.compactor.compact_sections(
|
sections, section_compaction = self.compactor.compact_sections(
|
||||||
@@ -192,10 +219,10 @@ class ContextEngine:
|
|||||||
self._estimate_message_tokens(m) for m in messages
|
self._estimate_message_tokens(m) for m in messages
|
||||||
)
|
)
|
||||||
|
|
||||||
if total_tokens > settings.effective_context_budget:
|
if total_tokens > budget:
|
||||||
hard_message_budget = max(
|
hard_message_budget = max(
|
||||||
1,
|
1,
|
||||||
settings.effective_context_budget - system_prompt_tokens,
|
budget - system_prompt_tokens,
|
||||||
)
|
)
|
||||||
messages, conversation_compaction = self.compactor.compact_conversation(
|
messages, conversation_compaction = self.compactor.compact_conversation(
|
||||||
messages,
|
messages,
|
||||||
@@ -212,6 +239,7 @@ class ContextEngine:
|
|||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
total_token_estimate=total_tokens,
|
total_token_estimate=total_tokens,
|
||||||
|
budget_tokens=budget,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Guardar contexto completo del último build (solo el último por sesión)
|
# Guardar contexto completo del último build (solo el último por sesión)
|
||||||
@@ -219,8 +247,8 @@ class ContextEngine:
|
|||||||
"system_prompt": system_prompt,
|
"system_prompt": system_prompt,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"total_tokens": total_tokens,
|
"total_tokens": total_tokens,
|
||||||
"budget_tokens": settings.effective_context_budget,
|
"budget_tokens": budget,
|
||||||
"threshold_tokens": settings.effective_compaction_threshold,
|
"threshold_tokens": threshold,
|
||||||
"timestamp": time.time(),
|
"timestamp": time.time(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,8 +281,8 @@ class ContextEngine:
|
|||||||
"user_message_preview": user_content[:200],
|
"user_message_preview": user_content[:200],
|
||||||
"artifacts_count": len(artifacts) if artifacts else 0,
|
"artifacts_count": len(artifacts) if artifacts else 0,
|
||||||
"conversation_messages": conv_len,
|
"conversation_messages": conv_len,
|
||||||
"budget_tokens": settings.effective_context_budget,
|
"budget_tokens": budget,
|
||||||
"threshold_tokens": settings.effective_compaction_threshold,
|
"threshold_tokens": threshold,
|
||||||
"message_tokens": conversation_compaction.get("output_tokens", raw_message_tokens),
|
"message_tokens": conversation_compaction.get("output_tokens", raw_message_tokens),
|
||||||
"message_tokens_before_compaction": raw_message_tokens,
|
"message_tokens_before_compaction": raw_message_tokens,
|
||||||
"pre_compaction_tokens": pre_compaction_total,
|
"pre_compaction_tokens": pre_compaction_total,
|
||||||
@@ -263,7 +291,7 @@ class ContextEngine:
|
|||||||
"message_budget_tokens": message_budget,
|
"message_budget_tokens": message_budget,
|
||||||
"section_compaction": section_compaction,
|
"section_compaction": section_compaction,
|
||||||
"conversation_compaction": conversation_compaction,
|
"conversation_compaction": conversation_compaction,
|
||||||
"over_budget": total_tokens > settings.effective_context_budget,
|
"over_budget": total_tokens > budget,
|
||||||
}
|
}
|
||||||
|
|
||||||
history = self._history[session.session_id]
|
history = self._history[session.session_id]
|
||||||
@@ -340,29 +368,29 @@ class ContextEngine:
|
|||||||
def _build_immutable_rules(
|
def _build_immutable_rules(
|
||||||
self, session: SessionState, agent: AgentProfile
|
self, session: SessionState, agent: AgentProfile
|
||||||
) -> ContextSection:
|
) -> ContextSection:
|
||||||
parts = [
|
# `agent.system_prompt` ya incluye el contrato compartido (concatenado
|
||||||
"# System Rules (Immutable)",
|
# por el registry al cargar). Aqui solo se añaden reglas de sesion
|
||||||
"",
|
# cuando existen — el bloque hardcoded de "Contrato de Contexto" que
|
||||||
agent.system_prompt,
|
# 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:
|
if session.immutable_rules:
|
||||||
parts.append("## Session Rules")
|
parts.append("\n\n## Session Rules\n")
|
||||||
for rule in session.immutable_rules:
|
for rule in session.immutable_rules:
|
||||||
parts.append(f"- {rule}")
|
parts.append(f"- {rule}")
|
||||||
parts.extend(
|
content = "\n".join(p for p in parts if p)
|
||||||
[
|
|
||||||
"",
|
|
||||||
"## 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)
|
|
||||||
return ContextSection(
|
return ContextSection(
|
||||||
section_type=ContextSectionType.IMMUTABLE_RULES,
|
section_type=ContextSectionType.IMMUTABLE_RULES,
|
||||||
content=content,
|
content=content,
|
||||||
@@ -388,14 +416,30 @@ class ContextEngine:
|
|||||||
async def _build_knowledge_base(
|
async def _build_knowledge_base(
|
||||||
self,
|
self,
|
||||||
session: SessionState,
|
session: SessionState,
|
||||||
|
agent: AgentProfile,
|
||||||
max_tokens: int,
|
max_tokens: int,
|
||||||
) -> ContextSection | None:
|
) -> 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
|
Estrategias soportadas:
|
||||||
task. Always includes a title index of ALL docs so the agent
|
- `none`: no inyecta KB (devuelve None).
|
||||||
knows what exists and can request more.
|
- `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:
|
if not self.memory:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -412,36 +456,124 @@ class ContextEngine:
|
|||||||
if not all_docs:
|
if not all_docs:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
doc_map = {d.memory_id: d for d in all_docs}
|
# 1) Pre-filtrado segun strategy.
|
||||||
|
candidates: list[MemoryDocument]
|
||||||
# Rank docs by semantic similarity
|
if strategy == "cheatsheet_only":
|
||||||
query = self._build_search_query(session)
|
candidates = [d for d in all_docs if "cheatsheet" in (d.load_when or [])]
|
||||||
ranked_ids: list[str] = []
|
elif strategy == "glossary_only":
|
||||||
|
candidates = [d for d in all_docs if "glossary" in (d.load_when or [])]
|
||||||
if query:
|
elif strategy == "planner_only":
|
||||||
ranked_ids = await self._semantic_rank(query)
|
candidates = [
|
||||||
|
d for d in all_docs
|
||||||
if not ranked_ids:
|
if any(t in (d.load_when or []) for t in ("planner_only", "cheatsheet", "glossary"))
|
||||||
# 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))
|
|
||||||
]
|
]
|
||||||
|
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
|
token_budget = max_tokens
|
||||||
|
top_n_cap = agent.kb_top_n or settings.kb_top_n_docs
|
||||||
full_docs: list[MemoryDocument] = []
|
full_docs: list[MemoryDocument] = []
|
||||||
|
|
||||||
for doc_id in ranked_ids:
|
for doc in ordered:
|
||||||
doc = doc_map.get(doc_id)
|
if len(full_docs) >= top_n_cap and strategy not in ("cheatsheet_only", "glossary_only", "planner_only"):
|
||||||
if not doc:
|
break
|
||||||
continue
|
|
||||||
doc_tokens = estimate_tokens(doc.content)
|
doc_tokens = estimate_tokens(doc.content)
|
||||||
if doc_tokens <= token_budget:
|
if doc_tokens <= token_budget:
|
||||||
full_docs.append(doc)
|
full_docs.append(doc)
|
||||||
token_budget -= doc_tokens
|
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}
|
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]
|
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:
|
if not_included:
|
||||||
lines.append("## Other Available Docs")
|
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:
|
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("")
|
lines.append("")
|
||||||
|
|
||||||
content = "\n".join(lines)
|
content = "\n".join(lines)
|
||||||
@@ -472,8 +604,18 @@ class ContextEngine:
|
|||||||
token_estimate=estimate_tokens(content),
|
token_estimate=estimate_tokens(content),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _semantic_rank(self, query: str) -> list[str]:
|
async def _semantic_rank(self, query: str) -> list[tuple[str, float]]:
|
||||||
"""Rank knowledge docs by cosine similarity to the query."""
|
"""Rank knowledge docs by cosine similarity. Returns (doc_id, score)."""
|
||||||
|
# Sin credencial de embeddings no tiene sentido intentar la llamada (daria
|
||||||
|
# 401 en cada turno). Se desactiva limpiamente con un aviso unico.
|
||||||
|
if not settings.embeddings_enabled:
|
||||||
|
if not getattr(self, "_embed_disabled_warned", False):
|
||||||
|
logger.warning(
|
||||||
|
"Embeddings disabled (no AGENTIC_EMBEDDINGS_API_KEY) — "
|
||||||
|
"semantic search off, loading all docs"
|
||||||
|
)
|
||||||
|
self._embed_disabled_warned = True
|
||||||
|
return []
|
||||||
try:
|
try:
|
||||||
if not self._embed_service:
|
if not self._embed_service:
|
||||||
self._embed_service = EmbeddingService()
|
self._embed_service = EmbeddingService()
|
||||||
@@ -484,7 +626,7 @@ class ContextEngine:
|
|||||||
namespace="knowledge",
|
namespace="knowledge",
|
||||||
top_k=50,
|
top_k=50,
|
||||||
)
|
)
|
||||||
return [doc_id for doc_id, _score in results]
|
return list(results)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Semantic search failed: %s — loading all docs", e)
|
logger.warning("Semantic search failed: %s — loading all docs", e)
|
||||||
@@ -572,6 +714,7 @@ class ContextEngine:
|
|||||||
def _build_task_state(
|
def _build_task_state(
|
||||||
self,
|
self,
|
||||||
task: TaskState,
|
task: TaskState,
|
||||||
|
session: SessionState | None = None,
|
||||||
objective_override: str | None = None,
|
objective_override: str | None = None,
|
||||||
resolved_context: str = "",
|
resolved_context: str = "",
|
||||||
followup_mode: str = "none",
|
followup_mode: str = "none",
|
||||||
@@ -659,6 +802,37 @@ class ContextEngine:
|
|||||||
f" {marker} Step {i + 1} [{status_label}{compacted_label}]: {step.description}"
|
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)
|
content = "\n".join(lines)
|
||||||
return ContextSection(
|
return ContextSection(
|
||||||
section_type=ContextSectionType.TASK_STATE,
|
section_type=ContextSectionType.TASK_STATE,
|
||||||
@@ -773,8 +947,19 @@ class ContextEngine:
|
|||||||
messages.append({"role": "user", "content": "\n".join(history_lines)})
|
messages.append({"role": "user", "content": "\n".join(history_lines)})
|
||||||
messages.append({"role": "assistant", "content": "Entendido, tengo el contexto del historial. ¿En qué puedo ayudarte ahora?"})
|
messages.append({"role": "assistant", "content": "Entendido, tengo el contexto del historial. ¿En qué puedo ayudarte ahora?"})
|
||||||
|
|
||||||
# Current user message
|
# Current user message — con imágenes adjuntas (visión nativa) si las hay.
|
||||||
messages.append({"role": "user", "content": user_content})
|
# 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)
|
# Append real conversation (assistant messages + tool results from current step)
|
||||||
if conversation:
|
if conversation:
|
||||||
@@ -795,7 +980,22 @@ class ContextEngine:
|
|||||||
else:
|
else:
|
||||||
base_user_content = "Awaiting task assignment."
|
base_user_content = "Awaiting task assignment."
|
||||||
|
|
||||||
followup_mode = self._classify_followup_mode(base_user_content)
|
# Un follow-up (transform/fetch_more/ambiguous) SOLO tiene sentido si hay
|
||||||
|
# un turno anterior al que referirse. En una sesión fresca / primer mensaje
|
||||||
|
# no hay nada que transformar, así que NO clasificamos: de lo contrario un
|
||||||
|
# primer prompt que casualmente contenga un marker ("resumen", "estructura",
|
||||||
|
# "busca", "adapta"…) se marcaría como `transform` y `_get_allowed_tools`
|
||||||
|
# devolvería [] — el agente se quedaría SIN tools y emitiría los tool calls
|
||||||
|
# como texto sin ejecutarlos (caso real: el prompt de análisis de estilos
|
||||||
|
# que dice "Guarda un resumen…").
|
||||||
|
has_prior_turn = bool(session.task_history) or bool(
|
||||||
|
getattr(session, "recent_messages", [])
|
||||||
|
)
|
||||||
|
followup_mode = (
|
||||||
|
self._classify_followup_mode(base_user_content)
|
||||||
|
if has_prior_turn
|
||||||
|
else "none"
|
||||||
|
)
|
||||||
resolved_context = ""
|
resolved_context = ""
|
||||||
if session.task_history and followup_mode != "none":
|
if session.task_history and followup_mode != "none":
|
||||||
resolved_context = self._build_followup_resolution(session.task_history[-1])
|
resolved_context = self._build_followup_resolution(session.task_history[-1])
|
||||||
@@ -871,6 +1071,10 @@ class ContextEngine:
|
|||||||
elif btype == "tool_result":
|
elif btype == "tool_result":
|
||||||
tc = block.get("content", "")
|
tc = block.get("content", "")
|
||||||
total += estimate_tokens(tc if isinstance(tc, str) else str(tc))
|
total += estimate_tokens(tc if isinstance(tc, str) else str(tc))
|
||||||
|
elif btype == "image_url":
|
||||||
|
# Heurística conservadora: una imagen ~1500 tokens (no se
|
||||||
|
# cuenta el base64 como texto, que infla muchísimo).
|
||||||
|
total += 1500
|
||||||
else:
|
else:
|
||||||
total += estimate_tokens(str(block))
|
total += estimate_tokens(str(block))
|
||||||
return total
|
return total
|
||||||
|
|||||||
@@ -54,7 +54,11 @@ async def lifespan(app: FastAPI):
|
|||||||
await redis_storage.connect()
|
await redis_storage.connect()
|
||||||
|
|
||||||
# 2. Initialize model adapter
|
# 2. Initialize model adapter
|
||||||
if settings.default_model_provider == "openai":
|
if settings.default_model_provider == "litellm":
|
||||||
|
from .adapters.litellm_adapter import LiteLLMAdapter
|
||||||
|
model_adapter = LiteLLMAdapter()
|
||||||
|
logger.info("Using LiteLLM adapter (model: %s)", settings.litellm_model or settings.default_model_id)
|
||||||
|
elif settings.default_model_provider == "openai":
|
||||||
model_adapter = OpenAIAdapter()
|
model_adapter = OpenAIAdapter()
|
||||||
logger.info("Using OpenAI adapter (model: %s)", settings.default_model_id)
|
logger.info("Using OpenAI adapter (model: %s)", settings.default_model_id)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -18,6 +18,15 @@ from ..models.tools import ToolDefinition
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Buffer maximo (bytes) del StreamReader para leer las respuestas JSON-RPC del
|
||||||
|
# MCP por stdout. Una respuesta llega en UNA sola linea; tools como el
|
||||||
|
# screenshot fullPage de Playwright devuelven la imagen en base64 en esa linea
|
||||||
|
# y superan de largo el 64KB por defecto de asyncio (y el 1MB que teniamos),
|
||||||
|
# lanzando LimitOverrunError que mataba el read loop y dejaba la sesion MCP
|
||||||
|
# inservible (el agente "se paraba" al hacer acciones). 64MB cubre cualquier
|
||||||
|
# screenshot real; por encima, el read loop descarta esa respuesta y sigue vivo.
|
||||||
|
MCP_STREAM_LIMIT = 64 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
class MCPClientError(Exception):
|
class MCPClientError(Exception):
|
||||||
pass
|
pass
|
||||||
@@ -74,7 +83,7 @@ class MCPClient:
|
|||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
env=self._env,
|
env=self._env,
|
||||||
limit=1024 * 1024, # 1MB buffer for large MCP responses
|
limit=MCP_STREAM_LIMIT, # buffer grande para respuestas MCP (screenshots base64)
|
||||||
)
|
)
|
||||||
self._running = True
|
self._running = True
|
||||||
self._reader_task = asyncio.create_task(self._read_loop())
|
self._reader_task = asyncio.create_task(self._read_loop())
|
||||||
@@ -225,14 +234,30 @@ class MCPClient:
|
|||||||
if not self._process or not self._process.stdout:
|
if not self._process or not self._process.stdout:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
stdout = self._process.stdout
|
||||||
try:
|
try:
|
||||||
while self._running:
|
while self._running:
|
||||||
line = await self._process.stdout.readline()
|
try:
|
||||||
|
line = await stdout.readline()
|
||||||
|
except (ValueError, asyncio.LimitOverrunError):
|
||||||
|
# Una respuesta JSON-RPC supero el buffer (p.ej. screenshot
|
||||||
|
# fullPage de Playwright en base64 por encima de 64MB). Antes
|
||||||
|
# esto mataba el read loop y dejaba TODA la sesion MCP muerta
|
||||||
|
# (el agente se "paraba" en la siguiente accion). Ahora
|
||||||
|
# descartamos solo esa respuesta, re-sincronizamos el stream
|
||||||
|
# y seguimos vivos para las demas tools.
|
||||||
|
logger.warning(
|
||||||
|
"MCP [%s]: respuesta supera el buffer (%d MB), se descarta y se continua",
|
||||||
|
self.name, MCP_STREAM_LIMIT // (1024 * 1024),
|
||||||
|
)
|
||||||
|
await self._drain_until_newline(stdout)
|
||||||
|
continue
|
||||||
|
|
||||||
if not line:
|
if not line:
|
||||||
logger.warning("MCP server stdout closed")
|
logger.warning("MCP server stdout closed")
|
||||||
break
|
break
|
||||||
|
|
||||||
line_str = line.decode().strip()
|
line_str = line.decode(errors="replace").strip()
|
||||||
if not line_str:
|
if not line_str:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -251,6 +276,21 @@ class MCPClient:
|
|||||||
finally:
|
finally:
|
||||||
self._running = False
|
self._running = False
|
||||||
|
|
||||||
|
async def _drain_until_newline(self, stdout: asyncio.StreamReader) -> None:
|
||||||
|
"""Consume bytes del stream hasta el proximo salto de linea para
|
||||||
|
re-sincronizar tras un LimitOverrunError (la respuesta sobredimensionada
|
||||||
|
se descarta). `read()` no usa separador, asi que no vuelve a disparar el
|
||||||
|
overrun y va vaciando el buffer hasta liberar la linea gigante."""
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
chunk = await stdout.read(65536)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
if not chunk:
|
||||||
|
return
|
||||||
|
if b"\n" in chunk:
|
||||||
|
return
|
||||||
|
|
||||||
def _handle_message(self, message: dict[str, Any]) -> None:
|
def _handle_message(self, message: dict[str, Any]) -> None:
|
||||||
"""Route an incoming JSON-RPC message."""
|
"""Route an incoming JSON-RPC message."""
|
||||||
msg_id = message.get("id")
|
msg_id = message.get("id")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ defines the project-specific variables (ACAI_WEB_URL, etc.).
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -17,6 +18,15 @@ from .manager import MCPManager
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
class MCPRegistry:
|
||||||
"""Manages per-session MCPManager instances.
|
"""Manages per-session MCPManager instances.
|
||||||
@@ -29,6 +39,7 @@ class MCPRegistry:
|
|||||||
self._config_path = Path(config_path) if config_path else None
|
self._config_path = Path(config_path) if config_path else None
|
||||||
self._config: MCPConfigFile | None = None
|
self._config: MCPConfigFile | None = None
|
||||||
self._sessions: dict[str, MCPManager] = {} # session_id → MCPManager
|
self._sessions: dict[str, MCPManager] = {} # session_id → MCPManager
|
||||||
|
self._last_used: dict[str, float] = {} # session_id → monotonic ts (LRU)
|
||||||
|
|
||||||
def load_config(self) -> None:
|
def load_config(self) -> None:
|
||||||
"""Load the global MCP config template."""
|
"""Load the global MCP config template."""
|
||||||
@@ -91,6 +102,7 @@ class MCPRegistry:
|
|||||||
|
|
||||||
results = await manager.start()
|
results = await manager.start()
|
||||||
self._sessions[session_id] = manager
|
self._sessions[session_id] = manager
|
||||||
|
self._last_used[session_id] = time.monotonic()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"MCP started for session %s: %s",
|
"MCP started for session %s: %s",
|
||||||
@@ -98,18 +110,41 @@ class MCPRegistry:
|
|||||||
{k: v.get("status") for k, v in results.items()},
|
{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
|
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:
|
async def destroy_for_session(self, session_id: str) -> None:
|
||||||
"""Stop and clean up MCP servers for a session."""
|
"""Stop and clean up MCP servers for a session."""
|
||||||
manager = self._sessions.pop(session_id, None)
|
manager = self._sessions.pop(session_id, None)
|
||||||
|
self._last_used.pop(session_id, None)
|
||||||
if manager:
|
if manager:
|
||||||
await manager.stop()
|
await manager.stop()
|
||||||
logger.info("MCP stopped for session %s", session_id[:12])
|
logger.info("MCP stopped for session %s", session_id[:12])
|
||||||
|
|
||||||
def get_for_session(self, session_id: str) -> MCPManager | None:
|
def get_for_session(self, session_id: str) -> MCPManager | None:
|
||||||
"""Get the MCPManager for a session, if any."""
|
"""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:
|
async def stop_all(self) -> None:
|
||||||
"""Stop all sessions' MCP servers (shutdown)."""
|
"""Stop all sessions' MCP servers (shutdown)."""
|
||||||
|
|||||||
@@ -25,12 +25,19 @@ class EmbeddingService:
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
model: str = DEFAULT_MODEL,
|
model: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._client = AsyncOpenAI(
|
# Credenciales dedicadas de embeddings. Fallback a openai_api_key por
|
||||||
api_key=api_key or settings.openai_api_key,
|
# compat. El base_url solo se aplica si se configura explicitamente
|
||||||
)
|
# `embeddings_base_url`; vacio => OpenAI real (api.openai.com). NO se
|
||||||
self._model = model
|
# hereda `openai_base_url` (que apunta al chat, p.ej. DeepSeek sin
|
||||||
|
# endpoint de embeddings).
|
||||||
|
key = api_key or settings.effective_embeddings_key
|
||||||
|
kwargs: dict[str, Any] = {"api_key": key}
|
||||||
|
if settings.embeddings_base_url:
|
||||||
|
kwargs["base_url"] = settings.embeddings_base_url
|
||||||
|
self._client = AsyncOpenAI(**kwargs)
|
||||||
|
self._model = model or settings.embeddings_model or DEFAULT_MODEL
|
||||||
|
|
||||||
async def embed(self, text: str) -> list[float]:
|
async def embed(self, text: str) -> list[float]:
|
||||||
"""Generate embedding for a single text."""
|
"""Generate embedding for a single text."""
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ class AgentProfile(BaseModel):
|
|||||||
system_prompt: str = ""
|
system_prompt: str = ""
|
||||||
allowed_tools: list[str] = Field(default_factory=list)
|
allowed_tools: list[str] = Field(default_factory=list)
|
||||||
model_id: str | None = None
|
model_id: str | None = None
|
||||||
|
planner_model_id: str | None = None # override del modelo solo para el sub-loop del planner
|
||||||
|
reasoning_effort: str | None = None # nivel de razonamiento (minimal|low|medium|high) resuelto por sesión
|
||||||
temperature: float | None = None
|
temperature: float | None = None
|
||||||
max_tokens: int | None = None
|
max_tokens: int | None = None
|
||||||
context_sections: list[str] = Field(
|
context_sections: list[str] = Field(
|
||||||
@@ -31,6 +33,23 @@ class AgentProfile(BaseModel):
|
|||||||
)
|
)
|
||||||
stream_deltas: bool = True # Si emite deltas por SSE al usuario
|
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):
|
class SubAgentDefinition(BaseModel):
|
||||||
"""A runnable subagent configuration within the orchestrator."""
|
"""A runnable subagent configuration within the orchestrator."""
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ class ContextPackage(BaseModel):
|
|||||||
system_prompt: str = ""
|
system_prompt: str = ""
|
||||||
messages: list[dict[str, Any]] = Field(default_factory=list)
|
messages: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
total_token_estimate: int = 0
|
total_token_estimate: int = 0
|
||||||
|
# Budget de contexto (tokens) usado para construir/compactar este paquete —
|
||||||
|
# derivado de la ventana del modelo activo. Lo usa el loop del agente para
|
||||||
|
# compactar más agresivo si aún no cabe en la ventana.
|
||||||
|
budget_tokens: int = 0
|
||||||
|
|
||||||
def to_messages(self) -> list[dict[str, Any]]:
|
def to_messages(self) -> list[dict[str, Any]]:
|
||||||
"""Produce the final messages list for the model adapter."""
|
"""Produce the final messages list for the model adapter."""
|
||||||
@@ -62,6 +66,10 @@ class MemoryDocument(BaseModel):
|
|||||||
content: str
|
content: str
|
||||||
summary: str = ""
|
summary: str = ""
|
||||||
tags: list[str] = Field(default_factory=list)
|
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
|
embedding: list[float] | None = None
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ class TaskState(BaseModel):
|
|||||||
|
|
||||||
task_id: str = Field(default_factory=lambda: uuid.uuid4().hex[:12])
|
task_id: str = Field(default_factory=lambda: uuid.uuid4().hex[:12])
|
||||||
objective: str
|
objective: str
|
||||||
|
# Imágenes adjuntas a la petición actual (visión nativa). Cada item es un
|
||||||
|
# bloque listo para el modelo: {"type":"image_url","image_url":{"url":"data:..."}}.
|
||||||
|
image_attachments: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
status: TaskStatus = TaskStatus.PENDING
|
status: TaskStatus = TaskStatus.PENDING
|
||||||
plan: list[TaskStep] = Field(default_factory=list)
|
plan: list[TaskStep] = Field(default_factory=list)
|
||||||
current_step_index: int = 0
|
current_step_index: int = 0
|
||||||
@@ -94,8 +97,8 @@ class SessionState(BaseModel):
|
|||||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
def begin_task(self, objective: str) -> TaskState:
|
def begin_task(self, objective: str, image_attachments: list[dict[str, Any]] | None = None) -> TaskState:
|
||||||
task = TaskState(objective=objective)
|
task = TaskState(objective=objective, image_attachments=image_attachments or [])
|
||||||
self.current_task = task
|
self.current_task = task
|
||||||
self.status = SessionStatus.EXECUTING
|
self.status = SessionStatus.EXECUTING
|
||||||
self.turn_count += 1
|
self.turn_count += 1
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import time
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Any, AsyncIterator
|
from typing import Any, AsyncIterator
|
||||||
|
|
||||||
from ...adapters.base import ModelAdapter, ModelConfig, StreamChunk
|
from ...adapters.base import ContextOverflowError, ModelAdapter, ModelConfig, StreamChunk
|
||||||
from ...config import settings
|
from ...config import settings
|
||||||
from ...context.engine import ContextEngine
|
from ...context.engine import ContextEngine
|
||||||
|
from ..cost import resolve_context_window
|
||||||
from ...mcp.manager import MCPManager
|
from ...mcp.manager import MCPManager
|
||||||
from ...memory.store import MemoryStore
|
from ...memory.store import MemoryStore
|
||||||
from ...models.agent import AgentProfile
|
from ...models.agent import AgentProfile
|
||||||
@@ -19,6 +20,9 @@ from ...models.artifacts import ArtifactSummary
|
|||||||
from ...models.session import SessionState
|
from ...models.session import SessionState
|
||||||
from ...models.tools import ToolExecution, ToolExecutionStatus
|
from ...models.tools import ToolExecution, ToolExecutionStatus
|
||||||
from ...streaming.sse import SSEEmitter, EventType
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -64,19 +68,53 @@ class BaseAgent:
|
|||||||
total_output_tokens = 0
|
total_output_tokens = 0
|
||||||
# Real conversation history: assistant messages + tool results
|
# Real conversation history: assistant messages + tool results
|
||||||
conversation: list[dict[str, Any]] = []
|
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):
|
for step in range(max_steps):
|
||||||
# Build context with real conversation
|
# Build context with real conversation. El budget se deriva de la
|
||||||
ctx = await self.context.build_context(
|
# ventana REAL del modelo activo; si el contexto estimado no cabe ni
|
||||||
session=session,
|
# tras compactar, reconstruimos con compactación más agresiva antes
|
||||||
agent=self.profile,
|
# de llamar al LLM (evita una llamada condenada a fallar). Si ni así
|
||||||
artifacts=artifacts,
|
# cabe → ContextOverflowError → error accionable (no rompe la sesión).
|
||||||
conversation=conversation,
|
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(
|
tool_defs = self._get_allowed_tools(
|
||||||
followup_mode=str(session.metadata.get("followup_mode", "none")),
|
followup_mode=str(session.metadata.get("followup_mode", "none")),
|
||||||
|
plan_mode=str(session.metadata.get("plan_mode", "off") or "off"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Stream model response
|
# Stream model response
|
||||||
@@ -84,8 +122,14 @@ class BaseAgent:
|
|||||||
model_id=self.profile.model_id or "",
|
model_id=self.profile.model_id or "",
|
||||||
max_tokens=self.profile.max_tokens or 4096,
|
max_tokens=self.profile.max_tokens or 4096,
|
||||||
temperature=self.profile.temperature or 0.3,
|
temperature=self.profile.temperature or 0.3,
|
||||||
|
reasoning_effort=self.profile.reasoning_effort or "",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Snapshot del numero de tool_executions ya acumulados ANTES del
|
||||||
|
# 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 = ""
|
full_text = ""
|
||||||
tool_calls: list[dict[str, Any]] = []
|
tool_calls: list[dict[str, Any]] = []
|
||||||
active_tools: dict[str, dict[str, Any]] = {}
|
active_tools: dict[str, dict[str, Any]] = {}
|
||||||
@@ -134,6 +178,17 @@ class BaseAgent:
|
|||||||
turn_blocks_by_index[chunk.block_index] = blk
|
turn_blocks_by_index[chunk.block_index] = blk
|
||||||
if blk.get("type") == "thinking":
|
if blk.get("type") == "thinking":
|
||||||
blk["thinking"] = blk.get("thinking", "") + chunk.thinking_delta
|
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:
|
if chunk.thinking_signature and chunk.block_index >= 0:
|
||||||
blk = turn_blocks_by_index.get(chunk.block_index)
|
blk = turn_blocks_by_index.get(chunk.block_index)
|
||||||
@@ -264,11 +319,51 @@ class BaseAgent:
|
|||||||
|
|
||||||
# If no tool calls, we're done
|
# If no tool calls, we're done
|
||||||
if not tool_calls:
|
if not tool_calls:
|
||||||
|
# Quirk DeepSeek thinking: a veces el modelo emite TODA su
|
||||||
|
# respuesta como reasoning y cierra el turno sin text ni
|
||||||
|
# tool_use. Si el turno termina SOLO con bloques thinking,
|
||||||
|
# promovemos el thinking a un bloque text en el snapshot que
|
||||||
|
# se persiste — asi el UI no lo muestra como "pensando" al
|
||||||
|
# recargar y el siguiente turno no rompe con
|
||||||
|
# "content or tool_calls must be set".
|
||||||
|
if turn_blocks and all(b.get("type") == "thinking" for b in turn_blocks):
|
||||||
|
promoted = "\n".join(
|
||||||
|
b.get("thinking", "") for b in turn_blocks if b.get("thinking")
|
||||||
|
)
|
||||||
|
turn_blocks = [{"type": "text", "text": promoted}]
|
||||||
|
accumulated_content += promoted
|
||||||
|
if promoted and self.profile.stream_deltas:
|
||||||
|
# Emision en vivo via AGENT_DELTA normal: el
|
||||||
|
# ClaudeFormatEmitter cierra el thinking block abierto
|
||||||
|
# (content_block_stop) y abre un text block nuevo con
|
||||||
|
# su propio indice (start/delta/stop), asi que el
|
||||||
|
# protocolo de bloques no se rompe.
|
||||||
|
await self.sse.emit(
|
||||||
|
EventType.AGENT_DELTA,
|
||||||
|
{
|
||||||
|
"agent": self.profile.role,
|
||||||
|
"delta": promoted,
|
||||||
|
"step": step,
|
||||||
|
},
|
||||||
|
session_id=session.session_id,
|
||||||
|
)
|
||||||
if turn_blocks:
|
if turn_blocks:
|
||||||
conversation.append({"role": "assistant", "content": turn_blocks})
|
conversation.append({"role": "assistant", "content": turn_blocks})
|
||||||
elif full_text:
|
elif full_text:
|
||||||
# Fallback (no debiera ocurrir si el adapter emite block_index).
|
# Fallback (no debiera ocurrir si el adapter emite block_index).
|
||||||
conversation.append({"role": "assistant", "content": full_text})
|
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
|
break
|
||||||
|
|
||||||
# Push del assistant turn con TODOS los blocks (thinking+text+tool_use).
|
# Push del assistant turn con TODOS los blocks (thinking+text+tool_use).
|
||||||
@@ -344,6 +439,17 @@ class BaseAgent:
|
|||||||
if tool_result_blocks:
|
if tool_result_blocks:
|
||||||
conversation.append({"role": "user", "content": 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 {
|
return {
|
||||||
"content": accumulated_content,
|
"content": accumulated_content,
|
||||||
"artifacts": artifacts,
|
"artifacts": artifacts,
|
||||||
@@ -374,6 +480,20 @@ class BaseAgent:
|
|||||||
|
|
||||||
logger.info("Tool call: %s(%s)", tool_name, json.dumps(arguments)[:200])
|
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()
|
start = time.monotonic()
|
||||||
try:
|
try:
|
||||||
if self.mcp.is_running:
|
if self.mcp.is_running:
|
||||||
@@ -439,25 +559,560 @@ class BaseAgent:
|
|||||||
|
|
||||||
return tool_exec
|
return tool_exec
|
||||||
|
|
||||||
def _get_allowed_tools(self, followup_mode: str = "none") -> list[dict[str, Any]]:
|
# ---- Tools internas del orquestador (Fase 5) -----------------------------
|
||||||
"""Return tool definitions filtered by this agent's allowed_tools."""
|
|
||||||
|
@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":
|
if followup_mode == "transform":
|
||||||
return []
|
return []
|
||||||
if not self.mcp.is_running:
|
if not self.mcp.is_running:
|
||||||
return []
|
return []
|
||||||
all_tools = self.mcp.get_tool_definitions()
|
all_tools = self.mcp.get_tool_definitions()
|
||||||
if not self.profile.allowed_tools:
|
if self.profile.allowed_tools:
|
||||||
return all_tools # No filter → all tools
|
tool_defs = [t for t in all_tools if t["name"] in self.profile.allowed_tools]
|
||||||
return [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
|
@staticmethod
|
||||||
def _extract_mcp_output(result: dict[str, Any]) -> str:
|
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", [])
|
content = result.get("content", [])
|
||||||
if isinstance(content, list):
|
if isinstance(content, list):
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
|
image_count = 0
|
||||||
for item in content:
|
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", ""))
|
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 "\n".join(parts) if parts else json.dumps(result)
|
||||||
return str(content)
|
return str(content)
|
||||||
|
|||||||
257
src/orchestrator/cost.py
Normal file
257
src/orchestrator/cost.py
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
"""Cálculo de coste por modelo (Fase 2).
|
||||||
|
|
||||||
|
Prioridad de fuentes de precio (para que el coste registrado en
|
||||||
|
`consumo_acaicode` coincida con lo que muestra el Forge Admin Panel):
|
||||||
|
1. Catálogo OpenRouter cacheado por el panel en Redis db 0
|
||||||
|
(`acai:config:ai:models_cache:openrouter` → price_in_1m / price_out_1m).
|
||||||
|
2. Price map de LiteLLM (conoce muchos modelos deepseek/, anthropic/, etc.).
|
||||||
|
3. Coste fijo de `settings` (comportamiento previo).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
import redis.asyncio as redis
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Caches de catálogo que publica el Forge Admin Panel en Redis db 0, por proveedor.
|
||||||
|
# El id se guarda SIN el prefijo de proveedor de litellm (p.ej.
|
||||||
|
# "moonshotai/kimi-k2.7-code", "deepseek-v4-pro").
|
||||||
|
_CACHE_KEYS = {
|
||||||
|
"openrouter": "acai:config:ai:models_cache:openrouter",
|
||||||
|
"deepseek": "acai:config:ai:models_cache:deepseek",
|
||||||
|
}
|
||||||
|
_CONFIG_DB = 0
|
||||||
|
_cfg_redis: "redis.Redis | None" = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_cfg_redis() -> "redis.Redis":
|
||||||
|
global _cfg_redis
|
||||||
|
if _cfg_redis is None:
|
||||||
|
_cfg_redis = redis.Redis(
|
||||||
|
host=settings.redis_host,
|
||||||
|
port=settings.redis_port,
|
||||||
|
db=_CONFIG_DB,
|
||||||
|
password=settings.redis_password or None,
|
||||||
|
decode_responses=True,
|
||||||
|
)
|
||||||
|
return _cfg_redis
|
||||||
|
|
||||||
|
|
||||||
|
# --- Catálogo con self-heal -------------------------------------------------
|
||||||
|
# El catálogo OpenRouter lo publica el Forge Admin Panel con TTL de 1h y solo se
|
||||||
|
# repuebla al abrir su ventana de IA. En runtime (coste y ventana de contexto)
|
||||||
|
# eso es frágil: si caduca, perdemos precio Y context_length del modelo activo.
|
||||||
|
# Aquí lo repoblamos nosotros (fetch público a OpenRouter, mismo shape que el
|
||||||
|
# admin) cuando falta, con un cooldown para no martillear la API. DeepSeek es
|
||||||
|
# persistente (lo escribe el admin en el arranque) y no necesita self-heal.
|
||||||
|
_OPENROUTER_URL = "https://openrouter.ai/api/v1/models"
|
||||||
|
_OPENROUTER_TIMEOUT = 15
|
||||||
|
_OR_SELFHEAL_TTL = 86_400 # 24h: persiste bastante; el admin lo refresca aparte
|
||||||
|
_OR_REFRESH_COOLDOWN = 300 # como mucho un fetch / 5 min
|
||||||
|
_or_last_refresh = [0.0]
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_openrouter_catalog_sync() -> list[dict]:
|
||||||
|
"""GET público al catálogo OpenRouter, normalizado al MISMO shape que el
|
||||||
|
admin panel (id, context_length, price_*, supports_reasoning, supports_images).
|
||||||
|
Filtra a modelos con soporte `tools` (igual que el admin)."""
|
||||||
|
req = urllib.request.Request(_OPENROUTER_URL, method="GET")
|
||||||
|
req.add_header("Accept", "application/json")
|
||||||
|
with urllib.request.urlopen(req, timeout=_OPENROUTER_TIMEOUT) as resp:
|
||||||
|
payload = json.loads(resp.read().decode("utf-8"))
|
||||||
|
items = payload.get("data") if isinstance(payload, dict) else None
|
||||||
|
if not isinstance(items, list):
|
||||||
|
return []
|
||||||
|
out: list[dict] = []
|
||||||
|
for it in items:
|
||||||
|
if not isinstance(it, dict) or not it.get("id"):
|
||||||
|
continue
|
||||||
|
supported = it.get("supported_parameters") or []
|
||||||
|
if not isinstance(supported, list) or "tools" not in supported:
|
||||||
|
continue
|
||||||
|
pricing = it.get("pricing") or {}
|
||||||
|
try:
|
||||||
|
pin = float(pricing.get("prompt", 0) or 0) * 1_000_000
|
||||||
|
pout = float(pricing.get("completion", 0) or 0) * 1_000_000
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pin = pout = 0.0
|
||||||
|
try:
|
||||||
|
ctx = int(it.get("context_length") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
ctx = 0
|
||||||
|
mods = (it.get("architecture") or {}).get("input_modalities") or []
|
||||||
|
out.append({
|
||||||
|
"id": it.get("id"),
|
||||||
|
"name": it.get("name") or it.get("id"),
|
||||||
|
"context_length": ctx,
|
||||||
|
"price_in_1m": pin,
|
||||||
|
"price_out_1m": pout,
|
||||||
|
"supports_reasoning": "reasoning" in supported or "include_reasoning" in supported,
|
||||||
|
"supports_images": isinstance(mods, list) and "image" in mods,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_catalog(provider: str | None) -> list[dict] | None:
|
||||||
|
"""Catálogo del proveedor desde Redis. Para OpenRouter, si falta (TTL
|
||||||
|
caducado) lo repuebla en runtime (self-heal con cooldown)."""
|
||||||
|
cache_key = _CACHE_KEYS.get(provider or "")
|
||||||
|
if not cache_key:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
cached = await _get_cfg_redis().get(cache_key)
|
||||||
|
if cached:
|
||||||
|
data = json.loads(cached)
|
||||||
|
if isinstance(data, list):
|
||||||
|
return data
|
||||||
|
except Exception as e: # pragma: no cover - defensivo
|
||||||
|
logger.warning("catálogo %s no disponible: %s", provider, e)
|
||||||
|
if provider != "openrouter":
|
||||||
|
return None
|
||||||
|
# Self-heal solo para OpenRouter, con cooldown para no martillear la API.
|
||||||
|
now = time.time()
|
||||||
|
if now - _or_last_refresh[0] < _OR_REFRESH_COOLDOWN:
|
||||||
|
return None
|
||||||
|
_or_last_refresh[0] = now
|
||||||
|
try:
|
||||||
|
models = await asyncio.to_thread(_fetch_openrouter_catalog_sync)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("self-heal catálogo openrouter falló: %s", e)
|
||||||
|
return None
|
||||||
|
if models:
|
||||||
|
try:
|
||||||
|
await _get_cfg_redis().set(cache_key, json.dumps(models), ex=_OR_SELFHEAL_TTL)
|
||||||
|
logger.info("catálogo openrouter repoblado en runtime: %d modelos", len(models))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return models
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _catalog_price_per_1m(model_id: str | None):
|
||||||
|
"""(price_in_1m, price_out_1m) del catálogo, o None. model_id en formato
|
||||||
|
litellm ("<provider>/<id>")."""
|
||||||
|
if not model_id or "/" not in model_id:
|
||||||
|
return None
|
||||||
|
provider, _, raw_id = model_id.partition("/")
|
||||||
|
models = await _get_catalog(provider)
|
||||||
|
if not models:
|
||||||
|
return None
|
||||||
|
for m in models:
|
||||||
|
if m.get("id") == raw_id:
|
||||||
|
pin = m.get("price_in_1m")
|
||||||
|
pout = m.get("price_out_1m")
|
||||||
|
if pin is not None and pout is not None:
|
||||||
|
return (float(pin), float(pout))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Ventana de contexto por modelo -----------------------------------------
|
||||||
|
# Cache en proceso con TTL corto: build_context resuelve la ventana en cada step
|
||||||
|
# del loop, y el catálogo cambia rara vez. Evita pegar a Redis 25x/turno.
|
||||||
|
_window_cache: dict[str, tuple[float, int | None]] = {}
|
||||||
|
_WINDOW_TTL = 60.0
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_context_window(model_id: str | None) -> int | None:
|
||||||
|
"""Ventana de contexto (tokens) del modelo activo.
|
||||||
|
|
||||||
|
Fuentes en orden: catálogo del Forge Admin Panel en Redis (`context_length`)
|
||||||
|
→ price/info map de LiteLLM (`max_input_tokens`/`max_tokens`) → None.
|
||||||
|
`model_id` viene en formato litellm ("<provider>/<id>").
|
||||||
|
"""
|
||||||
|
if not model_id or "/" not in model_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
cached = _window_cache.get(model_id)
|
||||||
|
if cached and (now - cached[0]) < _WINDOW_TTL:
|
||||||
|
return cached[1]
|
||||||
|
|
||||||
|
window: int | None = None
|
||||||
|
|
||||||
|
# 1. Catálogo del panel (con self-heal para OpenRouter si caducó).
|
||||||
|
provider, _, raw_id = model_id.partition("/")
|
||||||
|
models = await _get_catalog(provider)
|
||||||
|
if models:
|
||||||
|
for m in models:
|
||||||
|
if m.get("id") == raw_id:
|
||||||
|
cl = m.get("context_length")
|
||||||
|
if isinstance(cl, int) and cl > 0:
|
||||||
|
window = cl
|
||||||
|
break
|
||||||
|
|
||||||
|
# 2. Fallback: LiteLLM conoce muchos modelos (deepseek/, anthropic/, ...).
|
||||||
|
if window is None:
|
||||||
|
try:
|
||||||
|
import litellm
|
||||||
|
|
||||||
|
info = litellm.get_model_info(model_id) or {}
|
||||||
|
for key in ("max_input_tokens", "max_tokens"):
|
||||||
|
v = info.get(key)
|
||||||
|
if isinstance(v, int) and v > 0:
|
||||||
|
window = v
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
_window_cache[model_id] = (now, window)
|
||||||
|
return window
|
||||||
|
|
||||||
|
|
||||||
|
async def compute_cost(model_id: str | None, input_tokens: int, output_tokens: int) -> dict:
|
||||||
|
"""Coste de una ejecución para `model_id` y los tokens dados.
|
||||||
|
|
||||||
|
Devuelve {"cost_usd", "input_cost_1m", "output_cost_1m"} — el coste total y
|
||||||
|
las tarifas por 1M tokens REALMENTE aplicadas (se almacenan en
|
||||||
|
`consumo_acaicode.input_cost_1M` / `output_cost_1M`).
|
||||||
|
"""
|
||||||
|
input_tokens = int(input_tokens or 0)
|
||||||
|
output_tokens = int(output_tokens or 0)
|
||||||
|
|
||||||
|
def _result(in_1m: float, out_1m: float) -> dict:
|
||||||
|
return {
|
||||||
|
"cost_usd": (input_tokens / 1_000_000) * in_1m + (output_tokens / 1_000_000) * out_1m,
|
||||||
|
"input_cost_1m": round(in_1m, 6),
|
||||||
|
"output_cost_1m": round(out_1m, 6),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Precio del catálogo OpenRouter (fuente que muestra el admin).
|
||||||
|
prices = await _catalog_price_per_1m(model_id)
|
||||||
|
if prices:
|
||||||
|
return _result(prices[0], prices[1])
|
||||||
|
|
||||||
|
# 2. Price map de LiteLLM (deepseek/, anthropic/, etc.).
|
||||||
|
if model_id and "/" in model_id:
|
||||||
|
try:
|
||||||
|
import litellm
|
||||||
|
|
||||||
|
prompt_cost, completion_cost = litellm.cost_per_token(
|
||||||
|
model=model_id,
|
||||||
|
prompt_tokens=input_tokens,
|
||||||
|
completion_tokens=output_tokens,
|
||||||
|
)
|
||||||
|
total = (prompt_cost or 0.0) + (completion_cost or 0.0)
|
||||||
|
if total > 0:
|
||||||
|
# Derivar tarifa por 1M a partir del coste por-token de litellm.
|
||||||
|
in_1m = (prompt_cost / input_tokens) * 1_000_000 if input_tokens else 0.0
|
||||||
|
out_1m = (completion_cost / output_tokens) * 1_000_000 if output_tokens else 0.0
|
||||||
|
return {
|
||||||
|
"cost_usd": total,
|
||||||
|
"input_cost_1m": round(in_1m, 6),
|
||||||
|
"output_cost_1m": round(out_1m, 6),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("cost_per_token(%s) falló, uso coste fijo: %s", model_id, e)
|
||||||
|
|
||||||
|
# 3. Coste fijo configurado.
|
||||||
|
return _result(settings.cost_per_1m_input, settings.cost_per_1m_output)
|
||||||
@@ -11,10 +11,10 @@ import logging
|
|||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from ..adapters.base import ModelAdapter
|
from ..adapters.base import ContextOverflowError, ModelAdapter
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
from ..context.engine import ContextEngine
|
from ..context.engine import ContextEngine
|
||||||
from ..context.compactor import estimate_tokens
|
from ..context.compactor import ContextCompactor, estimate_tokens
|
||||||
from ..mcp.manager import MCPManager
|
from ..mcp.manager import MCPManager
|
||||||
from ..memory.store import MemoryStore
|
from ..memory.store import MemoryStore
|
||||||
from ..models.agent import AgentProfile
|
from ..models.agent import AgentProfile
|
||||||
@@ -52,11 +52,16 @@ class OrchestratorEngine:
|
|||||||
self,
|
self,
|
||||||
session: SessionState,
|
session: SessionState,
|
||||||
message: str,
|
message: str,
|
||||||
|
image_attachments: list[dict[str, Any]] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Process a user message. Single agent execution with timeout."""
|
"""Process a user message. Single agent execution with timeout.
|
||||||
|
|
||||||
|
`image_attachments`: bloques image_url (visión nativa) para el turno del
|
||||||
|
usuario, cuando el modelo activo es multimodal.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
return await asyncio.wait_for(
|
return await asyncio.wait_for(
|
||||||
self._run(session, message),
|
self._run(session, message, image_attachments),
|
||||||
timeout=settings.max_execution_timeout_seconds,
|
timeout=settings.max_execution_timeout_seconds,
|
||||||
)
|
)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
@@ -70,6 +75,20 @@ class OrchestratorEngine:
|
|||||||
session_id=session.session_id,
|
session_id=session.session_id,
|
||||||
)
|
)
|
||||||
return self._error_result(session, "Execution timed out")
|
return self._error_result(session, "Execution timed out")
|
||||||
|
except ContextOverflowError as e:
|
||||||
|
# El contexto no cabe en la ventana del modelo ni tras compactar al
|
||||||
|
# máximo. Mensaje accionable (no fallo genérico de plataforma): el
|
||||||
|
# usuario sabe qué hacer (acortar o cambiar de modelo).
|
||||||
|
logger.warning("Context overflow for session %s: %s", session.session_id, e)
|
||||||
|
if session.current_task:
|
||||||
|
session.current_task.mark_failed(str(e))
|
||||||
|
session.status = SessionStatus.ERROR
|
||||||
|
await self.sse.emit(
|
||||||
|
EventType.ERROR,
|
||||||
|
{"error": "context_overflow", "message": str(e)},
|
||||||
|
session_id=session.session_id,
|
||||||
|
)
|
||||||
|
return self._error_result(session, str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Unhandled error for session %s", session.session_id)
|
logger.exception("Unhandled error for session %s", session.session_id)
|
||||||
if session.current_task:
|
if session.current_task:
|
||||||
@@ -86,6 +105,7 @@ class OrchestratorEngine:
|
|||||||
self,
|
self,
|
||||||
session: SessionState,
|
session: SessionState,
|
||||||
message: str,
|
message: str,
|
||||||
|
image_attachments: list[dict[str, Any]] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Execute: message → agent → response."""
|
"""Execute: message → agent → response."""
|
||||||
|
|
||||||
@@ -99,10 +119,27 @@ class OrchestratorEngine:
|
|||||||
session_id=session.session_id,
|
session_id=session.session_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create task
|
# Plan mode 'force': el usuario ha pulsado el toggle Plan en el chat.
|
||||||
task = session.begin_task(objective=message)
|
# 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
|
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
|
# Execute with the selected agent
|
||||||
agent = BaseAgent(
|
agent = BaseAgent(
|
||||||
profile=self.agent_profile,
|
profile=self.agent_profile,
|
||||||
@@ -137,6 +174,9 @@ class OrchestratorEngine:
|
|||||||
session.recent_messages,
|
session.recent_messages,
|
||||||
message=message,
|
message=message,
|
||||||
conversation=result.get("conversation", []),
|
conversation=result.get("conversation", []),
|
||||||
|
image_attachments=(
|
||||||
|
session.current_task.image_attachments if session.current_task else None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
session.task_history.append(
|
session.task_history.append(
|
||||||
@@ -165,13 +205,18 @@ class OrchestratorEngine:
|
|||||||
task.status = TaskStatus.COMPLETED
|
task.status = TaskStatus.COMPLETED
|
||||||
session.complete_task()
|
session.complete_task()
|
||||||
|
|
||||||
# Calculate cost
|
# Calculate cost — por modelo realmente usado (Fase 2). El model_id
|
||||||
|
# efectivo vive en el agent_profile (resuelto en send_message).
|
||||||
total_input = usage.get("input_tokens", 0)
|
total_input = usage.get("input_tokens", 0)
|
||||||
total_output = usage.get("output_tokens", 0)
|
total_output = usage.get("output_tokens", 0)
|
||||||
cost_usd = (
|
model_used = (
|
||||||
(total_input / 1_000_000) * settings.cost_per_1m_input
|
self.agent_profile.model_id
|
||||||
+ (total_output / 1_000_000) * settings.cost_per_1m_output
|
or settings.litellm_model
|
||||||
|
or settings.default_model_id
|
||||||
)
|
)
|
||||||
|
from .cost import compute_cost
|
||||||
|
cost_info = await compute_cost(model_used, total_input, total_output)
|
||||||
|
cost_usd = cost_info["cost_usd"]
|
||||||
|
|
||||||
await self.sse.emit(
|
await self.sse.emit(
|
||||||
EventType.EXECUTION_COMPLETED,
|
EventType.EXECUTION_COMPLETED,
|
||||||
@@ -184,6 +229,19 @@ class OrchestratorEngine:
|
|||||||
"status": "completed",
|
"status": "completed",
|
||||||
"usage": usage,
|
"usage": usage,
|
||||||
"total_cost_usd": round(cost_usd, 6),
|
"total_cost_usd": round(cost_usd, 6),
|
||||||
|
# Modelo + tarifas usadas → se propagan a consumo_acaicode via
|
||||||
|
# _report_usage (columnas input_cost_1M / output_cost_1M).
|
||||||
|
"model": model_used,
|
||||||
|
"modelUsage": {
|
||||||
|
model_used: {
|
||||||
|
"inputTokens": total_input,
|
||||||
|
"outputTokens": total_output,
|
||||||
|
"costUSD": round(cost_usd, 6),
|
||||||
|
"inputCost1M": cost_info["input_cost_1m"],
|
||||||
|
"outputCost1M": cost_info["output_cost_1m"],
|
||||||
|
"reasoningEffort": self.agent_profile.reasoning_effort or "",
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
session_id=session.session_id,
|
session_id=session.session_id,
|
||||||
)
|
)
|
||||||
@@ -209,6 +267,21 @@ class OrchestratorEngine:
|
|||||||
"status": "completed",
|
"status": "completed",
|
||||||
"usage": usage,
|
"usage": usage,
|
||||||
"total_cost_usd": round(cost_usd, 6),
|
"total_cost_usd": round(cost_usd, 6),
|
||||||
|
# Modelo + tarifas realmente usadas. Se incluyen tambien aqui (ademas
|
||||||
|
# del evento SSE EXECUTION_COMPLETED) para que el camino NO streaming
|
||||||
|
# (cronjobs -> _report_usage) reporte el modelo correcto a
|
||||||
|
# consumo_acaicode en vez de "unknown".
|
||||||
|
"model": model_used,
|
||||||
|
"modelUsage": {
|
||||||
|
model_used: {
|
||||||
|
"inputTokens": total_input,
|
||||||
|
"outputTokens": total_output,
|
||||||
|
"costUSD": round(cost_usd, 6),
|
||||||
|
"inputCost1M": cost_info["input_cost_1m"],
|
||||||
|
"outputCost1M": cost_info["output_cost_1m"],
|
||||||
|
"reasoningEffort": self.agent_profile.reasoning_effort or "",
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
def _error_result(self, session: SessionState, error: str) -> dict[str, Any]:
|
def _error_result(self, session: SessionState, error: str) -> dict[str, Any]:
|
||||||
@@ -229,13 +302,21 @@ class OrchestratorEngine:
|
|||||||
existing: list[dict[str, Any]],
|
existing: list[dict[str, Any]],
|
||||||
message: str,
|
message: str,
|
||||||
conversation: list[dict[str, Any]],
|
conversation: list[dict[str, Any]],
|
||||||
|
image_attachments: list[dict[str, Any]] | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
merged = [OrchestratorEngine._sanitize_recent_message(m) for m in existing]
|
merged = [OrchestratorEngine._sanitize_recent_message(m) for m in existing]
|
||||||
merged = [m for m in merged if m]
|
merged = [m for m in merged if m]
|
||||||
|
|
||||||
current_turn: list[dict[str, Any]] = []
|
current_turn: list[dict[str, Any]] = []
|
||||||
if message.strip():
|
if message.strip() or image_attachments:
|
||||||
current_turn.append({"role": "user", "content": message})
|
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:
|
for message_obj in conversation:
|
||||||
sanitized = OrchestratorEngine._sanitize_recent_message(message_obj)
|
sanitized = OrchestratorEngine._sanitize_recent_message(message_obj)
|
||||||
@@ -243,7 +324,76 @@ class OrchestratorEngine:
|
|||||||
current_turn.append(sanitized)
|
current_turn.append(sanitized)
|
||||||
|
|
||||||
merged.extend(current_turn)
|
merged.extend(current_turn)
|
||||||
return merged
|
return OrchestratorEngine._trim_recent_messages(merged)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _trim_recent_messages(
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Recorta recent_messages a un presupuesto de tokens eliminando
|
||||||
|
mensajes ENTEROS desde el principio (los mas antiguos).
|
||||||
|
|
||||||
|
Dos reglas para no romper el invariante tool_use ↔ tool_result:
|
||||||
|
- Nunca cortar dentro de un par: si se elimina un assistant con
|
||||||
|
tool_use, se eliminan tambien sus tool_results (user carrier o run
|
||||||
|
de mensajes legacy `role: tool`).
|
||||||
|
- El primer mensaje resultante nunca puede ser un carrier de
|
||||||
|
tool_result ni un `role: tool`.
|
||||||
|
|
||||||
|
Mantiene siempre al menos los ultimos 4 mensajes aunque excedan el
|
||||||
|
presupuesto.
|
||||||
|
"""
|
||||||
|
budget = settings.recent_messages_max_tokens
|
||||||
|
if budget <= 0 or not messages:
|
||||||
|
return messages
|
||||||
|
|
||||||
|
estimate = ContextCompactor._estimate_message_tokens
|
||||||
|
total = sum(estimate(m) for m in messages)
|
||||||
|
if total <= budget:
|
||||||
|
return messages
|
||||||
|
|
||||||
|
def _is_tool_result_carrier(msg: dict[str, Any]) -> bool:
|
||||||
|
if msg.get("role") == "tool":
|
||||||
|
return True
|
||||||
|
if msg.get("role") != "user":
|
||||||
|
return False
|
||||||
|
content = msg.get("content")
|
||||||
|
return isinstance(content, list) and any(
|
||||||
|
isinstance(b, dict) and b.get("type") == "tool_result"
|
||||||
|
for b in content
|
||||||
|
)
|
||||||
|
|
||||||
|
def _has_tool_use(msg: dict[str, Any]) -> bool:
|
||||||
|
if msg.get("role") != "assistant":
|
||||||
|
return False
|
||||||
|
if msg.get("tool_calls"):
|
||||||
|
return True
|
||||||
|
content = msg.get("content")
|
||||||
|
return isinstance(content, list) and any(
|
||||||
|
isinstance(b, dict) and b.get("type") == "tool_use"
|
||||||
|
for b in content
|
||||||
|
)
|
||||||
|
|
||||||
|
min_keep = 4
|
||||||
|
n = len(messages)
|
||||||
|
start = 0
|
||||||
|
while total > budget and start < n - min_keep:
|
||||||
|
end = start + 1
|
||||||
|
if _has_tool_use(messages[start]):
|
||||||
|
# Arrastrar los tool_results del par (no cortar dentro de el).
|
||||||
|
while end < n and _is_tool_result_carrier(messages[end]):
|
||||||
|
end += 1
|
||||||
|
if n - end < min_keep:
|
||||||
|
break # Eliminar el par completo invadiria los ultimos min_keep
|
||||||
|
for k in range(start, end):
|
||||||
|
total -= estimate(messages[k])
|
||||||
|
start = end
|
||||||
|
|
||||||
|
trimmed = messages[start:]
|
||||||
|
# El primer mensaje nunca puede ser un tool_result sin su tool_use.
|
||||||
|
while trimmed and _is_tool_result_carrier(trimmed[0]):
|
||||||
|
trimmed.pop(0)
|
||||||
|
return trimmed
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _sanitize_recent_message(message: dict[str, Any]) -> dict[str, Any]:
|
def _sanitize_recent_message(message: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
|||||||
113
src/orchestrator/model_resolver.py
Normal file
113
src/orchestrator/model_resolver.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"""Resolución dinámica del modelo IA por sesión (Fase 2).
|
||||||
|
|
||||||
|
Prioridad:
|
||||||
|
1. Override por-usuario: `session.metadata["ai_provider"|"ai_model"]`. Lo
|
||||||
|
inyecta acai-app via self-read del WS (`getAcaiCodeUserAiModel`) al crear
|
||||||
|
la sesión.
|
||||||
|
2. Default global: Redis `acai:config:ai:provider` / `acai:config:ai:model`,
|
||||||
|
que escribe el Forge Admin Panel. OJO: esas keys NO llevan el prefijo
|
||||||
|
`agentic` (son globales del stack Acai).
|
||||||
|
3. Sin configuración → None: no se toca el modelo y el adapter usa su default
|
||||||
|
(comportamiento previo).
|
||||||
|
|
||||||
|
Solo aplica cuando el provider activo es `litellm` — los providers del catálogo
|
||||||
|
(openrouter, deepseek) se enrutan por LiteLLM. Para claude/openai no se toca.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import redis.asyncio as redis
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Keys del Forge Admin Panel (globales, SIN prefijo agentic).
|
||||||
|
_GLOBAL_PROVIDER_KEY = "acai:config:ai:provider"
|
||||||
|
_GLOBAL_MODEL_KEY = "acai:config:ai:model"
|
||||||
|
_GLOBAL_REASONING_KEY = "acai:config:ai:reasoning_effort"
|
||||||
|
|
||||||
|
# Niveles de razonamiento válidos (lo demás se ignora → sin razonamiento).
|
||||||
|
_VALID_EFFORTS = {"minimal", "low", "medium", "high"}
|
||||||
|
|
||||||
|
# El Forge Admin Panel escribe la config global en Redis db 0 (REDIS_DB=0 del
|
||||||
|
# admin). El agentic usa db 1 para sus sesiones, así que para leer la config
|
||||||
|
# global necesitamos una conexión dedicada a db 0 (misma instancia Redis).
|
||||||
|
_GLOBAL_CONFIG_DB = 0
|
||||||
|
_global_redis: "redis.Redis | None" = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_global_redis() -> "redis.Redis":
|
||||||
|
global _global_redis
|
||||||
|
if _global_redis is None:
|
||||||
|
_global_redis = redis.Redis(
|
||||||
|
host=settings.redis_host,
|
||||||
|
port=settings.redis_port,
|
||||||
|
db=_GLOBAL_CONFIG_DB,
|
||||||
|
password=settings.redis_password or None,
|
||||||
|
decode_responses=True,
|
||||||
|
)
|
||||||
|
return _global_redis
|
||||||
|
|
||||||
|
|
||||||
|
def to_litellm_model(provider: str | None, model: str | None) -> str:
|
||||||
|
"""Mapea {provider, model} del catálogo a un model string de LiteLLM."""
|
||||||
|
provider = (provider or "").strip().lower()
|
||||||
|
model = (model or "").strip()
|
||||||
|
if not model:
|
||||||
|
return ""
|
||||||
|
if provider == "openrouter":
|
||||||
|
# Los ids de OpenRouter ya vienen como "vendor/name" → prefijo openrouter/.
|
||||||
|
return model if model.startswith("openrouter/") else f"openrouter/{model}"
|
||||||
|
if provider == "deepseek":
|
||||||
|
return model if model.startswith("deepseek/") else f"deepseek/{model}"
|
||||||
|
# Provider desconocido: respetar el id tal cual (puede traer ya su prefijo).
|
||||||
|
return model
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_effort(value) -> str | None:
|
||||||
|
v = (value or "").strip().lower()
|
||||||
|
return v if v in _VALID_EFFORTS else None
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_session_model(session) -> dict:
|
||||||
|
"""Resuelve modelo + razonamiento efectivos para la sesión.
|
||||||
|
|
||||||
|
Devuelve {"model_id": str|None, "reasoning_effort": str|None}. El effort se
|
||||||
|
toma de la MISMA fuente que el modelo (override de usuario o default global),
|
||||||
|
para que sean coherentes. model_id None = sin override (adapter usa default).
|
||||||
|
"""
|
||||||
|
none = {"model_id": None, "reasoning_effort": None}
|
||||||
|
if settings.default_model_provider != "litellm":
|
||||||
|
return none
|
||||||
|
|
||||||
|
# 1. Override por-usuario (metadata de la sesión).
|
||||||
|
meta = getattr(session, "metadata", None) or {}
|
||||||
|
provider = meta.get("ai_provider")
|
||||||
|
model = meta.get("ai_model")
|
||||||
|
if provider and model:
|
||||||
|
return {
|
||||||
|
"model_id": to_litellm_model(provider, model) or None,
|
||||||
|
"reasoning_effort": _norm_effort(meta.get("ai_reasoning_effort")),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Default global (Redis db 0, keys sin prefijo agentic).
|
||||||
|
try:
|
||||||
|
gr = _get_global_redis()
|
||||||
|
provider = await gr.get(_GLOBAL_PROVIDER_KEY)
|
||||||
|
model = await gr.get(_GLOBAL_MODEL_KEY)
|
||||||
|
effort = await gr.get(_GLOBAL_REASONING_KEY)
|
||||||
|
except Exception as e: # pragma: no cover - defensivo
|
||||||
|
logger.warning("resolve_session_model: lectura Redis falló: %s", e)
|
||||||
|
return none
|
||||||
|
|
||||||
|
if provider and model:
|
||||||
|
return {
|
||||||
|
"model_id": to_litellm_model(provider, model) or None,
|
||||||
|
"reasoning_effort": _norm_effort(effort),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. Sin configuración → sin override.
|
||||||
|
return none
|
||||||
206
src/orchestrator/plan_judge.py
Normal file
206
src/orchestrator/plan_judge.py
Normal 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
411
src/orchestrator/planner.py
Normal 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,
|
||||||
|
)
|
||||||
@@ -25,6 +25,15 @@ class AgentRegistry:
|
|||||||
self._agents: dict[str, AgentProfile] = {}
|
self._agents: dict[str, AgentProfile] = {}
|
||||||
self._metadata: dict[str, dict[str, Any]] = {}
|
self._metadata: dict[str, dict[str, Any]] = {}
|
||||||
self._agents_dir = agents_dir
|
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
|
# Carga
|
||||||
@@ -34,6 +43,7 @@ class AgentRegistry:
|
|||||||
"""Escanea agents_dir y carga todos los agentes encontrados."""
|
"""Escanea agents_dir y carga todos los agentes encontrados."""
|
||||||
self._agents.clear()
|
self._agents.clear()
|
||||||
self._metadata.clear()
|
self._metadata.clear()
|
||||||
|
self._contract = self._load_contract()
|
||||||
|
|
||||||
if not self._agents_dir.is_dir():
|
if not self._agents_dir.is_dir():
|
||||||
logger.warning("Agents directory not found: %s", self._agents_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()):
|
for agent_dir in sorted(self._agents_dir.iterdir()):
|
||||||
if not agent_dir.is_dir():
|
if not agent_dir.is_dir():
|
||||||
continue
|
continue
|
||||||
|
# Skip directorios especiales (`_shared`, etc).
|
||||||
|
if agent_dir.name.startswith("_"):
|
||||||
|
continue
|
||||||
|
|
||||||
yaml_path = agent_dir / "agent.yaml"
|
yaml_path = agent_dir / "agent.yaml"
|
||||||
prompt_path = agent_dir / "system.md"
|
prompt_path = agent_dir / "system.md"
|
||||||
@@ -60,6 +73,26 @@ class AgentRegistry:
|
|||||||
|
|
||||||
agent_id = meta.get("name", agent_dir.name)
|
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(
|
profile = AgentProfile(
|
||||||
role=agent_id,
|
role=agent_id,
|
||||||
name=agent_id,
|
name=agent_id,
|
||||||
@@ -70,6 +103,7 @@ class AgentRegistry:
|
|||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
allowed_tools=meta.get("allowed_tools", []),
|
allowed_tools=meta.get("allowed_tools", []),
|
||||||
model_id=meta.get("model_id"),
|
model_id=meta.get("model_id"),
|
||||||
|
planner_model_id=meta.get("planner_model_id"),
|
||||||
temperature=meta.get("temperature"),
|
temperature=meta.get("temperature"),
|
||||||
max_tokens=meta.get("max_tokens"),
|
max_tokens=meta.get("max_tokens"),
|
||||||
context_sections=meta.get("context_sections", [
|
context_sections=meta.get("context_sections", [
|
||||||
@@ -79,6 +113,12 @@ class AgentRegistry:
|
|||||||
"task_state",
|
"task_state",
|
||||||
]),
|
]),
|
||||||
stream_deltas=meta.get("stream_deltas", True),
|
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
|
self._agents[agent_id] = profile
|
||||||
|
|||||||
63
src/orchestrator/tool_groups.py
Normal file
63
src/orchestrator/tool_groups.py
Normal 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
|
||||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import uuid
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import Any, AsyncIterator
|
from typing import Any, AsyncIterator
|
||||||
|
|
||||||
@@ -127,14 +128,26 @@ class RedisStorage:
|
|||||||
# Execution lock (prevents concurrent messages on same session)
|
# Execution lock (prevents concurrent messages on same session)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Compare-and-delete atómico: solo borra el lock si el valor coincide con
|
||||||
|
# el token de quien lo adquirió. Evita que una ejecución cuyo lock expiró
|
||||||
|
# por TTL borre en su `finally` el lock que ya adquirió otra petición.
|
||||||
|
_UNLOCK_LUA = (
|
||||||
|
"if redis.call('get', KEYS[1]) == ARGV[1] then "
|
||||||
|
"return redis.call('del', KEYS[1]) else return 0 end"
|
||||||
|
)
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def session_lock(
|
async def session_lock(
|
||||||
self, session_id: str, timeout: int = 300
|
self, session_id: str, timeout: int | None = None
|
||||||
) -> AsyncIterator[bool]:
|
) -> AsyncIterator[bool]:
|
||||||
"""Acquire an exclusive execution lock for a session.
|
"""Acquire an exclusive execution lock for a session.
|
||||||
|
|
||||||
Uses SETNX with auto-expiry to prevent deadlocks if the process
|
Uses SETNX with auto-expiry to prevent deadlocks if the process
|
||||||
crashes mid-execution.
|
crashes mid-execution. El TTL es mayor que el timeout global de
|
||||||
|
ejecución para que el lock no expire (y otra petición lo robe)
|
||||||
|
mientras la ejecución original sigue viva. Cada adquisición guarda
|
||||||
|
un token único como valor y la liberación es compare-and-delete
|
||||||
|
(Lua), de modo que solo el dueño puede borrar el lock.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
async with storage.session_lock(session_id) as acquired:
|
async with storage.session_lock(session_id) as acquired:
|
||||||
@@ -142,10 +155,34 @@ class RedisStorage:
|
|||||||
raise HTTPException(409, "Session busy")
|
raise HTTPException(409, "Session busy")
|
||||||
# ... execute ...
|
# ... execute ...
|
||||||
"""
|
"""
|
||||||
|
if timeout is None:
|
||||||
|
timeout = int(settings.max_execution_timeout_seconds) + 60
|
||||||
key = self._key("session", session_id, "lock")
|
key = self._key("session", session_id, "lock")
|
||||||
acquired = await self.client.set(key, "1", nx=True, ex=timeout)
|
token = uuid.uuid4().hex
|
||||||
|
acquired = await self.client.set(key, token, nx=True, ex=timeout)
|
||||||
try:
|
try:
|
||||||
yield bool(acquired)
|
yield bool(acquired)
|
||||||
finally:
|
finally:
|
||||||
if acquired:
|
if acquired:
|
||||||
await self.client.delete(key)
|
released = await self.client.eval(self._UNLOCK_LUA, 1, key, token)
|
||||||
|
if not released:
|
||||||
|
# El lock expiró por TTL y/o lo posee otra petición — no
|
||||||
|
# tocamos nada, pero lo dejamos registrado.
|
||||||
|
logger.warning(
|
||||||
|
"session_lock for %s no longer owned at release "
|
||||||
|
"(expired or taken over)",
|
||||||
|
session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def clear_session_lock(self, session_id: str) -> None:
|
||||||
|
"""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)
|
||||||
|
|||||||
@@ -19,6 +19,71 @@ from .sse import EventType, SSEEmitter
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
_GENERIC_ERROR = (
|
||||||
|
"Ha ocurrido un error procesando tu mensaje. Vuelve a intentarlo en unos momentos."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Patrones que el frontend interpreta por sí mismo (login / sesión expirada).
|
||||||
|
# No los genericamos para no romper esas detecciones.
|
||||||
|
_PASSTHROUGH_PATTERNS = (
|
||||||
|
"not logged in",
|
||||||
|
"login required",
|
||||||
|
"authentication required",
|
||||||
|
"no conversation found",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def friendly_error_message(raw: str, code: str = "") -> str:
|
||||||
|
"""Traduce un error crudo (proveedor/excepción) a un mensaje genérico y
|
||||||
|
localizado para el usuario final, sin filtrar detalles internos.
|
||||||
|
|
||||||
|
Devuelve el texto original sin tocar para los casos de auth/sesión que el
|
||||||
|
frontend ya gestiona por contenido.
|
||||||
|
"""
|
||||||
|
raw = raw or ""
|
||||||
|
text = "{} {}".format(code or "", raw).lower()
|
||||||
|
|
||||||
|
# Auth / sesión: dejar pasar el texto original (lo maneja el frontend)
|
||||||
|
if any(p in text for p in _PASSTHROUGH_PATTERNS):
|
||||||
|
return raw
|
||||||
|
|
||||||
|
# Timeout de ejecución
|
||||||
|
if "timeout" in text or "timed out" in text:
|
||||||
|
return (
|
||||||
|
"La tarea tardó demasiado en completarse. Prueba a dividirla en "
|
||||||
|
"pasos más pequeños o vuelve a intentarlo."
|
||||||
|
)
|
||||||
|
# Saldo insuficiente / facturación del proveedor (402)
|
||||||
|
if (
|
||||||
|
"402" in text
|
||||||
|
or "insufficient balance" in text
|
||||||
|
or "insufficient_quota" in text
|
||||||
|
or "billing" in text
|
||||||
|
):
|
||||||
|
return (
|
||||||
|
"El asistente no está disponible en este momento. Inténtalo de "
|
||||||
|
"nuevo en unos minutos."
|
||||||
|
)
|
||||||
|
# Credenciales del proveedor inválidas (401)
|
||||||
|
if (
|
||||||
|
"401" in text
|
||||||
|
or "invalid_api_key" in text
|
||||||
|
or "incorrect api key" in text
|
||||||
|
or "invalid api key" in text
|
||||||
|
):
|
||||||
|
return (
|
||||||
|
"El asistente no está disponible temporalmente por un problema de "
|
||||||
|
"configuración. Estamos trabajando en ello."
|
||||||
|
)
|
||||||
|
# Límite de peticiones (429)
|
||||||
|
if "429" in text or "rate limit" in text or "rate_limit" in text:
|
||||||
|
return (
|
||||||
|
"Hay mucha demanda en este momento. Espera unos segundos y vuelve "
|
||||||
|
"a intentarlo."
|
||||||
|
)
|
||||||
|
return _GENERIC_ERROR
|
||||||
|
|
||||||
|
|
||||||
class ClaudeFormatEmitter:
|
class ClaudeFormatEmitter:
|
||||||
"""Emits events in Claude Code CLI SSE format.
|
"""Emits events in Claude Code CLI SSE format.
|
||||||
|
|
||||||
@@ -35,6 +100,8 @@ class ClaudeFormatEmitter:
|
|||||||
self._tool_block_index: dict[str, dict[str, int]] = {} # session -> {tool_call_id -> index}
|
self._tool_block_index: dict[str, dict[str, int]] = {} # session -> {tool_call_id -> index}
|
||||||
self._content_blocks: dict[str, list[dict[str, Any]]] = {}
|
self._content_blocks: dict[str, list[dict[str, Any]]] = {}
|
||||||
self._text_accumulator: dict[str, str] = {}
|
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:
|
def _next_index(self, session_id: str) -> int:
|
||||||
idx = self._block_counter.get(session_id, 0)
|
idx = self._block_counter.get(session_id, 0)
|
||||||
@@ -48,6 +115,8 @@ class ClaudeFormatEmitter:
|
|||||||
self._tool_block_index[session_id] = {}
|
self._tool_block_index[session_id] = {}
|
||||||
self._content_blocks[session_id] = []
|
self._content_blocks[session_id] = []
|
||||||
self._text_accumulator[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:
|
def _push(self, session_id: str, payload: dict[str, Any]) -> None:
|
||||||
"""Push a formatted line to all subscribers of a session."""
|
"""Push a formatted line to all subscribers of a session."""
|
||||||
@@ -119,7 +188,43 @@ class ClaudeFormatEmitter:
|
|||||||
tool_args = data.get("tool_arguments", "")
|
tool_args = data.get("tool_arguments", "")
|
||||||
tool_call_id = data.get("tool_call_id", "")
|
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:
|
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
|
# Text streaming
|
||||||
if not self._text_block_open.get(session_id):
|
if not self._text_block_open.get(session_id):
|
||||||
self._open_text_block(session_id)
|
self._open_text_block(session_id)
|
||||||
@@ -152,6 +257,15 @@ class ClaudeFormatEmitter:
|
|||||||
tool_name = data.get("tool", "unknown")
|
tool_name = data.get("tool", "unknown")
|
||||||
tool_call_id = data.get("tool_call_id", "")
|
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
|
# Close open text block
|
||||||
self._close_text_block(session_id)
|
self._close_text_block(session_id)
|
||||||
|
|
||||||
@@ -207,6 +321,29 @@ class ClaudeFormatEmitter:
|
|||||||
# Emit assistant snapshot for reconciliation
|
# Emit assistant snapshot for reconciliation
|
||||||
self._push(session_id, self._build_assistant_snapshot(session_id))
|
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:
|
elif event_type == EventType.EXECUTION_COMPLETED:
|
||||||
# Close any open text block
|
# Close any open text block
|
||||||
self._close_text_block(session_id)
|
self._close_text_block(session_id)
|
||||||
@@ -226,13 +363,18 @@ class ClaudeFormatEmitter:
|
|||||||
"cache_creation_input_tokens": 0,
|
"cache_creation_input_tokens": 0,
|
||||||
},
|
},
|
||||||
"total_cost_usd": data.get("total_cost_usd", 0),
|
"total_cost_usd": data.get("total_cost_usd", 0),
|
||||||
|
# Modelo usado → acai-app lo registra en consumo_acaicode.
|
||||||
|
"modelUsage": data.get("modelUsage", {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
# Done
|
# Done
|
||||||
self._push(session_id, {"type": "done"})
|
self._push(session_id, {"type": "done"})
|
||||||
|
|
||||||
elif event_type == EventType.ERROR:
|
elif event_type == EventType.ERROR:
|
||||||
error_msg = data.get("message", str(data.get("error", "Unknown error")))
|
raw_msg = data.get("message", str(data.get("error", "Unknown error")))
|
||||||
|
user_msg = friendly_error_message(raw_msg, str(data.get("error", "")))
|
||||||
|
# El error real (detalles del proveedor) solo va al log, nunca al cliente.
|
||||||
|
logger.warning("Session %s error (raw): %s", session_id, raw_msg)
|
||||||
|
|
||||||
# Close any open block
|
# Close any open block
|
||||||
self._close_text_block(session_id)
|
self._close_text_block(session_id)
|
||||||
@@ -240,7 +382,7 @@ class ClaudeFormatEmitter:
|
|||||||
self._push(session_id, {
|
self._push(session_id, {
|
||||||
"type": "result",
|
"type": "result",
|
||||||
"is_error": True,
|
"is_error": True,
|
||||||
"result": error_msg,
|
"result": user_msg,
|
||||||
"usage": {"input_tokens": 0, "output_tokens": 0, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0},
|
"usage": {"input_tokens": 0, "output_tokens": 0, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0},
|
||||||
"total_cost_usd": 0,
|
"total_cost_usd": 0,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ class EventType(StrEnum):
|
|||||||
TOOL_COMPLETED = "tool.completed"
|
TOOL_COMPLETED = "tool.completed"
|
||||||
SUBAGENT_ASSIGNED = "subagent.assigned"
|
SUBAGENT_ASSIGNED = "subagent.assigned"
|
||||||
EXECUTION_COMPLETED = "execution.completed"
|
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"
|
ERROR = "error"
|
||||||
KEEPALIVE = "keepalive"
|
KEEPALIVE = "keepalive"
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,128 @@ class TestSettingsBudget:
|
|||||||
assert cfg.effective_context_budget == 172_000
|
assert cfg.effective_context_budget == 172_000
|
||||||
assert cfg.effective_compaction_threshold == 137_600
|
assert cfg.effective_compaction_threshold == 137_600
|
||||||
|
|
||||||
|
def test_budget_for_window_small_and_large(self):
|
||||||
|
cfg = Settings(
|
||||||
|
context_max_tokens=0,
|
||||||
|
model_max_output_tokens=4_096,
|
||||||
|
context_reserve_ratio=0.10,
|
||||||
|
_env_file=None,
|
||||||
|
)
|
||||||
|
# 32k: window - max_output - 10% reserve
|
||||||
|
assert cfg.budget_for_window(32_000) == 32_000 - 4_096 - 3_200
|
||||||
|
# 1M: budget mucho mayor (no compacta innecesariamente)
|
||||||
|
assert cfg.budget_for_window(1_000_000) == 1_000_000 - 4_096 - 100_000
|
||||||
|
# ventana inválida → fallback al budget estático
|
||||||
|
assert cfg.budget_for_window(0) == cfg.effective_context_budget
|
||||||
|
|
||||||
|
def test_compaction_threshold_for_uses_ratio(self):
|
||||||
|
cfg = Settings(
|
||||||
|
compaction_threshold_tokens=0,
|
||||||
|
compaction_threshold_ratio=0.80,
|
||||||
|
_env_file=None,
|
||||||
|
)
|
||||||
|
assert cfg.compaction_threshold_for(100_000) == 80_000
|
||||||
|
|
||||||
|
|
||||||
|
class TestContextWindowResolution:
|
||||||
|
def test_resolve_window_from_catalog(self, monkeypatch):
|
||||||
|
import json
|
||||||
|
from src.orchestrator import cost
|
||||||
|
|
||||||
|
cost._window_cache.clear()
|
||||||
|
|
||||||
|
class _FakeRedis:
|
||||||
|
async def get(self, key):
|
||||||
|
return json.dumps([
|
||||||
|
{"id": "kimi-k2.7-code", "context_length": 256_000},
|
||||||
|
{"id": "otro", "context_length": 32_000},
|
||||||
|
])
|
||||||
|
|
||||||
|
monkeypatch.setattr(cost, "_get_cfg_redis", lambda: _FakeRedis())
|
||||||
|
w = asyncio.run(cost.resolve_context_window("openrouter/kimi-k2.7-code"))
|
||||||
|
assert w == 256_000
|
||||||
|
# segunda llamada usa cache (no peta aunque cambie el fake)
|
||||||
|
assert asyncio.run(cost.resolve_context_window("openrouter/kimi-k2.7-code")) == 256_000
|
||||||
|
|
||||||
|
def test_resolve_window_miss_is_none_or_int(self, monkeypatch):
|
||||||
|
from src.orchestrator import cost
|
||||||
|
|
||||||
|
cost._window_cache.clear()
|
||||||
|
|
||||||
|
class _FakeRedis:
|
||||||
|
async def get(self, key):
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(cost, "_get_cfg_redis", lambda: _FakeRedis())
|
||||||
|
w = asyncio.run(cost.resolve_context_window("openrouter/modelo-inexistente-xyz"))
|
||||||
|
assert w is None or isinstance(w, int)
|
||||||
|
|
||||||
|
def test_resolve_window_ignores_non_litellm_ids(self):
|
||||||
|
from src.orchestrator import cost
|
||||||
|
|
||||||
|
cost._window_cache.clear()
|
||||||
|
assert asyncio.run(cost.resolve_context_window("sin-prefijo")) is None
|
||||||
|
assert asyncio.run(cost.resolve_context_window(None)) is None
|
||||||
|
|
||||||
|
def test_resolve_window_self_heals_when_catalog_missing(self, monkeypatch):
|
||||||
|
"""Si el catálogo OpenRouter caducó, se repuebla en runtime (self-heal)."""
|
||||||
|
from src.orchestrator import cost
|
||||||
|
|
||||||
|
cost._window_cache.clear()
|
||||||
|
cost._or_last_refresh[0] = 0.0 # desactivar cooldown para el test
|
||||||
|
store = {}
|
||||||
|
|
||||||
|
class _FakeRedis:
|
||||||
|
async def get(self, key):
|
||||||
|
return store.get(key)
|
||||||
|
|
||||||
|
async def set(self, key, val, ex=None):
|
||||||
|
store[key] = val
|
||||||
|
|
||||||
|
monkeypatch.setattr(cost, "_get_cfg_redis", lambda: _FakeRedis())
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cost, "_fetch_openrouter_catalog_sync",
|
||||||
|
lambda: [{"id": "moonshotai/kimi-x", "context_length": 262_144,
|
||||||
|
"price_in_1m": 0.6, "price_out_1m": 3.0}],
|
||||||
|
)
|
||||||
|
|
||||||
|
w = asyncio.run(cost.resolve_context_window("openrouter/moonshotai/kimi-x"))
|
||||||
|
assert w == 262_144
|
||||||
|
# quedó repoblado en el cache para futuras lecturas
|
||||||
|
assert "acai:config:ai:models_cache:openrouter" in store
|
||||||
|
|
||||||
|
|
||||||
|
class TestModelAwareBudget:
|
||||||
|
def test_build_context_uses_model_window_budget(self, monkeypatch):
|
||||||
|
from src.orchestrator import cost
|
||||||
|
|
||||||
|
async def _fake_window(model_id):
|
||||||
|
return 40_000
|
||||||
|
|
||||||
|
monkeypatch.setattr(cost, "resolve_context_window", _fake_window)
|
||||||
|
session = SessionState(immutable_rules=["No romper"])
|
||||||
|
session.begin_task("hola")
|
||||||
|
agent = AgentProfile(role="acai", name="Acai", system_prompt="Haz el trabajo.")
|
||||||
|
|
||||||
|
pkg = asyncio.run(
|
||||||
|
ContextEngine().build_context(
|
||||||
|
session=session, agent=agent, model_id="openrouter/m"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert pkg.budget_tokens == settings.budget_for_window(40_000)
|
||||||
|
|
||||||
|
def test_budget_override_wins(self):
|
||||||
|
session = SessionState(immutable_rules=["No romper"])
|
||||||
|
session.begin_task("hola")
|
||||||
|
agent = AgentProfile(role="acai", name="Acai", system_prompt="Haz el trabajo.")
|
||||||
|
|
||||||
|
pkg = asyncio.run(
|
||||||
|
ContextEngine().build_context(
|
||||||
|
session=session, agent=agent, budget_override=12_345
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert pkg.budget_tokens == 12_345
|
||||||
|
|
||||||
|
|
||||||
class TestContextEngine:
|
class TestContextEngine:
|
||||||
def test_build_context_keeps_task_history_and_current_task(self):
|
def test_build_context_keeps_task_history_and_current_task(self):
|
||||||
@@ -294,11 +416,27 @@ class TestTaskHistoryTrim:
|
|||||||
class TestConversationCompaction:
|
class TestConversationCompaction:
|
||||||
def test_compactor_preserves_last_user_and_compacts_old_tool_results(self):
|
def test_compactor_preserves_last_user_and_compacts_old_tool_results(self):
|
||||||
compactor = ContextCompactor(max_tokens=999999)
|
compactor = ContextCompactor(max_tokens=999999)
|
||||||
|
# Los assistants llevan sus tool_calls: sin ellos los `role: tool`
|
||||||
|
# serian huerfanos y `_enforce_tool_pairing` los convertiria a user.
|
||||||
messages = [
|
messages = [
|
||||||
{"role": "user", "content": "Contexto anterior " * 10},
|
{"role": "user", "content": "Contexto anterior " * 10},
|
||||||
{"role": "assistant", "content": "Voy a revisar el modulo ahora mismo. " * 6},
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Voy a revisar el modulo ahora mismo. " * 6,
|
||||||
|
"tool_calls": [
|
||||||
|
{"id": "tool-1", "type": "function",
|
||||||
|
"function": {"name": "t", "arguments": "{}"}},
|
||||||
|
],
|
||||||
|
},
|
||||||
{"role": "tool", "tool_call_id": "tool-1", "content": "resultado antiguo\n" * 80},
|
{"role": "tool", "tool_call_id": "tool-1", "content": "resultado antiguo\n" * 80},
|
||||||
{"role": "assistant", "content": "He visto el resultado anterior. " * 6},
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "He visto el resultado anterior. " * 6,
|
||||||
|
"tool_calls": [
|
||||||
|
{"id": "tool-2", "type": "function",
|
||||||
|
"function": {"name": "t", "arguments": "{}"}},
|
||||||
|
],
|
||||||
|
},
|
||||||
{"role": "tool", "tool_call_id": "tool-2", "content": "resultado reciente\n" * 80},
|
{"role": "tool", "tool_call_id": "tool-2", "content": "resultado reciente\n" * 80},
|
||||||
{"role": "user", "content": "Este es el ultimo mensaje del usuario y debe quedar intacto."},
|
{"role": "user", "content": "Este es el ultimo mensaje del usuario y debe quedar intacto."},
|
||||||
]
|
]
|
||||||
@@ -358,9 +496,18 @@ class TestConversationCompaction:
|
|||||||
|
|
||||||
def test_compactor_only_touches_user_messages_as_last_resort(self):
|
def test_compactor_only_touches_user_messages_as_last_resort(self):
|
||||||
compactor = ContextCompactor(max_tokens=999999)
|
compactor = ContextCompactor(max_tokens=999999)
|
||||||
|
# tool_calls en el assistant para que el `role: tool` no sea huerfano
|
||||||
|
# (el invariante `_enforce_tool_pairing` convertiria un huerfano a user).
|
||||||
messages = [
|
messages = [
|
||||||
{"role": "user", "content": "Contexto previo del usuario " * 8},
|
{"role": "user", "content": "Contexto previo del usuario " * 8},
|
||||||
{"role": "assistant", "content": "Respuesta previa del asistente " * 6},
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Respuesta previa del asistente " * 6,
|
||||||
|
"tool_calls": [
|
||||||
|
{"id": "tool-1", "type": "function",
|
||||||
|
"function": {"name": "t", "arguments": "{}"}},
|
||||||
|
],
|
||||||
|
},
|
||||||
{"role": "tool", "tool_call_id": "tool-1", "content": "resultado viejo\n" * 80},
|
{"role": "tool", "tool_call_id": "tool-1", "content": "resultado viejo\n" * 80},
|
||||||
{"role": "user", "content": "Ultimo mensaje del usuario"},
|
{"role": "user", "content": "Ultimo mensaje del usuario"},
|
||||||
]
|
]
|
||||||
|
|||||||
110
tests/test_context_real_session.py
Normal file
110
tests/test_context_real_session.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
"""Test de integración contra sesiones REALES de Redis (db 1).
|
||||||
|
|
||||||
|
Valida el budget por-ventana y la compactación sobre las conversaciones reales
|
||||||
|
del agentic (las que los usuarios mantienen abiertas), no sobre fixtures
|
||||||
|
sintéticos. Es OPT-IN: se salta si no hay Redis disponible o no hay sesiones,
|
||||||
|
para no acoplar la suite a datos de cliente ni romper en CI.
|
||||||
|
|
||||||
|
Ejecutar contra el Redis real:
|
||||||
|
docker run --rm --network acai-net \\
|
||||||
|
-v "$PWD/agenticSystem/src:/app/src" -v "$PWD/agenticSystem/tests:/app/tests" \\
|
||||||
|
-e AGENTIC_REDIS_HOST=redis -w /app acai-vscode-plugin-agentic \\
|
||||||
|
sh -lc "pip install -q pytest pytest-asyncio; python -m pytest tests/test_context_real_session.py -q"
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import enum
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
if not hasattr(enum, "StrEnum"):
|
||||||
|
class _CompatStrEnum(str, enum.Enum):
|
||||||
|
pass
|
||||||
|
|
||||||
|
enum.StrEnum = _CompatStrEnum
|
||||||
|
|
||||||
|
for _name, _attr in (("anthropic", "AsyncAnthropic"), ("openai", "AsyncOpenAI")):
|
||||||
|
if _name not in sys.modules:
|
||||||
|
_stub = types.ModuleType(_name)
|
||||||
|
setattr(_stub, _attr, type("_Stub", (), {}))
|
||||||
|
sys.modules[_name] = _stub
|
||||||
|
|
||||||
|
from src.config import settings
|
||||||
|
from src.context.compactor import estimate_tokens
|
||||||
|
from src.context.engine import ContextEngine
|
||||||
|
from src.models.agent import AgentProfile
|
||||||
|
from src.models.session import SessionState
|
||||||
|
|
||||||
|
|
||||||
|
def _load_largest_real_session():
|
||||||
|
"""Mayor sesión real de Redis db 1, o None si no hay acceso/sesiones."""
|
||||||
|
try:
|
||||||
|
import redis
|
||||||
|
|
||||||
|
r = redis.Redis(
|
||||||
|
host=settings.redis_host,
|
||||||
|
port=settings.redis_port,
|
||||||
|
db=1,
|
||||||
|
password=settings.redis_password or None,
|
||||||
|
decode_responses=True,
|
||||||
|
socket_connect_timeout=2,
|
||||||
|
)
|
||||||
|
keys = [
|
||||||
|
k for k in r.scan_iter("agentic:session:*")
|
||||||
|
if not k.endswith((":events", ":artifacts"))
|
||||||
|
]
|
||||||
|
if not keys:
|
||||||
|
return None
|
||||||
|
biggest = max(keys, key=lambda k: r.strlen(k))
|
||||||
|
raw = r.get(biggest)
|
||||||
|
return json.loads(raw) if raw else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_session_compacts_under_model_window(monkeypatch):
|
||||||
|
data = _load_largest_real_session()
|
||||||
|
if not data or not data.get("recent_messages"):
|
||||||
|
pytest.skip("sin Redis/sesiones reales disponibles")
|
||||||
|
|
||||||
|
rm = data["recent_messages"]
|
||||||
|
raw_tokens = sum(estimate_tokens(json.dumps(m)) for m in rm)
|
||||||
|
|
||||||
|
from src.orchestrator import cost
|
||||||
|
|
||||||
|
async def _fake_window(model_id):
|
||||||
|
return 32_000
|
||||||
|
|
||||||
|
monkeypatch.setattr(cost, "resolve_context_window", _fake_window)
|
||||||
|
|
||||||
|
session = SessionState(
|
||||||
|
immutable_rules=data.get("immutable_rules") or ["No romper"],
|
||||||
|
project_profile=data.get("project_profile") or {},
|
||||||
|
task_history=data.get("task_history") or [],
|
||||||
|
recent_messages=rm,
|
||||||
|
)
|
||||||
|
session.begin_task("Sigamos con lo anterior")
|
||||||
|
agent = AgentProfile(
|
||||||
|
role="acai",
|
||||||
|
name="Acai",
|
||||||
|
system_prompt="Haz el trabajo.",
|
||||||
|
context_sections=["immutable_rules", "task_state"],
|
||||||
|
)
|
||||||
|
|
||||||
|
pkg = asyncio.run(
|
||||||
|
ContextEngine().build_context(
|
||||||
|
session=session, agent=agent, conversation=rm, model_id="openrouter/x"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Budget derivado de la ventana REAL del modelo (32k), no del fijo de 120k/200k.
|
||||||
|
assert pkg.budget_tokens == settings.budget_for_window(32_000)
|
||||||
|
# La sesión real se compactó de verdad (no se reenvía cruda).
|
||||||
|
assert pkg.total_token_estimate < raw_tokens
|
||||||
|
# Y el resultado cabe en el budget del modelo → no habría overflow.
|
||||||
|
assert pkg.total_token_estimate <= pkg.budget_tokens
|
||||||
93
tests/test_overflow_recovery.py
Normal file
93
tests/test_overflow_recovery.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""Tests de recuperación ante overflow de ventana de contexto.
|
||||||
|
|
||||||
|
Cubre: detección del error de context-length del proveedor, y el envoltorio del
|
||||||
|
adapter que lo traduce a `ContextOverflowError` (dominio) tanto si salta al
|
||||||
|
iniciar el stream como durante la iteración.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import enum
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
if not hasattr(enum, "StrEnum"):
|
||||||
|
class _CompatStrEnum(str, enum.Enum):
|
||||||
|
pass
|
||||||
|
|
||||||
|
enum.StrEnum = _CompatStrEnum
|
||||||
|
|
||||||
|
if "anthropic" not in sys.modules:
|
||||||
|
anthropic_stub = types.ModuleType("anthropic")
|
||||||
|
anthropic_stub.AsyncAnthropic = type("_AsyncAnthropic", (), {})
|
||||||
|
sys.modules["anthropic"] = anthropic_stub
|
||||||
|
|
||||||
|
if "openai" not in sys.modules:
|
||||||
|
openai_stub = types.ModuleType("openai")
|
||||||
|
openai_stub.AsyncOpenAI = type("_AsyncOpenAI", (), {})
|
||||||
|
sys.modules["openai"] = openai_stub
|
||||||
|
|
||||||
|
from src.adapters.base import ContextOverflowError
|
||||||
|
from src.adapters.openai_adapter import OpenAIAdapter, _is_context_overflow
|
||||||
|
|
||||||
|
|
||||||
|
class TestOverflowDetection:
|
||||||
|
def test_detects_by_message(self):
|
||||||
|
assert _is_context_overflow(
|
||||||
|
Exception("This model's maximum context length is 8192 tokens, however you requested 9000")
|
||||||
|
)
|
||||||
|
assert _is_context_overflow(Exception("context_length_exceeded"))
|
||||||
|
assert _is_context_overflow(Exception("Please reduce the length of the messages"))
|
||||||
|
|
||||||
|
def test_does_not_flag_unrelated_errors(self):
|
||||||
|
assert not _is_context_overflow(Exception("rate limit exceeded"))
|
||||||
|
assert not _is_context_overflow(Exception("invalid api key"))
|
||||||
|
|
||||||
|
def test_detects_by_type_name(self):
|
||||||
|
class ContextWindowExceededError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert _is_context_overflow(ContextWindowExceededError("boom"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamWrapperMapsOverflow:
|
||||||
|
def _make_adapter(self):
|
||||||
|
# Saltamos __init__ (no necesitamos el cliente AsyncOpenAI: parcheamos
|
||||||
|
# _stream_impl). Así el test no depende del stub de openai.
|
||||||
|
return OpenAIAdapter.__new__(OpenAIAdapter)
|
||||||
|
|
||||||
|
def test_overflow_at_stream_init_becomes_domain_error(self, monkeypatch):
|
||||||
|
adapter = self._make_adapter()
|
||||||
|
|
||||||
|
async def _impl(messages, tools=None, config=None):
|
||||||
|
raise RuntimeError("maximum context length is 32768 tokens")
|
||||||
|
yield # noqa: hace de esto un async generator
|
||||||
|
|
||||||
|
monkeypatch.setattr(adapter, "_stream_impl", _impl)
|
||||||
|
|
||||||
|
async def _run():
|
||||||
|
async for _ in adapter.stream([{"role": "user", "content": "hola"}]):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with pytest.raises(ContextOverflowError):
|
||||||
|
asyncio.run(_run())
|
||||||
|
|
||||||
|
def test_non_overflow_error_propagates_unchanged(self, monkeypatch):
|
||||||
|
adapter = self._make_adapter()
|
||||||
|
|
||||||
|
async def _impl(messages, tools=None, config=None):
|
||||||
|
raise RuntimeError("connection reset by peer")
|
||||||
|
yield
|
||||||
|
|
||||||
|
monkeypatch.setattr(adapter, "_stream_impl", _impl)
|
||||||
|
|
||||||
|
async def _run():
|
||||||
|
async for _ in adapter.stream([{"role": "user", "content": "hola"}]):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError) as exc:
|
||||||
|
asyncio.run(_run())
|
||||||
|
assert not isinstance(exc.value, ContextOverflowError)
|
||||||
585
tests/test_tool_pairing_real.py
Normal file
585
tests/test_tool_pairing_real.py
Normal 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)
|
||||||
Reference in New Issue
Block a user