Agente de atención al cliente con LangGraph: pedidos, devoluciones y sustituciones con el límite de aprobación humana en el código
Índice
TL;DR
El límite de la aprobación humana no está en el prompt ni en la interfaz. Está en un middleware que intercepta la llamada a la herramienta con efecto, evalúa una condición sobre los argumentos, el estado y el contexto, y si se cumple para el grafo antes de que la herramienta corra. Verificado: la interrupción salta en un nodo posterior al modelo y anterior a tools, y la herramienta no se ejecuta hasta que un humano decide.
El humano puede aprobar, editar los argumentos, rechazar con motivo o responder en lugar de la herramienta. Son las cuatro decisiones que admite HumanInTheLoopMiddleware en langchain 1.4.1, y las cuatro tienen un contrato de datos fijo que la aplicación de aprobaciones tiene que hablar. Editar el importe de un reembolso de 120 a 90 hace que la herramienta corra con 90; se ha comprobado con la herramienta instrumentada.
interrupt() sin checkpointer no falla al interrumpir. Devuelve el valor en __interrupt__ como si todo fuera bien, y falla al reanudar con Cannot use Command(resume=...) without checkpointer. Un despliegue sin persistencia parece funcionar hasta que el primer encargado pulsa aprobar.
Al reanudar, el nodo que interrumpió se ejecuta entero otra vez. Con un nodo propio, lo que haya antes de la interrupción corre dos veces. Con el middleware, la interrupción vive en su propio nodo y la herramienta corre después, una vez. Lo que sigue pudiendo correr dos veces es la herramienta cuando el pod muere con ella en vuelo, y por eso el reembolso lleva el identificador de la llamada como clave de idempotencia.
El checkpointer no sabe listar hilos interrumpidos. La cola de aprobaciones es una tabla propia que la aplicación escribe cuando invoke devuelve __interrupt__ y borra cuando se reanuda. Sin esa tabla, la única forma de saber qué está esperando es recordar los identificadores de hilo.
El rechazo llega al modelo como un mensaje en inglés que empieza por User rejected the tool call. El prompt le dice al modelo qué hacer con él. Sin esa línea, el modelo puede disculparse en inglés con un cliente de Talavera.
Estás aquí: el segundo caso, donde el ciclo se rompe
Este es el caso 2 de la serie de agentes con LangGraph para retail. El caso 1 construyó un asistente de sala que cabe en un ciclo modelo-herramientas. Este caso usa el mismo create_agent, el mismo contexto y el mismo checkpointer, y añade lo que el ciclo no tiene: parar, esperar a una persona que puede tardar un día, y seguir desde el mismo sitio.
El agente atiende a un cliente por el canal web sobre un pedido concreto. Puede informar del estado, iniciar una devolución con reembolso, o proponer una sustitución por otra referencia con stock. Las dos últimas tienen efecto en el dinero o en el inventario. La política de la tienda dice cuáles se hacen solas y cuáles firma un encargado.
La analogía: el vale de devolución con casilla de firma
En caja, una devolución de 20 euros con ticket la hace el dependiente. Una de 200, o sin ticket, o de un cliente que ya devolvió tres cosas este mes, lleva un vale con una casilla que firma el encargado. El dependiente rellena el vale entero, lo deja en la bandeja y atiende al siguiente. El encargado pasa cuando puede, lee el vale, y firma, tacha el importe y firma otro, o lo devuelve con una nota.
El agente es el dependiente que rellena el vale. El middleware es la casilla de firma. El checkpointer es la bandeja donde el vale espera. Y el contrato de la interrupción es el formato del vale: si el encargado no entiende lo que hay escrito, no firma.
La política, en una función
La política de aprobación es lo primero que se escribe, y se escribe como una función que devuelve verdadero cuando hace falta un humano. Recibe la llamada a herramienta que el modelo ha propuesto, el estado del hilo y el contexto de ejecución.
from langchain.agents.middleware import ToolCallRequest
def necesita_encargado(req: ToolCallRequest) -> bool:
args = req.tool_call["args"]
cliente = req.runtime.context.cliente_id
if req.tool_call["name"] == "reembolsar":
if args["importe"] > 60:
return True
if devoluciones_ultimos_30_dias(cliente) >= 2:
return True
return not pedido_entregado_hace_menos_de(args["pedido"], dias=30)
if req.tool_call["name"] == "sustituir":
return diferencia_pvp(args["sku_original"], args["sku_nuevo"]) > 0
return False
Tres cosas de esta función. La primera: consulta la base de negocio, no el estado del agente. El historial de devoluciones del cliente está en devoluciones, y es la fuente que un encargado aceptaría. La segunda: recibe req.state y req.runtime; verificado que state es el estado completo del agente, con messages, y que runtime.context es el objeto de contexto que se pasó en invoke. La tercera: se ejecuta después de que el modelo haya decidido llamar a la herramienta y antes de que la herramienta corra. Si devuelve falso, la herramienta corre sin parar. Si devuelve verdadero, el grafo para.
El middleware y el contrato de la interrupción
from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware
aprobacion = HumanInTheLoopMiddleware(
interrupt_on={
"reembolsar": {
"allowed_decisions": ["approve", "edit", "reject"],
"when": necesita_encargado,
},
"sustituir": {
"allowed_decisions": ["approve", "reject"],
"when": necesita_encargado,
},
"consultar_pedido": False,
"stock_alternativas": False,
},
description_prefix="Operación fuera de política. Requiere aprobación de un encargado.",
)
agente = create_agent(
modelo,
tools=[consultar_pedido, stock_alternativas, reembolsar, sustituir],
middleware=[prompt_cliente, errores, limites, aprobacion],
context_schema=ContextoCliente,
checkpointer=checkpointer,
)
Compilado, el grafo tiene un nodo más que el del caso 1: HumanInTheLoopMiddleware.after_model. Ahí es donde se evalúa when y se llama a interrupt. Es un nodo posterior al modelo y anterior a tools; verificado con una herramienta instrumentada que la herramienta no corre antes de la decisión.
Cuando la política dice que sí, invoke devuelve el estado con una clave __interrupt__. Es una lista de objetos Interrupt con value e id. El value tiene este formato exacto, obtenido de una ejecución con un reembolso de 120 euros:
{
"action_requests": [
{
"name": "reembolsar",
"args": {"pedido": "P-20260917-0041", "importe": 120.0},
"description": "Operación fuera de política. Requiere aprobación de un encargado.\n\nTool: reembolsar\nArgs: {'pedido': 'P-20260917-0041', 'importe': 120.0}"
}
],
"review_configs": [
{"action_name": "reembolsar", "allowed_decisions": ["approve", "edit", "reject"]}
]
}
action_requests lleva una entrada por herramienta interceptada en la misma vuelta del modelo; si el modelo pide un reembolso y una sustitución a la vez y las dos pasan la política, hay dos entradas y una sola interrupción. review_configs le dice a la interfaz qué botones pintar. El campo description es el prefijo más un volcado de la herramienta y los argumentos; para el encargado es mejor construir la descripción con una función, que InterruptOnConfig admite en description, y darle el nombre del cliente, la fecha del pedido y el motivo que el cliente dio.
Con interrupt_on={"reembolsar": True}, sin diccionario, se permiten las cuatro decisiones y no hay condición: todo reembolso para. Es el valor para empezar y para las pruebas, no para la tienda.
Las cuatro decisiones
La reanudación entra con Command(resume=...) y un diccionario con una lista decisions, en el mismo orden que action_requests.
from langgraph.types import Command
agente.invoke(
Command(resume={"decisions": [{"type": "approve"}]}),
config={"configurable": {"thread_id": hilo}},
)
Las cuatro, verificadas con la herramienta instrumentada:
| Decisión | Formato | Qué pasa |
|---|---|---|
approve | {"type": "approve"} | La herramienta corre con los argumentos que propuso el modelo |
edit | {"type": "edit", "edited_action": {"name": "reembolsar", "args": {...}}} | La herramienta corre con los argumentos editados. Con el importe cambiado de 120 a 90, la herramienta recibió 90 |
reject | {"type": "reject", "message": "Fuera de plazo, el pedido tiene 45 días."} | La herramienta no corre. El modelo recibe un ToolMessage con status="error" y el texto User rejected the tool call for reembolsar with reason: Fuera de plazo, el pedido tiene 45 días. |
respond | {"type": "respond", "message": "Ofrece un vale en vez de reembolso."} | La herramienta no corre. El modelo recibe un ToolMessage con status="success" y ese texto como si fuera el resultado |
Dos consecuencias para el diseño.
El rechazo llega al modelo en inglés. El marco User rejected the tool call ... with reason: lo pone el middleware y no se configura. El motivo del encargado va en castellano detrás. El prompt del agente lleva esta línea: “Si una operación es rechazada por un encargado, comunica al cliente el motivo tal cual, en castellano, y no propongas repetirla”. Sin ella, se han visto respuestas que mezclan idiomas o que reintentan el reembolso con otro importe.
respond es la puerta para la resolución manual. El encargado no aprueba ni rechaza; le dice al agente qué hacer. Como la herramienta no corre, el vale o el reembolso parcial que el encargado proponga lo tiene que emitir el propio encargado en el TPV. Para este agente se deja fuera de allowed_decisions en las dos herramientas: el encargado edita o rechaza, y lo que quiera hacer distinto lo hace fuera. Es una decisión de tienda, no de código, y el sitio donde se toma es esa lista.
Lo que se persiste y lo que se repite
Una interrupción es un checkpoint más. Con el middleware de aprobación y sin los límites del caso 1, la ejecución que para en un reembolso deja 6 checkpoints; la reanudación con edit añade 4 y acaba en 10. El estado del hilo entre los dos invoke está entero en Postgres: mensajes, la llamada propuesta, y la interrupción pendiente. agente.get_state(config) lo devuelve con next=('tools',) y una tarea con la interrupción en tasks[0].interrupts.
Sobre qué se repite hay dos casos distintos que no hay que mezclar.
La re-ejecución del nodo al reanudar. Es diseño de LangGraph: el nodo que llamó a interrupt se ejecuta desde el principio cuando llega Command(resume=...), y la llamada a interrupt devuelve entonces el valor reanudado. Verificado con un nodo propio que registra efectos antes y después de la interrupción: la secuencia es antes, antes, después. Con el middleware esto no afecta a la herramienta, porque la interrupción vive en after_model y la herramienta corre en tools, un nodo después. Afecta a cualquier nodo propio que se escriba con interrupt dentro, y la regla es que la interrupción sea lo primero del nodo.
La re-ejecución de una herramienta en vuelo. Si el pod muere con reembolsar a medias, al reanudar el hilo la tarea del nodo tools se ejecuta otra vez. Las tareas que terminaron antes del fallo no: sus escrituras quedan guardadas como pendientes y se reaplican. Verificado con un abanico de 20 tareas en el que una falla: el estado tras el fallo tiene 19 resultados, y la reanudación con invoke(None, config) ejecuta solo la que faltaba. Pero la que estaba en vuelo cuando murió el proceso no deja escritura, y corre de nuevo.
Por eso reembolsar es idempotente por identificador de llamada:
from langchain.tools import tool, ToolRuntime
@tool
def reembolsar(pedido: str, importe: float, runtime: ToolRuntime[ContextoCliente]) -> dict:
"""Emite un reembolso al medio de pago original del pedido."""
with pool.connection() as conn, conn.transaction():
previo = conn.execute(
"SELECT id, estado FROM reembolsos WHERE clave_idempotencia = %s",
(runtime.tool_call_id,),
).fetchone()
if previo:
return {"ok": True, "reembolso": previo[0], "repetido": True}
rid = conn.execute(
"INSERT INTO reembolsos (pedido_id, importe, clave_idempotencia, aprobado_por, trace_id) "
"VALUES (%s, %s, %s, %s, %s) RETURNING id",
(pedido, importe, runtime.tool_call_id,
runtime.config["metadata"].get("aprobado_por"),
runtime.config["metadata"].get("langfuse_trace_id")),
).fetchone()[0]
pasarela.reembolsar(pedido, importe, referencia=rid)
return {"ok": True, "reembolso": rid}
runtime.tool_call_id es el identificador que el modelo puso en la llamada, y se conserva en el checkpoint. Una re-ejecución trae el mismo identificador, y la columna clave_idempotencia con restricción única lo para. La llamada a la pasarela va fuera de la transacción y con la referencia del reembolso, para que la pasarela deduplique por su lado si el proceso muere entre el INSERT y la llamada.
La durabilidad se fija en invoke. Con durability="sync", cada checkpoint se escribe antes de seguir al siguiente superstep. Es el valor para los hilos con dinero. "async" escribe en paralelo con la ejecución y puede perder el último paso si el pod muere. "exit" escribe al terminar y no vale para nada que pueda interrumpirse.
agente.invoke(
Command(resume={"decisions": decisiones}),
config=config,
durability="sync",
)
La cola de aprobaciones
El checkpointer guarda cada hilo, pero no tiene ninguna consulta que devuelva los hilos que están esperando a un humano. PostgresSaver.list lista checkpoints de un hilo dado; no lista hilos. Y no hay columna de estado en checkpoints que diga “interrumpido”.
La cola es una tabla propia:
CREATE TABLE aprobaciones_pendientes (
thread_id text PRIMARY KEY,
tienda text NOT NULL,
cliente_id text NOT NULL,
interrupt_id text NOT NULL,
peticion jsonb NOT NULL, -- el value de la interrupción
creada timestamptz NOT NULL DEFAULT now(),
asignada_a text,
trace_id text
);
La aplicación la escribe cuando invoke devuelve __interrupt__ y la borra en la reanudación, en la misma transacción en la que registra quién decidió qué. Es también la tabla desde la que se mide el tiempo entre interrupción y decisión.
resultado = agente.invoke(entrada, context=ctx, config=config, durability="sync")
if interrupciones := resultado.get("__interrupt__"):
intr = interrupciones[0]
with pool.connection() as conn:
conn.execute(
"INSERT INTO aprobaciones_pendientes (thread_id, tienda, cliente_id, interrupt_id, peticion, trace_id) "
"VALUES (%s, %s, %s, %s, %s, %s) ON CONFLICT (thread_id) DO UPDATE SET peticion = EXCLUDED.peticion",
(config["configurable"]["thread_id"], ctx.tienda, ctx.cliente_id, intr.id,
Json(intr.value), config["metadata"]["langfuse_trace_id"]),
)
return {"estado": "esperando_aprobacion", "mensaje_cliente": mensaje_espera(intr.value)}
Y del lado del encargado, la reanudación con el registro de quién decidió:
def decidir(thread_id: str, encargado: str, decisiones: list[dict]):
config = {
"configurable": {"thread_id": thread_id},
"metadata": {"aprobado_por": encargado},
"callbacks": [langfuse_handler],
}
resultado = agente.invoke(Command(resume={"decisions": decisiones}), config=config, durability="sync")
with pool.connection() as conn, conn.transaction():
conn.execute("DELETE FROM aprobaciones_pendientes WHERE thread_id = %s", (thread_id,))
conn.execute(
"INSERT INTO decisiones_encargado (thread_id, encargado, decisiones) VALUES (%s, %s, %s)",
(thread_id, encargado, Json(decisiones)),
)
return resultado["messages"][-1].content
El contexto del cliente no viaja en esta llamada. Command(resume=...) se invoca sin context, y el prompt dinámico y las herramientas lo leen del runtime: en la reanudación, runtime.context es None si no se pasa. Verificado con un prompt dinámico que registra el contexto en cada llamada al modelo: C1 en la primera, None tras Command(resume=...) sin context. Hay dos opciones: guardar el contexto del cliente en el estado del agente con un state_schema propio, o pasarlo otra vez en la reanudación leyéndolo de la cola. Aquí se hace lo segundo, porque el contexto tiene el identificador del cliente y no se quiere en el checkpoint más de lo necesario. La función real de decidir carga ContextoCliente de aprobaciones_pendientes y lo pasa en context=.
Quién puede reanudar es autorización de la capa HTTP: decidir solo se llama desde un endpoint que exige rol de encargado y comprueba que la tienda del encargado es la de la fila. El checkpointer acepta cualquier Command(resume=...) para cualquier thread_id, y eso se documenta en el marco de la serie.
El prompt
@dynamic_prompt
def prompt_cliente(request: ModelRequest) -> str:
ctx = request.runtime.context
return (
f"Atiendes al cliente {ctx.cliente_id} de la tienda {ctx.tienda} sobre su pedido. "
"Consulta el pedido antes de proponer nada. "
"Para devolver, usa reembolsar con el importe de las líneas devueltas; para cambiar por otra referencia, "
"comprueba stock_alternativas y usa sustituir. "
"Algunas operaciones esperan la aprobación de un encargado; si es así, dile al cliente que la operación "
"queda pendiente y que recibirá confirmación, sin prometer un plazo. "
"Si un encargado rechaza una operación, comunica el motivo tal cual, en castellano, y no la repitas "
"con otros valores. Nunca inventes un número de reembolso: solo el que devuelva la herramienta."
)
La frase sobre la aprobación pendiente importa porque el modelo no ve la interrupción. Desde su punto de vista, pidió una herramienta y la conversación se detuvo. Cuando el hilo se reanuda con approve, lo siguiente que ve es el resultado de la herramienta, como si no hubiera pasado tiempo. Es la aplicación la que le dice al cliente que espere, con mensaje_espera, no el modelo. Y en la reanudación, el mensaje que el modelo genera tras el resultado es el que se le envía al cliente por el canal que corresponda, con el retraso que haya habido.
Una devolución fuera de política, de principio a fin
Con las herramientas reales sobre el catálogo de prueba y las respuestas del modelo guionizadas, como en el caso 1:
Cliente: Quiero devolver el pedido P-20260917-0041, la cafetera llegó con la jarra rota.
[tools] consultar_pedido(pedido="P-20260917-0041") -> {"pedido": "...", "importe": 120.0, "entregado": true, "dias_desde_entrega": 4, "lineas": [...]}
[after_model] reembolsar(pedido="P-20260917-0041", importe=120.0) -> when=True (importe > 60)
[__interrupt__] {"action_requests": [{"name": "reembolsar", "args": {"pedido": "P-20260917-0041", "importe": 120.0}, ...}], ...}
Aplicación -> Cliente: Tu solicitud de devolución está registrada y pendiente de revisión. Te confirmaremos por este canal.
[encargado, 3 horas después] edit: importe 90.0
[tools] reembolsar(pedido="P-20260917-0041", importe=90.0) -> {"ok": true, "reembolso": 7712}
Agente -> Cliente: Hemos aprobado un reembolso de 90 euros por la cafetera, con referencia 7712. El importe llegará a tu medio de pago en 3 a 5 días.
El modelo no sabe por qué el importe es 90 y no 120: la edición cambia los argumentos de la llamada, no le explica nada. Si la tienda quiere que el cliente reciba una explicación (jarra de repuesto sin coste, reembolso del resto), el encargado la escribe en la aplicación y la aplicación la añade al mensaje final. Meter esa explicación en el modelo exigiría una herramienta de notas con respond, y en este agente respond está fuera de allowed_decisions.
Lo que se mide
| Métrica | Dónde | Qué dice |
|---|---|---|
| Interrupciones por cada cien conversaciones | aprobaciones_pendientes (histórico) | Si la política para demasiado o demasiado poco. Entre 10 y 25 es la zona de trabajo; por encima, el umbral de importe es bajo |
| Tiempo entre interrupción y decisión, p50 y p95 | creada frente a la fila de decisiones_encargado | Lo que el cliente espera. El p95 dice si hay un turno sin encargado |
| Decisiones por tipo | decisiones_encargado | Muchos edit con el mismo patrón son una regla que falta en la política; muchos reject con el mismo motivo, una condición que falta en when |
Reembolsos con repetido = true | reembolsos | Re-ejecuciones reales. Debe ser cercano a cero y distinto de cero alguna vez; si nunca pasa, la clave de idempotencia no se está probando |
Operaciones con efecto sin fila en decisiones_encargado y con when verdadero | Cruce de reembolsos con decisiones_encargado | Debe ser cero. Es la auditoría de que el límite funciona |
| Tokens y coste por conversación, con y sin interrupción | Langfuse, metadato caso=cliente | Una conversación interrumpida tiene dos trazas o una con hueco, según se configure el callback; las dos formas valen si se agrupan por thread_id |
La última fila de la tabla es la que se enseña al responsable. No dice cuánto ahorra el agente; dice que ningún euro ha salido sin la firma que la política exige. Es la condición para que el resto de métricas importen.
Checklist
- La política es una función
whenque consulta la base de negocio, no el prompt. allowed_decisionsestá decidido por herramienta yrespondestá fuera salvo en herramientas de notas.descriptiones una función que da al encargado el contexto que necesita, no el volcado por defecto.- La aplicación escribe
aprobaciones_pendientesal recibir__interrupt__y la borra al reanudar, en transacción condecisiones_encargado. - Las herramientas con efecto usan
runtime.tool_call_idcomo clave de idempotencia con restricción única. invokellevadurability="sync"en los hilos con dinero.- La reanudación pasa
context=cargado de la cola; no se confía en que elruntimelo recuerde. - El endpoint de reanudación exige rol de encargado y comprueba la tienda.
- El prompt dice qué hacer con un rechazo y no promete plazos.
- Hay una consulta de auditoría que cruza efectos con decisiones y da cero.
Trampas y cosas que no son lo que parecen
interrupt sin checkpointer no falla al interrumpir. Devuelve __interrupt__ con normalidad y el estado se pierde. El error, Cannot use Command(resume=...) without checkpointer, sale en la reanudación. Un entorno de pruebas sin persistencia da falsos positivos hasta que alguien intenta aprobar.
La herramienta no corre antes de la decisión, pero el modelo ya la ha pedido. Lo que se aprueba es una llamada con argumentos concretos. Si el encargado tarda un día y el stock ha cambiado, sustituir puede fallar al ejecutarse con los argumentos aprobados. La herramienta tiene que volver a comprobar, y el mensaje de error tiene que decir que la aprobación caducó.
El nodo que interrumpe se ejecuta dos veces. Con el middleware no importa; con un nodo propio, todo lo anterior a interrupt se repite. La interrupción va primero.
La herramienta en vuelo se repite si muere el pod. Las que terminaron no. La clave de idempotencia es el identificador de la llamada, y hay que probar la re-ejecución a propósito, matando el pod con una herramienta lenta, para ver la fila con repetido = true.
El rechazo llega en inglés. User rejected the tool call for ... with reason: es texto fijo del middleware. Sin instrucción en el prompt, el modelo puede contestar en inglés o reintentar.
No hay lista de hilos interrumpidos. Ni en el checkpointer ni en el estado. La cola es una tabla propia, y si la aplicación se cae entre el invoke y el INSERT, hay un hilo esperando que nadie ve. La escritura de la cola va lo más cerca posible del invoke, y una tarea periódica cruza aprobaciones_pendientes con los hilos que tienen next=('tools',) y una interrupción en get_state, recorriendo los hilos del día desde consultas_asistente.
El contexto no se recuerda en la reanudación. runtime.context es lo que se pase en esa llamada. Sin context=, el prompt dinámico ve None y falla al formatear.
Cierre
El límite de aprobación humana es una función, un middleware y una tabla. La función decide cuándo se pregunta; el middleware para el grafo antes de la herramienta y fija el contrato de la pregunta y de la respuesta; la tabla es la cola que el checkpointer no tiene. Alrededor, tres decisiones que no son de LangGraph pero que el código obliga a tomar: qué decisiones se permiten por herramienta, cómo se hace idempotente lo que tiene efecto, y quién puede reanudar.
El siguiente caso deja al cliente y mira al almacén. Un agente de compras tiene que revisar miles de referencias contra las incidencias de sus proveedores y proponer una reposición. Ahí el problema no es parar, es abrir en abanico sin tirar el servidor de inferencia, agregar sin condiciones de carrera y no pagar dos veces por la misma referencia.
Ver también
- Agentes con LangGraph para retail: el marco de la serie
- Servir agentes de LangGraph sin langgraph-api
- Agente de empleado de tienda con LangGraph: procedimientos, stock y roturas
- Agente de cadena de suministro con LangGraph: incidencias y reposición con map-reduce
- deepagents en un cluster propio: el SDK es libre, el servidor no
- Ejecución durable con Temporal y el coste de los agentes
- Aislar agentes de IA por cliente en el cluster
Fuentes
- Human-in-the-loop en LangChain 1.x: https://docs.langchain.com/oss/python/langchain/human-in-the-loop
- Interrupciones en LangGraph: https://docs.langchain.com/oss/python/langgraph/interrupts
- Persistencia y durabilidad en LangGraph: https://docs.langchain.com/oss/python/langgraph/persistence
- Código de
langchain.agents.middleware.human_in_the_loop(langchain 1.4.1):InterruptOnConfig,HITLRequest,HITLResponse,ApproveDecision,EditDecision,RejectDecision,RespondDecision - Código de
langgraph.types(langgraph 1.2.11):interrupt,Command,Interrupt - Código de
langgraph.pregel.main(langgraph 1.2.11): firma deinvoke, parámetrodurability