Building a Ride-Share Zone-Balancing Agent with LangGraph - Part 5: Coordinating Two Zones at Once
This is Part 5, the last part of this series. Part 4 let a human step into the loop before a risky decision executes. Every part so far, though, has shared one assumption: a zone is evaluated completely on its own. That assumption hides a real inconsistency. driver_bonus and surge_pricing both attract "new drivers," as if from an unlimited outside pool. But a real regional driver pool is finite, and it's shared. Two zones both running an aggressive incentive at the same hour can't both be right about where their new drivers are actually coming from. Part 5 is the first part where the agent has to notice that. Two zones, evaluated together every cycle - fixed at 2, not a general N, more on that below. Each zone gets two new supply channels, on top of its existing zone-local response: - A local dormant pool. Off-platform drivers a zone can entice with a big enough incentive. It's fully local, finite, and it depletes as it's used. - A cross-zone pull request. Asking to draw from the adjacent zone's genuine surplus. This is only ever a request at evaluation time - granting it depends on a resource the requesting zone doesn't unilaterally control, so nothing is finalized until both zones' evaluations are in. One interrupt, not three. Part 4 had three interrupt categories: editing, approval, debugging. Part 5 keeps only approval, gated on a new condition specific to this stage: does a zone's cross-zone pull request exceed what the adjacent zone can comfortably spare? "Comfortably" is the key word - it's a conservative line, not a hard limit, so crossing it isn't dangerous, just tighter than usual. Most cycles, the answer is no, and both zones' policies run straight through. When a request does cross that line, a human decides: approve it anyway, reject it back down to the comfortable amount, or override it with a specific number. The Graph โโโโถ evaluate_zone_a โโโ start_cycle โโโโโโค โโโโถ reconcile_and_approve โโโถ simulate_and_report โโโโถ evaluate_zone_b โโโ (gate: cross-zone request > safely_pullable) Fixed at exactly 2 zones, not a general N. With a known, fixed number of parallel branches, two plain static edges are the right tool. LangGraph's Send API is for when the number of branches is only decided at runtime - that's not the case here. Why each zone's evaluation is its own compiled sub-graph, invoked as one atomic node, instead of two duplicate top-level nodes. Look at the per-zone chain: a balanced zone takes the short branch (trivial_do_nothing ) and finishes in a handful of steps. A deficit or surplus zone takes the long branch, including an LLM call, and needs several more. Same chain, different path through it - decided by route_llm_or_skip . If those steps were exposed directly as top-level nodes duplicated per zone - detect_imbalance_a /_b , classify_severity_a /_b , and so on, all feeding into one shared reconcile_and_approve - the two zones would finish in a different number of steps in the same cycle. LangGraph's fan-in only reliably waits for parallel branches that complete in the same step. It does not wait for branches that legitimately take longer. A short branch finishing early would fire the fan-in node immediately, using whatever partial state the slower branch happened to have at that moment, not once both zones were actually done. Wrapping each zone's whole chain as a sub-graph, invoked from a single parent-level node, hides that difference entirely - the same way calling a Python function hides how many lines ran inside it from the caller. Whether a zone took 4 internal steps or 7, the parent graph only ever sees one call that returns a result. Both branches become exactly one parent-level step each, which is the case LangGraph's fan-in handles correctly. reconcile_and_approve is the only node with visibility into both zones at once. It's where the new coordination mechanic actually happens, and where the interrupt lives. The State The per-zone state (ZoneEvalState ) is identical to Part 2's state. That's deliberate - each zone's evaluation reuses Part 1 and Part 2's nodes unchanged: class ZoneEvalState(TypedDict): zone: dict ops_note: str imbalance_ratio: float imbalance_type: str severity: str candidate_policies: list policy_evaluations: dict policy_resolutions: dict recommended_policy: str explanation: str messages: Annotated[list[BaseMessage], operator.add] The parent state holds both zones' fields side by side, each suffixed _a /_b , rather than a nested {zone_name: {...}} structure. With exactly two zones, state["severity_a"] is a direct, one-line read off a result dict - no lookup, no if zone_name == ... branching, no risk of a typo'd key silently returning nothing instead of raising. That's a deliberate trade specific to a fixed count of 2. It wouldn't hold up for a general N zones, where a list or a dict keyed by zone name is the right shape instead - the fixed two-zone scope is exactly what makes the suffixed approach the more readable choice here, not a general pattern to reach for by default: class AgentState(TypedDict): zone_a: dict zone_b: dict ops_note_a: str ops_note_b: str initial_hour: int cycle_number: int history_a: Annotated[list[dict], operator.add] history_b: Annotated[list[dict], operator.add] imbalance_ratio_a: float imbalance_ratio_b: float imbalance_type_a: str imbalance_type_b: str severity_a: str severity_b: str candidate_policies_a: list candidate_policies_b: list policy_evaluations_a: dict policy_evaluations_b: dict policy_resolutions_a: dict policy_resolutions_b: dict recommended_policy_a: str recommended_policy_b: str explanation_a: str explanation_b: str messages_a: Annotated[list[BaseMessage], operator.add] messages_b: Annotated[list[BaseMessage], operator.add] cross_zone_pull_final_a: float cross_zone_pull_final_b: float outcome_a: dict outcome_b: dict outcome_delta_a: dict outcome_delta_b: dict report_a: str report_b: str The Per-Zone Sub-Graph Only one node here is genuinely new: resolved_imbalance_regional . It calls evaluate_policy_regional - the dormant-pool, cross-zone-aware version - instead of Part 1's plain evaluate_policy : def resolved_imbalance_regional(state: ZoneEvalState) -> ZoneEvalState: zone = state["zone"] evaluations, resolutions = {}, {} for policy in state["candidate_policies"]: result = evaluate_policy_regional(zone, policy, noise=False) evaluations[policy] = result["profit"] resolutions[policy] = "N/A" if state["severity"] == "none" else result["resolved"] return {"policy_evaluations": evaluations, "policy_resolutions": resolutions} Same shape and role as Part 1's resolved_imbalance : evaluate every candidate once, record its profit, record whether it resolves the imbalance. Only the function it calls is different. The rest of the sub-graph is wired exactly like Part 2's build_agent - same nodes, same edges, this new node dropped in where resolved_imbalance used to be. So it isn't rebuilt from scratch a third time. _build_zone_evaluator does that wiring, and each zone gets its own instance: def _build_zone_evaluator(llm_with_reconcile_tool, llm): def _reconcile(state): return reconcile_inputs(state, llm_with_reconcile_tool) def _explain(state): return generate_explanation(state, llm) g = StateGraph(ZoneEvalState) g.add_node("detect_imbalance", detect_imbalance) g.add_node("classify_severity", classify_severity) g.add_node("set_candidates", set_candidates) g.add_node("trivial_do_nothing", trivial_do_nothing) g.add_node("reconcile_inputs", _reconcile) g.add_node("resolved_imbalance", resolved_imbalance_regional) g.add_node("choose_best_policy", choose_best_policy) g.add_node("generate_explanation", _explain) g.add_edge(START, "detect_imbalance") g.add_edge("detect_imbalance", "classify_severity") g.add_edge("classify_severity", "set_candidates") g.add_conditional_edges("set_candidates", route_llm_or_skip, { "trivial_do_nothing": "trivial_do_nothing", "reconcile_inputs": "reconcile_inputs", }) g.add_edge("reconcile_inputs", "resolved_imbalance") g.add_edge("resolved_imbalance", "choose_best_policy") g.add_edge("choose_best_policy", "generate_explanation") g.add_edge("generate_explanation", END) g.add_edge("trivial_do_nothing", END) return g.compile() The Parent Graph reconcile_and_approve is where the actual coordination happens, and where interrupt() gets called for this stage. It's the one node worth seeing in full: def reconcile_and_approve(state: AgentState) -> AgentState: requested = {} for suffix in ("_a", "_b"): zone = state[f"zone{suffix}"] policy = state[f"recommended_policy{suffix}"] result = evaluate_policy_regional(zone, policy, noise=False) requested[suffix] = result.get("cross_zone_pull_requested", 0.0) zone_name = {s: state[f"zone{s}"]["zone_name"] for s in ("_a", "_b")} safe_cap, conflicts = {}, [] for suffix in ("_a", "_b"): if requested[suffix] cap: conflicts.append({ "zone": zone_name[suffix], "adjacent_zone": zone_name[_OTHER[suffix]], "policy": state[f"recommended_policy{suffix}"], "requested": round(requested[suffix], 2), "safe_cap": round(cap, 2), }) finalized = dict(requested) if conflicts: answer = interrupt({ "kind": "cross_zone_approval", "question": ( "A zone's cross-zone driver pull would exceed what the adjacent " "zone can safely give up. For each conflict: approve (grant the " "full request anyway), reject (cap it at the safe amount), or " "override (set a specific amount)." ), "conflicts": conflicts, }) decisions = {d["zone"]: d for d in (answer or {}).get("decisions", [])} for suffix in ("_a", "_b"): if suffix not in safe_cap or requested[suffix] 2 conflicts this cycle: Downtown Core and Midtown each ask for more than the other's comfortable buffer. Approved anyway - both zones can absorb running a bit tighter. [Cycle 2] [Downtown Core] policy=do_nothing | driver_count 8โ9 [Cycle 2] [Midtown] policy=surge_pricing | driver_count 4โ4 | resolved=NO -> Midtown's request crosses Downtown Core's comfortable buffer. Approved anyway. [Cycle 3] [Downtown Core] policy=do_nothing | driver_count 9โ11 [Cycle 3]
Comments
No comments yet. Start the discussion.