Spaces:
Running
Running
| "use client"; | |
| import type { CSSProperties } from "react"; | |
| import { useCallback, useEffect, useMemo, useState } from "react"; | |
| import type { AgentCompletionStep, AgentRunLog, AgentToolEvent } from "@/lib/agent-run-log-types"; | |
| import { buildDebugTrace } from "@/lib/agent-debug-trace"; | |
| import { appToast } from "@/lib/app-toast"; | |
| function formatRelativeTime(ts: number): string { | |
| const sec = Math.round((Date.now() - ts) / 1000); | |
| if (sec < 45) return "just now"; | |
| if (sec < 3600) return `${Math.round(sec / 60)} min ago`; | |
| if (sec < 86400) return `${Math.round(sec / 3600)} h ago`; | |
| return `${Math.round(sec / 86400)} d ago`; | |
| } | |
| function stoppedBadgeStyle(s: AgentRunLog["stoppedBecause"]): CSSProperties { | |
| if (s === "completed") return { background: "rgba(34,197,94,0.15)", color: "#16a34a", border: "1px solid rgba(34,197,94,0.35)" }; | |
| if (s === "budget_exhausted" || s === "timeout" || s === "partial_results_awaiting_resume") | |
| return { background: "rgba(245,158,11,0.15)", color: "#d97706", border: "1px solid rgba(245,158,11,0.35)" }; | |
| return { background: "rgba(239,68,68,0.12)", color: "#dc2626", border: "1px solid rgba(239,68,68,0.3)" }; | |
| } | |
| function toolStatusSymbol(st: AgentToolEvent["status"]): string { | |
| if (st === "ok") return "✓"; | |
| if (st === "error") return "✗"; | |
| return "⚠"; | |
| } | |
| type HirebaseProbeResult = { | |
| configured: boolean; | |
| ok: boolean; | |
| httpStatus?: number; | |
| message: string; | |
| keySuffix?: string; | |
| hint?: string; | |
| mode?: "lexical" | "neural"; | |
| }; | |
| export function AgentRunsDebugger() { | |
| const [runs, setRuns] = useState<AgentRunLog[]>([]); | |
| const [meta, setMeta] = useState<{ labInferenceLogging: boolean; redisConfigured: boolean } | null>(null); | |
| const [hirebaseProbe, setHirebaseProbe] = useState<HirebaseProbeResult | null>(null); | |
| const [hirebaseProbeLoading, setHirebaseProbeLoading] = useState(false); | |
| const [loading, setLoading] = useState(true); | |
| const [detail, setDetail] = useState<AgentRunLog | null>(null); | |
| const [detailLoading, setDetailLoading] = useState(false); | |
| const [expandedTools, setExpandedTools] = useState<Set<number>>(() => new Set()); | |
| const [expandedSteps, setExpandedSteps] = useState<Set<number>>(() => new Set()); | |
| const refreshList = useCallback(async () => { | |
| setLoading(true); | |
| try { | |
| const res = await fetch("/api/lab/agent-runs", { credentials: "include" }); | |
| if (!res.ok) { | |
| setRuns([]); | |
| setMeta(null); | |
| return; | |
| } | |
| const j = (await res.json()) as { | |
| runs?: AgentRunLog[]; | |
| labInferenceLogging?: boolean; | |
| redisConfigured?: boolean; | |
| }; | |
| setRuns(Array.isArray(j.runs) ? j.runs : []); | |
| setMeta({ | |
| labInferenceLogging: Boolean(j.labInferenceLogging), | |
| redisConfigured: Boolean(j.redisConfigured), | |
| }); | |
| } catch { | |
| setRuns([]); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }, []); | |
| useEffect(() => { | |
| void refreshList(); | |
| }, [refreshList]); | |
| const runHirebaseProbe = useCallback(async () => { | |
| setHirebaseProbeLoading(true); | |
| try { | |
| const res = await fetch("/api/lab/hirebase-probe", { credentials: "include" }); | |
| if (!res.ok) { | |
| setHirebaseProbe(null); | |
| appToast.error("Hirebase probe failed — check lab admin access"); | |
| return; | |
| } | |
| const j = (await res.json()) as HirebaseProbeResult; | |
| setHirebaseProbe(j); | |
| } catch { | |
| setHirebaseProbe(null); | |
| appToast.error("Could not run Hirebase probe"); | |
| } finally { | |
| setHirebaseProbeLoading(false); | |
| } | |
| }, []); | |
| useEffect(() => { | |
| void runHirebaseProbe(); | |
| }, [runHirebaseProbe]); | |
| const loadDetail = useCallback(async (runId: string) => { | |
| setDetailLoading(true); | |
| try { | |
| const res = await fetch(`/api/lab/agent-runs?runId=${encodeURIComponent(runId)}&verbose=true`, { | |
| credentials: "include", | |
| }); | |
| if (!res.ok) { | |
| setDetail(null); | |
| return; | |
| } | |
| const j = (await res.json()) as { run?: AgentRunLog }; | |
| setDetail(j.run ?? null); | |
| } catch { | |
| setDetail(null); | |
| } finally { | |
| setDetailLoading(false); | |
| } | |
| }, []); | |
| const copyTrace = useCallback(async () => { | |
| if (!detail) return; | |
| const md = buildDebugTrace(detail); | |
| try { | |
| if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) { | |
| throw new Error("no clipboard"); | |
| } | |
| await navigator.clipboard.writeText(md); | |
| appToast.success("Debug trace copied to clipboard"); | |
| } catch { | |
| appToast.error("Could not copy — try selecting and copying manually"); | |
| } | |
| }, [detail]); | |
| const toggleTool = useCallback((i: number) => { | |
| setExpandedTools((prev) => { | |
| const n = new Set(prev); | |
| if (n.has(i)) n.delete(i); | |
| else n.add(i); | |
| return n; | |
| }); | |
| }, []); | |
| const toggleStepMessages = useCallback((i: number) => { | |
| setExpandedSteps((prev) => { | |
| const n = new Set(prev); | |
| if (n.has(i)) n.delete(i); | |
| else n.add(i); | |
| return n; | |
| }); | |
| }, []); | |
| const sectionStyle = useMemo( | |
| () => | |
| ({ | |
| padding: 14, | |
| borderRadius: 10, | |
| border: "1px solid var(--border-subtle)", | |
| marginBottom: 16, | |
| }) as const, | |
| [], | |
| ); | |
| return ( | |
| <section style={sectionStyle}> | |
| <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}> | |
| <h2 | |
| style={{ | |
| fontSize: 11, | |
| fontWeight: 600, | |
| textTransform: "uppercase", | |
| letterSpacing: 0.5, | |
| color: "var(--muted)", | |
| margin: 0, | |
| }} | |
| > | |
| Agent runs | |
| </h2> | |
| <button type="button" onClick={() => void refreshList()}> | |
| Refresh | |
| </button> | |
| </div> | |
| {meta ? ( | |
| <p style={{ fontSize: 12, color: "var(--muted)", marginTop: 0, marginBottom: 10 }}> | |
| Redis: {meta.redisConfigured ? "configured" : "not configured"} · Verbose inference ( | |
| <code>LAB_LOG_INFERENCE</code>): {meta.labInferenceLogging ? "on" : "off"} | |
| </p> | |
| ) : null} | |
| <div | |
| style={{ | |
| fontSize: 12, | |
| marginBottom: 12, | |
| padding: 10, | |
| borderRadius: 8, | |
| border: "1px solid var(--border-subtle)", | |
| background: "var(--surface-raised)", | |
| }} | |
| > | |
| <div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center", marginBottom: 6 }}> | |
| <strong>Hirebase</strong> | |
| <button type="button" disabled={hirebaseProbeLoading} onClick={() => void runHirebaseProbe()}> | |
| {hirebaseProbeLoading ? "Probing…" : "Probe API key"} | |
| </button> | |
| </div> | |
| {hirebaseProbe ? ( | |
| <div style={{ color: hirebaseProbe.ok ? "#16a34a" : "#dc2626" }}> | |
| {hirebaseProbe.configured ? | |
| `Key …${hirebaseProbe.keySuffix ?? "????"} · mode: ${hirebaseProbe.mode ?? "lexical"} · HTTP ${hirebaseProbe.httpStatus ?? "—"} · ${hirebaseProbe.message}` | |
| : "HIREBASE_API_KEY not set on server"} | |
| </div> | |
| ) : ( | |
| <div style={{ color: "var(--muted)" }}>Run probe to verify search API auth.</div> | |
| )} | |
| {hirebaseProbe?.hint ? ( | |
| <div style={{ marginTop: 6, color: "var(--muted)" }}>{hirebaseProbe.hint}</div> | |
| ) : null} | |
| <div style={{ marginTop: 6, color: "var(--muted)", fontSize: 11 }}> | |
| Server logs: filter <code>hirebase.search</code> or <code>[hirebase]</code> on your Space runtime logs. | |
| </div> | |
| </div> | |
| {loading ? ( | |
| <p style={{ fontSize: 13 }}>Loading…</p> | |
| ) : runs.length === 0 ? ( | |
| <p style={{ fontSize: 13, color: "var(--muted)" }}>No runs recorded yet.</p> | |
| ) : ( | |
| <div style={{ overflowX: "auto" }}> | |
| <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 12 }}> | |
| <thead> | |
| <tr style={{ textAlign: "left", color: "var(--muted)" }}> | |
| <th style={{ padding: "6px 8px" }}>When</th> | |
| <th style={{ padding: "6px 8px" }}>User</th> | |
| <th style={{ padding: "6px 8px" }}>Duration</th> | |
| <th style={{ padding: "6px 8px" }}>Stop</th> | |
| <th style={{ padding: "6px 8px" }}>Tools</th> | |
| <th style={{ padding: "6px 8px" }}>Credits</th> | |
| <th style={{ padding: "6px 8px" }}>Verbose</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {runs.map((r) => ( | |
| <tr | |
| key={r.runId} | |
| style={{ | |
| cursor: "pointer", | |
| borderTop: "1px solid var(--border-subtle)", | |
| background: detail?.runId === r.runId ? "var(--surface-muted)" : undefined, | |
| }} | |
| onClick={() => void loadDetail(r.runId)} | |
| > | |
| <td style={{ padding: "8px", whiteSpace: "nowrap" }}>{formatRelativeTime(r.startedAt)}</td> | |
| <td style={{ padding: "8px", fontFamily: "ui-monospace, monospace", maxWidth: 120 }} title={r.userId}> | |
| {r.userId.length > 14 ? `${r.userId.slice(0, 10)}…` : r.userId} | |
| </td> | |
| <td style={{ padding: "8px" }}>{(r.durationMs / 1000).toFixed(1)}s</td> | |
| <td style={{ padding: "8px" }}> | |
| <span style={{ ...stoppedBadgeStyle(r.stoppedBecause), borderRadius: 6, padding: "2px 8px", fontSize: 11 }}> | |
| {r.stoppedBecause} | |
| </span> | |
| </td> | |
| <td style={{ padding: "8px" }}>{r.totalTools}</td> | |
| <td style={{ padding: "8px" }}>{r.creditCost}</td> | |
| <td style={{ padding: "8px" }}>{r.verboseTraceStored ? "yes" : "—"}</td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| )} | |
| {detail ? ( | |
| <div style={{ marginTop: 16, borderTop: "1px solid var(--border-subtle)", paddingTop: 14 }}> | |
| <div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center", marginBottom: 12 }}> | |
| <strong style={{ fontSize: 14 }}>Run {detail.runId.slice(0, 8)}…</strong> | |
| <button type="button" onClick={() => void copyTrace()}> | |
| Copy debug trace | |
| </button> | |
| {detailLoading ? <span style={{ fontSize: 12, color: "var(--muted)" }}>Loading…</span> : null} | |
| </div> | |
| {detail.errorMessage ? ( | |
| <div style={{ fontSize: 12, marginBottom: 10, color: "#dc2626" }}> | |
| Run error: {detail.errorMessage} | |
| {detail.rawErrorMessage && detail.rawErrorMessage !== detail.errorMessage ? ( | |
| <div style={{ marginTop: 4, color: "#b45309", wordBreak: "break-word" }}> | |
| Raw: {detail.rawErrorMessage} | |
| </div> | |
| ) : null} | |
| </div> | |
| ) : null} | |
| <div | |
| style={{ | |
| display: "flex", | |
| flexWrap: "wrap", | |
| gap: 14, | |
| }} | |
| > | |
| <div> | |
| <h3 style={{ fontSize: 12, margin: "0 0 8px", color: "var(--muted)" }}>Tool timeline</h3> | |
| <ul style={{ listStyle: "none", padding: 0, margin: 0, fontSize: 12 }}> | |
| {detail.tools.map((t, i) => ( | |
| <li | |
| key={`${t.tool}-${t.startedAt}-${i}`} | |
| style={{ | |
| marginBottom: 10, | |
| padding: 8, | |
| borderRadius: 8, | |
| border: "1px solid var(--border-subtle)", | |
| background: "var(--surface-raised)", | |
| }} | |
| > | |
| <div> | |
| {toolStatusSymbol(t.status)} <strong>{t.tool}</strong> · {t.durationMs}ms | |
| {t.backend ? ` · backend: ${t.backend}` : ""} | |
| {t.provider ? ` · scoring: ${t.provider}` : ""} | |
| {t.creditCost ? ` · ${t.creditCost} cr` : ""} | |
| </div> | |
| {t.resultSummary ? ( | |
| <div style={{ marginTop: 4, color: "var(--muted)" }}>{t.resultSummary}</div> | |
| ) : null} | |
| {t.rawResultSummary && t.rawResultSummary !== t.resultSummary ? ( | |
| <div style={{ marginTop: 4, color: "#b45309", fontSize: 11, wordBreak: "break-word" }}> | |
| Raw: {t.rawResultSummary} | |
| </div> | |
| ) : null} | |
| {t.input && Object.keys(t.input).length > 0 ? ( | |
| <div style={{ marginTop: 6 }}> | |
| <button type="button" style={{ fontSize: 11 }} onClick={() => toggleTool(i)}> | |
| {expandedTools.has(i) ? "Hide" : "Show"} input | |
| </button> | |
| {expandedTools.has(i) ? ( | |
| <pre | |
| style={{ | |
| marginTop: 6, | |
| fontSize: 10, | |
| overflow: "auto", | |
| maxHeight: 160, | |
| background: "#111", | |
| color: "#b8c0cc", | |
| padding: 8, | |
| borderRadius: 6, | |
| }} | |
| > | |
| {JSON.stringify(t.input, null, 2)} | |
| </pre> | |
| ) : null} | |
| </div> | |
| ) : null} | |
| </li> | |
| ))} | |
| </ul> | |
| </div> | |
| <div style={{ flex: "1 1 320px", minWidth: 0 }}> | |
| <h3 style={{ fontSize: 12, margin: "0 0 8px", color: "var(--muted)" }}>LLM steps</h3> | |
| <ul style={{ listStyle: "none", padding: 0, margin: 0, fontSize: 12 }}> | |
| {detail.completionSteps.map((s: AgentCompletionStep) => ( | |
| <li | |
| key={s.stepIndex} | |
| style={{ | |
| marginBottom: 10, | |
| padding: 8, | |
| borderRadius: 8, | |
| border: "1px solid var(--border-subtle)", | |
| background: "var(--surface-raised)", | |
| }} | |
| > | |
| <div> | |
| <strong>Step {s.stepIndex + 1}</strong> · {s.model || "?"} ({s.provider}) · {s.durationMs}ms | |
| </div> | |
| {s.errorMessage ? ( | |
| <div style={{ color: "#dc2626", marginTop: 4 }}>{s.errorMessage}</div> | |
| ) : null} | |
| {s.rawErrorMessage && s.rawErrorMessage !== s.errorMessage ? ( | |
| <div style={{ color: "#b45309", marginTop: 4, fontSize: 11, wordBreak: "break-word" }}> | |
| Raw: {s.rawErrorMessage} | |
| </div> | |
| ) : null} | |
| {s.assistantReasoningText ? ( | |
| <blockquote style={{ margin: "6px 0", fontSize: 11, color: "var(--muted)" }}> | |
| {s.assistantReasoningText.slice(0, 600)} | |
| {s.assistantReasoningText.length > 600 ? "…" : ""} | |
| </blockquote> | |
| ) : null} | |
| {s.toolCalls?.length ? ( | |
| <ul style={{ margin: "4px 0 0", paddingLeft: 16 }}> | |
| {s.toolCalls.map((tc) => ( | |
| <li key={tc.name}> | |
| <code>{tc.name}</code> | |
| </li> | |
| ))} | |
| </ul> | |
| ) : null} | |
| {s.messages?.length ? ( | |
| <div style={{ marginTop: 6 }}> | |
| <button type="button" style={{ fontSize: 11 }} onClick={() => toggleStepMessages(s.stepIndex)}> | |
| {expandedSteps.has(s.stepIndex) ? "Hide" : "View"} full messages ({s.messages.length}) | |
| </button> | |
| {expandedSteps.has(s.stepIndex) ? ( | |
| <pre | |
| style={{ | |
| marginTop: 6, | |
| fontSize: 10, | |
| overflow: "auto", | |
| maxHeight: 240, | |
| background: "#111", | |
| color: "#b8c0cc", | |
| padding: 8, | |
| borderRadius: 6, | |
| }} | |
| > | |
| {JSON.stringify(s.messages, null, 2).slice(0, 12000)} | |
| </pre> | |
| ) : null} | |
| </div> | |
| ) : null} | |
| </li> | |
| ))} | |
| </ul> | |
| </div> | |
| </div> | |
| </div> | |
| ) : null} | |
| </section> | |
| ); | |
| } | |