Stage 1: dispatcher

Yesterday you built the general agent loop and proved it on toy tools. Today you configure it into a dispatcher. Nothing about the loop changes: everything task-specific lives in dispatch.py and prompts/dispatch_system.md - the state message, the system prompt, the typed tools - and run_dispatch wires them into the loop you already wrote.

flowchart LR
    subgraph Intake["Intake (Agents)"]
        i1["'smoke near the<br/>school on 5th'"]
        i2["'building fire on Elm St,<br/>people inside'"]
        i3["'fire is out at<br/>the school now'"]
    end

    r{"Reconciliation (Code)<br/>match to known incidents"}

    subgraph Incidents["Incidents (Memory)"]
        n1["1: School, 5th Ave"]
        n2["2: Elm St"]
    end

    subgraph Dispatch["Dispatch loop (Agent)"]
        plan["plan"] --> act["act"]
        act --> plan
    end

    subgraph Trucks["Fire trucks"]
        t1["truck A"]
        t2["truck B"]
    end

    i1 --> r
    i2 --> r
    i3 --> r
    r -- "update" --> n1
    r -- "new" --> n2
    n1 --> plan
    n2 --> plan
    act <--> t1
    act <--> t2

Now, we will build and test the dispatcher. Stage 1 hands you exact structured reports, one per incident, so we can skip intake and reconciliation.

TIP: If you want to see the exact data that your agent gets from the simulation, connect to it via Claude Code and play a Stage 1 level by hand. Ask your agent to describe the data going in and out.

What needs to be built

run_dispatch and _run_tool in dispatch.py are already working. You need to complete these four:

  1. _state_message(incidents, trucks, tick) in dispatch.py - the user message the model reasons over each tick. This is the one that decides your casualty count.
  2. prompts/dispatch_system.md - five TODO sections: what you control, the world is uncertain, priorities, committing vs holding, and the rules.
  3. _finalize in intake.py, at least the parts _project_claim needs. The projection itself ships working: a structured_report payload becomes a report with no model call and zero tokens, but it exits through _finalize, so nothing reaches your stores until you write that gate.
  4. The intake and reconcile holes in runner._process_tick - build a Report per contact and, for this stage only, open one incident per report through IncidentStore.open. Deterministic matching arrives in Stage 3.

The state message is the lesson

_state_message receives the live vehicle observations, each shaped {vehicle_id, status, position: {x, y}, incident_id, route: {eta_ticks, ...}, ...}. Three things:

  • Coordinates are nested under position, not flat top-level x/y.
  • route is null unless the truck is moving - a stationary vehicle has no route - so reaching straight for route["eta_ticks"] dies on the first idle truck.
  • incident_id is the incident of that truck’s most recent dispatch. A status=idle truck that put its fire out keeps the old incident_id still attached.

You should present the model with the data pre-structured and have it make just the dispatch decision.

Build a state message containing:

  1. each open incident, a. whether its COVERED or UNCOVERED (i.e. if a truck is assigned to it) b. order the incidents people-first, and
  2. each truck, whether it is FREE or committed,
  3. one line of instruction.

If your model sends both trucks to the same fire, this is typically a failure of the state message system. You can fix it by using a much more expensive model, or by improving your state message here.

Activity: Configure the dispatcher

  1. Write _state_message. Pre-reconcile coverage and priority as above, and read truck coordinates from position.

  2. Fill prompts/dispatch_system.md. The allocation rule under “Committing vs holding” is the key section that changes outcomes. A good place to start is “one free truck to the highest-priority uncovered incident, no more than one truck per incident.”

  3. Open the gate. Implement enough of _finalize to satisfy uv run behave features/intake.feature. Envelope authority is the rule being tested: contact_id and reported_location are copied from the contact and never invented, and a none claim carries no severity or headcount - and note that INT-3 feeds you a claim that does carry both, so the gate has to strip them even though a machine sent them.

  4. Pin the enforcement. uv run behave features/dispatch.feature runs run_dispatch end to end against a fake LLM. DISP-2 checks that your code only dispatches trucks to incidents that exist.

  5. Run it live. Fill the two holes in runner._process_tick, bring an engine up, and run a full episode:

    cd ../engine && uv run hadr-engine serve   # in its own terminal
    uv run hadr-runner stage1-basic@londone
    

    Start with minimax-m3 as DISPATCH_MODEL (the default).

  6. Tune. run_dispatch hands a DispatchResult back to the runner every tick and nothing prints it, so print its rounds and plan while you work. Hitting MAX_TOOL_ROUNDS or watching both trucks converge on one fire is a state-message problem, not a model problem.

  7. Checkpoint. Paste the skeleton progress checker prompt into Claude Code to verify the complete path and settle the travel-time and dispatch-budget choices.

When you’re done

  1. Open a pull request with the state message, the prompt, and the runner wiring, and have a teammate review it against a live run rather than against the test output. behave green only means the scripted model was satisfied with a well-formed request; no fixture can tell you whether the state message you handed it makes the right fire come first.
  2. Post your Stage 1 numbers on the Padlet: casualties and buildings lost on stage1-basic@londone, plus the dispatch token total. Your teammates ran the same scenario on the same seed, so a wide spread means somebody’s state message is doing far more of the thinking than the others - find out whose, and why.