Ajustes de estructura
This commit is contained in:
@@ -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.
|
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
|
## 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`:
|
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`:
|
||||||
|
|||||||
@@ -1,17 +1,32 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { withAuth, getSessionCredentials } from "../../auth/index.js";
|
import { withAuth } from "../../auth/index.js";
|
||||||
import { handleApiResponse, handleToolError, validateRequired } from "../helpers/errorHandler.js";
|
|
||||||
import { AcaiHttpClient } from "../helpers/acaiHttpClient.js";
|
|
||||||
import { withAuthParams } from "../helpers/authSchema.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) {
|
export function registerGetModuleConfigVarsTool(server) {
|
||||||
server.tool(
|
server.tool(
|
||||||
"get_module_config_vars",
|
"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:
|
Required params:
|
||||||
- tableName (string) without 'cms_' prefix
|
- tableName (string) without 'cms_' prefix (parent page table, e.g. 'apartados')
|
||||||
- recordNum (number) record primary key ('num' field, never 'id')
|
- recordNum (number) parent record primary key ('num', never 'id')
|
||||||
- sectionId (string) section ID of the module instance`,
|
- sectionId (string) section ID of the module instance`,
|
||||||
withAuthParams({
|
withAuthParams({
|
||||||
tableName: z.string().describe("Parent table name (e.g. 'apartados')"),
|
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"),
|
sectionId: z.string().describe("Section ID of the module instance"),
|
||||||
}),
|
}),
|
||||||
{ readOnlyHint: true, destructiveHint: false },
|
{ readOnlyHint: true, destructiveHint: false },
|
||||||
withAuth(async ({ tableName, recordNum, sectionId }, extra) => {
|
withAuth(async ({ tableName, recordNum, sectionId }, _extra) => {
|
||||||
try {
|
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;
|
if (validationError) return validationError;
|
||||||
|
|
||||||
const sessionId = extra.sessionId;
|
const { projectSlug } = getCurrentProjectInfo();
|
||||||
const credentials = await getSessionCredentials(sessionId);
|
const result = await pythonGet("/api/creator/get-module-vars", {
|
||||||
const payload = {
|
project: projectSlug,
|
||||||
tableName,
|
table: tableName,
|
||||||
recordNum,
|
num: recordNum,
|
||||||
sectionId
|
sectionId,
|
||||||
};
|
});
|
||||||
|
|
||||||
const response = await AcaiHttpClient.getModuleConfigVars(
|
// El endpoint Python devuelve la respuesta del PHP envuelta:
|
||||||
credentials,
|
// { success, data: { moduleId, vars, configVars, uploadFields, varsMeta, ... } }
|
||||||
credentials.token,
|
// o directamente las claves en raiz. Normalizamos.
|
||||||
credentials.tokenHash,
|
const inner = result?.data && typeof result.data === "object" ? result.data : result;
|
||||||
payload
|
if (!inner || inner.success === false) {
|
||||||
);
|
return {
|
||||||
|
content: [{
|
||||||
const apiError = handleApiResponse(response.data, 'get_module_config_vars');
|
type: "text",
|
||||||
if (apiError) return apiError;
|
text: JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error: inner?.error || "Could not read module config vars",
|
||||||
|
}),
|
||||||
|
}],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [{
|
content: [{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: JSON.stringify({
|
text: JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
action: 'get_module_config_vars',
|
action: "get_module_config_vars",
|
||||||
tableName,
|
tableName,
|
||||||
recordNum,
|
recordNum,
|
||||||
sectionId,
|
sectionId,
|
||||||
data: response.data?.data ?? response.data
|
data: {
|
||||||
}, null, 2)
|
moduleId: inner.moduleId,
|
||||||
}]
|
sectionId: inner.sectionId,
|
||||||
|
vars: inner.vars || {},
|
||||||
|
varsMeta: inner.varsMeta || {},
|
||||||
|
uploadFields: inner.uploadFields || {},
|
||||||
|
configVars: inner.configVars || {},
|
||||||
|
},
|
||||||
|
}, null, 2),
|
||||||
|
}],
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleToolError(error, 'get_module_config_vars', { tableName, recordNum, sectionId });
|
return handleToolError(error, "get_module_config_vars", { tableName, recordNum, sectionId });
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,7 +31,48 @@ Field types:
|
|||||||
- separator: visual separator in the form (no data column)
|
- separator: visual separator in the form (no data column)
|
||||||
|
|
||||||
'initialProps' is optional; use it to override defaults (e.g. {isRequired:1, maxLength:100}).
|
'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({
|
withAuthParams({
|
||||||
tableName: z.string().describe("Table name without 'cms_' prefix"),
|
tableName: z.string().describe("Table name without 'cms_' prefix"),
|
||||||
fieldName: z.string().describe("New field name (SQL-safe identifier)"),
|
fieldName: z.string().describe("New field name (SQL-safe identifier)"),
|
||||||
|
|||||||
@@ -29,7 +29,27 @@ Destructive cases:
|
|||||||
drops HTML). The backend returns 'warnings' in the response — surface them
|
drops HTML). The backend returns 'warnings' in the response — surface them
|
||||||
to the user.
|
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({
|
withAuthParams({
|
||||||
tableName: z.string().describe("Table name without 'cms_' prefix"),
|
tableName: z.string().describe("Table name without 'cms_' prefix"),
|
||||||
fieldName: z.string().describe("Current field name"),
|
fieldName: z.string().describe("Current field name"),
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
from typing import Any, AsyncIterator
|
from typing import Any, AsyncIterator
|
||||||
|
|
||||||
import anthropic
|
import anthropic
|
||||||
@@ -15,6 +17,75 @@ from .base import ModelAdapter, ModelConfig, ModelResponse, StreamChunk
|
|||||||
logger = logging.getLogger(__name__)
|
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
|
# Errores transitorios del proxy del modelo (MiniMax/Anthropic). Reintentamos
|
||||||
# con backoff exponencial: 1s, 3s, 9s. 529 es overloaded_error de Anthropic;
|
# con backoff exponencial: 1s, 3s, 9s. 529 es overloaded_error de Anthropic;
|
||||||
# 429 rate-limit; 503 service unavailable.
|
# 429 rate-limit; 503 service unavailable.
|
||||||
@@ -98,6 +169,14 @@ class ClaudeAdapter(ModelAdapter):
|
|||||||
current_tool_name = ""
|
current_tool_name = ""
|
||||||
accumulated_args = ""
|
accumulated_args = ""
|
||||||
input_tokens = 0
|
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:
|
async for event in stream:
|
||||||
yielded_any = True
|
yielded_any = True
|
||||||
@@ -121,7 +200,29 @@ class ClaudeAdapter(ModelAdapter):
|
|||||||
if event.type == "content_block_delta":
|
if event.type == "content_block_delta":
|
||||||
delta = event.delta
|
delta = event.delta
|
||||||
if delta.type == "text_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":
|
elif delta.type == "input_json_delta":
|
||||||
accumulated_args += delta.partial_json
|
accumulated_args += delta.partial_json
|
||||||
yield StreamChunk(
|
yield StreamChunk(
|
||||||
@@ -145,9 +246,40 @@ class ClaudeAdapter(ModelAdapter):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if event.type == "message_delta":
|
if event.type == "message_delta":
|
||||||
|
# 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(
|
||||||
|
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
|
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(
|
yield StreamChunk(
|
||||||
finish_reason=event.delta.stop_reason or "",
|
finish_reason=stop_reason,
|
||||||
usage={
|
usage={
|
||||||
"input_tokens": input_tokens,
|
"input_tokens": input_tokens,
|
||||||
"output_tokens": output_tokens,
|
"output_tokens": output_tokens,
|
||||||
|
|||||||
@@ -64,8 +64,6 @@ class BaseAgent:
|
|||||||
total_output_tokens = 0
|
total_output_tokens = 0
|
||||||
# Real conversation history: assistant messages + tool results
|
# Real conversation history: assistant messages + tool results
|
||||||
conversation: list[dict[str, Any]] = []
|
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):
|
for step in range(max_steps):
|
||||||
# Build context with real conversation
|
# Build context with real conversation
|
||||||
@@ -205,14 +203,19 @@ class BaseAgent:
|
|||||||
]
|
]
|
||||||
conversation.append(assistant_msg)
|
conversation.append(assistant_msg)
|
||||||
|
|
||||||
# Execute tool calls and add COMPLETE results to conversation
|
# Execute tool calls and add COMPLETE results to conversation.
|
||||||
duplicates_this_step = 0
|
# 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:
|
for tc in tool_calls:
|
||||||
# Si los args no se pudieron parsear (p.ej. truncados por max_tokens),
|
# 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
|
# NO ejecutamos la tool. En su lugar devolvemos un mensaje al modelo
|
||||||
# explicando el problema para que pueda ajustar el siguiente intento
|
# explicando el problema para que pueda ajustar el siguiente intento
|
||||||
# (dividir el contenido, acortar, etc.). Fingerprint incluye el hash
|
# (dividir el contenido, acortar, etc.).
|
||||||
# del raw para distinguir fallos distintos.
|
|
||||||
if tc.get("parse_error"):
|
if tc.get("parse_error"):
|
||||||
pe = tc["parse_error"]
|
pe = tc["parse_error"]
|
||||||
conversation.append({
|
conversation.append({
|
||||||
@@ -229,24 +232,6 @@ class BaseAgent:
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
continue
|
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(
|
tool_exec = await self._execute_tool(
|
||||||
session=session,
|
session=session,
|
||||||
@@ -255,7 +240,6 @@ class BaseAgent:
|
|||||||
artifacts=artifacts,
|
artifacts=artifacts,
|
||||||
tool_call_id=tc["id"],
|
tool_call_id=tc["id"],
|
||||||
)
|
)
|
||||||
tool_fingerprints[fp] = tool_exec
|
|
||||||
tool_executions.append(tool_exec)
|
tool_executions.append(tool_exec)
|
||||||
|
|
||||||
# COMPLETE result in conversation (truncated to safe limit)
|
# 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 {
|
return {
|
||||||
"content": accumulated_content,
|
"content": accumulated_content,
|
||||||
"artifacts": artifacts,
|
"artifacts": artifacts,
|
||||||
|
|||||||
Reference in New Issue
Block a user