From 3af875ed113b60f5aa9b70775be1d04b0367508e Mon Sep 17 00:00:00 2001 From: Jordan Diaz Date: Tue, 28 Apr 2026 20:25:09 +0000 Subject: [PATCH] Ajustes de estructura --- agents/acai/system.md | 11 ++ .../tools/records/getModuleConfigVars.js | 91 ++++++++---- mcp-server/tools/tables/createField.js | 43 +++++- mcp-server/tools/tables/updateField.js | 22 ++- src/adapters/claude_adapter.py | 136 +++++++++++++++++- src/orchestrator/agents/base.py | 60 ++------ 6 files changed, 279 insertions(+), 84 deletions(-) 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,