GenAINextVercel AI SDKLangchainGoogle Gemini

Function Calling y Herramientas (Tools). Parte 3

Antonio Pérez
2025-09-09
16
Function Calling y Herramientas (Tools). Parte 3

Llevando las herramientas al siguiente nivel: Actualización y Asignación

Podrás encontrar el código de este artículo en: https://github.com/aperezl/ai-fullstack-serie/tree/tools-3

En la sección anterior, sentamos las bases de nuestro agente dándole la capacidad de leer datos (findUserTickets) y crear nuevas entradas (createSupportTicket). Si bien esto ya es potente, un verdadero asistente de soporte debe poder gestionar el estado de las tareas existentes. ¿Qué pasa si un ticket ya no es relevante? ¿O si la responsabilidad de una tarea debe cambiar de una persona a otra?

Para abordar esto, vamos a expandir el "arsenal" de nuestro ChipiBot con dos nuevas herramientas que le permitirán modificar datos existentes, demostrando cómo el LLM puede orquestar operaciones más complejas.

Paso 1: Ampliando la lógica de negocio en `mock-db.ts`

Primero, dotamos a nuestra base de datos simulada de las funciones necesarias para actualizar y reasignar tickets.

typescriptsrc/lib/db/mock-db.ts

// Tipos de datos para nuestra base de datos simulada
export interface User {
  id: string;
  name: string;
  email: string;
  tickets: string[];
}

export type TicketStatus = 'open' | 'closed' | 'in-progress'

export interface Ticket {
  id: string;
  subject: string;
  status: TicketStatus;
  userEmail: string;
}

// Nuestra "base de datos" en memoria
const users: User[] = [
  { id: 'usr_1', name: 'Antonio Perez', email: 'antonio@unuko.com', tickets: ['tkt_1', 'tkt_3'] },
  { id: 'usr_2', name: 'Ada Lovelace', email: 'ada@unuko.com', tickets: ['tkt_2'] },
];

const tickets: Ticket[] = [
  { id: 'tkt_1', subject: 'Empezar a escribir un blog', status: 'closed', userEmail: 'antonio@unuko.com' },
  { id: 'tkt_2', subject: 'Consulta sobre el Motor Analítico', status: 'open', userEmail: 'ada@unuko.com' },
  { id: 'tkt_3', subject: 'Terminar la serie AI FullStack', status: 'in-progress', userEmail: 'antonio@unuko.com' },
];

// Funciones que simulan el acceso a la base de datos
export const db = {
  findUserByEmail: async (email: string): Promise<User | undefined> => {
    console.log(`[DB] Buscando usuario por email: ${email}`);
    return users.find(user => user.email === email);
  },
  findTicketsByUserEmail: async (email: string): Promise<Ticket[]> => {
    console.log(`[DB] Buscando tickets para el email: ${email}`);
    return tickets.filter(ticket => ticket.userEmail === email);
  },
  createTicket: async (subject: string, userEmail: string): Promise<Ticket> => {
    console.log(`[DB] Creando ticket para ${userEmail} con asunto: ${subject}`);
    const newTicket: Ticket = {
      id: `tkt_${tickets.length + 1}`,
      subject,
      status: 'open',
      userEmail,
    };
    tickets.push(newTicket);
    // Añadimos el ticket al usuario correspondiente
    const user = await db.findUserByEmail(userEmail);
    user?.tickets.push(newTicket.id);
    return newTicket;
  },
  updateTicketStatus: async (ticketId: string, status: TicketStatus) => {
    console.log(`[DB] Actualizando estado del ticket ${ticketId} a ${status}`);
    const ticket = tickets.find(t => t.id === ticketId);
    if (ticket) {
      ticket.status = status;
      return ticket;
    }
  },
  assignTicketToUser: async (ticketId: string, userEmail: string) => {
    console.log(`[DB] Asignando ticket ${ticketId} al usuario ${userEmail}`);
    const ticket = tickets.find(t => t.id === ticketId);
    const user = await db.findUserByEmail(userEmail);
    if (ticket && user) {
      // Opcional: eliminar el ticket del usuario anterior
      const oldUser = await db.findUserByEmail(ticket.userEmail);
      if (oldUser) {
        oldUser.tickets = oldUser.tickets.filter(t => t !== ticketId);
      }
      
      ticket.userEmail = userEmail;
      if (!user.tickets.includes(ticketId)) {
        user.tickets.push(ticketId);
      }
      return ticket;
    }
  }
};

Paso 2: Definiendo las nuevas herramientas en `tools.ts`

Ahora, exponemos estas nuevas capacidades al LLM a través de la definición de herramientas. Es crucial ser precisos en la description y estrictos en el inputSchema.

typescriptsrc/lib/ai/tools.ts
import { tool } from 'ai';
import { z } from 'zod';
import { db } from '../db/mock-db';

// Herramienta de solo lectura
export const findUserTickets = tool({
  description: 'Busca los tickets de soporte para un usuario específico usando su dirección de correo electrónico.',
  inputSchema: z.object({
    email: z.string().email({ message: "Se requiere un correo electrónico válido." }),
  }),
  execute: async ({ email }) => {
    // ... (código sin cambios)
  },
})

// Herramientas de escritura
export const createSupportTicket = tool({
  description: 'Crea un nuevo ticket de soporte para un usuario. Devuelve el ticket recién creado.',
  inputSchema: z.object({
    // ... (código sin cambios)
  }),
  execute: async ({ userEmail, subject }) => {
    // ... (código sin cambios)
  },
})

export const updateSupportTicket = tool({
  description: 'Actualiza el estado de un ticket de soporte existente.',
  inputSchema: z.object({
    ticketId: z.string().min(5, { message: "El ID del ticket debe tener al menos 5 caracteres." }),
    status: z.enum(['open', 'closed', 'in-progress'], { message: "El estado debe ser 'open', 'closed' o 'in-progress'." })
  }),
  execute: async ({ ticketId, status }) => {
    try {
      const updatedTicket = await db.updateTicketStatus(ticketId, status);
      if (!updatedTicket) {
        return { error: 'ticket_no_encontrado', message: `El ticket con ID ${ticketId} no existe.` };
      }
      return { message: `El ticket ${ticketId} ha sido actualizado a estado ${status}.` };
    } catch (error) {
      return { error: 'actualizacion_fallida', message: 'No se pudo actualizar el ticket de soporte.' };
    }
  }
})

export const assignTicketToUser = tool({
  description: 'Asigna un ticket de soporte a un usuario específico mediante su correo electrónico.',
  inputSchema: z.object({
    ticketId: z.string().min(5, { message: "El ID del ticket debe tener al menos 5 caracteres." }),
    userEmail: z.string().email({ message: "Se requiere un correo electrónico válido para asignar el ticket." }),
  }),
  execute: async ({ ticketId, userEmail }) => {
    try {
      const user = await db.findUserByEmail(userEmail);
      if (!user) {
        return { error: 'usuario_no_encontrado', message: `No se puede asignar el ticket al email ${userEmail} porque el usuario no existe.` };
      }
      const assignedTicket = await db.assignTicketToUser(ticketId, userEmail);
      if (!assignedTicket) {
        return { error: 'ticket_no_encontrado', message: `El ticket con ID ${ticketId} no existe.` };
      }
      return { message: `El ticket ${ticketId} ha sido asignado al usuario con email ${userEmail}.` };
    } catch (error) {
      return { error: 'asignacion_fallida', message: 'No se pudo asignar el ticket de soporte.' };
    }
  },
})

Análisis de las nuevas herramientas:

  • updateSupportTicket: Fíjate en el uso de z.enum. Esta validación es extremadamente útil, ya que garantiza que el LLM solo pueda intentar establecer uno de los tres estados permitidos, evitando datos corruptos en nuestro sistema.
  • assignTicketToUser: Esta herramienta es más compleja. Implica validar la existencia del nuevo usuario antes de realizar la asignación. El LLM ahora puede gestionar relaciones entre diferentes entidades de nuestra base de datos.

Paso 3: Integrando el set de herramientas completo

El último paso es importar y registrar todas las herramientas en nuestro endpoint de la API. El Vercel AI SDK se encargará de presentarlas al modelo en cada turno de la conversación.

typescriptsrc/app/api/tools/route.ts
import { google } from '@ai-sdk/google';
import { streamText, convertToModelMessages, UIMessage, stepCountIs } from 'ai';
// Importamos las herramientas de forma individual
import { findUserTickets, createSupportTicket, updateSupportTicket, assignTicketToUser } from '@/lib/ai/tools'; 

export const maxDuration = 30;

export async function POST(req: Request) {
  try {
    const { messages }: { messages: UIMessage[] } = await req.json();

    const systemPrompt = `
      Eres un asistente de soporte técnico para una plataforma digital.
      Tu nombre es "ChipiBot". Eres amable, extremadamente servicial y eficiente.
      Utiliza las herramientas disponibles para responder a las preguntas del usuario y realizar tareas.
      Cuando busques información de un usuario, siempre dirígete a él por su nombre si lo encuentras.
      El correo del usuario actual es "antonio@unuko.com". Solo puedes operar sobre este usuario a menos que se te proporcione otro correo explícitamente.
    `;

    const result = await streamText({
      model: google('gemini-2.5-flash'),
      system: systemPrompt,
      messages: convertToModelMessages(messages),
      // Construimos el objeto de herramientas para el modelo
      tools: { findUserTickets, createSupportTicket, updateSupportTicket, assignTicketToUser },
      stopWhen: stepCountIs(5), // Evita bucles infinitos de llamadas a herramientas
    });

    return result.toUIMessageStreamResponse();

  } catch (error) {
    console.error("Error en la API de chat con herramientas:", error);
    return new Response("Un error inesperado ha ocurrido.", { status: 500 });
  }
}

Con estas modificaciones, nuestro agente ha ganado una profundidad funcional mucho mayor. Ahora está listo para manejar flujos de trabajo más realistas.

Puesta en marcha y escenarios de interacción con el agente

Nuestro frontend, gracias a la refactorización y al diseño genérico del hook useChat, está prácticamente listo para interactuar con nuestro nuevo backend basado en herramientas. El protocolo de streaming del Vercel AI SDK es capaz de manejar tanto respuestas de texto como los ciclos intermedios de llamadas a herramientas, por lo que no se requieren cambios significativos en la UI para una funcionalidad básica.

Paso 1: Ejecutar la aplicación

Asegúrate de que todos los cambios en los archivos estén guardados. Inicia tu servidor de desarrollo si no está corriendo:

bash
pnpm dev

Navega a http://localhost:3000/tools. La interfaz sigue siendo la del asistente de Aaron Swartz, pero ahora, por debajo, su "cerebro" ha sido reemplazado por el de un agente de soporte técnico.

Escenario de prueba 1: Tarea de solo lectura (Read-Only)

Vamos a probar una tarea que requiere que el agente busque información en nuestra base de datos simulada.

Interacción:

  1. Escribe el siguiente prompt en el chat:

> "¿Podrías revisar si tengo algún ticket de soporte abierto?"

2. Envía el mensaje.

Observaciones del flujo completo:

UI del frontend:

  • Tu pregunta aparece inmediatamente a la derecha.
  • El formulario se deshabilita.
  • A diferencia de una respuesta de texto simple, puede que no veas una respuesta inmediata. La UI esperará mientras el agente realiza su ciclo de Razonamiento-Acción.

Consola del servidor (Terminal): Aquí es donde veremos la verdadera magia. Observarás una secuencia de logs que refleja el ciclo de herramientas:

  1. [DB] Buscando usuario por email: antonio@unuko.com
  2. [DB] Buscando tickets para el email: antonio@unuko.com

UI del frontend (Respuesta Final):

Después de unos segundos, la respuesta del agente aparecerá en el chat, streameándose como de costumbre:

> ¡Claro que sí! Con gusto revisaré si tienes algún ticket de soporte abierto. Permíteme un momento. Antonio, he revisado tus tickets y encontré que tienes un ticket en progreso con el asunto "Terminar la serie AI FullStack". No tienes ningún ticket con el estado "abierto" actualmente.

alt text

Análisis del escenario:

El agente ha ejecutado un flujo de trabajo complejo de forma autónoma:

  1. Entendió la intención: Comprendió que "mis tickets de soporte" se refería a los tickets asociados al usuario actual (antonio@unuko.com, proporcionado en el systemPrompt).
  2. Seleccionó la herramienta correcta: De las herramientas disponibles, eligió findUserTickets como la más adecuada.
  3. Extrajo el parámetro: Identificó que la herramienta necesitaba un email y usó el que le dimos en el contexto del prompt.
  4. Invocó la herramienta: El AI SDK ejecutó la función execute de la herramienta findUserTickets.
  5. Recibió y entendió el resultado: Obtuvo el array de tickets de nuestra base de datos simulada.
  6. Sintetizó la respuesta: Formuló una respuesta en lenguaje natural, amigable y útil, resumiendo la información encontrada y dirigiéndose al usuario por su nombre (que encontró en el paso de búsqueda del usuario).

Escenario de prueba 2: Tarea de escritura (Write)

Ahora, probemos una tarea que modifica el estado de nuestro sistema.

Interacción:

  1. Escribe el siguiente prompt en el chat:

> "Necesito ayuda. No puedo acceder a mi cuenta. Por favor, crea un ticket de soporte."

2. Envía el mensaje.

Observaciones del flujo completo:

Consola del servidor: Verás una secuencia diferente de logs:

  1. [DB] Buscando usuario por email: antonio@unuko.com (El agente primero verifica que el usuario existe antes de crear un ticket para él, una buena práctica).
  2. [DB] Creando ticket para antonio@unuko.com con asunto: No puedo acceder a mi cuenta.

3. UI del frontend (Respuesta final): El agente confirmará la acción realizada:

> ¡Claro que sí, Antonio! Entiendo que tienes problemas para acceder a tu cuenta.

> Permíteme crear un ticket de soporte para ti ahora mismo. El asunto del ticket será "Problema de acceso a la cuenta".

> Ejecutando herramienta: createSupportTicket

> ¡Listo, Antonio! He creado un ticket de soporte para ti con el ID tkt_4 y el asunto "Problema de acceso a la cuenta". Nuestro equipo de soporte se pondrá en contacto contigo lo antes posible para ayudarte a resolver este problema.

Si necesitas algo más, no dudes en preguntar."

alt text

Análisis del escenario:

El agente no solo ha leído datos, sino que ha realizado una operación de escritura.

  1. Descomposición de la tarea: El LLM entendió que la solicitud requería la herramienta createSupportTicket.
  2. Extracción de múltiples parámetros: Extrajo correctamente tanto el userEmail del contexto como el subject del texto del prompt del usuario.
  3. Ejecución segura: Nuestra lógica en execute primero verificó que el usuario existía antes de proceder con la creación del ticket.
  4. Respuesta basada en la acción: La respuesta final no es solo un eco de los datos, sino una confirmación de la acción realizada, incluyendo información útil como el ID del nuevo ticket, que obtuvo del resultado de la función execute.

Escenario de prueba 3: Casos de uso del agente avanzado

Ahora que ChipiBot tiene un conjunto de herramientas más robusto, puede manejar conversaciones mucho más complejas y útiles. Veamos algunos ejemplos de cómo podría razonar y utilizar sus nuevas capacidades.

Caso de uso 1: Consulta y actualización en dos pasos

El usuario quiere revisar sus tareas pendientes y actuar sobre una de ellas.

Usuario:

> Hola ChipiBot, ¿puedes recordarme qué tareas tengo en progreso? Y ya que estamos, cierra la que trata sobre la serie AI FullStack, que ya la he terminado.

Análisis del Proceso Interno:

  1. Razonamiento del LLM: El prompt del usuario contiene dos intenciones: una de lectura ("recordarme qué tareas tengo en progreso") y una de escritura ("cierra la que trata sobre la serie"). Necesito resolver la primera para poder ejecutar la segunda.
  2. Llamada a herramienta 1: El LLM invoca findUserTickets({ email: 'antonio@unuko.com' }).
  3. Resultado 1: La herramienta devuelve una lista de tickets, incluyendo { id: 'tkt_3', subject: 'Terminar la serie AI FullStack', status: 'in-progress', ... }.
  4. Razonamiento del LLM (continua): Ahora tengo la información. El ticket que el usuario quiere cerrar es tkt_3. La acción es "cerrar", que se corresponde con el estado closed.
  5. Llamada a herramienta 2: El LLM invoca updateSupportTicket({ ticketId: 'tkt_3', status: 'closed' }).
  6. Resultado 2: La herramienta devuelve { message: 'El ticket tkt_3 ha sido actualizado a estado closed.' }.
  7. Respuesta final: El LLM sintetiza toda la información y acciones en una respuesta coherente para el usuario.

ChipiBot (Respuesta):

> Hola Antonio, claro que sí. Permíteme buscar tus tareas.

> Ejecutando herramienta: findUserTickets

> Antonio, tienes una tarea en progreso:

  • Terminar la serie AI FullStack (ID: tkt_3)

> Ahora, procederé a cerrar el ticket de la serie AI FullStack.

> Ejecutando herramienta: updataeSupportTicket

> ¡Listo Antonio! He cerrado el ticket con el asunto "Terminar la serie AI FullStack". Si necesitas algo más, no dudes en consultarme.

alt text

Caso de uso 2: Reasignación de tareas entre usuarios

El usuario necesita delegar una tarea a otro miembro del equipo.

Usuario:

> Por favor, asigname a mi el ticket sobre la "Consulta sobre el Motor Analítico" de Ada Lovelace, su correo es ada@unuko.com.

Análisis del Proceso Interno:

  1. Razonamiento del LLM: La intención es clara: reasignar un ticket. La herramienta assignTicketToUser es la adecuada. Necesito dos parámetros: ticketId y userEmail.
  2. Extracción de parámetros: Del prompt extraigo:

* userEmail: "ada@unuko.com"

* ticketId: No tengo el ID exacto, pero tengo el subject. Aquí el LLM tiene dos opciones:

* Opción A (más robusta): Primero usar findUserTickets para encontrar el ticket por su subject y obtener el ID.

* Opción B (más directa, si el modelo es suficientemente bueno): El LLM podría "recordar" o deducir el ticketId (tkt_2) de la información inicial de la base de datos si estuviera en su contexto. Asumamos que para garantizar la precisión, sigue la Opción A.

3. Llamada a herramienta 1 (implícita): Busca el ticket y encuentra que "Consulta sobre el Motor Analítico" corresponde a tkt_2.

4. Llamada a herramienta 2: Invoca assignTicketToUser({ ticketId: 'tkt_2', userEmail: 'ada@unuko.com' }).

5. Resultado: La herramienta devuelve { message: 'El ticket tkt_2 ha sido asignado al usuario con email ada@unuko.com.' }.

6. Respuesta final: El LLM confirma la operación.

ChipiBot (Respuesta):

alt text

> Ejecutando herramienta: findUserTickets

> Ejecutando herramienta: assignTicketToUser

> ¡Listo! El ticket "Consulta sobre el Motor Analítico" (ID: tkt_2) ha sido asignado a tu correo, antonio@unuko.com.

Estos ejemplos demuestran el verdadero poder del *function calling*: no se trata solo de ejecutar una función, sino de la capacidad del LLM para razonar, planificar y orquestar una secuencia de llamadas a herramientas para cumplir con una petición compleja del usuario.

Desafío de experto: Manejando la ambigüedad

¿Qué pasaría si el LLM no tiene suficiente información? Un buen agente debe saber cuándo pedir aclaraciones.

Prueba este prompt:

> "Crea un ticket sobre mi problema."

  • Comportamiento esperado: El inputSchema de nuestra herramienta createSupportTicket requiere un subject con un mínimo de 10 caracteres. El LLM debería reconocer que "mi problema" es demasiado ambiguo y no cumple con el contrato de la herramienta.
  • Respuesta del agente: En lugar de llamar a la herramienta con datos inválidos, el agente debería responder algo como:

> Claro Antonio, puedo ayudarte a crear un ticket. Por favor, describe brevemente tu problema para que pueda establecer el asunto del ticket.

alt text

Este comportamiento demuestra la robustez del sistema. La combinación de un buen prompt engineering, un esquema de Zod estricto y un LLM capaz permite al agente manejar la ambigüedad de forma inteligente, interactuando con el usuario para recopilar la información necesaria antes de actuar.

Conclusión del capítulo

Hemos dado un paso transformador. Nuestro agente ha evolucionado de un mero procesador de información a un ejecutor de tareas. Al equiparlo con herramientas, le hemos proporcionado "manos" para interactuar con el mundo digital.

Hemos dominado los siguientes conceptos clave:

  • Definición de contratos de herramientas: Hemos aprendido a describir nuestro código de backend de una manera que los LLMs puedan entender y utilizar de forma fiable, utilizando zod como un lenguaje de esquemas robusto.
  • Orquestación del ciclo de acción: Hemos implementado y analizado el ciclo completo de Razonamiento -> Acción -> Observación -> Síntesis, que es el núcleo de la mayoría de los sistemas de agentes modernos.
  • Diseño de herramientas seguras y robustas: Hemos aplicado principios de ingeniería de software (como la validación y el manejo de errores) dentro de nuestras herramientas para crear un sistema seguro y predecible.
  • Inyección de contexto de sesión: Hemos visto cómo proporcionar información contextual clave (como el email del usuario) en el systemPrompt permite al agente actuar de manera más autónoma y eficiente.

Sin embargo, nuestro agente actual, aunque potente, sigue un flujo de razonamiento relativamente lineal y simple. ¿Qué sucede cuando las tareas requieren múltiples pasos, planificación a largo plazo o la colaboración de varios agentes especializados? Para manejar esta complejidad, necesitamos una forma más potente de definir el flujo de control.

Podrás encontrar el código de este artículo en: https://github.com/aperezl/ai-fullstack-serie/tree/tools-3

En los próximos capítulos daremos el salto a LangChain.js y LangGraph.js, herramientas diseñadas específicamente para orquestar estos flujos de trabajo de IA complejos y stateful.