This commit is contained in:
Jordan Diaz
2026-05-08 21:31:28 +00:00
parent 0dabba5442
commit 44cb956f95
37 changed files with 2120 additions and 251 deletions

View File

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

View File

@@ -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: top_n
kb_max_tokens: 4000
kb_top_n: 2
has_planner_tool: true

View File

@@ -1,165 +1,88 @@
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) # 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.
# 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 # Patrones canónicos (aplica por defecto)
| Intención | Secuencia canónica | - **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.
| **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` | - **Tabla "publicable"** (noticias, vacantes, blog): `fecha_publicacion`, `fecha_expiracion?`, `visible` (checkbox).
| **Editar módulo** | `get_module_config_vars``acai-view``acai-line-replace``set_module_config_vars` si cambian valores | - **Form embebido**: `<form_postular :vacante_num="thisrecord.num"></form_postular>`.
| **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 # Mapa de docs (pide con `read_doc` lo que necesites)
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? })`. `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?})`.
| Doc | Cubre | # Estilo
|-----|-------|
| `01-builder-fields` | Campos editables (`data-field-type`), atributos Acai (`c-if`, `c-for`, `c-class`), `<set>`, `c-form`, componentes built-in |
| `02-twig` | Filtros Twig (`get`, `queryDB`, `hook`, `module`, `imagec`, `translate`, `raw`...), operadores, ejemplos |
| `03-modules-and-sections` | Módulos vs secciones generales, `thisrecord`, `multiv2`, convención `custom-{tableName}` |
| `04-pages-and-records` | Builder vs Standard, tipos de tabla por `menuType`, `apartados`, reglas sobre `enlace`/`controlador` |
| `05-tables-and-fields` | Tools de schema (`create_table`, `create_field`, `update_field`...), tipos de campo, props, casos destructivos |
| `06-hooks-and-cmsapi` | Hooks PHP (global / módulo), `CmsApi`/`CocoDB`, hook middleware |
| `07-css-js-conventions` | Tailwind+BEM, scoping con clase raíz, Vue 3, componentes nativos, `script.js`/`style.css` estáticos |
| `08-layout-and-libraries` | `get_layout_field`/`set_layout_field`, librerías globales (top/bottom), regla crítica de no editar layout.json |
| `09-mcp-tools-reference` | Inventario completo de tools + workflows canónicos paso a paso |
| `10-production-patterns` | Patrones reales reutilizables (cabecera, zigzag, FAQ, formulario, detalle, gallery) |
| `11-quick-reference` | Cheat sheet con todas las reglas, tipos, filtros, formatos |
Si vas a crear o editar algo y no recuerdas exactamente cómo, **prefiere leer la doc** (`read_doc`) antes que adivinar. 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.
# Patrones de diseño canónicos
Aplica estos patrones **por defecto** sin preguntar; desvíate solo si el usuario lo pide explícitamente.
## Detalle de registros — Sección General `custom-{tableName}`
Toda tabla con campo `enlace` (vacantes, productos, noticias, servicios) tiene automáticamente una sección general que el CMS renderiza al acceder a la URL de cualquier registro. El módulo se llama **literalmente** `custom-{tableName}` (ej. `custom-vacantes`).
Flujo correcto:
1. `create_table` con `enlace=true`
2. `create_field` para cada campo
3. `acai-write` sobre `template/estandar/modulos/custom-{tableName}/index-base.tpl` con `thisrecord.*`
4. (Opcional) Módulo de listado `{tableName}_listado_xxxxxx`
5. (Opcional) Página índice `/{tableName}/` en `apartados` (Builder) con el listado dentro
Reglas duras:
- NO crees una página por registro en `apartados`.
- NO uses `_detailPage` (no existe).
- NO construyas URLs con query params (`?id=5`).
- NO uses hooks para cargar el registro — `thisrecord` ya está disponible.
- El nombre del módulo **debe** ser `custom-{tableName}` exacto.
## Formularios — `c-form`
Para contacto, postulación, cualquier form estándar: usa `c-form` (inserta en BD + envía email automáticamente). NO construyas POST/hook custom si `c-form` cubre el caso. Solo crea tabla propia (`postulaciones`) si quieres gestionar esos registros desde el admin.
## Campos típicos de tablas "publicables"
Cuando creas una tabla con `enlace` (noticias, vacantes, blog), añade por defecto:
- `fecha_publicacion` (date) — ordenar y filtrar
- `fecha_expiracion` (date, opcional) — ocultar registro al caducar
- `visible` (checkbox) — control manual
NO añadas un campo "estado" calculado si ya tienes `visible` + fechas.
## Schema (.ini.php) — NUNCA editar a mano
Los `cms/data/schema/*.ini.php` se modifican **exclusivamente** con las tools de schema: `create_table`, `update_table_metadata`, `delete_table`, `reorder_tables`, `create_field`, `update_field`, `delete_field`, `reorder_fields`. NO uses `acai-write` ni `acai-line-replace` sobre estos archivos:
- Saltarías validaciones (regex, tipos, etc.)
- No invalidas la cache de schemas — el frontend ve schema viejo
- No sincronizas con MySQL (no crea/borra columnas reales)
- Puedes romper el formato INI con un escape mal puesto
Para subcampos de un `multitext`, llama a `update_field` con `props.descriptionjson` como **string JSON** del array `[{id_campo, nombre_campo, tipo}, ...]`. La tool docu lo explica.
## Formularios embebidos en detalles
Si un detalle necesita un formulario (postular, pedir info), embebe el módulo del formulario **dentro** de la sección general pasándole el `num`:
```html
<form_postular :vacante_num="thisrecord.num"></form_postular>
```
NO pongas el formulario como sección suelta del listado.
# Acai Core (web-base)
El workspace del proyecto contiene solo la **capa de personalización** (módulos, hooks, schemas, uploads). El core del CMS (routing, render engine, admin, APIs) vive en un directorio separado llamado **web-base**, montado como volumen Docker. NO modifiques archivos de `web-base` — son compartidos entre proyectos.
# Comportamiento esperado
- Comunicación clara, breve y en **español**.
- Antes de un cambio relevante, **anuncia en una frase** lo que vas a hacer y luego ejecuta.
- Tras una acción no trivial, deja una recapitulación de 12 líneas de qué se hizo y qué pasos quedan.
- Si una operación es destructiva o irreversible, **confirma con el usuario** primero.
- Si te falta un dato concreto (qué tabla, qué módulo, qué página), pregúntalo. NO adivines.
- Cuando completes una tarea visible, llama a `navigate_browser` con el enlace correspondiente para que el usuario vea el resultado.

View File

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

View File

@@ -13,3 +13,7 @@ context_sections:
allowed_tools: [] 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,10 @@
---
title: "Campos editables del builder"
tags: [builder, twig, html, modules]
load_priority: 80
load_when: [always]
summary: "Atributos data-field-* (textfield, headfield, link, upload, list, multiv2, checkbox), c-if/c-for/c-class, c-form, componentes built-in del builder Acai."
---
# Builder Fields — Campos editables del index-base.tpl # 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`.

View File

@@ -1,3 +1,10 @@
---
title: "Filtros Twig personalizados de Acai"
tags: [twig, filters, frontend]
load_priority: 70
load_when: [always]
summary: "Filtros Twig: get, hook, queryDB, translate, imagec, módulo, set; concatenación con ~; if con ==; reglas de uso vs c-if del builder."
---
# Twig — Filtros personalizados de Acai # 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 %}` (==).

View File

@@ -1,3 +1,10 @@
---
title: "Módulos y Secciones Generales"
tags: [modules, sections, structure, twig, html]
load_priority: 75
load_when: [always]
summary: "Módulos (carpetas en template/estandar/modulos), index-base.tpl, secciones generales (custom-{tableName}/), thisrecord, gestión de uploads de un registro."
---
# Módulos y Secciones Generales # 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/`.

View File

@@ -1,3 +1,10 @@
---
title: "Páginas y Registros"
tags: [pages, apartados, records, structure]
load_priority: 70
load_when: [always]
summary: "Páginas Builder vs Standard, controlador, enlace, registros con num/PK, builder_custom, workflows de creación y edición."
---
# Páginas y Registros # 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`.

View File

@@ -1,3 +1,10 @@
---
title: "Schema management — tablas y campos"
tags: [tables, schema, fields, db]
load_priority: 80
load_when: [always]
summary: "create_table/update_table_metadata/delete_table/reorder_tables, create_field/update_field/delete_field/reorder_fields, regenerate_enlaces, tipos de campo."
---
# Tablas y Campos # 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/`.

View File

@@ -1,3 +1,10 @@
---
title: "Hooks PHP y CmsApi"
tags: [php, hooks, cmsapi, backend]
load_priority: 70
load_when: [always]
summary: "Hooks globales (hooks/hooks.X.php) y de módulo (hook.php), CmsApi::get/insert/update/delete con uploads/relations/translates, set_hook_middleware, auto-registro en layout.json."
---
# Hooks y CmsApi (server-side) # 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.

View File

@@ -1,3 +1,10 @@
---
title: "CSS y JavaScript — Convenciones"
tags: [css, js, frontend, conventions]
load_priority: 65
load_when: [always]
summary: "Tailwind primary, BEM scoped, data-* para pasar valores dinámicos a script.js (que es estático), CmsApi.hook desde JS, native components, Vue 3 builder."
---
# CSS y JavaScript — Convenciones del Módulo # 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`.

View File

@@ -1,3 +1,10 @@
---
title: "Layout global y librerías"
tags: [layout, header, footer, libraries]
load_priority: 60
load_when: [always]
summary: "header/footer/javascript/style en layout.json via get_layout_field/set_layout_field, librerías globales (CDN, npm), modos top/bottom, librerías AMP."
---
# Layout Global y Librerías Globales # 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.).

View File

@@ -1,3 +1,10 @@
---
title: "Referencia maestra de tools MCP"
tags: [tools, reference, workflows]
load_priority: 50
load_when: [ranked]
summary: "Inventario completo de tools por categoría (archivos, módulos, registros, tablas, layout, libs, hooks, media, navegación, proyecto, git, auth, docs) y workflows canónicos."
---
# MCP Tools — Referencia Completa # 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 |

View File

@@ -1,3 +1,10 @@
---
title: "Patrones de producción"
tags: [patterns, snippets, examples]
load_priority: 55
load_when: [always]
summary: "Snippets reales: header con menú, FAQ acordeón, zigzag de servicios, formularios con c-form, listados con filtros, structured data, breadcrumbs."
---
# Patrones de Producción # 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
View File

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

View File

@@ -1,6 +1,13 @@
# Quick Reference — Cheat sheet ---
title: "Reglas inmutables y cheat-sheet de tipos"
tags: [reference, rules, cheat]
load_priority: 90
load_when: [cheatsheet]
summary: "Reglas no negociables (cms_, num, _num, upload arrays, c-if/{% if %}), tipos de builder field, atributos Acai, filtros Twig, formato de datos para insert/update, errores comunes."
---
# Reglas inmutables y cheat-sheet
Este documento es un **resumen ejecutable** de las reglas críticas, los tipos de campo, los filtros Twig, los formatos de datos para insert/update y las variables globales. Es la **fuente única de verdad** para resolver dudas rápidas sin tener que abrir los docs largos. Léelo antes de cualquier operación cuando quieras refrescar las reglas; el resto de docs (`01``10`) profundizan en cada tema. Resumen ejecutable de reglas críticas, tipos de campo, filtros y formatos de datos. Si tienes duda rápida, consulta esto antes de los docs largos.
## Reglas inmutables ## 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
View File

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

View File

@@ -586,6 +586,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

View File

@@ -46,6 +46,9 @@ class SendMessageRequest(BaseModel):
message: str message: str
stream: bool = False stream: bool = False
agent_id: str | None = None agent_id: str | None = None
# 'auto' = el agente decide (heuristica trivial-vs-complex). 'force' = forzar
# acai_plan antes de cualquier ejecucion. UI: toggle en ChatPanel.
plan_mode: str = "auto"
class CompletionRequest(BaseModel): class CompletionRequest(BaseModel):
@@ -79,6 +82,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
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -290,6 +295,14 @@ 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)
# 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.
plan_mode = (body.plan_mode or "auto").lower()
if plan_mode not in ("auto", "force"):
plan_mode = "auto"
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)
@@ -379,6 +392,27 @@ 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"),
}
return SessionResponse( return SessionResponse(
session_id=session.session_id, session_id=session.session_id,
status=session.status.value, status=session.status.value,
@@ -388,6 +422,7 @@ async def get_session(session_id: str) -> SessionResponse:
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 +447,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,34 +590,85 @@ 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 = []
for line in lines:
if line.startswith("## "):
tags.append(line.lstrip("#").strip().lower()[:30])
docs_data.append((doc_id, title, content, summary, tags[:10]))
# Generate embeddings in batch # Generate embeddings in batch
from ..memory.embeddings import EmbeddingService from ..memory.embeddings import EmbeddingService
embed_service = EmbeddingService() embed_service = EmbeddingService()
embed_texts = [f"{title}\n{summary}\n{content[:2000]}" for _, title, content, summary, _ in docs_data] embed_texts = [
f"{title}\n{summary}\n{content[:2000]}"
for _, title, content, summary, _, _, _ in docs_data
]
try: try:
embeddings = await embed_service.embed_batch(embed_texts) embeddings = await embed_service.embed_batch(embed_texts)
@@ -576,7 +697,7 @@ async def _load_knowledge_from_dir(docs_path: str = "docs") -> dict[str, Any]:
# 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,6 +706,8 @@ 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)
@@ -596,6 +719,8 @@ async def _load_knowledge_from_dir(docs_path: str = "docs") -> dict[str, Any]:
"title": title, "title": title,
"chars": len(content), "chars": len(content),
"tags": tags[:5], "tags": tags[:5],
"priority": priority,
"load_when": load_when,
"embedded": embeddings[i] is not None, "embedded": embeddings[i] is not None,
}) })

View File

@@ -45,7 +45,19 @@ 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 = 2000
conversation_recent_raw_limit: int = 2 conversation_recent_raw_limit: int = 2

View File

@@ -90,11 +90,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 +117,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,
@@ -340,29 +345,16 @@ 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).
"", parts = [agent.system_prompt or ""]
]
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 +380,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 +420,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 +555,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 +568,8 @@ 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)."""
try: try:
if not self._embed_service: if not self._embed_service:
self._embed_service = EmbeddingService() self._embed_service = EmbeddingService()
@@ -484,7 +580,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 +668,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 +756,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,

View File

@@ -31,6 +31,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."""

View File

@@ -62,6 +62,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))

View File

@@ -19,6 +19,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,6 +67,10 @@ 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
@@ -86,6 +93,11 @@ class BaseAgent:
temperature=self.profile.temperature or 0.3, temperature=self.profile.temperature or 0.3,
) )
# 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]] = {}
@@ -269,6 +281,18 @@ class BaseAgent:
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 +368,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 +409,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 +488,554 @@ class BaseAgent:
return tool_exec return tool_exec
# ---- Tools internas del orquestador (Fase 5) -----------------------------
@staticmethod
def _summarize_parent_thinking(conversation: list[dict[str, Any]], max_chars: int = 1200) -> str:
"""Resumen del thinking acumulado del agente principal hasta este turno.
Recorre los assistants Anthropic-style con content blocks `type=thinking`,
junta los textos y trunca a `max_chars`. Se usa para pasar contexto
comprimido al planner sub-loop sin contaminarlo con el thinking entero.
"""
chunks: list[str] = []
total = 0
for msg in reversed(conversation):
if msg.get("role") != "assistant":
continue
content = msg.get("content")
if not isinstance(content, list):
continue
for block in content:
if isinstance(block, dict) and block.get("type") == "thinking":
txt = block.get("thinking", "") or ""
if not txt:
continue
chunks.append(txt)
total += len(txt)
if total >= max_chars:
break
if total >= max_chars:
break
# Concatenamos del mas viejo al mas reciente para mantener orden logico.
joined = "\n---\n".join(reversed(chunks))
if len(joined) > max_chars:
joined = "[...] " + joined[-max_chars:]
return joined
async def _execute_acai_plan(
self,
session: SessionState,
arguments: dict[str, Any],
tool_call_id: str,
tool_exec: ToolExecution,
) -> ToolExecution:
"""Implementacion de la tool sintetica `acai_plan`.
Lanza un sub-loop con `system.planner.md` y solo tools de lectura.
Persiste el plan resultante en `session.metadata["current_plan"]`.
"""
# Limite de invocaciones por turno: maximo 2. Tras eso, el modelo debe
# ejecutar directo o abandonar.
count = int(session.metadata.get("plan_call_count_in_turn", 0))
if count >= 2:
tool_exec.status = ToolExecutionStatus.COMPLETED
tool_exec.result_summary = (
"Ya invocaste acai_plan dos veces este turno. "
"Ejecuta directo o usa acai_plan_advance({abandon:true}) para resetear."
)
tool_exec.raw_output = json.dumps({"error": "max_plan_calls_per_turn"})
await self.sse.emit(
EventType.TOOL_COMPLETED,
{"tool": "acai_plan", "status": "completed", "summary": tool_exec.result_summary, "tool_call_id": tool_call_id},
session_id=session.session_id,
)
return tool_exec
session.metadata["plan_call_count_in_turn"] = count + 1
objective = str(arguments.get("objective") or "").strip()
scope = str(arguments.get("scope") or "").strip()
if not objective:
tool_exec.status = ToolExecutionStatus.FAILED
tool_exec.error = "Falta el campo 'objective'"
tool_exec.result_summary = "acai_plan FALLO: falta objective."
tool_exec.raw_output = json.dumps({"error": "missing_objective"})
await self.sse.emit(
EventType.TOOL_COMPLETED,
{"tool": "acai_plan", "status": "failed", "error": tool_exec.error, "tool_call_id": tool_call_id},
session_id=session.session_id,
)
return tool_exec
# Resumen del thinking acumulado en el turno actual (si lo hay).
# `self._current_conversation` se setea al inicio de execute() — ver mas abajo.
parent_summary = self._summarize_parent_thinking(
getattr(self, "_current_conversation", []) or [],
)
start = time.monotonic()
try:
result = await run_planner_subloop(
objective=objective,
scope=scope,
agent_profile=self.profile,
model_adapter=self.model,
mcp=self.mcp,
parent_thinking_summary=parent_summary,
)
except Exception as e:
logger.error("Planner sub-loop crashed: %s", e)
tool_exec.status = ToolExecutionStatus.FAILED
tool_exec.error = str(e)
tool_exec.duration_ms = (time.monotonic() - start) * 1000
tool_exec.result_summary = f"acai_plan FALLO: {str(e)[:200]}"
tool_exec.raw_output = json.dumps({"error": str(e)[:500]})
await self.sse.emit(
EventType.TOOL_COMPLETED,
{"tool": "acai_plan", "status": "failed", "error": str(e), "tool_call_id": tool_call_id},
session_id=session.session_id,
)
return tool_exec
tool_exec.duration_ms = (time.monotonic() - start) * 1000
if not result.plan:
err = result.error or "Plan vacio"
logger.warning(
"[acai_plan] Plan FAILED: %s (raw_preview=%r)",
err, (result.raw_text or "")[:200],
)
tool_exec.status = ToolExecutionStatus.FAILED
tool_exec.error = err
tool_exec.result_summary = (
f"acai_plan FALLO: {err}. Procede en modo directo o reintenta con scope distinto."
)
tool_exec.raw_output = json.dumps({
"error": err,
"raw_text_preview": (result.raw_text or "")[:500],
})
await self.sse.emit(
EventType.TOOL_COMPLETED,
{"tool": "acai_plan", "status": "failed", "error": err, "tool_call_id": tool_call_id},
session_id=session.session_id,
)
return tool_exec
# Plan valido: persistir en metadata. Si habia un plan activo previo,
# moverlo a history como `superseded`.
old_plan = session.metadata.get("current_plan")
if old_plan and old_plan.get("status") == "active":
old_plan["status"] = "superseded"
session.metadata.setdefault("plan_history", []).append(old_plan)
plan = dict(result.plan)
plan["cursor"] = 0
plan["completed_step_ids"] = []
plan["status"] = "active"
plan["created_at"] = int(time.time())
session.metadata["current_plan"] = plan
steps = plan.get("steps") or []
next_desc = steps[0]["description"] if steps else "(plan vacio)"
n_steps = len(steps)
n_risks = len(plan.get("risks") or [])
tool_exec.status = ToolExecutionStatus.COMPLETED
tool_exec.result_summary = (
f"Plan generado: {n_steps} step(s), {n_risks} risk(s). "
f"Proximo: step 1 — {next_desc[:200]}"
)
logger.info(
"[acai_plan] Plan persisted: %d steps, %d risks, objective=%r",
n_steps, n_risks, objective[:120],
)
# raw_output al modelo: el JSON completo del plan (truncado a 4000 chars).
plan_json = json.dumps(plan, ensure_ascii=False)
if len(plan_json) > 4000:
tool_exec.raw_output = plan_json[:4000] + "\n[...truncated]"
else:
tool_exec.raw_output = plan_json
await self.sse.emit(
EventType.TOOL_COMPLETED,
{
"tool": "acai_plan",
"status": "completed",
"summary": tool_exec.result_summary[:200],
"raw_output": tool_exec.raw_output[:4000],
"tool_call_id": tool_call_id,
},
session_id=session.session_id,
)
# PlanStepper UI: notifica al frontend que hay un plan nuevo activo.
await self.sse.emit(
EventType.PLAN_CREATED,
{
"objective": plan.get("objective", ""),
"steps": [
{
"id": s.get("id"),
"description": s.get("description", "")[:300],
"agent_action": s.get("agent_action", "")[:200],
"files_touched": s.get("files_touched", [])[:10],
"tables_touched": s.get("tables_touched", [])[:10],
}
for s in plan.get("steps", [])
],
"risks": plan.get("risks", [])[:10],
"cursor": plan.get("cursor", 0),
"completed_step_ids": plan.get("completed_step_ids", []),
"status": plan.get("status", "active"),
},
session_id=session.session_id,
)
return tool_exec
async def _execute_acai_plan_advance(
self,
session: SessionState,
arguments: dict[str, Any],
tool_call_id: str,
tool_exec: ToolExecution,
) -> ToolExecution:
"""Avanza/abandona el plan activo."""
plan = session.metadata.get("current_plan")
if not plan or plan.get("status") != "active":
tool_exec.status = ToolExecutionStatus.COMPLETED
tool_exec.result_summary = "No hay plan activo."
tool_exec.raw_output = json.dumps({"status": "no_active_plan"})
await self.sse.emit(
EventType.TOOL_COMPLETED,
{"tool": "acai_plan_advance", "status": "completed", "summary": tool_exec.result_summary, "tool_call_id": tool_call_id},
session_id=session.session_id,
)
return tool_exec
if arguments.get("abandon"):
plan["status"] = "abandoned"
session.metadata.setdefault("plan_history", []).append(plan)
session.metadata["current_plan"] = None
tool_exec.status = ToolExecutionStatus.COMPLETED
tool_exec.result_summary = "Plan abandonado."
tool_exec.raw_output = json.dumps({"status": "abandoned"})
await self.sse.emit(
EventType.TOOL_COMPLETED,
{"tool": "acai_plan_advance", "status": "completed", "summary": tool_exec.result_summary, "tool_call_id": tool_call_id},
session_id=session.session_id,
)
await self.sse.emit(
EventType.PLAN_ENDED,
{"status": "abandoned", "objective": plan.get("objective", "")},
session_id=session.session_id,
)
return tool_exec
# Aplicar completed_ids
completed_in = arguments.get("completed_ids") or []
completed_set = set(plan.get("completed_step_ids", []))
for cid in completed_in:
if isinstance(cid, int) and cid not in completed_set:
plan.setdefault("completed_step_ids", []).append(cid)
completed_set.add(cid)
# Aplicar cursor
steps = plan.get("steps") or []
if "next_cursor" in arguments:
plan["cursor"] = max(0, min(int(arguments["next_cursor"]), len(steps)))
else:
# Auto-avanzar al primer step no completado.
for i, st in enumerate(steps):
if st.get("id") not in completed_set:
plan["cursor"] = i
break
else:
plan["status"] = "done"
cursor = plan.get("cursor", 0)
if plan.get("status") == "done" or cursor >= len(steps):
tool_exec.result_summary = f"Plan completado ({len(completed_set)}/{len(steps)} steps)."
else:
next_desc = steps[cursor].get("description", "(?)") if cursor < len(steps) else "(?)"
tool_exec.result_summary = (
f"Plan avanzado a step {cursor + 1}/{len(steps)}: {next_desc[:200]}"
)
tool_exec.status = ToolExecutionStatus.COMPLETED
tool_exec.raw_output = json.dumps({
"cursor": plan.get("cursor", 0),
"completed_step_ids": plan.get("completed_step_ids", []),
"status": plan.get("status", "active"),
})
await self.sse.emit(
EventType.TOOL_COMPLETED,
{"tool": "acai_plan_advance", "status": "completed", "summary": tool_exec.result_summary, "tool_call_id": tool_call_id},
session_id=session.session_id,
)
# Emitir PLAN_ADVANCED o PLAN_ENDED segun el resultado.
if plan.get("status") == "done":
await self.sse.emit(
EventType.PLAN_ENDED,
{"status": "done", "objective": plan.get("objective", "")},
session_id=session.session_id,
)
else:
await self.sse.emit(
EventType.PLAN_ADVANCED,
{
"cursor": plan.get("cursor", 0),
"completed_step_ids": plan.get("completed_step_ids", []),
"status": plan.get("status", "active"),
},
session_id=session.session_id,
)
return tool_exec
@staticmethod
def _match_step_to_executions(
step: dict[str, Any],
tool_executions: list[ToolExecution],
) -> bool:
"""Heuristica: matchea step.agent_action con tool calls reales.
Marca el step como completado si alguna de las tools ejecutadas
coincide con el `agent_action` del step. Compara:
1) nombre de la tool (normalizando guion/underscore: `acai-write`
matchea con `acai_write`).
2) si action menciona algun `files_touched` y la tool ejecutada
tiene ese path en sus argumentos.
3) si action menciona algun `tables_touched` y la tool ejecutada
tiene ese tableName en sus argumentos.
"""
action = (step.get("agent_action") or "").lower()
files_touched = [str(f).lower() for f in (step.get("files_touched") or [])]
tables_touched = [str(t).lower() for t in (step.get("tables_touched") or [])]
if not action and not files_touched and not tables_touched:
return False
for te in tool_executions:
if te.status != ToolExecutionStatus.COMPLETED:
continue
raw_name = strip_namespace(te.tool_name).lower()
# Normaliza guiones/underscores para matching tool name <-> action.
tool_variants = {raw_name, raw_name.replace("-", "_"), raw_name.replace("_", "-")}
# Match 1: nombre de la tool aparece en action
if any(v and v in action for v in tool_variants):
return True
# Match 2/3: path o tableName en los args de la tool
try:
args_str = json.dumps(te.arguments or {}, ensure_ascii=False).lower()
except Exception:
args_str = str(te.arguments or "").lower()
for f in files_touched:
if f and f in args_str:
return True
for t in tables_touched:
if t and t in args_str:
return True
return False
async def _auto_advance_plan_cursor(
self,
session: SessionState,
tool_executions_this_step: list[ToolExecution],
no_tool_calls_this_step: bool = False,
) -> None:
"""Avanza el cursor del plan tras un step interno del agente.
Usa LLM-as-judge (`plan_judge.judge_plan_progress`) para decidir que
steps del plan se acaban de completar con las tool_executions del step
actual. Mas robusto que el matching string heuristico anterior.
Si `no_tool_calls_this_step=True` y hay un plan active, marcamos el plan
como `done` — el agente decidio terminar (end_turn) sin mas tools, asi
que confiamos en su criterio. Esto cierra el plan visualmente cuando el
agente acaba.
"""
plan = session.metadata.get("current_plan")
if not plan or plan.get("status") != "active":
return
steps = plan.get("steps") or []
prev_cursor = int(plan.get("cursor", 0))
prev_completed = list(plan.get("completed_step_ids", []))
completed_set = set(prev_completed)
rationale = ""
# Si el agente termino el turn sin tools, NO marcamos los pendientes
# como completados — seria un falso positivo (caso real: agente se
# queda atascado y devuelve mensaje de chat sin haber hecho la tarea).
# Solo si el `completed_set` previo ya cubre todos los steps cerramos
# como done; si quedan pendientes, dejamos `active`.
if no_tool_calls_this_step:
if steps and len(completed_set) >= len(steps):
rationale = "agente termino el turn; todos los steps ya completados"
else:
rationale = "agente termino el turn con steps pendientes (no cerrado)"
# No tocar completed_set: respetamos lo que el judge dijo en steps previos
elif tool_executions_this_step:
# Pregunta al judge que steps acaba de completar.
try:
completed_ids, judge_rationale = await judge_plan_progress(
plan=plan,
tool_executions_this_step=tool_executions_this_step,
model_adapter=self.model,
model_id=self.profile.model_id,
)
for cid in completed_ids:
completed_set.add(cid)
rationale = judge_rationale
except Exception as e:
logger.warning("[plan-judge] failed, no advance this step: %s", e)
# Sin judge, no avanzamos el cursor — preferimos dejar el plan
# como esta antes que falsos positivos heuristicos.
return
# Cursor: primer step NO completado. Si todos completados → done.
cursor = len(steps)
for i, step in enumerate(steps):
if step.get("id") not in completed_set:
cursor = i
break
plan["cursor"] = cursor
plan["completed_step_ids"] = sorted(completed_set)
ended = False
if cursor >= len(steps) and steps:
plan["status"] = "done"
ended = True
# Solo emitimos si hubo cambio real.
changed = cursor != prev_cursor or set(plan["completed_step_ids"]) != set(prev_completed)
logger.info(
"[plan-advance] tools_in_step=%d prev_cursor=%d new_cursor=%d completed=%s changed=%s rationale=%r",
len(tool_executions_this_step), prev_cursor, cursor,
plan["completed_step_ids"], changed, rationale[:160],
)
if not changed:
return
try:
if ended:
await self.sse.emit(
EventType.PLAN_ENDED,
{"status": "done", "objective": plan.get("objective", "")},
session_id=session.session_id,
)
else:
await self.sse.emit(
EventType.PLAN_ADVANCED,
{
"cursor": plan["cursor"],
"completed_step_ids": plan["completed_step_ids"],
"status": plan.get("status", "active"),
},
session_id=session.session_id,
)
except Exception as e:
logger.warning("PLAN_ADVANCED/ENDED emit failed: %s", e)
# ---- Allowed tools --------------------------------------------------------
def _get_allowed_tools(self, followup_mode: str = "none") -> list[dict[str, Any]]: def _get_allowed_tools(self, followup_mode: str = "none") -> list[dict[str, Any]]:
"""Return tool definitions filtered by this agent's allowed_tools.""" """Return tool definitions filtered by this agent's allowed_tools.
Si el agente tiene `has_planner_tool=True`, anade definiciones sinteticas
de `acai_plan` y `acai_plan_advance` (Fase 5: la tool interna no
atraviesa MCP — se intercepta en `_execute_tool`).
"""
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:
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)

View File

@@ -99,10 +99,27 @@ class OrchestratorEngine:
session_id=session.session_id, session_id=session.session_id,
) )
# Plan mode 'force': el usuario ha pulsado el toggle Plan en el chat.
# Prependeamos una directiva al mensaje para que el agente llame
# acai_plan ANTES de ejecutar nada. El system prompt ya conoce la tool;
# esto solo bypassa la heuristica trivial-vs-complex.
plan_mode = (session.metadata.get("plan_mode") or "auto").lower()
if plan_mode == "force":
message = (
"[modo Plan activo por el usuario] Tu PRIMERA accion debe ser "
"llamar a la tool `acai_plan` con un plan detallado del trabajo "
"que vas a hacer. No ejecutes ninguna otra tool antes. Despues "
"del plan, procede con la ejecucion normal.\n\n"
f"Peticion del usuario:\n{message}"
)
# Create task # Create task
task = session.begin_task(objective=message) task = session.begin_task(objective=message)
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,

View File

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

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

@@ -0,0 +1,355 @@
"""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 ..mcp.manager import MCPManager
from ..models.agent import AgentProfile
from .tool_groups import PLANNER_TOOLS, strip_namespace
logger = logging.getLogger(__name__)
@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(
model_id=agent_profile.model_id or "",
max_tokens=agent_profile.max_tokens or 4096,
# Temperatura mas baja que el agente principal — queremos JSON limpio.
temperature=0.1,
)
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 = ""
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.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.
messages.append({"role": "assistant", "content": full_text or accumulated_text})
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.
assistant_blocks: list[dict[str, Any]] = []
if full_text:
assistant_blocks.append({"type": "text", "text": full_text})
for tc in tool_calls_this_step:
assistant_blocks.append({
"type": "tool_use",
"id": tc["id"],
"name": tc["name"],
"input": tc.get("parsed_arguments", {}),
})
messages.append({"role": "assistant", "content": assistant_blocks})
tool_result_blocks: list[dict[str, Any]] = []
for tc in tool_calls_this_step:
# Solo ejecutamos tools de lectura. Si por algun bug llega una
# tool de escritura, devolvemos error en lugar de ejecutarla.
tool_name_raw = tc["name"]
if not strip_namespace(tool_name_raw) in PLANNER_TOOLS:
tool_result_blocks.append({
"type": "tool_result",
"tool_use_id": tc["id"],
"content": f"[ERROR planner] tool '{tool_name_raw}' no permitida en planner sub-loop (solo lectura).",
"is_error": True,
})
continue
try:
if not mcp or not mcp.is_running:
raise RuntimeError("MCP no disponible")
result = await mcp.call_tool(tool_name_raw, tc.get("parsed_arguments", {}))
# Extraer texto del resultado MCP
content_parts: list[str] = []
for c in (result.get("content") or []):
if isinstance(c, dict) and c.get("type") == "text":
content_parts.append(c.get("text", ""))
raw_output = "\n".join(content_parts) if content_parts else json.dumps(result)
tool_result_blocks.append({
"type": "tool_result",
"tool_use_id": tc["id"],
"content": raw_output[:4000],
})
tool_executions_log.append({
"name": tool_name_raw,
"arguments": tc.get("parsed_arguments", {}),
"raw_output_preview": raw_output[:300],
})
except Exception as e:
logger.warning("Planner tool %s failed: %s", tool_name_raw, e)
tool_result_blocks.append({
"type": "tool_result",
"tool_use_id": tc["id"],
"content": f"[ERROR] {e}",
"is_error": True,
})
messages.append({"role": "user", "content": tool_result_blocks})
# En el penultimo y ultimo turno, forzamos al modelo a parar de
# investigar y emitir el JSON. M2.7 a veces sigue pidiendo tools
# indefinidamente — hay que cortar.
if sub_step >= max_subloop_steps - 2:
messages.append({
"role": "user",
"content": (
"PARA. No llames mas tools. Ya tienes lo necesario. "
"Emite AHORA el plan JSON segun la especificacion del system prompt. "
"Solo el JSON, sin texto alrededor."
),
})
# Si salimos del loop sin plan, fallamos.
logger.warning(
"Planner agotado: %d steps, %d tool calls totales, accumulated_text=%r",
max_subloop_steps,
len(tool_executions_log),
accumulated_text[:300],
)
return PlannerResult(
plan=None,
error=f"Planner agotado tras {max_subloop_steps} steps sin emitir JSON",
raw_text=accumulated_text,
tool_executions=tool_executions_log,
)

View File

@@ -25,6 +25,15 @@ class AgentRegistry:
self._agents: dict[str, AgentProfile] = {} self._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,
@@ -79,6 +112,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

View File

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

View File

@@ -207,6 +207,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)

View File

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