Ajustes de estructura

This commit is contained in:
Jordan Diaz
2026-04-28 20:25:09 +00:00
parent 6881d64a08
commit 3af875ed11
6 changed files with 279 additions and 84 deletions

View File

@@ -128,6 +128,17 @@ Cuando creas una tabla con `enlace` (noticias, vacantes, blog), añade por defec
NO añadas un campo "estado" calculado si ya tienes `visible` + fechas.
## Schema (.ini.php) — NUNCA editar a mano
Los `cms/data/schema/*.ini.php` se modifican **exclusivamente** con las tools de schema: `create_table`, `update_table_metadata`, `delete_table`, `reorder_tables`, `create_field`, `update_field`, `delete_field`, `reorder_fields`. NO uses `acai-write` ni `acai-line-replace` sobre estos archivos:
- Saltarías validaciones (regex, tipos, etc.)
- No invalidas la cache de schemas — el frontend ve schema viejo
- No sincronizas con MySQL (no crea/borra columnas reales)
- Puedes romper el formato INI con un escape mal puesto
Para subcampos de un `multitext`, llama a `update_field` con `props.descriptionjson` como **string JSON** del array `[{id_campo, nombre_campo, tipo}, ...]`. La tool docu lo explica.
## Formularios embebidos en detalles
Si un detalle necesita un formulario (postular, pedir info), embebe el módulo del formulario **dentro** de la sección general pasándole el `num`:

View File

@@ -1,17 +1,32 @@
import { z } from "zod";
import { withAuth, getSessionCredentials } from "../../auth/index.js";
import { handleApiResponse, handleToolError, validateRequired } from "../helpers/errorHandler.js";
import { AcaiHttpClient } from "../helpers/acaiHttpClient.js";
import { withAuth } from "../../auth/index.js";
import { withAuthParams } from "../helpers/authSchema.js";
import { handleToolError, validateRequired } from "../helpers/errorHandler.js";
import { pythonGet } from "../helpers/pythonServerClient.js";
import { getCurrentProjectInfo } from "../files/helpers.js";
// get_module_config_vars
//
// Pasa por el server Python (/api/creator/get-module-vars) en lugar de ir
// directamente al PHP. Asi obtenemos `varsMeta`: para cada variable del
// modulo, su ubicacion fisica en `builder_custom` (recordNum + columna real).
// Sin esto el agente solo conoce el nombre humano de la variable (ej.
// `titulo`) y al hacer create_or_update_record adivina el campo y suele
// fallar silenciosamente porque la columna real es algo como `title2`.
export function registerGetModuleConfigVarsTool(server) {
server.tool(
"get_module_config_vars",
`Get the current configuration variable values for a module instance on a page record. Returns resolved values (text, HTML, etc.) for simple vars and arrays of objects for multi/repeater vars.
`Get the current configuration variable values for a module instance on a page record. Returns:
- vars: resolved values (text, HTML, etc.) for simple vars and arrays for multi/repeater vars
- varsMeta: per-var physical location { tableName: 'builder_custom', recordNum, fieldName, type }. USE THIS to know exactly which row + column to update with create_or_update_record. The variable's display name (e.g. 'titulo') is NOT the same as the physical column name (e.g. 'title2').
- uploadFields: per-var upload location for upload_record_image / replace_record_image
- moduleId, sectionId
Required params:
- tableName (string) without 'cms_' prefix
- recordNum (number) record primary key ('num' field, never 'id')
- tableName (string) without 'cms_' prefix (parent page table, e.g. 'apartados')
- recordNum (number) parent record primary key ('num', never 'id')
- sectionId (string) section ID of the module instance`,
withAuthParams({
tableName: z.string().describe("Parent table name (e.g. 'apartados')"),
@@ -19,44 +34,62 @@ Required params:
sectionId: z.string().describe("Section ID of the module instance"),
}),
{ readOnlyHint: true, destructiveHint: false },
withAuth(async ({ tableName, recordNum, sectionId }, extra) => {
withAuth(async ({ tableName, recordNum, sectionId }, _extra) => {
try {
const validationError = validateRequired({ tableName, recordNum, sectionId }, ['tableName', 'recordNum', 'sectionId'], 'get_module_config_vars');
const validationError = validateRequired(
{ tableName, recordNum, sectionId },
["tableName", "recordNum", "sectionId"],
"get_module_config_vars"
);
if (validationError) return validationError;
const sessionId = extra.sessionId;
const credentials = await getSessionCredentials(sessionId);
const payload = {
tableName,
recordNum,
sectionId
const { projectSlug } = getCurrentProjectInfo();
const result = await pythonGet("/api/creator/get-module-vars", {
project: projectSlug,
table: tableName,
num: recordNum,
sectionId,
});
// El endpoint Python devuelve la respuesta del PHP envuelta:
// { success, data: { moduleId, vars, configVars, uploadFields, varsMeta, ... } }
// o directamente las claves en raiz. Normalizamos.
const inner = result?.data && typeof result.data === "object" ? result.data : result;
if (!inner || inner.success === false) {
return {
content: [{
type: "text",
text: JSON.stringify({
success: false,
error: inner?.error || "Could not read module config vars",
}),
}],
isError: true,
};
const response = await AcaiHttpClient.getModuleConfigVars(
credentials,
credentials.token,
credentials.tokenHash,
payload
);
const apiError = handleApiResponse(response.data, 'get_module_config_vars');
if (apiError) return apiError;
}
return {
content: [{
type: "text",
text: JSON.stringify({
success: true,
action: 'get_module_config_vars',
action: "get_module_config_vars",
tableName,
recordNum,
sectionId,
data: response.data?.data ?? response.data
}, null, 2)
}]
data: {
moduleId: inner.moduleId,
sectionId: inner.sectionId,
vars: inner.vars || {},
varsMeta: inner.varsMeta || {},
uploadFields: inner.uploadFields || {},
configVars: inner.configVars || {},
},
}, null, 2),
}],
};
} catch (error) {
return handleToolError(error, 'get_module_config_vars', { tableName, recordNum, sectionId });
return handleToolError(error, "get_module_config_vars", { tableName, recordNum, sectionId });
}
})
);

View File

@@ -31,7 +31,48 @@ Field types:
- separator: visual separator in the form (no data column)
'initialProps' is optional; use it to override defaults (e.g. {isRequired:1, maxLength:100}).
Table names WITHOUT 'cms_' prefix. Primary key is always 'num'.`,
Table names WITHOUT 'cms_' prefix. Primary key is always 'num'.
MULTITEXT FIELDS (type='multitext') — initialProps shape:
A multitext field is a repeater of N sub-fields per row. Each row in the
admin form gives the user a set of sub-fields to fill (e.g. a list of FAQ
items where each one has 'question' + 'answer' + 'category').
- tablaAuxiliar (string, opcional): nombre de tabla auxiliar para el repeater.
- descriptionjson (REQUIRED): JSON STRING (not object) with the array of
sub-fields. Each sub-field is an object with:
- id_campo (string): clave tecnica, slug. Estable: NO se cambia despues.
- nombre_campo (string): etiqueta visible en la UI.
- tipo (string '0'..'5'): '0'=texto, '1'=tabla CMS, '3'=fecha, '4'=color, '5'=icono.
- When tipo='1': also pass tabla (target table without 'cms_'),
campo_valor (default 'num'), campo_muestra (label field).
Example (correct):
"descriptionjson": "[{\\"id_campo\\":\\"pregunta\\",\\"nombre_campo\\":\\"Pregunta\\",\\"tipo\\":\\"0\\"},{\\"id_campo\\":\\"respuesta\\",\\"nombre_campo\\":\\"Respuesta\\",\\"tipo\\":\\"0\\"}]"
⚠ It MUST be a JSON-encoded string. If you pass an object the backend
rejects it. Strings, single-quoted JSON, or other formats break the editor.
LIST FIELDS (type='list') — initialProps shape:
- listType (REQUIRED): one of 'pulldown' | 'radios' | 'pulldownMulti' | 'checkboxes'.
NOT 'select' nor 'dropdown' — use 'pulldown'.
- optionsType (REQUIRED): one of 'text' | 'table' | 'query'.
- When optionsType='text': pass 'optionsText' as a SINGLE string with one
option per line, separated by REAL NEWLINE CHARACTERS ('\\n' in JSON).
Each line is either 'value|Label' (preferred) or just 'label' (value=label).
⚠ Do NOT separate options with commas. Commas inside an option are valid
data — Acai uses '\\n' as the option delimiter, period.
Example (correct):
"optionsText": "indefinido|Indefinido\\ntemporal|Temporal\\nfreelance|Freelance"
Example (WRONG, will store all 4 as a single option):
"optionsText": "Indefinido,Temporal,Prácticas,Freelance"
- When optionsType='table': pass 'optionsTablename' (target table without cms_),
'optionsValueField' (default 'num'), 'optionsLabelField'. Optional 'filterField'
is a SQL WHERE clause without the WHERE keyword (e.g. "visible=1").
- When optionsType='query': pass 'optionsQuery' as raw SQL. Acai uses POSITIONAL
columns: column 0 is the value, column 1 is the label. So write
"SELECT num, titulo FROM cms_xxx WHERE active=1" — the 'AS value/label'
aliases have NO effect.`,
withAuthParams({
tableName: z.string().describe("Table name without 'cms_' prefix"),
fieldName: z.string().describe("New field name (SQL-safe identifier)"),

View File

@@ -29,7 +29,27 @@ Destructive cases:
drops HTML). The backend returns 'warnings' in the response — surface them
to the user.
Table names WITHOUT 'cms_' prefix.`,
Table names WITHOUT 'cms_' prefix.
MULTITEXT FIELDS — when 'props' touches multitext config:
- descriptionjson: JSON STRING (not object) with the array of sub-fields.
Each: {id_campo, nombre_campo, tipo} where tipo is '0'(texto)|'1'(tabla)|
'3'(fecha)|'4'(color)|'5'(icono). For tipo='1' also include tabla,
campo_valor, campo_muestra.
⚠ MUST be JSON-encoded string. Backend rejects objects directly.
Example: "descriptionjson":"[{\\"id_campo\\":\\"pregunta\\",\\"nombre_campo\\":\\"Pregunta\\",\\"tipo\\":\\"0\\"}]"
LIST FIELDS — when 'props' touches list config:
- listType: 'pulldown' | 'radios' | 'pulldownMulti' | 'checkboxes' (NOT 'select').
- optionsType: 'text' | 'table' | 'query'.
- optionsText (for optionsType='text'): one option per LINE, separated by
'\\n' (real newline). Each line is 'value|Label' or just 'label'.
⚠ Do NOT use commas as the option separator — commas are valid inside
a label. Example (correct): "indef|Indefinido\\ntemp|Temporal".
- optionsTablename / optionsValueField / optionsLabelField / filterField
for optionsType='table'.
- optionsQuery for optionsType='query' — column 0 is the value, column 1
the label (positional, 'AS value/label' aliases are ignored).`,
withAuthParams({
tableName: z.string().describe("Table name without 'cms_' prefix"),
fieldName: z.string().describe("Current field name"),

View File

@@ -5,6 +5,8 @@ from __future__ import annotations
import asyncio
import json
import logging
import re
import uuid
from typing import Any, AsyncIterator
import anthropic
@@ -15,6 +17,75 @@ from .base import ModelAdapter, ModelConfig, ModelResponse, StreamChunk
logger = logging.getLogger(__name__)
# Algunos fine-tunes (sobre todo MiniMax) ocasionalmente emiten las tool calls
# como texto literal en lugar de usar los `tool_use` blocks nativos:
# <minimax:tool_call>
# <invoke name="acai_code__acai_view">
# <parameter name="file_path">...</parameter>
# </invoke>
# </minimax:tool_call>
#
# Cuando eso pasa el orquestador ve "texto" y la tool nunca se ejecuta — el
# usuario ve el XML crudo en el chat. Detectamos y convertimos a tool_use
# sintetico mientras streameamos. Es un parche defensivo: el caso normal
# (tool_use blocks) sigue por el camino estandar.
_TOOL_CALL_OPEN_RE = re.compile(r"<(?:minimax:tool_call|invoke\s+name)", re.IGNORECASE)
_INVOKE_RE = re.compile(
r"<invoke\s+name=\"([^\"]+)\"\s*>(.*?)</invoke>",
re.IGNORECASE | re.DOTALL,
)
_PARAM_RE = re.compile(
r"<parameter\s+name=\"([^\"]+)\"\s*>(.*?)</parameter>",
re.IGNORECASE | re.DOTALL,
)
def _safe_emit_split(buf: str) -> str:
"""Devuelve el prefijo del buffer que es seguro emitir como texto sin
perder un posible inicio de tag XML que esta llegando fragmentado.
Mantenemos en hold los ultimos 30 chars si terminan con `<` o con un
prefijo parcial de `<minimax:tool_call` / `<invoke`. Si el buffer es
largo y no termina con `<`, todo es seguro.
"""
if not buf:
return ""
# Buscar el ultimo `<` y comprobar si lo que sigue puede ser apertura.
idx = buf.rfind("<")
if idx == -1:
return buf
tail = buf[idx:]
# Si el tail ya tiene `>` cerrado, es un tag normal — emitir todo.
if ">" in tail:
return buf
# Si el tail puede ser inicio de tool_call/invoke, retenerlo.
candidates = ("<minimax:tool_call", "<invoke")
for cand in candidates:
if cand.startswith(tail.lower()) or tail.lower().startswith(cand[:len(tail)].lower()):
return buf[:idx]
# No coincide con ninguna apertura sospechosa — emitir todo.
return buf
def _parse_xml_tool_calls(text: str) -> list[dict[str, Any]]:
"""Extrae tool calls del texto. Devuelve lista de {id, name, arguments}.
Si no encuentra patrones validos devuelve []."""
calls = []
for m in _INVOKE_RE.finditer(text):
name = m.group(1).strip()
body = m.group(2)
args = {}
for p in _PARAM_RE.finditer(body):
args[p.group(1).strip()] = p.group(2).strip()
if name:
calls.append({
"id": "xml_{}".format(uuid.uuid4().hex[:12]),
"name": name,
"arguments": args,
})
return calls
# Errores transitorios del proxy del modelo (MiniMax/Anthropic). Reintentamos
# con backoff exponencial: 1s, 3s, 9s. 529 es overloaded_error de Anthropic;
# 429 rate-limit; 503 service unavailable.
@@ -98,6 +169,14 @@ class ClaudeAdapter(ModelAdapter):
current_tool_name = ""
accumulated_args = ""
input_tokens = 0
# Buffer + flag para detectar XML tool calls inline (MiniMax).
# En modo "text", emitimos delta directamente. Si vemos `<invoke`
# o `<minimax:tool_call`, pasamos a modo "buffer" y dejamos de
# emitir hasta cerrar el bloque o terminar el mensaje. Al final
# parseamos y emitimos un tool_use sintetico.
text_buffer = ""
in_xml_capture = False
xml_buffer = ""
async for event in stream:
yielded_any = True
@@ -121,7 +200,29 @@ class ClaudeAdapter(ModelAdapter):
if event.type == "content_block_delta":
delta = event.delta
if delta.type == "text_delta":
yield StreamChunk(delta=delta.text)
text_buffer += delta.text
if in_xml_capture:
xml_buffer += delta.text
else:
# Detectar inicio del bloque XML antes de emitir.
m = _TOOL_CALL_OPEN_RE.search(text_buffer)
if m:
# Emitir el texto previo al match (texto
# legitimo que el modelo escribio antes del XML).
prev = text_buffer[:m.start()]
if prev:
yield StreamChunk(delta=prev)
in_xml_capture = True
xml_buffer = text_buffer[m.start():]
text_buffer = ""
else:
# Holdback: si el final del buffer parece
# arrancar una apertura ('<', '<i', '<inv'...)
# esperamos al siguiente delta antes de emitir.
safe = _safe_emit_split(text_buffer)
if safe:
yield StreamChunk(delta=safe)
text_buffer = text_buffer[len(safe):]
elif delta.type == "input_json_delta":
accumulated_args += delta.partial_json
yield StreamChunk(
@@ -145,9 +246,40 @@ class ClaudeAdapter(ModelAdapter):
continue
if event.type == "message_delta":
output_tokens = getattr(event.usage, "output_tokens", 0) if event.usage else 0
# Antes de cerrar, vaciar buffers.
if in_xml_capture and xml_buffer:
# Parsear el XML capturado y emitir tool_use sinteticos.
calls = _parse_xml_tool_calls(xml_buffer)
if calls:
logger.info(
"Detected %d inline XML tool call(s) — converting to tool_use",
len(calls),
)
for c in calls:
yield StreamChunk(
finish_reason=event.delta.stop_reason or "",
tool_call_id=c["id"],
tool_name=c["name"],
)
yield StreamChunk(
tool_call_id=c["id"],
tool_name=c["name"],
tool_arguments=json.dumps(c["arguments"]),
finish_reason="tool_use",
)
else:
# No se pudo parsear — devolver al usuario el
# texto crudo para no perderlo silenciosamente.
yield StreamChunk(delta=xml_buffer)
xml_buffer = ""
in_xml_capture = False
elif text_buffer:
yield StreamChunk(delta=text_buffer)
text_buffer = ""
output_tokens = getattr(event.usage, "output_tokens", 0) if event.usage else 0
# Si convertimos XML a tool_use, override el stop_reason.
stop_reason = event.delta.stop_reason or ""
yield StreamChunk(
finish_reason=stop_reason,
usage={
"input_tokens": input_tokens,
"output_tokens": output_tokens,

View File

@@ -64,8 +64,6 @@ class BaseAgent:
total_output_tokens = 0
# Real conversation history: assistant messages + tool results
conversation: list[dict[str, Any]] = []
tool_fingerprints: dict[str, ToolExecution] = {}
all_duplicates_streak = 0 # consecutive steps where ALL calls are duplicates
for step in range(max_steps):
# Build context with real conversation
@@ -205,14 +203,19 @@ class BaseAgent:
]
conversation.append(assistant_msg)
# Execute tool calls and add COMPLETE results to conversation
duplicates_this_step = 0
# Execute tool calls and add COMPLETE results to conversation.
# Antes habia dos capas anti-duplicado: (a) cachear resultado y
# devolver "[DUPLICADO]" en lugar de re-ejecutar y (b) cortar el
# step si TODAS las llamadas del paso eran duplicadas. Las quitamos
# porque en conversaciones largas el agente puede LEGITIMAMENTE
# repetir una llamada (p.ej. re-leer un fichero tras editarlo) y
# las heuristicas bloqueaban acciones validas. El usuario prefiere
# libertad — runaway loops se mitigan con limit de steps externo.
for tc in tool_calls:
# Si los args no se pudieron parsear (p.ej. truncados por max_tokens),
# NO ejecutamos la tool. En su lugar devolvemos un mensaje al modelo
# explicando el problema para que pueda ajustar el siguiente intento
# (dividir el contenido, acortar, etc.). Fingerprint incluye el hash
# del raw para distinguir fallos distintos.
# (dividir el contenido, acortar, etc.).
if tc.get("parse_error"):
pe = tc["parse_error"]
conversation.append({
@@ -229,24 +232,6 @@ class BaseAgent:
),
})
continue
fp_raw = f"{tc['name']}:{json.dumps(tc.get('parsed_arguments', {}), sort_keys=True)}"
fp = hashlib.md5(fp_raw.encode()).hexdigest()
if fp in tool_fingerprints:
prev_exec = tool_fingerprints[fp]
tool_executions.append(prev_exec)
duplicates_this_step += 1
# Return cached result as tool message
conversation.append({
"role": "tool",
"tool_call_id": tc["id"],
"content": (
"[DUPLICADO] Ya ejecutada con mismos argumentos. Resultado: "
f"{prev_exec.raw_output[:settings.tool_raw_output_max_chars]}"
),
})
logger.warning("Duplicate tool call skipped: %s (fingerprint: %s)", tc["name"], fp[:8])
continue
tool_exec = await self._execute_tool(
session=session,
@@ -255,7 +240,6 @@ class BaseAgent:
artifacts=artifacts,
tool_call_id=tc["id"],
)
tool_fingerprints[fp] = tool_exec
tool_executions.append(tool_exec)
# COMPLETE result in conversation (truncated to safe limit)
@@ -269,32 +253,6 @@ class BaseAgent:
),
})
# Loop detection: if ALL tool calls in this step were duplicates
if duplicates_this_step == len(tool_calls):
all_duplicates_streak += 1
if all_duplicates_streak >= 2:
logger.warning("Loop detected: %d consecutive steps with all duplicate calls. Breaking.", all_duplicates_streak)
conversation.append({
"role": "user",
"content": "[SISTEMA] Se detectaron llamadas repetidas. Ya tienes toda la información necesaria. Genera tu respuesta final ahora.",
})
# One more chance to generate a final response
ctx = await self.context.build_context(
session=session, agent=self.profile,
artifacts=artifacts, conversation=conversation,
)
async for chunk in self.model.stream(
messages=ctx.to_messages(),
config=config,
):
if chunk.delta:
accumulated_content += chunk.delta
if chunk.finish_reason:
break
break
else:
all_duplicates_streak = 0
return {
"content": accumulated_content,
"artifacts": artifacts,