From 44cb956f957a0ad02b615313153c8a6fed06c77f Mon Sep 17 00:00:00 2001 From: Jordan Diaz Date: Fri, 8 May 2026 21:31:28 +0000 Subject: [PATCH] Ajustes --- agents/_shared/contract.md | 20 + agents/acai/agent.yaml | 4 + agents/acai/system.md | 205 ++---- agents/acai/system.planner.md | 64 ++ agents/accessibility/agent.yaml | 4 + agents/analytics/agent.yaml | 2 + agents/code-reviewer/agent.yaml | 3 + agents/content/agent.yaml | 2 + agents/qa/agent.yaml | 2 +- agents/seo/agent.yaml | 4 + docs/01-builder-fields.md | 7 + docs/02-twig.md | 7 + docs/03-modules-and-sections.md | 7 + docs/04-pages-and-records.md | 7 + docs/05-tables-and-fields.md | 7 + docs/06-hooks-and-cmsapi.md | 7 + docs/07-css-js-conventions.md | 7 + docs/08-layout-and-libraries.md | 7 + docs/09-mcp-tools-reference.md | 22 + docs/10-production-patterns.md | 7 + docs/11a-decision-table.md | 110 ++++ ...-reference.md => 11b-rules-cheat-sheet.md} | 41 +- docs/12-glossary.md | 86 +++ src/adapters/claude_adapter.py | 9 + src/api/routes.py | 167 ++++- src/config.py | 14 +- src/context/engine.py | 230 +++++-- src/models/agent.py | 17 + src/models/context.py | 4 + src/orchestrator/agents/base.py | 590 +++++++++++++++++- src/orchestrator/engine.py | 17 + src/orchestrator/plan_judge.py | 206 ++++++ src/orchestrator/planner.py | 355 +++++++++++ src/orchestrator/registry.py | 39 ++ src/orchestrator/tool_groups.py | 63 ++ src/streaming/claude_format.py | 23 + src/streaming/sse.py | 5 + 37 files changed, 2120 insertions(+), 251 deletions(-) create mode 100644 agents/_shared/contract.md create mode 100644 agents/acai/system.planner.md create mode 100644 docs/11a-decision-table.md rename docs/{11-quick-reference.md => 11b-rules-cheat-sheet.md} (66%) create mode 100644 docs/12-glossary.md create mode 100644 src/orchestrator/plan_judge.py create mode 100644 src/orchestrator/planner.py create mode 100644 src/orchestrator/tool_groups.py diff --git a/agents/_shared/contract.md b/agents/_shared/contract.md new file mode 100644 index 0000000..75b9064 --- /dev/null +++ b/agents/_shared/contract.md @@ -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 ``, ni `[TOOL_CALL]`, ni ``, ni ``, 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. diff --git a/agents/acai/agent.yaml b/agents/acai/agent.yaml index 70e7aac..8051807 100644 --- a/agents/acai/agent.yaml +++ b/agents/acai/agent.yaml @@ -13,3 +13,7 @@ context_sections: allowed_tools: [] model_id: null stream_deltas: true +kb_load_strategy: top_n +kb_max_tokens: 4000 +kb_top_n: 2 +has_planner_tool: true diff --git a/agents/acai/system.md b/agents/acai/system.md index 6dd6850..15bb38c 100644 --- a/agents/acai/system.md +++ b/agents/acai/system.md @@ -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 => ...}`, `{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: -1. Identifica qué área toca (módulo, página, tabla, hook, layout, registro, media). -2. Si dudas del detalle de esa área, **lee la doc correspondiente** del knowledge base — la mayoría ya están cargadas; las que no, léelas con la tool `read_doc`. -3. Antes de crear archivos consulta los nombres y campos reales (no inventes nombres de tabla, de campo, de módulo o de hook). -4. Usa la tool adecuada en cada paso. Las tools de archivos `acai-write` / `acai-line-replace` sobre `index-base.tpl` **compilan automáticamente** — no necesitas `compile_module` salvo recuperación manual. +**Modo planificación** — llama PRIMERO la tool `acai_plan(objective, scope?)`: +- Construir una landing entera, una tienda, un módulo nuevo con tabla + hook + frontend juntos. +- Refactor amplio (clonar módulo, migrar de uno a otro, mover layout). +- Cambio cross-cutting con riesgo (modificar todos los módulos que cumplen una condición). +- Petición ambigua donde necesitas leer estado primero para decidir el plan correcto. -# 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": "", "scope": ""})`. + +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// ├── index-base.tpl # source — EDITA SOLO ESTE ├── index.tpl # autogenerado — NO TOCAR - ├── index-twig.tpl # autogenerado — NO TOCAR ├── builder.json # autogenerado — NO TOCAR ├── style.css # estático (sin Twig) ├── script.js # estático (sin Twig) └── hook.php # opcional — hook propio del módulo - hooks/hooks..php # hooks globales -cms/data/schema/ # schemas de tablas (.ini.php) -cms/lib/plugins/builder_saas/layout.json # PROHIBIDO editar directamente +cms/data/schema/ # .ini.php — SOLO con tools de schema ``` -# Reglas inmutables +# Reglas duras (no negociables) -1. **Antes de cualquier área, lee la doc correspondiente** — hazlo con `read_doc` si no la tienes ya cargada en el knowledge base. -2. **NUNCA uses `mkdir`.** Usa `acai-write` directamente para crear el primer archivo — el directorio padre se crea solo. -3. En los módulos **solo editas `index-base.tpl`**. `index.tpl`, `index-twig.tpl` y `builder.json` son autogenerados por la compilación. -4. Editar `index-base.tpl` con `acai-write` o `acai-line-replace` **dispara compilación automática**. `compile_module` solo para recuperación manual. -5. **`script.js` y `style.css` son archivos estáticos.** NO uses sintaxis Twig ni atributos builder dentro. Pasa valores dinámicos vía atributos `data-*` desde `index-base.tpl`. -6. **Twig usa filtros con `|`**, nunca funciones (`'tabla' | get()`, no `get('tabla')`). -7. **Tablas siempre sin prefijo `cms_`** en tools, Twig y `CmsApi`. Excepción: `queryDB` y el `middleWare` de `set_hook_middleware` sí llevan `cms_`. -8. **Primary key siempre `num`**, nunca `id`. Foreign keys con sufijo `_num` (`categoria_num`). -9. **Upload fields son arrays**: `imagen[0].urlPath`, no `imagen`. -10. **Twig concatena con `~`**: `'value=' ~ variable`. -11. **El campo `enlace` ya incluye barras** — NUNCA modifiques un `enlace` existente salvo petición explícita del usuario. -12. **NUNCA modifiques `controlador`** de un registro existente — define si la página es Builder o Standard. -13. **NUNCA inventes nombres de campo o tabla.** Confirma con `get_table_schema` antes de usarlos. -14. **NUNCA edites directamente** `cms/lib/plugins/builder_saas/layout.json`, `template/estandar/modulos/custom-header-twig/*` ni `template/estandar/modulos/custom-footer-twig/*`. Usa `get_layout_field` / `set_layout_field`. -15. **Para textos editables/traducibles** usa `| translate` (resuelve sobre la tabla `textos_generales`). NUNCA crees archivos JSON, `.po` ni sistemas i18n externos. -16. **Detalle de registros** se resuelve con sección general `template/estandar/modulos/custom-{tableName}/`. NO crees página por registro en `apartados`. NO uses ni configures `_detailPage` (no existe). -17. **`c-if` usa `=` (un igual). `{% if %}` usa `==` (doble igual).** -18. **Checkbox guarda `1` o `0` (número)**, nunca `true` / `false`. -19. **Para URLs del sitio** usa `get_web_url` siempre + `?pruebas=1`. Nunca `localhost:8080` ni dominios de producción. -20. **Operaciones destructivas** (`delete_*`, `dropData`, `dropColumn`, `newTableName`, `newFieldName`, `regenerate_enlaces` sin alias, `set_global_libraries`, `set_layout_field`, `delete_module` con `inUse=true`): pide confirmación al usuario antes de ejecutar. +1. **NUNCA `mkdir`.** Usa `acai-write` directamente — el directorio se crea solo. +2. **Solo edita `index-base.tpl`** de los módulos. Los `.tpl` y `.json` autogenerados NO se tocan. +3. `acai-write` / `acai-line-replace` sobre `index-base.tpl` **compilan automáticamente**. +4. **`script.js` y `style.css` son estáticos** — no Twig dentro. Pasa valores con `data-*`. +5. **Tablas sin `cms_`** en tools y Twig. En `queryDB` y `set_hook_middleware` SÍ con `cms_`. +6. **Primary key `num`**, foreign keys `*_num`. **Upload fields son arrays**: `imagen[0].urlPath`. +7. **Twig concatena con `~`**. **`c-if` usa `=`, `{% if %}` usa `==`**. **Checkbox: `1`/`0`**. +8. **`enlace` ya incluye barras** — no lo toques. **NUNCA modifiques `controlador`** de un registro existente. +9. **NUNCA inventes** nombres de campo / tabla / módulo. Si dudas: `get_table_schema`, `acai-glob`, `acai-grep`. +10. **Layout y libs**: `get_layout_field` / `set_layout_field`. NUNCA `acai-write` sobre `custom-header-twig/*` ni `cms/lib/plugins/builder_saas/layout.json`. +11. **Texto traducible**: filtro `| translate` (tabla `textos_generales`). NUNCA i18n externo. +12. **Detalle de registros**: sección general `template/estandar/modulos/custom-{tableName}/index-base.tpl` con `thisrecord.*`. NO página por registro en `apartados`. NO `_detailPage`. +13. **Schemas (.ini.php)** solo con tools (`create_table`, `create_field`, etc.). +14. **URL del proyecto**: `get_web_url` + `?pruebas=1` siempre. +15. **Operaciones destructivas**: confirma con el usuario antes de ejecutar. -# Decision tree — qué hacer según la intención del usuario +# Patrones canónicos (aplica por defecto) -| Intención | Secuencia canónica | -|-----------|--------------------| -| **Crear módulo nuevo** | (lee `01-builder-fields`, si JS `07-css-js-conventions`, si hook `06-hooks-and-cmsapi`) → `acai-write index-base.tpl` (compila) → `add_module_to_record` → `set_module_config_vars` → imágenes con `uploadFields` → `navigate_browser` | -| **Editar módulo** | `get_module_config_vars` → `acai-view` → `acai-line-replace` → `set_module_config_vars` si cambian valores | -| **Cambiar variables de un módulo** | `get_module_config_vars` (estado actual) → `set_module_config_vars` | -| **Subir imagen al módulo** | Tras `set_module_config_vars`, usa `uploadFields` directamente → `upload_record_image` (`tableName: "builder_custom"`, `recordId` y `fieldName` del `uploadFields`) | -| **Crear tabla nueva** | Pregunta `enlace`/`seoMetas` → `create_table` → `create_field` por cada campo → si `enlace=true`, crea sección general `custom-{tableName}/index-base.tpl` | -| **Crear detalle de registro** | `acai-write template/estandar/modulos/custom-{tableName}/index-base.tpl` con `thisrecord.*`. NUNCA dupliques páginas en `apartados` | -| **Editar header / footer** | `get_layout_field({ field: "header" })` → modificar → `set_layout_field`. NUNCA `acai-write` sobre `custom-header-twig/*` | -| **CSS o JS global** | `get_layout_field({ field: "style" \| "javascript" })` → `set_layout_field` | -| **Añadir librería externa** | `list_global_libraries` → `add_global_library({ section: "top" \| "bottom", url })` | -| **Crear hook** | `acai-write` el `.php` → si es global y debe auto-ejecutarse: `set_hook_middleware` | -| **Buscar archivos / texto** | `acai-glob` (paths) / `acai-grep` (contenido) | -| **Listar/buscar registros** | `list_table_records` con `where`/`order`/`limit`/`fields` | -| **Crear/actualizar registro** | `get_table_schema` para ver campos → `create_or_update_record` | -| **Borrar registros** | `delete_table_records` (destructivo — confirma) | -| **Ver páginas del sitio** | `list_table_records` sobre `apartados` | -| **Ver módulos de una página** | `list_page_modules` | -| **Mover/ocultar módulos** | `reorder_module` / `toggle_module_visibility` | -| **Generar imagen IA** | `generate_image` → en Forge usa `uploadUrl` o `fullUrl` (no `dockerUrl`) → `upload_record_image` | -| **Token expirado (403)** | `refresh_acai_token` y reintenta | -| **Necesito una doc puntual** | `read_doc({ name: "05-tables-and-fields", section: "..." })` o `list_docs()` | +- **Detalle de registro**: sección `custom-{tableName}` con `thisrecord.*`. +- **Form contacto/postulación**: `c-form` (inserta + email auto). Tabla propia solo si el usuario quiere admin. +- **Tabla "publicable"** (noticias, vacantes, blog): `fecha_publicacion`, `fecha_expiracion?`, `visible` (checkbox). +- **Form embebido**: ``. -# 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 | -|-----|-------| -| `01-builder-fields` | Campos editables (`data-field-type`), atributos Acai (`c-if`, `c-for`, `c-class`), ``, `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 | +# Estilo -Si vas a crear o editar algo y no recuerdas exactamente cómo, **prefiere leer la doc** (`read_doc`) antes que adivinar. - -# 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 - -``` -NO pongas el formulario como sección suelta del listado. - -# Acai Core (web-base) - -El workspace del proyecto contiene solo la **capa de personalización** (módulos, hooks, schemas, uploads). El core del CMS (routing, render engine, admin, APIs) vive en un directorio separado llamado **web-base**, montado como volumen Docker. NO modifiques archivos de `web-base` — son compartidos entre proyectos. - -# Comportamiento esperado - -- Comunicación clara, breve y en **español**. -- Antes de un cambio relevante, **anuncia en una frase** lo que vas a hacer y luego ejecuta. -- Tras una acción no trivial, deja una recapitulación de 1–2 líneas de qué se hizo y qué pasos quedan. -- Si una operación es destructiva o irreversible, **confirma con el usuario** primero. -- Si te falta un dato concreto (qué tabla, qué módulo, qué página), pregúntalo. NO adivines. -- Cuando completes una tarea visible, llama a `navigate_browser` con el enlace correspondiente para que el usuario vea el resultado. +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. diff --git a/agents/acai/system.planner.md b/agents/acai/system.planner.md new file mode 100644 index 0000000..b39ffa8 --- /dev/null +++ b/agents/acai/system.planner.md @@ -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. diff --git a/agents/accessibility/agent.yaml b/agents/accessibility/agent.yaml index f46b9b1..ff49f6f 100644 --- a/agents/accessibility/agent.yaml +++ b/agents/accessibility/agent.yaml @@ -13,3 +13,7 @@ context_sections: allowed_tools: [] model_id: null stream_deltas: true +kb_load_strategy: tags +kb_tags: [html, css, modules, twig] +kb_max_tokens: 3000 +kb_top_n: 2 diff --git a/agents/analytics/agent.yaml b/agents/analytics/agent.yaml index 5d72334..5ad2f4f 100644 --- a/agents/analytics/agent.yaml +++ b/agents/analytics/agent.yaml @@ -13,3 +13,5 @@ context_sections: allowed_tools: [] model_id: null stream_deltas: true +kb_load_strategy: cheatsheet_only +kb_max_tokens: 1500 diff --git a/agents/code-reviewer/agent.yaml b/agents/code-reviewer/agent.yaml index e4bf63b..e96dac6 100644 --- a/agents/code-reviewer/agent.yaml +++ b/agents/code-reviewer/agent.yaml @@ -13,3 +13,6 @@ context_sections: allowed_tools: [] model_id: null stream_deltas: true +kb_load_strategy: top_n +kb_max_tokens: 6000 +kb_top_n: 3 diff --git a/agents/content/agent.yaml b/agents/content/agent.yaml index f85357a..67ab220 100644 --- a/agents/content/agent.yaml +++ b/agents/content/agent.yaml @@ -13,3 +13,5 @@ context_sections: allowed_tools: [] model_id: null stream_deltas: true +kb_load_strategy: glossary_only +kb_max_tokens: 1500 diff --git a/agents/qa/agent.yaml b/agents/qa/agent.yaml index f381bb3..f4cf9a6 100644 --- a/agents/qa/agent.yaml +++ b/agents/qa/agent.yaml @@ -8,8 +8,8 @@ max_tokens: 4096 context_sections: - immutable_rules - project_profile - - knowledge_base - task_state allowed_tools: [] model_id: null stream_deltas: true +kb_load_strategy: none diff --git a/agents/seo/agent.yaml b/agents/seo/agent.yaml index 0c1c859..7cb875d 100644 --- a/agents/seo/agent.yaml +++ b/agents/seo/agent.yaml @@ -13,3 +13,7 @@ context_sections: allowed_tools: [] model_id: null stream_deltas: true +kb_load_strategy: tags +kb_tags: [twig, modules, html, builder] +kb_max_tokens: 3500 +kb_top_n: 2 diff --git a/docs/01-builder-fields.md b/docs/01-builder-fields.md index 851ec41..5b3d3a5 100644 --- a/docs/01-builder-fields.md +++ b/docs/01-builder-fields.md @@ -1,3 +1,10 @@ +--- +title: "Campos editables del builder" +tags: [builder, twig, html, modules] +load_priority: 80 +load_when: [always] +summary: "Atributos data-field-* (textfield, headfield, link, upload, list, multiv2, checkbox), c-if/c-for/c-class, c-form, componentes built-in del builder Acai." +--- # Builder Fields — Campos editables del index-base.tpl Este documento define los campos editables que el usuario rellena desde el panel del builder de Acai. Cubre el atributo `data-field-type` con todos sus tipos (`textfield`, `headfield`, `textbox`, `wysiwyg`, `link`, `upload`, `uploadMulti`, `list`, `multiv2`, `checkbox`, `colorpicker`), la regla `data-field-label` → nombre de variable, los atributos Acai (`c-if`, `c-else`, `c-for`, `c-class`, `c-hidden`, `c-required`), el tag ``, 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`. diff --git a/docs/02-twig.md b/docs/02-twig.md index 8dd2adb..40d76d2 100644 --- a/docs/02-twig.md +++ b/docs/02-twig.md @@ -1,3 +1,10 @@ +--- +title: "Filtros Twig personalizados de Acai" +tags: [twig, filters, frontend] +load_priority: 70 +load_when: [always] +summary: "Filtros Twig: get, hook, queryDB, translate, imagec, módulo, set; concatenación con ~; if con ==; reglas de uso vs c-if del builder." +--- # Twig — Filtros personalizados de Acai Este documento describe los filtros Twig propios de Acai (`get`, `queryDB`, `hook`, `module`, `imagec`, `translate`) y los filtros estándar más usados (`raw`, `truncate`, `json_decode`, `split`, `filter`). Acai usa **filtros con pipe `|`**, nunca funciones. Léelo antes de escribir cualquier expresión Twig dentro de `index-base.tpl` o de una sección general. Cubre también la concatenación con `~`, los ternarios, el operador `default` y la diferencia entre `c-if` (=) y `{% if %}` (==). diff --git a/docs/03-modules-and-sections.md b/docs/03-modules-and-sections.md index 7620c31..f9ad01a 100644 --- a/docs/03-modules-and-sections.md +++ b/docs/03-modules-and-sections.md @@ -1,3 +1,10 @@ +--- +title: "Módulos y Secciones Generales" +tags: [modules, sections, structure, twig, html] +load_priority: 75 +load_when: [always] +summary: "Módulos (carpetas en template/estandar/modulos), index-base.tpl, secciones generales (custom-{tableName}/), thisrecord, gestión de uploads de un registro." +--- # Módulos y Secciones Generales Este documento explica el sistema modular de Acai: la diferencia entre **módulos** (componentes visuales reutilizables que el usuario coloca en páginas Builder) y **secciones generales** (plantillas ligadas a una tabla que se renderizan automáticamente al acceder al `enlace` de un registro). Cubre la estructura de archivos de un módulo, las reglas obligatorias sobre `index-base.tpl`, las variables globales (`section_id`, `interno`, `server.HTTP_HOST`, `loop`), la convención `custom-{tableName}` para detalles de registro, la inclusión de un módulo dentro de otro y el uso de `thisrecord` en secciones generales. Léelo antes de crear, mover o editar cualquier carpeta dentro de `template/estandar/modulos/`. diff --git a/docs/04-pages-and-records.md b/docs/04-pages-and-records.md index 3230b6c..7d07d72 100644 --- a/docs/04-pages-and-records.md +++ b/docs/04-pages-and-records.md @@ -1,3 +1,10 @@ +--- +title: "Páginas y Registros" +tags: [pages, apartados, records, structure] +load_priority: 70 +load_when: [always] +summary: "Páginas Builder vs Standard, controlador, enlace, registros con num/PK, builder_custom, workflows de creación y edición." +--- # Páginas y Registros Este documento explica cómo Acai modela las páginas del sitio: toda fila con campo `enlace` es una página, y según el campo `controlador` puede ser **Builder** (modular, contenido por módulos) o **Standard** (contenido directo en los campos del registro). Cubre los tipos de tabla por `menuType` (`category`, `multi`, `single`, `separador`), las particularidades de la tabla `apartados`, los campos de visibilidad (`visible_en_el_menu` vs `visible`), las reglas inviolables sobre `enlace` y `controlador`, y el patrón canónico para implementar el detalle de un registro vía sección general `custom-{tableName}`. Léelo antes de crear, modificar o eliminar cualquier registro de tabla con `enlace`. diff --git a/docs/05-tables-and-fields.md b/docs/05-tables-and-fields.md index a61acc3..e776a37 100644 --- a/docs/05-tables-and-fields.md +++ b/docs/05-tables-and-fields.md @@ -1,3 +1,10 @@ +--- +title: "Schema management — tablas y campos" +tags: [tables, schema, fields, db] +load_priority: 80 +load_when: [always] +summary: "create_table/update_table_metadata/delete_table/reorder_tables, create_field/update_field/delete_field/reorder_fields, regenerate_enlaces, tipos de campo." +--- # Tablas y Campos Este documento explica cómo gestionar tablas y campos en Acai usando las tools del MCP. Cubre: cómo se almacena el schema (`cms/data/schema/{tabla}.ini.php`), los `menuType` (`multi`, `single`, `category`, `separador`), el flag `enlace` para tablas públicas, todos los tipos de campo (`textfield`, `textbox`, `wysiwyg`, `codigo`, `date`, `list`, `checkbox`, `upload`, `multitext`, `separator`), los props comunes (`isRequired`, `defaultValue`, `optionsType`, etc.), la diferencia entre operaciones reversibles e irreversibles (`dropData`, `dropColumn`, rename), y el flujo correcto para crear una funcionalidad nueva. Léelo antes de usar cualquier tool del grupo `tables/`. diff --git a/docs/06-hooks-and-cmsapi.md b/docs/06-hooks-and-cmsapi.md index 1eaf81c..5f15b8a 100644 --- a/docs/06-hooks-and-cmsapi.md +++ b/docs/06-hooks-and-cmsapi.md @@ -1,3 +1,10 @@ +--- +title: "Hooks PHP y CmsApi" +tags: [php, hooks, cmsapi, backend] +load_priority: 70 +load_when: [always] +summary: "Hooks globales (hooks/hooks.X.php) y de módulo (hook.php), CmsApi::get/insert/update/delete con uploads/relations/translates, set_hook_middleware, auto-registro en layout.json." +--- # Hooks y CmsApi (server-side) Este documento describe cómo crear y consumir hooks PHP en Acai (lógica server-side) y cómo usar `CmsApi` (alias de `CocoDB`) para acceder a la base de datos. Cubre las dos ubicaciones válidas para un hook (global en `hooks/hooks..php` o propio de módulo en `template/estandar/modulos//hook.php`), las cuatro formas de invocarlo (filtro Twig, etiqueta ``, 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. diff --git a/docs/07-css-js-conventions.md b/docs/07-css-js-conventions.md index 0cb5b0b..5655e31 100644 --- a/docs/07-css-js-conventions.md +++ b/docs/07-css-js-conventions.md @@ -1,3 +1,10 @@ +--- +title: "CSS y JavaScript — Convenciones" +tags: [css, js, frontend, conventions] +load_priority: 65 +load_when: [always] +summary: "Tailwind primary, BEM scoped, data-* para pasar valores dinámicos a script.js (que es estático), CmsApi.hook desde JS, native components, Vue 3 builder." +--- # CSS y JavaScript — Convenciones del Módulo Este documento define cómo escribir CSS, JavaScript y, cuando hace falta, Vue 3 dentro de un módulo Acai. Cubre la regla "Tailwind first" + BEM para CSS custom, las clases utilitarias propias de Acai (`transition3s`, `click-a-child`, `line-clamp2`, `lazyload`, `bg-main-color`, etc.), las CSS variables del tema (`--main-color`), el patrón obligatorio de **scoping** vía la clase raíz del módulo, la regla dura de que `script.js` y `style.css` son **archivos estáticos** (sin Twig dentro), cómo pasar valores dinámicos desde `index-base.tpl` a JS vía `data-*`, cuándo usar Vue 3 y cómo integrarlo evitando conflicto de delimiters con Twig, y los componentes nativos del builder (Carousel `c-tns-wrapper`, Lightbox, Breadcrumb, AOS, Lazy loading). Léelo antes de escribir cualquier `style.css` o `script.js`. diff --git a/docs/08-layout-and-libraries.md b/docs/08-layout-and-libraries.md index 11239b6..ad8d36d 100644 --- a/docs/08-layout-and-libraries.md +++ b/docs/08-layout-and-libraries.md @@ -1,3 +1,10 @@ +--- +title: "Layout global y librerías" +tags: [layout, header, footer, libraries] +load_priority: 60 +load_when: [always] +summary: "header/footer/javascript/style en layout.json via get_layout_field/set_layout_field, librerías globales (CDN, npm), modos top/bottom, librerías AMP." +--- # Layout Global y Librerías Globales Este documento explica cómo gestionar los **4 campos globales del proyecto** (`style` CSS global, `javascript` JS global, `header` Twig del header del sitio, `footer` Twig del footer) y las **librerías globales** (CSS/JS/fonts inyectadas en `` o antes de ``). 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.). diff --git a/docs/09-mcp-tools-reference.md b/docs/09-mcp-tools-reference.md index cd235d1..4897e28 100644 --- a/docs/09-mcp-tools-reference.md +++ b/docs/09-mcp-tools-reference.md @@ -1,3 +1,10 @@ +--- +title: "Referencia maestra de tools MCP" +tags: [tools, reference, workflows] +load_priority: 50 +load_when: [ranked] +summary: "Inventario completo de tools por categoría (archivos, módulos, registros, tablas, layout, libs, hooks, media, navegación, proyecto, git, auth, docs) y workflows canónicos." +--- # MCP Tools — Referencia Completa Este documento es el **inventario canónico** de todas las tools MCP disponibles para el agente Acai. Está agrupado por categoría (archivos, módulos, registros, tablas, layout, librerías, hooks, media, navegación, proyecto, git, autenticación, docs) y describe para cada tool su propósito, parámetros clave, qué devuelve y cuándo usarla. Incluye además los **workflows canónicos** para las operaciones más comunes (crear módulo, editar módulo, crear funcionalidad nueva con tabla + detalle, gestionar imágenes de un módulo, editar header/footer, configurar middleware de hook). Léelo antes de cualquier tarea para elegir la secuencia correcta de tools. @@ -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/`) | +### Inspección de páginas (Playwright headless) + +Tools del MCP `playwright`. El browser headless es del agente — el usuario NO ve lo que pasa aquí. Para que el USER vea algo, usa `navigate_browser`. + +| Tool | Acción | Cuándo usarla | +|------|--------|---------------| +| `browser_navigate` | Carga una URL en el headless browser interno | Antes de cualquier otra `browser_*` | +| `browser_snapshot` | Devuelve el accessibility tree YAML (texto estructurado) | **Tool primaria de inspección**. La usas para leer la página. Ves DOM, roles, valores de inputs, enlaces, jerarquía | +| `browser_click`, `browser_fill_form`, `browser_press_key`, `browser_select_option` | Interacciones con elementos | Solo cuando necesitas simular interacción del usuario para reproducir un bug o validar un flow | +| `browser_console_messages` | Logs del console del browser | Para detectar errores JS | +| `browser_network_requests` | Lista de requests | Para detectar 404, fallos de fetch | +| `browser_take_screenshot` | Captura PNG | **Evítala**. El modelo NO procesa imágenes; el screenshot se descarta. Solo úsala si el usuario explícitamente pide "haz un screenshot para que lo vea yo" | + +**Regla**: para auditar/inspeccionar/depurar UI, `browser_navigate` → `browser_snapshot`. NUNCA `browser_take_screenshot` esperando "ver" la página — el modelo es text-only y la imagen se pierde. + ### Proyecto | Tool | Acción | diff --git a/docs/10-production-patterns.md b/docs/10-production-patterns.md index 61e50b7..88ea6f8 100644 --- a/docs/10-production-patterns.md +++ b/docs/10-production-patterns.md @@ -1,3 +1,10 @@ +--- +title: "Patrones de producción" +tags: [patterns, snippets, examples] +load_priority: 55 +load_when: [always] +summary: "Snippets reales: header con menú, FAQ acordeón, zigzag de servicios, formularios con c-form, listados con filtros, structured data, breadcrumbs." +--- # Patrones de Producción Este documento recoge patrones reales usados en módulos y secciones generales de proyectos Acai en producción. Cada patrón incluye el HTML/Twig listo para reutilizar y notas sobre cuándo aplicarlo. Cubre: cabecera de sección con colores configurables, layout zigzag (imagen + texto alternado), acordeón FAQ, formulario de contacto completo con `c-form`, compartir en redes sociales, sección general de detalle de producto, galería con carousel modo `gallery`. Léelo cuando vayas a crear un módulo y quieras evitar reinventar patrones que ya tienen una versión canónica testeada en producción. diff --git a/docs/11a-decision-table.md b/docs/11a-decision-table.md new file mode 100644 index 0000000..b9ad846 --- /dev/null +++ b/docs/11a-decision-table.md @@ -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` | diff --git a/docs/11-quick-reference.md b/docs/11b-rules-cheat-sheet.md similarity index 66% rename from docs/11-quick-reference.md rename to docs/11b-rules-cheat-sheet.md index 81392dd..9250255 100644 --- a/docs/11-quick-reference.md +++ b/docs/11b-rules-cheat-sheet.md @@ -1,6 +1,13 @@ -# Quick Reference — Cheat sheet +--- +title: "Reglas inmutables y cheat-sheet de tipos" +tags: [reference, rules, cheat] +load_priority: 90 +load_when: [cheatsheet] +summary: "Reglas no negociables (cms_, num, _num, upload arrays, c-if/{% if %}), tipos de builder field, atributos Acai, filtros Twig, formato de datos para insert/update, errores comunes." +--- +# Reglas inmutables y cheat-sheet -Este documento es un **resumen ejecutable** de las reglas críticas, los tipos de campo, los filtros Twig, los formatos de datos para insert/update y las variables globales. Es la **fuente única de verdad** para resolver dudas rápidas sin tener que abrir los docs largos. Léelo antes de cualquier operación cuando quieras refrescar las reglas; el resto de docs (`01`–`10`) profundizan en cada tema. +Resumen ejecutable de reglas críticas, tipos de campo, filtros y formatos de datos. Si tienes duda rápida, consulta esto antes de los docs largos. ## Reglas inmutables @@ -80,7 +87,7 @@ Este documento es un **resumen ejecutable** de las reglas críticas, los tipos d | `multitext` | String JSON | `"[{\"item\":\"valor\"}]"` | | `upload` | NO enviar — usar `upload_record_image` después | -## Variables globales +## Variables globales en Twig | Variable | Descripción | |----------|-------------| @@ -91,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 | | `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 - 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`). - Usar Twig dentro de `script.js` o `style.css` (estáticos — pasa valores via `data-*`). - Llamar `mkdir` (usa `acai-write` directamente — crea el directorio padre). +- Usar `upload_record_image` para "reemplazar" una imagen existente (añade un upload nuevo encima — usa `replace_record_image`). diff --git a/docs/12-glossary.md b/docs/12-glossary.md new file mode 100644 index 0000000..7d662f2 --- /dev/null +++ b/docs/12-glossary.md @@ -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..php` → endpoint `/hooks//`. + +## 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`. + +**`_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//`. 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_
-", ...]` (páginas específicas). Se configura con `set_hook_middleware`. + +**Auto-registro de hooks** — cuando creas/borras un fichero `hooks/hooks..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 `
` en un formulario que persiste a una tabla del CMS. Sintaxis: ``. 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`. diff --git a/src/adapters/claude_adapter.py b/src/adapters/claude_adapter.py index acd5d8b..f2b6c5e 100644 --- a/src/adapters/claude_adapter.py +++ b/src/adapters/claude_adapter.py @@ -586,6 +586,15 @@ class ClaudeAdapter(ModelAdapter): if force_tool: kwargs["tool_choice"] = {"type": "tool", "name": force_tool} + # Permite desactivar thinking para llamadas que no lo necesitan (p.ej. + # plan_judge: solo evalua, no razona). MiniMax M2.7 acepta el parametro + # Anthropic-style `thinking`. Aunque la implementacion no respeta del + # todo el "disabled" (a veces sigue emitiendo thinking blocks), reduce + # el consumo de tokens y deja mas espacio para el JSON output. + thinking_cfg = (config.extra or {}).get("thinking") + if thinking_cfg: + kwargs["thinking"] = thinking_cfg + # Retry con backoff sobre errores transitorios (429/503/529). El proxy # MiniMax devuelve 529 overloaded_error con cierta frecuencia bajo carga. last_exc: Exception | None = None diff --git a/src/api/routes.py b/src/api/routes.py index 3f4bd56..cec5431 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -46,6 +46,9 @@ class SendMessageRequest(BaseModel): message: str stream: bool = False 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): @@ -79,6 +82,8 @@ class SessionResponse(BaseModel): created_at: str updated_at: str agent_id: str = "acai" + # Plan activo (Fase 5.5: PlanStepper UI). None si no hay plan en curso. + current_plan: dict[str, Any] | None = None # ------------------------------------------------------------------ @@ -290,6 +295,14 @@ async def send_message( if not agent_profile: 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 orchestrator = _build_orchestrator(mcp_manager or MCPManager(), agent_profile) @@ -379,6 +392,27 @@ async def get_session(session_id: str) -> SessionResponse: if not session: raise HTTPException(status_code=404, detail="Session not found") + plan = session.metadata.get("current_plan") + plan_payload = None + if isinstance(plan, dict) and plan.get("status") == "active": + plan_payload = { + "objective": plan.get("objective", ""), + "steps": [ + { + "id": s.get("id"), + "description": s.get("description", "")[:300], + "agent_action": s.get("agent_action", "")[:200], + "files_touched": s.get("files_touched", [])[:10], + "tables_touched": s.get("tables_touched", [])[:10], + } + for s in (plan.get("steps") or []) + ], + "risks": (plan.get("risks") or [])[:10], + "cursor": plan.get("cursor", 0), + "completed_step_ids": plan.get("completed_step_ids", []), + "status": plan.get("status", "active"), + } + return SessionResponse( session_id=session.session_id, status=session.status.value, @@ -388,6 +422,7 @@ async def get_session(session_id: str) -> SessionResponse: created_at=session.created_at.isoformat(), updated_at=session.updated_at.isoformat(), 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} +# ------------------------------------------------------------------ +# POST /sessions/{id}/plan/abandon — cancela el plan activo (Fase 5.5) +# ------------------------------------------------------------------ + +@router.post("/sessions/{session_id}/plan/abandon") +async def abandon_plan(session_id: str) -> dict[str, Any]: + storage = _get_storage() + session = await storage.get_session(session_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + plan = session.metadata.get("current_plan") + if not isinstance(plan, dict) or plan.get("status") != "active": + return {"status": "no_active_plan", "session_id": session_id} + + plan["status"] = "abandoned" + session.metadata.setdefault("plan_history", []).append(plan) + session.metadata["current_plan"] = None + await storage.update_session(session) + + # Notificar al frontend via SSE. + sse = _get_sse() + try: + from ..streaming.sse import EventType as _ET + await sse.emit( + _ET.PLAN_ENDED, + {"status": "abandoned", "objective": plan.get("objective", "")}, + session_id=session_id, + ) + except Exception: + logger.warning("PLAN_ENDED emit failed on abandon", exc_info=True) + + return {"status": "abandoned", "session_id": session_id} + + # ------------------------------------------------------------------ # GET /sessions/{id}/events # ------------------------------------------------------------------ @@ -520,34 +590,85 @@ async def _load_knowledge_from_dir(docs_path: str = "docs") -> dict[str, Any]: if not docs_dir.is_dir(): return {"status": "error", "message": f"Directory not found: {docs_dir}"} - # Read all docs - docs_data: list[tuple[str, str, str, str, list[str]]] = [] # (id, title, content, summary, tags) + # Read all docs. Cada doc puede tener frontmatter YAML al inicio: + # --- + # title: "..." + # tags: [a, b] + # load_priority: 80 + # load_when: [always] + # summary: "..." + # --- + # Si no hay frontmatter, se cae al modo legacy (heuristica sobre headings). + import re as _re + import yaml as _yaml + _FM_RE = _re.compile(r"^---\s*\n(.*?)\n---\s*\n", _re.DOTALL) + + # (id, title, content, summary, tags, priority, load_when) + docs_data: list[tuple[str, str, str, str, list[str], int, list[str]]] = [] for md_file in sorted(docs_dir.glob("*.md")): - content = md_file.read_text(encoding="utf-8") + raw = md_file.read_text(encoding="utf-8") doc_id = md_file.stem + + # Defaults + title = doc_id + summary = "" + tags: list[str] = [] + priority = 50 + load_when: list[str] = [] + + # Intentar parsear frontmatter + fm_match = _FM_RE.match(raw) + if fm_match: + try: + fm = _yaml.safe_load(fm_match.group(1)) or {} + if isinstance(fm, dict): + title = str(fm.get("title", title)) + summary = str(fm.get("summary", ""))[:500] + fm_tags = fm.get("tags") or [] + if isinstance(fm_tags, list): + tags = [str(t).lower()[:30] for t in fm_tags][:10] + priority = int(fm.get("load_priority", 50)) + fm_load_when = fm.get("load_when") or [] + if isinstance(fm_load_when, list): + load_when = [str(x).lower()[:30] for x in fm_load_when][:10] + # Body sin frontmatter — no contamina embeddings ni cuenta + # como contenido en el system prompt. + content = raw[fm_match.end():] + except _yaml.YAMLError: + logger.warning("Frontmatter invalido en %s — fallback legacy", md_file.name) + content = raw + else: + content = raw + + # Fallback legacy: si no hubo frontmatter o falto algun campo, + # derivar title/summary/tags del contenido. lines = content.strip().splitlines() - title = lines[0].lstrip("#").strip() if lines else doc_id + if title == doc_id and lines: + title = lines[0].lstrip("#").strip() or doc_id + if not summary: + summary_lines: list[str] = [] + for line in lines[:30]: + stripped = line.strip() + if stripped and not stripped.startswith("#"): + summary_lines.append(stripped) + if len(" ".join(summary_lines)) > 500: + break + summary = " ".join(summary_lines)[:500] + if not tags: + for line in lines: + if line.startswith("## "): + tags.append(line.lstrip("#").strip().lower()[:30]) + tags = tags[:10] - summary_lines = [] - for line in lines[:30]: - line = line.strip() - if line and not line.startswith("#"): - summary_lines.append(line) - if len(" ".join(summary_lines)) > 500: - break - summary = " ".join(summary_lines)[:500] - - 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])) + docs_data.append((doc_id, title, content, summary, tags, priority, load_when)) # Generate embeddings in batch from ..memory.embeddings import EmbeddingService embed_service = EmbeddingService() - embed_texts = [f"{title}\n{summary}\n{content[:2000]}" for _, title, content, summary, _ in docs_data] + embed_texts = [ + f"{title}\n{summary}\n{content[:2000]}" + for _, title, content, summary, _, _, _ in docs_data + ] try: 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 loaded = [] - for i, (doc_id, title, content, summary, tags) in enumerate(docs_data): + for i, (doc_id, title, content, summary, tags, priority, load_when) in enumerate(docs_data): doc = MemoryDocument( memory_id=doc_id, memory_type=MemoryType.DOCUMENT, @@ -585,6 +706,8 @@ async def _load_knowledge_from_dir(docs_path: str = "docs") -> dict[str, Any]: content=content, summary=summary, tags=tags, + priority=priority, + load_when=load_when, ) await memory.store_document(doc) @@ -596,6 +719,8 @@ async def _load_knowledge_from_dir(docs_path: str = "docs") -> dict[str, Any]: "title": title, "chars": len(content), "tags": tags[:5], + "priority": priority, + "load_when": load_when, "embedded": embeddings[i] is not None, }) diff --git a/src/config.py b/src/config.py index ab13ba5..fa3c6c4 100644 --- a/src/config.py +++ b/src/config.py @@ -45,7 +45,19 @@ class Settings(BaseSettings): compaction_threshold_ratio: float = 0.80 context_reserve_ratio: float = 0.10 artifact_summary_max_chars: int = 2000 - knowledge_base_max_tokens: int = 30_000 + # KB inyectada como system prompt. Default 4k (antes 30k) — la doc + # oficial de M2.7 advierte que system prompts grandes degradan rendimiento. + # Top-2 docs medianos + cheat sheet ≈ 4k tokens caben con margen. + # Se sobrescribe per-agent via `agent.yaml.kb_max_tokens`. + knowledge_base_max_tokens: int = 4_000 + # Cap absoluto del numero de docs incluidos (filtro tras ranking). + kb_top_n_docs: int = 2 + # Penalty al `load_priority` de docs `load_when: [ranked]` para que + # no entren "por defecto" en el branch top_n, solo si rankean muy alto. + kb_ranked_penalty: int = 10 + # Umbral de similitud por debajo del cual el ranking no es confiable + # y se usa el `load_priority` del frontmatter como tie-break. + kb_similarity_floor: float = 0.6 working_context_max_items: int = 20 tool_raw_output_max_chars: int = 2000 conversation_recent_raw_limit: int = 2 diff --git a/src/context/engine.py b/src/context/engine.py index 8e06d98..aba9429 100644 --- a/src/context/engine.py +++ b/src/context/engine.py @@ -90,11 +90,15 @@ class ContextEngine: and ("artifact_memory" in allowed or "task_state" in allowed) ) - # 3. Knowledge base — loaded from memory store + # 3. Knowledge base — loaded from memory store. Strategy y budget + # vienen del agent profile (Fase 1 refactor): cada agente decide + # cuanto KB inyecta y como filtra (top_n / tags / cheatsheet_only / ...). if "knowledge_base" in allowed and self.memory: + kb_budget = agent.kb_max_tokens or settings.knowledge_base_max_tokens kb_section = await self._build_knowledge_base( session, - max_tokens=settings.knowledge_base_max_tokens, + agent=agent, + max_tokens=kb_budget, ) if kb_section: sections.append(kb_section) @@ -113,6 +117,7 @@ class ContextEngine: sections.append( self._build_task_state( session.current_task, + session=session, objective_override=base_user_content, resolved_context=resolved_followup_context, followup_mode=followup_mode, @@ -340,29 +345,16 @@ class ContextEngine: def _build_immutable_rules( self, session: SessionState, agent: AgentProfile ) -> ContextSection: - parts = [ - "# System Rules (Immutable)", - "", - agent.system_prompt, - "", - ] + # `agent.system_prompt` ya incluye el contrato compartido (concatenado + # por el registry al cargar). Aqui solo se añaden reglas de sesion + # cuando existen — el bloque hardcoded de "Contrato de Contexto" que + # vivia aqui se ha movido a `agents/_shared/contract.md` (Fase 3). + parts = [agent.system_prompt or ""] if session.immutable_rules: - parts.append("## Session Rules") + parts.append("\n\n## Session Rules\n") for rule in session.immutable_rules: parts.append(f"- {rule}") - parts.extend( - [ - "", - "## Contrato de Contexto", - "- Los resultados de herramientas se incluyen completos en la conversación.", - "- Los steps anteriores pueden estar compactados como resúmenes.", - "- Mantén las respuestas enfocadas en el paso actual.", - "- Si ya tienes la información necesaria, genera tu respuesta final.", - "- NO repitas llamadas a herramientas con los mismos argumentos.", - "- Responde SIEMPRE en español.", - ] - ) - content = "\n".join(parts) + content = "\n".join(p for p in parts if p) return ContextSection( section_type=ContextSectionType.IMMUTABLE_RULES, content=content, @@ -388,14 +380,30 @@ class ContextEngine: async def _build_knowledge_base( self, session: SessionState, + agent: AgentProfile, max_tokens: int, ) -> ContextSection | None: - """Load relevant knowledge documents via semantic search. + """Carga el subset relevante de la KB segun `agent.kb_load_strategy`. - Uses embeddings to find the most relevant docs for the current - task. Always includes a title index of ALL docs so the agent - knows what exists and can request more. + Estrategias soportadas: + - `none`: no inyecta KB (devuelve None). + - `cheatsheet_only`: solo docs con `load_when` que contiene "cheatsheet". + - `glossary_only`: solo docs con `load_when` que contiene "glossary". + - `planner_only`: docs con `load_when` que contiene "planner_only" | + "cheatsheet" | "glossary". Usado por el sub-loop de `acai_plan`. + - `tags`: filtra por interseccion con `agent.kb_tags`, ranking dentro. + - `top_n` (default): ranking semantico sobre docs `always`/`ranked`, + con penalty para `ranked` y tie-break por `priority` cuando la + similitud cae bajo `kb_similarity_floor`. + - `all` (legacy): comportamiento previo, todos los que quepan. + + Siempre incluye al final un listado "Other Available Docs" para que + el agente pueda pedirlos via `read_doc`. """ + strategy = (agent.kb_load_strategy or "top_n").lower() + if strategy == "none": + return None + if not self.memory: return None @@ -412,36 +420,124 @@ class ContextEngine: if not all_docs: return None - doc_map = {d.memory_id: d for d in all_docs} - - # Rank docs by semantic similarity - query = self._build_search_query(session) - ranked_ids: list[str] = [] - - if query: - ranked_ids = await self._semantic_rank(query) - - if not ranked_ids: - # No embeddings or no task — sort by size (smallest first) - ranked_ids = [ - d.memory_id - for d in sorted(all_docs, key=lambda d: len(d.content)) + # 1) Pre-filtrado segun strategy. + candidates: list[MemoryDocument] + if strategy == "cheatsheet_only": + candidates = [d for d in all_docs if "cheatsheet" in (d.load_when or [])] + elif strategy == "glossary_only": + candidates = [d for d in all_docs if "glossary" in (d.load_when or [])] + elif strategy == "planner_only": + candidates = [ + d for d in all_docs + if any(t in (d.load_when or []) for t in ("planner_only", "cheatsheet", "glossary")) ] + elif strategy == "tags": + agent_tags = {t.lower() for t in (agent.kb_tags or [])} + if not agent_tags: + candidates = [] + else: + candidates = [ + d for d in all_docs + if agent_tags.intersection({t.lower() for t in (d.tags or [])}) + ] + elif strategy == "all": + # Legacy / debugging — todos los docs. + candidates = list(all_docs) + else: + # `top_n` (default): considera docs `always` y `ranked`. Si el + # frontmatter no esta presente, los tratamos como `always` para + # no excluirlos por accidente (modo legacy). + def _eligible_top_n(d: MemoryDocument) -> bool: + lw = d.load_when or [] + if not lw: + return True # legacy: sin frontmatter → considerado + return "always" in lw or "ranked" in lw + candidates = [d for d in all_docs if _eligible_top_n(d)] - # Include ALL docs — 42K tokens fits well within model context (128K) + if not candidates: + # No hay docs aplicables al strategy. Devolvemos solo el indice + # de "Other Available Docs" para que el agente pueda pedir on-demand. + return self._build_kb_section_only_index(all_docs, full_docs=[]) + + # 2) Ranking. Para strategies "estaticas" (cheatsheet_only, glossary_only, + # planner_only) ordenamos por priority desc — son sets pequenos y el + # ranking semantico no aporta. Para `tags` y `top_n` aplicamos ranking + # semantico cuando hay query, sino priority desc. + candidate_ids = {d.memory_id for d in candidates} + ordered: list[MemoryDocument] + + if strategy in ("cheatsheet_only", "glossary_only", "planner_only"): + ordered = sorted(candidates, key=lambda d: d.priority, reverse=True) + else: + query = self._build_search_query(session) + ranked: list[tuple[str, float]] = [] + if query: + ranked = await self._semantic_rank(query) + ranked = [(did, s) for did, s in ranked if did in candidate_ids] + ranked_map = {did: s for did, s in ranked} + + def _score(d: MemoryDocument) -> tuple[float, int]: + # Score combinado: similitud + priority/100 (peso bajo). + # Si la similitud es < floor, fallback a priority pura. + sim = ranked_map.get(d.memory_id, 0.0) + prio = d.priority + # Penalty para `ranked` (no entra "por defecto") + if "ranked" in (d.load_when or []): + prio -= settings.kb_ranked_penalty + if sim < settings.kb_similarity_floor: + return (prio / 100.0, prio) + return (sim + prio / 1000.0, prio) + + ordered = sorted(candidates, key=_score, reverse=True) + + # 3) Cap por kb_max_tokens y kb_top_n. token_budget = max_tokens + top_n_cap = agent.kb_top_n or settings.kb_top_n_docs full_docs: list[MemoryDocument] = [] - for doc_id in ranked_ids: - doc = doc_map.get(doc_id) - if not doc: - continue + for doc in ordered: + if len(full_docs) >= top_n_cap and strategy not in ("cheatsheet_only", "glossary_only", "planner_only"): + break doc_tokens = estimate_tokens(doc.content) if doc_tokens <= token_budget: full_docs.append(doc) token_budget -= doc_tokens + elif not full_docs: + # Si el primer doc ya no cabe, se incluye truncado para tener + # algo. Mejor un doc parcial que ningun doc. + truncated = self._truncate_to_tokens(doc.content, token_budget) + if truncated: + full_docs.append(MemoryDocument( + memory_id=doc.memory_id, + memory_type=doc.memory_type, + namespace=doc.namespace, + title=doc.title, + content=truncated + "\n\n[...] (doc truncado)", + summary=doc.summary, + tags=doc.tags, + priority=doc.priority, + load_when=doc.load_when, + )) + break - # Build section — ALWAYS include title index of ALL docs + return self._build_kb_section_only_index(all_docs, full_docs) + + @staticmethod + def _truncate_to_tokens(text: str, max_tokens: int) -> str: + # Heuristica: ~4 chars por token. Truncamos a 4*max_tokens caracteres. + if max_tokens <= 0: + return "" + cap = max(0, max_tokens * 4) + if len(text) <= cap: + return text + return text[:cap] + + @staticmethod + def _build_kb_section_only_index( + all_docs: list[MemoryDocument], + full_docs: list[MemoryDocument], + ) -> ContextSection: + """Construye la seccion KB final: docs cargados + indice del resto.""" included_ids = {d.memory_id for d in full_docs} not_included = [d for d in all_docs if d.memory_id not in included_ids] @@ -459,9 +555,9 @@ class ContextEngine: if not_included: lines.append("## Other Available Docs") - lines.append("_Ask for any of these if you need the full content:_") + lines.append("_Pidelos con `read_doc({name: \"\"})` cuando los necesites:_") for doc in not_included: - lines.append(f"- **{doc.title}** ({doc.memory_id}): {doc.summary[:150]}") + lines.append(f"- **{doc.title}** (`{doc.memory_id}`): {(doc.summary or '')[:150]}") lines.append("") content = "\n".join(lines) @@ -472,8 +568,8 @@ class ContextEngine: token_estimate=estimate_tokens(content), ) - async def _semantic_rank(self, query: str) -> list[str]: - """Rank knowledge docs by cosine similarity to the query.""" + async def _semantic_rank(self, query: str) -> list[tuple[str, float]]: + """Rank knowledge docs by cosine similarity. Returns (doc_id, score).""" try: if not self._embed_service: self._embed_service = EmbeddingService() @@ -484,7 +580,7 @@ class ContextEngine: namespace="knowledge", top_k=50, ) - return [doc_id for doc_id, _score in results] + return list(results) except Exception as e: logger.warning("Semantic search failed: %s — loading all docs", e) @@ -572,6 +668,7 @@ class ContextEngine: def _build_task_state( self, task: TaskState, + session: SessionState | None = None, objective_override: str | None = None, resolved_context: str = "", followup_mode: str = "none", @@ -659,6 +756,37 @@ class ContextEngine: f" {marker} Step {i + 1} [{status_label}{compacted_label}]: {step.description}" ) + # Active Plan (Fase 5: tool acai_plan). Si hay un plan activo en + # session.metadata, lo renderizamos con cursor + completed marks. + if session is not None: + current_plan = session.metadata.get("current_plan") + if isinstance(current_plan, dict) and current_plan.get("status") == "active": + steps = current_plan.get("steps") or [] + cursor = int(current_plan.get("cursor", 0)) + completed_set = set(current_plan.get("completed_step_ids", [])) + lines.append("") + lines.append("## Active Plan (acai_plan)") + lines.append(f"**Objetivo**: {current_plan.get('objective', '')}") + if steps: + lines.append(f"**Cursor**: → step {min(cursor + 1, len(steps))}/{len(steps)}") + lines.append("") + for i, st in enumerate(steps): + sid = st.get("id", i + 1) + desc = st.get("description", "") + if sid in completed_set: + marker, label = "✓", "done" + elif i == cursor: + marker, label = "→", "pending" + else: + marker, label = "·", "pending" + lines.append(f" {marker} Step {i + 1} [{label}]: {desc}") + risks = current_plan.get("risks") or [] + if risks: + lines.append("") + lines.append("**Risks**:") + for r in risks[:5]: + lines.append(f"- {r}") + content = "\n".join(lines) return ContextSection( section_type=ContextSectionType.TASK_STATE, diff --git a/src/models/agent.py b/src/models/agent.py index 79a3d7b..5154613 100644 --- a/src/models/agent.py +++ b/src/models/agent.py @@ -31,6 +31,23 @@ class AgentProfile(BaseModel): ) stream_deltas: bool = True # Si emite deltas por SSE al usuario + # KB load strategy (Fase 1 refactor): controla CUANTO y QUE de la KB se + # inyecta como system prompt. Ver `_build_knowledge_base` en context/engine.py. + # - `top_n` (default): ranking semantico, top-N docs hasta agotar budget. + # - `tags`: filtra por interseccion con `kb_tags`, ranking dentro. + # - `cheatsheet_only`: solo docs con `load_when: [cheatsheet]`. + # - `glossary_only`: solo docs con `load_when: [glossary]`. + # - `planner_only`: solo docs con `load_when: [planner_only|cheatsheet|glossary]` + # (usado por la sub-llamada interna de `acai_plan`). + # - `none`: no carga KB. + # - `all` (legacy): comportamiento previo, todos los docs que quepan. + kb_load_strategy: str = "top_n" + kb_tags: list[str] = Field(default_factory=list) + kb_max_tokens: int | None = None # override per-agent del default global + kb_top_n: int | None = None # override per-agent del default global + has_planner_tool: bool = False # si expone la tool interna `acai_plan` + system_prompt_planner: str = "" # cargado de `system.planner.md` si existe + class SubAgentDefinition(BaseModel): """A runnable subagent configuration within the orchestrator.""" diff --git a/src/models/context.py b/src/models/context.py index ff2562c..f2c6f14 100644 --- a/src/models/context.py +++ b/src/models/context.py @@ -62,6 +62,10 @@ class MemoryDocument(BaseModel): content: str summary: str = "" tags: list[str] = Field(default_factory=list) + # Frontmatter YAML del doc (Fase 4 refactor). Si el doc no tiene frontmatter + # se quedan en defaults: priority=50, load_when=[]. + priority: int = 50 + load_when: list[str] = Field(default_factory=list) embedding: list[float] | None = None created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) diff --git a/src/orchestrator/agents/base.py b/src/orchestrator/agents/base.py index 056432a..c7bc2e3 100644 --- a/src/orchestrator/agents/base.py +++ b/src/orchestrator/agents/base.py @@ -19,6 +19,9 @@ from ...models.artifacts import ArtifactSummary from ...models.session import SessionState from ...models.tools import ToolExecution, ToolExecutionStatus from ...streaming.sse import SSEEmitter, EventType +from ..planner import run_planner_subloop +from ..plan_judge import judge_plan_progress +from ..tool_groups import is_plan_internal_tool, strip_namespace logger = logging.getLogger(__name__) @@ -64,6 +67,10 @@ class BaseAgent: total_output_tokens = 0 # Real conversation history: assistant messages + tool results conversation: list[dict[str, Any]] = [] + # Expuesta para que las tools internas (acai_plan) puedan resumir + # el thinking acumulado del agente principal sin que tengamos que + # pasarlo explicitamente por cada llamada a `_execute_tool`. + self._current_conversation = conversation for step in range(max_steps): # Build context with real conversation @@ -86,6 +93,11 @@ class BaseAgent: 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 = "" tool_calls: list[dict[str, Any]] = [] active_tools: dict[str, dict[str, Any]] = {} @@ -269,6 +281,18 @@ class BaseAgent: elif full_text: # Fallback (no debiera ocurrir si el adapter emite block_index). conversation.append({"role": "assistant", "content": full_text}) + # El agente termino sin mas tool calls: cerramos el plan si + # estaba activo. El judge no se llama (no hay tools que evaluar); + # el flag `no_tool_calls_this_step=True` marca todos los pendientes + # como completados. + try: + await self._auto_advance_plan_cursor( + session, + [], + no_tool_calls_this_step=True, + ) + except Exception as e: + logger.warning("[plan-advance] failed at end_turn: %s", e) break # Push del assistant turn con TODOS los blocks (thinking+text+tool_use). @@ -344,6 +368,17 @@ class BaseAgent: if tool_result_blocks: conversation.append({"role": "user", "content": tool_result_blocks}) + # Auto-avance del cursor del plan TRAS CADA STEP INTERNO (no solo + # al final del turno). Asi el frontend ve los `✓` aparecer en vivo + # conforme el agente ejecuta tools, no de golpe al final. + try: + await self._auto_advance_plan_cursor( + session, + tool_executions[exec_offset:], + ) + except Exception as e: + logger.warning("Auto-advance plan cursor failed: %s", e) + return { "content": accumulated_content, "artifacts": artifacts, @@ -374,6 +409,20 @@ class BaseAgent: logger.info("Tool call: %s(%s)", tool_name, json.dumps(arguments)[:200]) + # Intercepcion: tools internas del orquestador (Fase 5: acai_plan). + # No atraviesan MCP — se ejecutan en Python directamente. + if is_plan_internal_tool(tool_name): + raw_name = strip_namespace(tool_name) + await self.sse.emit( + EventType.TOOL_STARTED, + {"tool": raw_name, "tool_call_id": tool_call_id}, + session_id=session.session_id, + ) + if raw_name == "acai_plan": + return await self._execute_acai_plan(session, arguments, tool_call_id, tool_exec) + if raw_name == "acai_plan_advance": + return await self._execute_acai_plan_advance(session, arguments, tool_call_id, tool_exec) + start = time.monotonic() try: if self.mcp.is_running: @@ -439,25 +488,554 @@ class BaseAgent: 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]]: - """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": return [] if not self.mcp.is_running: return [] all_tools = self.mcp.get_tool_definitions() - if not self.profile.allowed_tools: - return all_tools # No filter → all tools - return [t for t in all_tools if t["name"] in self.profile.allowed_tools] + if self.profile.allowed_tools: + tool_defs = [t for t in all_tools if t["name"] in self.profile.allowed_tools] + else: + tool_defs = list(all_tools) + + if self.profile.has_planner_tool: + tool_defs.append({ + "name": "acai_plan", + "description": ( + "Genera un plan estructurado de ejecucion. Usa esta tool al recibir " + "una peticion compuesta (landing entera, tienda, refactor amplio, modulo " + "con tabla+hook+frontend). NO la uses para tareas triviales (cambiar un titulo, " + "ajustar un color, leer datos). Devuelve JSON con steps, risks, files_touched, " + "tables_touched." + ), + "input_schema": { + "type": "object", + "required": ["objective"], + "properties": { + "objective": { + "type": "string", + "description": "Descripcion en español de lo que hay que conseguir.", + }, + "scope": { + "type": "string", + "description": "Restricciones opcionales (ej. 'no toques el header').", + }, + }, + }, + }) + tool_defs.append({ + "name": "acai_plan_advance", + "description": ( + "Avanza/abandona el plan activo. Llama con `abandon: true` si el " + "usuario corrige y el plan ya no es valido, o con `next_cursor` para " + "saltar al siguiente step pendiente." + ), + "input_schema": { + "type": "object", + "properties": { + "abandon": {"type": "boolean"}, + "completed_ids": {"type": "array", "items": {"type": "integer"}}, + "next_cursor": {"type": "integer"}, + }, + }, + }) + + return tool_defs @staticmethod def _extract_mcp_output(result: dict[str, Any]) -> str: - """Extract text content from MCP tool result.""" + """Extract text content from MCP tool result. + + El modelo (MiniMax M2.7) es text-only — los blocks `type=image` no + pueden reenviarse. En lugar de descartar silenciosamente (lo que dejaba + al agente con un tool_result vacio y le hacia repetir la llamada), + emitimos un placeholder explicito que le dice que use `browser_snapshot` + si quiere inspeccionar la pagina. + """ content = result.get("content", []) if isinstance(content, list): parts: list[str] = [] + image_count = 0 for item in content: - if isinstance(item, dict) and item.get("type") == "text": + if not isinstance(item, dict): + continue + itype = item.get("type") + if itype == "text": parts.append(item.get("text", "")) + elif itype == "image": + image_count += 1 + if image_count and not parts: + return ( + f"[{image_count} imagen(es) no procesada(s) — el modelo es " + f"text-only. Para inspeccionar la pagina usa " + f"`browser_snapshot` (devuelve accessibility tree en texto). " + f"`browser_take_screenshot` solo sirve para que el usuario " + f"vea la captura, no para tu analisis.]" + ) + if image_count and parts: + parts.append( + f"\n[Adicionalmente {image_count} imagen(es) no incluida(s): " + f"el modelo no las procesa.]" + ) return "\n".join(parts) if parts else json.dumps(result) return str(content) diff --git a/src/orchestrator/engine.py b/src/orchestrator/engine.py index 8ec2633..7b01232 100644 --- a/src/orchestrator/engine.py +++ b/src/orchestrator/engine.py @@ -99,10 +99,27 @@ class OrchestratorEngine: 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 task = session.begin_task(objective=message) task.status = TaskStatus.EXECUTING + # Reset del contador de invocaciones de `acai_plan` por turno (Fase 5). + session.metadata["plan_call_count_in_turn"] = 0 + # Execute with the selected agent agent = BaseAgent( profile=self.agent_profile, diff --git a/src/orchestrator/plan_judge.py b/src/orchestrator/plan_judge.py new file mode 100644 index 0000000..e6e0eb1 --- /dev/null +++ b/src/orchestrator/plan_judge.py @@ -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: ")` — 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 diff --git a/src/orchestrator/planner.py b/src/orchestrator/planner.py new file mode 100644 index 0000000..5586937 --- /dev/null +++ b/src/orchestrator/planner.py @@ -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, + ) diff --git a/src/orchestrator/registry.py b/src/orchestrator/registry.py index ab2e8a9..db9a385 100644 --- a/src/orchestrator/registry.py +++ b/src/orchestrator/registry.py @@ -25,6 +25,15 @@ class AgentRegistry: self._agents: dict[str, AgentProfile] = {} self._metadata: dict[str, dict[str, Any]] = {} self._agents_dir = agents_dir + self._contract: str = "" + + def _load_contract(self) -> str: + """Lee el contrato compartido (`_shared/contract.md`) que se concatena + al system prompt de cada agente. Si no existe, devuelve string vacio.""" + contract_path = self._agents_dir / "_shared" / "contract.md" + if contract_path.is_file(): + return contract_path.read_text(encoding="utf-8") + return "" # ------------------------------------------------------------------ # Carga @@ -34,6 +43,7 @@ class AgentRegistry: """Escanea agents_dir y carga todos los agentes encontrados.""" self._agents.clear() self._metadata.clear() + self._contract = self._load_contract() if not self._agents_dir.is_dir(): logger.warning("Agents directory not found: %s", self._agents_dir) @@ -42,6 +52,9 @@ class AgentRegistry: for agent_dir in sorted(self._agents_dir.iterdir()): if not agent_dir.is_dir(): continue + # Skip directorios especiales (`_shared`, etc). + if agent_dir.name.startswith("_"): + continue yaml_path = agent_dir / "agent.yaml" prompt_path = agent_dir / "system.md" @@ -60,6 +73,26 @@ class AgentRegistry: agent_id = meta.get("name", agent_dir.name) + # Concatena contract.md al system prompt del agente + # (Fase 3: las reglas comunes viven en _shared/contract.md). + # La identidad del agente va PRIMERO, las reglas de ambiente + # despues — separadas por linea horizontal. + if self._contract: + if system_prompt: + system_prompt = system_prompt.rstrip() + "\n\n---\n\n" + self._contract + else: + system_prompt = self._contract + + # Planner system prompt (opcional, usado por la tool + # interna `acai_plan` cuando el agente lo expone). + # El planner tambien recibe el contract. + planner_path = agent_dir / "system.planner.md" + planner_prompt = "" + if planner_path.exists(): + planner_prompt = planner_path.read_text(encoding="utf-8") + if self._contract: + planner_prompt = planner_prompt.rstrip() + "\n\n---\n\n" + self._contract + profile = AgentProfile( role=agent_id, name=agent_id, @@ -79,6 +112,12 @@ class AgentRegistry: "task_state", ]), stream_deltas=meta.get("stream_deltas", True), + kb_load_strategy=meta.get("kb_load_strategy", "top_n"), + kb_tags=meta.get("kb_tags", []), + kb_max_tokens=meta.get("kb_max_tokens"), + kb_top_n=meta.get("kb_top_n"), + has_planner_tool=meta.get("has_planner_tool", False), + system_prompt_planner=planner_prompt, ) self._agents[agent_id] = profile diff --git a/src/orchestrator/tool_groups.py b/src/orchestrator/tool_groups.py new file mode 100644 index 0000000..936dfb9 --- /dev/null +++ b/src/orchestrator/tool_groups.py @@ -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 `__` 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 diff --git a/src/streaming/claude_format.py b/src/streaming/claude_format.py index 8518a51..8126c89 100644 --- a/src/streaming/claude_format.py +++ b/src/streaming/claude_format.py @@ -207,6 +207,29 @@ class ClaudeFormatEmitter: # Emit assistant snapshot for reconciliation self._push(session_id, self._build_assistant_snapshot(session_id)) + elif event_type == EventType.PLAN_CREATED: + # Fase 5.5: PlanStepper UI. Reenviamos los datos del plan al + # frontend como evento custom "plan.created". + self._push(session_id, { + "type": "plan.created", + "plan": data, + }) + + elif event_type == EventType.PLAN_ADVANCED: + self._push(session_id, { + "type": "plan.advanced", + "cursor": data.get("cursor", 0), + "completed_step_ids": data.get("completed_step_ids", []), + "status": data.get("status", "active"), + }) + + elif event_type == EventType.PLAN_ENDED: + self._push(session_id, { + "type": "plan.ended", + "status": data.get("status", "done"), + "objective": data.get("objective", ""), + }) + elif event_type == EventType.EXECUTION_COMPLETED: # Close any open text block self._close_text_block(session_id) diff --git a/src/streaming/sse.py b/src/streaming/sse.py index 4e79c78..a2951d1 100644 --- a/src/streaming/sse.py +++ b/src/streaming/sse.py @@ -27,6 +27,11 @@ class EventType(StrEnum): TOOL_COMPLETED = "tool.completed" SUBAGENT_ASSIGNED = "subagent.assigned" EXECUTION_COMPLETED = "execution.completed" + # Plan lifecycle (Fase 5.5: PlanStepper UI). Emitidos por BaseAgent + # cuando la tool interna `acai_plan` produce/avanza/cierra un plan. + PLAN_CREATED = "plan.created" + PLAN_ADVANCED = "plan.advanced" + PLAN_ENDED = "plan.ended" ERROR = "error" KEEPALIVE = "keepalive"