Wildfire Incident Response¶
Coordinate an emergency-response swarm over the mesh. A fire simulator writes world state to KV, a high-altitude UAV turns hot cells into detection records, survey drones elect themselves onto pending detections with no leader, and an LLM tasker translates operator language into typed dispatch commands. Nobody orchestrates the cascade: it emerges from three coordination patterns.
- KV-driven detection. A
kv_sourcehandler watches a KV namespace and usesmesh.kv.create()(put-if-absent) as the dedup primitive. - Leaderless task election. Drones race a CAS on the detection record; winning the write is winning the job.
- LLM peer as a plain typed agent. Natural language in, Pydantic-validated command out, callable via
mesh.call()from anywhere on the mesh.
This recipe distills those patterns from the full demo in demos/wildfire/.
The Contracts¶
All state lives in the shared mesh-context KV bucket under wildfire.* keys. Every record is a Pydantic model, so a malformed write fails at the boundary, not three agents downstream.
import asyncio
import contextlib
import hashlib
import time
from typing import Literal
from pydantic import BaseModel, Field
from openagentmesh import AgentMesh, AgentSpec, KVEntry, KVKeyExists
class Coords(BaseModel):
x: float = Field(ge=-5.0, le=5.0) # km from HQ at the origin
y: float = Field(ge=-5.0, le=5.0)
class CellState(BaseModel):
"""KV value at wildfire.world.cell.<x_idx>.<y_idx> (200 m grid)."""
coords: Coords
temperature: float # degrees Celsius
class SurveyResult(BaseModel):
surveyor_instance_id: str
fire_visible: bool
persons_detected: int
class DetectionRecord(BaseModel):
"""KV value at wildfire.detection.{detection_id}.
Lifecycle: pending -> assigned:{instance_id} -> surveyed.
"""
detection_id: str
state: str
coords: Coords
severity: float # 0..1, derived from temperature
survey: SurveyResult | None = None
class TaskRequest(BaseModel):
operator_id: str
text: str # natural language from the operator
class TaskCommand(BaseModel):
target_fleet: Literal["heli", "ffunit", "medevac"]
coords: Coords
priority: Literal["low", "med", "high"]
persons_estimated: int = 0
rationale: str # one line, for the audit log
Pattern 1: KV-Driven Detection (UAV)¶
The UAV never polls and is never called. Binding mesh.kv_source() to the agent fires the handler on every cell update; on_init="replay" re-fires the current snapshot at startup, so a restarted UAV re-checks the whole world without extra code.
Deduplication needs no coordinator either: detections in the same 100 m bucket within the same 30 s window hash to the same ID, and mesh.kv.create() refuses to overwrite an existing key. The collision is the dedup mechanism.
TEMP_THRESHOLD_C = 100.0
def dedup_id(coords: Coords, now: float) -> str:
"""One detection per 100 m grid bucket per 30 s window."""
bucket = f"{round(coords.x / 0.1)}:{round(coords.y / 0.1)}:{int(now // 30)}"
return hashlib.sha1(bucket.encode()).hexdigest()[:16]
def build_uav(mesh: AgentMesh) -> None:
@mesh.agent(
AgentSpec(
name="high-alt.uav",
description="High-altitude thermal observer; writes pending "
"detections from world-cell updates.",
),
sources=[mesh.kv_source("wildfire.world.cell.>", on_init="replay")],
)
async def uav(entry: KVEntry[CellState]) -> None:
if entry.operation == "DELETE":
return # cell cooled back to ambient; detections are not retracted
cell = entry.value
if cell.temperature <= TEMP_THRESHOLD_C:
return
detection_id = dedup_id(cell.coords, time.time())
record = DetectionRecord(
detection_id=detection_id,
state="pending",
coords=cell.coords,
severity=min(1.0, (cell.temperature - 100.0) / 700.0),
)
# KVKeyExists means this area was already reported in this window:
# the create is the dedup.
with contextlib.suppress(KVKeyExists):
await mesh.kv.create(f"wildfire.detection.{detection_id}", record)
> vs * in source patterns
Cell keys carry two trailing segments (.<x_idx>.<y_idx>), so the pattern must use > (one or more segments). wildfire.world.cell.* matches exactly one segment and would never fire.
Pattern 2: Leaderless Task Election (Drone)¶
Every drone sees every new detection. Instead of a dispatcher assigning work, each drone attempts a single compare-and-swap on the record: pending -> assigned:{instance_id}. Exactly one CAS commits; the losers return without error. try_cas_model() treats losing the race as data (committed == False), not as an exception.
def build_drone(mesh: AgentMesh) -> None:
@mesh.agent(
AgentSpec(
name="low-alt.drone",
description="Low-altitude survey drone; CAS-elects itself on "
"pending detections and writes the survey result.",
),
sources=[mesh.kv_source("wildfire.detection.*", on_init="replay")],
)
async def drone(entry: KVEntry[DetectionRecord]) -> None:
if entry.operation == "DELETE":
return
rec = entry.value
if rec.state != "pending":
return
key = f"wildfire.detection.{rec.detection_id}"
# Election: one CAS attempt. Losing the race is data, not an error.
async with mesh.kv.try_cas_model(key, DetectionRecord) as claim:
if claim.value.state != "pending":
return # claimed between the event and our read
claim.value.state = f"assigned:{mesh.instance_id}"
if not claim.committed:
return # another drone won
await asyncio.sleep(0.1) # simulate travel + sensor sweep
async with mesh.kv.try_cas_model(key, DetectionRecord) as done:
done.value.state = "surveyed"
done.value.survey = SurveyResult(
surveyor_instance_id=mesh.instance_id,
fire_visible=True,
persons_detected=0,
)
Run five copies of this process and they load-balance themselves: the closest free drone usually wins because it attempts the CAS first, and ties resolve atomically in KV. No queue groups, no scheduler, no lock service.
Crashed claimants
An assigned:{instance_id} record whose claimant dies would stall forever. The full demo runs a watchdog that CAS-flips detections back to pending when the assignment outlives the claimant's heartbeat; a sibling drone then re-elects itself and the cascade self-heals (see demos/wildfire/fleet/briefer.py).
Pattern 3: An LLM Peer Is a Plain Typed Agent (Tasker)¶
The tasker is a Responder like any other: TaskRequest in, TaskCommand out. That it happens to run an LLM inside the handler is invisible to the mesh; callers get the same typed contract, discovery, and validation as every other agent. Anything on the mesh can invoke it: another agent, a CLI, or a browser through the TypeScript SDK.
def build_tasker(mesh: AgentMesh) -> None:
@mesh.agent(AgentSpec(
name="tasker",
description="Translates an operator's natural-language request into "
"one typed TaskCommand. Do NOT use it to execute or "
"dispatch anything: it only translates.",
))
async def tasker(req: TaskRequest) -> TaskCommand:
# Ground in live mesh state: target the newest surveyed detection.
detections = await mesh.kv.list_models(
"wildfire.detection.>", DetectionRecord,
)
surveyed = [e.value for e in detections if e.value.state == "surveyed"]
target = surveyed[-1].coords if surveyed else Coords(x=0.0, y=0.0)
# Stand-in for one structured LLM call (see below).
text = req.text.lower()
wants_extraction = "person" in text or "extract" in text
return TaskCommand(
target_fleet="medevac" if wants_extraction else "heli",
coords=target,
priority="high" if ("urgent" in text or "now" in text) else "med",
rationale=f"Translated from operator {req.operator_id} request.",
)
Note
The recipe simulates the LLM with keyword routing so it runs offline. The full demo makes exactly one structured call per request, with the output schema pinned via forced tool use and Pydantic as the final gate: a hallucinated target_fleet="hovercraft" fails validation instead of reaching a fleet.
import os
from openai import AsyncOpenAI
client = AsyncOpenAI(
base_url="https://openrouter.ai/api/v1",
api_key=os.environ["OPENROUTER_API_KEY"],
)
resp = await client.chat.completions.create(
model="anthropic/claude-sonnet-4.6",
max_tokens=1024,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": grounding_json},
],
tools=[{
"type": "function",
"function": {
"name": "emit_taskcommand",
"description": "Emit the TaskCommand result.",
"parameters": TaskCommand.model_json_schema(),
},
}],
tool_choice={"type": "function", "function": {"name": "emit_taskcommand"}},
)
call = resp.choices[0].message.tool_calls[0]
command = TaskCommand.model_validate_json(call.function.arguments)
The Cascade¶
Wire the three agents together and ignite a cell. No agent calls another agent until the operator speaks; everything up to surveyed is reactive.
async def main(mesh: AgentMesh) -> None:
build_uav(mesh)
build_drone(mesh)
build_tasker(mesh)
await mesh.catalog() # flush registrations: KV sources bind, agents go live
await asyncio.sleep(0.1) # let the watchers attach
# Ignite: write one burning cell. Nobody dispatches anything.
await mesh.kv.put_model(
"wildfire.world.cell.31.21",
CellState(coords=Coords(x=1.2, y=-0.8), temperature=620.0),
)
# Watch the detection record walk its lifecycle.
async def until_surveyed() -> DetectionRecord:
async for value in mesh.kv.watch("wildfire.detection.*"):
rec = DetectionRecord.model_validate_json(value)
print(f"detection {rec.detection_id}: {rec.state}")
if rec.state == "surveyed":
return rec
rec = await asyncio.wait_for(until_surveyed(), timeout=10.0)
assert rec.survey is not None # a surveyed detection always carries its result
print(f"survey: fire_visible={rec.survey.fire_visible}")
# Operator speaks; the mesh answers with a typed, validated command.
result = await mesh.call(
"tasker",
TaskRequest(operator_id="op-1", text="Send the water bomber, urgent"),
)
command = TaskCommand.model_validate(result)
print(f"dispatch: {command.target_fleet} -> "
f"({command.coords.x}, {command.coords.y}) [{command.priority}]")
Run It¶
import asyncio
from openagentmesh import AgentMesh
async def run():
async with AgentMesh.local() as mesh:
await main(mesh)
asyncio.run(run())
How It Works¶
sequenceDiagram
participant World as Fire sim
participant KV as mesh-context KV
participant UAV
participant Drone
participant Operator
participant Tasker
UAV->>KV: kv_source("wildfire.world.cell.>")
Drone->>KV: kv_source("wildfire.detection.*")
World->>KV: put(wildfire.world.cell.31.21, 620 C)
KV-->>UAV: cell update
UAV->>KV: create(wildfire.detection.{id}, pending)
Note over UAV,KV: duplicate hash -> KVKeyExists -> dropped
KV-->>Drone: detection update (pending)
Drone->>KV: CAS pending -> assigned:{instance_id}
Note over Drone,KV: losers see committed=False and stand down
Drone->>KV: CAS assigned -> surveyed + SurveyResult
Operator->>Tasker: mesh.call("tasker", "Send the water bomber, urgent")
Tasker->>KV: list_models(wildfire.detection.>)
Tasker-->>Operator: TaskCommand(heli, coords, high)
Key properties:
- State is the API. Agents coordinate through typed KV records, not point-to-point calls. Kill any agent and restart it;
on_init="replay"rebuilds its view from the current snapshot. - Two primitives, two problems.
create()(put-if-absent) answers "has anyone reported this yet?"; CAS answers "who owns this work?". Both resolve races in the bus, with no lock service. - The lifecycle is auditable.
pending -> assigned:{instance_id} -> surveyedlives in one KV record. Debugging a stuck detection is reading one key. - LLMs are peers, not infrastructure. The tasker registers a contract like every other agent. Swapping the model, or replacing it with rules, changes nothing for its callers.
Run the Full Demo¶
The complete package adds a procedural fire simulator, five drone processes, action fleets (heli, ffunit, medevac), an LLM briefer and narrator, chaos kills, and a watchdog:
The seed pins the terrain, and each boot wipes the previous run's JetStream state, so every run starts from the same clean world. Two UIs come up with it:
- Scenario UI (
http://127.0.0.1:8081): tactical map with live fire spread, fleet sprites and trails, incident briefings, mission log, and narrator pane. Double-click terrain to ignite a fire; click a unit to kill its process and watch the swarm self-heal. - Admin UI (
http://127.0.0.1:8088): the mesh's own view: agent registry with liveness, live event feed, and a contract sandbox where you can call the tasker in natural language from the browser and watch the typedTaskCommandcome back.
Export OPENROUTER_API_KEY first if you want real briefings and translations (the demo reaches Claude through OpenRouter); without it every LLM surface degrades to an honest typed error. The full source is in demos/wildfire/ (agents in fleet/, world simulation in world/, contracts and keys in core/).