Subgraphs: composing agents like functions (almost)
In June a second product needed the research loop I'd already built once: search, fetch, take notes, write it up with citations. The first copy lived in a competitive-intel tool I run for myself; the new caller was the support product's knowledge-base answerer. Copy-paste would have worked for a month and haunted me for a year, so I finally did subgraphs properly.
A subgraph is a compiled graph mounted where a node would go. The parent doesn't care. It calls something that takes state and returns state, like everything else it owns.
the schema is the signature
The research subgraph owns its own state (query, sources, notes, summary), designed the way I design any state: first, before any nodes. That schema is the subgraph's function signature in every practical sense. Both products call it by building a ResearchState, invoking, and reading summary and sources back out.
research = build_research_graph() # compiled once, tested alone
def run_research(state: AnswerState):
out = research.invoke({"query": state["question"]})
return {"kb_answer": out["summary"], "cites": out["sources"]}
the almost
Here's why the title needs its qualifier. A real function signature gets checked at the call site. State mapping between parent and child schemas is glue you write by hand, and nothing checks it for you. My two wrapper nodes are about 50 lines each, carry their own tests, and have still broken twice: both times a renamed child field, both times the parent kept passing a silently wrong dict until an eval caught the empty citations.
A subgraph's state schema is its function signature — except nobody type-checks the call site, so you're the compiler now.
The mapping tax is real, and you pay it again on every schema change, on either side.
second use, not first
My extraction rule, learned the expensive way: extract on the second concrete use, never the first. In March I'd extracted a planner subgraph from the triage system because it felt reusable. Nothing reused it. It added a schema boundary, a mapping layer, and a package to version. For exactly one caller. I folded it back in May and deleted about 180 lines of pure ceremony.
The research subgraph earned extraction the day a second product existed, not a day sooner. The same rule applies if you're heading toward a supervisor setup: promote a worker to a subgraph when the second supervisor wants it.
testing it alone
The quiet win is CI. The subgraph compiles standalone with an in-memory checkpointer, the search tool is stubbed with canned results, and eleven cases run in about 40 seconds with neither parent product installed. When a research bug shows up, I reproduce it in the subgraph's own tests, fix it once, and both products pick it up on their next deploy.
Almost like functions, then. The composition is real and so is the reuse; the signature is a handshake you keep honoring by hand. Two products in, the trade has paid for itself. But I wouldn't pay the mapping tax for one.