Command: primitiva triple resume / goto / update
Responsabilidades
Command es la «primitiva de control entre grafos» de LangGraph, definida en libs/langgraph/langgraph/types.py(Command class:759). Una instancia de Command puede portar simultáneamente tres semánticas — resume (reanudar la ejecución desde una interrupción), goto (indicar a qué nodo saltar a continuación) y update (escribir valores en los canales de estado del grafo actual). La función nodo devuelve Command(...), o bien el llamador lo pasa como entrada de graph.invoke(Command(...)), y Pregel lo descompone en pending writes que se escriben en los canales.
Su posición está entre el nodo y el bucle principal de Pregel: el nodo produce Command, y Pregel usa map_command(map_command:56) para descomponer sus campos en tuplas (task_id, channel, value) que se escriben como pending writes; la siguiente ronda de prepare_next_tasks los ve de forma natural y programa según ellos — goto se convierte en un Send escrito en el canal TASKS, update escribe directamente el canal correspondiente, y resume va por el canal RESUME para que lo consuma interrupt().
Motivación de diseño
- Expresión unificada de «efectos»: además de escribir valores en el estado, un nodo puede querer «saltar a un nodo no predeterminado», «enviar un mensaje al grafo padre» o «reanudar una interrupción» — antes estas operaciones eran APIs dispersas;
Commandlas empaqueta en un único tipo de retorno, y la firma-> Command[Literal["node_a", "node_b"]]permite incluso validar en compilación los destinos de salto válidos. Command.PARENTentre niveles de grafo: un nodo dentro de un subgrafo puede hacerreturn Command(graph=Command.PARENT, update=...)para escribir actualizaciones en el estado del grafo padre —map_command, al vergraph == "__parent__"(PARENT guard:58)raise InvalidUpdateError("There is no parent graph"); el verdadero alcance al padre ocurre en el_firstdel grafo padre, que procesa los writes reportados por el subgrafo.gotoaceptaSend:Command(goto=Send("node", arg))equivale a lanzar un task PUSH de tipo fan-out;goto="node"equivale agoto=Send("node", START)(dispara el PULL de ese nodo). Por tantoCommandintegra la capacidad de las conditional edges dentro del valor de retorno del nodo, sin necesidad de escribiradd_conditional_edgespor separado.resumecomo única entrada:Command(resume=...)es la única vía de reanudación deinterrupt()— soporta tanto un valor únicoCommand(resume="answer")que se enruta automáticamente al único pending interrupt, como un dictCommand(resume={interrupt_id: value, ...})para reanudar varios interrupts concurrentes.updatecon reducer:Command(update={"messages": [msg]})pasa por el reducer del canal — el canalmessagesconfigurado conoperator.addhace que este update sea un append, no una sobrescritura. El envoltorioOverwritepermite saltarse el reducer(Overwrite:938), yendo por la ruta de escritura directa del canalBinaryOperatorAggregate.
Archivos clave
Command class:759—@dataclass class Command(graph, update, resume, goto), el genéricoNrepresenta el tipo del nombre de nodo de goto.Command fields:780— definición y docstring de los camposgraph/update/resume/goto._update_as_tuples:791— descomponeupdateen[(channel, value), ...], soportando dict, list[tuple] y annotated dataclass.map_command:56— función central que descomponeCommanden una secuencia de pending writes.PARENT guard:58— cuandocmd.graph == Command.PARENTse lanza el error «no hay grafo padre».goto as sends:62— descomponecmd.gotoen una lista deSend;Sendse escribe en el canalTASKS,strse escribe enbranch:to:<node>para disparar START.resume write:72—cmd.resumese escribe en el canalRESUME, para que lo leainterrupt()al reentrar.update writes:74—cmd.updatese descompone en(channel, value)escrito en el canal correspondiente, pasando por el reducer.Command resume mapping:904— entrada donde el grafo padre, al recibirCommand(resume=...), enruta resume al pending interrupt correspondiente.Overwrite:938— envoltorioOverwrite(value=...), que al combinarse con un canalBinaryOperatorAggregateignora el reducer y escribe directamente.
Flujo de datos
map_command es el núcleo del parsing de Command(map_command:56):
def map_command(cmd: Command) -> Iterator[tuple[str, str, Any]]:
"""Map input chunk to a sequence of pending writes in the form (channel, value)."""
if cmd.graph == Command.PARENT:
raise InvalidUpdateError("There is no parent graph")
if cmd.goto:
if isinstance(cmd.goto, (tuple, list)):
sends = cmd.goto
else:
sends = [cmd.goto]
for send in sends:
if isinstance(send, Send):
yield (NULL_TASK_ID, TASKS, send)
elif isinstance(send, str):
yield (NULL_TASK_ID, f"branch:to:{send}", START)
else:
raise TypeError(
f"In Command.goto, expected Send/str, got {type(send).__name__}"
)
if cmd.resume is not None:
yield (NULL_TASK_ID, RESUME, cmd.resume)
if cmd.update:
for k, v in cmd._update_as_tuples():
yield (NULL_TASK_ID, k, v)El task_id es siempre NULL_TASK_ID — estos writes no pertenecen a ningún task concreto, sino que son «envíos» a nivel de grafo: el Send de goto entra en el canal TASKS (la siguiente ronda de prepare_next_tasks lo ve y lo programa como task PUSH), el str de goto va al canal branch:to:<node> (canal específico de conditional edge, escribir START significa «disparar la entrada de ese nodo»); resume entra en el canal RESUME, para que lo consuma interrupt() al reentrar al nodo; cada (k, v) de update entra en el canal de estado correspondiente y se fusiona mediante el reducer.
Cuando Command se pasa como entrada de graph.stream(...), PregelLoop._first(Command resume mapping:904)primero lo convierte en pending writes, los agrupa por task_id y los escribe en checkpoint_pending_writes; después prepare_next_taskslos procesa de forma natural. CuandoCommandes el valor de retorno de un nodo,attach_nodecoloca un mapper_get_updatesen loswritersdel nodo(<SrcLink path="libs/langgraph/langgraph/graph/state.py" lines="1431" label="attach_node"/>), que extrae el update deCommand._update_as_tuples()y lo escribe en los canales de estado, mientras que goto y resume se enrutan mediante el mapper_control_branchal canalbranch:to:y aRESUME`.
Límites y fallos
Command.PARENTfalla en el grafo raíz:map_commandlanza directamenteraise InvalidUpdateError("There is no parent graph")(PARENT guard:58) —Command.PARENTsólo tiene sentido dentro de un nodo de subgrafo; invocarlo en el grafo raíz falla al instante.- Restricciones de tipo en
goto:gotosólo aceptaSend/stro secuencias de ellos; cualquier otro tipo lanzaraise TypeError(goto type:69) — no se permite pasar enteros, dicts, etc. - resume sin id fuerza un único pending:
Command(resume="single")con varios pending interrupts lanzaraise RuntimeError(multi interrupt error:917); debe cambiarse aCommand(resume={id: value, ...}). updatepasa por reducer, no escribe directamente: los canalesBinaryOperatorAggregate(por ejemploAnnotated[list, operator.add]) pasan por defecto por el reducer y fusionan múltiples updates; para saltar el reducer se usaOverwrite(value=...)(Overwrite:938), pero varias llamadasOverwriteal mismo canal en el mismo superstep lanzanInvalidUpdateError.Commandyentrypoint.finalson mutuamente excluyentes: la función@entrypointdevuelveentrypoint.final(value=..., save=...), que internamente va por los canalesEND/PREVIOUS; devolverCommandva por canales de control — no se pueden usar a la vez; la función entrypoint debería devolverCommandofinal, sin mezclar.Commandvacío lanza error: cuando los tres campos deCommand()sonNone/(),map_commandno produce nada, y_firstal ver writes vacíos lanzaraise EmptyInputError("Received empty Command input")(empty command:928).
Resumen
Command es la primitiva triple resume / goto / update; a través de map_command se descompone en writes a canales y la siguiente ronda de prepare_next_tasks los procesa de forma natural. Unifica conditional edges, interrupt resume y actualización de estado en un único valor de retorno. La primitiva interrupt() complementaria se trata en /interrupt/interrupt; la semántica de扇出 con Send en /subgraph/send-command. Véase la documentación oficial: Documentación de LangGraph · README