07 · Un agente como herramienta de otro, y un supervisor que enruta
Esto es un fichero que se ejecuta:
examples/agentes/07_agente_como_herramienta.py↗ Esta página lo transcribe y enseña lo que imprime. Si dejan de coincidir, falla un test.
Dominio: una mesa de entrada que reparte entre varios sistemas. Llega una petición en lenguaje natural y puede ser de tres sitios distintos: un incidente de observabilidad, un documento para extraer, o un pago. Cada uno tiene su especialista, sus herramientas y su riesgo — y esa es la situación de cualquier organización con más de un sistema en producción.
El patrón: envolver un agente en un @tool. No hace falta nada del
framework para esto — un @tool es una función asíncrona, y un agente se
ejecuta con async for. Pero tiene consecuencias que conviene entender antes de
usarlo, y están al final del fichero.
Cómo correrlo
uv run python examples/agentes/07_agente_como_herramienta.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
07 · Un agente como herramienta
───────────────────────────────
sin inferencia · respuestas guionizadas (exporta SYNAPTUM_BASE_URL para usar un modelo real)
petición ¿Por qué va lento el gateway de inferencia y cuánto saldo le queda al agente agt-7741?
↳ delega a observabilidad
↳ delega a tesoreria
El gateway recarga el modelo en cada petición —5.9s de los 6.4s del p95—, y agt-7741 tiene 4.812.400µ disponibles con 150.000µ retenidos.
coste supervisor entrada=300
coste especialistas entrada=400
coste real entrada=700
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, FinalStep, Phase, Risk, Session, ToolStep, Usage, tool
from synaptum.testing import calls, says
from comun import encabezado, gateway, nombre_del_modelo
# Consumo de los especialistas, que hay que sumar a mano: desde fuera, una
# llamada a un subagente parece una herramienta barata y no lo es.
COSTE = Usage.zero()
Herramientas de los especialistas
@tool(idempotent=True)
async def buscar_spans(consulta: Annotated[str, "Consulta sobre trazas"]) -> str:
"""Busca en las trazas de la plataforma de observabilidad."""
return "chat.completions p95 6.4s · model.load 5.9s de esos · 412 spans"
@tool(idempotent=True)
async def clasificar_documento(ruta: Annotated[str, "Ruta del documento"]) -> str:
"""Clasifica un documento en una de las categorías soportadas."""
return "tipo: boleta_de_pago · confianza 0.94 · 1 página · texto nativo (sin OCR)"
@tool(risk=Risk.SOFT_WRITE)
async def consultar_saldo(cuenta: Annotated[str, "Identificador de cuenta"]) -> str:
"""Saldo disponible de una cuenta del libro mayor."""
return f"{cuenta}: 4.812.400µ disponibles · 150.000µ retenidos"
Los especialistas, envueltos como herramientas
async def _ejecutar(agente: Agent, brief: str, run_id: str, puerta) -> str:
"""Corre un agente hasta el final y devuelve su salida.
El historial del subagente **no sale de aquí**: lo que vuelve es su
respuesta. Es lo que hace que delegar aísle contexto en vez de duplicarlo.
"""
global COSTE
async for paso in agente.run(brief, session=Session(run_id, puerta)):
if isinstance(paso, FinalStep):
COSTE += paso.usage
return str(paso.output)
return "el especialista no devolvió nada"
@tool
async def especialista_observabilidad(
pregunta: Annotated[str, "La pregunta, tal cual, para el especialista"],
) -> str:
"""Responde preguntas sobre incidentes, latencias y trazas de la plataforma."""
agente = Agent(
"obs", model=nombre_del_modelo(),
instructions="Respondes sobre observabilidad usando las trazas.",
tools=[buscar_spans],
)
puerta = gateway(
[calls("buscar_spans", consulta="p95 gateway"),
says("El p95 son 6.4s y 5.9s de ellos son carga de modelo: se está "
"recargando el modelo en cada petición.")],
tools=[buscar_spans],
)
return await _ejecutar(agente, pregunta, "sup-obs", puerta)
@tool
async def especialista_documentos(
pregunta: Annotated[str, "La pregunta, tal cual, para el especialista"],
) -> str:
"""Clasifica y extrae datos de documentos empresariales."""
agente = Agent(
"docs", model=nombre_del_modelo(),
instructions="Clasificas documentos y dices qué se puede extraer.",
tools=[clasificar_documento],
)
puerta = gateway(
[calls("clasificar_documento", ruta="/entrada/88231.pdf"),
says("Es una boleta de pago con 94% de confianza y texto nativo, así que "
"no hace falta OCR.")],
tools=[clasificar_documento],
)
return await _ejecutar(agente, pregunta, "sup-docs", puerta)
@tool
async def especialista_tesoreria(
pregunta: Annotated[str, "La pregunta, tal cual, para el especialista"],
) -> str:
"""Consulta saldos, retenciones y movimientos del libro mayor."""
agente = Agent(
"tesoro", model=nombre_del_modelo(),
instructions="Respondes sobre el estado de las cuentas.",
tools=[consultar_saldo],
)
puerta = gateway(
[calls("consultar_saldo", cuenta="agt-7741"),
says("Hay 4.812.400µ disponibles y 150.000µ retenidos.")],
tools=[consultar_saldo],
)
return await _ejecutar(agente, pregunta, "sup-tes", puerta)
El supervisor
async def main() -> None:
encabezado("07 · Un agente como herramienta")
especialistas = [
especialista_observabilidad,
especialista_documentos,
especialista_tesoreria,
]
supervisor = Agent(
"mesa-de-entrada",
model=nombre_del_modelo(),
instructions=(
"Enrutas cada petición al especialista adecuado y devuelves su "
"respuesta. No contestes tú: no tienes acceso a los datos. Si una "
"petición toca dos dominios, consulta a los dos."
),
tools=especialistas,
)
peticion = "¿Por qué va lento el gateway de inferencia y cuánto saldo le queda al agente agt-7741?"
guion = [
calls("especialista_observabilidad",
pregunta="¿Por qué va lento el gateway de inferencia?"),
calls("especialista_tesoreria", pregunta="¿Cuánto saldo le queda a agt-7741?"),
says("El gateway recarga el modelo en cada petición —5.9s de los 6.4s del "
"p95—, y agt-7741 tiene 4.812.400µ disponibles con 150.000µ retenidos."),
]
print(f" petición {peticion}\n")
sesion = Session("mesa-1", gateway(guion, tools=especialistas))
async for paso in supervisor.run(peticion, session=sesion):
match paso:
case ToolStep(phase=Phase.ATTEMPTED, call=llamada) if llamada:
print(f" ↳ delega a {llamada.name.removeprefix('especialista_')}")
case FinalStep(output=respuesta, usage=consumo_supervisor):
print(f"\n {respuesta}\n")
print(f" coste supervisor entrada={consumo_supervisor.input}")
print(f" coste especialistas entrada={COSTE.input}")
print(f" coste real entrada={consumo_supervisor.input + COSTE.input}")
if __name__ == "__main__":
asyncio.run(main())
Lo que esto enseña
Cuándo usar esto, y cuándo no ─────────────────────────────
A favor: el supervisor no necesita conocer las herramientas de nadie. Sus tres herramientas tienen una firma de una línea, mientras cada especialista puede tener quince. Añadir un dominio es añadir una función, no reescribir un prompt.
En contra, y es lo que nadie cuenta:
- El coste desaparece de la vista.
FinalStep.usagedel supervisor mide
*sus* llamadas, no las de dentro. Aquí se suma a mano en `COSTE`, y por eso
el ejemplo imprime las tres cifras: si solo miras la del supervisor, un
sistema que gasta cinco veces más parece igual de barato.
**Esto ya está resuelto** si delegas como primitiva en vez de a mano:
`Agent(delegates=[…])` transporta el consumo del subagente en el
`DelegateStep` y lo suma al total del padre. Ver el ejemplo 09.
- Un subagente envuelto a mano no es un paso durable. Si el proceso muere
a mitad de un especialista, al reanudar la herramienta se reejecuta entera
— el journal la ve como una llamada, no como un run con sus propios pasos.
Para lectura da igual; para algo que mueva dinero, no.
**También resuelto delegando como primitiva**: el subagente tiene su propio
diario y no se repite.
- El riesgo no se propaga.
especialista_tesoreriaesRisk.READpor
defecto aunque por dentro llame a algo que escribe, y aquí hay que
declararlo a mano en el envoltorio.
**Delegando como primitiva sí se deriva**: el riesgo de delegar es el mayor
de lo que el subagente puede hacer. Envolviendo a mano el framework no
puede deducirlo —ve una función que devuelve `str`—; como delegado, sí ve
sus herramientas.
Los tres desaparecen con Agent(delegates=[…]), que es el ejemplo
09. Este patrón sigue siendo el correcto cuando lo de
dentro no es un Agent: una API ajena, un servicio heredado, cualquier
cosa que no tenga un bucle que ceder.
El fichero entero, para clonarlo y tocarlo: examples/agentes/07_agente_como_herramienta.py ↗
Está en examples/ con los otros quince, y todos corren igual.