12 · Un agente que vive en otro contenedor
Esto es un fichero que se ejecuta:
examples/agentes/12_agente_remoto.py↗ Esta página lo transcribe y enseña lo que imprime. Si dejan de coincidir, falla un test.
Dominio: el pipeline de doblaje. El revisor de sincronía no es tuyo: lo mantiene otro equipo, se despliega por su cuenta y se actualiza cuando ellos quieren. No puedes importarlo — solo hablarle.
Esa es la pregunta que este ejemplo contesta: ¿se pueden desplegar los agentes en contenedores distintos y seguir orquestándolos? Sí, y la respuesta no es nuestra: es A2A, el protocolo que estandarizó la Linux Foundation, la misma pieza que MCP ocupa para las herramientas. MCP conecta un agente con sus herramientas; A2A conecta un agente con otro agente.
Y lo que hace este framework con eso cabe en una frase: un agente remoto entra por la misma ranura que uno local.
coordinador = Agent("…", delegates=[local, remoto])
Cómo correrlo
uv run python examples/agentes/12_agente_remoto.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
12 · Un agente en otro contenedor
─────────────────────────────────
sin inferencia · respuestas guionizadas (exporta SYNAPTUM_BASE_URL para usar un modelo real)
su tarjeta dice: revisor-de-doblaje — Revisa sincronía y calidad de una pista doblada.
habilidades: ['Revisar doblaje']
Lo que **no** dice es qué riesgo tiene, porque A2A no tiene ese
campo: una tarjeta declara habilidades. Por eso `risk` es obligatorio
al conectarlo y no tiene valor por defecto — un defecto conservador
sería correcto y silencioso, y la decisión la tomaría un valor en vez
de una persona.
el catálogo del coordinador:
transcriptor riesgo=read
revisor riesgo=read
→ delega en transcriptor
← transcriptor · entrada=200 salida=40
→ delega en revisor
← revisor · entrada=2140 salida=96
El episodio 12 tiene tres intervenciones fuera de tolerancia (04:12, 09:38 y 17:55), dos de ellas en el tramo denso del guion. Con retocar esas tres queda listo.
total del run: entrada=2640 salida=196
mensajes que recibió el agente remoto: 1
contextId que usó el cliente: 'doblaje-ep12/000003-delegate'
— es el `run_id` del sub-run, `{run_id del padre}/{step_id}`. Un
identificador, dos sistemas: para nosotros es la identidad del paso;
para A2A, la conversación.
reanudando con el mismo contextId:
mensajes enviados: 1 → 1
y aun así devuelve el resultado: Doblaje es-419 del episodio 12: 47 intervencione…
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,
Phase,
Risk,
Role,
Session,
tool,
)
from synaptum.a2a import A2AClient, RemoteDelegate
from synaptum.testing import calls, says
from comun import encabezado, gateway, nombre_del_modelo
sys.path.insert(0, str(Path(__file__).parent))
from servidor_a2a.agente_remoto import AgenteRemoto
Un especialista local, para comparar
@tool(idempotent=True)
async def leer_guion(
episodio: Annotated[str, "Identificador del episodio"],
) -> str:
"""Guion original y sus marcas de tiempo."""
return (
f"{episodio} · 47 intervenciones · duración 22:41\n"
"marcas densas entre 04:00-05:00 y 17:30-18:10"
)
def transcriptor() -> Agent:
return Agent(
"transcriptor",
model=nombre_del_modelo(),
instructions="Lees el guion original y resumes su estructura.",
tools=[leer_guion],
)
async def main() -> None:
encabezado("12 · Un agente en otro contenedor")
remoto_proc = AgenteRemoto() # en producción: otro despliegue
try:
await parte_uno(remoto_proc)
await parte_dos(remoto_proc)
finally:
remoto_proc.cerrar()
1 · Quién es, antes de hablarle
async def parte_uno(remoto_proc: AgenteRemoto) -> None:
tarjeta = await A2AClient(remoto_proc.url).agent_card()
print(f" su tarjeta dice: {tarjeta.name} — {tarjeta.description}")
print(f" habilidades: {[h.get('name') for h in tarjeta.skills]}")
print(
"\n Lo que **no** dice es qué riesgo tiene, porque A2A no tiene ese\n"
" campo: una tarjeta declara habilidades. Por eso `risk` es obligatorio\n"
" al conectarlo y no tiene valor por defecto — un defecto conservador\n"
" sería correcto y silencioso, y la decisión la tomaría un valor en vez\n"
" de una persona.\n"
)
2 · Delegar sin que el bucle note la diferencia
async def parte_dos(remoto_proc: AgenteRemoto) -> None:
revisor = RemoteDelegate(
name="revisor",
url=remoto_proc.url,
# Lo declara quien lo conecta, con su nombre. Nadie más puede saberlo:
# al otro lado hay un modelo decidiendo, y puede cambiar sin avisarnos.
risk=Risk.READ,
description="Revisa la sincronía de una pista doblada contra el original.",
poll_every=0.05, # en producción, segundos
)
coordinador = Agent(
"doblaje",
model=nombre_del_modelo(),
instructions=(
"Coordinas la revisión de un episodio doblado. Pide la estructura "
"del guion y la revisión de sincronía, y resume en dos frases qué "
"hay que retocar."
),
# Uno local y uno al otro lado de una red. El bucle no los distingue:
# acepta cualquier cosa con `execute`, no una clase concreta.
delegates=[transcriptor(), revisor],
)
print(" el catálogo del coordinador:")
for definicion in coordinador.tools:
print(f" {definicion.name:<14} riesgo={definicion.risk.value}")
print()
sesion = Session(
"doblaje-ep12",
gateway([_guion] * 5, tools=[leer_guion]),
MemoryCheckpointer(),
)
async for paso in coordinador.run("Revisa el episodio 12.", session=sesion):
match paso:
case DelegateStep(phase=Phase.ATTEMPTED, agent=quien):
print(f" → delega en {quien}")
case DelegateStep(phase=Phase.COMPLETED, agent=quien, usage=consumo):
medido = (
f"entrada={consumo.input} salida={consumo.output}"
if consumo.input is not None
else "sin medir — el remoto no reportó consumo"
)
print(f" ← {quien} · {medido}")
case FinalStep(output=salida, usage=total):
print(f"\n {salida}")
print(f"\n total del run: entrada={total.input} salida={total.output}")
print(
f"\n mensajes que recibió el agente remoto: {len(remoto_proc.enviados)}\n"
f" contextId que usó el cliente: "
f"{remoto_proc.enviados[0].get('contextId')!r}\n"
" — es el `run_id` del sub-run, `{run_id del padre}/{step_id}`. Un\n"
" identificador, dos sistemas: para nosotros es la identidad del paso;\n"
" para A2A, la conversación.\n"
)
# Y lo que hace ese identificador, medido:
await reanudar(remoto_proc, revisor)
async def reanudar(remoto_proc: AgenteRemoto, revisor: RemoteDelegate) -> None:
"""Volver a entrar en la misma delegación no vuelve a encargarla."""
antes = len(remoto_proc.enviados)
contexto = remoto_proc.enviados[0].get("contextId")
sesion = Session("doblaje-ep12", gateway([], tools=[]), MemoryCheckpointer())
resultado, _ = await revisor.execute("Revisa el episodio 12.", sesion, contexto)
print(" reanudando con el mismo contextId:")
print(f" mensajes enviados: {antes} → {len(remoto_proc.enviados)}")
print(f" y aun así devuelve el resultado: {resultado[:48]}…")
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 "revisor" in nombres: # el coordinador
if resultados == 0:
return calls("transcriptor", id="d1", brief="Resume la estructura del episodio 12.")
if resultados == 1:
return calls("revisor", id="d2", brief="Revisa la sincronía del episodio 12 en es-419.")
return says(
"El episodio 12 tiene tres intervenciones fuera de tolerancia "
"(04:12, 09:38 y 17:55), dos de ellas en el tramo denso del guion. "
"Con retocar esas tres queda listo."
)
if resultados == 0: # el transcriptor
return calls("leer_guion", id="t1", episodio="ep12")
return says("47 intervenciones en 22:41, con marcas densas en 04:00-05:00 y 17:30-18:10.")
if __name__ == "__main__":
asyncio.run(main())
Lo que esto enseña
Las tres cosas que pasan aquí y no se ven:
Reanudar es una consulta, no una apuesta. El taskId lo asigna el
servidor y se pierde en una caída. El contextId lo ponemos nosotros, así que
al volver se pregunta tasks/list(contextId) antes de enviar nada — y si ya
había una tarea, se espera a esa en vez de encargar el trabajo otra vez. Es la
diferencia entre reanudar y pagar dos veces un trabajo irreversible. Contra un
servidor sin tasks/list se baja un peldaño —un messageId determinista, que
garantiza menos— y se dice.
Esperar es parte del protocolo. Un agente remoto puede tardar minutos, y
puede quedarse esperando a una persona (input-required, auth-required):
eso no es un fallo ni un resultado, es lo mismo que nuestro ApprovalStep del
otro lado de la red, y vuelve al modelo dicho con esas palabras. Cancelar es
dejar de esperar: al cerrar el iterador se cancela la tarea remota.
El consumo puede no venir. A2A no define un campo para él. Si el servidor
lo pone en metadata se lee; si no, queda None — «nadie lo midió», que no
es lo mismo que cero. Un agente remoto siempre cuesta algo.
Y una advertencia sobre dónde poner la url: en un despliegue gobernado
apunta al proxy del arnés, no al agente. Un control en el framework es una
petición; una frontera en el camino es una frontera. El código no cambia — solo
a dónde apunta.
El fichero entero, para clonarlo y tocarlo: examples/agentes/12_agente_remoto.py ↗
Está en examples/ con los otros quince, y todos corren igual.