Beyond the Bot: The Anatomy of an Operational Agent for Emergencies

Translated from the original Portuguese article. Ler o original em português

Cover of the article Beyond the Bot: The Anatomy of an Operational Agent for Emergencies

When people talk about automation in geoprocessing, the most common image is a script running in a loop or a bot that responds to predefined commands. In critical scenarios, such as traffic accidents, that rigidity can become a risk: the system has to deal with uncertainty, failures and changes in context. That is why, more than a task executor, what makes the difference is an Agent.

The distinction is simple, but quite important. While a bot follows a linear flow, an Agent interprets the context, chooses which tools to call and in what order, and adjusts its plan according to the information available. In this specific case, it does not just react; it coordinates the process.

In this article, I will break down how I built an operational GIS Agent designed for emergency situations. The idea is to explain the reasoning behind the system, why each part exists, and how design decisions turn a spatial analysis pipeline into an interactive application.

The expected outcome is simple:

  • Locate the incident using reference points;
  • Find the nearest hospital;
  • Draw a route on the map and generate a template communication message.

Preparing the ground: dependencies and constants

Before talking about autonomy, the environment has to be ready to perform "real" actions. That requires three basic things: a reliable spatial foundation, integration with external services, and an interface that supports interactive use.

In the spatial layer, GeoPandas and Shapely let us manipulate geometries and perform operations such as intersection, clipping and other geoprocessing tasks. Even when the user only sees a point on the map, there is a set of calculations happening behind it, and that is where our friends NumPy and pandas come in, supporting numerical data and tabular structures, especially when the results require aggregations and conversions. For integration with external data, we use the requests library, which connects to the public APIs from which we will fetch the data.

For the interface, the combination of ipywidgets and ipyleaflet turns a notebook into a simple but interactive application. This kind of approach works well for a proof of concept and lets us explore the flow without building a complete front-end, which was ideal for me, since I am not very familiar with interface development.

import os, json, requests, polyline as pl
import numpy as np
import pandas as pd
import geopandas as gpd
import time
import random

from shapely.geometry import Point, LineString
from shapely.ops import transform
from pyproj import CRS, Transformer

import ipywidgets as w
from ipyleaflet import (
    Map, Marker, Polyline, Polygon as LeafPoly,
    GeoJSON, LayerGroup, AwesomeIcon, basemaps, basemap_to_tiles,
    CircleMarker, DivIcon
)
from IPython.display import display

There are also some important fixed parameters. OVERPASS_URL points to the Overpass API, used to query OpenStreetMap data, which will return information about the surroundings of the incidents (roads, hospitals and landmarks). OSRM_URL defines the routing service (OSRM), which we will use to calculate the best route to the nearest hospital, including distance, duration and the route geometry itself.

Other important settings: DEFAULT_BUFFER_M defines the default analysis radius, that is, how much data we will pull from the API for analysis. Here we have to balance providing enough context against performance, to avoid downloading and analyzing the entire city. Next, DEFAULT_ROAD_TAGS restricts which road classes are relevant to the response, in this case to avoid bringing unnecessary context into the application that would not affect the incident. Finally, there are the AI API settings, in this case OpenAI's.

OVERPASS_URL = "https://overpass-api.de/api/interpreter"
OSRM_URL = "https://router.project-osrm.org/route/v1/driving"

DEFAULT_BUFFER_M  = 2000
DEFAULT_ROAD_TAGS = ["motorway","trunk","primary","secondary",
                     "tertiary","residential","service"]
# OpenAI
from openai import OpenAI
from google.colab import userdata
os.environ["OPENAI_API_KEY"]=userdata.get('OPENAI_API_KEY')
client = OpenAI()

Local projection: buffer in meters and consistent measurements

A common mistake in quick spatial analyses is treating latitude/longitude as a metric unit, which is very common among people who have never worked with geospatial data. For visualization, that is perfectly fine, but for measurement it is not ideal. Distances, road lengths and buffers need to be calculated in a projected CRS; otherwise, the result varies with latitude and can distort the entire analysis.

To avoid this, we define a helper function that automatically computes a local UTM CRS. This way, we ensure the portability of the features and can project them without depending on a fixed zone. In the end, the flow is quite simple: the coordinate comes in as WGS84, is transformed to UTM to apply the buffer in meters, and then goes back to WGS84 to maintain compatibility.

def local_utm_crs(lon, lat):
    zone = int((lon + 180) / 6) + 1
    return CRS.from_epsg((32700 if lat < 0 else 32600) + zone)
def make_buffer_m(lat, lon, buf):
    wgs = CRS.from_epsg(4326)
    utm = local_utm_crs(lon, lat)
    to_utm = Transformer.from_crs(wgs, utm, always_xy=True).transform
    to_wgs = Transformer.from_crs(utm, wgs, always_xy=True).transform
    return transform(to_wgs, transform(to_utm, Point(lon, lat)).buffer(buf))

Overpass with mirrors and fault tolerance

Another important point to highlight is that, in an academic pipeline, an API failure is an inconvenience; in an operational pipeline, it is expected. That is why it is extremely important to treat instability as part of the scenario.

The strategy to solve this problem combines three decisions: using multiple mirrors (reducing dependence on a single endpoint), retries with exponential backoff (avoiding aggressive hammering), and explicit handling of rate limits, especially when the API returns 429. Together, these reduce outages and increase the chance of a useful response under real-world conditions.

OVERPASS_MIRRORS = [
    "https://overpass-api.de/api/interpreter",
    "https://overpass.kumi.systems/api/interpreter",
    "https://maps.mail.ru/osm/tools/overpass/api/interpreter",
    "https://overpass.openstreetmap.ru/api/interpreter", 
    "https://overpass.osm.ch/api/interpreter",           
]

def overpass_query(q, timeout=60, retries=3):
    for mirror in OVERPASS_MIRRORS:
        for attempt in range(1, retries + 1):
            try:
                print(f"  → Overpass: {mirror} (tentativa {attempt})")
                r = requests.post(mirror, data={"data": q}, timeout=timeout)
                r.raise_for_status()
                return r.json()
            except requests.exceptions.Timeout:
                wait = (2 ** attempt) + random.uniform(0, 1)  # backoff exponencial
                print(f"    Timeout, aguardando {wait:.1f}s…")
                time.sleep(wait)
            except requests.exceptions.HTTPError as e:
                if r.status_code == 429:  # rate limit explícito
                    wait = int(r.headers.get("Retry-After", 30))
                    print(f"    Rate limit (429), aguardando {wait}s…")
                    time.sleep(wait)
                    continue
                if r.status_code in (502, 503, 504):
                    time.sleep(2 ** attempt)
                    break
                raise
            except requests.exceptions.ConnectionError:
                break
    raise RuntimeError("Overpass indisponível em todos os mirrors.")

From OSM to GIS: organize before you analyze

The Overpass response mixes everything that comes from the API, with nodes, ways and relations in the same set. For the Agent we are building, working with all of that at once can make the pipeline fragile and hard to maintain; at least, that is what I observed in other tests. The first step, then, is to organize the data into predictable structures.

The parsing function does exactly that: it creates a dictionary of nodes keyed by ID for fast access and dedicated lists for ways and relations, making the final data less complex and the subsequent steps more stable.

def parse_elements(elements):
    nodes, ways, rels = {}, [], []
    for el in elements:
        t = el.get("type")
        if t == "node": nodes[el["id"]] = el
        elif t == "way": ways.append(el)
        elif t == "relation": rels.append(el)
    return nodes, ways, rels

Still on OpenStreetMap data, it is also important to handle the fact that a road (way) does not arrive as a ready-made line; it comes as a list of node IDs. Converting it into geometry requires resolving those IDs into coordinates and only then building a LineString, which makes it much easier to clip, measure and visualize the roads.

def way_to_ls(way, nodes):
    coords = [(nodes[n]["lon"], nodes[n]["lat"])
              for n in way.get("nodes", []) if n in nodes]
    return LineString(coords) if len(coords) >= 2 else None

However, in an emergency, more data does not mean a better answer. Excess information can confuse the model and even lead it to an unexpected response. To make our job easier, we need to filter only the roads relevant to travel and the operational context, not every trail and marginal tag that might show up. So we create a helper function that filters ways by the highway tag and, when a list of classes is given, keeps only the categories defined as useful in DEFAULT_ROAD_TAGS.

def extract_roads(nodes, ways, road_tags=None):
    rt = set(road_tags or [])
    rows = []
    for w in ways:
        tags = w.get("tags", {}); hw = tags.get("highway")
        if not hw or (rt and hw not in rt): continue
        geom = way_to_ls(w, nodes)
        if geom: rows.append({"osm_id": w["id"], "highway": hw,
                               "name": tags.get("name"), "geometry": geom})
    return (gpd.GeoDataFrame(rows, geometry="geometry", crs="EPSG:4326") if rows
            else gpd.GeoDataFrame(columns=["osm_id","highway","name","geometry"],
                                  geometry="geometry", crs="EPSG:4326"))

We know that OpenStreetMap (OSM) data is very rich, but not always consistent, since it is collected by the community itself. For example, a hospital may have a name field, may have only an operator, may have neither of these attributes, or may even exist only as an address.

For the agent, this is a critical point, because the usefulness of the final result depends on its ability to name destinations and landmarks in a clear and understandable way. Imagine if every reference passed on to the SAMU team (Brazil's emergency medical service) were missing the necessary information.

NAME_FIELDS     = ["name", "operator"]
POI_NAME_FIELDS = ["name", "brand"]
POI_KIND_FIELDS = ["amenity", "tourism", "leisure", "shop"]

def get_name(tags: dict) -> str:
    named = next((tags[f] for f in NAME_FIELDS if tags.get(f)), None)
    if named:
        return named
    street  = tags.get("addr:street", "")
    number  = tags.get("addr:housenumber", "")
    address = f"{street}, {number}".strip(", ")
    return address or "Hospital sem nome cadastrado"

Continuing with the naming policy, it defines an order of priority and applies a fallback to the address when needed ("Hospital sem nome cadastrado", i.e., "hospital with no registered name"). This avoids generic answers and improves clarity.

From there, two helper functions come in:

  • get_poi_name, which goes through the fields defined in POI_NAME_FIELDS and returns the first available value, otherwise returning "Ponto de referência" ("reference point")
  • get_poi_kind, which complements the identification by classifying the type of reference point
def get_poi_name(tags: dict) -> str:
    return next((tags[f] for f in POI_NAME_FIELDS if tags.get(f)), "Ponto de referência")
def get_poi_kind(tags: dict) -> str:
    return next((tags[k] for k in POI_KIND_FIELDS if tags.get(k)), "local")

Another point to consider is that hospitals can appear as a node, a way or a relation. That is why it is essential to cover all these possibilities, reducing false negatives, such as cases where the system reports "hospital not found" simply because of how the data was modeled.

In addition, by standardizing the names, the agent tends to produce a more useful and readable final text.

def extract_hospitals(nodes, ways, rels):
    rows = []

    for n in nodes.values():
        tags = n.get("tags", {})
        if tags.get("amenity") == "hospital":
            rows.append({"name": get_name(tags), "geometry": Point(n["lon"], n["lat"])})

    for w in ways:
        tags = w.get("tags", {})
        if tags.get("amenity") == "hospital":
            g = way_to_ls(w, nodes)
            if g:
                rows.append({"name": get_name(tags), "geometry": g.centroid})

    for r in rels:
        tags = r.get("tags", {})
        if tags.get("amenity") == "hospital" and "center" in r:
            c = r["center"]
            rows.append({"name": get_name(tags), "geometry": Point(c["lon"], c["lat"])})

    cols = ["name", "geometry"]
    return (gpd.GeoDataFrame(rows, geometry="geometry", crs="EPSG:4326")
            if rows else gpd.GeoDataFrame(columns=cols, geometry="geometry", crs="EPSG:4326"))

Even with a precise coordinate, as I mentioned in the article about H3, a lat/lon pair on its own does not help the AI much in understanding the context. For this kind of case, it is far more effective to work with reference points.

So I created a function that extracts and searches for elements with tags of interest (services, shops, tourism and leisure), removes items that could confuse the report, such as hospitals, which have already been extracted, and returns two candidates, prioritizing proximity. This makes it easier for the AI, or even for SAMU, to understand that the accident happened near Store X and Mall Y. To make this even more complete, we could also include the address using reverse geocoding via https://nominatim.openstreetmap.org/reverse.

POI_TAGS = {"amenity", "shop", "tourism", "leisure"}

def extract_reference_points(nodes, lat, lon, max_points: int = 2):
    candidates = []

    for n in nodes.values():
        tags = n.get("tags", {})
        if not POI_TAGS.intersection(tags) or tags.get("amenity") == "hospital":
            continue

        dist_m = ((n["lat"] - lat) ** 2 + (n["lon"] - lon) ** 2) ** 0.5 * 111_000
        candidates.append({
            "name":   get_poi_name(tags),
            "type":   get_poi_kind(tags),
            "lat":    n["lat"],
            "lon":    n["lon"],
            "dist_m": dist_m,
        })

    candidates.sort(key=lambda x: x["dist_m"])
    return candidates[:max_points]

Impact analysis around the accident

With the buffer, the Overpass query and the conversion to geometries in place, the Agent gains context about the surroundings of the incident through the compute_impact function. It starts by building a single query that fetches roads, hospitals and points of interest within the same spatial envelope (search region), and clips the roads to consider only what actually falls inside the buffer.

Next, the result is reprojected to a system in metric units. From there, the road lengths are calculated and aggregated by class, which gives a sense of the density and type of infrastructure in the area. In parallel, the distance to the nearest hospital is also calculated.

All of this comes back as the accident context in a JSON, with information such as: accident_location, radius, road_count, total_road_length, road_length_by_class, nearest_hospital and landmarks.

def compute_impact(lat, lon, buf, road_tags=None):
    area = make_buffer_m(lat, lon, buf)
    s, w2, n, e = area.bounds[1], area.bounds[0], area.bounds[3], area.bounds[2]

    q = f"""[out:json][timeout:180];
    (way["highway"]({s},{w2},{n},{e});
     node["amenity"="hospital"]({s},{w2},{n},{e});
     way["amenity"="hospital"]({s},{w2},{n},{e});
     relation["amenity"="hospital"]({s},{w2},{n},{e});
     node["amenity"~"pharmacy|fuel|police|fire_station|bank"]({s},{w2},{n},{e});
     node["shop"~"supermarket|bakery|convenience"]({s},{w2},{n},{e});
    );(._;>;);out body center;"""

    data  = overpass_query(q)
    nodes, ways, rels = parse_elements(data.get("elements", []))

    roads_gdf = extract_roads(nodes, ways, road_tags or DEFAULT_ROAD_TAGS)
    hosp_wgs  = extract_hospitals(nodes, ways, rels)
    roads_in  = roads_gdf[roads_gdf.intersects(area)].copy()

    utm      = local_utm_crs(lon, lat)
    roads_utm = roads_in.to_crs(utm)
    hosp_utm  = hosp_wgs.to_crs(utm)
    pt_utm    = (gpd.GeoDataFrame([{"geometry": Point(lon, lat)}], crs="EPSG:4326")
                 .to_crs(utm).iloc[0].geometry)

    total_m, by_class = 0.0, {}
    if len(roads_utm):
        roads_utm["len_m"] = roads_utm.length
        total_m  = float(roads_utm["len_m"].sum())
        by_class = (roads_utm.groupby("highway")["len_m"].sum()
                              .sort_values(ascending=False).to_dict())

    nearest = None
    if len(hosp_utm):
        hosp_utm["dist_m"] = hosp_utm.distance(pt_utm)
        idx  = hosp_utm["dist_m"].idxmin()
        hw   = hosp_wgs.loc[idx]
        nearest = {
            "name":   hw.get("name"),
            "dist_m": float(hosp_utm.loc[idx, "dist_m"]),
            "lat":    float(hw.geometry.y),
            "lon":    float(hw.geometry.x),
        }

    ref_points = extract_reference_points(nodes, lat, lon, max_points=2)

    result = {
        "accident":            {"lat": lat, "lon": lon},
        "buffer_m":            buf,
        "roads_count":         int(len(roads_in)),
        "roads_total_len_m":   float(total_m),
        "roads_len_by_class_m": {k: float(v) for k, v in by_class.items()},
        "nearest_hospital":    nearest,
        "reference_points":    ref_points,
    }
    return result, area, roads_in, hosp_wgs

Route to the nearest hospital

Once the Agent identifies the nearest hospital, the result stops being just context and starts guiding an action. The step that happens in parallel is routing, which turns a destination into a navigable path, including distance, duration and route geometry.

This routing is done with a call to the public OSRM API at https://router.project-osrm.org/route/v1/driving, passing origin and destination in lon,lat format. The response contains one or more routes, and here we use the first one, extracting:

  • distance_m, the total distance in meters
  • duration_s, the estimated duration in seconds
  • geometry, the full route path, decoded from polyline into a list of coordinates
def get_route(olat, olon, dlat, dlon):
    url = f"{OSRM_URL}/{olon},{olat};{dlon},{dlat}"
    r   = requests.get(url, params={"overview":"full","geometries":"polyline"},
                       timeout=30)
    r.raise_for_status()
    d = r.json()
    if d.get("code") != "Ok" or not d.get("routes"):
        raise ValueError(f"OSRM sem rota: {d}")
    rt = d["routes"][0]
    return {"distance_m": rt["distance"], "duration_s": rt["duration"],
            "geometry": pl.decode(rt["geometry"])}

Communication: generating a template call message

Here is an interesting point: in a production environment, the draft_samu_message function would be open to evolving into a dedicated, isolated component, since communication is a sensitive step that usually requires standardization, traceability and auditing. The idea would be for it not only to compose the message, but to structure the call for dispatch to the competent authorities, in the format and through the channel defined by the operation, for example, an API integration, a message queue or an internal system.

Here, since the project is an example, this step remains integrated into the tool executor to demonstrate the complete end-to-end flow without adding structural complexity.

Tools and System Prompt: when it becomes behavior (and not just a function)

So far, what we have are functions. To become an Agent, these capabilities need to be packaged as tools. A tool is a contract: its description is not just documentation; it guides the model on when and how to use each resource, which inputs are expected and what kind of output should be produced.

When you make dependencies explicit, for example, that get_route and draft_samu_message must be called after geo_analyze_impact, you reduce classic flow errors, such as a route without a defined destination, a message without context, and answers that sound good but are not grounded in the data.

TOOLS = [
    {
        "type": "function",
        "name": "geo_analyze_impact",
        "description": (
            "Analisa impacto em vias (OSM) num buffer ao redor do acidente "
            "e encontra o hospital mais próximo."
        ),
        "parameters": {
            "type": "object",
            "properties": {
                "lat":      {"type": "number",  "description": "Latitude WGS84."},
                "lon":      {"type": "number",  "description": "Longitude WGS84."},
                "buffer_m": {"type": "integer", "description": "Raio em metros (padrão 250)."},
                "road_tags": {
                    "type": "array",
                    "items": {"type": "string",
                              "enum": ["motorway","trunk","primary","secondary",
                                       "tertiary","residential","service","unclassified"]},
                    "description": "Classes OSM para filtrar. Padrão: todas acima."
                }
            },
            "required": ["lat", "lon", "buffer_m"]
        }
    },
    {
        "type": "function",
        "name": "get_route",
        "description": (
            "Calcula rota viária (OSRM) da origem ao hospital mais próximo, "
            "considerando as vias disponíveis. Chame APÓS geo_analyze_impact."
        ),
        "parameters": {
            "type": "object",
            "properties": {
                "origin_lat": {"type": "number"},
                "origin_lon": {"type": "number"},
                "dest_lat":   {"type": "number"},
                "dest_lon":   {"type": "number"},
            },
            "required": ["origin_lat", "origin_lon", "dest_lat", "dest_lon"]
        }
    },
    {
        "type": "function",
        "name": "draft_samu_message",
        "description": (
            "Gera mensagem FICTÍCIA/EDUCACIONAL ao SAMU com base no resultado geo. "
            "Chame APÓS geo_analyze_impact."
        ),
        "parameters": {
            "type": "object",
            "properties": {
                "geo_result": {"type": "object",
                               "description": "JSON retornado por geo_analyze_impact."}
            },
            "required": ["geo_result"]
        }
    },
]

With the tools ready, the next step is to define the System Prompt, which establishes the behavior in flows that mirror real scenarios in the interface. In our case:

  • Witness, with guided triage;
  • Emergency, with immediate execution (no questions);
  • Simple route request.

The general rules, such as never returning an empty answer, always ending with a warning, not making up data and reinforcing that this is a simulation, bring "discipline" to the system and help keep it consistent across interactions.

SYSTEM = """
Você é um agente de emergência GIS (SIMULAÇÃO EDUCACIONAL).
Seu objetivo é auxiliar em situações de acidente de trânsito com base em dados de localização.

## Fluxo 1 — PRESENCIEI UM ACIDENTE
Quando o usuário informar que presenciou um acidente e fornecer respostas às perguntas de triagem
(consciência, sangramento, mobilidade, número de vítimas), você deve:
1. Chamar `geo_analyze_impact` para mapear as vias afetadas.
2. Chamar `draft_samu_message` com os dados de triagem relatados.
3. Responder com um resumo claro: estado das vítimas, hospital mais próximo e orientações
   de primeiros socorros básicos até o SAMU chegar.

## Fluxo 2 — EMERGÊNCIA COM DADOS DA VÍTIMA
Quando receber uma mensagem iniciando com "EMERGÊNCIA — execute imediatamente as tools",
você DEVE obrigatoriamente chamar as tools na ordem indicada sem fazer perguntas:
1. `geo_analyze_impact` com lat/lon fornecidos.
2. `get_route` para o hospital mais próximo encontrado.
3. `draft_samu_message` com os dados da vítima.
Depois retorne um resumo completo: dados da vítima, hospital, distância, rota no mapa
e mensagem modelo para o SAMU.

## Fluxo 3 — VER ROTA
Quando solicitado, chamar `get_route` e informar o hospital destino e distância estimada.

## Regras gerais
- NUNCA retorne uma resposta vazia ou apenas "ok". Sempre retorne um resumo útil.
- Sempre encerre respostas de emergência com: "⚠️ Em emergência real, ligue 192 imediatamente."
- Seja objetivo e claro. Priorize informações de segurança.
- Não invente dados que não foram fornecidos.
- Este é um sistema de SIMULAÇÃO — nunca afirme que o SAMU foi realmente acionado.
- Ao resumir o resultado, SEMPRE cite os reference_points retornados pelo geo_analyze_impact
  como pontos de referência para localizar a vítima (ex: "próximo à Farmácia X e ao Posto Y").
"""

The agent loop: think, act, observe and conclude

The Agent's behavior depends on a loop that allows multiple steps within a single request. The model receives the history and the list of tools, decides which calls to make, you execute them, return the results, and it keeps iterating until it produces the final answer.

This design brings predictability, because you control the maximum number of rounds and prevent an alert chat from turning into an endless conversation. At the same time, it keeps the flexibility needed to chain actions, since one step can depend directly on the result of the previous one. In addition, keeping the conversation and results as history helps the answer stay coherent across interactions, reducing contradictions and preventing the model from losing context mid-flow.

def agent_turn(history, state, lat, lon, buf):
    messages = [{"role": "system", "content": SYSTEM}, *history]

    for step in range(6):  # máximo de rodadas
        resp = client.responses.create(
            model="gpt-4.1-mini",
            input=messages,
            tools=TOOLS,
            tool_choice="auto",
        )

        # Coleta tool calls dessa rodada
        tool_calls = [
            item for item in (getattr(resp, "output", []) or [])
            if getattr(item, "type", None) == "function_call"
        ]

        # Se não tem tool calls, tenta extrair texto e retorna
        if not tool_calls:
            text = extract_text(resp)
            if text:
                return text
            # Fallback: modelo parou mas sem texto
            return "⚠️ O agente finalizou sem gerar resposta textual."

        # Adiciona output do modelo ao histórico
        messages += list(getattr(resp, "output", []) or [])

        # Executa cada tool call e adiciona resultado
        for tc in tool_calls:
            try:
                args = tc.arguments
                if isinstance(args, str):
                    args = json.loads(args)
                result = run_tool(tc.name, args or {}, state, lat, lon, buf)
                output = json.dumps(json_sanitize(result), ensure_ascii=False)
            except Exception as ex:
                output = f"Erro ao executar {tc.name}: {ex}"

            messages.append({
                "type": "function_call_output",
                "call_id": tc.call_id,
                "output": output,
            })

        # Após tools, verifica se já há texto na resposta atual
        text = extract_text(resp)
        if text:
            return text

    return "⚠️ O agente não finalizou após o limite de rodadas."

It is also worth handling a practical issue: the text must be extracted safely, avoiding the impression that the app has frozen when the SDK changes its response format, which does happen in practice. By covering the different possible structures (direct text, blocks, messages and refusals), the application becomes more resilient to variations in what the API returns.

def extract_text(resp) -> str:
    # caminho mais simples SDK novo
    txt = getattr(resp, "output_text", None)
    if isinstance(txt, str) and txt.strip():
        return txt.strip()

    # varre output
    out = []
    for item in getattr(resp, "output", []) or []:
        itype = getattr(item, "type", None)

        # Alguns SDKs retornam output_text como item direto
        if itype in ("output_text", "text"):
            t = getattr(item, "text", None)
            if isinstance(t, str) and t.strip():
                out.append(t.strip())
            continue

        # Mensagem com blocos
        if itype == "message":
            for block in getattr(item, "content", []) or []:
                btype = getattr(block, "type", None)

                # blocos textuais comuns
                if hasattr(block, "text") and isinstance(block.text, str) and block.text.strip():
                    out.append(block.text.strip())
                    continue

                if btype in ("output_text", "text"):
                    t = getattr(block, "text", None)
                    if isinstance(t, str) and t.strip():
                        out.append(t.strip())
                    continue

                # quando o modelo recusa por segurança (às vezes vem assim)
                if btype == "refusal":
                    r = getattr(block, "refusal", None) or getattr(block, "text", None)
                    if isinstance(r, str) and r.strip():
                        out.append(r.strip())
                    continue

        # Em alguns casos, recusa vem como item direto
        if itype == "refusal":
            r = getattr(item, "refusal", None) or getattr(item, "text", None)
            if isinstance(r, str) and r.strip():
                out.append(r.strip())
            continue

    return "\n".join(out).strip()

Short-term memory: shared state for continuity

An Agent without short-term memory becomes tiresome, because the user ends up having to repeat the context all the time. That is why it makes sense to create a function that stores state and recent results, enabling dependent calls without requiring "perfect" inputs at every step.

In addition, the tool executor can normalize parameters and reuse what has already been discovered in the flow. If the route call does not include an explicit destination, for example, it retrieves the hospital saved in the state. If the message needs context, it combines what the user sent with the latest available geographic result. This reuse makes the flow smoother and brings the experience closer to what you would expect from a real application, where the system maintains continuity and reduces friction.

def run_tool(name, args, state, lat, lon, buf):
    if name == "geo_analyze_impact":
        args.setdefault("lat", lat); args.setdefault("lon", lon)
        args.setdefault("buffer_m", buf)
        valid = set(DEFAULT_ROAD_TAGS)
        rt    = [t for t in args.get("road_tags", []) if t in valid]
        args["road_tags"] = rt if rt else DEFAULT_ROAD_TAGS
        out, area, roads, hosps = compute_impact(
            lat=args["lat"], lon=args["lon"],
            buf=args["buffer_m"], road_tags=args["road_tags"]
        )
        state.update({"last_geo": out, "last_area": area,
                      "last_roads": roads, "last_hosps": hosps})
        return out

    if name == "get_route":
        args.setdefault("origin_lat", lat); args.setdefault("origin_lon", lon)
        if "dest_lat" not in args:
            nh = state.get("last_geo", {}).get("nearest_hospital")
            if not nh: raise ValueError("Sem hospital no state para get_route.")
            args["dest_lat"] = nh["lat"]; args["dest_lon"] = nh["lon"]
        out = get_route(args["origin_lat"], args["origin_lon"],
                        args["dest_lat"],   args["dest_lon"])
        state["last_route"] = out
        return out

    if name == "draft_samu_message":
        geo_from_state = state.get("last_geo", {})
        geo_result = {**geo_from_state, **args.get("geo_result", {})}

        nh = geo_result.get("nearest_hospital")
        if nh:
            nome_hospital = nh.get("name") or "Hospital sem nome cadastrado"
            ht = f"{nome_hospital} (~{nh['dist_m']:.0f}m)"
        else:
            ht = "nenhum hospital encontrado"

        refs = geo_result.get("reference_points", [])
        if refs:
            ref_txt = "\nReferências próximas: " + " | ".join(
                f'{r["name"]} ({r["type"]}, ~{r["dist_m"]:.0f}m)' for r in refs
            )
        else:
            ref_txt = ""

        acc = geo_result.get("accident", {})
        return {"message": (
            "EXEMPLO EDUCACIONAL (não real)\n\n"
            f'SAMU 192: "Sofri um acidente. '
            f'Localização: lat {acc.get("lat",0):.6f}, lon {acc.get("lon",0):.6f}. '
            f'Hospital de referência: {ht}.{ref_txt} Aguardo socorro."\n\n'
            "⚠️ Em emergência real, ligue 192 imediatamente."
        )}

    raise ValueError(f"Tool desconhecida: {name}")

Interface: the application as the final operational layer

In the interface layer, the priority was not polished front-end engineering, but delivering functionality. I used ChatGPT to help build the UI, with a layered map, chat-style history and clearly highlighted action buttons to trigger the "Presenciei acidente" (I witnessed an accident), "Tive um acidente" (I had an accident) and "Ver rota" (View route) modes.

The key point is that the UI acts as the operational panel of the simulation: it guides the triage, triggers immediate execution when needed and displays verifiable results on the map. This helps validate what the agent inferred versus what was actually calculated by the tools.

Figure 1 of the article Beyond the Bot: The Anatomy of an Operational Agent for Emergencies

Use cases

To demonstrate the operational behavior of the system, the simulation was organized into two use cases that represent common emergency situations. The goal is not just to "show the map", but to highlight how the Agent changes its stance depending on the context:

  • Use case 1: Witness with guided triage;
  • Use case 2: Victim with immediate execution and a route on the map

Figure 2 of the article Beyond the Bot: The Anatomy of an Operational Agent for Emergencies

I know the comic strip is a bit exaggerated, but it illustrates the idea well.

Use Case 1: Witness with guided triage

The user enters as a witness, answers triage questions and receives a summary with instructions. The map shows the hospital and reference points, making the location easier to understand. The template message shows how the geographic result turns into objective communication.

Figure 3 of the article Beyond the Bot: The Anatomy of an Operational Agent for Emergencies

Use Case 2: Victim with immediate execution and a route on the map

The user triggers emergency mode, for example via voice control, and the Agent runs the full sequence: analysis of the surroundings, route and template message. The difference is the absence of questions; it would take the user's information directly from the device. The system acts, draws the route and delivers a summary with the destination, distance, estimated duration and landmarks.

Figure 4 of the article Beyond the Bot: The Anatomy of an Operational Agent for Emergencies

Conclusion

The value of this project lies not only in applying geoprocessing, nor only in using a language model. It lies in turning isolated functions into a system with operational behavior. The tools define what the Agent can do, the prompt defines how it should act, the loop allows decisions to be chained, the state ensures continuity, and the interface makes everything usable and verifiable on the map.

Even though it is an educational simulation, the proposal already points to a clear path toward a more mature application. It could become a real product by keeping the same core, that is, answers based on structured data, objective communication, spatial visualization and resilience mechanisms to deal with API failures and real-world variations. From there, it is a matter of adding operational layers such as observability, auditing, testing, message governance and integrations.

Keep reading

All articles