Job-Scorer / components /lab /AgentRunsDebugger.tsx
zimejin's picture
Make agent turns outcome-driven: soft-fail after signal, auto-cycle, resume-first.
de1f194
Raw
History Blame Contribute Delete
17.1 kB
"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>
);
}