Dev.to · 10 min read

Building a Ride-Share Zone-Balancing Agent with LangGraph — Part 5: Coordinating Two Zones at Once

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] Midtown's request crosses Downtown Core's comfortable buffer. Approved anyway. [Cycle 3] [Downtown Core] policy=do_nothing | driver_count 9→11 [Cycle 3] [Midtown] policy=surge_pricing | driver_count 4→5 | resolved=YES -> Midtown's request crosses Downtown Core's comfortable buffer. Approved anyway. [Cycles 4-8] both zones settle into do_nothing, no conflicts — the regional channels simply aren't needed [Cycle 9] [Downtown Core] policy=do_nothing | driver_count 12→10 [Cycle 9] [Midtown] policy=surge_pricing | driver_count 5→5 | resolved=YES -> Midtown's request crosses Downtown Core's comfortable buffer. Approved anyway. [Cycle 10] [Downtown Core] policy=do_nothing | driver_count 10→9 [Cycle 10] [Midtown] policy=do_nothing | driver_count 5→5 Five real conflicts fired across these 10 cycles — two at once on cycle 1 (both zones asking at the same time), then cycle 2, cycle 3, a five-cycle quiet stretch, then one more at cycle 9. None of that count was decided in advance. It's the coordination check catching ordinary demand drift eroding each zone's slack, exactly when the numbers say it should, and staying silent the rest of the time. Decide differently at the first pause in the notebook — reject instead of approve — and everything downstream changes with it. A rejected pull caps the zone at the comfortable amount instead of the full request, which changes its driver count, which changes what it's able to do next cycle, including whether the later pauses even happen the same way. What We Built Part 4 Part 5 Scope One zone, reviewed by a human Two zones, coordinated with each other New LangGraph concept interrupt() / Command Parallel fan-out/fan-in, sub-graph as atomic node Supply model Zone-local only + a local dormant pool, + a cross-zone pull request What's shared Nothing Each zone's safely-pullable surplus, contended by the other Interrupt gate severity == "critical" A cross-zone request crossing the adjacent zone's comfortable buffer Interrupt categories used Approval, editing, debugging (3) Approval only (1), deliberately The single-zone decision core — detect_imbalance, classify_severity, choose_best_policy — is still exactly Part 1's code, still untouched. What changed is that a policy's supply effect is no longer assumed to come from nowhere. It's either a finite local pool that visibly depletes, or a real request against a neighbor that has to be reconciled, not just asserted. Five Parts, Five Additions That's the series. Each part added exactly one thing the previous one genuinely couldn't do: Part 1 — rules, because the problem started out fully structured. Part 2 — an LLM, for exactly two jobs a lookup table couldn't do. Part 3 — memory, because one decision often wasn't enough. Part 4 — a human pause, because automatic wasn't always safe. Part 5 — coordination, because a zone's supply was never really its own. None of it showed up before the problem actually demanded it. That was the whole point. Code for this series: github.com/ebiarian/zone-balancing-ridesharing-langgraph-agent

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More AI & Machine Learning News