"""Agent panel: live tool-trace feed, budget meter, verdict banner, charts.
Consumes typed v2 agent events (``token | step | tool_call | tool_result |
final | usage``) from :func:`ui._compat.agent_run_stream`. Every tool call
renders as a trace card — pending while running, then done with a wall-time
ms bar scaled to the slowest call this turn and an expandable payload
preview. The budget meter ticks live (steps / tool calls / score_week cap /
≈tokens) against the slider-chosen tool budget, and the final answer's
"Best: …" contract line is lifted into a verdict banner. Any ``score_week``
payload in a tool result is charted straight into the week strip (the LLM
never owns numbers), and **every answered turn ends with a visualisation
matched to the question**: recommendation questions close on the
recommended-scores chart — a region-sweep leaderboard, or a side-by-side
compare when several spots were scored this turn (a deterministic engine
sweep fills in when the agent never ranked); climate / seafloor / similar /
explain questions get their rose, depth map, similarity bars or component
split; anything else falls back to the viewed spot's own scored week.
User controls in the bar: a "max tool calls" slider (the per-turn step
budget; token + score_week caps scale with it) and chained State → Region
dropdowns — page-one style, the State pick rewrites the Region choices —
that anchor "where should I surf" sweeps. Both are plain component inputs —
session state stays at exactly three ``gr.State`` objects. Skill comes
from the lens panel's Skill filter — one skill, whole page.
The BYO HF token box is session-only: the value is passed straight to the
agent factory per turn, never stored, never logged.
"""
from __future__ import annotations
import html
import json
import re
import gradio as gr
from ui import _compat as C
from ui.charts import climate as climate_chart
from ui.charts import seafloor as seafloor_chart
from ui.charts import score as score_chart
from ui.charts import strip as strip_chart
from ui.contracts import AGENT_KEYS, fill
_TOOL_ICONS = {
"score_week": "⚙", "rank_region_week": "🏆", "explain_score": "🔍",
"find_best_windows": "🕐", "find_spots": "🔎", "find_similar_spots": "🧭",
"get_spot_knowledge": "📖", "get_climate_profile": "🌡",
"get_seafloor_profile": "🪨", "get_session_brief": "🤿",
"list_regions": "🗺",
}
_MEDALS = ["🥇", "🥈", "🥉"]
_VERDICT_RE = re.compile(r"Best:\s*(.+)", re.IGNORECASE)
# Query intent (deterministic, on the user's text only): which end-of-turn
# chart matches the question. Scores charts are the house default; the
# specialties jump the queue only when the question asks for them.
_REC_RE = re.compile(
r"\bwhere\s+(?:should|to|can|shall|could)\b"
r"|\bbest\s+(?:spots?|breaks?|beaches?|places?)\b"
r"|\brecommend|\brank\w*|\btop\s*\d+"
r"|\bcompare|\bversus\b|\bvs\b"
r"|\bshould\s+i\s+(?:surf|go|paddle)\b", re.IGNORECASE)
_CLIMATE_RE = re.compile(
r"\bclimate\b|\bmonthly\b|\bmonths?\b|winter|summer|autumn|spring\b"
r"|\bswell\s+direction\b|\bwhen\s+to\s+go\b", re.IGNORECASE)
_SEAFLOOR_RE = re.compile(
r"\bseafloor\b|\bsea\s?floor\b|\bsea\s?bed\b|\bbathymetry\b|\bdepths?\b"
r"|\bstructure\b|\bsandbar\b|\breef\b", re.IGNORECASE)
_SIMILAR_RE = re.compile(
r"\b(?:spots?|breaks?|beaches?)\s+like\b|\bsimilar\s+to\b"
r"|\balternatives?\s+to\b|\bquieter\b", re.IGNORECASE)
_EXPLAIN_RE = re.compile(
r"\bwhy\b|\bexplain\w*|\bcomponent\w*|\bbreakdown\b", re.IGNORECASE)
_SPECIALTY_RE = (
("get_climate_profile", _CLIMATE_RE),
("get_seafloor_profile", _SEAFLOOR_RE),
("find_similar_spots", _SIMILAR_RE),
("explain_score", _EXPLAIN_RE),
)
_STATE_CODES = {
"NSW": "New South Wales", "QLD": "Queensland", "VIC": "Victoria",
"WA": "Western Australia", "SA": "South Australia", "TAS": "Tasmania",
}
_EMPTY_TRACE = ("
tool calls land here live — "
"watch the engines work…
")
_EMPTY_RANK_HTML = ("leaderboard — ask "
"“where should I surf in …” to rank a region.
")
def _short_args(args: dict, limit: int = 120) -> str:
try:
text = json.dumps(args, ensure_ascii=False)
except (TypeError, ValueError):
text = str(args)
return text if len(text) <= limit else text[: limit - 1] + "…"
def _pretty_time(value) -> str:
text = str(value or "")
return text.replace("T", " ")[:16]
def _trace_html(cards: list[dict], ask: str = "") -> str:
"""Render the live tool trace: cards + timing bars + payload previews."""
bits = []
if ask:
bits.append(f"ask: {html.escape(ask[:140])}
")
if not cards:
bits.append(_EMPTY_TRACE)
return "".join(bits) if ask else _EMPTY_TRACE
times = [c.get("ms") for c in cards
if c.get("state") == "done" and isinstance(c.get("ms"), (int, float))]
slowest = max(times) if times else 0.0
parts = list(bits)
parts.append("")
for c in cards:
icon = _TOOL_ICONS.get(str(c.get("name")), "🔧")
done = c.get("state") == "done"
state_cls = "done" if done else "pending"
ms = c.get("ms")
ms_bit = ("
…running" if not done
else f"
⚡ {float(ms):.0f} ms"
if isinstance(ms, (int, float))
else "
done")
bar = ""
if done and isinstance(ms, (int, float)) and slowest > 0:
pct = max(6, int(round(100.0 * float(ms) / slowest)))
bar = f"
"
summary = (f"
{html.escape(str(c.get('summary') or ''))}
"
if done and c.get("summary") else "")
details = ""
if done and c.get("preview"):
details = ("
payload
"
f"{html.escape(str(c['preview']))}")
parts.append(
f"
"
"
"
f"{icon}"
f"{html.escape(str(c.get('name', '?')))}"
f"({html.escape(str(c.get('args', '')))})"
f"{ms_bit}
{bar}{summary}{details}
"
)
parts.append("
")
return "".join(parts)
def _verdict_html(text: str) -> str:
"""Lift the answer's contract verdict line ("Best: …") into a banner."""
for line in (text or "").splitlines():
match = _VERDICT_RE.search(line)
if match:
clean = re.sub(r"\*", "", match.group(1)).strip()
if clean:
return (f"🏆 Best: "
f"{html.escape(clean)}
")
return ""
def _format_rank_cards(rank: list[dict] | None) -> str:
if not rank:
return _EMPTY_RANK_HTML
parts = [""]
for i, r in enumerate(rank):
medal = _MEDALS[i] if i < len(_MEDALS) else str(i + 1)
score = r.get("best_score", "")
score_bit = f"{score}/10" if score not in ("", None) else "—"
parts.append(
"
"
f"{medal}"
""
f"{html.escape(str(r.get('name', '?')))} "
f"{html.escape(str(r.get('region', '') or ''))}"
f"{html.escape(score_bit)}"
f"{html.escape(_pretty_time(r.get('best_time')))}"
"
"
)
parts.append("
")
return "".join(parts)
def _format_meter(usage: dict | None, source: str,
caps: dict | None = None) -> str:
"""Final meter after a turn: exact tokens (when known) + steps + caps."""
if not usage:
return "_Token meter — per-turn usage lands here._"
model = str(usage.get("model") or "").split("/")[-1]
profile = usage.get("profile")
budget = usage.get("budget") or caps or {}
tail = (f" · {profile} profile" if profile else "") \
+ (f" · cap {budget.get('max_steps', '?')} steps" if budget else "") \
+ (f" · {model}" if model else "") + f" (via {source})"
if usage.get("prompt_tokens") is not None or usage.get("completion_tokens") is not None:
p = usage.get("prompt_tokens", "?")
c = usage.get("completion_tokens", "?")
try:
total = int(p) + int(c)
except (TypeError, ValueError):
total = "?"
return f"🪙 **prompt {p} · completion {c} · total {total} tokens**{tail}"
steps = usage.get("steps", "?")
calls = usage.get("tool_calls") or {}
n_calls = sum(calls.values()) if isinstance(calls, dict) else "?"
return f"⚙️ **{steps} steps · {n_calls} tool call(s)**{tail}"
def _live_meter(live: dict, caps: dict, source: str) -> str:
"""Mid-turn meter: running counts against the depth profile's caps."""
bits = [
f"⚙️ steps {live.get('steps', 0)}/{caps.get('max_steps', '?')}",
f"🔧 {live.get('calls', 0)} call(s)",
f"score_week {live.get('score_week', 0)}/{caps.get('score_week_calls', '?')}",
f"≈{live.get('chars', 0) // 4} tokens",
]
return " · ".join(bits) + f" — _via {source}_"
def fresh_store() -> dict:
return {"spots": [], "rank": None, "usage": None}
def _build_strip(hours: list[dict], label: str, records: list[dict]):
"""Week strip + best-hour line for an agent-scored spot."""
spot_name = label.split(" (")[0]
match = C.find_break(records, spot_name)
fig = strip_chart.build_week_strip(hours, _scoring_spot(match), height=520)
return fig, score_chart.format_best(score_chart.best_window(hours), label)
def _scoring_spot(break_: dict | None) -> dict | None:
"""Minimal scoring spot for wind directional colouring."""
if not break_:
return None
wind = (break_ or {}).get("idealWind", {}) or {}
direction = wind.get("direction", "N")
if isinstance(direction, list):
direction = "/".join(str(d) for d in direction if d)
return {"ideal_wind": {"direction": str(direction or "N")}}
def _coerce_rank_rows(payload) -> list[dict] | None:
if isinstance(payload, dict) and isinstance(payload.get("rank"), list):
payload = payload["rank"]
if isinstance(payload, dict) and isinstance(payload.get("spots"), list):
payload = payload["spots"]
if not isinstance(payload, list) or not payload:
return None
if not all(isinstance(r, dict) and "name" in r and "best_score" in r for r in payload):
return None
return payload
def _viz_spot(args: dict, obs) -> tuple[str, str]:
"""(name, region) for a tool observation, falling back to call args."""
info = (obs.get("spot") or {}) if isinstance(obs, dict) else {}
name = args.get("spot_name") or info.get("name") or ""
region = args.get("region") or info.get("region") or ""
return str(name), str(region)
def _specialty_tool(query: str) -> str | None:
"""The tool whose chart the question explicitly asks for, if any."""
for tool_name, rx in _SPECIALTY_RE:
if rx.search(query or ""):
return tool_name
return None
def _rose_chart(entry: tuple[dict, object], records: list[dict]):
_a, obs = entry
if not isinstance(obs, dict):
return None, ""
rose = (obs.get("climate") or {}).get("direction_rose_pct") or {}
try:
if not any(float(v or 0) > 0 for v in rose.values()):
return None, ""
except (TypeError, ValueError):
return None, ""
name, _region = _viz_spot(_a, obs)
rec = C.find_break(records, name)
ideal = (((rec or {}).get("idealSwell") or {}).get("direction")
if rec else None)
fig = climate_chart.build_rose_fig({"rose": rose}, name=name,
ideal_dirs=ideal)
return fig, f"swell climate — {name} (5-yr ERA5 rose)"
def _depth_chart(entry: tuple[dict, object], records: list[dict]):
_a, obs = entry
if not isinstance(obs, dict):
return None, ""
name, _region = _viz_spot(_a, obs)
rec = C.find_break(records, name)
if not rec:
return None, ""
try:
grid = (C.get_seafloor(rec) or {}).get("grid") or {}
return seafloor_chart.build_depth_fig(grid, name), \
f"seafloor depth map — {name}"
except Exception: # noqa: BLE001 — fall through to the next viz
return None, ""
def _similarity_chart(entry: tuple[dict, object], records: list[dict]):
_a, obs = entry
if (isinstance(obs, list) and obs and isinstance(obs[0], dict)
and "similarity" in obs[0]):
ref = str(_a.get("spot_name") or "")
return (score_chart.build_similarity_fig(obs, ref),
f"{len(obs)} spots like {ref} — similarity")
return None, ""
def _components_chart(entry: tuple[dict, object], records: list[dict]):
_a, obs = entry
if (isinstance(obs, dict) and isinstance(obs.get("components"), dict)
and obs["components"]):
name, _region = _viz_spot(_a, obs)
when = str(obs.get("time") or "")
fig = score_chart.build_component_fig(obs["components"], spot=name,
when=when, score=obs.get("score"))
return fig, f"component split — {name} @ {when}"
return None, ""
def _specialty_figure(by_name: dict[str, tuple[dict, object]],
records: list[dict],
prefer: str | None = None):
"""First buildable question-specific chart from the tool payloads.
Scans climate rose → seafloor depth map → similarity bars → component
split; a question-matched tool (``prefer``) is scanned first so e.g. a
seafloor question wins its depth map even when a rose payload exists.
Returns (figure, markdown note) or (None, "").
"""
checks = (
("get_climate_profile", _rose_chart),
("get_seafloor_profile", _depth_chart),
("find_similar_spots", _similarity_chart),
("explain_score", _components_chart),
)
if prefer:
checks = tuple(sorted(checks, key=lambda c: c[0] != prefer))
for _tool_name, build in checks:
fig, note = build(by_name.get(_tool_name, ({}, None)), records)
if fig is not None:
return fig, note
return None, ""
def _scored_compare_fig(scored: list[dict]):
"""Side-by-side best-score bars for a multi-spot scored turn."""
rows = []
for s in scored or []:
best = score_chart.best_window(s.get("hours") or [])
if not best:
continue
rows.append({"name": str(s.get("label") or "?"),
"region": str(s.get("region") or ""),
"best_score": best.get("score"),
"best_time": best.get("time")})
if len(rows) < 2:
return None, ""
names = " vs ".join(r["name"].split(" (")[0] for r in rows[:2])
fig = score_chart.build_compare_fig(
rows, "name", "best_score", "recommended breaks — best score this week",
sub_keys=("region", "best_time"))
return fig, f"{names} — best-score compare ({len(rows)} spots)"
def _query_region(query: str, records: list[dict]) -> str | None:
"""A canonical state/region named in the query (codes expanded), or None."""
text = f" {query or ''} ".lower()
vocab = sorted({str(r.get("state") or "") for r in records}
| {str(r.get("region") or "") for r in records},
key=len, reverse=True)
for name in vocab:
if name and re.search(rf"(? 1:
s["label"] = f"{label} #{counts[s['label']]}"
fig, best_md = _build_strip(hours, spots[-1]["label"], records)
charted = f"📊 _Charted: {spots[-1]['label']} — {best_md}_"
decorations.append(charted)
history[-1]["content"] += f"\n\n{charted}"
labels = [s["label"] for s in spots]
turn_charted = True
yield emit(f"📊 Charted '{spots[-1]['label']}' — agent still thinking…",
strip_fig=fig,
spot_update=gr.update(choices=labels, value=spots[-1]["label"],
visible=len(labels) > 1))
continue
if isinstance(obs, (dict, list)) and not (
isinstance(obs, dict) and "error" in obs):
viz.append((name, d.get("arguments") or {}, obs))
rows = _coerce_rank_rows(obs)
if rows:
rank = rows
yield emit(f"🏆 Ranked {len(rows)} spot(s) — agent still thinking…")
continue
if name == "get_seafloor_profile" and isinstance(obs, dict) and "error" not in obs:
shelf = obs.get("shelf_class") or "shape read"
channel = (" — deeper gutter(s) nearby" if obs.get("channel_hint")
else "")
note = f"🪨 _Seafloor: {shelf}{channel}._"
decorations.append(note)
history[-1]["content"] += f"\n\n{note}"
elif name == "get_session_brief" and isinstance(obs, dict) and "error" not in obs:
wetsuit = obs.get("wetsuit") or "check the water temp"
sst = obs.get("sst_c")
sun = f" · sun {obs.get('sunrise', '')}–{obs.get('sunset', '')}"
note = (f"🤿 _{wetsuit}"
+ (f" (water {sst} °C)" if sst is not None else "")
+ sun + "_")
decorations.append(note)
history[-1]["content"] += f"\n\n{note}"
yield emit(f"📦 {name} done — agent still thinking…")
elif kind == "usage":
usage = payload if isinstance(payload, dict) else None
meter = _format_meter(usage, token_source, caps=caps)
yield emit("🪙 Usage recorded — agent still thinking…")
elif kind == "final":
final_text = str(payload) if payload else history[-1]["content"]
tail = ("\n\n" + "\n\n".join(decorations)) if decorations else ""
history[-1]["content"] = final_text + tail
verdict = _verdict_html(final_text)
yield emit("✅ Answer complete.")
except ValueError as e:
if "HF_TOKEN" in str(e):
history[-1]["content"] = ("_No Hugging Face token found._ Paste one in the "
"**HF token** box (session-only, never logged) or set "
"`HF_TOKEN`, then ask again.")
yield emit("⚠️ No HF token — paste one above or set HF_TOKEN.")
return
history[-1]["content"] += f"\n\n_❌ Agent run failed: {e}_"
yield emit(f"❌ Agent run failed: {e}")
return
except Exception as e:
history[-1]["content"] += f"\n\n_❌ Agent run failed: {e}_"
yield emit(f"❌ Agent run failed: {e}")
return
# Every answered turn ends with a visualisation matched to the question,
# scores first. Multi-spot scored turns close on the side-by-side
# compare; otherwise the chain runs only when nothing was charted
# mid-turn. The status is yielded first so a slow fetch (e.g. a make-up
# region sweep) reads as a staged spin-up, never a hang.
fallback_fig = None
viz_note = ""
if (not turn_charted) or len(spots) > 1:
yield emit("📊 assembling this turn's chart…")
try:
fallback_fig, viz_note = _turn_figure(viz, selected, skill, records,
query=text,
focus=_region_hint(state, region),
scored=spots,
charted=turn_charted)
except Exception: # noqa: BLE001 — a chart must never break the answer
fallback_fig, viz_note = None, ""
if fallback_fig is not None:
history[-1]["content"] += f"\n\n📊 _Charted: {viz_note}_"
labels = [s["label"] for s in spots]
status = ("✅ Answer complete — chart updated." if fallback_fig is not None
else "✅ Answer complete." if (spots or rank)
else "💬 Nothing chartable this turn — pick a spot and ask again.")
out = dict(chatbot=history, msg="",
trace_html=_trace_html(cards, ask=text), token_md=meter,
agent_status=status, verdict_html=verdict,
spot_dd=gr.update(choices=labels, value=(labels[0] if labels else None),
visible=len(labels) > 1),
rank_html=_format_rank_cards(rank),
agent_store=dict(store, spots=spots, rank=rank, usage=usage))
if fallback_fig is not None:
out["agent_strip"] = fallback_fig
yield fill(AGENT_KEYS, **out)
def show_spot(label: str | None, store: dict | None, records: list[dict]):
"""Rebuild the week strip for the spot picked in the dropdown."""
spots = (store or {}).get("spots") or []
entry = next((s for s in spots if s.get("label") == label), None)
if entry is None:
return gr.skip()
try:
fig, _ = _build_strip(entry["hours"], entry["label"], records)
return fig
except Exception:
return gr.skip()
_DEFAULT_STEPS = 6
def _region_hint(state: str | None, region: str | None) -> str | None:
"""Agent focus from the chained filters: region > state > auto (None)."""
for value in (region, state):
if value and value != C.ALL:
return str(value)
return None
def ctx_line(skill: str, selected: dict | None, steps: int | None,
state: str | None, region: str | None) -> str:
"""One live line showing what the agent inherits from the page."""
caps = C.agent_budget_for_steps(int(steps) if steps else _DEFAULT_STEPS)
name = (selected or {}).get("name") or "no spot picked"
reg = (selected or {}).get("region") or ""
hint = _region_hint(state, region)
focus = hint or "auto (map pick)"
view = f"{name}" + (f" — {reg}" if reg else "")
return (f"scoring for **{C.scoring_skill(skill)}** · viewing **{view}** · "
f"focus **{focus}** · budget **{caps['max_steps']} steps / "
f"{caps['score_week_calls']} score_week / {caps['max_tokens']} tokens**")
def clear_chat():
"""Reset the agent panel (session token box is a component, untouched)."""
return fill(AGENT_KEYS, chatbot=[], msg="",
trace_html=_trace_html([]), token_md="_Token meter — per-turn usage lands here._",
agent_status="Ask anything — every answer lands with a chart…",
verdict_html="",
agent_strip=None,
spot_dd=gr.update(choices=[], value=None, visible=False),
rank_html=_EMPTY_RANK_HTML, agent_store=fresh_store())
CHIP_PROMPTS = [
"Where should I surf in NSW this weekend as a beginner?",
"When are the best morning windows at Bells Beach this week?",
"Compare Bells Beach and Snapper Rocks this weekend.",
"What's the seafloor like at Bells Beach — and what wetsuit do I need?",
"What breaks are like Snapper Rocks but quieter?",
]
def build_agent(selected, store, records: list[dict], vocab: dict | None,
visible: bool = False) -> dict:
"""Build the agent bar (full width, hidden until agentic mode slides on)."""
states = list((vocab or {}).get("states") or [])
regions_by_state = dict((vocab or {}).get("regions_by_state") or {})
all_regions = list((vocab or {}).get("all_regions") or [])
with gr.Column(elem_classes=["agent-col"], visible=visible) as agent_col:
gr.Markdown("### 💬 ask the agent — engines compute, the llm narrates")
gr.Markdown("the agent calls ⚙ scoring, 🪨 the gebco world model, 🌡 era5 climate "
"and 📡 open-meteo directly; nemotron 3 ultra only narrates their "
"numbers. every answer lands with a question-matched chart — "
"recommended-scores leaderboard, week strip, rose, depth map, "
"similarity bars or component split.")
ctx_md = gr.Markdown("", elem_classes=["ctx-line"])
with gr.Row():
with gr.Column(scale=1):
with gr.Row(elem_classes=["agent-controls"]):
state_dd = gr.Dropdown([C.ALL, *states], value=C.ALL,
label="State", min_width=120)
region_dd = gr.Dropdown([C.ALL, *all_regions], value=C.ALL,
label="Region", min_width=120)
steps_slider = gr.Slider(1, 12, step=1,
value=_DEFAULT_STEPS,
label="max calls", scale=2,
min_width=180,
elem_classes=["steps-slider"])
token_box = gr.Textbox(label="HF token (optional, session-only)", type="password",
placeholder="Defaults to the Space secret — never logged or saved.")
chatbot = gr.Chatbot(label="Surf agent", height=460)
with gr.Row(elem_classes=["chat-input-row"]):
msg = gr.Textbox(show_label=False,
placeholder="e.g. When should I surf Bells Beach this week? (Enter to send)",
container=False, scale=8)
send_btn = gr.Button("Send", variant="primary", scale=1)
clear_btn = gr.Button("Clear", variant="stop", scale=1)
trace_html = gr.HTML(_trace_html([]))
with gr.Column(scale=1):
verdict_html = gr.HTML("", elem_classes=["verdict-slot"])
chips = [gr.Button(p, variant="secondary", elem_classes=["chip-btn"])
for p in CHIP_PROMPTS]
token_md = gr.Markdown("_Token meter — per-turn usage lands here._")
status_box = gr.Textbox(label="Status", interactive=False,
placeholder="Ask anything — every answer lands with a chart…")
spot_dd = gr.Dropdown(choices=[], value=None, visible=False, label="Chart spot (multiple scored)")
agent_strip = gr.Plot(label="week strip — agent-scored spot")
rank_html = gr.HTML(_EMPTY_RANK_HTML)
def _chat(message, history, skill, hf_token, sel, st, steps, state, region):
yield from chat_fn(message, history, skill, hf_token, sel, st,
steps, state, region, records)
def agent_state_regions(state: str):
"""Page-one pattern: the State pick rewrites the Region choices."""
regions = regions_by_state.get(state, all_regions) if state != C.ALL else all_regions
return gr.update(choices=[C.ALL, *regions], value=C.ALL)
def _show(label, st):
return show_spot(label, st, records)
def _chip(text):
return lambda: text
return {
"agent_col": agent_col,
"token_box": token_box, "chatbot": chatbot, "msg": msg,
"send_btn": send_btn, "clear_btn": clear_btn, "trace_html": trace_html,
"verdict_html": verdict_html, "rank_html": rank_html,
"token_md": token_md, "agent_status": status_box, "spot_dd": spot_dd,
"agent_strip": agent_strip, "chips": tuple(chips),
"steps_slider": steps_slider, "state_dd": state_dd,
"region_dd": region_dd, "ctx_md": ctx_md,
"chat": _chat, "show_spot": _show, "clear": clear_chat, "chip_text": _chip,
"ctx": ctx_line, "state_regions": agent_state_regions,
}