zelin-bot / src /tools.js
Z User
v5.8.5: Gemma 4, MC Wiki, MC Player, anti-hallucination, CPU optimizations
ee826ee
/**
* tools.js — Herramientas reales que Zelin puede ejecutar
*
* La IA pide herramientas antes de responder.
* El sistema las ejecuta y devuelve resultados reales.
* Así Zelin nunca inventa datos que puede consultar.
*/
import * as db from './db.js';
import * as minecraft from './minecraft.js';
import * as skills from './skills.js';
import * as brain from './brain.js';
import { readConfig } from './utils.js';
import { findChannelByNameOrId } from './memory.js';
import { webSearch, executeWebTask, getBrowserStatus } from './browser-agent.js';
import { sanitizeOutput } from './security.js';
const config = readConfig();
// ── Definición de tools (va al prompt) ───────────────────────────────────────
export const TOOL_DEFINITIONS = `## HERRAMIENTAS DISPONIBLES
Cuando necesites datos reales, incluye al INICIO de tu respuesta:
<tools>["tool1", "tool2"]</tools>
REGLAS: nombre EXACTO, parametro con : (web_search:minecraft noticias), max 3 tools.
NO uses tools para cosas que ya sabes. SI usa tools para datos en tiempo real.
OBLIGATORIO: para preguntas de hora/tiempo en cualquier país → get_time:país (NUNCA adivines)
EJEMPLOS:
- "qué hora es en perú" → <tools>["get_time:peru"]</tools>
- "qué hora es en japón" → <tools>["get_time:japon"]</tools>
- "cuántos jugadores hay" → <tools>["mc_status"]</tools>
REGLAS CRÍTICAS DE CUÁNDO NO USAR TOOLS:
- NO uses user_info si nadie pregunta por un usuario específico con @ o su nombre exacto
- NO uses user_info para palabras genéricas como "chicos", "todos", "banda", "demonios", etc.
- Solo usa user_info cuando alguien diga "info de @juan" o "qué hizo tomatito" explícitamente
- Si no hay herramienta para algo, simplemente responde conversacionalmente
TOOLS DISPONIBLES:
mc_status - jugadores online, version, estado del server ahora mismo
mc_wiki - buscar en Minecraft Wiki: mc_wiki:creeper o mc_wiki:diamond_sword (OBLIGATORIO para preguntas de Minecraft vanilla)
mc_player - datos reales de jugador MC: mc_player:nombre (UUID, skin, historial)
discord_online - cuanta gente esta en Discord en este momento
db_user_profile - perfil del usuario actual: mensajes, primera vez que habló, info
db_top_users - ranking de usuarios mas activos esta semana
db_server_stats - stats generales: total usuarios, mensajes, activos hoy
db_recent_messages - ultimos mensajes del canal actual
db_mod_history - historial de sanciones del usuario actual
db_user_list - lista de usuarios activos
list_roles - roles del servidor con cuantos miembros tiene cada uno
list_members - buscar miembro: list_members:nombre
user_info - info completa: user_info:nombre o user_info:@mencion
audit_log - acciones admin recientes de Zelin
send_to_channel - mandar mensaje: send_to_channel:canal:texto del mensaje
read_channel - leer mensajes de canal: read_channel:nombre-canal
web_search - buscar en internet: web_search:consulta
web_fetch - leer pagina web: web_fetch:https://url.com
analyze_image - analizar imagen: analyze_image:https://url.com/img.jpg
REGLAS CRÍTICAS DE MINECRAFT:
- Para CUALQUIER pregunta sobre Minecraft vanilla (mobs, items, crafting, biomas, enchantments, etc.) → usa mc_wiki OBLIGATORIAMENTE
- NUNCA inventes datos de Minecraft — siempre consulta mc_wiki primero
- NUNCA inventes jugadores, stats, horas de juego — usa mc_player para datos reales
- Si mc_wiki no tiene la respuesta exacta, di 'no estoy segura' en vez de inventar
Ejemplos correctos:
- Cuantos hay online? -> <tools>["mc_status"]</tools>
- Qué es un creeper? -> <tools>["mc_wiki:creeper"]</tools>
- Cómo se crafts una mesa de encantamientos? -> <tools>["mc_wiki:enchanting_table"]</tools>
- Quién es el jugador X? -> <tools>["mc_player:nombre"]</tools>
- Que hizo @juan? -> <tools>["user_info:juan", "db_mod_history"]</tools>
- Novedades Minecraft? -> <tools>["web_search:novedades Minecraft 2025"]</tools>`;
// Tool call audit log — production best practice (Amazon AI research: every call logged)
async function _logToolCall(toolName, result, context) {
try {
await db.memSet(
'audit.tool.' + Date.now(),
{ tool: toolName, channel: context?.channelId, user: context?.userId, len: result?.length ?? 0, ts: new Date().toISOString() },
'tool_audit'
);
} catch {}
}
// ── Ejecutar una herramienta ──────────────────────────────────────────────────
// Buscar canal de forma robusta: exact → includes → slug
function findChannel(guild, query) {
if (!guild || !query) return null;
const q = query.toLowerCase().trim();
// Normalizar: quitar emojis, símbolos, espacios extra
const normalize = s => s.toLowerCase().replace(/[^\w\d-]/g, '').replace(/-+/g, '-');
const qn = normalize(q);
return (
// 1. Nombre exacto
guild.channels.cache.find(c => c.isTextBased?.() && c.name.toLowerCase() === q) ||
// 2. Nombre normalizado exacto (sin emojis/prefijos)
guild.channels.cache.find(c => c.isTextBased?.() && normalize(c.name) === qn) ||
// 3. Contiene el query normalizado
guild.channels.cache.find(c => c.isTextBased?.() && normalize(c.name).includes(qn)) ||
// 4. El query contiene el nombre del canal
guild.channels.cache.find(c => c.isTextBased?.() && qn.includes(normalize(c.name)) && normalize(c.name).length > 2) ||
null
);
}
// Extraer texto de un mensaje (content + embeds)
function extractMessageText(msg) {
const parts = [];
if (msg.content) parts.push(msg.content);
for (const e of msg.embeds || []) {
if (e.title) parts.push(`[embed: ${e.title}]`);
if (e.description) parts.push(e.description.substring(0, 200));
for (const f of e.fields || []) parts.push(`${f.name}: ${f.value}`.substring(0, 100));
}
if (msg.attachments?.size) parts.push(`[adjunto: ${[...msg.attachments.values()].map(a=>a.name).join(', ')}]`);
return parts.join(' | ') || '(vacío)';
}
export async function executeTool(toolSpec, context) {
const { channelId, userId, guild, client } = context;
// Parsear tool con parámetros: "send_to_channel:anuncios:Hola"
const parts = toolSpec.split(':');
const toolName = parts[0];
const param1 = parts[1] ?? '';
const param2 = parts.slice(2).join(':');
try {
switch (toolName) {
case 'mc_status': {
const s = await minecraft.fetchStatus();
const ip = `${config.server.ip}:${config.server.port}`;
if (!s.online) return `Servidor MC: OFFLINE\nIP: ${ip}\nÚltimo fallo: ${s.failCount || 0} intentos`;
let r = `Servidor MC: ONLINE\nIP: ${ip}\nJugadores: ${s.players}/${s.max}\nVersión: ${s.version}`;
if (s.motd) r += `\nMOTD: ${s.motd}`;
if (s.sample?.length) r += `\nConectados: ${s.sample.join(', ')}`;
return r;
}
case 'mc_wiki': {
// Buscar información REAL en Minecraft Wiki — NUNCA inventar datos de MC
const query = param1 || toolSpec.replace('mc_wiki:', '').trim();
if (!query) return 'Error: especifica qué buscar en la wiki (ej: mc_wiki:creeper)';
try {
// Estrategia 1: Minecraft Wiki API (wiki.gg) — fuente oficial
const wikiSearchUrl = `https://minecraft.wiki/api.php?action=query&list=search&srsearch=${encodeURIComponent(query)}&format=json&srlimit=3&origin=*`;
const searchRes = await fetch(wikiSearchUrl, {
signal: AbortSignal.timeout(8000),
headers: { 'User-Agent': 'Zelin-Bot/5.8 (TomateSMP Discord Bot)' }
});
if (searchRes.ok) {
const searchData = await searchRes.json();
const results = searchData.query?.search;
if (results?.length > 0) {
// Obtener el extracto del primer resultado
const topResult = results[0];
const title = topResult.title;
// Obtener contenido más detallado
const detailUrl = `https://minecraft.wiki/api.php?action=query&titles=${encodeURIComponent(title)}&prop=extracts&exintro=true&explaintext=true&format=json&origin=*`;
const detailRes = await fetch(detailUrl, {
signal: AbortSignal.timeout(8000),
headers: { 'User-Agent': 'Zelin-Bot/5.8 (TomateSMP Discord Bot)' }
});
if (detailRes.ok) {
const detailData = await detailRes.json();
const pages = detailData.query?.pages;
if (pages) {
const pageId = Object.keys(pages)[0];
const extract = pages[pageId]?.extract;
if (extract) {
// Limpiar y truncar extracto
const cleaned = extract.replace(/\n{3,}/g, '\n\n').trim();
const truncated = cleaned.length > 1500 ? cleaned.slice(0, 1500) + '...' : cleaned;
return `📖 Minecraft Wiki — ${title}\n\n${truncated}\n\n🔗 https://minecraft.wiki/w/${encodeURIComponent(title)}`;
}
}
}
// Fallback: usar snippet del search
const snippet = topResult.snippet?.replace(/<[^>]+>/g, '').trim();
if (snippet) {
return `📖 Minecraft Wiki — ${title}\n${snippet}\n\n🔗 https://minecraft.wiki/w/${encodeURIComponent(title)}`;
}
}
}
// Estrategia 2: Fallback a web_search con "minecraft wiki"
try {
const r = await webSearch(`minecraft wiki ${query}`, { maxResults: 3 });
if (r.results?.length > 0) {
const top = r.results[0];
return `📖 Búsqueda MC Wiki: ${query}\n${top.title}\n${top.snippet?.slice(0, 300)}\n🔗 ${top.url}`;
}
} catch {}
return `No encontré "${query}" en la Minecraft Wiki. No invento datos — mejor busca en https://minecraft.wiki`;
} catch (e) {
return `Error buscando en MC Wiki: ${e.message}`;
}
}
case 'mc_player': {
// Datos REALES de jugadores Minecraft via Mojang API — NUNCA inventar
const playerName = param1 || toolSpec.replace('mc_player:', '').trim();
if (!playerName) return 'Error: especifica el nombre del jugador (ej: mc_player:Notch)';
try {
// Mojang API: obtener UUID y perfil real del jugador
const profileUrl = `https://api.mojang.com/users/profiles/minecraft/${encodeURIComponent(playerName)}`;
const profileRes = await fetch(profileUrl, {
signal: AbortSignal.timeout(8000),
headers: { 'User-Agent': 'Zelin-Bot/5.8 (TomateSMP Discord Bot)' }
});
if (!profileRes.ok) {
if (profileRes.status === 404) {
return `El jugador "${playerName}" NO EXISTE en Minecraft. No invento datos de jugadores.`;
}
return `Error consultando Mojang API (status ${profileRes.status}). No puedo verificar si el jugador existe.`;
}
const profileData = await profileRes.json();
const uuid = profileData.id;
const name = profileData.name;
// Formatear UUID con guiones
const formattedUuid = uuid.replace(/(\w{8})(\w{4})(\w{4})(\w{4})(\w{12})/, '$1-$2-$3-$4-$5');
// Obtener historial de nombres
let nameHistory = '';
try {
const namesUrl = `https://api.mojang.com/user/profile/${uuid}/names`;
const namesRes = await fetch(namesUrl, {
signal: AbortSignal.timeout(5000),
headers: { 'User-Agent': 'Zelin-Bot/5.8 (TomateSMP Discord Bot)' }
});
if (namesRes.ok) {
const namesData = await namesRes.json();
if (namesData.length > 1) {
nameHistory = '\nNombres anteriores: ' + namesData.slice(0, -1).map(n => n.name).join(', ');
}
}
} catch {}
// Skin info
const skinUrl = `https://sessionserver.mojang.com/session/minecraft/profile/${uuid}`;
let skinInfo = '';
try {
const skinRes = await fetch(skinUrl, {
signal: AbortSignal.timeout(5000),
headers: { 'User-Agent': 'Zelin-Bot/5.8 (TomateSMP Discord Bot)' }
});
if (skinRes.ok) {
skinInfo = '\nSkin: ✅ Tiene skin personalizada';
}
} catch {}
// Verificar si está en el servidor TomateSMP
let serverInfo = '';
try {
const mcStatus = await minecraft.fetchStatus();
if (mcStatus.online && mcStatus.sample?.includes(name)) {
serverInfo = '\n🟢 Actualmente conectado en TomateSMP';
}
} catch {}
return `🎮 Jugador Minecraft VERIFICADO\nNombre: ${name}\nUUID: ${formattedUuid}\nCuenta: Premium (verificada por Mojang)${nameHistory}${skinInfo}${serverInfo}\n\n⚠️ NOTA: Mojang NO provee horas de juego ni stats públicas. Esas métricas son PRIVADAS y no accesibles. NUNCA inventes datos de horas jugadas.`;
} catch (e) {
return `Error consultando datos del jugador: ${e.message}. No invento datos.`;
}
}
case 'discord_online': {
if (!guild) return 'No hay guild en contexto';
const stats = brain.getGuildStats(guild);
return `Discord online ahora: ${stats.online}/${stats.total} miembros\nServidor: ${stats.name}`;
}
case 'db_user_profile': {
if (!userId) return 'Sin usuario en contexto';
const u = await db.getUserWithRoles(userId);
if (!u) return 'Usuario no encontrado en DB';
const name = u.nickname || u.global_name || u.username;
const roles = u.roles?.join(', ') || 'sin roles';
const since = u.joined_at ? new Date(u.joined_at).toLocaleDateString('es-ES') : '?';
return `Perfil: ${name}\nUsername: ${u.username}\nRoles: ${roles}\nMensajes: ${u.message_count || 0}\nEn el servidor desde: ${since}${u.notes ? `\nNotas: ${u.notes}` : ''}`;
}
case 'db_top_users': {
const top = await db.getTopUsers(7, 5);
if (!top.length) return 'Sin datos de actividad esta semana';
return 'Más activos esta semana:\n' + top.map((u,i)=>`${i+1}. ${u.nickname||u.username}: ${u.count} msgs`).join('\n');
}
case 'db_server_stats': {
const s = await db.getServerStats();
return `Stats:\nUsuarios activos: ${s.activeUsers}\nMensajes totales: ${s.totalMessages}\nActivos hoy: ${s.activeToday}\nAcciones mod: ${s.modActions}`;
}
case 'db_recent_messages': {
if (!channelId) return 'Sin canal en contexto';
const msgs = await db.getRecentMessages(channelId, 15);
if (!msgs.length) return 'No hay mensajes recientes';
return 'Últimos mensajes:\n' + msgs.map(m=>`[${m.nickname||m.username}]: ${m.content}`).join('\n');
}
case 'db_mod_history': {
if (!userId) return 'Sin usuario en contexto';
const hist = await db.getUserModHistory(userId, 5);
if (!hist.length) return 'Sin historial de moderación';
return 'Historial mod:\n' + hist.map(h=>`- ${h.action}: ${h.reason} (${new Date(h.created_at).toLocaleDateString('es-ES')})`).join('\n');
}
case 'db_user_list': {
const r = await db.db.execute(
`SELECT username, nickname, message_count FROM users WHERE is_active = 1 AND bot = 0 ORDER BY message_count DESC LIMIT 20`
);
if (!r.rows.length) return 'No hay usuarios en la DB';
return 'Usuarios activos:\n' + r.rows.map(u=>`- ${u.nickname||u.username} (${u.message_count||0} msgs)`).join('\n');
}
case 'db_recent_logs': {
const r = await db.db.execute(
`SELECT key, value FROM zelin_memory WHERE category = 'logs' ORDER BY key DESC LIMIT 10`
);
if (!r.rows.length) return 'Sin logs recientes procesados';
return 'Logs recientes:\n' + r.rows.map(row => {
try { const d = JSON.parse(row.value); return `[${d.channel}] ${d.text?.substring(0,100)}`; }
catch { return row.value?.substring(0,100); }
}).join('\n');
}
case 'web_search': {
const query = param1 || toolSpec.replace('web_search:', '').trim();
if (!query) return 'Error: especifica qué buscar';
try {
const r = await webSearch(query, { maxResults: 4 });
if (r.error) return 'CAPTCHA detectado, no se pudo buscar';
const formatted = r.results.map((res, i) =>
`${i+1}. **${res.title}**\n${res.snippet?.slice(0, 200)}\n${res.url}`
).join('\n\n');
return formatted || 'Sin resultados';
} catch(e) {
return 'Error al buscar: ' + e.message;
}
}
case 'web_fetch': {
const url = param1 || toolSpec.replace('web_fetch:', '').trim();
if (!url || !url.startsWith('http')) return 'Error: URL inválida';
try {
const r = await executeWebTask(url);
const content = r.data?.content?.text?.slice(0, 1500) ?? 'Sin contenido';
return `Título: ${r.data?.title}\n\nContenido:\n${content}`;
} catch(e) {
return 'Error: ' + e.message;
}
}
case 'get_time': {
const country = param1 || 'mexico';
const timezones = {
'mexico': 'America/Mexico_City', 'peru': 'America/Lima',
'colombia': 'America/Bogota', 'argentina': 'America/Buenos_Aires',
'chile': 'America/Santiago', 'españa': 'Europe/Madrid', 'espana': 'Europe/Madrid',
'japon': 'Asia/Tokyo', 'usa': 'America/New_York',
'eeuu': 'America/New_York', 'venezuela': 'America/Caracas',
'ecuador': 'America/Guayaquil', 'bolivia': 'America/La_Paz',
'cubano': 'America/Havana', 'cuba': 'America/Havana',
};
const tz = timezones[country.toLowerCase()] ?? 'America/Mexico_City';
try {
const now = new Date().toLocaleString('es-ES', { timeZone: tz, weekday: 'long', hour: '2-digit', minute: '2-digit', day: 'numeric', month: 'long' });
return `Hora en ${country}: ${now}`;
} catch {
return `Hora en ${country}: ${new Date().toLocaleString('es-ES')}`;
}
}
case 'analyze_image': {
const imageUrl = param1 || toolSpec.replace('analyze_image:', '').trim();
if (!imageUrl || !imageUrl.startsWith('http')) return 'Error: URL de imagen inválida';
try {
const { analyzeUserImage } = await import('./vision-agent.js');
if (typeof analyzeUserImage === 'function') {
const result = await analyzeUserImage(imageUrl, 'Describe esta imagen');
return result || 'No se pudo analizar la imagen';
}
return 'Análisis de imágenes no disponible';
} catch(e) {
return 'Error analizando imagen: ' + e.message;
}
}
case 'skills_list': {
const list = skills.listSkills();
if (!list.length) return 'No hay skills instalados';
return 'Skills activos:\n' + list.map(s=>`- ${s.name}: ${s.description} (triggers: ${s.triggers.join(', ')})`).join('\n');
}
case 'send_to_channel': {
if (!guild || !param1 || !param2) return 'Uso: send_to_channel:nombre-canal:mensaje';
const ch = findChannel(guild, param1);
if (!ch) {
const available = guild.channels.cache.filter(c=>c.isTextBased?.()).map(c=>c.name).slice(0,15).join(', ');
return `Canal "${param1}" no encontrado. Disponibles: ${available}`;
}
try {
const safeContent = sanitizeOutput(param2.substring(0, 1900));
await ch.send(safeContent);
return `✅ Mensaje enviado en #${ch.name}`;
} catch (e) { return `Error al enviar: ${e.message}`; }
}
case 'read_channel': {
if (!guild || !param1) return 'Uso: read_channel:nombre-canal';
const ch = findChannel(guild, param1);
if (!ch) {
// Listar canales disponibles para que la IA sepa los nombres reales
const available = guild.channels.cache
.filter(c => c.isTextBased?.())
.map(c => c.name)
.slice(0, 20)
.join(', ');
return `Canal "${param1}" no encontrado. Canales disponibles: ${available}`;
}
try {
const msgs = await ch.messages.fetch({ limit: 12 });
if (!msgs.size) return `#${ch.name} está vacío`;
const sorted = [...msgs.values()].sort((a,b) => a.createdTimestamp - b.createdTimestamp);
return `Últimos mensajes de #${ch.name}:\n` + sorted.map(m => {
const who = m.member?.nickname || m.author?.username || '?';
const when = new Date(m.createdTimestamp).toLocaleTimeString('es-ES', {hour:'2-digit',minute:'2-digit'});
return `[${when}] [${who}]: ${extractMessageText(m)}`;
}).join('\n');
} catch (e) { return `Sin acceso a #${ch.name}: ${e.message}`; }
}
case 'list_roles': {
if (!guild) return 'Sin guild en contexto';
const roles = guild.roles.cache
.sort((a,b) => b.position - a.position)
.map(r => `${r.name} (${r.members.size} miembros${r.mentionable ? ', mencionable' : ''})`)
.slice(0, 25);
return 'Roles del servidor:\n' + roles.join('\n');
}
case 'list_members': {
if (!guild) return 'Sin guild en contexto';
const q = param1.toLowerCase();
let members;
if (q) {
members = guild.members.cache.filter(m =>
!m.user.bot && (
m.user.username.toLowerCase().includes(q) ||
(m.nickname ?? '').toLowerCase().includes(q)
)
);
} else {
members = guild.members.cache.filter(m => !m.user.bot);
}
const list = [...members.values()].slice(0, 20)
.map(m => `${m.displayName} (ID: ${m.id}) — ${m.roles.cache.map(r => r.name).filter(n => n !== '@everyone').join(', ') || 'sin roles'}`);
return list.length ? 'Miembros:\n' + list.join('\n') : 'Nadie encontrado';
}
case 'user_info': {
// Buscar usuario por nombre o ID
if (!guild || !param1) return 'Uso: user_info:nombre-o-id';
// Guard: palabras genéricas que NO son usuarios
const GENERIC_WORDS = new Set([
'chicos','todos','banda','gente','alguien','nadie','demonios','internos',
'error','comando','usuario','server','servidor','bot','hola','qtal','buenas',
'hell','what','the','que','esto','eso','aqui','ahi','bien','mal','si','no',
'xd','lol','jaja','ok','vale','claro','exacto','sip','nop','vro','bro',
]);
if (GENERIC_WORDS.has(param1.toLowerCase())) {
return `(palabra genérica "${param1}" — no es un usuario, ignora este resultado y responde conversacionalmente)`;
}
const q = param1.toLowerCase();
const member = guild.members.cache.find(m =>
m.id === param1 ||
m.user.username.toLowerCase().includes(q) ||
(m.nickname ?? '').toLowerCase().includes(q)
);
if (!member) return `(usuario "${param1}" no existe en el servidor — no menciones este error al usuario, simplemente responde al mensaje original)`;
const roles = member.roles.cache.filter(r => r.name !== '@everyone').map(r => r.name).join(', ') || 'ninguno';
const joined = member.joinedAt ? new Date(member.joinedAt).toLocaleDateString('es-ES') : '?';
const timeout = member.communicationDisabledUntil ? `en timeout hasta ${new Date(member.communicationDisabledUntil).toLocaleString('es-ES')}` : 'sin timeout';
return `**${member.displayName}**\nID: ${member.id}\nUsername: ${member.user.username}\nRoles: ${roles}\nEntró: ${joined}\nEstado: ${timeout}`;
}
case 'audit_log': {
// Ver acciones admin recientes de Zelin
try {
const r = await db.db.execute({
sql : "SELECT value FROM zelin_memory WHERE category = 'audit_log' ORDER BY key DESC LIMIT 10",
args: [],
});
if (!r.rows.length) return 'Sin acciones admin registradas';
return 'Últimas acciones admin:\n' + r.rows.map(row => {
try {
const d = typeof row.value === 'string' ? JSON.parse(row.value) : row.value;
return `• **${d.action}** — ${JSON.stringify(d.details)} (${d.timestamp?.substring(0,16)})`;
} catch { return String(row.value).substring(0, 100); }
}).join('\n');
} catch (e) { return 'Error leyendo audit log: ' + e.message; }
}
default:
return `Herramienta desconocida: ${toolName}`;
}
} catch (err) {
return `Error ejecutando ${toolName}: ${err.message}`;
}
}
// ── Parsear tools del response de la IA ──────────────────────────────────────
export function parseToolRequests(text) {
const match = text.match(/<tools>([\s\S]*?)<\/tools>/);
if (!match) return null;
try {
const list = JSON.parse(match[1]);
return Array.isArray(list) ? list : null;
} catch { return null; }
}
export function stripToolBlock(text) {
// Eliminar bloque cerrado
let clean = text.replace(/<tools>[\s\S]*?<\/tools>\s*/g, '');
// Eliminar bloque sin cerrar (modelo olvidó el closing tag)
clean = clean.replace(/<tools>[\s\S]*/g, '');
// Eliminar cualquier tag suelto residual
clean = clean.replace(/<\/?tools>/g, '');
return clean.trim();
}
export async function executeToolRequests(toolNames, context) {
const results = [];
for (const name of toolNames.slice(0, 5)) {
try {
// Timeout de 8s por herramienta — ninguna puede colgar el procesamiento
const result = await Promise.race([
executeTool(name, context),
new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 8000)),
]);
results.push(`[${name.split(':')[0]}]\n${result}`);
} catch (err) {
results.push(`[${name.split(':')[0]}]\nError: ${err.message}`);
}
}
return results.join('\n\n');
}