codi-api-agent 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
api_agent/ui.py ADDED
@@ -0,0 +1,918 @@
1
+ """Streamlit UI for the API Agent β€” packaged entry point.
2
+
3
+ Launch it with the console command after installing: ``api-agent``
4
+ or directly: ``streamlit run -m api_agent.ui`` (or ``python -m api_agent`` β†’ prints how).
5
+
6
+ Load an OpenAPI/Swagger, GraphQL, or Postman/RAML/API-Blueprint spec in the sidebar, then ask
7
+ questions β€” the agent picks the right operations, calls them (read-only), and answers with
8
+ citations and a faithfulness check.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import queue
14
+ import re
15
+ import threading
16
+
17
+ try: # work both installed and from a source checkout
18
+ import api_agent # noqa: F401
19
+ except ModuleNotFoundError: # pragma: no cover
20
+ import sys
21
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
22
+
23
+ import streamlit as st
24
+ import streamlit.components.v1 as components
25
+
26
+ from api_agent import log as _log
27
+ from api_agent.agent import Agent, _tool_category
28
+ from api_agent.catalog import Catalog
29
+ from api_agent.config import Config
30
+ from api_agent.graphql_loader import load_catalog
31
+ from api_agent.openapi_loader import build_catalog, merge_catalogs, spec_label
32
+ from api_agent.schemas import CACHED_INPUT_DISCOUNT, AgentResult, unpriced_models
33
+ from api_agent.supervisor import Supervisor
34
+
35
+ st.set_page_config(page_title="API Agent", page_icon="πŸ›°οΈ", layout="wide")
36
+
37
+ ENV = Config.from_env()
38
+
39
+ # ---- Console step trail -------------------------------------------------------------------- #
40
+ # Re-apply the log level AFTER Config.from_env(), which is what runs `load_dotenv()`. Import order
41
+ # makes this necessary rather than tidy: `api_agent.agent` imports `metrics` β†’ `log` before it
42
+ # imports `config`, so `log` was already configured at the pre-dotenv level and a `LOG_LEVEL` set
43
+ # in `.env` was silently ignored β€” the cap stayed 800 and the level stayed INFO with nothing said.
44
+ #
45
+ # LOG_LEVEL=DEBUG every step's FULL input and output (LLM message lists, replies,
46
+ # tool args, tool results, routing, verdicts)
47
+ # LOG_PAYLOAD_CHARS=0 no truncation (default 800 cuts exactly the long payloads you want)
48
+ # LOG_RAW_ROWS=1 log warehouse rows BEFORE PII redaction (see sql_loader._redact_row)
49
+ # LOG_FILE=agent.log also tee to a file
50
+ _log.setup(force=True)
51
+ _UILOG = _log.get_logger("api_agent.ui")
52
+ _UILOG.info("UI start β€” log level=%s payload_chars=%s raw_rows=%s (streamlit prints to THIS console)",
53
+ os.getenv("LOG_LEVEL", "INFO").upper(), _log.payload_chars(),
54
+ os.getenv("LOG_RAW_ROWS", "0"))
55
+ # No spec auto-loads by default (a fresh install has none) β€” set DEFAULT_SPEC to a URL/path to
56
+ # auto-load one. Example: DEFAULT_SPEC="https://petstore3.swagger.io/api/v3/openapi.json".
57
+ DEFAULT_SPEC = os.getenv("DEFAULT_SPEC", "").strip()
58
+
59
+
60
+ def _catalog_for_trace():
61
+ """The active catalog, for turning an operation name into its business category. Best-effort:
62
+ the trace must render even before a catalog is loaded."""
63
+ try:
64
+ return st.session_state.get("catalog")
65
+ except Exception: # pragma: no cover - outside a Streamlit run
66
+ return None
67
+
68
+
69
+ def _md(text: str) -> str:
70
+ """Prepare answer text for `st.markdown`. Streamlit renders `$…$` as LaTeX math, so currency
71
+ like `$5,606,794.94` breaks the whole line (bold turns into `βˆ—βˆ—`). Escape every unescaped `$`
72
+ so amounts render literally."""
73
+ return re.sub(r"(?<!\\)\$", r"\\$", text or "")
74
+
75
+
76
+ # Categorical slots 1-3 of the validated palette, in fixed order. Three is the ceiling that clears
77
+ # every colour-vision-deficiency pair test in BOTH modes; a fourth puts yellow and orange together
78
+ # and stops clearing. Assigned by POSITION in `spec.series`, never cycled, so a series keeps its
79
+ # colour when a sibling drops out of the result.
80
+ _SERIES_LIGHT = ["#2a78d6", "#eb6834", "#1baf7a"]
81
+ _SERIES_DARK = ["#3987e5", "#d95926", "#199e70"]
82
+
83
+
84
+ def _render_trend_chart(result: AgentResult) -> None:
85
+ """Draw the ONE result worth drawing, when there is one. Otherwise draw nothing.
86
+
87
+ Prose is weakest at exactly one shape: a trend. Measured β€” asked for "the Dentrix production
88
+ trend for the last 12 months through June 2026" the agent wrote an accurate paragraph naming
89
+ the first month, the last, the low, the high, the total and the year-over-year change: six
90
+ numbers standing in for twelve, and the reader still cannot see the shape.
91
+
92
+ `chart.chart_spec` owns the decision and refuses by default β€” truncated rows are never drawn,
93
+ because a line over a partial series shows a shape the data does not have and, unlike a table,
94
+ carries no visible sign that anything is missing.
95
+ """
96
+ try:
97
+ import altair as alt
98
+ import pandas as pd
99
+ except ModuleNotFoundError: # charting is a nicety; never break the answer over it
100
+ return
101
+ from api_agent.chart import chart_spec, humanize
102
+
103
+ spec = next((s for e in (result.evidence or [])
104
+ if (s := chart_spec(getattr(e, "rows", None),
105
+ truncated=getattr(e, "rows_truncated", False),
106
+ label=getattr(e, "tool", "")))), None)
107
+ if not spec:
108
+ return
109
+
110
+ dark = str(st.get_option("theme.base") or "").lower() == "dark"
111
+ palette = _SERIES_DARK if dark else _SERIES_LIGHT
112
+ ink, muted = ("#ffffff", "#c3c2b7") if dark else ("#0b0b0b", "#52514e")
113
+ names = [humanize(c) for c in spec.series]
114
+ df = pd.DataFrame(spec.points)
115
+ df["series"] = df["series"].map(humanize)
116
+ order = [n for n in names] # x stays in the order the warehouse returned
117
+
118
+ colour = alt.Color("series:N", title=None,
119
+ scale=alt.Scale(domain=order, range=palette[:len(order)]),
120
+ legend=alt.Legend(orient="top", direction="horizontal", labelColor=muted,
121
+ symbolStrokeWidth=3, symbolType="stroke"))
122
+ base = alt.Chart(df).encode(
123
+ # `sort=None` keeps the periods in the order the rows arrived. Altair sorts a nominal axis
124
+ # alphabetically by default, which turns "Jul 2025 β†’ Jun 2026" into "Apr, Aug, Dec, …" β€” a
125
+ # chart that looks fine and is pure noise.
126
+ x=alt.X("period:N", title=None, sort=None,
127
+ axis=alt.Axis(labelColor=muted, labelAngle=-45, domainColor=muted,
128
+ tickColor=muted, grid=False)),
129
+ y=alt.Y("value:Q", title=None,
130
+ # A trend chart is read for SHAPE, so the axis is not forced to zero β€” but it is
131
+ # never clipped so tightly that a 2% wobble looks like a collapse.
132
+ scale=alt.Scale(zero=False, nice=True, padding=12),
133
+ axis=alt.Axis(labelColor=muted, format="~s", domain=False, ticks=False,
134
+ gridColor=muted, gridOpacity=0.18, gridDash=[])),
135
+ color=colour,
136
+ )
137
+ hover = alt.selection_point(on="mouseover", nearest=True, fields=["period"], empty=False)
138
+ chart = (
139
+ base.mark_line(strokeWidth=2, point=alt.OverlayMarkDef(size=64, filled=True))
140
+ + base.mark_point(size=90, opacity=0).add_params(hover)
141
+ + base.mark_rule(color=muted, strokeWidth=1).encode(
142
+ opacity=alt.condition(hover, alt.value(0.35), alt.value(0)),
143
+ tooltip=[alt.Tooltip("period:N", title="Period"),
144
+ alt.Tooltip("series:N", title="Series"),
145
+ alt.Tooltip("value:Q", title="Value", format=",.2f")])
146
+ ).properties(height=280).configure_view(strokeWidth=0).configure_axis(labelFontSize=11)
147
+
148
+ st.altair_chart(chart, use_container_width=True)
149
+ # The table view is not optional garnish: two of the three light-mode hues sit below 3:1
150
+ # against the surface, and the palette's relief rule requires visible labels or a table
151
+ # wherever that holds. It also keeps every series the scale guard dropped reachable.
152
+ with st.expander("πŸ”’ The numbers behind this chart"):
153
+ st.dataframe(df.pivot(index="period", columns="series", values="value").reindex(
154
+ df["period"].drop_duplicates()), use_container_width=True)
155
+
156
+
157
+ def render_result(result: AgentResult) -> None:
158
+ """Render an answer's supporting detail: faithfulness, timing, sources, trace."""
159
+ # The sidebar may override per-model prices, and that `config` is built further down this
160
+ # module. Resolve it defensively rather than depending on definition order β€” falling back to
161
+ # the environment's table keeps costs priced even if this is ever called earlier.
162
+ pricing = getattr(globals().get("config") or ENV, "model_pricing", {})
163
+ cols = st.columns(3)
164
+ fh = result.faithfulness
165
+ if fh and fh.judged:
166
+ verdict = "grounded" if fh.supported else "check claims"
167
+ cols[0].caption(f"πŸ”Ž Evaluation: **{fh.score:.0%} grounded** ({verdict})")
168
+ elif not getattr(globals().get("config") or ENV, "judge_enabled", True):
169
+ # A deliberate cost decision, not a failure. The full note (what still ran) is in the
170
+ # tooltip so the caption doesn't turn into a paragraph.
171
+ cols[0].caption("πŸ”Ž Evaluation: **scoring off** for this session",
172
+ help=(fh.notes if fh else ""))
173
+ elif fh:
174
+ cols[0].caption(f"πŸ”Ž Evaluation: not judged β€” {fh.notes}")
175
+ if result.cached:
176
+ cols[1].caption(f"⚑ **{result.elapsed_s * 1000:.1f} ms** (cached)")
177
+ else:
178
+ cols[1].caption(f"⏱️ **{result.elapsed_s:.2f} s**")
179
+ u = result.usage
180
+ if u and u.total_tokens:
181
+ # Combined across every model (generator + writer + judge + router/cache), each priced at
182
+ # its OWN rate via Usage.by_model β€” the pipeline runs three different models, so a single
183
+ # blended rate would misprice it.
184
+ cost = f" Β· πŸ’² **${result.cost_usd:.4f}**" if result.cost_usd is not None else ""
185
+ split = (f" (in **{u.prompt_tokens:,}** Β· out **{u.completion_tokens:,}**)"
186
+ if (u.prompt_tokens or u.completion_tokens) else "")
187
+ cols[2].caption(f"πŸ”’ **{u.total_tokens:,}** tokens{split}{cost}")
188
+ # An UNPRICED model contributes $0 to the figure above, so the total reads as
189
+ # authoritative while being quietly too low. Say so rather than let it pass as free.
190
+ if missing := unpriced_models(u, pricing):
191
+ cols[2].caption(f"⚠️ no price set for `{', '.join(missing)}` β€” the $ above "
192
+ "excludes it")
193
+ if len(u.by_model or {}) > 1:
194
+ with st.expander("πŸ’² Cost by model"):
195
+ st.caption("Each model is priced at its own rate. The pipeline deliberately runs "
196
+ "a cheaper model for routing/execution/judging and a stronger one for "
197
+ "the user-facing writing, so these will differ.")
198
+ for m, (p, c) in sorted(u.by_model.items(),
199
+ key=lambda kv: -(kv[1][0] + kv[1][1])):
200
+ rate = pricing.get(m) or pricing.get(m.split("/")[-1])
201
+ cached = (u.cached_by_model or {}).get(m, 0)
202
+ if rate:
203
+ billed = max(p - cached, 0) + cached * CACHED_INPUT_DISCOUNT
204
+ line = f"**${billed / 1e6 * rate[0] + c / 1e6 * rate[1]:.4f}**"
205
+ else:
206
+ line = "*not priced*"
207
+ st.markdown(f"- `{m}` β€” in {p:,} Β· out {c:,}"
208
+ + (f" Β· {cached:,} cached" if cached else "") + f" β†’ {line}")
209
+
210
+ if getattr(result, "answered_by", ""):
211
+ st.caption(f"πŸ€– Answered by the **{result.answered_by}** agent")
212
+
213
+ if result.routed_tools and result.route_method not in ("", "all (small catalog)"):
214
+ chosen = ", ".join(f"`{t}`" for t in result.routed_tools)
215
+ st.caption(f"🧭 Router ({result.route_method}) selected {len(result.routed_tools)} op(s): {chosen}")
216
+
217
+ if result.needs_keys:
218
+ hosts = ", ".join(f"`{h}`" for h in result.needs_keys)
219
+ st.warning(
220
+ f"πŸ”‘ This API needs authentication for {hosts}. Re-load the spec with the "
221
+ f"**Auth** fields filled in (header name + value), then ask again."
222
+ )
223
+
224
+ # Evaluation β€” a multi-dimensional verdict (grounding + responsiveness), not just one opaque
225
+ # number, so it's clear WHY the status is what it is.
226
+ if fh and fh.judged:
227
+ #head = "looks good βœ…" if (fh.supported and fh.answers_question) else "see caveats ⚠️"
228
+ with st.expander(f"πŸ”Ž Evaluation"):
229
+ def _dim(ok, label_ok, label_bad):
230
+ return f"{'βœ…' if ok else '⚠️'} {label_ok if ok else label_bad}"
231
+ st.markdown(
232
+ f"- **Grounding:** {fh.score:.0%} of the answer's claims are backed by the fetched data\n"
233
+ f"- **Addresses the question:** {_dim(fh.answers_question, 'yes', 'not fully')}")
234
+ if fh.notes:
235
+ st.markdown(f"**Validator's justification:** {fh.notes}")
236
+ if fh.unsupported_claims:
237
+ st.markdown(
238
+ f"**{len(fh.unsupported_claims)} claim(s) not grounded in the data** "
239
+ "β€” this is what pulled the score below 100%:"
240
+ )
241
+ for u in fh.unsupported_claims:
242
+ st.markdown(f"- {u}")
243
+ elif fh.supported:
244
+ st.markdown("βœ… Every claim in the answer was grounded in the fetched data.")
245
+
246
+ # Five-dimension quality scorecard (mirrors the eval rubric D1–D5).
247
+ rb = getattr(result, "rubric", None)
248
+ if rb:
249
+ _RB_EMOJI = {"correctness": "🎯", "completeness": "🧩", "groundedness": "πŸ”—",
250
+ "safety": "πŸ›‘οΈ", "communication": "πŸ’¬", "traceability": "🧭"}
251
+ with st.expander(f"πŸ… Answer scorecard β€” **{rb.overall}/5**"):
252
+ # The ladder takes the FIRST rule that matches, so five green dimensions beside one
253
+ # amber leave the reader guessing which one set the headline. Say it outright.
254
+ if getattr(rb, "overall_reason", ""):
255
+ st.markdown(f"**{rb.overall}/5** β€” {_md(rb.overall_reason)}")
256
+ st.caption(
257
+ "Five quality dimensions (same rubric as the offline eval). Groundedness and "
258
+ "communication are LLM-judged; safety is a deterministic scan; correctness and "
259
+ "completeness are derived from grounding, data-sufficiency and responsiveness "
260
+ "(there's no reference answer at answer time, so those two mean 'consistent with "
261
+ "the fetched data and addresses the question')."
262
+ + (" **Traceability** appears on advisory answers only: it checks deterministically "
263
+ "that every recommendation has an owner and a timeframe, that its figures trace "
264
+ "back to the analysis, and that no forward dollar/percent outcome was invented."
265
+ if rb.traceability is not None else ""))
266
+ for d in rb.dims():
267
+ icon = "βœ…" if d.passed else "⚠️"
268
+ line = (f"{_RB_EMOJI.get(d.name, 'β€’')} **{d.name.title()}** β€” {icon} "
269
+ f"{d.score:.0%} Β· *{d.method}*")
270
+ if d.note:
271
+ line += f" \n&nbsp;&nbsp;&nbsp;{_md(d.note)}"
272
+ st.markdown(line)
273
+
274
+ # Both panels below are DEBUG surfaces β€” they name operations, arguments and sources.
275
+ # DEBUG_PANELS=0 removes them entirely for production.
276
+ if ENV.debug_panels and (result.citations or getattr(result, "source_note", "")):
277
+ with st.expander(f"πŸ“Ž Sources ({len(result.citations)})", expanded=True):
278
+ # Which system answered belongs HERE, not in the prose: the answer must never name the
279
+ # machinery, but the reader must still be able to find out what it drew on.
280
+ if note := getattr(result, "source_note", ""):
281
+ st.caption(_md(note))
282
+ for c in result.citations:
283
+ # Same reasoning as the trace panel below: a debugging surface, gated off in
284
+ # production, so it carries the real source and operation.
285
+ st.markdown(f"- [{c.source}]({c.source}) Β· via `{c.locator}`"
286
+ f" Β· *{_tool_category(c.locator, _catalog_for_trace())}*")
287
+
288
+ if ENV.debug_panels and result.trace:
289
+ with st.expander("🧭 How this answer was produced"):
290
+ is_doc = result.route_method == "doc"
291
+ mode = ("Looked the operation(s) up in the loaded spec β€” nothing was called."
292
+ if is_doc else
293
+ "Picked the relevant operation(s) and called them; here's each step.")
294
+ st.caption(f"**{len(result.trace)} step(s).** {mode}")
295
+ for i, t in enumerate(result.trace, 1):
296
+ src = str(t.source or "")
297
+ src_md = f"[{src}]({src})" if src.startswith("http") else f"`{src}`"
298
+ if is_doc:
299
+ st.markdown(f"{i}. πŸ“– **{t.detail}** β€” {src_md} \n *(read from the spec, not executed)*")
300
+ else:
301
+ # FULL DETAIL, deliberately. This panel is a DEBUGGING surface, not a
302
+ # customer one β€” it is gated off in production (`debug_panels`), and the
303
+ # customer-visible live step trail shows only the category. Understanding why
304
+ # an answer came out the way it did needs the operation, its exact arguments
305
+ # and the source it hit; a category alone cannot be debugged.
306
+ icon = "βœ…" if t.ok else "⚠️"
307
+ line = f"{i}. {icon} Called **`{t.tool}`**"
308
+ line += f" Β· *{_tool_category(t.tool, _catalog_for_trace())}*"
309
+ if t.args:
310
+ line += f" with `{t.args}`"
311
+ if t.detail:
312
+ line += f" β†’ returned {t.detail}"
313
+ if not t.ok and t.error:
314
+ line += f" β†’ **{t.error}**"
315
+ st.markdown(line)
316
+ if src:
317
+ st.caption(f" ↳ {src_md}")
318
+ if t.error:
319
+ st.caption(f" ↳ ⚠️ {t.error}")
320
+
321
+
322
+ def greeting_message() -> str:
323
+ """Welcome shown at the start of every session/chat (and after clearing)."""
324
+ return (
325
+ "πŸ‘‹ **Hi β€” I'm your API agent.**\n\n"
326
+ "Add a spec in the sidebar (**OpenAPI/Swagger, GraphQL, or a Postman/RAML/API-Blueprint "
327
+ "collection** β€” I auto-convert those), then ask me anything about it in plain language. "
328
+ "I pick the right operations, call them, and answer with **citations** and a "
329
+ "**faithfulness check**. I'm **read-only** and I never make data up.\n\n"
330
+ "**No spec yet? Try one of these free, no-auth demos** (Sidebar β†’ *Spec URL* β†’ *Add spec*):\n"
331
+ "- **GraphQL** β€” URL `https://countries.trevorblades.com/`, tick **GraphQL API** β†’ ask "
332
+ "*\"list the continents\"* or *\"capital and currency of Japan?\"*\n"
333
+ "- **Postman β†’ auto-converted** β€” URL "
334
+ "`https://raw.githubusercontent.com/aisabel/SWAPI-PostmanTest/master/SWAPI.postman_collection.json`, "
335
+ "Base URL `https://swapi.dev/api` β†’ ask *\"list the Star Wars characters\"*\n\n"
336
+ "What would you like to find out?"
337
+ )
338
+
339
+
340
+ # --------------------------------------------------------------------------- #
341
+ # Specs: load one or more specs (each with its own auth) into one catalog.
342
+ # --------------------------------------------------------------------------- #
343
+ if "specs" not in st.session_state:
344
+ st.session_state.specs = []
345
+ if DEFAULT_SPEC:
346
+ try:
347
+ st.session_state.specs.append(
348
+ {"label": spec_label(DEFAULT_SPEC), "source": DEFAULT_SPEC,
349
+ "catalog": load_catalog(DEFAULT_SPEC)}
350
+ )
351
+ except Exception as e:
352
+ st.sidebar.error(f"Failed to load DEFAULT_SPEC: {e}")
353
+
354
+ st.sidebar.title("βš™οΈ Configuration")
355
+ st.sidebar.subheader("πŸ“š Load an API")
356
+ st.sidebar.caption("Add one or more sources β€” a **spec** (OpenAPI/Swagger, GraphQL, or Postman/RAML/"
357
+ "API Blueprint) or a prose **API reference doc** (endpoints extracted for you to "
358
+ "review). The agent can use operations from any of them in a single answer.")
359
+ src_kind = st.sidebar.radio(
360
+ "Source type", ["API spec", "API reference doc", "Postgres warehouse"], horizontal=True,
361
+ help="A structured spec, a prose reference document (endpoints extracted deterministically "
362
+ "for review), or a Postgres warehouse whose schema functions become read-only tools.")
363
+ _is_doc = src_kind == "API reference doc"
364
+ _is_sql = src_kind == "Postgres warehouse"
365
+
366
+ if _is_sql:
367
+ # ---- SQL (warehouse) backend: schema functions -> read-only tools ----
368
+ sql_dsn = st.sidebar.text_input(
369
+ "Postgres DSN", ENV.sql_dsn, type="password",
370
+ help="e.g. postgresql://agent_ro:pass@localhost:7432/arq-dev β€” use a SELECT-only role; "
371
+ "connections are additionally forced read-only with a statement timeout.")
372
+ sql_schema = st.sidebar.text_input("Schema", ENV.sql_schema)
373
+ sql_prefix = st.sidebar.text_input(
374
+ "Function prefix", ENV.sql_fn_prefix,
375
+ help="Only functions matching this prefix are exposed as tools (the whitelist).")
376
+ sql_label = st.sidebar.text_input("Label (optional)", "", key="sql_label")
377
+ if st.sidebar.button("βž• Connect warehouse"):
378
+ if not sql_dsn.strip():
379
+ st.sidebar.warning("Enter a Postgres DSN first.")
380
+ else:
381
+ try:
382
+ from api_agent.sql_loader import load_sql_catalog
383
+ loaded = load_sql_catalog(
384
+ sql_dsn.strip(), schema=sql_schema.strip() or "gold",
385
+ fn_prefix=sql_prefix.strip(), timeout=ENV.http_timeout,
386
+ statement_timeout_ms=ENV.sql_timeout * 1000,
387
+ profile_rows=ENV.profile_rows)
388
+ if not loaded.tools:
389
+ st.sidebar.error(
390
+ f"No functions matching `{sql_prefix}*` found in schema `{sql_schema}`.")
391
+ else:
392
+ st.session_state.specs.append(
393
+ {"label": sql_label.strip() or f"{sql_schema} (warehouse)",
394
+ "source": f"sql://{sql_schema}", "catalog": loaded})
395
+ st.sidebar.success(f"Connected β€” **{len(loaded.tools)}** warehouse functions.")
396
+ except Exception as e:
397
+ st.sidebar.error(f"Failed β€” {e}")
398
+ spec_src, spec_files = "", [] # the spec/doc widgets below don't apply to a warehouse
399
+
400
+ if not _is_sql:
401
+ spec_src = st.sidebar.text_input("URL or file path", "")
402
+ spec_files = st.sidebar.file_uploader(
403
+ "…or upload file(s)",
404
+ type=(["md", "markdown", "txt"] if _is_doc
405
+ else ["json", "yaml", "yml", "graphql", "gql", "sdl", "raml", "apib"]),
406
+ accept_multiple_files=True,
407
+ help=("Prose reference doc(s) (.md/.txt) β€” combined into one." if _is_doc
408
+ else "OpenAPI/Swagger, GraphQL SDL, or Postman/RAML/API-Blueprint. Each file loads as a "
409
+ "separate source.")) or []
410
+ with st.sidebar.expander("Auth for this source (optional)"):
411
+ auth_header = st.text_input("Header name", "Authorization", help="e.g. Authorization, Cookie, or X-API-Key")
412
+ auth_value = st.text_input(
413
+ "Header value", "", type="password",
414
+ help="e.g. 'Bearer <token>', 'adminhtml=<cookie>', or your API key")
415
+ label_in = st.text_input("Label (optional)", "", help="Short name shown in the trace; defaults to the file/URL name")
416
+
417
+ if not _is_doc and not _is_sql:
418
+ # ---- Structured spec (OpenAPI / GraphQL / Postman / RAML / API Blueprint) ----
419
+ max_ops = st.sidebar.number_input(
420
+ "Max operations to load", 10, 5000, ENV.max_operations, step=50,
421
+ help="Large specs (e.g. GitHub has 600+) load up to this many ops; the router narrows them per query.")
422
+ _spec_name = (spec_src or (spec_files[0].name if spec_files else "")).lower().rstrip("/")
423
+ is_graphql = st.sidebar.checkbox(
424
+ "GraphQL API", value=_spec_name.endswith((".graphql", ".gql", ".sdl")),
425
+ help="Treat the source as GraphQL: introspect an endpoint URL, or load a local .graphql/.sdl "
426
+ "schema (then set the endpoint below). Only query fields become callable β€” mutations stay read-only.")
427
+ gql_endpoint = st.sidebar.text_input(
428
+ "GraphQL endpoint (for SDL files)", "",
429
+ help="Server URL to query when the source is a .graphql/.sdl file. Blank if the source is already the endpoint URL.",
430
+ ) if is_graphql else ""
431
+ base_url_in = "" if is_graphql else st.sidebar.text_input(
432
+ "Base URL (for Postman/RAML/Blueprint)", "",
433
+ help="The server to call for a converted spec, e.g. https://swapi.dev/api. Ignored for OpenAPI "
434
+ "specs that already declare their server. (Conversion needs Node/npx on PATH.)")
435
+ if base_url_in and not base_url_in.lower().startswith(("http://", "https://")):
436
+ base_url_in = "https://" + base_url_in.strip() # add a scheme if the user omitted it
437
+ if st.sidebar.button("βž• Add spec"):
438
+ # Each source loads as its own entry: the URL/path (if given) + every uploaded file.
439
+ import tempfile
440
+ jobs = [] # (display_name, path_or_url, is_temp_file)
441
+ if spec_src.strip():
442
+ jobs.append((spec_src.strip(), spec_src.strip(), False))
443
+ for f in spec_files:
444
+ ext = os.path.splitext(f.name)[1] or ".txt" # keep extension so format sniffing works
445
+ fd, tmp = tempfile.mkstemp(suffix=ext)
446
+ with os.fdopen(fd, "wb") as fh:
447
+ fh.write(f.getvalue())
448
+ jobs.append((f.name, tmp, True))
449
+ if not jobs:
450
+ st.sidebar.warning("Enter a spec URL/path or upload spec file(s) first.")
451
+ else:
452
+ single, added_ops, added, errors = len(jobs) == 1, 0, 0, []
453
+ for display, src, is_tmp in jobs:
454
+ try:
455
+ loaded = load_catalog(
456
+ src, graphql=is_graphql, endpoint=gql_endpoint or None,
457
+ base_url=base_url_in or None, max_operations=int(max_ops),
458
+ auth_header=auth_header or None, auth_value=auth_value or None)
459
+ if not loaded.tools:
460
+ errors.append(f"{display}: no callable read operations")
461
+ else:
462
+ st.session_state.specs.append(
463
+ {"label": (label_in.strip() if single else "") or spec_label(display),
464
+ "source": display, "catalog": loaded})
465
+ added_ops += len(loaded.tools)
466
+ added += 1
467
+ except Exception as e:
468
+ errors.append(f"{display}: {e}")
469
+ finally:
470
+ if is_tmp:
471
+ try:
472
+ os.remove(src)
473
+ except OSError:
474
+ pass
475
+ if added:
476
+ st.sidebar.success(f"Added **{added_ops}** operations from **{added}** source(s).")
477
+ for err in errors:
478
+ st.sidebar.error(f"Failed β€” {err}")
479
+ elif _is_doc:
480
+ # ---- Prose API reference doc: extract (structural, free) β†’ review β†’ load ----
481
+ base_url_in = st.sidebar.text_input(
482
+ "Base URL for these endpoints", "",
483
+ help="Server to call, e.g. https://api.example.com. Overrides any URL found in the doc.")
484
+ doc_use_llm = st.sidebar.checkbox(
485
+ "πŸ€– Also use the LLM to enrich params (slower, costs tokens)", value=False,
486
+ help="Endpoints are extracted from the doc's method+path lines for free. Tick to also have the "
487
+ "LLM add param types/enums, or to handle a prose-only doc. Uses your .env model.")
488
+ if st.session_state.get("doc_loaded_note"):
489
+ st.sidebar.success(st.session_state["doc_loaded_note"])
490
+ if st.sidebar.button("πŸ” Extract endpoints"):
491
+ from api_agent.doc_extract import build_doc_openapi, fetch_doc_text, looks_graphql_heavy
492
+ from api_agent.llm import LLMClient
493
+ try:
494
+ parts = []
495
+ if spec_src.strip():
496
+ parts.append(fetch_doc_text(spec_src.strip(), timeout=ENV.http_timeout))
497
+ for f in spec_files:
498
+ parts.append(f.getvalue().decode("utf-8", "ignore"))
499
+ text = "\n\n".join(p for p in parts if p and p.strip()) # combine all docs into one
500
+ if not text.strip():
501
+ st.sidebar.warning("Provide a doc URL/path or upload doc file(s) first.")
502
+ elif doc_use_llm and Config.from_env().problems():
503
+ st.sidebar.warning("LLM enrichment needs LLM_BASE_URL + LLM_API_KEY in .env β€” or untick it.")
504
+ else:
505
+ st.session_state.pop("doc_loaded_note", None)
506
+ prog = st.sidebar.progress(0.0, text="Extracting…")
507
+ _cfg = Config.from_env()
508
+ st.session_state.doc_draft = build_doc_openapi(
509
+ text, llm=LLMClient(_cfg) if doc_use_llm else None,
510
+ model=_cfg.generator_model, use_llm=doc_use_llm,
511
+ progress=lambda d, t: prog.progress(d / t, text=f"Enriching chunk {d}/{t}…"))
512
+ prog.empty()
513
+ heavy, n = looks_graphql_heavy(text)
514
+ st.session_state.doc_gql_note = (
515
+ f"ℹ️ This doc also describes ~{n} **GraphQL** operations (e.g. `useGetTasksQuery`); "
516
+ "those have no REST path β€” load the **GraphQL API** for them.") if heavy else None
517
+ except Exception as e:
518
+ st.sidebar.error(f"Extraction failed: {e}")
519
+
520
+ draft = st.session_state.get("doc_draft")
521
+ if draft:
522
+ from api_agent.doc_extract import endpoint_rows, prune_openapi
523
+ # Show ALL methods, not just GET. Writes (POST/PUT/DELETE…) load as DOCUMENTATION only β€”
524
+ # the agent can describe them ("what params does create-order take?") but the read-only
525
+ # guardrail never calls them; only GET/HEAD become callable tools.
526
+ rows = endpoint_rows(draft)
527
+ _readable = {"get", "head"}
528
+ n_get = sum(1 for m, _p, _op in rows if m in _readable)
529
+ n_write = len(rows) - n_get
530
+ if st.session_state.get("doc_gql_note"):
531
+ st.sidebar.info(st.session_state["doc_gql_note"])
532
+ xa = draft.get("x-auth") or {}
533
+ if xa.get("scheme"):
534
+ st.sidebar.caption(f"πŸ”‘ Doc suggests **{xa['scheme']}** auth"
535
+ + (f" via `{xa['header']}`" if xa.get("header") else "") + " β€” set it in *Auth* above.")
536
+ _found = f"**{len(rows)} endpoint(s) found**"
537
+ if n_write:
538
+ _found += f" β€” {n_get} callable GET/HEAD + {n_write} write op(s) (loaded as **read-only docs** the agent can describe, never call)"
539
+ st.sidebar.caption(_found + " β€” untick any to drop, then load:")
540
+ keep = set()
541
+ for m, p, op in rows:
542
+ tag = "" if m in _readable else " Β· _docs-only_"
543
+ label = f"`{m.upper()} {p}`" + tag + (f" β€” {op.get('summary')}" if op.get("summary") else "")
544
+ if st.sidebar.checkbox(label, value=True, key=f"docep::{m}::{p}"):
545
+ keep.add((m, p))
546
+ _keep_has_get = any(m in _readable for m, _p in keep)
547
+ # The endpoints are relative paths β†’ they need a real server. Prefer the field; fall back to
548
+ # a real (non-placeholder) base URL found in the doc. Show which is in effect.
549
+ _doc_base = (draft.get("servers") or [{}])[0].get("url", "")
550
+ _eff_base = base_url_in.strip().rstrip("/") or _doc_base
551
+ if _eff_base and not _eff_base.lower().startswith(("http://", "https://")):
552
+ _eff_base = "https://" + _eff_base # e.g. "viciqa.tapdemo.com" β†’ add scheme
553
+ if _eff_base:
554
+ st.sidebar.caption(f"🌐 Base URL: `{_eff_base}`")
555
+ else:
556
+ st.sidebar.caption("🌐 **No base URL** β€” the doc uses a placeholder like `https://{host}`. "
557
+ "Set **Base URL for these endpoints** above (e.g. `https://your-instance.tapclicks.com`).")
558
+ c1, c2 = st.sidebar.columns(2)
559
+ if c1.button(f"βœ… Approve & load ({len(keep)})"):
560
+ # A base URL is only needed to CALL an endpoint (GET/HEAD). A doc with only write ops
561
+ # loads as read-only documentation and is never called, so it doesn't require one.
562
+ if _keep_has_get and not _eff_base:
563
+ st.sidebar.warning("⚠️ Set a **Base URL for these endpoints** first β€” the endpoints are "
564
+ "relative and the doc's base is a placeholder (`https://{host}`), so "
565
+ "calls would have no host.")
566
+ st.stop()
567
+ pruned = prune_openapi(draft, keep)
568
+ pruned["servers"] = [{"url": _eff_base or "https://{host}"}]
569
+ auth = (auth_header, auth_value) if auth_value else None
570
+ cat = build_catalog(pruned, auth=auth)
571
+ if not cat.tools and not cat.reference:
572
+ st.sidebar.warning("Nothing approved β€” tick at least one endpoint to load.")
573
+ else:
574
+ title = (draft.get("info") or {}).get("title") or "extracted"
575
+ st.session_state.specs.append(
576
+ {"label": label_in.strip() or f"doc:{title}", "source": "doc-extract", "catalog": cat})
577
+ st.session_state.pop("doc_draft", None)
578
+ n_doc = max(len(cat.reference) - len(cat.tools), 0)
579
+ note = f"βœ… Loaded **{len(cat.tools)}** callable GET op(s) from the doc"
580
+ note += (f" + **{n_doc}** documented write op(s) (describe-only)." if n_doc else ".")
581
+ st.session_state.doc_loaded_note = note
582
+ st.rerun()
583
+ if c2.button("βœ– Discard"):
584
+ st.session_state.pop("doc_draft", None)
585
+ st.rerun()
586
+
587
+ # Loaded specs, each removable
588
+ if st.session_state.specs:
589
+ st.sidebar.caption("**Loaded sources**")
590
+ for i, s in enumerate(st.session_state.specs):
591
+ c1, c2 = st.sidebar.columns([5, 1])
592
+ c1.caption(f"β€’ **{s['label']}** Β· {len(s['catalog'].tools)} ops")
593
+ if c2.button("βœ•", key=f"rmspec_{i}", help=f"Remove {s['label']}"):
594
+ st.session_state.specs.pop(i)
595
+ st.rerun()
596
+ else:
597
+ st.sidebar.info("No specs loaded β€” add one above (see the demos in the chat).")
598
+
599
+ catalog = (
600
+ merge_catalogs([(s["label"], s["catalog"]) for s in st.session_state.specs])
601
+ if st.session_state.specs else Catalog([])
602
+ )
603
+ st.session_state.catalog = catalog
604
+ st.session_state.catalog_label = " + ".join(s["label"] for s in st.session_state.specs) or "(none)"
605
+ st.sidebar.caption(f"Active: **{len(st.session_state.specs)} source(s)** Β· {len(catalog.tools)} operations")
606
+
607
+ # --------------------------------------------------------------------------- #
608
+ # Model + behaviour
609
+ # --------------------------------------------------------------------------- #
610
+ st.sidebar.subheader("Model")
611
+ base_url = st.sidebar.text_input("LLM base URL", ENV.base_url)
612
+ api_key = st.sidebar.text_input("API key", ENV.api_key, type="password")
613
+ generator_model = st.sidebar.text_input("Generator model", ENV.generator_model)
614
+ synthesis_model = st.sidebar.text_input(
615
+ "Writer model (blank = generator)", ENV.synthesis_model,
616
+ help="Used ONLY for the user-facing writing stages β€” synthesis, self-review and resynthesis. "
617
+ "Everything else (router, executor, judge) stays on the generator model. Point this at a "
618
+ "stronger model to raise answer quality without paying for it on every call.")
619
+ judge_enabled = st.sidebar.checkbox(
620
+ "Quality scoring (LLM judges)", value=bool(ENV.judge_enabled),
621
+ help="On: the answer is graded β€” a grounding fact-check, a responsiveness check and a "
622
+ "presentation check, plus the 0–5 scorecard. That is up to three extra LLM calls per "
623
+ "answered question, and the grounding one re-sends the whole evidence block, so it is the "
624
+ "most expensive optional part of a query.\n\n"
625
+ "Off: none of those calls are made and no scorecard is shown. Every DETERMINISTIC check "
626
+ "still runs β€” figures are still matched against the fetched data, the total / attribution "
627
+ "/ superlative repairs still fire, and the confidentiality and PII scrubs are untouched. "
628
+ "The answer is still checked; it just isn't scored. Default comes from JUDGE_ENABLED.")
629
+ judge_model = st.sidebar.text_input("Judge model", ENV.judge_model,
630
+ disabled=not judge_enabled)
631
+ # Routing, embeddings and the tool-iteration limit are set in .env (not the UI) β€” they rarely change
632
+ # per session. The router AND cache-match LLM reuse the generator model unless ROUTER_MODEL is set.
633
+ # st.sidebar.caption(
634
+ # f"βš™οΈ Routing, embeddings & tool-iteration limits are set in **.env** "
635
+ # f"(`ROUTER_*`, `EMBEDDING_MODEL` = `{ENV.embedding_model or 'lexical only'}`, "
636
+ # f"`MAX_TOOL_ITERATIONS` = `{ENV.max_tool_iterations}`). The router & cache-match LLM reuse the "
637
+ # f"**generator model** unless `ROUTER_MODEL` is set.")
638
+
639
+ st.sidebar.subheader("πŸ’² Budget & cost")
640
+ max_response_tokens = st.sidebar.number_input(
641
+ "Max tokens / response (0 = unlimited)", min_value=0, value=int(ENV.max_response_tokens), step=1000,
642
+ help="Spending guardrail: stop a single response once its cumulative tokens reach this. The "
643
+ "answer is returned with what was gathered (marked partial).",
644
+ )
645
+ def _rate_of(model: str) -> tuple[float, float]:
646
+ return (ENV.model_pricing.get(model)
647
+ or ENV.model_pricing.get((model or "").split("/")[-1]) or (0.0, 0.0))
648
+
649
+ # Prices are keyed by model name so they RE-PREFILL when you switch models (type gpt-4o-mini β†’
650
+ # 0.15/0.60 appears). Known OpenAI/Groq/etc. models auto-fill from config.DEFAULT_MODEL_PRICING; an
651
+ # unknown model prefills 0 (tokens shown, no $) β€” type a rate to price it.
652
+ pricing = dict(ENV.model_pricing)
653
+
654
+ # ---- Generator pricing (executor + synthesis; ALSO the router & cache-match LLM, which reuse it) --
655
+ st.sidebar.caption(f"**Generator** pricing Β· `{generator_model or '(unset)'}` β€” routing, the executor tool-loop, planning and the judge")
656
+ _grate = _rate_of(generator_model)
657
+ gen_in = st.sidebar.number_input(
658
+ "Generator input price ($ / 1M tokens)", min_value=0.0, value=float(_grate[0]), step=0.05,
659
+ format="%.4f", key=f"gen_price_in::{generator_model}",
660
+ help="Applies to routing, the executor tool-loop, planning and the judge. NOT to synthesis β€” "
661
+ "that moved to the Writer model below. 0 = unknown/free β†’ not counted in $.")
662
+ gen_out = st.sidebar.number_input(
663
+ "Generator output price ($ / 1M tokens)", min_value=0.0, value=float(_grate[1]), step=0.05,
664
+ format="%.4f", key=f"gen_price_out::{generator_model}")
665
+ if gen_in or gen_out:
666
+ for _m in {generator_model, ENV.router_model or generator_model}:
667
+ if _m:
668
+ pricing[_m] = (gen_in, gen_out)
669
+
670
+ # ---- Writer pricing (synthesis + self-review + resynthesis). Only shown when the writer is a
671
+ # DISTINCT model; blank or same-as-generator means the generator price above already covers it. ----
672
+ if synthesis_model and synthesis_model != generator_model:
673
+ st.sidebar.caption(f"**Writer** pricing Β· `{synthesis_model}`")
674
+ _wrate = _rate_of(synthesis_model)
675
+ writer_in = st.sidebar.number_input(
676
+ "Writer input price ($ / 1M tokens)", min_value=0.0, value=float(_wrate[0]), step=0.05,
677
+ format="%.4f", key=f"writer_price_in::{synthesis_model}",
678
+ help="Applies to synthesis, self-review and resynthesis β€” roughly 42% of input tokens.")
679
+ writer_out = st.sidebar.number_input(
680
+ "Writer output price ($ / 1M tokens)", min_value=0.0, value=float(_wrate[1]), step=0.05,
681
+ format="%.4f", key=f"writer_price_out::{synthesis_model}")
682
+ if writer_in or writer_out:
683
+ pricing[synthesis_model] = (writer_in, writer_out)
684
+
685
+ # ---- Judge pricing (grounding + responsiveness checks). Only shown when the judge is a DISTINCT
686
+ # model; when judge == generator the generator price above already covers it. ----
687
+ if judge_enabled and judge_model and judge_model != generator_model:
688
+ st.sidebar.caption(f"**Judge** pricing Β· `{judge_model}`")
689
+ _jrate = _rate_of(judge_model)
690
+ judge_in = st.sidebar.number_input(
691
+ "Judge input price ($ / 1M tokens)", min_value=0.0, value=float(_jrate[0]), step=0.05,
692
+ format="%.4f", key=f"judge_price_in::{judge_model}",
693
+ help="Applies to the grounding + responsiveness checks. Set so the judge's tokens are priced "
694
+ "at ITS own rate (they're already counted in the total either way).")
695
+ judge_out = st.sidebar.number_input(
696
+ "Judge output price ($ / 1M tokens)", min_value=0.0, value=float(_jrate[1]), step=0.05,
697
+ format="%.4f", key=f"judge_price_out::{judge_model}")
698
+ if judge_in or judge_out:
699
+ pricing[judge_model] = (judge_in, judge_out)
700
+
701
+ tool_names = list(catalog.tools.keys())
702
+ enabled = st.sidebar.multiselect(
703
+ "Enabled operations", tool_names, default=tool_names,
704
+ key=f"enabled::{st.session_state.catalog_label}",
705
+ )
706
+
707
+ # Routing / embeddings / max-iterations are intentionally NOT overridden here β€” they come from .env
708
+ # (ENV already carries them). Only the per-session fields the sidebar still exposes are overridden.
709
+ config = ENV.override(
710
+ base_url=base_url, api_key=api_key, generator_model=generator_model, judge_model=judge_model,
711
+ judge_enabled=bool(judge_enabled), synthesis_model=synthesis_model,
712
+ max_response_tokens=int(max_response_tokens), model_pricing=pricing,
713
+ )
714
+ for issue in config.problems():
715
+ st.sidebar.warning(issue)
716
+
717
+ _clear_chat, _clear_cache = st.sidebar.columns(2)
718
+ if _clear_chat.button("πŸ—‘οΈ Clear chat", help="Remove the visible conversation."):
719
+ st.session_state.pop("messages", None)
720
+ if _clear_cache.button("⚑ Clear cache", help="Empty the in-memory answer cache so every question "
721
+ "re-runs fresh (the cache lives in memory only β€” there's no file)."):
722
+ from api_agent.agent import clear_query_cache
723
+ st.sidebar.success(f"Cleared {clear_query_cache()} cached answer(s).")
724
+
725
+ # One agent per BACKEND FAMILY when both are loaded (warehouse first β€” ties prefer the
726
+ # direct-SQL path); the Supervisor routes each question to ONE of them and falls back to
727
+ # the other if the chosen source is unreachable. A single family keeps today's one-agent path.
728
+ _sql_specs = [s for s in st.session_state.specs
729
+ if any(t.backend == "sql" for t in s["catalog"].tools.values())]
730
+ _http_specs = [s for s in st.session_state.specs if s not in _sql_specs]
731
+ if _sql_specs and _http_specs:
732
+ def _family_agent(specs):
733
+ return Agent(config, merge_catalogs([(s["label"], s["catalog"]) for s in specs]))
734
+ agent = Supervisor(config, [("warehouse", _family_agent(_sql_specs)),
735
+ ("api", _family_agent(_http_specs))])
736
+ else:
737
+ agent = Agent(config, catalog)
738
+
739
+ # --------------------------------------------------------------------------- #
740
+ # Main: chat
741
+ # --------------------------------------------------------------------------- #
742
+ st.title("πŸ›°οΈ API Agent")
743
+ st.caption(
744
+ "Load one or more specs in the sidebar, then ask a question β€” the agent picks the right "
745
+ "operations across all loaded specs, calls them, and answers with citations + a faithfulness check."
746
+ )
747
+
748
+ if "messages" not in st.session_state:
749
+ st.session_state.messages = []
750
+
751
+ if not st.session_state.messages:
752
+ with st.chat_message("assistant"):
753
+ st.markdown(greeting_message())
754
+
755
+ for m in st.session_state.messages:
756
+ with st.chat_message(m["role"]):
757
+ st.markdown(_md(m["content"]))
758
+ if m.get("steps"): # the live-status log, kept (collapsed) so it doesn't vanish on completion
759
+ with st.expander("βš™οΈ Steps the agent took", expanded=False):
760
+ st.markdown(m["steps"])
761
+ if m.get("result") is not None:
762
+ _render_trend_chart(m["result"]) # above the metadata, below the answer it illustrates
763
+ render_result(m["result"])
764
+
765
+ # A response can take many seconds. Rather than block the whole script (which would freeze the
766
+ # page and make a Stop button unclickable), the agent runs in a BACKGROUND THREAD that pushes
767
+ # progress events onto a queue; the main script polls that queue on a short auto-rerun and can set
768
+ # a cancel Event the agent checks at each step. So the user sees live steps AND can hit Stop.
769
+ _STAGE_ICONS = {
770
+ "cache": "⚑", "route": "🧭", "intent": "πŸ›‘οΈ", "doc": "πŸ“–",
771
+ "call": "πŸ”§", "complete": "βž•", "synthesize": "✍️", "validate": "πŸ”",
772
+ "retry": "♻️", "abstain": "⚠️", "budget": "πŸ›‘",
773
+ }
774
+ _SPINNER = "◐◓◑◒" # rotates once per poll β†’ a clearly visible "still working" indicator (no flicker)
775
+ # Pin the viewport to the NEW question so the long run doesn't look like it answered the old one.
776
+ _SCROLL_PIN = """<script>
777
+ (function () {
778
+ const doc = window.parent.document;
779
+ let n = 0;
780
+ function pin() {
781
+ const msgs = doc.querySelectorAll('[data-testid="stChatMessage"]');
782
+ const t = msgs[msgs.length - 2] || msgs[msgs.length - 1]; // the new USER question
783
+ if (t) t.scrollIntoView({block: "start", behavior: "instant"});
784
+ if (++n < 12) setTimeout(pin, 250);
785
+ }
786
+ pin();
787
+ })();
788
+ </script>"""
789
+
790
+
791
+ def _drain(run) -> bool:
792
+ """Move all queued progress events into run['events']; return True once the worker is done."""
793
+ done = False
794
+ while True:
795
+ try:
796
+ kind, payload = run["queue"].get_nowait()
797
+ except queue.Empty:
798
+ break
799
+ if kind == "done":
800
+ done = True
801
+ else:
802
+ run["events"].append(payload)
803
+ return done
804
+
805
+
806
+ def _events_summary(run) -> tuple[str, str, str, str]:
807
+ """Fold the queued progress events into (current-action header, step-log markdown, token line,
808
+ streaming answer). Rendered as plain markdown so the live view updates IN PLACE β€” unlike an
809
+ `st.status` expander, which replays an open/close animation on every poll (the flicker)."""
810
+ header, tokens, answer = "Starting…", "", ""
811
+ lines: list[str] = []
812
+ for ev in run["events"]:
813
+ stage = ev.get("stage", "")
814
+ if stage == "routed":
815
+ continue
816
+ if stage == "usage":
817
+ cost = ev.get("cost")
818
+ tail = f" Β· πŸ’²${cost:.4f}" if cost is not None else ""
819
+ tokens = (f"πŸ”’ {ev.get('total', 0):,} tokens "
820
+ f"(in {ev.get('prompt', 0):,} Β· out {ev.get('completion', 0):,}){tail}")
821
+ continue
822
+ msg = ev.get("message", "")
823
+ if stage == "answer":
824
+ answer = msg
825
+ elif stage == "call_done":
826
+ lines.append(f"- {'βœ“' if ev.get('ok') else 'βœ—'} {msg}")
827
+ else:
828
+ header = msg
829
+ lines.append(f"- {_STAGE_ICONS.get(stage, 'β€’')} {msg}")
830
+ return header, "\n".join(lines), tokens, answer
831
+
832
+
833
+ def _start_run(the_agent, prompt, history, enabled_tools) -> None:
834
+ """Kick off agent.run in a daemon thread. The thread NEVER touches st.* β€” it only pushes events
835
+ onto a thread-safe queue and stashes the result; the main script renders on its poll reruns."""
836
+ q: queue.Queue = queue.Queue()
837
+ cancel = threading.Event()
838
+ holder: dict = {}
839
+
840
+ def worker():
841
+ try:
842
+ holder["result"] = the_agent.run(
843
+ prompt, history=history, enabled_tools=enabled_tools,
844
+ progress=lambda ev: q.put(("ev", ev)), cancel_check=cancel.is_set)
845
+ except Exception as exc: # pragma: no cover β€” surfaced in the UI on the next poll
846
+ holder["error"] = str(exc)
847
+ finally:
848
+ q.put(("done", None))
849
+
850
+ threading.Thread(target=worker, daemon=True).start()
851
+ st.session_state.run = {"queue": q, "cancel": cancel, "holder": holder,
852
+ "prompt": prompt, "events": []}
853
+
854
+
855
+ run_active = bool(st.session_state.get("run"))
856
+ prompt = st.chat_input("Ask a question about the loaded API…", disabled=run_active)
857
+ if prompt and not run_active:
858
+ st.session_state.messages.append({"role": "user", "content": prompt})
859
+ history = [
860
+ {"role": m["role"], "content": m["content"]}
861
+ for m in st.session_state.messages[:-1]
862
+ if m["role"] in ("user", "assistant")
863
+ ]
864
+ _start_run(agent, prompt, history, enabled)
865
+ st.rerun()
866
+
867
+ @st.fragment(run_every=0.3)
868
+ def _render_active_run() -> None:
869
+ """Render the in-flight run's live progress. Runs as a FRAGMENT, so only THIS bubble reloads
870
+ every 0.5s to poll the background worker β€” the sidebar and chat history don't re-render. The
871
+ progress is drawn as plain markdown inside a static bordered box (NOT an `st.status` expander),
872
+ so a poll updates the text in place instead of animating a dropdown open/closed. On completion it
873
+ does one full ``st.rerun()`` to return to the idle state (which re-enables the chat input)."""
874
+ run = st.session_state.get("run")
875
+ if not run:
876
+ return
877
+ done = _drain(run)
878
+ run["polls"] = run.get("polls", 0) + 1
879
+ spin = _SPINNER[run["polls"] % len(_SPINNER)] # advances each poll β†’ a live "working" spinner
880
+ header, log_md, tokens, answer = _events_summary(run)
881
+ with st.container(border=True):
882
+ head = "βœ… Done" if done else f"{spin} Working β€” {header}"
883
+ st.markdown(f"**{head}** \n_{run['prompt'][:80]}_")
884
+ if log_md:
885
+ st.markdown(log_md)
886
+ if not done:
887
+ if tokens:
888
+ st.caption(tokens)
889
+ if answer:
890
+ st.markdown(_md(answer) + " β–Œ") # the answer streaming in as it's composed
891
+ # Stop is IMMEDIATE: flag cancel (so the worker makes no further calls) and ABANDON the run
892
+ # right now β€” don't wait for the in-flight call to return. Back to idle instantly with a
893
+ # "Stopped" message; the orphaned daemon thread finishes its current call and is discarded.
894
+ if st.button("⏹️ Stop", key="stop_run", type="primary",
895
+ help="Stop now β€” no further API/LLM calls."):
896
+ run["cancel"].set()
897
+ st.session_state.pop("run", None)
898
+ st.session_state.messages.append(
899
+ {"role": "assistant", "content": "⏹️ **Stopped.**", "steps": log_md})
900
+ st.rerun()
901
+ return # the fragment auto-reruns in ~0.3s to poll again
902
+ # ---- finished: stash the answer + the step log in history and drop back to the idle state ----
903
+ holder = run["holder"]
904
+ st.session_state.pop("run", None)
905
+ if "error" in holder:
906
+ st.session_state.messages.append(
907
+ {"role": "assistant", "content": f"⚠️ Agent error: {holder['error']}", "steps": log_md})
908
+ else:
909
+ result = holder["result"]
910
+ st.session_state.messages.append(
911
+ {"role": "assistant", "content": result.answer, "result": result, "steps": log_md})
912
+ st.rerun() # full rerun: re-enable the chat input and render the answer from history
913
+
914
+
915
+ if st.session_state.get("run"):
916
+ with st.chat_message("assistant"):
917
+ components.html(_SCROLL_PIN, height=0)
918
+ _render_active_run()