Stage 4 reference solution

By Stage 4 the pipeline already replans: the confidence-aware _state_message re-ranks incidents every tick, reconciliation retains contradictions and demotes false alarms, and the dispatch tool retargets a committed truck. The deliverable is scenarios that prove revision happens, the two safety caps, and one shipped-code fix.

New evidence overturns a decision

You do not add a replanning engine; you feed fresh beliefs into the same per-tick comparison. In a live stage4-replan@londone run the dispatcher reverses itself when the picture changes:

  • Tick 1: truck-2 -> inc-1 (“inc-1 is closer (2 ticks) and frees truck-1 for the higher-stakes inc-2”).
  • Tick 4: truck-2 redirected off inc-1 onto inc-3 (“pull truck-2 from contradicted low-value inc-1 (priority 12) to inc-3 (40 confirmed-trapped, priority 72), 4 ticks away vs truck-1’s 7”).

Between those ticks inc-1 picked up a contradiction and inc-3 (40 trapped) came into view, so the next tick’s priority comparison flipped the earlier commitment with a stated tradeoff. Pin this with scenarios for corrected location, changed severity, conflicting occupants, false alarm, and collapse, driving incident update and close through the store’s own methods.

Not every commitment is worth keeping

A single committed label is too blunt. A truck driving to an unconfirmed report and a truck already pouring water on a full building both read as committed, and the prompt’s “leave committed trucks alone” then protects the wrong one: the dispatcher lets a truck finish chasing a wrong address while a real fire reported four ticks later goes uncovered. There are only as many jobs worth doing as there are trucks, so split the label at that line:

    ranked = sorted(open_inc, key=lambda i: _priority(i, _occupants(i)), reverse=True)
    top_ids = {i.incident_id for i in ranked[: len(trucks)]}
    committed = {v for iid, v in covered_by.items() if iid in top_ids}
    redirectable = {v for iid, v in covered_by.items() if iid not in top_ids}

A truck below the line prints as REDIRECTABLE, and both the state message and the prompt tell the model to retarget it whenever an uncovered incident outranks the one it serves. That one distinction is what made stage3-partial@feeladelphia and stage5-triage@londone stop flipping between runs; it costs extra tool rounds (stage 3 dispatch tokens rise by roughly a third) because the model now actually reconsiders trucks in motion.

Why closes route through close()

Nothing to build here - dispatch._run_tool already ships this, and DISP-3 pins it under @stage1. It is worth reading anyway, because Stage 4 is the first stage that closes incidents in anger:

            if fields.get("status") == "closed":
                incidents.close(iid, fields.get("notes") or "closed by dispatch", tick)
            else:
                incidents.update(iid, tick=tick, **fields)

A bare update(status="closed") would flip the status and leave close_reason unset, so the store would record that a belief died without recording why. When a false alarm arrives after you dispatched, that reason is the whole audit trail.

Safety rails - one is free, one you build

  • Tick cap: already enforced by the engine. It flips lifecycle away from running at the scenario’s tick limit, and the runner’s while obs["lifecycle"] == "running": loop respects it for free - no agent code needed.
  • Spend cap: no instrument ships. Add an episode-level circuit breaker in the runner - track the running provider-reported token total and, once it crosses a cap, stop making paid model calls (skip the dispatch model and the human-text intake path; zero-cost structured claims still flow). A cap only in the prompt is a request; this is the checking instrument, the same lesson as incident_id.

Done when

New evidence can change an earlier dispatch decision (shown above); contradictions and your belief-strength stay visible in the incident state; trucks stay in idle/moving/firefighting (the engine owns that, so it is a sanity check, not something your code can violate).

Verified: the full suite is green against the solution including the new DISP-3 close-reason scenario (cd starter && uv run --project ../solution behave -> 29 scenarios); ruff/pyright clean; stage4-replan@londone resolves at casualties 5 / buildings_lost 1 on all three runs (~98 K tokens) with a logged mid-episode redirect confirmed in the engine run log, improving on the greedy baseline of 40 / 1; the REDIRECTABLE split above holds all 15 scenario@map pairs steady across 47 episodes.