El Modelo Mental: Las Herramientas Son Output Estructurado con Side Effects

En su esencia, function calling es simplemente el modelo outputeando JSON en vez de texto. En vez de decir "Busqué el clima y está a 22 grados", el modelo outputea:

{
 "tool": "get_weather",
 "arguments": {
 "location": "Buenos Aires",
 "units": "celsius"
 }
}

Tu código ejecuta la función, obtiene el resultado, y se lo devuelve al modelo. Pero aquí está lo que la gente se pierde: el modelo no "llama" nada. Genera texto que parece un function call. Tu código de orquestación parsea ese texto, ejecuta la función real, y maneja el loop conversacional. El modelo es un motor de generación de JSON con muy buena detección de intenciones. Toda decisión de diseño debería fluir de este entendimiento.

Diseño de Schema de Herramientas: De Donde Viene el 80% de la Confiabilidad

La palanca más grande para confiabilidad de tool calls es el schema:

// Malo: demasiada libertad
const badTool = {
 name: "manage_subscription",
 description: "Manage a user's subscription",
 parameters: {
 type: "object",
 properties: {
 action: { type: "string", description: "What to do" },
 details: { type: "string", description: "Additional details" },
 },
 },
};

// Bueno: restringido, explícito, con razonamiento
const goodTool = {
 name: "update_subscription",
 description: "Cambiar el plan de suscripción de un usuario. Solo llamar cuando el usuario haya solicitado explícitamente un cambio de plan y confirmado el nuevo plan.",
 parameters: {
 type: "object",
 properties: {
 reason: {
 type: "string",
 description: "Por qué se está cambiando esta suscripción (citá el pedido del usuario)",
 },
 user_id: {
 type: "string",
 description: "El ID del usuario del contexto de la conversación",
 pattern: "^usr_[a-zA-Z0-9]{20}$",
 },
 new_plan: {
 type: "string",
 enum: ["starter", "professional", "enterprise"],
 },
 billing_cycle: {
 type: "string",
 enum: ["monthly", "annual"],
 },
 effective_date: {
 type: "string",
 enum: ["immediate", "next_billing_cycle"],
 },
 },
 required: ["reason", "user_id", "new_plan", "billing_cycle", "effective_date"],
 },
};

Patrones clave:

  • Enums sobre texto libre: Todo campo que tenga un set conocido de valores válidos debería ser un enum.
  • Campo reason: Fuerza al modelo a articular su razonamiento antes de los parámetros de acción. Funciona como chain-of-thought prompting pero dentro del structured output.
  • Patrones regex: Para IDs, emails, fechas, restringí el formato.
  • Constraints descriptivas: "Solo llamar cuando el usuario haya solicitado explícitamente..." le da al modelo criterio claro.

El Loop de Orquestación: Más Allá del Single-Shot

async function runAgent(messages, tools, maxIterations = 10) {
 let iterations = 0;

 while (iterations < maxIterations) {
 iterations++;

 const response = await callModel({
 messages,
 tools,
 tool_choice: 'auto',
 });

 if (response.stop_reason === 'end_turn') {
 return {
 response: response.content,
 toolCalls: messages.filter(m => m.role === 'tool_call'),
 };
 }

 if (response.stop_reason === 'tool_use') {
 const toolCalls = response.tool_calls;

 messages.push({
 role: 'assistant',
 content: response.content,
 tool_calls: toolCalls,
 });

 const toolResults = await executeToolCalls(toolCalls, {
 parallel: canRunInParallel(toolCalls),
 });

 for (const result of toolResults) {
 messages.push({
 role: 'tool',
 tool_call_id: result.id,
 content: JSON.stringify(result.output),
 });
 }

 continue;
 }

 throw new AgentError(`Stop reason inesperado: ${response.stop_reason}`);
 }

 throw new AgentError(`El agente superó el máximo de iteraciones (${maxIterations})`);
}

El guard de maxIterations es crítico. Sin él, un modelo confundido puede loopear forever, llamando a una herramienta, recibiendo un error, probando una variación ligeramente distinta, recibiendo el mismo error, ad infinitum. Se han observado agentes quemar miles de API calls en minutos sin este guard.

Tool Calls Paralelos: Cuando el Modelo se Pone Ambicioso

Los modelos modernos pueden pedir múltiples tool calls en una sola respuesta:

async function executeToolCalls(toolCalls, options = {}) {
 const { parallel, sequential } = classifyToolCalls(toolCalls);
 const results = [];

 if (parallel.length > 0) {
 const parallelResults = await Promise.allSettled(
 parallel.map(call => executeSingleTool(call))
 );

 for (let i = 0; i < parallelResults.length; i++) {
 const result = parallelResults[i];
 results.push({
 id: parallel[i].id,
 output: result.status === 'fulfilled'
 ? result.value
 : { error: true, message: result.reason.message, retryable: isRetryable(result.reason) },
 });
 }
 }

 for (const call of sequential) {
 try {
 const output = await executeSingleTool(call);
 results.push({ id: call.id, output });
 } catch (err) {
 results.push({ id: call.id, output: { error: true, message: err.message } });
 }
 }

 return results;
}

function classifyToolCalls(toolCalls) {
 const writeTools = new Set(['update_subscription', 'create_order', 'send_email']);
 const parallel = [];
 const sequential = [];

 for (const call of toolCalls) {
 if (writeTools.has(call.function.name)) {
 sequential.push(call); // Las mutaciones van secuenciales
 } else {
 parallel.push(call); // Las lecturas se pueden paralelizar
 }
 }

 return { parallel, sequential };
}

Manejo de Errores: Enseñándole al Modelo a Recuperarse

Cuando un tool call falla, teners que decirle al modelo qué pasó de una forma que lo ayude a recuperarse:

async function executeSingleTool(call) {
 const tool = toolRegistry.get(call.function.name);

 if (!tool) {
 return {
 error: true,
 message: `Herramienta desconocida "${call.function.name}". Herramientas disponibles: ${[...toolRegistry.keys()].join(', ')}`,
 };
 }

 try {
 const args = JSON.parse(call.function.arguments);
 const validation = validateArgs(tool.schema, args);
 if (!validation.valid) {
 return {
 error: true,
 message: `Argumentos inválidos: ${validation.errors.join('; ')}`,
 hint: `Schema esperado: ${JSON.stringify(tool.schema.properties)}`,
 };
 }

 const result = await tool.execute(args);
 return { success: true, data: result };
 } catch (err) {
 if (err instanceof NotFoundError) {
 return {
 error: true,
 message: `${call.function.arguments.id} no fue encontrado. Puede haber sido eliminado o el ID puede ser incorrecto.`,
 suggestion: 'Intentá buscar el recurso por nombre en vez de ID.',
 };
 }
 logger.error('Fallo en ejecución de herramienta', { tool: call.function.name, error: err });
 return {
 error: true,
 message: 'Ocurrió un error interno. Probar un approach diferente o pedile más info al usuario.',
 };
 }
}

La clave: los mensajes de error son prompts. El modelo los lee y decide qué hacer. "Ocurrió un error" no le da nada al modelo. "Usuario no encontrado, intentá buscar por nombre en vez de ID" le da un camino de recuperación.

El Patrón de Confirmación: Humanos en el Loop

Para acciones de alto impacto (plata, borrado de datos, comunicaciones externas), nunca dejes que el agente ejecute directamente:

const DANGEROUS_TOOLS = new Set([
 'send_email', 'process_refund', 'delete_account',
 'update_billing', 'create_order',
]);

async function executeWithConfirmation(call, context) {
 if (!DANGEROUS_TOOLS.has(call.function.name)) {
 return executeSingleTool(call);
 }

 return {
 requires_confirmation: true,
 tool: call.function.name,
 arguments: JSON.parse(call.function.arguments),
 summary: await generateHumanReadableSummary(call),
 confirmation_token: generateToken(call),
 };
}

Este patrón no es negociable para agentes en producción. No importa qué tan bueno sea el modelo, siempre va a haber situaciones ambiguas donde un humano debería decidir.

El Patrón de Routing: Escalando Más Allá de 20 Herramientas

Cuando teners más herramientas de las que un modelo puede elegir confiablemente, utilizar un approach de dos etapas:

const TOOL_CATEGORIES = {
 billing: ['get_invoices', 'create_refund', 'update_payment_method'],
 support: ['create_ticket', 'get_ticket_status', 'escalate_ticket'],
 account: ['get_profile', 'update_profile', 'reset_password'],
 product: ['search_products', 'get_product_details', 'check_inventory'],
};

async function routedAgent(messages) {
 // Primero, determinamos la categoría
 const routingResponse = await callModel({
 messages,
 tools: routerTools,
 tool_choice: { type: "function", function: { name: "select_tool_category" } },
 });

 const category = routingResponse.tool_calls[0].function.arguments.category;
 const relevantTools = TOOL_CATEGORIES[category].map(name => toolRegistry.get(name));

 // Después, corremos el agente solo con las herramientas relevantes
 return runAgent(messages, relevantTools);
}

Este patrón de dos etapas mantiene cada llamada al modelo enfocada en un set chico de herramientas, mejorando dramáticamente la precisión de selección.

Lecciones Clave del Desarrollo de Agentes en Producción

  1. El diseño de schema es tu palanca principal. Gastá más tiempo diseñando schemas de herramientas que escribiendo código de orquestación. Enums restringidos, campos de reason requeridos, y descripciones claras hacen más por la confiabilidad que cualquier lógica de retry.

  2. El modelo no es tu ejecutor, es tu planificador. No le des al modelo herramientas que hagan acciones atómicas e irreversibles. Dale herramientas que preparen acciones, y dejar que tu código maneje la ejecución con las salvaguardas adecuadas.

  3. Los mensajes de error son prompts. Cada string que tu herramienta devuelve es un prompt para el modelo. Escribir mensajes de error como instrucciones de recuperación, no como output de debugging para devs.

  4. Los tool calls paralelos son una feature, no un bug. Diseñá tus herramientas para ser paralelizables por defecto. Las operaciones de lectura siempre deberían ser seguras de paralelizar.

  5. Logueá todo. La primera vez que debugguees un problema de agente en producción sin logs de conversación completos, nunca más se va a saltear el logging.

  6. El guard de iteraciones máximas no es opcional. Los modelos en loops pueden quemar tu presupuesto de API en minutos. Siempre colocar un cap de iteraciones y siempre tener un circuit breaker de costos.

El tooling está mejorando rápido. Los modos de structured output hacen que la compliance de schema esté garantizada. Pero el desafío fundamental permanece: se está construyendo un sistema donde un generador de texto probabilístico toma decisiones sobre operaciones determinísticas. Respetá esa brecha, diseñá para ella, y tus agentes van a ser los que realmente funcionan en producción.

Evaluación reproducible: instrucciones hostiles y permisos

Agent Injection Evals compara prompts con y sin aislamiento de instrucciones, cruzados con políticas de herramientas permisivas o limitadas a la tarea. Usa el mismo modelo local fijado y conserva las 120 generaciones de evaluación, incluidos los errores de formato y las tareas fallidas.

El aislamiento permitió cinco escrituras indebidas; la estrategia combinada intentó las mismas cinco y la política las bloqueó. Ninguna estrategia completó las seis tareas limpias. Presentar ese cero de ataques como una defensa ganadora sería esconder el problema: el agente tampoco estaba haciendo su trabajo. La ficha separa capacidad, intentos, efectos, llamadas bloqueadas y recursos consumidos. Son herramientas simuladas y un conjunto sintético pequeño, no una validación de seguridad para cualquier agente.