DEV Community

Cover image for Building a Ride-Share Zone-Balancing Agent with LangGraph — Part 1: A Rule-Based Agent, No LLM Yet
Ebrahim Arian
Ebrahim Arian

Posted on

Building a Ride-Share Zone-Balancing Agent with LangGraph — Part 1: A Rule-Based Agent, No LLM Yet

It's 5pm on a weekday, and you're standing outside a stadium after an event just wrapped up. You open your ride-share app. Estimated wait: 12 minutes. Meanwhile, three neighborhoods over, drivers are circling empty streets with nobody requesting a ride.

This happens constantly on any ride-share platform. It's not a bug — it's just supply and demand failing to line up on their own.

Someone, or something, has to watch each part of the city. It needs to notice when a zone is out of balance, then decide what to do:

  • Raise prices, to pull in more drivers
  • Pay drivers a bonus to reposition
  • Redirect riders to a calmer zone
  • Or just leave it alone, if it'll sort itself out

That decision-maker is what I'm building in this series, using LangGraph — one capability at a time.

This is Part 1 of a 5-part series. Each part adds exactly one thing the previous one genuinely couldn't do, so the agent gets closer to how this would actually have to work in production:

  • Part 1 (this one): a rule-based agent for a single zone — no LLM, everything is structured numbers
  • Part 2: an LLM, but only for the two jobs a lookup table can't do — reading a human's free-text note, and explaining the decision back
  • Part 3: memory, so the agent remembers what it already tried on a zone instead of deciding once and forgetting
  • Part 4: a human-in-the-loop pause, so a risky or unusual decision doesn't just execute unattended
  • Part 5: coordinating two zones at once, since pulling a driver from one zone is a decision that affects both

Nothing above is built yet in Part 1. That's deliberate. Every one of those additions is real complexity, and real complexity should only show up once the problem actually needs it — not because it sounds impressive.

So Part 1 stays as plain as the problem allows: one zone, one decision cycle, and rules a human ops person could write on a whiteboard.

The Data: A Snapshot of a City

Before there's an agent, there's a city to look at. I generate a synthetic snapshot of 8 zones — downtown, an airport, a couple of suburbs, a university district, and so on.

Each zone has:

  • A driver count
  • A rider request count
  • An average wait time
  • A couple of context flags — is it raining, is there an event nearby
zones = generate_all_zones(hour=17, rain=False, event=True, seed=42)

df = pd.DataFrame(zones)[[
    'zone_name', 'zone_type', 'driver_count',
    'rider_request_count', 'avg_wait_time', 'rain_flag', 'event_flag', 'traffic'
]]
df['ratio'] = (df['rider_request_count'] / df['driver_count']).round(2)
df
Enter fullscreen mode Exit fullscreen mode
zone_name driver_count rider_request_count avg_wait_time ratio
Downtown Core 15 29 6.3 1.93
Airport 8 17 6.7 2.12
Midtown 6 14 8.9 2.33
Stadium Area 6 15 9.2 2.50

The ratio column is rider requests divided by drivers. It's the whole signal the agent needs:

  • Above 1.3 → deficit (too few drivers)
  • Below 0.5 → surplus (too many idle drivers)
  • In between → balanced

Downtown Core is at 1.93 — a real deficit, made worse by a nearby event. That's the zone I'll walk through.

The Graph

Here's the shape of the decision, end to end:

detect_imbalance
      ↓
classify_severity
      ↓ (conditional edge)
 ┌────┴────────────┬──────────────┐
 ↓                 ↓              ↓
demand_action   supply_action  do_nothing
 └────────┬────────┴──────────────┘
          ↓
   resolved_imbalance
          ↓
   choose_best_policy
          ↓
   simulate_and_report
Enter fullscreen mode Exit fullscreen mode

Two nodes run first:

  • detect_imbalance computes the ratio.
  • classify_severity labels it mild, moderate, or critical. That label is for reporting only — it doesn't affect the decision.

Then comes the conditional edge, where the actual branching happens. It reads imbalance_type and decides which policies are even worth trying:

  • deficitsurge_pricing, driver_bonus, demand_redirect, or do_nothing
  • surplusreallocation_nudge or do_nothing (a zone with too many idle drivers doesn't need demand-side incentives)
  • balanceddo_nothing

Two more nodes run after that:

  • resolved_imbalance evaluates every candidate policy once. It records each one's profit, and whether it actually fixes the imbalance.
  • choose_best_policy picks the most profitable policy among those that resolve it. If none of them resolve it, it falls back to picking the most profitable one overall.

One more thing worth naming: every policy here is zone-local. None of them look past the selected zone's own numbers.

Pulling a driver in from a neighboring zone is a real idea. But it needs a second zone in the picture first — that's Part 5, not Part 1.

The State

LangGraph's StateGraph runs on a single shared piece of state. Every node reads from it and writes to it. In Python, that's just a TypedDict:

class ZoneState(TypedDict):
    zone:                dict  # the single zone this graph run studies
    imbalance_ratio:     float
    imbalance_type:      str   # "deficit" | "surplus" | "balanced"
    severity:            str   # "none" | "mild" | "moderate" | "critical"
    candidate_policies:  list  # which policies are worth evaluating, set by the routed branch
    policy_evaluations:  dict  # {policy_name: profit} for every candidate considered
    policy_resolutions:  dict  # {policy_name: bool} — did this candidate resolve the imbalance
    recommended_policy:  str
    outcome:             dict
    outcome_delta:       dict
    report:              str
Enter fullscreen mode Exit fullscreen mode

Nothing here is LangGraph-specific magic. It's a plain dictionary shape. The framework's only job is making sure every node agrees on it.

Wiring the Graph

Each node is a plain Python function. It takes the current state in, and returns whichever fields it changed. Nodes don't call each other directly — the graph's edges decide what runs next.

Wiring them together takes three steps:

  • Register each node under a name.
  • Connect the straightforward ones with add_edge.
  • Use add_conditional_edges for the one spot where the next step depends on the state.
g = StateGraph(ZoneState)

g.add_node("detect_imbalance",    detect_imbalance)
g.add_node("classify_severity",   classify_severity)
g.add_node("demand_action",       demand_action)
g.add_node("supply_action",       supply_action)
g.add_node("do_nothing",          do_nothing)
g.add_node("resolved_imbalance",  resolved_imbalance)
g.add_node("choose_best_policy",  choose_best_policy)
g.add_node("simulate_and_report", simulate_and_report)

g.add_edge(START, "detect_imbalance")
g.add_edge("detect_imbalance", "classify_severity")

g.add_conditional_edges("classify_severity", route_action, {
    "demand_action": "demand_action",
    "supply_action": "supply_action",
    "do_nothing":    "do_nothing",
})

g.add_edge("demand_action",       "resolved_imbalance")
g.add_edge("supply_action",       "resolved_imbalance")
g.add_edge("do_nothing",          "resolved_imbalance")
g.add_edge("resolved_imbalance",  "choose_best_policy")
g.add_edge("choose_best_policy",  "simulate_and_report")
g.add_edge("simulate_and_report", END)

app = g.compile()
Enter fullscreen mode Exit fullscreen mode

route_action is the function behind that conditional edge. It reads imbalance_type off the state, and returns the name of the branch to take.

That's the entire routing logic for this stage — one if-shaped decision, expressed as data instead of a chain of if statements.

Running It on Downtown Core

With the graph compiled, running it takes one call: invoke(), with Downtown Core's snapshot as the starting state.

result = app.invoke(initial_state)
Enter fullscreen mode Exit fullscreen mode

Here's what came back:

Policy Profit Resolved?
surge_pricing ← chosen $230.78 No
demand_redirect $202.46 No
driver_bonus $181.83 No
do_nothing $177.84 No

surge_pricing won on profit — same as it would have without the resolution check. But notice that nothing resolved the imbalance in one pass.

That's not a bug. resolved_imbalance only measures a policy's effect over one 15-minute window. A deficit this size — 29 riders against 15 drivers — genuinely needs more than one nudge to close.

The agent still made the best call it could. It just can't promise a full fix in a single shot, and it says so honestly (resolved=NO) instead of pretending otherwise. Giving it the ability to try again, and remember what it already attempted, is exactly what Part 3 adds.

Fast-Forwarding the Outcome

The last node is simulate_and_report. It plays the chosen policy forward 15 minutes.

The simulation is stochastic. Drivers respond to incentives probabilistically, the way real people do — not on a fixed schedule.

Metric Before After Change
driver_count 15 16 +1
rider_request_count 29 25 −4
avg_wait_time (min) 6.3 5.5 −0.8

Directionally correct: more drivers, fewer pending requests, a shorter wait. Just not enough to flip Downtown Core out of deficit territory in one cycle.

What's Missing

Everything the agent looked at here was already structured — numbers and boolean flags. That's exactly why plain rules could handle it. There was no ambiguity for a language model to resolve.

But an ops person doesn't write structured fields. They write something like "the stadium event just got cancelled, and there's an accident backing up traffic near the venue." Stage 1's agent has no way to read that sentence. No lookup table fixes that — it needs an LLM, used for exactly one narrow job. That's where Part 2 picks up.


Code for this series: github.com/ebiarian/zone-balancing-ridesharing-langgraph-agent

Next — Part 2: teaching the agent to read a human's ops note, and to explain its own decision back in plain English — plus a head-to-head comparison of five local LLMs to see which one actually holds up at the job.

Top comments (2)

Collapse
 
mansio profile image
Mikhail

Interesting Part 1. I think the most valuable thing here is not the LangGraph implementation itself, but the decision architecture problems that appear even before adding an LLM.

While experimenting with long-lived agents, I ran into very similar issues:

  1. A signal that exists but does not affect decisions.

You calculate severity (mild/moderate/critical), but the policy engine ignores it. This is a common trap: collecting more context does not automatically make a system smarter. Context only has value when it changes the decision path.

  1. "Best available action" vs "successful action".

The fallback behavior is the most interesting part:

if no policy resolves the imbalance, choose the most profitable one anyway

This is exactly where autonomous systems become dangerous. A system can be correct that an action is profitable, but wrong that the action solved the problem.

Sometimes the correct answer is not a policy. Sometimes it is:

"Current policies cannot resolve this state."

  1. Local decisions create global conflicts.

The zone-local design is a good starting point, but once you have multiple zones, every local optimizer can fight with the others.

One zone increases incentives → drivers move → another zone becomes worse → another agent reacts.

This is the same problem I saw in multi-agent workflows: multiple decision layers need a single authority for final routing, otherwise every component is locally correct but globally inconsistent.

I actually think this is the interesting path for the next parts:

  • How does memory change policy selection after repeated failures?
  • How does the system know that a previous "successful" action was actually ineffective?
  • Who has authority when multiple zones disagree?

The hard part is not adding an LLM node. The hard part is preserving evidence, state history and decision ownership when the system starts acting over time.

Looking forward to Part 2.

Collapse
 
ebrahim_arian_37097b72c7e profile image
Ebrahim Arian

Thanks — these are great questions, and worth being upfront about.

For your first two: this project doesn't actually close that loop today. The policy logic and pricing numbers are fixed, and while the agent does keep a history of past cycles, that history is only used to explain a decision, not to change one. Building real learning from failure — where the system adjusts what it tries next based on what actually worked — is a different kind of project. That's reinforcement learning territory, not a small addition to this one.

Your third question is actually answered, partly. Part 5 (dev.to/ebrahim_arian_37097b72c7e/b...) has two zones competing for the same pool of drivers, and when they disagree, a human decides — not the agent. So today, the answer to "who has authority" is: you do. Extending that to more zones deciding on their own is a real open question I didn't take on here.