Skip to main content
intermediatePart 2

Hand work between LangGraph agents without corrupting shared state

· 16 min read
Rafael Fernandes
NLP Engineer & Tech Writer at WiLine
Share:
+PostgreSQL+Langfuse
0/4
🎯 Skill path0/4 earned
Agent orchestration with LangGraph

You have two agents. One books appointments. One handles billing.

A ticket arrives that needs both: reschedule my install, and my bill looks wrong. So you send it to both.

The first one books Tuesday. The second one sees the account is past due and freezes it. Both finish at almost the same moment, and both save what they decided.

Only one of them is saved. Which one? Whichever finished first — which comes down to how slow an API call was that day. So you book an appointment on a frozen account, or freeze an account you just promised an engineer to. Afterwards it looks like one clean decision was made.

Part 1 built one agent that runs its steps in a fixed order. This post has several: a supervisor that picks who works on what, an agent that passes the job to another one halfway through, and two agents put in each other's way on purpose — to find out what LangGraph does when they disagree.

Reproducibility

Verified versions: Python 3.10.12 · langgraph 1.2.11 · langgraph-checkpoint 4.2.0 · langgraph-checkpoint-postgres 3.1.2 · psycopg 3.3.4 · langchain-openai 1.6.0 · langfuse 4.14.5 against Langfuse server v3.205.1 OSS · image postgres:17. The lg-checkpoints container and virtualenv from Part 1 are reused unchanged.

Prerequisites

Someone has to decide who works on it

Two agents means something has to choose between them. That thing is called a supervisor, and it is just another agent whose only job is picking the next one.

Part 1 wired its steps together in advance with add_edge — step one, then two, then three. A supervisor cannot do that, because it does not know where the work is going until it reads it.

Instead the node returns a Command:

from langgraph.types import Command

def supervisor(state: State) -> Command[Literal["scheduler", "billing"]]:
nxt = "billing" if "invoice" in state["ticket"].lower() else "scheduler"
print(f"supervisor routing to {nxt}", flush=True)
return Command(goto=nxt, update={"completed": [f"supervisor->{nxt}"]})

Command does two jobs at once: goto names the next node, and update carries a state change with it. There is no add_edge from supervisor to anything — routing happens at runtime, from inside the function.

Save the whole thing as lg_super.py:

lg_super.py
import sys
from operator import add
from typing import Annotated, Literal
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.postgres import PostgresSaver
from langgraph.types import Command

DB_URI = "postgresql://langgraph:[email protected]:5434/checkpoints"
THREAD = sys.argv[1] if len(sys.argv) > 1 else "sup-1"
TICKET = sys.argv[2] if len(sys.argv) > 2 else "reschedule my install"

class State(TypedDict):
ticket: str
completed: Annotated[list[str], add]

def supervisor(state: State) -> Command[Literal["scheduler", "billing"]]:
nxt = "billing" if "invoice" in state["ticket"].lower() else "scheduler"
print(f"supervisor routing to {nxt}", flush=True)
return Command(goto=nxt, update={"completed": [f"supervisor->{nxt}"]})

def scheduler(state: State):
print("scheduler running", flush=True)
return {"completed": ["scheduler"]}

def billing(state: State):
print("billing running", flush=True)
return {"completed": ["billing"]}

builder = StateGraph(State)
builder.add_node("supervisor", supervisor)
builder.add_node("scheduler", scheduler)
builder.add_node("billing", billing)
builder.add_edge(START, "supervisor")
builder.add_edge("scheduler", END)
builder.add_edge("billing", END)

if "--draw" in sys.argv:
print(builder.compile().get_graph().draw_mermaid())
raise SystemExit

with PostgresSaver.from_conn_string(DB_URI) as cp:
cp.setup()
graph = builder.compile(checkpointer=cp)
config = {"configurable": {"thread_id": THREAD}}
print("FINAL:", graph.invoke({"ticket": TICKET, "completed": []}, config), flush=True)

The --draw branch prints the graph and exits before touching Postgres, so you can inspect the topology without a database. It earns its place in the next section.

~/.venv/bin/python ~/lg_super.py sup-1 "reschedule my install"
Output
supervisor routing to scheduler
scheduler running
FINAL: {'ticket': 'reschedule my install', 'completed': ['supervisor->scheduler', 'scheduler']}

And the other branch, to show the routing is data-dependent rather than hardcoded:

~/.venv/bin/python ~/lg_super.py sup-2 "please fix the invoice on my account"
Output
supervisor routing to billing
billing running
FINAL: {'ticket': 'please fix the invoice on my account', 'completed': ['supervisor->billing', 'billing']}

The annotation that is not type hinting

That return annotation looks like ordinary typing you could drop:

def supervisor(state: State) -> Command[Literal["scheduler", "billing"]]:

Drop it and the code runs identically — same routing, same output. So it is optional. Except it is not.

sed 's/ -> Command\[Literal\["scheduler", "billing"\]\]//' ~/lg_super.py > ~/lg_super_noann.py

Ask both versions to draw themselves:

~/.venv/bin/python ~/lg_super.py --draw
~/.venv/bin/python ~/lg_super_noann.py --draw

With the annotation, the two possible routes are there as dotted conditional edges:

Output (with annotation)
__start__ --> supervisor;
supervisor -.-> billing;
supervisor -.-> scheduler;
billing --> __end__;
scheduler --> __end__;

Without it, they vanish, and the graph states something false:

Output (without annotation)
__start__ --> supervisor;
supervisor --> __end__;

scheduler and billing are still declared as nodes, and now nothing reaches them. The diagram says the supervisor goes straight to the end.

Two mermaid graphs side by side, one with dotted conditional edges to both agents and one where the supervisor connects straight to end Figure 1. Same code, one annotation apart. The lower graph is wrong.

The reason is mechanical: goto is decided inside the function body, where nothing can inspect it. The annotation is the only declaration of where a supervisor is allowed to send work. Omit it and your code still works while every diagram, and anything else reading the topology, quietly lies.

draw_ascii() needs a package LangGraph does not ship

get_graph().draw_ascii() raises ImportError: Install grandalf to draw graphs: 'pip install grandalf'. draw_mermaid() has no extra dependency and is what the examples above use.

Handing off without asking the supervisor

A supervisor decides once, at the start. That is a problem when the discovery happens later.

Take a ticket asking to reschedule an install, on an account that turns out to be past due. The supervisor sees a scheduling request and routes accordingly. The scheduler starts work, reads the account, and finds a problem billing owns.

The scheduler can route too — it is a node, and nodes return Command.

Copy lg_super.py to lg_handoff.py, replace scheduler with the version below, and delete the builder.add_edge("scheduler", END) line — the node decides its own exit now:

lg_handoff.py (changed parts)
def scheduler(state: State) -> Command[Literal["billing", "__end__"]]:
if "past due" in state["ticket"].lower():
print("scheduler: account is past due, handing off to billing", flush=True)
return Command(goto="billing", update={"completed": ["scheduler(handoff)"]})
print("scheduler: booking it", flush=True)
return Command(goto=END, update={"completed": ["scheduler(booked)"]})
~/.venv/bin/python ~/lg_handoff.py handoff-3 "reschedule my install, account is past due"
~/.venv/bin/python ~/lg_handoff.py handoff-4 "reschedule my install"
Output
supervisor -> scheduler
scheduler: account is past due, handing off to billing
billing running
FINAL: {'ticket': '...past due', 'completed': ['supervisor', 'scheduler(handoff)', 'billing']}

supervisor -> scheduler
scheduler: booking it
FINAL: {'ticket': 'reschedule my install', 'completed': ['supervisor', 'scheduler(booked)']}

Both handoff branches — one routing on to billing, one ending at the scheduler Figure 2. The supervisor never knew billing would be involved.

Two details worth keeping. goto=END works from inside a Command, so a node can finish the run itself. And "__end__" is the string form of END in that Literal — the annotation needs the literal value, not the constant.

This is the difference between the two published patterns. A supervisor decides from the outside and needs to know every precondition up front. A handoff lets the agent doing the work redirect once it learns something. Most real systems want both, which is what this graph has.

Sending work to two agents at once

So far the supervisor picks one agent. Sometimes you want both — check the calendar and the account at the same time, rather than waiting for one before starting the other. goto takes a list for exactly that:

return Command(goto=["scheduler", "billing"], update={"completed": ["supervisor"]})

Both run in the same super-step. Which is where it gets interesting, because now they can disagree.

Copy lg_super.py to lg_conflict.py. Give the state two kinds of field:

lg_conflict.py (changed parts)
class State(TypedDict):
completed: Annotated[list[str], add] # has a reducer
decision: str # no reducer

And have each agent write a different value to the un-reduced one:

def scheduler(state: State):
return {"completed": ["scheduler"], "decision": "book tuesday"}

def billing(state: State):
return {"completed": ["billing"], "decision": "refund first"}
~/.venv/bin/python ~/lg_conflict.py conflict-2
Output
supervisor sending work to BOTH
billing deciding
scheduler deciding
Traceback (most recent call last):
...
File ".../langgraph/pregel/_loop.py", line 692, in after_tick
self.updated_channels = apply_writes(
File ".../langgraph/channels/last_value.py", line 64, in update
raise InvalidUpdateError(msg)
langgraph.errors.InvalidUpdateError: At key 'decision': Can receive only one value
per step. Use an Annotated key to handle multiple values.

The InvalidUpdateError traceback ending at last_value.py with the 'At key decision' message Figure 3. Both agents finished. The graph refused to merge their answers.

Read the trace carefully, because three things in it matter.

Both agents ran. billing deciding and scheduler deciding both printed. The work completed; the failure came afterwards, in after_tickapply_writes. This is not two agents colliding mid-flight. It is the graph refusing to reconcile them at the step boundary.

completed was fine. Both agents appended to it in the same step and nothing complained, because add says what two values mean. Only decision failed.

LangGraph does not pick a winner. No last-write-wins, no silent choice. Which is the right behaviour — the alternative is a booking agent that sometimes books Tuesday and sometimes issues a refund depending on which task happened to finish first.

Note the order too: billing printed before scheduler, the reverse of the goto list. Parallel branches finish in whatever order they finish.

Stating the policy

The fix is not to prevent the disagreement. It is to say what a disagreement means. Copy lg_conflict.py to lg_resolve.py and add the reducer:

lg_resolve.py (changed parts)
PRIORITY = {"refund first": 2, "book tuesday": 1}

def prefer_higher_priority(old: str, new: str) -> str:
"""The policy: a billing hold outranks a scheduling decision."""
winner = max([old, new], key=lambda v: PRIORITY.get(v, 0))
print(f" reducer: '{old}' vs '{new}' -> '{winner}'", flush=True)
return winner

class State(TypedDict):
completed: Annotated[list[str], add]
decision: Annotated[str, prefer_higher_priority]
~/.venv/bin/python ~/lg_resolve.py resolve-2
Output
reducer: '' vs '' -> ''
supervisor sending work to BOTH
billing deciding
scheduler deciding
reducer: '' vs 'refund first' -> 'refund first'
reducer: 'refund first' vs 'book tuesday' -> 'refund first'
FINAL: {'completed': ['supervisor', 'billing', 'scheduler'], 'decision': 'refund first'}

The reducer firing three times, folding two agent decisions into one winner Figure 4. The print inside the reducer makes the merge visible.

Same graph, same disagreement, no error. And the trace shows three things the documentation does not spell out:

The reducer runs pairwise, folded left. Not once with both values — twice, accumulating. First it merges the existing state with billing's answer, then merges that result with scheduler's.

It must therefore be order-independent. Since branches finish in arbitrary order, a reducer like "take the newest" produces different answers on different runs. max by priority does not.

It fires before anything runs. That first reducer: '' vs '' -> '' is the initial state being written, merging the default with the decision: "" passed to invoke. Your reducer sees empty input and must tolerate it.

Crashing halfway through a parallel step

Part 1 showed a killed run resuming from its last completed node. Parallel work raises a sharper question: if one branch finishes and its sibling dies, is the finished work lost?

Copy lg_conflict.py to lg_partial.py. Drop the decision field, add RESUME = "--resume" in sys.argv, pass None if RESUME else {...} to invoke as in Part 1, and replace the two specialists with one instant node and one slow enough to kill:

lg_partial.py (changed parts)
def fast(state: State):
print("fast running — finishes immediately", flush=True)
return {"completed": ["fast"]}

def slow(state: State):
print("slow running — 30s window, KILL ME HERE", flush=True); time.sleep(30)
return {"completed": ["slow"]}
~/.venv/bin/python ~/lg_partial.py partial-2

Press Ctrl+C during the window. It takes two presses, and the run ends on something that looks much worse than a KeyboardInterrupt:

Output (tail)
File ".../langgraph/pregel/_runner.py", line 613, in commit
self.put_writes()(task.id, task.writes)
...
RuntimeError: cannot schedule new futures after shutdown

That is fast's checkpoint write being committed to an executor that has already shut down. It reads like data loss.

It is not:

~/.venv/bin/python ~/lg_partial.py partial-2 --resume
Output
BEFORE values={'completed': ['supervisor', 'fast']} next=('slow',)
slow running — 30s window, KILL ME HERE
FINAL: {'completed': ['supervisor', 'fast', 'slow']}

The resumed run showing fast recovered from Postgres and only slow pending Figure 5. fast recovered, did not re-run, and next names only the branch that died.

fast is in the recovered state, appears exactly once in the final result, and next=('slow',) names only the branch that never finished. The write had already landed in checkpoint_writes — the same table that stopped step_one re-running in Part 1 now protects a completed sibling.

Worth documenting precisely because the error suggests the opposite. A RuntimeError in a shutdown path is noise; the state is the thing to check.

A model as the router

Every routing decision so far has been "invoice" in ticket — keyword matching. That is the approach we measured coming apart on the gateway's complexity router, where a plural is enough to miss a keyword entirely.

So let the model decide. Copy lg_super.py to lg_traced.py — same graph, one node changed:

lg_traced.py (changed parts)
llm = ChatOpenAI(
model="gemma4",
base_url="https://inference.wiline.com/v1",
api_key=os.environ["WEC_API_KEY"],
temperature=0,
)

def supervisor(state: State) -> Command[Literal["scheduler", "billing"]]:
r = llm.invoke(
"Route this support ticket to exactly one team. "
"Answer with one word, either scheduler or billing, nothing else.\n\n"
f"Ticket: {state['ticket']}"
)
raw = (r.content or "").strip().lower()
nxt = "billing" if "billing" in raw else "scheduler"
print(f"supervisor: model said {raw!r} -> {nxt}", flush=True)
return Command(goto=nxt, update={"completed": [f"supervisor->{nxt}"]})

Credentials come from the environment, which the gateway's .env already holds:

set -a; . ~/llm-gateway/.env; set +a; ~/.venv/bin/python ~/lg_traced.py traced-1

The ticket is deliberately ambiguous — "my bill looks wrong and I need a new install date" mentions both:

Output
supervisor: model said 'billing' -> billing
billing running
FINAL: {'ticket': 'my bill looks wrong and I need a new install date', 'completed': ['supervisor->billing', 'billing']}

One word, no wrapper sentence, and the parse held. It picked billing — defensible, since an unpaid account gates the install, which is the same conclusion the handoff reached earlier by reading the account.

What that one word cost

Asking a model to choose is not free, and the bill is not where you would look for it. Turning on tracing shows where the time and the tokens went. It is one line, as in Part 1 — CallbackHandler() reads its credentials from the environment and goes in the same config dict as the thread id:

from langfuse.langchain import CallbackHandler
handler = CallbackHandler()
config = {"configurable": {"thread_id": THREAD}, "callbacks": [handler]}

Langfuse trace showing supervisor and billing spans, the token badge, the serialised Command with goto, and billing receiving the supervisor's state update Figure 6. Three things boxed: what routing cost, the decision as data, and the payload arriving.

The span tree is the graph:

LangGraph 8.66s
supervisor 8.53s
ChatOpenAI 8.49s 52 → 412 (Σ 464)
billing 0.01s

Three findings sit in that frame.

A one-word answer cost 412 output tokens. Confirmed against the API rather than read off the screen:

set -a; . ~/llm-gateway/.env; set +a
curl -s -u "$LANGFUSE_PUBLIC_KEY:$LANGFUSE_SECRET_KEY" \
"$LANGFUSE_HOST/api/public/observations?limit=8" \
| jq '.data[] | select(.name=="ChatOpenAI") | {model, usage}'
Output
{
"model": "gemma4",
"usage": {
"unit": "TOKENS",
"input": 52,
"output": 412,
"total": 464
}
}

The visible answer is seven characters. The bill is 412 output tokens. Whatever the model generated before settling, you paid for — and a routing call looks like the cheapest thing in the graph.

The supervisor is the expensive node, not the specialists. billing took 6ms and no tokens. The router took 8.49s and 464. In a multi-agent graph, the orchestration decisions are the cost centre.

The routing decision is recorded as data. The supervisor's output in the trace is the serialised Command:

{"graph": null, "update": {"completed": ["supervisor->billing"]}, "resume": null, "goto": "billing"}

And billing's input contains ["supervisor->billing"] — the update from that Command arriving as the receiving agent's state. Handoff, cost and payload in one frame.

A trace shows the path taken, not the graph

scheduler appears nowhere in this trace — not as a skipped span, not as a disabled node. The Graph panel draws __start__ → supervisor → billing → __end__. You cannot tell from a trace which alternatives existed, only which one ran, which is a second reason the missing Literal annotation matters: the drawn graph is the only place your alternatives are written down.

Finished this tutorial?
Mark it complete to earn Hand work between agents on your skill path.

What's next

Part 3: the tools an agent is allowed to call. Everything above trusts each node to do only what it should — the next post puts the tools behind a gateway with per-tool permissions, so an agent's capabilities are granted rather than assumed.

Troubleshooting

ImportError: Install grandalf to draw graphs

draw_ascii() needs a package LangGraph does not depend on. Use draw_mermaid().

The graph diagram shows the supervisor going straight to __end__

The routing node is missing its -> Command[Literal[...]] return annotation. The code still runs; only the topology is wrong.

InvalidUpdateError: At key 'x': Can receive only one value per step

Two nodes wrote the same key in one super-step and that key has no reducer. Either give it one with Annotated[T, fn], or stop dispatching both nodes in parallel.

RuntimeError: cannot schedule new futures after shutdown after Ctrl+C

A completed branch's checkpoint write raced the executor shutting down. Check graph.get_state(config).values before assuming anything was lost — in testing the write had landed every time.

The reducer produces different results on different runs

It is not order-independent. Parallel branches finish in arbitrary order, so a reducer that depends on which value arrives second is non-deterministic by construction.

Further reading

Comments & questions

Hit an error, spotted a typo, or have a question? Leave a note below.