"""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, }