tracellm-cli 0.1.0__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.
- app/__init__.py +1 -0
- app/database/__init__.py +1 -0
- app/database/mongodb.py +94 -0
- app/database/project_service.py +97 -0
- app/database/trace_service.py +417 -0
- app/main.py +44 -0
- app/models/__init__.py +14 -0
- app/models/health.py +5 -0
- app/models/project.py +32 -0
- app/models/trace.py +71 -0
- app/models/trace_model.py +62 -0
- app/routes/__init__.py +1 -0
- app/routes/health.py +10 -0
- app/routes/observability.py +60 -0
- app/routes/projects.py +25 -0
- app/websocket/__init__.py +1 -0
- app/websocket/socket.py +64 -0
- sdk/__init__.py +3 -0
- sdk/tracer.py +8 -0
- tracellm/__init__.py +6 -0
- tracellm/banner.py +34 -0
- tracellm/cli.py +124 -0
- tracellm/db.py +75 -0
- tracellm/exporter.py +65 -0
- tracellm/integrations/__init__.py +4 -0
- tracellm/integrations/langchain.py +186 -0
- tracellm/integrations/openai.py +234 -0
- tracellm/integrations/tool_tracer.py +151 -0
- tracellm/mascot.py +49 -0
- tracellm/monitor.py +381 -0
- tracellm/palette.py +186 -0
- tracellm/replay.py +80 -0
- tracellm/startup.py +121 -0
- tracellm/summary.py +53 -0
- tracellm/trace_stream.py +68 -0
- tracellm/tracer.py +598 -0
- tracellm/tree_renderer.py +78 -0
- tracellm/utils.py +390 -0
- tracellm_cli-0.1.0.dist-info/METADATA +30 -0
- tracellm_cli-0.1.0.dist-info/RECORD +43 -0
- tracellm_cli-0.1.0.dist-info/WHEEL +5 -0
- tracellm_cli-0.1.0.dist-info/entry_points.txt +2 -0
- tracellm_cli-0.1.0.dist-info/top_level.txt +3 -0
tracellm/monitor.py
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""Live monitor dashboard — htop for AI systems.
|
|
2
|
+
|
|
3
|
+
Connects to the backend WebSocket for real-time trace events, falls back to
|
|
4
|
+
polling MongoDB when the server is unavailable, and reconnects automatically
|
|
5
|
+
on disconnect.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import queue
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from rich.live import Live
|
|
19
|
+
from rich.panel import Panel
|
|
20
|
+
from rich.table import Table
|
|
21
|
+
from rich.text import Text
|
|
22
|
+
|
|
23
|
+
from tracellm.mascot import MascotState, message
|
|
24
|
+
from tracellm.utils import console, status_style, latency_style
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
_WS_DEFAULT_HOST = os.environ.get("TRACELLM_WS_HOST", "127.0.0.1")
|
|
29
|
+
_WS_DEFAULT_PORT = int(os.environ.get("TRACELLM_WS_PORT", "8000"))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _discover_ws_endpoint(hint_host: str, hint_port: int) -> tuple[str, int]:
|
|
33
|
+
"""Probe common ports if the hinted endpoint is unreachable.
|
|
34
|
+
|
|
35
|
+
Tries the hint first, then falls through to common alternatives.
|
|
36
|
+
Returns the first port that responds to a TCP connect.
|
|
37
|
+
"""
|
|
38
|
+
import socket
|
|
39
|
+
|
|
40
|
+
def _probe(host: str, port: int) -> bool:
|
|
41
|
+
try:
|
|
42
|
+
with socket.create_connection((host, port), timeout=0.5):
|
|
43
|
+
return True
|
|
44
|
+
except (OSError, socket.error):
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
if _probe(hint_host, hint_port):
|
|
48
|
+
return hint_host, hint_port
|
|
49
|
+
|
|
50
|
+
common_ports = [8000, 8001, 8080, 3000]
|
|
51
|
+
tried = {hint_port}
|
|
52
|
+
for p in common_ports:
|
|
53
|
+
if p not in tried:
|
|
54
|
+
tried.add(p)
|
|
55
|
+
if _probe(hint_host, p):
|
|
56
|
+
logger.info("Auto-discovered WS endpoint on port %d", p)
|
|
57
|
+
return hint_host, p
|
|
58
|
+
|
|
59
|
+
return hint_host, hint_port
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ── WebSocket background listener ──────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _ws_listener(
|
|
66
|
+
host: str,
|
|
67
|
+
port: int,
|
|
68
|
+
event_queue: queue.Queue,
|
|
69
|
+
stop_event: threading.Event,
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Background thread: connect to WebSocket and push events to the queue."""
|
|
72
|
+
import asyncio
|
|
73
|
+
|
|
74
|
+
import websockets
|
|
75
|
+
|
|
76
|
+
async def _listen() -> None:
|
|
77
|
+
nonlocal host, port
|
|
78
|
+
discovered_host, discovered_port = _discover_ws_endpoint(host, port)
|
|
79
|
+
host, port = discovered_host, discovered_port
|
|
80
|
+
|
|
81
|
+
uri = f"ws://{host}:{port}/ws"
|
|
82
|
+
retry_delay = 1.0
|
|
83
|
+
|
|
84
|
+
while not stop_event.is_set():
|
|
85
|
+
try:
|
|
86
|
+
async with websockets.connect(uri, ping_interval=20) as ws:
|
|
87
|
+
retry_delay = 1.0
|
|
88
|
+
event_queue.put(("ws_connected", None))
|
|
89
|
+
# Consume the welcome message
|
|
90
|
+
try:
|
|
91
|
+
welcome = await asyncio.wait_for(ws.recv(), timeout=2)
|
|
92
|
+
event_queue.put(("ws_welcome", welcome))
|
|
93
|
+
except asyncio.TimeoutError:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
while not stop_event.is_set():
|
|
97
|
+
try:
|
|
98
|
+
raw = await asyncio.wait_for(ws.recv(), timeout=1)
|
|
99
|
+
data = json.loads(raw)
|
|
100
|
+
if data.get("type") == "trace.created":
|
|
101
|
+
event_queue.put(("trace", data["trace"]))
|
|
102
|
+
except asyncio.TimeoutError:
|
|
103
|
+
continue
|
|
104
|
+
except websockets.ConnectionClosed:
|
|
105
|
+
event_queue.put(("ws_disconnected", None))
|
|
106
|
+
break
|
|
107
|
+
except asyncio.CancelledError:
|
|
108
|
+
break
|
|
109
|
+
except Exception as exc:
|
|
110
|
+
event_queue.put(("ws_error", f"WebSocket: {exc}"))
|
|
111
|
+
if retry_delay >= 2.0:
|
|
112
|
+
event_queue.put(("ws_retry", f"retrying in {retry_delay:.0f}s"))
|
|
113
|
+
|
|
114
|
+
if not stop_event.is_set():
|
|
115
|
+
await asyncio.sleep(retry_delay)
|
|
116
|
+
retry_delay = min(retry_delay * 2, 30.0)
|
|
117
|
+
|
|
118
|
+
asyncio.run(_listen())
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ── Stats computation ──────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _compute_stats(traces: list[Any]) -> dict[str, Any]:
|
|
125
|
+
total = len(traces)
|
|
126
|
+
completed = sum(1 for t in traces if getattr(t, "status", "") == "success")
|
|
127
|
+
errors = sum(1 for t in traces if getattr(t, "status", "") in ("failed", "warning"))
|
|
128
|
+
latencies = [float(t.latency) for t in traces if hasattr(t, "latency") and t.latency is not None]
|
|
129
|
+
avg_latency = sum(latencies) / len(latencies) if latencies else 0.0
|
|
130
|
+
sorted_lats = sorted(latencies)
|
|
131
|
+
p95 = sorted_lats[int(len(sorted_lats) * 0.95)] if sorted_lats else 0.0
|
|
132
|
+
total_tokens = sum(int(t.token_count) for t in traces if hasattr(t, "token_count") and t.token_count is not None)
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
"total": total,
|
|
136
|
+
"completed": completed,
|
|
137
|
+
"errors": errors,
|
|
138
|
+
"avg_latency": avg_latency,
|
|
139
|
+
"p95_latency": p95,
|
|
140
|
+
"total_tokens": total_tokens,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ── Dashboard rendering ────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _render_dashboard(
|
|
148
|
+
stats: dict[str, Any],
|
|
149
|
+
traces: list[Any],
|
|
150
|
+
refresh: float,
|
|
151
|
+
seen_count: int,
|
|
152
|
+
ws_status: str,
|
|
153
|
+
ws_detail: str,
|
|
154
|
+
polling: bool,
|
|
155
|
+
) -> Panel:
|
|
156
|
+
status_line = message("Monitor active", MascotState.LOADING)
|
|
157
|
+
|
|
158
|
+
stats_table = Table.grid(padding=(0, 3))
|
|
159
|
+
stats_table.add_column(style="bright_black", width=20)
|
|
160
|
+
stats_table.add_column(style="white")
|
|
161
|
+
|
|
162
|
+
stats_table.add_row("Total Traces", str(stats["total"]))
|
|
163
|
+
stats_table.add_row("Completed", f"[green]{stats['completed']}[/green]")
|
|
164
|
+
stats_table.add_row("Errors", f"[red]{stats['errors']}[/red]")
|
|
165
|
+
stats_table.add_row("Avg Latency", f"{stats['avg_latency']:.0f} ms")
|
|
166
|
+
stats_table.add_row("P95 Latency", f"[yellow]{stats['p95_latency']:.0f} ms[/yellow]")
|
|
167
|
+
stats_table.add_row("Total Tokens", f"{stats['total_tokens']:,}")
|
|
168
|
+
|
|
169
|
+
if ws_status:
|
|
170
|
+
stats_table.add_row("WebSocket", ws_status)
|
|
171
|
+
if ws_detail:
|
|
172
|
+
stats_table.add_row("", f"[bright_black]{ws_detail}[/bright_black]")
|
|
173
|
+
|
|
174
|
+
traces_table = Table(box=None, padding=(0, 2), header_style="dim")
|
|
175
|
+
traces_table.add_column("Time", width=8)
|
|
176
|
+
traces_table.add_column("Status", width=8, justify="center")
|
|
177
|
+
traces_table.add_column("Model", width=16, no_wrap=True)
|
|
178
|
+
traces_table.add_column("Latency", justify="right", width=10)
|
|
179
|
+
traces_table.add_column("Tokens", justify="right", width=8)
|
|
180
|
+
traces_table.add_column("Steps", justify="right", width=5)
|
|
181
|
+
traces_table.add_column("Prompt", width=36)
|
|
182
|
+
|
|
183
|
+
for t in traces:
|
|
184
|
+
ts = t.created_at.strftime("%H:%M:%S") if hasattr(t.created_at, "strftime") else str(t.created_at)[11:19]
|
|
185
|
+
lat = float(t.latency) if hasattr(t, "latency") and t.latency is not None else 0.0
|
|
186
|
+
traces_table.add_row(
|
|
187
|
+
ts,
|
|
188
|
+
f"[{status_style(t.status)}]{t.status.upper()}[/]",
|
|
189
|
+
str(getattr(t, "model_name", "?") or "?")[:16],
|
|
190
|
+
f"[{latency_style(lat)}]{lat:.0f}ms[/]",
|
|
191
|
+
str(getattr(t, "token_count", 0) or 0),
|
|
192
|
+
str(len(getattr(t, "steps", []) or [])),
|
|
193
|
+
str(getattr(t, "prompt", "") or "")[:36],
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
stats_panel = Panel(
|
|
197
|
+
stats_table,
|
|
198
|
+
title="\U0001f4ca Overview",
|
|
199
|
+
border_style="bright_black",
|
|
200
|
+
padding=(1, 2),
|
|
201
|
+
)
|
|
202
|
+
traces_panel = Panel(
|
|
203
|
+
traces_table,
|
|
204
|
+
title=f"\U0001f4cb Recent Traces ({seen_count} unique)",
|
|
205
|
+
border_style="bright_black",
|
|
206
|
+
padding=(1, 2),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
body = Table.grid(padding=(0, 1))
|
|
210
|
+
body.add_column()
|
|
211
|
+
body.add_row(status_line)
|
|
212
|
+
body.add_row(Text(""))
|
|
213
|
+
body.add_row(stats_panel)
|
|
214
|
+
body.add_row(Text(""))
|
|
215
|
+
body.add_row(traces_panel)
|
|
216
|
+
|
|
217
|
+
subtitle_parts = [f"Ctrl+C to stop"]
|
|
218
|
+
if polling:
|
|
219
|
+
subtitle_parts.append(f"polling every {refresh}s")
|
|
220
|
+
else:
|
|
221
|
+
subtitle_parts.append("live via WebSocket")
|
|
222
|
+
subtitle = " \u2014 ".join(subtitle_parts)
|
|
223
|
+
|
|
224
|
+
return Panel(
|
|
225
|
+
body,
|
|
226
|
+
title="TraceLLM Monitor",
|
|
227
|
+
subtitle=subtitle,
|
|
228
|
+
border_style="bright_black",
|
|
229
|
+
padding=(1, 2),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ── Main entry point ───────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def run_monitor(
|
|
237
|
+
refresh: float = 2.0,
|
|
238
|
+
limit: int = 10,
|
|
239
|
+
ws_host: str = _WS_DEFAULT_HOST,
|
|
240
|
+
ws_port: int = _WS_DEFAULT_PORT,
|
|
241
|
+
) -> None:
|
|
242
|
+
"""Run the live monitor dashboard.
|
|
243
|
+
|
|
244
|
+
Connects to the TraceLLM backend WebSocket for real-time trace events.
|
|
245
|
+
Falls back to polling MongoDB when the server is unavailable.
|
|
246
|
+
Reconnects automatically on disconnect.
|
|
247
|
+
|
|
248
|
+
When WebSocket is connected, polling is suspended and only live events
|
|
249
|
+
drive the display for minimal DB overhead.
|
|
250
|
+
"""
|
|
251
|
+
from tracellm.db import fetch_recent_traces
|
|
252
|
+
|
|
253
|
+
seen: set[str] = set()
|
|
254
|
+
event_queue: queue.Queue = queue.Queue()
|
|
255
|
+
stop_event = threading.Event()
|
|
256
|
+
ws_status = ""
|
|
257
|
+
ws_detail = ""
|
|
258
|
+
ws_connected = False
|
|
259
|
+
has_ever_connected = False
|
|
260
|
+
needs_initial_fetch = True
|
|
261
|
+
|
|
262
|
+
console.print()
|
|
263
|
+
console.print(message("Monitor starting...", MascotState.LOADING))
|
|
264
|
+
console.print()
|
|
265
|
+
|
|
266
|
+
# Start WebSocket listener in background thread
|
|
267
|
+
ws_thread = threading.Thread(
|
|
268
|
+
target=_ws_listener,
|
|
269
|
+
args=(ws_host, ws_port, event_queue, stop_event),
|
|
270
|
+
daemon=True,
|
|
271
|
+
)
|
|
272
|
+
ws_thread.start()
|
|
273
|
+
|
|
274
|
+
# Local cache of traces seen via WebSocket events
|
|
275
|
+
ws_traces: list[Any] = []
|
|
276
|
+
db_traces: list[Any] = []
|
|
277
|
+
|
|
278
|
+
with Live(console=console, refresh_per_second=4, screen=True) as live:
|
|
279
|
+
try:
|
|
280
|
+
while True:
|
|
281
|
+
# Drain queued WebSocket events
|
|
282
|
+
while not event_queue.empty():
|
|
283
|
+
try:
|
|
284
|
+
evt_type, evt_data = event_queue.get_nowait()
|
|
285
|
+
if evt_type == "ws_connected":
|
|
286
|
+
ws_status = "[green]\u25cf Connected[/green]"
|
|
287
|
+
ws_detail = ""
|
|
288
|
+
ws_connected = True
|
|
289
|
+
has_ever_connected = True
|
|
290
|
+
elif evt_type == "ws_welcome":
|
|
291
|
+
pass
|
|
292
|
+
elif evt_type == "ws_disconnected":
|
|
293
|
+
ws_status = "[yellow]\u25cf Disconnected[/yellow]"
|
|
294
|
+
ws_detail = "reconnecting..."
|
|
295
|
+
ws_connected = False
|
|
296
|
+
elif evt_type == "ws_error":
|
|
297
|
+
if not has_ever_connected:
|
|
298
|
+
ws_status = "[yellow]\u25cf Unavailable[/yellow]"
|
|
299
|
+
ws_detail = "polling MongoDB"
|
|
300
|
+
else:
|
|
301
|
+
ws_status = "[yellow]\u25cf Error[/yellow]"
|
|
302
|
+
ws_detail = str(evt_data)[:40]
|
|
303
|
+
ws_connected = False
|
|
304
|
+
elif evt_type == "ws_retry":
|
|
305
|
+
ws_status = "[yellow]\u25cf Reconnecting[/yellow]"
|
|
306
|
+
ws_detail = str(evt_data)
|
|
307
|
+
ws_connected = False
|
|
308
|
+
elif evt_type == "trace":
|
|
309
|
+
# Build a lightweight object so _compute_stats can read it
|
|
310
|
+
t = _make_trace_obj(evt_data)
|
|
311
|
+
if t.trace_id not in seen:
|
|
312
|
+
seen.add(t.trace_id)
|
|
313
|
+
ws_traces.insert(0, t)
|
|
314
|
+
ws_traces = ws_traces[:limit]
|
|
315
|
+
except queue.Empty:
|
|
316
|
+
break
|
|
317
|
+
|
|
318
|
+
# Decide whether to poll MongoDB or use live data
|
|
319
|
+
if needs_initial_fetch or not ws_connected:
|
|
320
|
+
try:
|
|
321
|
+
db_traces = fetch_recent_traces(limit=limit)
|
|
322
|
+
for t in db_traces:
|
|
323
|
+
seen.add(t.trace_id)
|
|
324
|
+
needs_initial_fetch = False
|
|
325
|
+
except Exception as exc:
|
|
326
|
+
live.update(Panel(
|
|
327
|
+
f"[yellow]DB poll error: {exc}[/yellow]\n"
|
|
328
|
+
f"[bright_black]will retry in {refresh}s[/bright_black]",
|
|
329
|
+
title="Monitor",
|
|
330
|
+
border_style="bright_black",
|
|
331
|
+
))
|
|
332
|
+
time.sleep(refresh)
|
|
333
|
+
continue
|
|
334
|
+
|
|
335
|
+
# Use WS traces when connected, DB traces otherwise
|
|
336
|
+
display_traces = ws_traces if (ws_connected and has_ever_connected and ws_traces) else db_traces
|
|
337
|
+
polling = not ws_connected or not has_ever_connected
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
stats = _compute_stats(display_traces)
|
|
341
|
+
live.update(_render_dashboard(
|
|
342
|
+
stats, display_traces, refresh, len(seen),
|
|
343
|
+
ws_status, ws_detail, polling,
|
|
344
|
+
))
|
|
345
|
+
except Exception as exc:
|
|
346
|
+
live.update(Panel(
|
|
347
|
+
f"[yellow]Render error: {exc}[/yellow]",
|
|
348
|
+
title="Monitor",
|
|
349
|
+
border_style="bright_black",
|
|
350
|
+
))
|
|
351
|
+
|
|
352
|
+
# Sleep in smaller increments so we can respond to Ctrl+C quickly
|
|
353
|
+
for _ in range(int(refresh * 4)):
|
|
354
|
+
if stop_event.is_set():
|
|
355
|
+
break
|
|
356
|
+
time.sleep(0.25)
|
|
357
|
+
|
|
358
|
+
except KeyboardInterrupt:
|
|
359
|
+
stop_event.set()
|
|
360
|
+
raise
|
|
361
|
+
|
|
362
|
+
finally:
|
|
363
|
+
stop_event.set()
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _make_trace_obj(data: dict[str, Any]) -> Any:
|
|
367
|
+
"""Convert a trace dict from WebSocket into a simple object compatible with _compute_stats."""
|
|
368
|
+
|
|
369
|
+
class _TraceObj:
|
|
370
|
+
def __init__(self, d: dict[str, Any]) -> None:
|
|
371
|
+
self.trace_id = d.get("trace_id", "")
|
|
372
|
+
self.status = d.get("status", "success")
|
|
373
|
+
self.latency = d.get("latency", 0.0)
|
|
374
|
+
self.token_count = d.get("token_count", 0)
|
|
375
|
+
self.model_name = d.get("model_name", "?")
|
|
376
|
+
self.prompt = d.get("prompt", "")
|
|
377
|
+
self.created_at = d.get("created_at", "")
|
|
378
|
+
steps = d.get("steps", []) or []
|
|
379
|
+
self.steps = steps if isinstance(steps, list) else []
|
|
380
|
+
|
|
381
|
+
return _TraceObj(data)
|
tracellm/palette.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Interactive command palette for TraceLLM CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.align import Align
|
|
10
|
+
from rich.live import Live
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
|
|
15
|
+
from tracellm.mascot import render, MascotState
|
|
16
|
+
|
|
17
|
+
_OPTIONS: list[tuple[str, str, str]] = [
|
|
18
|
+
("\U0001f996 Trace Request", "trace", "Run a traced prompt simulation"),
|
|
19
|
+
("\u21bb Replay Trace", "replay", "Replay a saved trace from MongoDB"),
|
|
20
|
+
("\u21e7 Export Traces", "export", "Export traces to JSON or CSV"),
|
|
21
|
+
("\u25cb Monitor Live", "monitor", "Watch incoming traces in realtime"),
|
|
22
|
+
("\u25b6 Start Stack", "start", "Start backend + dashboard"),
|
|
23
|
+
("\u2699 Create Project", "create-project", "Create a project and API key"),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
_IS_WINDOWS = sys.platform == "win32"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _get_key() -> str:
|
|
30
|
+
"""Read a single keypress from the terminal (cross-platform)."""
|
|
31
|
+
if _IS_WINDOWS:
|
|
32
|
+
return _get_key_windows()
|
|
33
|
+
return _get_key_unix()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _get_key_unix() -> str:
|
|
37
|
+
"""Unix key reader using termios/tty."""
|
|
38
|
+
import termios
|
|
39
|
+
import tty
|
|
40
|
+
fd = sys.stdin.fileno()
|
|
41
|
+
old = termios.tcgetattr(fd)
|
|
42
|
+
try:
|
|
43
|
+
tty.setraw(fd)
|
|
44
|
+
ch = sys.stdin.read(1)
|
|
45
|
+
if ch == "\x1b":
|
|
46
|
+
seq = sys.stdin.read(2)
|
|
47
|
+
return {"[A": "up", "[B": "down", "[C": "right", "[D": "left"}.get(seq, "esc")
|
|
48
|
+
if ch == "\r":
|
|
49
|
+
return "enter"
|
|
50
|
+
if ch == "\x03":
|
|
51
|
+
return "ctrl_c"
|
|
52
|
+
return ch
|
|
53
|
+
finally:
|
|
54
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _get_key_windows() -> str:
|
|
58
|
+
"""Windows key reader using msvcrt."""
|
|
59
|
+
import msvcrt
|
|
60
|
+
ch = msvcrt.getch()
|
|
61
|
+
if ch == b"\xe0":
|
|
62
|
+
seq = msvcrt.getch()
|
|
63
|
+
return {b"H": "up", b"P": "down", b"M": "right", b"K": "left"}.get(seq, "esc")
|
|
64
|
+
if ch in (b"\r", b"\n"):
|
|
65
|
+
return "enter"
|
|
66
|
+
if ch == b"\x03":
|
|
67
|
+
return "ctrl_c"
|
|
68
|
+
if ch == b"q":
|
|
69
|
+
return "q"
|
|
70
|
+
return ch.decode("utf-8", errors="replace")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _render_palette(options: list[tuple[str, str, str]], selected: int) -> Panel:
|
|
74
|
+
"""Render the interactive palette as a Rich Panel."""
|
|
75
|
+
title = Text("TraceLLM Command Palette", style="bold white")
|
|
76
|
+
subtitle = Text("Use arrow keys to navigate, Enter to select, q to quit", style="dim")
|
|
77
|
+
|
|
78
|
+
table = Table.grid(padding=(0, 2))
|
|
79
|
+
table.add_column(style="bright_black", width=2)
|
|
80
|
+
table.add_column(style="white", width=20)
|
|
81
|
+
table.add_column(style="dim")
|
|
82
|
+
|
|
83
|
+
for i, (label, _, desc) in enumerate(options):
|
|
84
|
+
indicator = "\u25b6" if i == selected else " "
|
|
85
|
+
style = "bold cyan" if i == selected else "white"
|
|
86
|
+
table.add_row(indicator, f"[{style}]{label}[/]", f"[bright_black]{desc}[/]")
|
|
87
|
+
|
|
88
|
+
body = Table.grid(padding=(0, 1))
|
|
89
|
+
body.add_column()
|
|
90
|
+
body.add_row(Align.center(title))
|
|
91
|
+
body.add_row(Align.center(subtitle))
|
|
92
|
+
body.add_row(Text(""))
|
|
93
|
+
body.add_row(Align.center(table))
|
|
94
|
+
body.add_row(Text(""))
|
|
95
|
+
body.add_row(Align.center(render(MascotState.IDLE)))
|
|
96
|
+
|
|
97
|
+
return Panel(body, border_style="bright_black", padding=(1, 3))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _prompt_for_trace(console: Any) -> str | None:
|
|
101
|
+
"""Prompt user for a trace prompt. Returns the prompt or None if empty."""
|
|
102
|
+
console.print()
|
|
103
|
+
console.print("[bold]Enter prompt:[/bold]")
|
|
104
|
+
try:
|
|
105
|
+
prompt = input().strip()
|
|
106
|
+
return prompt if prompt else None
|
|
107
|
+
except (EOFError, KeyboardInterrupt):
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def run_palette(app: typer.Typer) -> None:
|
|
112
|
+
"""Show interactive command palette and let the user pick a command."""
|
|
113
|
+
from tracellm.utils import console
|
|
114
|
+
|
|
115
|
+
while True:
|
|
116
|
+
selected = 0
|
|
117
|
+
inline_input = False
|
|
118
|
+
restart = False
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
with Live(console=console, refresh_per_second=20, screen=True) as live:
|
|
122
|
+
while True:
|
|
123
|
+
live.update(_render_palette(_OPTIONS, selected))
|
|
124
|
+
key = _get_key()
|
|
125
|
+
|
|
126
|
+
if key == "up":
|
|
127
|
+
selected = (selected - 1) % len(_OPTIONS)
|
|
128
|
+
elif key == "down":
|
|
129
|
+
selected = (selected + 1) % len(_OPTIONS)
|
|
130
|
+
elif key == "enter":
|
|
131
|
+
cmd = _OPTIONS[selected][1]
|
|
132
|
+
live.stop()
|
|
133
|
+
if cmd == "trace":
|
|
134
|
+
prompt = _prompt_for_trace(console)
|
|
135
|
+
if prompt:
|
|
136
|
+
app(args=["trace", prompt])
|
|
137
|
+
return
|
|
138
|
+
console.print("[yellow]Prompt cannot be empty. Returning to menu...[/yellow]")
|
|
139
|
+
restart = True
|
|
140
|
+
else:
|
|
141
|
+
app(args=[cmd])
|
|
142
|
+
return
|
|
143
|
+
break
|
|
144
|
+
elif key in ("q", "ctrl_c", "esc"):
|
|
145
|
+
live.stop()
|
|
146
|
+
return
|
|
147
|
+
elif key == "fallback":
|
|
148
|
+
inline_input = True
|
|
149
|
+
break
|
|
150
|
+
except Exception:
|
|
151
|
+
inline_input = True
|
|
152
|
+
|
|
153
|
+
if inline_input:
|
|
154
|
+
_run_fallback(app)
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
if not restart:
|
|
158
|
+
break
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _run_fallback(app: typer.Typer) -> None:
|
|
162
|
+
"""Fallback numbered menu when raw terminal input is unavailable."""
|
|
163
|
+
from tracellm.utils import console
|
|
164
|
+
|
|
165
|
+
console.print()
|
|
166
|
+
console.print(Text("TraceLLM Command Palette", style="bold white"))
|
|
167
|
+
console.print()
|
|
168
|
+
for i, (label, _, desc) in enumerate(_OPTIONS, 1):
|
|
169
|
+
console.print(f" [bright_black]{i}.[/] {label} [dim]{desc}[/dim]")
|
|
170
|
+
console.print(f" [bright_black]0.[/] [dim]Quit[/dim]")
|
|
171
|
+
console.print()
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
choice = input(" Select [0-6]: ").strip()
|
|
175
|
+
if choice.isdigit():
|
|
176
|
+
idx = int(choice) - 1
|
|
177
|
+
if 0 <= idx < len(_OPTIONS):
|
|
178
|
+
cmd = _OPTIONS[idx][1]
|
|
179
|
+
if cmd == "trace":
|
|
180
|
+
prompt = input(" Enter prompt: ").strip()
|
|
181
|
+
if prompt:
|
|
182
|
+
app(args=["trace", prompt])
|
|
183
|
+
else:
|
|
184
|
+
app(args=[cmd])
|
|
185
|
+
except (EOFError, KeyboardInterrupt):
|
|
186
|
+
pass
|
tracellm/replay.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from rich.live import Live
|
|
5
|
+
from rich.panel import Panel
|
|
6
|
+
from rich.rule import Rule
|
|
7
|
+
|
|
8
|
+
from tracellm.db import fetch_trace
|
|
9
|
+
from tracellm.mascot import MascotState, header, message
|
|
10
|
+
from tracellm.tree_renderer import render_execution_panel
|
|
11
|
+
from tracellm.utils import (
|
|
12
|
+
console,
|
|
13
|
+
render_trace_report,
|
|
14
|
+
status_style,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _replay_detail_panel(trace_data: dict[str, Any], step: dict[str, Any], index: int, total: int) -> Panel:
|
|
19
|
+
duration = float(step.get("duration", 0.0))
|
|
20
|
+
success = bool(step.get("success", True))
|
|
21
|
+
tool_name = step.get("tool_name", "unknown")
|
|
22
|
+
inp = str(step.get("input", {}))
|
|
23
|
+
out = str(step.get("output", {}))
|
|
24
|
+
|
|
25
|
+
lines = [
|
|
26
|
+
f"[bright_black]step[/bright_black] {index}/{total}",
|
|
27
|
+
f"[bright_black]tool[/bright_black] [white]{tool_name}[/white]",
|
|
28
|
+
f"[bright_black]duration[/bright_black] {duration:.0f} ms",
|
|
29
|
+
f"[bright_black]status[/bright_black] {'[green]OK[/]' if success else '[red]RETRY[/]'}",
|
|
30
|
+
]
|
|
31
|
+
if inp:
|
|
32
|
+
clipped = inp[:200] + ("..." if len(inp) > 200 else "")
|
|
33
|
+
lines.append(f"[bright_black]input[/bright_black] [dim]{clipped}[/dim]")
|
|
34
|
+
if out:
|
|
35
|
+
clipped = out[:200] + ("..." if len(out) > 200 else "")
|
|
36
|
+
lines.append(f"[bright_black]output[/bright_black] [dim]{clipped}[/dim]")
|
|
37
|
+
|
|
38
|
+
body = "\n".join(lines)
|
|
39
|
+
return Panel.fit(body, title="Step Detail", border_style="bright_black", padding=(1, 2))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def replay_trace(trace_id: str, speed: float = 1.0, show_response: bool = False) -> None:
|
|
43
|
+
trace = fetch_trace(trace_id)
|
|
44
|
+
trace_data = trace.model_dump(mode="json")
|
|
45
|
+
steps = trace_data.get("steps", [])
|
|
46
|
+
|
|
47
|
+
if not steps:
|
|
48
|
+
console.print(f"[yellow]Trace {trace_id} has no steps to replay.[/yellow]")
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
console.print()
|
|
52
|
+
console.print(header("Replaying execution timeline...", MascotState.LOADING))
|
|
53
|
+
console.print()
|
|
54
|
+
meta_lines = [
|
|
55
|
+
f"[bright_black]trace_id[/bright_black] {trace_data['trace_id']}",
|
|
56
|
+
f"[bright_black]status[/bright_black] [{status_style(str(trace_data['status']))}]{str(trace_data['status']).upper()}[/]",
|
|
57
|
+
f"[bright_black]latency[/bright_black] {float(trace_data['latency']):.2f} ms",
|
|
58
|
+
f"[bright_black]retries[/bright_black] {trace_data['retry_count']}",
|
|
59
|
+
f"[bright_black]steps[/bright_black] {len(steps)}",
|
|
60
|
+
]
|
|
61
|
+
console.print(Panel.fit("\n".join(meta_lines), title="Replay", border_style="bright_black", padding=(1, 2)))
|
|
62
|
+
|
|
63
|
+
with Live(console=console, refresh_per_second=12, transient=False) as live:
|
|
64
|
+
for index, step in enumerate(steps, start=1):
|
|
65
|
+
tree_panel = render_execution_panel(steps, active_index=index)
|
|
66
|
+
detail = _replay_detail_panel(trace_data, step, index, len(steps))
|
|
67
|
+
body = f"{tree_panel}\n\n{detail}"
|
|
68
|
+
live.update(Panel(body, title=f"Replaying step {index}/{len(steps)}", border_style="bright_black", padding=(1, 2)))
|
|
69
|
+
delay = float(step.get("duration", 0.0)) / 1000 / max(speed, 0.1)
|
|
70
|
+
time.sleep(max(0.08, min(0.55, delay)))
|
|
71
|
+
|
|
72
|
+
status = str(trace_data.get("status", "success")).lower()
|
|
73
|
+
if status == "success":
|
|
74
|
+
console.print(message("Replay complete", MascotState.SUCCESS))
|
|
75
|
+
elif status in ("warning", "failed"):
|
|
76
|
+
console.print(message("Warning: trace had issues", MascotState.WARNING))
|
|
77
|
+
console.print()
|
|
78
|
+
render_trace_report(trace_data)
|
|
79
|
+
if show_response:
|
|
80
|
+
console.print(Panel(str(trace_data.get("response") or ""), title="Full Response", border_style="bright_black", padding=(1, 2)))
|