Saltar al contenido

Capítulo 13 de 18

Varios agentes, inbox y coordinación durable

Divide trabajo independiente, integra resultados con evidencia y conserva entregas aunque el coordinador esté suspendido.

#Paralelizar no elimina la integración

Un agente investiga la configuración de una API y otro revisa sus registros. Pueden trabajar a la vez porque consultan fuentes distintas. Dos agentes modificando las mismas diez líneas introducen conflictos y quizá pruebas sobre estados mezclados. El beneficio depende de la independencia del trabajo, no del número de agentes.

Un subagente es una ejecución con objetivo, contexto, herramientas y presupuesto propios. Puede usar el mismo modelo o uno distinto. No necesita una personalidad teatral. «Eres un experto brillante» aporta menos que un briefing que delimita qué comprobar y cómo entregar evidencia.

#El briefing como contrato de trabajo

{
  "task_id": "child-logs",
  "parent_run_id": "r-17",
  "goal": "Localizar el primer fallo de conexión de api a db",
  "allowed_resources": ["logs/api@v9", "logs/db@v4"],
  "allowed_actions": ["read", "search"],
  "deliverable": {"findings": [], "evidence_refs": [], "uncertainties": []},
  "max_steps": 8,
  "deadline_seconds": 120
}

La entrega separa hallazgos de incertidumbres. El coordinador puede comprobar las referencias antes de usar una conclusión. Si el hijo solo devuelve «parece un fallo de red», el padre tendrá que repetir investigación o confiar sin evidencia.

No supongas que un hijo comparte toda la conversación. Construye su contexto desde lo que necesita. Heredar todo aumenta coste y puede trasladar instrucciones irrelevantes o datos privados. Si comparte una base de documentos, debe conservar los mismos filtros de permisos que el padre o unos más estrechos.

#Fan-out, fan-in y dependencia

Fan-out distribuye tareas. Fan-in reúne resultados. Antes de lanzar hijos, define cuándo basta: todos completados, dos fuentes independientes, un resultado que satisfaga un criterio o un plazo. Define también qué hacer con hijos restantes.

Si B necesita el resultado de A, no son tareas paralelas. Representa esa dependencia y activa B después de aceptar A. Si varias ramas escriben artefactos, usa espacios separados y una fase de integración que verifique el conjunto. «Cada hijo pasó sus pruebas» no demuestra que los cambios combinados funcionen.

Un coordinador tampoco debe delegar recursivamente sin límite. La profundidad, el número de hijos y el presupuesto total son controles del runtime. Un hijo no puede ampliar su autoridad porque otro modelo se lo sugiera.

#Por qué hace falta un inbox

Un hijo puede terminar mientras el padre está ocupado, suspendido o reiniciándose. Una llamada en memoria parent.on_result(...) se pierde si el padre no existe. Un inbox durable guarda la entrega. Una activación avisa de que hay trabajo que atender.

La información y la señal cumplen funciones distintas. Si el aviso se pierde, el inbox permite encontrar la entrega al reanudar o mediante reconciliación. Si el aviso llega dos veces, una identidad estable y la idempotencia del receptor permiten reconocerla.

CREATE TABLE inbox (
    delivery_id TEXT PRIMARY KEY,
    parent_run_id TEXT NOT NULL,
    child_run_id TEXT NOT NULL,
    payload TEXT NOT NULL,
    admitted_seq INTEGER
);

delivery_id identifica esta entrega, no cada intento de transporte. Un resultado corregido puede ser una nueva entrega que referencia la anterior; no lo sobrescribas como si siempre hubiera dicho lo mismo.

#Admitir una entrega sin perderla

El paso crítico es incorporar la entrega a la trayectoria del padre y marcarla como admitida. Deben confirmarse juntos. Si marcas primero como consumida y caes antes de añadir el mensaje, pierdes la entrega. Si añades el mensaje y marcas después en otra transacción, puedes duplicarlo.

Vamos a comprobarlo con un padre que espera a dos hijos. El primer resultado llega dos veces; el proceso se cierra antes de admitirlo; durante la primera admisión simulamos un fallo. Finalmente llegan ambos resultados y el padre pasa de waiting a queued. También entregaremos resultados a padres terminales para comprobar que permanecen terminales.

hijo → recibir en inbox → confirmar recepción al transporte
                             ↓ puede haber reinicios
          [añadir mensaje + marcar admitido + decidir activación] COMMIT
                             ↓
                    padre listo para reclamar

Los cuatro bloques siguientes forman un ejemplo completo con Python 3.12 o posterior y SQLite. No ejecuta modelos ni requiere un broker. El estado queued funciona como cola consultable por los workers; no hay un aviso en memoria cuya pérdida pueda ocultar esa activación.

#Representar a quién espera el padre

No aceptaremos cualquier resultado dirigido a un parent_id conocido: expected_children registra qué hijos creó ese padre. inbox distingue recepción, admisión y entrega tardía. messages conserva la identidad de origen con una restricción única, para impedir que una misma entrega aparezca dos veces en la trayectoria.

import json
import sqlite3
import tempfile
from contextlib import contextmanager
from pathlib import Path


@contextmanager
def transaction(db):
    db.execute("BEGIN IMMEDIATE")
    try:
        yield
        db.execute("COMMIT")
    except BaseException:
        if db.in_transaction:
            db.execute("ROLLBACK")
        raise


def open_db(path):
    db = sqlite3.connect(path, autocommit=True)
    db.execute("PRAGMA foreign_keys=ON")
    return db


SCHEMA = """
CREATE TABLE runs (
    id TEXT PRIMARY KEY,
    status TEXT NOT NULL CHECK (status IN
        ('waiting', 'queued', 'running', 'completed', 'failed', 'cancelled')),
    version INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE expected_children (
    parent_id TEXT NOT NULL REFERENCES runs(id),
    child_id TEXT NOT NULL,
    PRIMARY KEY (parent_id, child_id)
);
CREATE TABLE inbox (
    delivery_id TEXT PRIMARY KEY,
    parent_id TEXT NOT NULL,
    child_id TEXT NOT NULL,
    payload TEXT NOT NULL,
    disposition TEXT NOT NULL DEFAULT 'received'
        CHECK (disposition IN ('received', 'admitted', 'late')),
    admitted_seq INTEGER,
    FOREIGN KEY (parent_id, child_id)
        REFERENCES expected_children(parent_id, child_id)
);
CREATE TABLE messages (
    parent_id TEXT NOT NULL REFERENCES runs(id),
    seq INTEGER NOT NULL,
    delivery_id TEXT NOT NULL UNIQUE REFERENCES inbox(delivery_id),
    payload TEXT NOT NULL,
    PRIMARY KEY (parent_id, seq)
);
"""

PRAGMA foreign_keys=ON activa la comprobación de relaciones en esta conexión SQLite. DEFAULT proporciona el valor inicial si el INSERT no lo indica. El campo version del padre se incrementará cuando cambie a preparado, para que un lector pueda detectar una versión antigua.

#Recibir dos veces no significa admitir dos veces

receive devuelve después de confirmar la escritura. El transporte podría perder esa respuesta y repetir la entrega. Una repetición idéntica devuelve duplicate; reutilizar la misma identidad con otro contenido es un error. Guardamos JSON con claves ordenadas para que el orden de un diccionario no parezca un cambio de significado.

def receive(db, parent_id, child_id, delivery_id, payload):
    if not isinstance(payload, dict):
        raise ValueError("La entrega debe ser un objeto")
    if not isinstance(delivery_id, str) or not 1 <= len(delivery_id) <= 100:
        raise ValueError("Identidad de entrega inválida")
    body = json.dumps(payload, sort_keys=True, separators=(",", ":"), allow_nan=False)
    with transaction(db):
        expected = db.execute(
            "SELECT 1 FROM expected_children WHERE parent_id=? AND child_id=?",
            (parent_id, child_id),
        ).fetchone()
        if expected is None:
            raise PermissionError("El hijo no pertenece a este padre")
        previous = db.execute(
            "SELECT parent_id, child_id, payload FROM inbox WHERE delivery_id=?",
            (delivery_id,),
        ).fetchone()
        if previous is not None:
            if previous != (parent_id, child_id, body):
                raise ValueError("La identidad de entrega se reutilizó con otros datos")
            return "duplicate"
        db.execute(
            "INSERT INTO inbox(delivery_id, parent_id, child_id, payload) "
            "VALUES (?, ?, ?, ?)", (delivery_id, parent_id, child_id, body),
        )
    return "received"

En un servicio real, child_id se obtiene de la identidad del worker o canal autenticado y se comprueba contra la tarea concedida. No basta con que un cliente escriba el identificador de otro hijo en su JSON. Aquí pasamos identidades conocidas directamente para concentrarnos en recepción y admisión. El esquema del contenido de los hallazgos también necesitará validación según el briefing; el ejemplo solo exige un objeto serializable.

#Admitir y activar en la misma transacción

La siguiente función trabaja sobre lo ya recibido. No llama al modelo dentro de la transacción. Primero descarta trabajo ya resuelto; después protege los estados terminales; solo entonces asigna una secuencia y añade el resultado. El padre se prepara únicamente cuando cada hijo esperado tiene una entrega admitida.

def admit(db, delivery_id, *, fail_after_message=False):
    with transaction(db):
        delivery = db.execute(
            "SELECT i.parent_id, i.payload, i.disposition, r.status "
            "FROM inbox i JOIN runs r ON r.id=i.parent_id WHERE i.delivery_id=?",
            (delivery_id,),
        ).fetchone()
        if delivery is None:
            raise ValueError("Entrega desconocida")
        parent_id, body, disposition, status = delivery
        if disposition != "received":
            return disposition
        if status in {"completed", "failed", "cancelled"}:
            db.execute("UPDATE inbox SET disposition='late' WHERE delivery_id=?",
                       (delivery_id,))
            return "late"
        seq = db.execute(
            "SELECT COALESCE(MAX(seq), 0) + 1 FROM messages WHERE parent_id=?",
            (parent_id,),
        ).fetchone()[0]
        db.execute("INSERT INTO messages VALUES (?, ?, ?, ?)",
                   (parent_id, seq, delivery_id, body))
        if fail_after_message:
            raise RuntimeError("Fallo antes de marcar la entrega admitida")
        db.execute(
            "UPDATE inbox SET disposition='admitted', admitted_seq=? WHERE delivery_id=?",
            (seq, delivery_id),
        )
        missing = db.execute(
            "SELECT COUNT(*) FROM expected_children e WHERE e.parent_id=? "
            "AND NOT EXISTS (SELECT 1 FROM inbox i WHERE i.parent_id=e.parent_id "
            "AND i.child_id=e.child_id AND i.disposition='admitted')",
            (parent_id,),
        ).fetchone()[0]
        if status == "waiting" and missing == 0:
            db.execute("UPDATE runs SET status='queued', version=version+1 WHERE id=?",
                       (parent_id,))
    return "admitted"

COALESCE(MAX(seq), 0) usa cero cuando aún no existen mensajes y después suma uno. Hacer ese cálculo sin reservar escritura permitiría que dos procesos eligiesen la misma secuencia. Aquí BEGIN IMMEDIATE serializa a los escritores SQLite durante la admisión; la clave primaria refuerza la unicidad.

NOT EXISTS busca hijos sin ninguna entrega admitida. Contar simplemente «dos mensajes» sería incorrecto: podrían ser dos versiones del resultado de un solo hijo. Un return dentro del with también pasa por su salida y confirma los cambios; una excepción los revierte. Así late queda guardado sin insertar un mensaje ni reactivar al padre.

El ejemplo supone que waiting significa «esperando a estos hijos». Si tu ejecución también puede esperar una aprobación o al usuario, guarda la causa y compruébala antes de cambiar a queued. Recibir un resultado no satisface automáticamente cualquier otra condición de espera.

#Probar duplicados, reapertura y una entrega tardía

with tempfile.TemporaryDirectory() as directory:
    path = Path(directory) / "inbox.db"
    db = open_db(path)
    db.executescript(SCHEMA)
    db.execute("INSERT INTO runs(id, status) VALUES ('padre', 'waiting')")
    db.executemany("INSERT INTO expected_children VALUES ('padre', ?)",
                   [("logs",), ("config",)])
    payload = {"finding": "El servicio usa el puerto 5433", "source": "log:17"}
    assert receive(db, "padre", "logs", "delivery-1", payload) == "received"
    assert receive(db, "padre", "logs", "delivery-1", payload) == "duplicate"
    try:
        receive(db, "padre", "logs", "delivery-1", {"finding": "Otra conclusión"})
    except ValueError:
        pass
    else:
        raise AssertionError("Se cambió una entrega ya recibida")
    db.close()

    db = open_db(path)
    assert db.execute("SELECT COUNT(*) FROM inbox").fetchone()[0] == 1
    try:
        admit(db, "delivery-1", fail_after_message=True)
    except RuntimeError:
        pass
    db.close()

    db = open_db(path)
    assert db.execute("SELECT COUNT(*) FROM messages").fetchone()[0] == 0
    assert db.execute("SELECT disposition FROM inbox").fetchone()[0] == "received"
    assert admit(db, "delivery-1") == "admitted"
    assert admit(db, "delivery-1") == "admitted"
    assert db.execute("SELECT status FROM runs WHERE id='padre'").fetchone()[0] == "waiting"
    receive(db, "padre", "config", "delivery-2", {"finding": "La base escucha en 5432"})
    admit(db, "delivery-2")
    assert db.execute("SELECT COUNT(*) FROM messages").fetchone()[0] == 2
    assert db.execute("SELECT status, version FROM runs WHERE id='padre'").fetchone() == ("queued", 1)

    for terminal in ("completed", "failed", "cancelled"):
        db.execute("INSERT INTO runs(id, status) VALUES (?, ?)", (terminal, terminal))
        db.execute("INSERT INTO expected_children VALUES (?, 'tardio')", (terminal,))
        delivery_id = "late-" + terminal
        receive(db, terminal, "tardio", delivery_id, {"finding": "Resultado tardío"})
        assert admit(db, delivery_id) == "late"
        assert db.execute("SELECT status FROM runs WHERE id=?", (terminal,)).fetchone()[0] == terminal
    assert db.execute("SELECT COUNT(*) FROM messages").fetchone()[0] == 2
    assert db.execute("SELECT COUNT(*) FROM inbox WHERE disposition='late'").fetchone()[0] == 3
    db.close()
    print("Dos resultados, una activación; duplicado absorbido y tres terminales intactos")

La reapertura muestra que la primera entrega existía antes de admitirla. El fallo entre INSERT messages y UPDATE inbox deja cero mensajes y una entrega todavía disponible. Admitirla dos veces termina con un solo mensaje; el padre espera al otro hijo antes de prepararse. Los tres resultados tardíos permanecen consultables en el inbox, sin volver a abrir sus padres.

Las aserciones aíslan estas garantías del transporte. Todavía debes comprobar autenticación y validar los hallazgos antes de tratarlos como evidencia correcta. En otra base de datos, conserva la frontera transaccional y utiliza bloqueo de filas o una actualización condicional apropiada; no traslades el cálculo de secuencia fuera del bloqueo.

Cuando padre e hijo viven en bases distintas, no hay una transacción local que cubra ambos. El hijo conserva una outbox transaccional, el receptor deduplica en su inbox y la admisión ocurre localmente. El transporte puede repetir sin cambiar la semántica de entrega.

#Resultados tardíos y cancelaciones

Si el padre está cancelado, conserva el resultado tardío para auditoría o consulta, pero no lo reanudes automáticamente. Propaga cancelación a los hijos cuando proceda y registra cuáles ya tenían efectos en curso. Un hijo independiente puede seguir siendo útil, pero esa política debe ser explícita.

Un padre que espera «todos los hijos» necesita saber cuáles esperaba. Guarda las identidades al hacer fan-out; no calcules la condición contando cualquier mensaje que llegue. Un fallo permanente de un hijo debe permitir un estado parcial o un replanteamiento, no una espera infinita.

#Integrar exige verificar, no votar

Tres agentes pueden repetir el mismo error porque usaron la misma fuente. La coincidencia de respuestas no equivale a independencia de evidencia. Compara fuentes, versiones y método. Si discrepan, localiza la afirmación concreta y realiza una comprobación que pueda resolverla.

Para código, el coordinador inspecciona el diff integrado y ejecuta pruebas sobre esa versión. Para investigación, comprueba que cada afirmación tiene soporte y que los documentos no se contradicen por pertenecer a fechas distintas. Un informe de subagente es una entrada a revisar, no una autorización para declarar terminado el objetivo global.

#Medir si compensa

Compara una solución de un agente con la multiagente en tareas representativas. Mide tiempo hasta resultado válido, coste total, conflictos, trabajo repetido y tasa de éxito. La latencia de la rama más lenta más la integración puede superar la ejecución serial si las tareas son pequeñas.

Una buena primera ampliación es delegar investigación de solo lectura, mantener un único escritor de artefactos y exigir entregas estructuradas. Introduce escritores paralelos cuando tengas una política de aislamiento e integración que puedas probar.

#Referencias

    ↑ ↓ para recorrer · Intro para abrir · Ctrl K para buscar