diff --git a/agents/acai/system.md b/agents/acai/system.md
index 0365538..570491c 100644
--- a/agents/acai/system.md
+++ b/agents/acai/system.md
@@ -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`:
diff --git a/mcp-server/tools/records/getModuleConfigVars.js b/mcp-server/tools/records/getModuleConfigVars.js
index ed36e8e..85b986b 100644
--- a/mcp-server/tools/records/getModuleConfigVars.js
+++ b/mcp-server/tools/records/getModuleConfigVars.js
@@ -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,
+ });
- const response = await AcaiHttpClient.getModuleConfigVars(
- credentials,
- credentials.token,
- credentials.tokenHash,
- payload
- );
-
- const apiError = handleApiResponse(response.data, 'get_module_config_vars');
- if (apiError) return apiError;
+ // 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,
+ };
+ }
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 });
}
})
);
diff --git a/mcp-server/tools/tables/createField.js b/mcp-server/tools/tables/createField.js
index 03374b5..eeb9df0 100644
--- a/mcp-server/tools/tables/createField.js
+++ b/mcp-server/tools/tables/createField.js
@@ -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)"),
diff --git a/mcp-server/tools/tables/updateField.js b/mcp-server/tools/tables/updateField.js
index eded563..2dcde2c 100644
--- a/mcp-server/tools/tables/updateField.js
+++ b/mcp-server/tools/tables/updateField.js
@@ -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"),
diff --git a/src/adapters/claude_adapter.py b/src/adapters/claude_adapter.py
index c8842e6..7890186 100644
--- a/src/adapters/claude_adapter.py
+++ b/src/adapters/claude_adapter.py
@@ -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:
+#
+#
+# ...
+#
+#
+#
+# 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"(.*?)",
+ re.IGNORECASE | re.DOTALL,
+)
+_PARAM_RE = re.compile(
+ r"(.*?)",
+ 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 `` cerrado, es un tag normal — emitir todo.
+ if ">" in tail:
+ return buf
+ # Si el tail puede ser inicio de tool_call/invoke, retenerlo.
+ candidates = (" 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 `= 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,