09 · Delegar como primitiva, no como patrón
Esto es un fichero que se ejecuta:
examples/agentes/09_delegar.py↗ Esta página lo transcribe y enseña lo que imprime. Si dejan de coincidir, falla un test.
Dominio: Aerarium — el cierre de mes. Hay dos trabajos distintos y no conviene mezclarlos en un solo agente: conciliar es leer mucho y decidir poco, pagar es leer poco y decidir algo irreversible.
El 07 ya enrutaba a especialistas envolviendo
cada uno en un @tool. Funciona, y tiene tres agujeros que el propio ejemplo
documenta al final. Este fichero hace lo mismo con una línea distinta —
Agent(delegates=[…])— y los tres desaparecen:
- el consumo del subagente sube al total de quien delega,
- la delegación es un paso durable con su propio diario,
- el riesgo se deriva: delegar en alguien que borra es destructivo.
El tercero es el que no se puede conseguir a mano, y es el que importa.
Cómo correrlo
uv run python examples/agentes/09_delegar.py
No hace falta configurar nada: sin modelo, las respuestas van guionizadas y todo lo
demás es real — las herramientas se ejecutan, el journal se escribe, el consumo se mide.
Con AXONIUM_CLIENT_ID o SYNAPTUM_BASE_URL en el entorno, el mismo fichero sin tocar
habla con un modelo de verdad; lo que cambia entonces es lo que diga el modelo, no el
código. Ver Modelos.
Lo que imprime
09 · Delegar como primitiva
───────────────────────────
sin inferencia · respuestas guionizadas (exporta SYNAPTUM_BASE_URL para usar un modelo real)
el catálogo del supervisor:
conciliador riesgo=read argumentos=['brief']
tesoreria riesgo=hard_write argumentos=['brief']
«tesoreria» sale HARD_WRITE sin que nadie lo declare aquí: el riesgo
de delegar es el mayor de lo que el otro puede hacer.
→ delega en conciliador: Concilia el periodo 2026-08.
← conciliador · entrada=200 salida=40
su diario vive en 'cierre-2026-08/000001-delegate'
→ delega en tesoreria: Paga el asiento as-8812 por 1.204.000 micros.
← tesoreria · entrada=200 salida=40
su diario vive en 'cierre-2026-08/000003-delegate'
Agosto queda cerrado con una salvedad: pagado as-8812 (Cloudflare, 1.204.000µ). as-8840 y as-8851 quedan retenidos — un duplicado aparente de nómina y un reembolso sin justificante.
gasto propio del supervisor: entrada=300 salida=60
total del run: entrada=700 salida=140
la diferencia es lo que costaron los especialistas, y sin
delegar como primitiva no aparecería en ninguna parte.
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
from typing import Annotated
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from synaptum import (
Agent,
DelegateStep,
FinalStep,
MemoryCheckpointer,
ModelStep,
Phase,
Risk,
Role,
Session,
Usage,
tool,
)
from synaptum.testing import calls, says
from comun import encabezado, gateway, nombre_del_modelo
Las herramientas de cada especialista
Fíjate en el riesgo declarado: el conciliador solo lee, la tesorería mueve dinero. Esa diferencia es la que va a viajar sola hasta el supervisor.
@tool(idempotent=True)
async def listar_pendientes(
periodo: Annotated[str, "Periodo contable, por ejemplo '2026-08'"],
) -> str:
"""Asientos del periodo que no cuadran con el extracto bancario."""
return (
"3 pendientes en 2026-08:\n"
" as-8812 proveedor Cloudflare 1.204.000µ sin contrapartida\n"
" as-8840 nómina septiembre 8.900.000µ duplicado aparente\n"
" as-8851 reembolso viaje 84.500µ falta justificante"
)
@tool(risk=Risk.HARD_WRITE)
async def ejecutar_pago(
asiento: Annotated[str, "Identificador del asiento a pagar"],
importe_micros: Annotated[int, "Importe en micros"],
) -> str:
"""Ordena un pago contra el banco. **Irreversible.**"""
return f"pago ordenado · {asiento} · {importe_micros}µ · ref BK-33901"
Los especialistas
Son agentes normales. Ni saben ni tienen por qué saber que alguien los va a usar como subagentes: eso lo decide quien los compone, no ellos.
def especialistas() -> tuple[Agent, Agent]:
conciliador = Agent(
"conciliador",
model=nombre_del_modelo(),
instructions=(
"Concilias asientos contra el extracto bancario. Enumera lo que no "
"cuadra y di qué falta en cada caso. No ordenas pagos."
),
tools=[listar_pendientes],
)
tesoreria = Agent(
"tesoreria",
model=nombre_del_modelo(),
instructions=(
"Ejecutas pagos ya aprobados. Un pago por asiento, con su importe "
"exacto. Si el brief no trae importe, no pagas."
),
tools=[ejecutar_pago],
)
return conciliador, tesoreria
El supervisor
async def main() -> None:
encabezado("09 · Delegar como primitiva")
conciliador, tesoreria = especialistas()
cierre = Agent(
"cierre",
model=nombre_del_modelo(),
instructions=(
"Coordinas el cierre de mes. Primero pides la conciliación, y solo "
"después ordenas los pagos que hayan quedado claros. Termina con un "
"resumen de dos frases."
),
# Esta es la línea. Un `Agent` suelto se envuelve solo; también se
# acepta cualquier cosa que cumpla el contrato —un delegado remoto, por
# ejemplo (ver el `12`)— y el bucle no distingue.
delegates=[conciliador, tesoreria],
)
# Lo que el supervisor ve de cada especialista: un nombre, para qué sirve, y
# **un solo parámetro**. No hereda su catálogo, así que delegar no infla el
# prefijo de quien delega — un especialista con quince herramientas se
# presenta igual que uno con una.
print(" el catálogo del supervisor:")
for definicion in cierre.tools:
print(
f" {definicion.name:<14} riesgo={definicion.risk.value:<10} "
f"argumentos={sorted(definicion.parameters['properties'])}"
)
print(
"\n «tesoreria» sale HARD_WRITE sin que nadie lo declare aquí: el riesgo\n"
" de delegar es el mayor de lo que el otro puede hacer.\n"
)
sesion = Session(
"cierre-2026-08",
# Siete respuestas: tres del supervisor y dos de cada especialista.
gateway([guion] * 7, tools=[listar_pendientes, ejecutar_pago]),
MemoryCheckpointer(),
)
propio = Usage.zero() # lo que gastó el supervisor por su cuenta
async for paso in cierre.run("Cierra agosto de 2026.", session=sesion):
match paso:
case ModelStep(phase=Phase.COMPLETED, response=respuesta) if respuesta:
propio += respuesta.usage
case DelegateStep(phase=Phase.ATTEMPTED, agent=quien, brief=encargo):
print(f" → delega en {quien}: {_corto(encargo)}")
case DelegateStep(phase=Phase.COMPLETED, agent=quien, usage=consumo, step_id=paso_id):
print(f" ← {quien} · entrada={consumo.input} salida={consumo.output}")
print(f" su diario vive en 'cierre-2026-08/{paso_id}'")
case FinalStep(output=resumen, usage=total):
print(f"\n {resumen}\n")
print(f" gasto propio del supervisor: entrada={propio.input} salida={propio.output}")
print(f" total del run: entrada={total.input} salida={total.output}")
print(" la diferencia es lo que costaron los especialistas, y sin")
print(" delegar como primitiva no aparecería en ninguna parte.")
def _corto(texto: str, tope: int = 60) -> str:
return texto if len(texto) <= tope else f"{texto[:tope]}…"
El guion, solo para cuando no hay modelo
Es una función y no una lista porque aquí hay tres bucles compartiendo gateway —el supervisor y sus dos especialistas— y un guion posicional no sabría a cuál le toca. Un modelo de verdad responde por lo que ve; este también.
def guion(peticion):
nombres = {t.name for t in peticion.tools}
resultados = sum(1 for m in peticion.messages if m.role is Role.TOOL)
if "conciliador" in nombres: # el supervisor
if resultados == 0:
return calls("conciliador", id="d1", brief="Concilia el periodo 2026-08.")
if resultados == 1:
return calls(
"tesoreria", id="d2",
brief="Paga el asiento as-8812 por 1.204.000 micros.",
)
return says(
"Agosto queda cerrado con una salvedad: pagado as-8812 (Cloudflare, "
"1.204.000µ). as-8840 y as-8851 quedan retenidos — un duplicado "
"aparente de nómina y un reembolso sin justificante."
)
if "listar_pendientes" in nombres: # el conciliador
if resultados == 0:
return calls("listar_pendientes", id="c1", periodo="2026-08")
return says(
"Tres asientos sin cuadrar: as-8812 es un cargo real de Cloudflare "
"sin contrapartida contable y se puede pagar; as-8840 parece un "
"duplicado de nómina y as-8851 no tiene justificante."
)
if resultados == 0: # la tesorería
return calls("ejecutar_pago", id="t1", asiento="as-8812", importe_micros=1_204_000)
return says("Pagado as-8812 por 1.204.000µ, referencia bancaria BK-33901.")
if __name__ == "__main__":
asyncio.run(main())
Lo que esto enseña
Lo que cambia respecto al 07, dicho entero:
Sigue habiendo un sitio para envolver a mano. Si lo que hay detrás no es
un Agent —una API ajena, un servicio heredado— un @tool es la respuesta
correcta y esto no aplica.
Pero entre agentes, delegar es una primitiva por un motivo concreto: un
subagente necesita su propio diario. Envuelto en una función, una caída a
mitad lo reejecuta entero, porque el journal del padre vio una llamada y no un
run. Como primitiva, su identidad se deriva de la del padre
—{run_id}/{step_id}, determinista como todo lo demás— así que al reanudar se
reencuentra con lo suyo y no vuelve a pagarlo.
Y lo que no cambia, porque es la misma regla de siempre:
Entre agentes viaja el resultado, nunca el historial.
Al especialista le llega el brief y nada más. De vuelta suben el resultado y el consumo. Su conversación se queda en su diario, donde se puede auditar, y no en el prompt del supervisor, donde solo costaría dinero.
Un aviso honesto sobre el riesgo derivado: se declara —el modelo lo ve en el catálogo, el arnés en el handshake— pero la delegación en sí no cruza la costura, así que una política no puede denegarla antes de que empiece. Las herramientas del especialista sí la cruzan cuando las llama, así que un efecto destructivo se detiene igual; lo que se pierde es detenerlo antes de pagar la inferencia del hijo. Cerrarlo exige un método nuevo en la costura, y eso va por el canal de coordinación.
El fichero entero, para clonarlo y tocarlo: examples/agentes/09_delegar.py ↗
Está en examples/ con los otros quince, y todos corren igual.