{"slug": "langgraph-supervisor-template-with-budget-guards-retries-and-resumable-human", "title": "LangGraph supervisor template with budget guards, retries, and resumable human approval", "summary": "A developer released a production-shaped LangGraph multi-agent template that wires five critical production features—structured routing, parallel-branch reducers, per-node retry policies, hard budget ceilings, and resumable human approval—into a single runnable graph. The template uses a Literal type for routing to prevent hallucinated node names and enforces hop and token budgets in code rather than delegating to the model.", "body_md": "| \"\"\" | |\n| supervisor_graph.py - a production-shaped LangGraph multi-agent template. | |\n| Most LangGraph examples stop at \"supervisor routes to workers.\" That part is easy. | |\n| The parts that actually break in production are scattered across a dozen doc pages: | |\n| 1. Routing that can't hallucinate a node name -> structured output over a Literal | |\n| 2. Parallel branches clobbering shared state -> a reducer on every multi-writer field | |\n| 3. One flaky call killing a 40-step run -> per-node RetryPolicy | |\n| 4. A supervisor loop quietly burning $200 -> hop + token budget enforced in code | |\n| 5. Crash recovery and human sign-off -> checkpointer + interrupt() | |\n| This file wires all five into one runnable graph. It is a skeleton, not a demo: | |\n| swap the worker prompts and you have a real pipeline. | |\n| Tested against: | |\n| langgraph 0.3.x, langchain-anthropic 0.3.x, langgraph-checkpoint-sqlite 2.x | |\n| Setup: | |\n| pip install langgraph langchain-anthropic langgraph-checkpoint-sqlite pydantic | |\n| export ANTHROPIC_API_KEY=... | |\n| python supervisor_graph.py | |\n| \"\"\" | |\n| from __future__ import annotations | |\n| import operator | |\n| import os | |\n| from typing import Annotated, Literal, TypedDict | |\n| from langchain_anthropic import ChatAnthropic | |\n| from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, SystemMessage | |\n| from langgraph.checkpoint.sqlite import SqliteSaver | |\n| from langgraph.graph import END, START, StateGraph | |\n| from langgraph.graph.message import add_messages | |\n| from langgraph.types import Command, RetryPolicy, interrupt | |\n| from pydantic import BaseModel, Field | |\n| # Nothing below is model-specific. Point this anywhere. | |\n| MODEL = os.environ.get(\"AGENT_MODEL\", \"claude-sonnet-4-5\") | |\n| # Hard ceilings. These are checked in code, never delegated to the model -- | |\n| # a supervisor asked to reason about its own budget will reason its way into | |\n| # one more loop every single time. | |\n| MAX_HOPS = 12 | |\n| MAX_TOKENS = 120_000 | |\n| WORKERS = (\"researcher\", \"analyst\", \"writer\") | |\n| # -------------------------------------------------------------------------- | |\n| # State | |\n| # -------------------------------------------------------------------------- | |\n| class Finding(TypedDict): | |\n| agent: str | |\n| content: str | |\n| class AgentState(TypedDict): | |\n| \"\"\"Rule of thumb: any field more than one node can write in the same | |\n| superstep needs a reducer, or LangGraph raises InvalidUpdateError the first | |\n| time you fan out. `draft` and `approved` have exactly one writer each, so | |\n| they're safe as last-write-wins.\"\"\" | |\n| messages: Annotated[list[AnyMessage], add_messages] | |\n| findings: Annotated[list[Finding], operator.add] | |\n| tokens_used: Annotated[int, operator.add] | |\n| hops: Annotated[int, operator.add] | |\n| task: str | |\n| draft: str | |\n| approved: bool | |\n| class Route(BaseModel): | |\n| \"\"\"The Literal is the entire point. The model physically cannot emit a node | |\n| name that isn't in the graph, which kills the single most common failure | |\n| mode in string-parsed supervisors.\"\"\" | |\n| next: Literal[\"researcher\", \"analyst\", \"writer\", \"FINISH\"] | |\n| reason: str = Field(description=\"One sentence. This is what you read in traces at 2am.\") | |\n| def _text(msg: AIMessage) -> str: | |\n| \"\"\"Anthropic returns content as a block list the moment tools or extended | |\n| thinking are in play. Never index msg.content[0] -- flatten it.\"\"\" | |\n| if isinstance(msg.content, str): | |\n| return msg.content | |\n| return \"\\n\".join( | |\n| b.get(\"text\", \"\") | |\n| for b in msg.content | |\n| if isinstance(b, dict) and b.get(\"type\") == \"text\" | |\n| ) | |\n| def _digest(findings: list[Finding]) -> str: | |\n| if not findings: | |\n| return \"(nothing yet -- you're first)\" | |\n| return \"\\n\\n\".join(f\"[{f['agent']}]\\n{f['content']}\" for f in findings) | |\n| # -------------------------------------------------------------------------- | |\n| # Workers | |\n| # -------------------------------------------------------------------------- | |\n| WORKER_PROMPTS = { | |\n| \"researcher\": ( | |\n| \"You gather raw material. Surface concrete facts, numbers, and sources. \" | |\n| \"Do not interpret, do not recommend. If you don't know something, say so plainly.\" | |\n| ), | |\n| \"analyst\": ( | |\n| \"You pressure-test what the researcher found. Name the weakest assumption, \" | |\n| \"the missing counter-evidence, and the one thing that would change the conclusion.\" | |\n| ), | |\n| \"writer\": ( | |\n| \"You produce the deliverable. Plain language, no preamble, no summary of \" | |\n| \"your own process. Lead with the answer.\" | |\n| ), | |\n| } | |\n| def make_worker(name: str): | |\n| llm = ChatAnthropic(model=MODEL, max_tokens=2048) | |\n| def worker(state: AgentState) -> dict: | |\n| reply = llm.invoke([ | |\n| SystemMessage(WORKER_PROMPTS[name]), | |\n| HumanMessage( | |\n| f\"Task: {state['task']}\\n\\n\" | |\n| f\"What the team has so far:\\n{_digest(state['findings'])}\" | |\n| ), | |\n| ]) | |\n| body = _text(reply) | |\n| usage = reply.usage_metadata or {} | |\n| out: dict = { | |\n| \"findings\": [Finding(agent=name, content=body)], | |\n| \"messages\": [AIMessage(content=body, name=name)], | |\n| \"tokens_used\": usage.get(\"total_tokens\", 0), | |\n| } | |\n| if name == \"writer\": | |\n| out[\"draft\"] = body | |\n| return out | |\n| return worker | |\n| # -------------------------------------------------------------------------- | |\n| # Supervisor | |\n| # -------------------------------------------------------------------------- | |\n| def supervisor( | |\n| state: AgentState, | |\n| ) -> Command[Literal[\"researcher\", \"analyst\", \"writer\", \"approval\", \"__end__\"]]: | |\n| # Guard first, LLM second. Cheaper and unbypassable. | |\n| if state[\"hops\"] >= MAX_HOPS or state[\"tokens_used\"] >= MAX_TOKENS: | |\n| return Command( | |\n| goto=\"approval\" if state.get(\"draft\") else END, | |\n| update={\"messages\": [SystemMessage(\"Budget ceiling hit. Wrapping up.\")]}, | |\n| ) | |\n| router = ChatAnthropic(model=MODEL, max_tokens=512).with_structured_output(Route) | |\n| route: Route = router.invoke([ | |\n| SystemMessage( | |\n| \"You coordinate three specialists: researcher (gathers), analyst \" | |\n| \"(critiques), writer (produces the deliverable). Pick who goes next. \" | |\n| \"Choose FINISH only once a draft exists and the analyst has seen it. \" | |\n| \"Do not send work to an agent whose input hasn't changed since last turn.\" | |\n| ), | |\n| HumanMessage( | |\n| f\"Task: {state['task']}\\n\" | |\n| f\"Hops used: {state['hops']}/{MAX_HOPS}\\n\\n\" | |\n| f\"Transcript:\\n{_digest(state['findings'])}\" | |\n| ), | |\n| ]) | |\n| if route.next == \"FINISH\": | |\n| return Command(goto=\"approval\" if state.get(\"draft\") else \"writer\") | |\n| return Command( | |\n| goto=route.next, | |\n| update={\"hops\": 1, \"messages\": [SystemMessage(f\"-> {route.next}: {route.reason}\")]}, | |\n| ) | |\n| # -------------------------------------------------------------------------- | |\n| # Human gate | |\n| # -------------------------------------------------------------------------- | |\n| def approval(state: AgentState) -> Command[Literal[\"supervisor\", \"__end__\"]]: | |\n| # GOTCHA: on resume, LangGraph re-executes this node from the top. Every | |\n| # line above interrupt() runs twice. Keep DB writes, emails, and payments | |\n| # strictly below it. | |\n| decision = interrupt({ | |\n| \"draft\": state.get(\"draft\", \"\"), | |\n| \"findings\": len(state[\"findings\"]), | |\n| \"tokens_used\": state[\"tokens_used\"], | |\n| \"how_to_resume\": \"{'approved': true} to ship, or {'approved': false, 'notes': '...'}\", | |\n| }) | |\n| if decision.get(\"approved\"): | |\n| return Command(goto=END, update={\"approved\": True}) | |\n| return Command( | |\n| goto=\"supervisor\", | |\n| update={\"messages\": [HumanMessage(f\"Revision requested: {decision.get('notes', '')}\")]}, | |\n| ) | |\n| # -------------------------------------------------------------------------- | |\n| # Wiring | |\n| # -------------------------------------------------------------------------- | |\n| def build_graph(checkpointer): | |\n| b = StateGraph(AgentState) | |\n| b.add_node(\"supervisor\", supervisor) | |\n| b.add_node(\"approval\", approval) | |\n| for name in WORKERS: | |\n| b.add_node( | |\n| name, | |\n| make_worker(name), | |\n| # On langgraph < 0.2.60 this kwarg is `retry=` instead. | |\n| retry_policy=RetryPolicy( | |\n| max_attempts=3, | |\n| initial_interval=1.0, | |\n| backoff_factor=2.0, | |\n| retry_on=(TimeoutError, ConnectionError), | |\n| ), | |\n| ) | |\n| b.add_edge(START, \"supervisor\") | |\n| for name in WORKERS: | |\n| b.add_edge(name, \"supervisor\") # workers always report back | |\n| # No edges out of supervisor or approval -- Command(goto=...) carries the | |\n| # routing. The Command[Literal[...]] return annotations are what let | |\n| # graph.get_graph().draw_mermaid() still render those hops. | |\n| return b.compile(checkpointer=checkpointer) | |\n| def main() -> None: | |\n| task = \"Should a two-person team self-host Postgres or pay for a managed instance?\" | |\n| cfg = {\"configurable\": {\"thread_id\": \"demo-001\"}} | |\n| seed: AgentState = { | |\n| \"messages\": [], | |\n| \"findings\": [], | |\n| \"tokens_used\": 0, | |\n| \"hops\": 0, | |\n| \"task\": task, | |\n| \"draft\": \"\", | |\n| \"approved\": False, | |\n| } | |\n| with SqliteSaver.from_conn_string(\"checkpoints.sqlite\") as saver: | |\n| graph = build_graph(saver) | |\n| for update in graph.stream(seed, cfg, stream_mode=\"updates\"): | |\n| for node, payload in update.items(): | |\n| print(f\" [{node}] {str(payload)[:160]}\") | |\n| snapshot = graph.get_state(cfg) | |\n| if snapshot.next: # parked on the interrupt | |\n| payload = snapshot.tasks[0].interrupts[0].value | |\n| print(\"\\n--- awaiting human ---\") | |\n| print(payload[\"draft\"][:800]) | |\n| print(f\"({payload['tokens_used']:,} tokens, {payload['findings']} findings)\\n\") | |\n| # Kill the process here and rerun with the same thread_id: it picks | |\n| # up exactly at this line. That is the whole value of the checkpointer. | |\n| for update in graph.stream(Command(resume={\"approved\": True}), cfg, stream_mode=\"updates\"): | |\n| for node, p in update.items(): | |\n| print(f\" [{node}] {str(p)[:160]}\") | |\n| print(\"\\nfinal:\", graph.get_state(cfg).values[\"draft\"][:800]) | |\n| if __name__ == \"__main__\": | |\n| main() | |\n| # -------------------------------------------------------------------------- | |\n| # Things that cost me time, in rough order of pain: | |\n| # | |\n| # - InvalidUpdateError on fan-out almost always means a shared state field is | |\n| # missing a reducer, not that your graph shape is wrong. | |\n| # - interrupt() replays the node from the top. Side effects go after it. | |\n| # - Checkpoints are keyed on thread_id. Reusing one silently resumes an old | |\n| # run; generate a fresh id per job unless resumption is the point. | |\n| # - RetryPolicy wraps the node, not the LLM call, so a retried node re-emits | |\n| # its full state update. Keep nodes idempotent. | |\n| # - MemorySaver is fine for tests and useless in prod -- it dies with the | |\n| # process. Sqlite for single-box, Postgres for anything real. | |\n| # - recursion_limit in config is a separate ceiling from your hop budget. Set | |\n| # both; they fail differently and you want to know which one tripped. | |\n| # -------------------------------------------------------------------------- |", "url": "https://wpnews.pro/news/langgraph-supervisor-template-with-budget-guards-retries-and-resumable-human", "canonical_source": "https://gist.github.com/ayaqen/8fe05d2d7222d309c079cdde6cec858c", "published_at": "2026-07-20 19:01:28+00:00", "updated_at": "2026-07-29 16:06:23.415222+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "large-language-models"], "entities": ["LangGraph", "Anthropic", "Claude Sonnet 4-5", "SqliteSaver", "Pydantic"], "alternates": {"html": "https://wpnews.pro/news/langgraph-supervisor-template-with-budget-guards-retries-and-resumable-human", "markdown": "https://wpnews.pro/news/langgraph-supervisor-template-with-budget-guards-retries-and-resumable-human.md", "text": "https://wpnews.pro/news/langgraph-supervisor-template-with-budget-guards-retries-and-resumable-human.txt", "jsonld": "https://wpnews.pro/news/langgraph-supervisor-template-with-budget-guards-retries-and-resumable-human.jsonld"}}