Compare commits

...

11 Commits

Author SHA1 Message Date
Jordan Diaz
f17be543ee fix: update coder agent 2026-04-04 09:02:43 +00:00
Jordan Diaz
967d5bf25d Simplificar a agente único: eliminar planner/reviewer/steps
El sistema multi-agente (planner → coder → reviewer) añadía
complejidad y causaba problemas (sobreplanificaci��n, repetición
de tareas, pérdida de contexto entre steps).

Ahora: mensaje → coder → respuesta. Como Claude Code.
- El coder decide si responder directamente o usar tools
- Sin plan intermedio, sin reviewer, sin steps
- Un solo execute() con conversación real completa
- Historial compactado con key_data al finalizar
- System prompt actualizado: asistente de desarrollo, no agente

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 23:57:08 +00:00
Jordan Diaz
1c3d67847a Reforzar reglas críticas de JS/CSS en system prompt del coder
GPT-5.4 ignora las convenciones del knowledge base (42K tokens).
Las reglas más críticas se duplican en el system prompt del coder:
- script.js y style.css son ESTÁTICOS (sin Twig)
- Valores dinámicos via data-* attributes
- CmsApi.hook() en vez de fetch
- querySelectorAll con clase raíz en vez de getElementById

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 23:49:59 +00:00
Jordan Diaz
301cef4d69 Forzar máximo 2 steps en plan: 1 coder + 1 reviewer opcional
El planner generaba 3+ steps para tareas simples causando que el
coder repitiera acciones en cada step (creaba el módulo varias veces).
Ahora el engine fusiona los steps en 1 coder con descripción combinada.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 23:47:05 +00:00
Jordan Diaz
ded0e997ed Emitir plan como bloque tool_use visible en el frontend
El plan del planner se emite como tool_use(name="plan") + tool_result
con los steps formateados. El frontend lo renderiza como un bloque
colapsable de herramienta con el plan de ejecución.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 23:32:45 +00:00
Jordan Diaz
2d5cc4e10a Knowledge completo en contexto: 50K token budget
Budget de 15K dejaba fuera docs críticos (css-js-conventions,
hooks-and-api). Con 42K tokens totales y 128K de contexto,
incluir todo es la mejor estrategia.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 23:17:58 +00:00
Jordan Diaz
0bfc8e2b97 Planner: limitar plans a 2-3 steps, evitar sobreplanificación
El planner generaba 6+ steps para tareas simples como crear un módulo,
causando que el coder repitiera acciones o creara el módulo dos veces.

- Reglas explícitas: máximo 2-3 steps
- Crear módulo = 1 step coder (archivos + add + config)
- Explorar = 1 step coder
- Reviewer solo para tareas complejas

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 23:08:58 +00:00
Jordan Diaz
bcfaeb7e39 Conversación continua: historial como mensajes user/assistant reales
El agenticSystem ahora es conversacional — recuerda lo dicho en
mensajes anteriores de la misma sesión.

- engine.py: direct_response guarda en task_history con formato
  "User: X → Agent: Y"
- context/engine.py: _build_messages() reconstruye el task_history
  como pares user/assistant reales en el array de messages, antes
  del mensaje actual. El modelo ve una conversación completa.
- base.py: planner/reviewer no emiten AGENT_DELTA al frontend
  (su output es interno, no para el usuario)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 22:56:23 +00:00
Jordan Diaz
151596a52d Fix: no emitir deltas del planner/reviewer al frontend
El planner genera JSON interno que no debe mostrarse al usuario.
Solo coder y collector emiten AGENT_DELTA al stream.

Para direct_response, el engine emite como agent=coder para que
el ClaudeFormatEmitter lo procese correctamente.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:53:00 +00:00
Jordan Diaz
56c8a9c066 Planner: respuesta directa para saludos y preguntas simples
El planner ahora puede devolver direct_response en vez de un plan
cuando el mensaje no requiere herramientas (saludos, preguntas
generales, conversación casual).

- planner.py: prompt actualizado con formato direct_response
- engine.py: si planner devuelve string, emitir como texto y
  completar sin ejecutar steps

"hola" → "¡Hola! ¿En qué puedo ayudarte hoy?" (0 steps, 0 tools)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:48:28 +00:00
Jordan Diaz
df7dfbc280 SSE en formato Claude Code CLI via ?format=claude
Nuevo ClaudeFormatEmitter traduce eventos nativos al formato exacto
que produce Claude Code CLI: content_block_start/delta/stop, tool_result,
assistant snapshots, result con usage/cost, done.

- streaming/claude_format.py: ClaudeFormatEmitter + DualEmitter
- base.py: enriquecer eventos con tool_call_id, raw_output, tool_arguments
- engine.py: usage/cost en EXECUTION_COMPLETED
- routes.py: ?format=claude en /sessions/{id}/stream
- main.py: DualEmitter wiring (emite a ambos formatos)

El frontend puede consumir el stream sin cambios — mismos event types
que Claude Code CLI. El formato nativo sigue disponible para el dashboard.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:48:07 +00:00
15 changed files with 935 additions and 568 deletions

View File

@@ -4,6 +4,13 @@
El atributo `data-field-label` se convierte a variable removiendo espacios y caracteres especiales (minúsculas). El atributo `data-field-label` se convierte a variable removiendo espacios y caracteres especiales (minúsculas).
Reglas obligatorias:
- Todo elemento editable con `data-field-type` debe incluir también `data-field-label`
- Si falta `data-field-label`, el builder puede generar variables temporales o incorrectas y el módulo queda mal configurado
- Usa labels descriptivos y estables; no dejes labels vacíos ni genéricos como "Campo" o "Texto"
Evita también en `index-base.tpl` las clases Tailwind con valores arbitrarios como `text-[44px]`, `font-['Cinzel']` o `leading-[1.1]`. En este stack pueden romper el parseo/compilación del template. Muévelas a `style.css`.
| Label | Variable | | Label | Variable |
|-------|----------| |-------|----------|
| Categoría Noticia | `categoranoticia` | | Categoría Noticia | `categoranoticia` |

View File

@@ -72,12 +72,29 @@ Patrón para colores configurables por el usuario:
JavaScript scopeado al módulo usando `section_id`: JavaScript scopeado al módulo usando `section_id`:
This is the default and expected place for module JavaScript.
Do NOT embed `<script>` tags directly inside `index-base.tpl` for normal module behavior.
Keep `index-base.tpl` for HTML/Twig markup and put interactive logic in `script.js`.
`script.js` is a static file: do NOT use Twig syntax inside it.
Do NOT write `{{ section_id }}`, `{{ variable }}`, `{% if ... %}`, or builder attributes inside `script.js`.
If JavaScript needs dynamic values, pass them from `index-base.tpl` through `data-*` attributes:
```html
<section
class="buscador-apartados"
data-section-id="{{ section_id }}"
data-hook-endpoint="/hooks/buscadorapartados_hjd8s/">
</section>
```
```js ```js
const section = document.getElementById('{{ section_id }}'); document.querySelectorAll('.buscador-apartados').forEach((section) => {
if (section) { const sectionId = section.dataset.sectionId;
const hookEndpoint = section.dataset.hookEndpoint;
const buttons = section.querySelectorAll('.btn'); const buttons = section.querySelectorAll('.btn');
// ... // ...
} });
``` ```
### CmsApi (Client-Side) ### CmsApi (Client-Side)
@@ -88,6 +105,25 @@ CmsApi.hook('/hooks/module_id/', { action: 'getData', id: 123 }, function(respon
}); });
``` ```
If you are calling a hook that belongs to the current module, the endpoint must use the real module id:
```js
// Module folder: template/estandar/modulos/buscadorapartados_hjd8s/
CmsApi.hook('/hooks/buscadorapartados_hjd8s/', { termino: 'vela' }, function(response) {
console.log(response);
});
```
Do not try to build this endpoint with Twig inside `script.js`. Put the final endpoint in a `data-hook-endpoint` attribute in `index-base.tpl` if needed.
### Module Styles (`style.css`)
`style.css` is also a static file.
Do NOT use Twig syntax or builder attributes inside it.
Do NOT write selectors or values that depend on `{{ section_id }}`.
Scope styles with the module root class instead of dynamic Twig ids.
### Cuándo usar Vue 3 ### Cuándo usar Vue 3
Usar Vue 3 CDN cuando la lógica requiera: Usar Vue 3 CDN cuando la lógica requiera:

View File

@@ -2,7 +2,39 @@
## Hooks ## Hooks
Hooks son archivos PHP en `hooks/` que ejecutan lógica server-side. También pueden estar dentro de un módulo en `template/estandar/modulos/<module-id>/hook.php`. Hooks son archivos PHP que ejecutan lógica server-side. Pueden existir en dos sitios:
- Hooks globales en `hooks/hooks.<hook-id>.php`
- Hooks propios de módulo en `template/estandar/modulos/<module-id>/hook.php`
### Tipos de hooks
**1. Hook global**
- Archivo: `hooks/hooks.<hook-id>.php`
- Endpoint: `/hooks/<hook-id>/`
- Úsalo cuando la lógica se reutiliza entre módulos, páginas o formularios
**2. Hook propio de módulo**
- Archivo: `template/estandar/modulos/<module-id>/hook.php`
- Endpoint: `/hooks/<module-id>/`
- Úsalo cuando la lógica pertenece solo a ese módulo
Ejemplos:
- `hooks/hooks.buscar_barcos.php` -> `/hooks/buscar_barcos/`
- `template/estandar/modulos/hero_banner/hook.php` -> `/hooks/hero_banner/`
Regla práctica:
- Si el hook solo sirve a un módulo, créalo dentro del módulo
- Si varias piezas del proyecto lo van a consumir, créalo como hook global
## Reglas obligatorias para hooks
- Un hook debe devolver datos con `return [...]`
- No uses `echo json_encode(...)`
- No uses `exit`
- Para leer parámetros, usa `$_REQUEST[...]` o las variables ya inyectadas por el sistema
- En hooks, usa `CmsApi::get()` o `CocoDB::get()` como primera opción
- No uses `CocoDB::getInstance()` salvo necesidad real muy excepcional
- No escribas SQL manual con `prepare()/bind_param()` salvo que no exista forma razonable de resolverlo con `CmsApi` o `CocoDB`
### Estructura de un Hook ### Estructura de un Hook
@@ -34,6 +66,10 @@ No usar X-Hooks-Token en desarrollo local.
### Cómo Llamar Hooks ### Cómo Llamar Hooks
La referencia siempre se hace con el endpoint `/hooks/<id>/`:
- Para hooks globales, `<id>` es el nombre lógico del hook sin `hooks.` ni `.php`
- Para hooks de módulo, `<id>` es el `module-id` de la carpeta del módulo
**Desde HTML (recomendado para módulos):** **Desde HTML (recomendado para módulos):**
```html ```html
<hook result="myVar" endpoint="/hooks/module_id/" :param1="value1" :param2="'string'"></hook> <hook result="myVar" endpoint="/hooks/module_id/" :param1="value1" :param2="'string'"></hook>
@@ -53,6 +89,14 @@ CmsApi.hook('/hooks/mimodulo/', {param1: 100, param2: 'texto'}, (data) => {
}); });
``` ```
**Ejemplo real para hook de módulo:**
```js
// Módulo: template/estandar/modulos/buscadorapartados_hjd8s/
CmsApi.hook('/hooks/buscadorapartados_hjd8s/', { termino: 'vela' }, (data) => {
console.log(data);
});
```
**Desde otro Hook PHP:** **Desde otro Hook PHP:**
```php ```php
<?php <?php
@@ -71,37 +115,49 @@ API server-side para operaciones de base de datos. Disponible en todos los hooks
### Read — `CmsApi::get()` ### Read — `CmsApi::get()`
## IMPORTANTE : Las tablas y nombres de campos puedes extraerlas de los esquemas en cms/data/schemas/<nombre_de_tabla>.ini.php
```php ```php
// Todos los registros // Todos los registros
$products = CmsApi::get('productos'); $products = CmsApi::get("productos");
// Con condición WHERE // Con condición WHERE en string
$active = CmsApi::get('productos', ['active' => 1]); $active = CmsApi::get("productos", "active=1");
// Con orden y límite // Con orden y límite
$latest = CmsApi::get('noticias', [], 'fecha DESC', 5); $latest = CmsApi::get("noticias", "", "fecha DESC", 5);
// Con condición string // Con condición string
$activos = CmsApi::get('productos', 'activo=1'); $activos = CmsApi::get("productos", "activo=1");
// Condición compleja como array // Condición compleja
$caros = CmsApi::get('productos', [ $caros = CmsApi::get("productos", "precio > 100");
["column" => "precio", "operator" => ">", "value" => 100]
]);
// Múltiples condiciones (AND) // Múltiples condiciones (AND)
$resultados = CmsApi::get('productos', [ $resultados = CmsApi::get("productos", "activo = 1 AND stock > 0");
["column" => "activo", "operator" => "=", "value" => 1],
["column" => "stock", "operator" => ">", "value" => 0]
]);
// Con operadores // Con operadores
$expensive = CmsApi::get('productos', ['precio' => ['>=' => 100]]); $expensive = CmsApi::get("productos", "precio >= 100");
$search = CmsApi::get('productos', ['nombre' => ['LIKE' => '%keyword%']]); $search = CmsApi::get("productos", "nombre LIKE '%keyword%'");
$inList = CmsApi::get('productos', ['categoria_num' => ['IN' => [1, 2, 3]]]); $inList = CmsApi::get("productos", "categoria_num IN (1, 2, 3)");
#### Opciones de `get()`
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `uploads` | bool | `true` | Incluir datos de upload fields |
| `relations` | bool/array | `true` | Resolver foreign keys. Array para limitar: `['category']` |
| `relationsDepth` | int | 2 | Profundidad de relaciones anidadas |
| `translates` | string | current lang | Código de idioma |
| `groupBy` | string | null | GROUP BY clause |
| `aggregates` | array | `[]` | Funciones de agregación |
| `onlyFields` | array | null | Seleccionar solo campos específicos |
| `debug` | bool | false | Mostrar SQL query |
| `redis` | bool | null | Forzar cache Redis |
| `redis_expire` | int | 60 | TTL de cache Redis (segundos) |
// Con opciones // Con opciones
$datos = CmsApi::get('productos', '', '', '', [ $datos = CmsApi::get("productos", "", "", "", [
'translates' => true, 'translates' => true,
'uploads' => true, 'uploads' => true,
'relations' => true, 'relations' => true,
@@ -111,6 +167,8 @@ $datos = CmsApi::get('productos', '', '', '', [
### Insert — `CmsApi::insert()` ### Insert — `CmsApi::insert()`
## IMPORTANTE : Las tablas y nombres de campos puedes extraerlas de los esquemas en cms/data/schemas/<nombre_de_tabla>.ini.php
```php ```php
// Un registro // Un registro
CmsApi::insert('contacto', [ CmsApi::insert('contacto', [
@@ -131,8 +189,19 @@ CmsApi::insert('productos',
); );
``` ```
#### Opciones de insert
| Option | Description |
|--------|-------------|
| `forceNum` | Permite setear el campo `num` manualmente |
| `ignoreSchema` | Saltar validación de schema |
| `ignoreFields` | Array de campos a ignorar |
### Update — `CmsApi::update()` ### Update — `CmsApi::update()`
## IMPORTANTE : Las tablas y nombres de campos puedes extraerlas de los esquemas en cms/data/schemas/<nombre_de_tabla>.ini.php
```php ```php
// Con condición string // Con condición string
CmsApi::update('productos', ["precio" => 150], "num=1"); CmsApi::update('productos', ["precio" => 150], "num=1");
@@ -147,8 +216,19 @@ CmsApi::update('productos',
CmsApi::update('productos', ["activo" => 0], "precio < 50"); CmsApi::update('productos', ["activo" => 0], "precio < 50");
``` ```
#### Opciones de update
| Option | Description |
|--------|-------------|
| `forceNum` | Permite setear el campo `num` manualmente |
| `ignoreSchema` | Saltar validación de schema |
| `ignoreFields` | Array de campos a ignorar |
### Delete — `CmsApi::delete()` ### Delete — `CmsApi::delete()`
## IMPORTANTE : Las tablas y nombres de campos puedes extraerlas de los esquemas en cms/data/schemas/<nombre_de_tabla>.ini.php
```php ```php
CmsApi::delete('productos', "num=5"); CmsApi::delete('productos', "num=5");
@@ -187,134 +267,24 @@ CmsApi.get('tableName', { where: conditions }, function(records) {
Capa de abstracción de BD de bajo nivel usada internamente por CmsApi. Usar directamente desde hooks cuando necesites más control. Capa de abstracción de BD de bajo nivel usada internamente por CmsApi. Usar directamente desde hooks cuando necesites más control.
Para búsquedas y lecturas habituales, prioriza:
1. `CmsApi::get()`
2. `CocoDB::get()`
3. SQL manual solo si de verdad no hay alternativa razonable
### `CocoDB::get($table, $where, $order, $limit, $options)` ### `CocoDB::get($table, $where, $order, $limit, $options)`
## Funcionalidad exactamente igual a CmsApi::get ( ver referencia CmsApi::get )
```php
// Básico
$records = CocoDB::get('productos', ['activo' => 1], 'orden ASC', 10);
// Where con operadores avanzados
$records = CocoDB::get('productos', [
['column' => 'precio', 'value' => 100, 'operator' => '>='],
['column' => 'categoria_num', 'value' => [1, 2, 3], 'operator' => 'IN'],
]);
// Condiciones OR
$records = CocoDB::get('productos', [
['column' => 'nombre', 'value' => '%keyword%', 'operator' => 'LIKE'],
['column' => 'descripcion', 'value' => '%keyword%', 'operator' => 'LIKE', 'or' => true],
]);
// NOT
$records = CocoDB::get('productos', [
['column' => 'estado', 'value' => 'borrador', 'operator' => '=', 'not' => true],
]);
// IS NULL
$records = CocoDB::get('productos', [
['column' => 'fecha_baja', 'value' => '', 'operator' => 'IS NULL'],
]);
// Limit con offset
$records = CocoDB::get('productos', [], 'num DESC', ['limit' => 10, 'offset' => 20]);
```
#### Opciones de `get()`
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `uploads` | bool | `true` | Incluir datos de upload fields |
| `relations` | bool/array | `true` | Resolver foreign keys. Array para limitar: `['category']` |
| `relationsDepth` | int | 2 | Profundidad de relaciones anidadas |
| `translates` | string | current lang | Código de idioma |
| `groupBy` | string | null | GROUP BY clause |
| `aggregates` | array | `[]` | Funciones de agregación |
| `onlyFields` | array | null | Seleccionar solo campos específicos |
| `debug` | bool | false | Mostrar SQL query |
| `redis` | bool | null | Forzar cache Redis |
| `redis_expire` | int | 60 | TTL de cache Redis (segundos) |
### `CocoDB::insertRecords($table, $records, $functions, $options)` ### `CocoDB::insertRecords($table, $records, $functions, $options)`
## Funcionalidad exactamente igual a CmsApi::insert ( ver referencia CmsApi::insert )
```php
// Un registro
$count = CocoDB::insertRecords('contacto', [
'nombre' => 'John',
'email' => 'john@example.com',
]);
// Usar mysql_insert_id() para obtener el nuevo num
// Múltiples
$count = CocoDB::insertRecords('productos', [
['nombre' => 'Product A', 'precio' => 10],
['nombre' => 'Product B', 'precio' => 20],
]);
```
#### Opciones de insert/update
| Option | Description |
|--------|-------------|
| `forceNum` | Permite setear el campo `num` manualmente |
| `ignoreSchema` | Saltar validación de schema |
| `ignoreFields` | Array de campos a ignorar |
### `CocoDB::updateRecords($table, $records, $where, $functions, $options)` ### `CocoDB::updateRecords($table, $records, $where, $functions, $options)`
## Funcionalidad exactamente igual a CmsApi::update ( ver referencia CmsApi::update )
```php
CocoDB::updateRecords('productos',
['precio' => 29.99, 'activo' => 1],
['num' => 42]
);
// Con operador en where
CocoDB::updateRecords('productos',
['activo' => 0],
[['column' => 'stock', 'value' => 0, 'operator' => '<=']]
);
```
### `CocoDB::deleteRecords($table, $where, $options)` ### `CocoDB::deleteRecords($table, $where, $options)`
## Funcionalidad exactamente igual a CmsApi::delete ( ver referencia CmsApi::delete )
```php
CocoDB::deleteRecords('productos', ['num' => 42]);
CocoDB::deleteRecords('logs', [
['column' => 'fecha', 'value' => '2024-01-01', 'operator' => '<']
]);
```
### Parámetro `$functions`
Permite aplicar funciones MySQL a valores durante insert/update:
```php
CocoDB::insertRecords('logs', [
'mensaje' => 'Login exitoso',
'fecha' => '',
], [
'fecha' => 'NOW()',
]);
```
### Where Clause — Formatos
**Simple (key-value):**
```php
['campo' => 'valor'] // campo = 'valor'
```
**Avanzado (array de condiciones):**
```php
[
'column' => 'field_name',
'value' => 'match_value',
'operator' => '=', // =, !=, <, >, <=, >=, LIKE, IN, IS NULL
'or' => false, // OR en vez de AND
'not' => false, // Negar la condición
'raw_key' => false, // Saltar check de existencia de columna
]
```
--- ---
@@ -379,12 +349,15 @@ return [
<p>Total: ${{ precio.total }}</p> <p>Total: ${{ precio.total }}</p>
``` ```
En este ejemplo, el endpoint usa `calcular_precio` porque el archivo vive en:
`template/estandar/modulos/calcular_precio/hook.php`
### Hook con Operaciones de BD ### Hook con Operaciones de BD
```php ```php
<?php <?php
// hook.php del módulo "procesar_compra" // hook.php del módulo "procesar_compra"
$producto = CmsApi::get('productos', "num=$producto_id"); $producto = CmsApi::get("productos", "num=" . $producto_id);
if (empty($producto)) { if (empty($producto)) {
return ["success" => false, "message" => "Producto no encontrado"]; return ["success" => false, "message" => "Producto no encontrado"];
@@ -402,7 +375,7 @@ CmsApi::insert('ventas', [[
]], [], ['return_last_id' => true]); ]], [], ['return_last_id' => true]);
// Actualizar stock // Actualizar stock
$stock = CmsApi::get('stocks', "producto_num=$producto_id"); $stock = CmsApi::get("stocks", "producto_num=" . $producto_id);
if (!empty($stock)) { if (!empty($stock)) {
CmsApi::update('stocks', CmsApi::update('stocks',
["cantidad" => $stock[0]['cantidad'] - $cantidad], ["cantidad" => $stock[0]['cantidad'] - $cantidad],

View File

@@ -73,6 +73,20 @@ PHP files that execute server-side logic. Triggered by:
- JavaScript: `CmsApi.hook('/hooks/module_id/', {param: value}, callback)` - JavaScript: `CmsApi.hook('/hooks/module_id/', {param: value}, callback)`
- Form action: via `c-form` attribute - Form action: via `c-form` attribute
There are two valid hook locations:
- Global hooks in `hooks/hooks.<hook-id>.php` for reusable/shared server-side logic
- Module-specific hooks in `template/estandar/modulos/<module-id>/hook.php` for logic owned by a single module
How to reference them:
- Global hook `hooks/hooks.calcular_precio.php` -> endpoint `/hooks/calcular_precio/`
- Module hook `template/estandar/modulos/hero_banner/hook.php` -> endpoint `/hooks/hero_banner/`
- Module hook `template/estandar/modulos/buscadorapartados_hjd8s/hook.php` -> endpoint `/hooks/buscadorapartados_hjd8s/`
Rule of thumb:
- If the logic is only used by one module, prefer that module's `hook.php`
- If the logic will be reused by several modules/pages, create a global hook in `hooks/`
- Return arrays from hooks; do not use `echo json_encode(...)` or `exit`
See [docs/hooks-and-api.md](docs/hooks-and-api.md) for usage. See [docs/hooks-and-api.md](docs/hooks-and-api.md) for usage.
## Database Access ## Database Access
@@ -103,17 +117,18 @@ Do NOT modify web-base files — they are shared across all projects.
## Critical Rules ## Critical Rules
1. **Before working with any area (hooks, modules, templates, CSS/JS, etc.), read the corresponding documentation in `docs/` first.** Do not guess or assume — always consult the docs before taking action. 1. **Before working with any area (hooks, modules, templates, CSS/JS, etc.), read the corresponding documentation in `docs/` first.** Do not guess or assume — always consult the docs before taking action.
2. **NEVER use `mkdir` to create directories.** Instead, use the `Write` tool to create the first file inside the directory — this creates parent directories automatically. For example, to create a new module, directly write the `index-base.tpl` file. 2. **NEVER use `mkdir` to create directories.** Instead, use `acai-write` to create the first file inside the directory — this creates parent directories automatically. For example, to create a new module, directly write the `index-base.tpl` file.
3. Only edit `index-base.tpl` in modules — `index.tpl`, `index-twig.tpl`, and `builder.json` are auto-generated 3. Only edit `index-base.tpl` in modules — `index.tpl`, `index-twig.tpl`, and `builder.json` are auto-generated
3. **Edit `index-base.tpl` using `acai_write` or `acai_line_replace`** — the server compiles automatically when the file is saved. No need to call `compile_module` manually. 4. Editing or creating any `index-base.tpl` through `acai-write` or `acai-line-replace` triggers automatic compilation. `compile_module` is only for manual recovery when you need to force a recompile without changing the file.
4. Use Twig **filters** (with `|`), never Twig functions 5. `script.js` and `style.css` are static files — do NOT use Twig syntax inside them. Pass dynamic values from `index-base.tpl` via `data-*` attributes.
5. Table names without `cms_` prefix everywhere 6. Use Twig **filters** (with `|`), never Twig functions
6. Primary key is `num`, never `id` 7. Table names without `cms_` prefix everywhere
7. Upload fields are arrays — access with `[0].urlPath` 8. Primary key is `num`, never `id`
8. Tailwind CSS as primary styling, custom CSS scoped with BEM when needed 9. Upload fields are arrays — access with `[0].urlPath`
9. Twig concatenation uses `~` operator: `'value=' ~ variable` 10. Tailwind CSS as primary styling, custom CSS scoped with BEM when needed
10. `enlace` (link) fields already include slashes — **NEVER modify an existing enlace** unless explicitly asked 11. Twig concatenation uses `~` operator: `'value=' ~ variable`
11. **NEVER modify the `controlador` field** of existing records — it defines whether a page is Builder or Standard 12. `enlace` (link) fields already include slashes — **NEVER modify an existing enlace** unless explicitly asked
13. **NEVER modify the `controlador` field** of existing records — it defines whether a page is Builder or Standard
## MCP Tools ## MCP Tools
@@ -122,8 +137,8 @@ This project has MCP tools for managing modules, records, media, and more. **Bef
See [docs/mcp-tools-reference.md](docs/mcp-tools-reference.md) for the complete list of available tools and step-by-step workflows. See [docs/mcp-tools-reference.md](docs/mcp-tools-reference.md) for the complete list of available tools and step-by-step workflows.
Key workflows: Key workflows:
- **Create module**: Read [docs/module-creation-guide.md](docs/module-creation-guide.md) first → Write `index-base.tpl` via `acai_write``add_module_to_record` (returns sectionId) → `set_module_config_vars` (returns uploadFields) → images via uploadFields - **Create module**: Read [docs/module-creation-guide.md](docs/module-creation-guide.md) first → create files with `acai-write` / refine with `acai-line-replace` → automatic compile on `index-base.tpl``add_module_to_record` (returns sectionId) → `set_module_config_vars` (returns uploadFields) → images via uploadFields. Use `compile_module` only if you need a manual recompile without editing the file.
- **Edit module**: read vars → edit `index-base.tpl` with `acai_write` or `acai_line_replace` (server compiles automatically) - **Edit module**: `acai-view``acai-line-replace` (or `acai-write` for full rewrites) → automatic compile on `index-base.tpl`
- **Add images**: use `uploadFields` from `set_module_config_vars` response → `upload_record_image` - **Add images**: use `uploadFields` from `set_module_config_vars` response → `upload_record_image`
- **Generate images**: `generate_image``upload_record_image` with returned URL - **Generate images**: `generate_image``upload_record_image` with returned URL

View File

@@ -4,11 +4,15 @@
| Tool | Categoría | Acción | | Tool | Categoría | Acción |
|------|-----------|--------| |------|-----------|--------|
| `create_module` | Módulos | (Legacy) Alternativa para crear módulo — preferir acai_write | | `compile_module` | Módulos | Recompilación manual de rescate cuando necesitas forzar la compilación sin editar el archivo |
| `compile_module` | Módulos | Compila módulo tras editar index-base.tpl |
| `check_module` | Módulos | Preview de cómo renderiza un módulo | | `check_module` | Módulos | Preview de cómo renderiza un módulo |
| `check_module_usage` | Módulos | Qué páginas usan un módulo | | `check_module_usage` | Módulos | Qué páginas usan un módulo |
| `set_module_example_data` | Módulos | Datos de ejemplo para editor visual | | `acai-view` | Archivos | Lee un archivo del proyecto por tramos |
| `acai-glob` | Archivos | Busca rutas de archivos por patrón |
| `acai-grep` | Archivos | Busca texto en archivos del proyecto |
| `acai-line-replace` | Archivos | Reemplaza un bloque concreto en un archivo existente |
| `acai-write` | Archivos | Crea o reescribe un archivo completo. Antes de usarlo, lee la doc correspondiente según el tipo de archivo (`module-creation-guide`, `builder-fields`, `css-js-conventions`, `hooks-and-api`) |
| `acai-delete` | Archivos | Borra un archivo del proyecto |
| `list_page_modules` | Registros | Lista módulos de una página | | `list_page_modules` | Registros | Lista módulos de una página |
| `add_module_to_record` | Registros | Añade módulo a una página | | `add_module_to_record` | Registros | Añade módulo a una página |
| `remove_module_from_record` | Registros | Elimina módulo de una página | | `remove_module_from_record` | Registros | Elimina módulo de una página |
@@ -30,29 +34,43 @@
| `refresh_acai_token` | Auth | Renovar token JWT expirado | | `refresh_acai_token` | Auth | Renovar token JWT expirado |
| `navigate_browser` | Navegación | Navegar el browser del frontend a una URL | | `navigate_browser` | Navegación | Navegar el browser del frontend a una URL |
| `save_project_styles` | Proyecto | Guardar resumen de estilos en docs/project-styles.md | | `save_project_styles` | Proyecto | Guardar resumen de estilos en docs/project-styles.md |
| `orchestrate_task` | Orquestador | Guía paso a paso para tareas complejas |
| `rollback_git` | Git | Recuperar cambios de git remoto | | `rollback_git` | Git | Recuperar cambios de git remoto |
## Flujos de trabajo ## Flujos de trabajo
### Crear un módulo nuevo desde cero ### Crear un módulo nuevo desde cero
1. `acai_write`Escribe `index-base.tpl` en `template/estandar/modulos/NOMBRE/`. El server crea la carpeta si no existe, compila y genera todos los archivos derivados (index-twig.tpl, index.tpl, builder.json, screenshots) 1. `acai-write`Crea `index-base.tpl`, `style.css`, `script.js` y cualquier hook necesario con rutas relativas al proyecto
2. `add_module_to_record` — Añade el módulo a una página (tabla padre, ej: `apartados`) 2. `acai-write` o `acai-line-replace` compilan automáticamente al tocar `index-base.tpl`
3. `set_module_config_vars` — Rellena las variables con contenido (textos, colores, opciones). **OBLIGATORIO** — sin esto el módulo no muestra nada. Devuelve: 3. `add_module_to_record` — Añade el módulo a una página (tabla padre, ej: `apartados`)
4. `set_module_config_vars` — Rellena las variables con contenido (textos, colores, opciones). **OBLIGATORIO** — sin esto el módulo no muestra nada. Devuelve:
- `configVars`: mapa de variables → recordNums - `configVars`: mapa de variables → recordNums
- `uploadFields`: mapa de variables upload → `{ fieldName, recordNum }`**usa estos directamente** para subir imágenes sin necesidad de leer builder.json - `uploadFields`: mapa de variables upload → `{ fieldName, recordNum }`**usa estos directamente** para subir imágenes sin necesidad de leer builder.json
- Para vars multi con uploads: `uploadFields["varName.subVarName"]` es un array con `[{ index, fieldName, recordNum }]` - Para vars multi con uploads: `uploadFields["varName.subVarName"]` es un array con `[{ index, fieldName, recordNum }]`
4. Para imágenes: `generate_image` o `upload_record_image` usando el `recordNum` y `fieldName` del `uploadFields` devuelto en el paso 3 5. Para imágenes: `generate_image` o `upload_record_image` usando el `recordNum` y `fieldName` del `uploadFields` devuelto en el paso 4
5. Verificar con `check_module` o recargando la página 6. Verificar con `check_module` o recargando la página
> **Nota:** `create_module` es una alternativa legacy que hace lo mismo pero con menos control sobre el contenido del template.
### Editar un módulo existente ### Editar un módulo existente
1. `get_module_config_vars` — Leer el estado actual del módulo (variables, recordNums) 1. `get_module_config_vars` — Leer el estado actual del módulo (variables, recordNums)
2. Editar `index-base.tpl` con `acai_write` o `acai_line_replace` — el server compila automáticamente al guardar 2. `acai-view` — Leer solo el tramo de `index-base.tpl` que se va a modificar
3. Si cambias variables: `set_module_config_vars` para actualizar valores 3. `acai-line-replace` — Editar el bloque concreto. Usa `acai-write` solo si el archivo es nuevo o el cambio es masivo
4. La compilación es automática tras cada edición de `index-base.tpl`
5. Si cambias variables: `set_module_config_vars` para actualizar valores
### Editar archivos del proyecto con bajo consumo de tokens
1. `acai-view` — Leer el archivo o un rango de líneas
2. `acai-glob` — Encontrar archivos relevantes por ruta cuando no conoces el path exacto
3. `acai-grep` — Buscar texto o atributos concretos dentro de archivos del proyecto
4. `acai-line-replace` — Reemplazar el bloque exacto en archivos existentes
5. `acai-write` — Crear archivos nuevos o reescribirlos por completo si es necesario
6. `acai-delete` — Borrar archivos solo cuando sea explícitamente necesario
Reglas:
- Usa siempre rutas relativas al proyecto
- No edites `index.tpl`, `index-twig.tpl` ni `builder.json` — son auto-generados
- Tras editar cualquier `index-base.tpl` con las file tools, la compilación se ejecuta automáticamente
### Añadir/modificar imágenes de un módulo ### Añadir/modificar imágenes de un módulo
@@ -68,13 +86,13 @@
- `tableName`: `"builder_custom"` (siempre sin cms_) - `tableName`: `"builder_custom"` (siempre sin cms_)
- `recordId`: el recordNum del paso 1 - `recordId`: el recordNum del paso 1
- `fieldName`: el campo de relations del builder.json (ej: `image1`) - `fieldName`: el campo de relations del builder.json (ej: `image1`)
- `imageUrl`: URL accesible desde Docker (ej: `http://localhost/cms/uploads/...`) - `imageUrl`: usa la URL recomendada por la tool que generó/subió la imagen. En Forge, si `generate_image` devuelve `uploadUrl` o `fullUrl`, priorízala frente a `dockerUrl`
### Generar imagen con IA ### Generar imagen con IA
1. `generate_image` con prompt descriptivo + style (photographic, digital-art, minimalist...) 1. `generate_image` con prompt descriptivo + style (photographic, digital-art, minimalist...)
2. La imagen se guarda en `cms/uploads/generated/` y devuelve `dockerUrl` 2. La imagen se guarda en `cms/uploads/generated/` y devuelve una URL local de preview (`dockerUrl`) y, cuando aplica, una URL recomendada para subida (`uploadUrl` / `fullUrl`)
3. Usar esa `dockerUrl` con `upload_record_image` para asignarla a un módulo 3. Para `upload_record_image`, usa la URL recomendada por la tool. En Forge, prioriza `uploadUrl` o `fullUrl` si están presentes
### Gestionar registros de una tabla ### Gestionar registros de una tabla
@@ -85,10 +103,9 @@
### Explorar el sitio ### Explorar el sitio
1. `orchestrate_task` con workflow `explore_site` — Guía para entender la estructura 1. `list_page_modules` — Ver qué módulos tiene cada página
2. `list_page_modules` — Ver qué módulos tiene cada página 2. `get_module_config_vars` — Ver los datos de cada módulo
3. `get_module_config_vars` — Ver los datos de cada módulo 3. `check_module` — Preview de cómo renderiza
4. `check_module` — Preview de cómo renderiza
## Reglas importantes para todas las tools ## Reglas importantes para todas las tools

View File

@@ -37,12 +37,35 @@ Each module lives in `template/estandar/modulos/<moduleId>/` with:
## Creating a Module — Full Workflow ## Creating a Module — Full Workflow
If the module needs JavaScript, you MUST read `docs/css-js-conventions.md` before writing `index-base.tpl` or `script.js`.
If the module needs server-side logic, dynamic data processing, form handling, or reusable backend behavior, you MUST read `docs/hooks-and-api.md` before creating `hook.php` or any global hook.
If the module will call hooks from Twig, also review `docs/twig-filters.md` for the `hook` filter syntax.
If `index-base.tpl` contains builder fields (`data-field-type`), you MUST review `docs/builder-fields.md` before writing the template.
Hard rules for module files:
- `index-base.tpl` is for HTML/Twig only
- `script.js` is for module JavaScript
- `style.css` is for module CSS
- `hook.php` is for server-side logic
- Do NOT embed `<script>` tags in `index-base.tpl`
- Do NOT put PHP logic in `index-base.tpl`
- `script.js` and `style.css` are static files: do NOT use Twig syntax inside them
- Do NOT use `{{ ... }}`, `{% ... %}`, `c-if`, `c-for`, or builder attributes inside `script.js` or `style.css`
- If JavaScript needs dynamic values such as `section_id` or a hook endpoint, expose them from `index-base.tpl` via `data-*` attributes
- Every editable builder field with `data-field-type` MUST also define `data-field-label`
- Avoid Tailwind arbitrary-value syntax such as `text-[...]`, `font-[...]`, `leading-[...]`, `bg-[...]` inside `index-base.tpl`; move those styles to `style.css`
1. **Read style reference** (steps above) 1. **Read style reference** (steps above)
2. **`acai_write`** — Write `index-base.tpl` to `template/estandar/modulos/MODULE_ID/index-base.tpl`. The server automatically creates the directory, compiles and generates all derived files. `create_module` is a legacy alternative. 2. **`acai-write`** — Create the module files directly (`index-base.tpl`, `style.css`, `script.js`, optional `hook.php`) using project-relative paths and complete file contents.
3. **`add_module_to_record`** — Adds the module to a page. Response includes `sectionId` — use it directly in the next step. - If the server-side logic belongs only to that module, create `template/estandar/modulos/<module-id>/hook.php`
4. **`set_module_config_vars`** — Fill variables with content. Response includes `uploadFields` with `{ fieldName, recordNum }` for each upload variable. - If the logic should be reused across modules/pages, create a global hook in `hooks/hooks.<hook-id>.php`
5. **Upload images** — Use `generate_image` then `upload_record_image` with the `recordNum` and `fieldName` from step 4's `uploadFields`. No need to read builder.json or call get_module_config_vars. - Inside the module, reference its own hook with `/hooks/<module-id>/`
6. **`navigate_browser`** — Navigate to the page so the user can see the result. - Example: module folder `template/estandar/modulos/buscadorapartados_hjd8s/` -> hook endpoint `/hooks/buscadorapartados_hjd8s/`
3. **Automatic compile** — Writing `index-base.tpl` automatically creates the generated template placeholders and triggers compilation. `compile_module` is only a manual recovery tool if you need to force a recompile without changing the file.
4. **`add_module_to_record`** — Adds the module to a page. Response includes `sectionId` — use it directly in the next step.
5. **`set_module_config_vars`** — Fill variables with content. Response includes `uploadFields` with `{ fieldName, recordNum }` for each upload variable.
6. **Upload images** — Use `generate_image` then `upload_record_image` with the `recordNum` and `fieldName` from step 5's `uploadFields`. No need to read builder.json or call get_module_config_vars.
7. **`navigate_browser`** — Navigate to the page so the user can see the result.
## HTML Field Types ## HTML Field Types
@@ -72,6 +95,6 @@ Modules with `MJMLModule: true` in their schema are email modules:
- Use `section_id` variable for unique anchors/scoping - Use `section_id` variable for unique anchors/scoping
- Use `interno` variable to detect CMS editor vs public view - Use `interno` variable to detect CMS editor vs public view
- Include other modules with: `<module_id :param1="value1"></module_id>` - Include other modules with: `<module_id :param1="value1"></module_id>`
- After editing `index-base.tpl` with `acai_write` or `acai_line_replace`, the server compiles automatically — no need to call `compile_module` - Editing `index-base.tpl` with `acai-write` or `acai-line-replace` compiles automatically
- Twig uses filters (with `|`), never functions - Twig uses filters (with `|`), never functions
- Twig concatenation uses `~`: `'value=' ~ variable` - Twig concatenation uses `~`: `'value=' ~ variable`

View File

@@ -70,7 +70,7 @@ The controlador defines whether the page is Builder or Standard. Changing it bre
1. List current modules: `list_page_modules` 1. List current modules: `list_page_modules`
2. Get module vars: `get_module_config_vars` 2. Get module vars: `get_module_config_vars`
3. Update vars: `set_module_config_vars` 3. Update vars: `set_module_config_vars`
4. Or edit the module template: edit `index-base.tpl` `compile_module` 4. Or edit the module template: edit `index-base.tpl` with `acai-line-replace` or `acai-write` → compilation runs automatically
## Working with Standard Pages ## Working with Standard Pages

View File

@@ -68,13 +68,15 @@ def set_dependencies(
context_engine: Any, context_engine: Any,
memory_store: Any, memory_store: Any,
sse_emitter: Any, sse_emitter: Any,
mcp_registry: Any, claude_emitter: Any = None,
mcp_registry: Any = None,
) -> None: ) -> None:
_deps["storage"] = storage _deps["storage"] = storage
_deps["model_adapter"] = model_adapter _deps["model_adapter"] = model_adapter
_deps["context_engine"] = context_engine _deps["context_engine"] = context_engine
_deps["memory_store"] = memory_store _deps["memory_store"] = memory_store
_deps["sse"] = sse_emitter _deps["sse"] = sse_emitter
_deps["claude_sse"] = claude_emitter
_deps["mcp_registry"] = mcp_registry _deps["mcp_registry"] = mcp_registry
@@ -207,22 +209,33 @@ async def _execute_and_persist(orchestrator, storage, session, message) -> dict[
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@router.get("/sessions/{session_id}/stream") @router.get("/sessions/{session_id}/stream")
async def stream_session(session_id: str) -> StreamingResponse: async def stream_session(session_id: str, format: str = "native") -> StreamingResponse:
storage = _get_storage() storage = _get_storage()
session = await storage.get_session(session_id) session = await storage.get_session(session_id)
if not session: if not session:
raise HTTPException(status_code=404, detail="Session not found") raise HTTPException(status_code=404, detail="Session not found")
sse = _get_sse() headers = {
return StreamingResponse(
sse.subscribe(session_id),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache", "Cache-Control": "no-cache",
"Connection": "keep-alive", "Connection": "keep-alive",
"X-Accel-Buffering": "no", "X-Accel-Buffering": "no",
}, }
if format == "claude":
claude_sse = _deps.get("claude_sse")
if not claude_sse:
raise HTTPException(status_code=501, detail="Claude format emitter not available")
return StreamingResponse(
claude_sse.subscribe(session_id),
media_type="text/event-stream",
headers=headers,
)
sse = _get_sse()
return StreamingResponse(
sse.subscribe(session_id),
media_type="text/event-stream",
headers=headers,
) )

View File

@@ -299,8 +299,8 @@ class ContextEngine:
for d in sorted(all_docs, key=lambda d: len(d.content)) for d in sorted(all_docs, key=lambda d: len(d.content))
] ]
# Fill token budget with top-ranked docs # Include ALL docs — 42K tokens fits well within model context (128K)
max_kb_tokens = 15_000 max_kb_tokens = 50_000
token_budget = max_kb_tokens token_budget = max_kb_tokens
full_docs: list[MemoryDocument] = [] full_docs: list[MemoryDocument] = []
@@ -541,9 +541,29 @@ class ContextEngine:
else: else:
user_content = "Awaiting task assignment." user_content = "Awaiting task assignment."
messages: list[dict[str, Any]] = [{"role": "user", "content": user_content}] messages: list[dict[str, Any]] = []
# Append real conversation (assistant messages + tool results) # Include previous task exchanges as conversation history
# (so the model remembers what was said in earlier turns)
for entry in session.task_history[-10:]:
summary = entry.get("summary", "")
objective = entry.get("objective", "")
if summary.startswith("User: "):
# Direct response format: "User: X → Agent: Y"
parts = summary.split(" → Agent: ", 1)
user_msg = objective or parts[0].replace("User: ", "", 1)
agent_msg = parts[1] if len(parts) > 1 else summary
messages.append({"role": "user", "content": user_msg})
messages.append({"role": "assistant", "content": agent_msg})
elif objective:
# Task with tools — include as compact exchange
messages.append({"role": "user", "content": objective})
messages.append({"role": "assistant", "content": summary[:500] if summary else "Tarea completada."})
# Current user message
messages.append({"role": "user", "content": user_content})
# Append real conversation (assistant messages + tool results from current step)
if conversation: if conversation:
messages.extend(conversation) messages.extend(conversation)

View File

@@ -27,6 +27,7 @@ from .mcp.registry import MCPRegistry
from .memory.store import MemoryStore from .memory.store import MemoryStore
from .orchestrator.engine import OrchestratorEngine from .orchestrator.engine import OrchestratorEngine
from .storage.redis import RedisStorage from .storage.redis import RedisStorage
from .streaming.claude_format import ClaudeFormatEmitter, DualEmitter
from .streaming.sse import SSEEmitter from .streaming.sse import SSEEmitter
logging.basicConfig( logging.basicConfig(
@@ -38,6 +39,8 @@ logger = logging.getLogger(__name__)
# Global instances (initialized in lifespan) # Global instances (initialized in lifespan)
redis_storage = RedisStorage() redis_storage = RedisStorage()
sse_emitter = SSEEmitter(redis_storage=redis_storage) sse_emitter = SSEEmitter(redis_storage=redis_storage)
claude_emitter = ClaudeFormatEmitter()
dual_emitter = DualEmitter(sse_emitter, claude_emitter)
mcp_registry = MCPRegistry() mcp_registry = MCPRegistry()
@@ -48,7 +51,6 @@ async def lifespan(app: FastAPI):
# 1. Connect Redis # 1. Connect Redis
await redis_storage.connect() await redis_storage.connect()
sse_emitter.set_storage(redis_storage)
# 2. Initialize model adapter # 2. Initialize model adapter
if settings.default_model_provider == "openai": if settings.default_model_provider == "openai":
@@ -82,12 +84,14 @@ async def lifespan(app: FastAPI):
mcp_registry.load_config() mcp_registry.load_config()
# 6. Wire dependencies (orchestrator is created per-message with session's MCP) # 6. Wire dependencies (orchestrator is created per-message with session's MCP)
dual_emitter.set_storage(redis_storage)
set_dependencies( set_dependencies(
storage=redis_storage, storage=redis_storage,
model_adapter=model_adapter, model_adapter=model_adapter,
context_engine=context_engine, context_engine=context_engine,
memory_store=memory_store, memory_store=memory_store,
sse_emitter=sse_emitter, sse_emitter=dual_emitter,
claude_emitter=claude_emitter,
mcp_registry=mcp_registry, mcp_registry=mcp_registry,
) )

View File

@@ -96,6 +96,9 @@ class BaseAgent:
): ):
if chunk.delta: if chunk.delta:
full_text += chunk.delta full_text += chunk.delta
# Only emit deltas for user-facing agents (coder, collector)
# Planner/reviewer output is internal
if self.profile.role not in ("planner", "reviewer"):
await self.sse.emit( await self.sse.emit(
EventType.AGENT_DELTA, EventType.AGENT_DELTA,
{ {
@@ -115,7 +118,7 @@ class BaseAgent:
} }
await self.sse.emit( await self.sse.emit(
EventType.TOOL_STARTED, EventType.TOOL_STARTED,
{"tool": chunk.tool_name, "step": step}, {"tool": chunk.tool_name, "tool_call_id": chunk.tool_call_id, "step": step},
session_id=session.session_id, session_id=session.session_id,
) )
@@ -123,6 +126,17 @@ class BaseAgent:
tool = active_tools.get(chunk.tool_call_id) tool = active_tools.get(chunk.tool_call_id)
if tool: if tool:
tool["arguments"] += chunk.tool_arguments tool["arguments"] += chunk.tool_arguments
await self.sse.emit(
EventType.AGENT_DELTA,
{
"agent": self.profile.role,
"delta": "",
"tool_arguments": chunk.tool_arguments,
"tool_call_id": chunk.tool_call_id,
"step": step,
},
session_id=session.session_id,
)
if chunk.finish_reason == "tool_use" and chunk.tool_call_id: if chunk.finish_reason == "tool_use" and chunk.tool_call_id:
tool = active_tools.pop(chunk.tool_call_id, None) tool = active_tools.pop(chunk.tool_call_id, None)
@@ -200,6 +214,7 @@ class BaseAgent:
tool_name=tc["name"], tool_name=tc["name"],
arguments=tc.get("parsed_arguments", {}), arguments=tc.get("parsed_arguments", {}),
artifacts=artifacts, artifacts=artifacts,
tool_call_id=tc["id"],
) )
tool_fingerprints[fp] = tool_exec tool_fingerprints[fp] = tool_exec
tool_executions.append(tool_exec) tool_executions.append(tool_exec)
@@ -253,6 +268,7 @@ class BaseAgent:
tool_name: str, tool_name: str,
arguments: dict[str, Any], arguments: dict[str, Any],
artifacts: list[ArtifactSummary], artifacts: list[ArtifactSummary],
tool_call_id: str = "",
) -> ToolExecution: ) -> ToolExecution:
"""Execute a tool and summarise the result.""" """Execute a tool and summarise the result."""
exec_id = uuid.uuid4().hex[:12] exec_id = uuid.uuid4().hex[:12]
@@ -299,6 +315,8 @@ class BaseAgent:
"tool": tool_name, "tool": tool_name,
"status": "completed", "status": "completed",
"summary": artifact.summary[:200], "summary": artifact.summary[:200],
"raw_output": raw_output[:4000],
"tool_call_id": tool_call_id,
}, },
session_id=session.session_id, session_id=session.session_id,
) )
@@ -311,7 +329,7 @@ class BaseAgent:
await self.sse.emit( await self.sse.emit(
EventType.TOOL_COMPLETED, EventType.TOOL_COMPLETED,
{"tool": tool_name, "status": "failed", "error": str(e)}, {"tool": tool_name, "status": "failed", "error": str(e), "tool_call_id": tool_call_id},
session_id=session.session_id, session_id=session.session_id,
) )

View File

@@ -5,28 +5,167 @@ from __future__ import annotations
from ...models.agent import AgentProfile, AgentRole from ...models.agent import AgentProfile, AgentRole
from .base import BaseAgent from .base import BaseAgent
CODER_SYSTEM_PROMPT = """Eres un Agente Programador de Acai CMS. Tu rol es ejecutar tareas de implementación usando las herramientas MCP disponibles. CODER_SYSTEM_PROMPT = """Eres el asistente de desarrollo de Acai CMS. Ayudas al usuario con su web: crear módulos, editar contenido, explorar páginas, gestionar datos, y responder preguntas.
## Instrucciones # Acai CMS — Project Instructions
- Concéntrate en la descripción del paso actual.
- Usa herramientas para lograr la tarea.
- Sé preciso y minucioso.
- Reporta lo que lograste, problemas encontrados y hechos relevantes.
- NO produzcas explicaciones innecesarias — produce resultados.
- Responde SIEMPRE en español.
## Uso de herramientas This is an Acai CMS website project. Follow these instructions when working with the codebase.
- CONSULTA la Knowledge Base ANTES de actuar — tiene la referencia completa de tools y flujos de trabajo.
- Para CREAR/EDITAR MÓDULOS usa `acai_write` sobre `template/estandar/modulos/NOMBRE/index-base.tpl`. El server crea la carpeta si no existe, compila y genera todos los archivos derivados automáticamente. NO necesitas compile_module. ## Environment
- `create_module` es legacy — funciona pero `acai_write` es el flujo estándar.
- Para GESTIONAR REGISTROS de tablas (apartados, travesias, etc.) usa `create_or_update_record`. - The site runs in Docker, typically at **http://localhost:8080**
- Flujo de módulo nuevo: acai_write index-base.tpl → add_module_to_record → set_module_config_vars. - You can make HTTP requests to test pages, APIs, or form submissions
- tableName siempre SIN prefijo cms_ (ej: apartados, NO cms_apartados). - If you need to inspect the live site, use browser tools (Playwright MCP) or HTTP requests to localhost:8080
- La primary key es siempre `num`, nunca `id`.
## Project Structure
```
.
├── template/estandar/
│ ├── modulos/ # Builder modules (visual components)
│ │ └── <module-id>/
│ │ ├── index-base.tpl # Twig template (source — EDIT THIS)
│ │ ├── style.css # Module styles
│ │ └── script.js # Module JavaScript
│ │ ├── index.tpl # Compiled (auto-generated, do NOT edit)
│ │ ├── index-twig.tpl # Compiled (auto-generated, do NOT edit)
│ │ └── builder.json # Compiled builder vars (auto-generated, do NOT edit)
│ ├── css/ # Global CSS
│ └── js/ # Global JavaScript
├── hooks/ # PHP hooks (server-side logic)
├── cms/
│ ├── data/schema/ # Database table schemas (JSON)
│ ├── lib/plugins/ # CMS plugins
│ └── uploads/ # Uploaded media files
├── .acai # Project config (domain, tokens, DB credentials)
├── .docker/
│ ├── .env # Docker environment (DB credentials)
│ ├── docker-compose.yml
│ ├── tunnel-url.txt # Public tunnel URL (if active)
│ └── bore-db-url.txt # Database tunnel URL (if active)
└── database.sql # Database dump
```
## Key Concepts
### Modules (`template/estandar/modulos/`)
Visual components that the site builder uses. Each module is a self-contained unit with its own template (Twig + Acai attributes), CSS, and JS. Modules are placed on pages via the drag-and-drop builder. The editable file is always `index-base.tpl`.
- Include other modules: `<module_id :param1="value1"></module_id>`
- Each module instance gets a unique `section_id` variable for anchors/scoping
- Use `interno` variable to detect CMS editor mode vs public view
See [docs/modular-system.md](docs/modular-system.md) for detailed rules.
### Pages
Every record with an `enlace` field is a page. Pages are either **Builder** (modular) or **Standard**:
- **Builder**: `controlador` = `cms/lib/plugins/builder_saas/controlador.php` — content via modules
- **Standard**: `controlador` = `cms/lib/plugins/builder_saas/controlador_tabla.php` — content in record fields
**Critical**: Never change `enlace` or `controlador` of existing pages unless explicitly asked.
See [docs/pages-and-records.md](docs/pages-and-records.md) for full details.
### General Sections
Database-backed templates (headers, footers, record views) that use the `thisrecord` variable to access record fields. They use the same Twig + Acai attribute engine as modules.
- Upload fields return arrays: `thisrecord.image[0].urlPath`
- Foreign keys use `_num` suffix: `category_num`
See [docs/modular-system.md](docs/modular-system.md) for details.
### Hooks (`hooks/`)
PHP files that execute server-side logic. Triggered by:
- Twig filter: `'hooks/module_id/' | hook({param: value})`
- HTML tag: `<hook result="var" endpoint="/hooks/module_id/" :param="value"></hook>`
- JavaScript: `CmsApi.hook('/hooks/module_id/', {param: value}, callback)`
- Form action: via `c-form` attribute
There are two valid hook locations:
- Global hooks in `hooks/hooks.<hook-id>.php` for reusable/shared server-side logic
- Module-specific hooks in `template/estandar/modulos/<module-id>/hook.php` for logic owned by a single module
How to reference them:
- Global hook `hooks/hooks.calcular_precio.php` -> endpoint `/hooks/calcular_precio/`
- Module hook `template/estandar/modulos/hero_banner/hook.php` -> endpoint `/hooks/hero_banner/`
- Module hook `template/estandar/modulos/buscadorapartados_hjd8s/hook.php` -> endpoint `/hooks/buscadorapartados_hjd8s/`
Rule of thumb:
- If the logic is only used by one module, prefer that module's `hook.php`
- If the logic will be reused by several modules/pages, create a global hook in `hooks/`
- Return arrays from hooks; do not use `echo json_encode(...)` or `exit`
See [docs/hooks-and-api.md](docs/hooks-and-api.md) for usage.
## Database Access
When the site is running in Docker, you can connect to the database:
- **Host:** `127.0.0.1`
- **Port:** Check `.docker/docker-compose.yml` for the mapped port (usually 3307+)
- **Credentials:** Read from `.docker/.env`:
- `DB_USERNAME`
- `DB_PASSWORD`
- `DB_DATABASE`
```bash
docker exec -it dw-<project-name>-db mysql -u root -p<password> <database>
```
**Important:** Table names in CmsApi/Twig do NOT use the `cms_` prefix. The primary key is always `num`, never `id`.
## Acai Core (web-base)
The project workspace contains only the **customization layer** (modules, hooks, schemas, uploads). The CMS core (routing, rendering engine, admin panel, APIs) lives in a separate directory called **web-base** that is mounted as a Docker volume.
The web-base path can be obtained via: `GET http://localhost:9090/api/web-base-path`
Do NOT modify web-base files — they are shared across all projects.
## Critical Rules
1. **Before working with any area (hooks, modules, templates, CSS/JS, etc.), read the corresponding documentation in `docs/` first.** Do not guess or assume — always consult the docs before taking action.
2. **NEVER use `mkdir` to create directories.** Instead, use `acai-write` to create the first file inside the directory — this creates parent directories automatically. For example, to create a new module, directly write the `index-base.tpl` file.
3. Only edit `index-base.tpl` in modules — `index.tpl`, `index-twig.tpl`, and `builder.json` are auto-generated
4. Editing or creating any `index-base.tpl` through `acai-write` or `acai-line-replace` triggers automatic compilation. `compile_module` is only for manual recovery when you need to force a recompile without changing the file.
5. `script.js` and `style.css` are static files — do NOT use Twig syntax inside them. Pass dynamic values from `index-base.tpl` via `data-*` attributes.
6. Use Twig **filters** (with `|`), never Twig functions
7. Table names without `cms_` prefix everywhere
8. Primary key is `num`, never `id`
9. Upload fields are arrays — access with `[0].urlPath`
10. Tailwind CSS as primary styling, custom CSS scoped with BEM when needed
11. Twig concatenation uses `~` operator: `'value=' ~ variable`
12. `enlace` (link) fields already include slashes — **NEVER modify an existing enlace** unless explicitly asked
13. **NEVER modify the `controlador` field** of existing records — it defines whether a page is Builder or Standard
## MCP Tools
This project has MCP tools for managing modules, records, media, and more. **Before starting any task, consult the tools reference for the correct workflow.**
See [docs/mcp-tools-reference.md](docs/mcp-tools-reference.md) for the complete list of available tools and step-by-step workflows.
Key workflows:
- **Create module**: Read [docs/module-creation-guide.md](docs/module-creation-guide.md) first → create files with `acai-write` / refine with `acai-line-replace` → automatic compile on `index-base.tpl` → `add_module_to_record` (returns sectionId) → `set_module_config_vars` (returns uploadFields) → images via uploadFields. Use `compile_module` only if you need a manual recompile without editing the file.
- **Edit module**: `acai-view` → `acai-line-replace` (or `acai-write` for full rewrites) → automatic compile on `index-base.tpl`
- **Add images**: use `uploadFields` from `set_module_config_vars` response → `upload_record_image`
- **Generate images**: `generate_image` → `upload_record_image` with returned URL
## Documentation
- [docs/modular-system.md](docs/modular-system.md) — Modules, general sections, global variables
- [docs/builder-fields.md](docs/builder-fields.md) — Builder field types, Acai attributes, c-form, components
- [docs/twig-filters.md](docs/twig-filters.md) — Twig filters reference (get, hook, module, queryDB, etc.)
- [docs/hooks-and-api.md](docs/hooks-and-api.md) — PHP hooks, CmsApi, CocoDB, record creation
- [docs/css-js-conventions.md](docs/css-js-conventions.md) — CSS/JS/Vue 3, Tailwind, BEM, native components
- [docs/quick-reference.md](docs/quick-reference.md) — Cheat sheet: domain rules, field types, filters
- [docs/production-patterns.md](docs/production-patterns.md) — Real production patterns (header, zigzag, FAQ, forms)
- [docs/vue-builder-rules.md](docs/vue-builder-rules.md) — CMS-VUE rules (tabs, colorpicker, components)
- [docs/vue-builder-examples.md](docs/vue-builder-examples.md) — Vue builder examples (Banner Slideshow, etc.)
- [docs/pages-and-records.md](docs/pages-and-records.md) — Page types (Builder vs Standard), sections, visibility, critical rules
- [docs/module-creation-guide.md](docs/module-creation-guide.md) — Module creation workflow, style reference, field types
- [docs/mcp-tools-reference.md](docs/mcp-tools-reference.md) — MCP tools reference, available tools, workflows
## Datos del historial
- Si el historial de sesión incluye Key Data con recordNums o sectionIds, ÚSALOS directamente sin re-consultar.
- Ejemplo: si el historial dice "pages: Inicio = record 2", usa recordNum=2 para la portada.
""" """

View File

@@ -12,16 +12,23 @@ from .base import BaseAgent
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
PLANNER_SYSTEM_PROMPT = """Eres un Agente Planificador. Tu rol es descomponer un objetivo en un plan de ejecución estructurado. PLANNER_SYSTEM_PROMPT = """Eres un Agente Planificador de Acai CMS. Tu rol es analizar el mensaje del usuario y decidir cómo responder.
## Instrucciones ## Instrucciones
- Analiza el objetivo y divídelo en pasos concretos y ordenados. - PRIMERO revisa el historial de conversación para entender el contexto.
- Cada paso debe ser ejecutable de forma independiente por un agente especializado. - Si el mensaje es conversacional (saludos, preguntas sobre la conversación, datos personales mencionados antes, preguntas generales que NO requieren consultar la base de datos ni herramientas) → devuelve una respuesta directa usando la información del historial.
- Asigna cada paso al rol de agente apropiado: coder, collector o reviewer. - SOLO genera un plan de ejecución cuando el usuario pide explícitamente una acción sobre su web: crear módulos, editar contenido, explorar páginas, consultar tablas de la base de datos, etc.
- "¿Cómo me llamo?" es conversacional (responde del historial), NO es una consulta a la base de datos.
- Responde SIEMPRE en español. - Responde SIEMPRE en español.
## Formato de salida ## Formato de salida
Devuelve SOLO un objeto JSON:
### Para respuestas directas (saludos, preguntas simples):
{
"direct_response": "Tu respuesta aquí. Sé amable y conciso."
}
### Para tareas que requieren herramientas:
{ {
"plan": [ "plan": [
{"description": "descripción del paso", "agent_role": "coder|collector|reviewer"}, {"description": "descripción del paso", "agent_role": "coder|collector|reviewer"},
@@ -31,7 +38,15 @@ Devuelve SOLO un objeto JSON:
"facts": ["hechos establecidos del análisis"] "facts": ["hechos establecidos del análisis"]
} }
NO incluyas comentarios fuera del JSON.""" ## REGLAS CRÍTICAS para planes:
- Máximo 2-3 steps. Un coder competente puede hacer múltiples acciones en un solo step.
- Para CREAR UN MÓDULO: 1 step coder (crea archivos + añade a página + configura vars). NO necesita steps separados para cada acción.
- Para EXPLORAR: 1 step coder (consulta tablas + lista módulos).
- Para EDITAR CONTENIDO: 1 step coder (lee + modifica).
- SOLO añade un step reviewer si la tarea es compleja (crear módulo con hook, editar múltiples páginas).
- NUNCA generes steps redundantes (ej: "crear módulo" + "añadir a página" + "configurar vars" son UN SOLO step).
Devuelve SOLO el objeto JSON, sin comentarios fuera."""
def create_planner_profile() -> AgentProfile: def create_planner_profile() -> AgentProfile:
@@ -55,26 +70,34 @@ def create_planner_profile() -> AgentProfile:
class PlannerAgent(BaseAgent): class PlannerAgent(BaseAgent):
"""Generates execution plans from objectives.""" """Generates execution plans from objectives."""
async def plan(self, session: SessionState) -> tuple[list[TaskStep], dict[str, int]]: async def plan(self, session: SessionState) -> tuple[list[TaskStep] | str, dict[str, int]]:
"""Generate a plan and return (TaskSteps, usage).""" """Generate a plan or a direct response.
Returns:
(steps, usage) if plan needed
(direct_response_string, usage) if no plan needed
"""
result = await self.execute(session, max_steps=1) result = await self.execute(session, max_steps=1)
usage = result.get("usage", {"input_tokens": 0, "output_tokens": 0}) usage = result.get("usage", {"input_tokens": 0, "output_tokens": 0})
content = result["content"].strip() content = result["content"].strip()
# Parse the JSON plan from the model output # Parse the JSON from the model output
try: try:
# Try to extract JSON from the content
json_str = content json_str = content
if "```" in content: if "```" in content:
# Extract from code block
start = content.find("{") start = content.find("{")
end = content.rfind("}") + 1 end = content.rfind("}") + 1
if start >= 0 and end > start: if start >= 0 and end > start:
json_str = content[start:end] json_str = content[start:end]
parsed = json.loads(json_str) parsed = json.loads(json_str)
steps: list[TaskStep] = []
# Check for direct response (no plan needed)
if "direct_response" in parsed:
return parsed["direct_response"], usage
# Build plan steps
steps: list[TaskStep] = []
for item in parsed.get("plan", []): for item in parsed.get("plan", []):
steps.append( steps.append(
TaskStep( TaskStep(
@@ -84,7 +107,6 @@ class PlannerAgent(BaseAgent):
) )
) )
# Extract constraints and facts into task state
if session.current_task: if session.current_task:
session.current_task.constraints.extend( session.current_task.constraints.extend(
parsed.get("constraints", []) parsed.get("constraints", [])
@@ -97,7 +119,6 @@ class PlannerAgent(BaseAgent):
except (json.JSONDecodeError, KeyError) as e: except (json.JSONDecodeError, KeyError) as e:
logger.warning("Failed to parse planner output: %s", e) logger.warning("Failed to parse planner output: %s", e)
# Fallback: single step with the full objective
return [ return [
TaskStep( TaskStep(
description=session.current_task.objective description=session.current_task.objective

View File

@@ -1,13 +1,13 @@
"""Orchestrator Engine — the main execution loop. """Orchestrator Engine — single-agent execution.
Flow: message → plan → route → execute steps → summarize → update → stream Flow: message → coder agent (with tools) → response
No planner, no reviewer. The coder decides what to do.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging import logging
from datetime import datetime, timezone
from typing import Any from typing import Any
from ..adapters.base import ModelAdapter from ..adapters.base import ModelAdapter
@@ -16,19 +16,15 @@ from ..context.engine import ContextEngine
from ..mcp.manager import MCPManager from ..mcp.manager import MCPManager
from ..memory.store import MemoryStore from ..memory.store import MemoryStore
from ..models.agent import AgentRole from ..models.agent import AgentRole
from ..models.session import SessionState, SessionStatus, TaskState, TaskStatus from ..models.session import SessionState, SessionStatus, TaskStatus
from ..streaming.sse import SSEEmitter, EventType from ..streaming.sse import SSEEmitter, EventType
from .agents.coder import CoderAgent, create_coder_profile from .agents.coder import CoderAgent, create_coder_profile
from .agents.collector import CollectorAgent, create_collector_profile
from .agents.planner import PlannerAgent, create_planner_profile
from .agents.reviewer import ReviewerAgent, create_reviewer_profile
from .router import route_step
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class OrchestratorEngine: class OrchestratorEngine:
"""Drives the full execution lifecycle for a session message.""" """Drives execution for a session message. Single agent, no planning."""
def __init__( def __init__(
self, self,
@@ -43,14 +39,7 @@ class OrchestratorEngine:
self.mcp = mcp_client self.mcp = mcp_client
self.memory = memory_store self.memory = memory_store
self.sse = sse_emitter self.sse = sse_emitter
self._coder_profile = create_coder_profile()
# Pre-built agent profiles
self._profiles = {
AgentRole.PLANNER: create_planner_profile(),
AgentRole.CODER: create_coder_profile(),
AgentRole.COLLECTOR: create_collector_profile(),
AgentRole.REVIEWER: create_reviewer_profile(),
}
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Public # Public
@@ -61,17 +50,10 @@ class OrchestratorEngine:
session: SessionState, session: SessionState,
message: str, message: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Process a user message through the full orchestration pipeline. """Process a user message. Single agent execution with timeout."""
Pipeline: plan → execute steps → review → complete
Handles errors gracefully: failed steps are marked and skipped,
the session always returns to idle/error — never stuck in executing.
"""
task = None
try: try:
return await asyncio.wait_for( return await asyncio.wait_for(
self._run_pipeline(session, message), self._run(session, message),
timeout=settings.max_execution_timeout_seconds, timeout=settings.max_execution_timeout_seconds,
) )
except asyncio.TimeoutError: except asyncio.TimeoutError:
@@ -86,7 +68,7 @@ class OrchestratorEngine:
) )
return self._error_result(session, "Execution timed out") return self._error_result(session, "Execution timed out")
except Exception as e: except Exception as e:
logger.exception("Unhandled error in pipeline for session %s", session.session_id) logger.exception("Unhandled error for session %s", session.session_id)
if session.current_task: if session.current_task:
session.current_task.mark_failed(str(e)) session.current_task.mark_failed(str(e))
session.status = SessionStatus.ERROR session.status = SessionStatus.ERROR
@@ -97,12 +79,12 @@ class OrchestratorEngine:
) )
return self._error_result(session, str(e)) return self._error_result(session, str(e))
async def _run_pipeline( async def _run(
self, self,
session: SessionState, session: SessionState,
message: str, message: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Inner pipeline — wrapped by process_message for error handling.""" """Execute: message → coder → response."""
await self.sse.emit( await self.sse.emit(
EventType.EXECUTION_STARTED, EventType.EXECUTION_STARTED,
@@ -110,166 +92,112 @@ class OrchestratorEngine:
session_id=session.session_id, session_id=session.session_id,
) )
# 1. Create task from message # Create task
task = session.begin_task(objective=message) task = session.begin_task(objective=message)
# 2. Plan
task.status = TaskStatus.PLANNING
planner_usage: dict[str, int] = {"input_tokens": 0, "output_tokens": 0}
try:
planner = self._create_agent(AgentRole.PLANNER)
plan_steps, planner_usage = await planner.plan(session)
task.plan = plan_steps
task.status = TaskStatus.EXECUTING task.status = TaskStatus.EXECUTING
except Exception as e:
logger.error("Planning failed: %s", e)
task.mark_failed(f"Planning failed: {e}")
session.status = SessionStatus.ERROR
await self.sse.emit(
EventType.ERROR,
{"error": "planning_failed", "message": str(e)},
session_id=session.session_id,
)
return self._error_result(session, f"Planning failed: {e}")
logger.info( # Execute with the coder agent directly
"Plan created with %d steps for task %s", agent = CoderAgent(
len(plan_steps), profile=self._coder_profile,
task.task_id, model_adapter=self.model,
) context_engine=self.context,
mcp_client=self.mcp,
# 3. Execute each step — failures are logged and skipped memory_store=self.memory,
results: list[dict[str, Any]] = [] sse_emitter=self.sse,
failed_steps: list[int] = []
for i, step in enumerate(task.plan):
if i >= settings.max_execution_steps:
logger.warning("Max execution steps reached")
break
task.current_step_index = i
step.status = TaskStatus.EXECUTING
step.started_at = datetime.now(timezone.utc)
role = route_step(step)
agent = self._create_agent(role)
await self.sse.emit(
EventType.SUBAGENT_ASSIGNED,
{
"step": i + 1,
"total_steps": len(task.plan),
"agent": role.value,
"description": step.description,
},
session_id=session.session_id,
) )
try: try:
step_result = await agent.execute( result = await agent.execute(
session=session, session=session,
max_steps=settings.subagent_max_steps, max_steps=settings.subagent_max_steps,
) )
results.append(step_result)
step.status = TaskStatus.COMPLETED
step.completed_at = datetime.now(timezone.utc)
step.result_summary = (step_result.get("content", ""))[:500]
step.tools_used = [
te.tool_name for te in step_result.get("tool_executions", [])
]
for artifact in step_result.get("artifacts", []):
task.facts_extracted.extend(artifact.facts[:5])
# Decide if previous steps should be compacted
if i > 0:
self._maybe_compact_previous_steps(task, current_index=i)
except Exception as e: except Exception as e:
logger.error("Step %d failed: %s", i + 1, e) logger.error("Execution failed: %s", e)
step.status = TaskStatus.FAILED task.mark_failed(str(e))
step.completed_at = datetime.now(timezone.utc) session.status = SessionStatus.ERROR
step.result_summary = f"Error: {e}"
failed_steps.append(i + 1)
await self.sse.emit( await self.sse.emit(
EventType.ERROR, EventType.ERROR,
{"error": "step_failed", "step": i + 1, "message": str(e)}, {"error": "execution_failed", "message": str(e)},
session_id=session.session_id, session_id=session.session_id,
) )
# Continue with next step — don't block the pipeline return self._error_result(session, str(e))
# 4. Review (if plan had more than 1 step and at least one succeeded) # Compact to history
review_result: dict[str, Any] = {} content = result.get("content", "")
if len(task.plan) > 1 and results: usage = result.get("usage", {"input_tokens": 0, "output_tokens": 0})
task.status = TaskStatus.REVIEWING key_data = self._extract_key_data_from_results([result])
try:
reviewer = self._create_agent(AgentRole.REVIEWER)
review_result = await reviewer.execute(
session=session,
max_steps=2,
)
except Exception as e:
logger.error("Review failed: %s", e)
review_result = {"content": f"Review skipped due to error: {e}"}
# 5. Compact task into history before completing session.task_history.append({
await self._compact_task_to_history(session, task, results, review_result) "task_id": task.task_id,
"objective": message,
"status": "completed",
"steps": 1,
"facts": task.facts_extracted[-10:],
"key_data": key_data,
"tools_used": [te.tool_name for te in result.get("tool_executions", [])],
"artifacts_count": len(result.get("artifacts", [])),
"summary": f"User: {message[:150]} → Agent: {content[:150]}",
"review": "",
})
if len(session.task_history) > 20:
session.task_history = session.task_history[-20:]
# 6. Complete — session ALWAYS returns to idle # Clean old artifacts
artifacts = await self.memory.list_artifacts(session.session_id)
recent_task_ids = {t["task_id"] for t in session.task_history[-2:]}
for artifact in artifacts:
if artifact.task_id not in recent_task_ids:
key = f"{self.memory._prefix}:session:{session.session_id}:artifacts"
await self.memory._r.hdel(key, artifact.artifact_id)
# Complete
task.status = TaskStatus.COMPLETED
session.complete_task() session.complete_task()
final_content = self._assemble_response(results, review_result) # Calculate cost
status = "completed" if not failed_steps else "partial" total_input = usage.get("input_tokens", 0)
total_output = usage.get("output_tokens", 0)
cost_usd = (
(total_input / 1_000_000) * settings.cost_per_1m_input
+ (total_output / 1_000_000) * settings.cost_per_1m_output
)
await self.sse.emit( await self.sse.emit(
EventType.EXECUTION_COMPLETED, EventType.EXECUTION_COMPLETED,
{ {
"session_id": session.session_id, "session_id": session.session_id,
"task_id": task.task_id, "task_id": task.task_id,
"steps_completed": len(results), "steps_completed": 1,
"steps_failed": failed_steps, "steps_failed": [],
"status": status, "status": "completed",
"usage": usage,
"total_cost_usd": round(cost_usd, 6),
}, },
session_id=session.session_id, session_id=session.session_id,
) )
# Accumulate token usage: planner + all steps + review logger.info(
total_input = planner_usage.get("input_tokens", 0) "Task %s completed (%d tools, %d artifacts, %d input tokens)",
total_output = planner_usage.get("output_tokens", 0) task.task_id,
for r in results: len(result.get("tool_executions", [])),
total_input += r.get("usage", {}).get("input_tokens", 0) len(result.get("artifacts", [])),
total_output += r.get("usage", {}).get("output_tokens", 0) total_input,
# Add review usage if any
total_input += review_result.get("usage", {}).get("input_tokens", 0)
total_output += review_result.get("usage", {}).get("output_tokens", 0)
# Calculate cost
cost_usd = (
(total_input / 1_000_000) * settings.cost_per_1m_input
+ (total_output / 1_000_000) * settings.cost_per_1m_output
) )
return { return {
"session_id": session.session_id, "session_id": session.session_id,
"task_id": task.task_id, "task_id": task.task_id,
"content": final_content, "content": content or "Task completed.",
"steps_completed": len(results), "steps_completed": 1,
"steps_failed": failed_steps, "steps_failed": [],
"artifacts_count": sum( "artifacts_count": len(result.get("artifacts", [])),
len(r.get("artifacts", [])) for r in results "review": "",
), "status": "completed",
"review": review_result.get("content", ""), "usage": usage,
"status": status,
"usage": {
"input_tokens": total_input,
"output_tokens": total_output,
},
"total_cost_usd": round(cost_usd, 6), "total_cost_usd": round(cost_usd, 6),
} }
def _error_result(self, session: SessionState, error: str) -> dict[str, Any]: def _error_result(self, session: SessionState, error: str) -> dict[str, Any]:
"""Build a standardized error response."""
task_id = session.current_task.task_id if session.current_task else "none" task_id = session.current_task.task_id if session.current_task else "none"
return { return {
"session_id": session.session_id, "session_id": session.session_id,
@@ -282,204 +210,36 @@ class OrchestratorEngine:
"status": "error", "status": "error",
} }
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
async def _compact_task_to_history(
self,
session: SessionState,
task: TaskState,
results: list[dict[str, Any]],
review_result: dict[str, Any],
) -> None:
"""Compact a completed task into a minimal history entry.
This is critical for long sessions: instead of keeping all
artifacts and facts from every task, we compress each completed
task into a ~200 token summary that preserves:
- What was done (objective)
- What was produced (file changes, modules created)
- Key facts learned
- Issues found by reviewer
"""
# Collect all artifact summaries from this task
artifacts = await self.memory.list_artifacts(session.session_id)
task_artifacts = [a for a in artifacts if a.task_id == task.task_id]
# Build compact summary
step_summaries = []
for step in task.plan:
if step.result_summary:
step_summaries.append(f"{step.agent_role}: {step.result_summary[:100]}")
tools_used = set()
for step in task.plan:
tools_used.update(step.tools_used)
# Extract key structured data from tool executions
key_data = self._extract_key_data_from_results(results)
history_entry = {
"task_id": task.task_id,
"objective": task.objective,
"status": task.status.value,
"steps": len(task.plan),
"facts": task.facts_extracted[-10:],
"key_data": key_data,
"tools_used": list(tools_used)[:10],
"artifacts_count": len(task_artifacts),
"summary": "; ".join(step_summaries)[:300],
"review": (review_result.get("content", ""))[:200],
}
# Keep max 20 task histories (trim oldest)
session.task_history.append(history_entry)
if len(session.task_history) > 20:
session.task_history = session.task_history[-20:]
# Clean up old artifacts from Redis to free memory
# Keep only artifacts from the last 2 tasks
recent_task_ids = {t["task_id"] for t in session.task_history[-2:]}
for artifact in artifacts:
if artifact.task_id not in recent_task_ids:
# Remove old artifact from Redis hash
key = f"{self.memory._prefix}:session:{session.session_id}:artifacts"
await self.memory._r.hdel(key, artifact.artifact_id)
logger.info(
"Compacted task %s into history (%d facts, %d tools, %d artifacts → summary)",
task.task_id, len(task.facts_extracted), len(tools_used), len(task_artifacts),
)
@staticmethod @staticmethod
def _extract_key_data_from_results(results: list[dict[str, Any]]) -> dict[str, Any]: def _extract_key_data_from_results(results: list[dict[str, Any]]) -> dict[str, Any]:
"""Extract structured data from tool executions for task history. """Extract structured data from tool executions for task history."""
Preserves key identifiers (recordNum, sectionId, tableName, moduleId)
so the model retains context across tasks without re-querying.
"""
key_data: dict[str, Any] = {} key_data: dict[str, Any] = {}
seen_tables: dict[str, list[int]] = {} # tableName -> recordNums seen_tables: dict[str, list[int]] = {}
seen_sections: list[str] = [] seen_sections: list[str] = []
seen_modules: list[str] = [] seen_modules: list[str] = []
seen_pages: dict[str, int] = {} # page name/url -> recordNum
for result in results: for result in results:
for te in result.get("tool_executions", []): for te in result.get("tool_executions", []):
args = te.arguments args = te.arguments
name = te.tool_name
# Track table + record relationships
table = args.get("tableName", "") table = args.get("tableName", "")
record = args.get("recordNum") record = args.get("recordNum")
if table and record: if table and record:
record_int = int(record) if str(record).isdigit() else None record_int = int(record) if str(record).isdigit() else None
if record_int and table not in seen_tables: if record_int:
seen_tables[table] = [] seen_tables.setdefault(table, [])
if record_int and record_int not in seen_tables.get(table, []): if record_int not in seen_tables[table]:
seen_tables[table].append(record_int) seen_tables[table].append(record_int)
# Track section IDs
section = args.get("sectionId", "") section = args.get("sectionId", "")
if section and section not in seen_sections: if section and section not in seen_sections:
seen_sections.append(section) seen_sections.append(section)
# Track modules
module = args.get("moduleId", "") or args.get("moduleName", "") module = args.get("moduleId", "") or args.get("moduleName", "")
if module and module not in seen_modules: if module and module not in seen_modules:
seen_modules.append(module) seen_modules.append(module)
# Extract page info from raw output (enlace, name)
if te.raw_output and "enlace" in te.raw_output:
try:
import json as _json
# Try to parse structured data from output
for line in te.raw_output.splitlines():
line = line.strip()
if line.startswith("{"):
try:
data = _json.loads(line)
if "enlace" in data and "num" in data:
page_key = data.get("name", data["enlace"])
seen_pages[page_key] = int(data["num"])
except _json.JSONDecodeError:
pass
except Exception:
pass
if seen_tables: if seen_tables:
key_data["tables"] = {t: nums[:10] for t, nums in seen_tables.items()} key_data["tables"] = {t: nums[:10] for t, nums in seen_tables.items()}
if seen_sections: if seen_sections:
key_data["sections"] = seen_sections[:20] key_data["sections"] = seen_sections[:20]
if seen_modules: if seen_modules:
key_data["modules"] = seen_modules[:20] key_data["modules"] = seen_modules[:20]
if seen_pages:
key_data["pages"] = dict(list(seen_pages.items())[:20])
return key_data return key_data
def _maybe_compact_previous_steps(
self, task: TaskState, current_index: int
) -> None:
"""Decide if previous steps should be compacted. Deterministic rules."""
current_step = task.plan[current_index]
for i in range(current_index):
prev = task.plan[i]
if prev.compacted or prev.status != TaskStatus.COMPLETED:
continue
# Rule 1: Change of agent role → previous steps are a different focus
if prev.agent_role != current_step.agent_role:
prev.compacted = True
logger.info(
"Compacted step %d (%s) — agent changed to %s",
i + 1, prev.agent_role, current_step.agent_role,
)
continue
# Rule 2: More than 3 completed non-compacted steps → compact oldest
non_compacted = [
s for s in task.plan[:current_index]
if s.status == TaskStatus.COMPLETED and not s.compacted
]
if len(non_compacted) > 3:
non_compacted[0].compacted = True
logger.info("Compacted oldest step to stay within budget")
def _create_agent(self, role: AgentRole) -> PlannerAgent | CoderAgent | CollectorAgent | ReviewerAgent:
"""Instantiate a subagent for the given role."""
profile = self._profiles[role]
agent_cls = {
AgentRole.PLANNER: PlannerAgent,
AgentRole.CODER: CoderAgent,
AgentRole.COLLECTOR: CollectorAgent,
AgentRole.REVIEWER: ReviewerAgent,
}[role]
return agent_cls(
profile=profile,
model_adapter=self.model,
context_engine=self.context,
mcp_client=self.mcp,
memory_store=self.memory,
sse_emitter=self.sse,
)
@staticmethod
def _assemble_response(
results: list[dict[str, Any]],
review_result: dict[str, Any],
) -> str:
"""Combine step results into a coherent final response."""
parts: list[str] = []
for i, r in enumerate(results):
content = r.get("content", "").strip()
if content:
parts.append(f"### Step {i + 1}\n{content}")
if review_result.get("content"):
parts.append(f"### Review\n{review_result['content']}")
return "\n\n".join(parts) if parts else "Task completed."

View File

@@ -0,0 +1,321 @@
"""Claude Code CLI compatible SSE format emitter.
Translates agenticSystem native events into the exact format that
Claude Code CLI produces, so the frontend can consume them without
any changes. Used via ?format=claude on the stream endpoint.
Wire format: data: {json}\n\n (no event: or id: fields)
"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any, AsyncIterator
from .sse import EventType, SSEEmitter
logger = logging.getLogger(__name__)
class ClaudeFormatEmitter:
"""Emits events in Claude Code CLI SSE format.
Maintains per-session state to track block indices and
accumulate content for assistant snapshots.
"""
def __init__(self) -> None:
self._queues: dict[str, list[asyncio.Queue[str | None]]] = {}
# Per-session state
self._block_counter: dict[str, int] = {}
self._text_block_open: dict[str, bool] = {}
self._text_block_index: dict[str, int] = {}
self._tool_block_index: dict[str, dict[str, int]] = {} # session -> {tool_call_id -> index}
self._content_blocks: dict[str, list[dict[str, Any]]] = {}
self._text_accumulator: dict[str, str] = {}
def _next_index(self, session_id: str) -> int:
idx = self._block_counter.get(session_id, 0)
self._block_counter[session_id] = idx + 1
return idx
def _reset_session(self, session_id: str) -> None:
self._block_counter[session_id] = 0
self._text_block_open[session_id] = False
self._text_block_index[session_id] = -1
self._tool_block_index[session_id] = {}
self._content_blocks[session_id] = []
self._text_accumulator[session_id] = ""
def _push(self, session_id: str, payload: dict[str, Any]) -> None:
"""Push a formatted line to all subscribers of a session."""
line = f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
for q in self._queues.get(session_id, []):
try:
q.put_nowait(line)
except asyncio.QueueFull:
logger.warning("Claude SSE queue full for session %s", session_id[:8])
def _close_text_block(self, session_id: str) -> None:
"""Close the current open text block if any."""
if self._text_block_open.get(session_id):
idx = self._text_block_index[session_id]
self._push(session_id, {
"type": "stream_event",
"event": {"type": "content_block_stop", "index": idx},
})
# Save accumulated text to content blocks
text = self._text_accumulator.get(session_id, "")
if text:
self._content_blocks.setdefault(session_id, []).append({
"type": "text", "text": text,
})
self._text_block_open[session_id] = False
self._text_accumulator[session_id] = ""
def _open_text_block(self, session_id: str) -> None:
"""Open a new text block."""
idx = self._next_index(session_id)
self._text_block_index[session_id] = idx
self._text_block_open[session_id] = True
self._text_accumulator[session_id] = ""
self._push(session_id, {
"type": "stream_event",
"event": {
"type": "content_block_start",
"index": idx,
"content_block": {"type": "text", "text": ""},
},
})
def _build_assistant_snapshot(self, session_id: str) -> dict[str, Any]:
"""Build assistant message snapshot for reconciliation."""
blocks = list(self._content_blocks.get(session_id, []))
return {
"type": "assistant",
"message": {"content": blocks},
"error": False,
}
async def emit(
self,
event_type: EventType,
data: dict[str, Any],
session_id: str,
) -> None:
"""Translate a native event into Claude Code CLI format."""
if event_type == EventType.EXECUTION_STARTED:
self._reset_session(session_id)
self._push(session_id, {
"type": "stream_event",
"event": {"type": "message_start"},
})
elif event_type == EventType.AGENT_DELTA:
delta_text = data.get("delta", "")
tool_args = data.get("tool_arguments", "")
tool_call_id = data.get("tool_call_id", "")
if delta_text:
# Text streaming
if not self._text_block_open.get(session_id):
self._open_text_block(session_id)
idx = self._text_block_index[session_id]
self._text_accumulator[session_id] = self._text_accumulator.get(session_id, "") + delta_text
self._push(session_id, {
"type": "stream_event",
"event": {
"type": "content_block_delta",
"index": idx,
"delta": {"type": "text_delta", "text": delta_text},
},
})
elif tool_args and tool_call_id:
# Tool input JSON streaming
tool_indices = self._tool_block_index.get(session_id, {})
idx = tool_indices.get(tool_call_id)
if idx is not None:
self._push(session_id, {
"type": "stream_event",
"event": {
"type": "content_block_delta",
"index": idx,
"delta": {"type": "input_json_delta", "partial_json": tool_args},
},
})
elif event_type == EventType.TOOL_STARTED:
tool_name = data.get("tool", "unknown")
tool_call_id = data.get("tool_call_id", "")
# Close open text block
self._close_text_block(session_id)
# Open tool_use block
idx = self._next_index(session_id)
self._tool_block_index.setdefault(session_id, {})[tool_call_id] = idx
self._push(session_id, {
"type": "stream_event",
"event": {
"type": "content_block_start",
"index": idx,
"content_block": {
"type": "tool_use",
"name": tool_name,
"id": tool_call_id,
},
},
})
elif event_type == EventType.TOOL_COMPLETED:
tool_name = data.get("tool", "unknown")
tool_call_id = data.get("tool_call_id", "")
status = data.get("status", "completed")
raw_output = data.get("raw_output", data.get("summary", ""))
is_error = status == "failed"
# Close tool_use block
tool_indices = self._tool_block_index.get(session_id, {})
idx = tool_indices.get(tool_call_id)
if idx is not None:
self._push(session_id, {
"type": "stream_event",
"event": {"type": "content_block_stop", "index": idx},
})
# Save tool_use to content blocks for snapshot
self._content_blocks.setdefault(session_id, []).append({
"type": "tool_use",
"id": tool_call_id,
"name": tool_name,
"input": {},
})
# Emit tool_result
content = data.get("error", raw_output) if is_error else raw_output
self._push(session_id, {
"type": "tool_result",
"tool_use_id": tool_call_id,
"content": content[:4000] if isinstance(content, str) else str(content)[:4000],
"is_error": is_error,
})
# Emit assistant snapshot for reconciliation
self._push(session_id, self._build_assistant_snapshot(session_id))
elif event_type == EventType.EXECUTION_COMPLETED:
# Close any open text block
self._close_text_block(session_id)
# Final assistant snapshot
self._push(session_id, self._build_assistant_snapshot(session_id))
# Result with usage
usage = data.get("usage", {})
self._push(session_id, {
"type": "result",
"is_error": False,
"usage": {
"input_tokens": usage.get("input_tokens", 0),
"output_tokens": usage.get("output_tokens", 0),
"cache_read_input_tokens": 0,
"cache_creation_input_tokens": 0,
},
"total_cost_usd": data.get("total_cost_usd", 0),
})
# Done
self._push(session_id, {"type": "done"})
elif event_type == EventType.ERROR:
error_msg = data.get("message", str(data.get("error", "Unknown error")))
# Close any open block
self._close_text_block(session_id)
self._push(session_id, {
"type": "result",
"is_error": True,
"result": error_msg,
"usage": {"input_tokens": 0, "output_tokens": 0, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0},
"total_cost_usd": 0,
})
self._push(session_id, {"type": "done"})
# Ignore other event types (KEEPALIVE, SESSION_CREATED, SUBAGENT_ASSIGNED)
async def subscribe(self, session_id: str) -> AsyncIterator[str]:
"""Subscribe to Claude-format SSE events for a session."""
queue: asyncio.Queue[str | None] = asyncio.Queue(maxsize=512)
if session_id not in self._queues:
self._queues[session_id] = []
self._queues[session_id].append(queue)
try:
while True:
try:
line = await asyncio.wait_for(queue.get(), timeout=15.0)
if line is None:
break
yield line
except asyncio.TimeoutError:
yield 'data: {"type":"keepalive"}\n\n'
finally:
if queue in self._queues.get(session_id, []):
self._queues[session_id].remove(queue)
def cleanup_session(self, session_id: str) -> None:
"""Clean up session state and close subscribers."""
for q in self._queues.get(session_id, []):
try:
q.put_nowait(None)
except asyncio.QueueFull:
pass
self._queues.pop(session_id, None)
self._block_counter.pop(session_id, None)
self._text_block_open.pop(session_id, None)
self._text_block_index.pop(session_id, None)
self._tool_block_index.pop(session_id, None)
self._content_blocks.pop(session_id, None)
self._text_accumulator.pop(session_id, None)
class DualEmitter:
"""Wraps SSEEmitter (native) + ClaudeFormatEmitter.
Agents call emit() and both formats are produced.
Duck-type compatible with SSEEmitter.
"""
def __init__(self, native: SSEEmitter, claude: ClaudeFormatEmitter) -> None:
self.native = native
self.claude = claude
async def emit(
self,
event_type: EventType,
data: dict[str, Any],
session_id: str,
) -> None:
await self.native.emit(event_type, data, session_id)
await self.claude.emit(event_type, data, session_id)
# Delegate native SSE methods for backward compatibility
async def subscribe(self, session_id: str) -> AsyncIterator[str]:
async for line in self.native.subscribe(session_id):
yield line
async def get_history(self, session_id: str) -> list[dict[str, Any]]:
return await self.native.get_history(session_id)
def cleanup_session(self, session_id: str) -> None:
self.native.cleanup_session(session_id)
self.claude.cleanup_session(session_id)
def set_storage(self, redis_storage: Any) -> None:
self.native.set_storage(redis_storage)