96kevinli29 Claude Opus 4.8 commited on
Commit
c7cd508
·
1 Parent(s): 91a3b45

Web: live streaming — show running cost and what the agent is doing in real time

Browse files

Adds a streaming solve path so the UI is transparent *during* the run, not just
after:
- MathAgent.solve(on_event=...) emits an event per step (llm turn, tool_start,
tool) carrying the running token/cost usage
- /api/solve_stream streams NDJSON events while the solve runs (thread + queue),
ending with a final "done" payload
- UI consumes the stream: a live meter (tokens · $cost · elapsed · calls) and a
live timeline that fills in as the model reasons and tools run; "working…"
shows the current action
- ui -> studio-v7-stream, badge studio · v7

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

ai4math_demo ADDED
@@ -0,0 +1 @@
 
 
1
+ Subproject commit e6275bc1aacd15849cef746cb4a0d389f367dab9
mathagent/agent/math_agent.py CHANGED
@@ -104,7 +104,11 @@ class MathAgent(BaseAgent):
104
  if usage.get("model"):
105
  t["model"] = usage["model"]
106
 
107
- def solve(self, task: Task) -> Trajectory:
 
 
 
 
108
  messages = [
109
  LLMMessage("system", self._system()),
110
  LLMMessage("user", self._user(task)),
@@ -112,6 +116,14 @@ class MathAgent(BaseAgent):
112
  steps: list[dict] = []
113
  self.last_usage = {}
114
 
 
 
 
 
 
 
 
 
115
  def _traj(output: str) -> Trajectory:
116
  return Trajectory(
117
  task_id=task.id,
@@ -131,6 +143,8 @@ class MathAgent(BaseAgent):
131
  {"type": "llm", "turn": turn + 1, "text": text,
132
  "reasoning": reasoning, "usage": resp.usage or {}}
133
  )
 
 
134
 
135
  final = _FINAL_RE.search(text)
136
  if final:
@@ -146,8 +160,10 @@ class MathAgent(BaseAgent):
146
 
147
  results = []
148
  for name, body in calls:
 
149
  out = self.tools.run(name, body)
150
  steps.append({"type": "tool", "tool": name, "input": body, "output": out})
 
151
  results.append(f"<tool:{name}> result:\n{out}")
152
  messages.append(
153
  LLMMessage(
 
104
  if usage.get("model"):
105
  t["model"] = usage["model"]
106
 
107
+ def solve(self, task: Task, on_event=None) -> Trajectory:
108
+ """Solve ``task``. If ``on_event`` is given, it is called with a dict for
109
+ each step as it happens (live streaming): an ``llm`` event after every
110
+ model turn and a ``tool`` event after every tool run, each carrying the
111
+ running token/cost usage so far under ``running``."""
112
  messages = [
113
  LLMMessage("system", self._system()),
114
  LLMMessage("user", self._user(task)),
 
116
  steps: list[dict] = []
117
  self.last_usage = {}
118
 
119
+ def _emit(ev: dict) -> None:
120
+ if on_event:
121
+ ev["running"] = dict(self.last_usage)
122
+ try:
123
+ on_event(ev)
124
+ except Exception:
125
+ pass
126
+
127
  def _traj(output: str) -> Trajectory:
128
  return Trajectory(
129
  task_id=task.id,
 
143
  {"type": "llm", "turn": turn + 1, "text": text,
144
  "reasoning": reasoning, "usage": resp.usage or {}}
145
  )
146
+ _emit({"ev": "step", "type": "llm", "turn": turn + 1,
147
+ "reasoning": reasoning, "usage": resp.usage or {}})
148
 
149
  final = _FINAL_RE.search(text)
150
  if final:
 
160
 
161
  results = []
162
  for name, body in calls:
163
+ _emit({"ev": "step", "type": "tool_start", "tool": name, "input": body})
164
  out = self.tools.run(name, body)
165
  steps.append({"type": "tool", "tool": name, "input": body, "output": out})
166
+ _emit({"ev": "step", "type": "tool", "tool": name, "input": body, "output": out})
167
  results.append(f"<tool:{name}> result:\n{out}")
168
  messages.append(
169
  LLMMessage(
mathagent/web/server.py CHANGED
@@ -20,7 +20,7 @@ import uuid
20
  from pathlib import Path
21
 
22
  from fastapi import FastAPI
23
- from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
24
  from fastapi.staticfiles import StaticFiles
25
  from pydantic import BaseModel
26
 
@@ -157,7 +157,7 @@ def create_app(
157
  return JSONResponse(
158
  {
159
  "status": "ok",
160
- "ui": "studio-v6-katex",
161
  "provider": cfg["provider_kind"],
162
  "model": _resolved_model(),
163
  "python": cfg["enable_python"],
@@ -212,6 +212,62 @@ def create_app(
212
  }
213
  )
214
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
  return app
216
 
217
 
 
20
  from pathlib import Path
21
 
22
  from fastapi import FastAPI
23
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse
24
  from fastapi.staticfiles import StaticFiles
25
  from pydantic import BaseModel
26
 
 
157
  return JSONResponse(
158
  {
159
  "status": "ok",
160
+ "ui": "studio-v7-stream",
161
  "provider": cfg["provider_kind"],
162
  "model": _resolved_model(),
163
  "python": cfg["enable_python"],
 
212
  }
213
  )
214
 
215
+ @app.post("/api/solve_stream")
216
+ async def solve_stream(req: SolveRequest) -> StreamingResponse:
217
+ """Live streaming: emits one NDJSON line per agent step (with running
218
+ token/cost usage), then a final ``done`` line with the full result."""
219
+ import asyncio
220
+ import queue
221
+ import threading
222
+
223
+ cfg = app.state.cfg
224
+ prov = build_provider(req.provider or cfg["provider_kind"], model=req.model or cfg["model"])
225
+ py = cfg["enable_python"] if req.enable_python is None else req.enable_python
226
+ ln = cfg["enable_lean"] if req.enable_lean is None else req.enable_lean
227
+ tools = default_registry(python=py, lean=ln)
228
+ agent = MathAgent(cfg["workspace"], prov, tools=tools, max_steps=req.max_steps)
229
+ model = req.model or _resolved_model()
230
+
231
+ q: queue.Queue = queue.Queue()
232
+
233
+ def run() -> None:
234
+ t0 = time.time()
235
+ try:
236
+ traj = agent.solve(
237
+ Task(id=uuid.uuid4().hex[:8], input=req.problem), on_event=q.put
238
+ )
239
+ files: dict[str, str] = {}
240
+ if req.format != "none":
241
+ name = f"sol-{uuid.uuid4().hex[:8]}"
242
+ produced = export(traj.output, cfg["out_dir"], name=name, fmt=req.format)
243
+ for kind, p in produced.items():
244
+ if kind in ("tex", "pdf", "docx"):
245
+ files[kind] = f"/files/{Path(p).name}"
246
+ q.put({"ev": "done", "result": {
247
+ "solution": traj.output, "answer": extract_boxed(traj.output),
248
+ "files": files, "model": model, "elapsed_s": round(time.time() - t0, 2),
249
+ "usage": getattr(agent, "last_usage", {}), "steps": traj.steps,
250
+ }})
251
+ except Exception as e:
252
+ q.put({"ev": "error", "error": f"{type(e).__name__}: {e}"})
253
+ finally:
254
+ q.put(None)
255
+
256
+ threading.Thread(target=run, daemon=True).start()
257
+
258
+ async def gen():
259
+ loop = asyncio.get_event_loop()
260
+ while True:
261
+ ev = await loop.run_in_executor(None, q.get)
262
+ if ev is None:
263
+ break
264
+ yield json.dumps(ev) + "\n"
265
+
266
+ return StreamingResponse(
267
+ gen(), media_type="application/x-ndjson",
268
+ headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
269
+ )
270
+
271
  return app
272
 
273
 
mathagent/web/static/index.html CHANGED
@@ -118,7 +118,7 @@
118
  <div class="logo"><span class="mark">∑</span> mathagent</div>
119
  <span class="tagline">math solving &amp; proofs</span>
120
  <div class="right">
121
- <span class="badge">studio · v6</span>
122
  <span class="dot" title="online"></span>
123
  <span id="cfg">…</span>
124
  </div>
@@ -304,23 +304,62 @@ function addHistory(d){
304
  `<div class="it" data-h="${i}"><div class="q">${escapeHtml(h.problem)}</div><div class="a">${escapeHtml(h.answer||'—')} · ${escapeHtml((h.model||'').replace('openrouter/',''))}</div></div>`).join('');
305
  $('history').querySelectorAll('[data-h]').forEach(el=>el.addEventListener('click',()=>renderResult(state.history[+el.dataset.h])));
306
  }
 
 
 
 
 
 
307
  async function solve(){
308
  const problem=$('problem').value.trim(); if(!problem) return;
309
  const btn=$('go'); btn.disabled=true;
310
  $('emptyState').classList.add('hidden');
311
  const body=$('resultBody'); body.classList.remove('hidden');
312
  const t0=Date.now();
313
- body.innerHTML=`<div style="color:var(--muted);padding:8px 2px"><span class="spinner"></span> &nbsp;solving… <span id="el">0.0s</span></div>`;
314
- const tick=setInterval(()=>{const e=$('el'); if(e) e.textContent=((Date.now()-t0)/1000).toFixed(1)+'s';},100);
 
 
 
 
 
 
 
 
 
 
315
  try{
316
  const req={problem, format:$('format').value, enable_python:$('python').checked, enable_lean:$('lean').checked};
317
  let m=$('model').value; if(m==='__custom__') m=$('modelCustom').value.trim(); if(m) req.model=m;
318
- const r=await fetch('/api/solve',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(req)});
319
- const d=await r.json(); clearInterval(tick);
320
- if(!r.ok||d.error){ body.innerHTML=`<div class="err">${escapeHtml(d.error||('HTTP '+r.status))}</div>`; return; }
321
- d.problem=problem; renderResult(d); addHistory(d);
322
- }catch(e){ clearInterval(tick); body.innerHTML=`<div class="err">${escapeHtml(e)}</div>`; }
323
- finally{ btn.disabled=false; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
324
  }
325
  $('go').addEventListener('click', solve);
326
  $('problem').addEventListener('keydown', e=>{ if((e.metaKey||e.ctrlKey)&&e.key==='Enter') solve(); });
 
118
  <div class="logo"><span class="mark">∑</span> mathagent</div>
119
  <span class="tagline">math solving &amp; proofs</span>
120
  <div class="right">
121
+ <span class="badge">studio · v7</span>
122
  <span class="dot" title="online"></span>
123
  <span id="cfg">…</span>
124
  </div>
 
304
  `<div class="it" data-h="${i}"><div class="q">${escapeHtml(h.problem)}</div><div class="a">${escapeHtml(h.answer||'—')} · ${escapeHtml((h.model||'').replace('openrouter/',''))}</div></div>`).join('');
305
  $('history').querySelectorAll('[data-h]').forEach(el=>el.addEventListener('click',()=>renderResult(state.history[+el.dataset.h])));
306
  }
307
+ function liveMeterHtml(run, t0){
308
+ const u=run||{};
309
+ return `<b>${u.total_tokens||0}</b> tok &nbsp;·&nbsp; <b>$${Number(u.cost_usd||0).toFixed(4)}</b>`
310
+ + ` &nbsp;·&nbsp; <b>${((Date.now()-t0)/1000).toFixed(1)}s</b>`
311
+ + ` &nbsp;·&nbsp; ${u.llm_calls||0} calls`;
312
+ }
313
  async function solve(){
314
  const problem=$('problem').value.trim(); if(!problem) return;
315
  const btn=$('go'); btn.disabled=true;
316
  $('emptyState').classList.add('hidden');
317
  const body=$('resultBody'); body.classList.remove('hidden');
318
  const t0=Date.now();
319
+ let run={};
320
+ body.innerHTML=`
321
+ <div class="answer-banner" style="background:var(--surface2);border-color:var(--line)">
322
+ <div><div class="lab">working…</div><div class="val"><span class="spinner"></span> <span id="liveAct">starting…</span></div></div>
323
+ <span class="verified" id="liveMeter" style="background:var(--surface);color:var(--muted);border-color:var(--line)">0 tok · $0.0000 · 0.0s</span>
324
+ </div>
325
+ <div class="timeline" id="liveTl"></div>`;
326
+ const liveSteps=[];
327
+ const tick=setInterval(()=>{const e=$('liveMeter'); if(e) e.innerHTML=liveMeterHtml(run,t0);},100);
328
+ const setAct=t=>{const e=$('liveAct'); if(e) e.textContent=t;};
329
+ const redraw=()=>{const e=$('liveTl'); if(e){ e.innerHTML=timelineHtml(liveSteps); typeset(e);} };
330
+
331
  try{
332
  const req={problem, format:$('format').value, enable_python:$('python').checked, enable_lean:$('lean').checked};
333
  let m=$('model').value; if(m==='__custom__') m=$('modelCustom').value.trim(); if(m) req.model=m;
334
+ const r=await fetch('/api/solve_stream',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(req)});
335
+ if(!r.ok||!r.body){ const d=await r.json().catch(()=>({})); throw new Error(d.error||('HTTP '+r.status)); }
336
+ const reader=r.body.getReader(); const dec=new TextDecoder(); let buf='';
337
+ let finished=false;
338
+ while(!finished){
339
+ const {value,done}=await reader.read(); if(done) break;
340
+ buf+=dec.decode(value,{stream:true});
341
+ let nl;
342
+ while((nl=buf.indexOf('\n'))>=0){
343
+ const line=buf.slice(0,nl).trim(); buf=buf.slice(nl+1);
344
+ if(!line) continue;
345
+ const ev=JSON.parse(line);
346
+ if(ev.ev==='step'){
347
+ if(ev.running) run=ev.running;
348
+ if(ev.type==='llm'){ setAct(`step ${ev.turn}: reasoning`); liveSteps.push(ev); redraw(); }
349
+ else if(ev.type==='tool_start'){ setAct(`running ${ev.tool}…`); }
350
+ else if(ev.type==='tool'){ setAct(`${ev.tool} done`); liveSteps.push(ev); redraw(); }
351
+ $('liveMeter').innerHTML=liveMeterHtml(run,t0);
352
+ } else if(ev.ev==='done'){
353
+ clearInterval(tick); finished=true;
354
+ const d=ev.result; d.problem=problem; renderResult(d); addHistory(d);
355
+ } else if(ev.ev==='error'){
356
+ clearInterval(tick); finished=true;
357
+ body.innerHTML=`<div class="err">${escapeHtml(ev.error)}</div>`;
358
+ }
359
+ }
360
+ }
361
+ }catch(e){ clearInterval(tick); body.innerHTML=`<div class="err">${escapeHtml(e.message||e)}</div>`; }
362
+ finally{ clearInterval(tick); btn.disabled=false; }
363
  }
364
  $('go').addEventListener('click', solve);
365
  $('problem').addEventListener('keydown', e=>{ if((e.metaKey||e.ctrlKey)&&e.key==='Enter') solve(); });