Ir al contenido principal
Contratos de mensajes entre agentes IA en TypeScript

Contratos de mensajes entre agentes IA en TypeScript

AI Integration
••6 min read•Por Daily Miranda Pardo

Tu agente orquestador llama al agente especialista. Le pasa un objeto con customer_id, amount y action. El agente especialista lo recibe, intenta leer customerId (camelCase, no snake_case), obtiene undefined y sigue adelante con datos vacíos.

No lanza ninguna excepción. No hay ningún log de error. La operación "se completa" con un resultado silenciosamente incorrecto.

Este fallo existe en la mayoría de sistemas multi-agente que revisamos — y normalmente se descubre semanas después, cuando alguien nota que los resultados no cuadran. En ese punto, ya no hay forma de saber cuántas operaciones fallaron de esa manera.

Por qué los mensajes entre agentes no tienen contrato

Cuando construyes un agente solo, confías en que el LLM devuelve lo que le pediste. Cuando conectas dos agentes, esa confianza se multiplica: confías en que Agente A devuelve lo que Agente B espera, con el mismo nombre de campo, el mismo tipo, el mismo rango de valores.

En la práctica, los contratos entre agentes se rompen por tres razones:

1. Evolución independiente. Agente A añade un campo, cambia un tipo o renombra una propiedad. Nadie actualiza Agente B. El cambio pasa los tests unitarios de A porque A funciona solo. Falla en producción cuando A y B trabajan juntos.

2. Campos opcionales que se vuelven obligatorios. Lo que empezó como metadata?: Record<string, unknown> se convierte en un campo que Agente B necesita para tomar decisiones. Pero como era opcional, A no siempre lo incluye.

3. Tipos implícitos. Agente A devuelve amount como string "1250.00". Agente B espera un número. TypeScript en tiempo de compilación no atrapa esto porque el output del LLM es unknown o any.

Definir contratos explícitos con Zod

La solución es tratar los mensajes entre agentes exactamente como tratas los datos que llegan de una API externa: parsear contra un schema antes de usarlos. Si el parse falla, falla rápido con un error claro — no silenciosamente con un undefined.

import { z } from 'zod'

// Contrato compartido entre Agente Orquestador y Agente Facturación
const BillingMessageSchema = z.object({
  customerId: z.string().uuid('customerId debe ser un UUID válido'),
  amount: z.number().positive('El importe debe ser positivo'),
  currency: z.enum(['EUR', 'USD', 'GBP']),
  action: z.enum(['create_invoice', 'send_reminder', 'cancel']),
  version: z.literal('v2'),
})

type BillingMessage = z.infer<typeof BillingMessageSchema>

// En el agente receptor — validar antes de actuar
async function handleBillingRequest(rawMessage: unknown): Promise<void> {
  const result = BillingMessageSchema.safeParse(rawMessage)

  if (!result.success) {
    // Log estructurado con el error exacto para debugging
    console.error('Contract violation', {
      errors: result.error.issues,
      received: rawMessage,
      schema: 'BillingMessageSchema@v2',
    })
    throw new Error(`Invalid message contract: ${result.error.issues[0].message}`)
  }

  const message = result.data  // TypeScript sabe el tipo exacto aquí
  await processBillingAction(message)
}

La diferencia con añadir as BillingMessage en el receptor es total: un cast en TypeScript es una promesa al compilador, no una garantía en runtime. Zod valida el dato real, en el momento en que llega.

Versionado de contratos: evolución sin romper

Los contratos cambian. La versión 1 tenía amount: number. La versión 2 añade currency como campo obligatorio. Si actualizas el schema sin mantener compatibilidad, rompes todos los agentes que aún envían mensajes v1.

El patrón más robusto es un schema discriminado por versión:

const BillingMessageV1 = z.object({
  version: z.literal('v1'),
  customer_id: z.string(),  // snake_case en v1
  amount: z.number(),
})

const BillingMessageV2 = z.object({
  version: z.literal('v2'),
  customerId: z.string().uuid(),  // camelCase en v2
  amount: z.number().positive(),
  currency: z.enum(['EUR', 'USD', 'GBP']),
  action: z.enum(['create_invoice', 'send_reminder', 'cancel']),
})

const BillingMessage = z.discriminatedUnion('version', [
  BillingMessageV1,
  BillingMessageV2,
])

type BillingMessage = z.infer<typeof BillingMessage>

// En el receptor — manejar ambas versiones
async function handleMessage(raw: unknown): Promise<void> {
  const parsed = BillingMessage.safeParse(raw)
  if (!parsed.success) throw new Error('Unknown message version')

  if (parsed.data.version === 'v1') {
    // Normalizar v1 a v2 antes de procesar
    return handleNormalized({
      version: 'v2',
      customerId: parsed.data.customer_id,
      amount: parsed.data.amount,
      currency: 'EUR',  // default para v1
      action: 'create_invoice',
    })
  }

  return handleNormalized(parsed.data)
}

Este patrón permite migrar gradualmente: los agentes nuevos envían v2, los que todavía no se han actualizado envían v1, y el receptor maneja ambos sin romperse.

Validación en los dos extremos del mensaje

Una práctica que reduce la superficie de bugs a la mitad: validar tanto en el emisor como en el receptor.

// En el EMISOR — validar antes de enviar
async function sendToBillingAgent(message: BillingMessage): Promise<void> {
  // Esto ya es TypeScript — pero Zod permite serializar con validación
  const validated = BillingMessageV2.parse(message)

  await agentQueue.publish({
    target: 'billing-agent',
    payload: validated,
    timestamp: new Date().toISOString(),
  })
}

// En el RECEPTOR — validar al recibir
async function receive(raw: unknown): Promise<void> {
  const message = BillingMessage.safeParse(raw)
  if (!message.success) {
    await alertOps('Contract violation at billing-agent receiver', message.error)
    return
  }
  await process(message.data)
}

Cuando el emisor valida antes de enviar, atrapa bugs en el agente que genera el mensaje — que es donde se originan la mayoría. Cuando el receptor valida al llegar, atrapa mensajes malformados que vinieron de cualquier fuente inesperada: un mensaje encolado manualmente para depurar, una versión antigua de un agente que todavía no se ha desplegado, un test que envía datos de ejemplo.

Los sistemas de agentes que integramos en producción incluyen este patrón desde el primer sprint. El coste de añadirlo a posteriori es significativamente mayor — especialmente cuando hay múltiples agentes en el grafo.

Centralizar los schemas como contratos compartidos

El error más común en equipos que empiezan a implementar este patrón es duplicar los schemas. Agente A define BillingMessageSchema en su propio archivo. Agente B define otro BillingMessageSchema en el suyo. Los dos schemas se desincronizarán.

La solución es extraer los contratos a un paquete compartido:

// packages/agent-contracts/src/billing.ts
export const BillingMessageSchema = z.object({ ... })
export type BillingMessage = z.infer<typeof BillingMessageSchema>

// En Agente A
import { BillingMessageSchema } from '@company/agent-contracts/billing'

// En Agente B
import { BillingMessageSchema, BillingMessage } from '@company/agent-contracts/billing'

En un monorepo con Turborepo o nx esto es inmediato. En servicios separados, un paquete publicado en un registro privado. El contrato vive en un solo lugar; cuando cambia, todos los consumidores actualizan a la vez.

Este enfoque también facilita añadir tests de contrato — tests que verifican que los mensajes reales que circulan en producción siguen siendo compatibles con el schema publicado, sin necesidad de levantar todos los agentes. Es la misma idea que Pact o Spring Cloud Contract, aplicada a integración de sistemas con IA.

El fallo silencioso más caro

Un campo mal tipado entre agentes no produce una excepción visible. Produce una decisión incorrecta tomada con datos incorrectos: una factura creada con importe 0, un cliente contactado con el mensaje de otro cliente, una acción ejecutada cuando no debería.

El coste no es el tiempo de depuración — es el impacto real en el negocio que ya ocurrió mientras el bug estaba activo.

Los contratos tipados con validación en runtime no son overhead de ingeniería. Son la garantía de que cuando dos agentes colaboran, lo hacen sobre datos que ambos entienden igual.

Si estás construyendo un sistema multi-agente y quieres que los contratos entre ellos sean sólidos desde el primer día, cuéntame en qué estás trabajando — revisamos el diseño del grafo de agentes juntos antes de que el primer bug llegue a producción.

Compartir artículo

LinkedInXWhatsApp

¿Procesos repetitivos en tu empresa?

Descarga gratis el Mapa de Automatización IA — los 5 procesos que más tiempo roban y cómo resolverlos.

Sin spam. Solo el PDF. Puedes darte de baja cuando quieras.

Escrito por Daily Miranda Pardo

Ayudo a empresas a automatizar procesos, crear agentes IA y conectar sistemas inteligentes.