Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions app/app/(obs)/agents/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import Link from "next/link";
import { agentRollupsAsync } from "@/lib/traces";
import { PageTitle, Empty } from "@/components/widgets";

export const dynamic = "force-dynamic";

function fmtCost(c: number) {
if (c === 0) return "—";
if (c < 0.0001) return `$${c.toExponential(1)}`;
if (c < 1) return `$${c.toFixed(4)}`;
return `$${c.toFixed(2)}`;
}

function fmtRel(ts: number) {
const diff = Date.now() / 1000 - ts;
if (diff < 60) return `${Math.round(diff)}s ago`;
if (diff < 3600) return `${Math.round(diff / 60)}m ago`;
if (diff < 86400) return `${Math.round(diff / 3600)}h ago`;
return `${Math.round(diff / 86400)}d ago`;
}

function fmtTokens(n: number) {
if (n === 0) return "—";
if (n < 1000) return n.toString();
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
return `${(n / 1_000_000).toFixed(1)}M`;
}

function fmtLatency(ms: number | null) {
if (ms == null) return "—";
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
return `${Math.round(ms / 60_000)}m`;
}

export default async function AgentsPage() {
const rows = await agentRollupsAsync({ onlyTopLevel: true });
const totalCost = rows.reduce((a, r) => a + r.cost_usd, 0);
const totalSubagents = rows.reduce((a, r) => a + r.agent_calls, 0);

return (
<>
<PageTitle
eyebrow="Agent runs"
title={`${rows.length.toLocaleString()} top-level agent_call${rows.length === 1 ? "" : "s"} · ${fmtCost(totalCost)} total · ${totalSubagents.toLocaleString()} nested subagent${totalSubagents === 1 ? "" : "s"}`}
subtitle="Per-agent rollups: cost, tokens, and structural counts summed across the full subtree of each top-level agent_call."
/>

{rows.length === 0 ? (
<Empty>
No agent_call spans yet. Wrap your agent entry-point with{" "}
<code className="mono text-[12px] px-1.5 py-0.5 rounded obs-json">
with wikitrace.span(&quot;agent_call&quot;, agent=&quot;your-agent&quot;):
</code>{" "}
— every nested LLM call below it will roll up here.
</Empty>
) : (
<div className="glass rounded-2xl overflow-hidden">
<div
className="obs-table-head grid items-center gap-3 px-4 py-2.5 text-[10.5px] uppercase tracking-[0.06em] font-semibold"
style={{
gridTemplateColumns:
"minmax(140px, 1fr) 90px 80px 80px 80px 90px 90px 90px",
}}
>
<span>Agent</span>
<span className="text-right">Subagents</span>
<span className="text-right">LLM</span>
<span className="text-right">Tools</span>
<span className="text-right">Tokens</span>
<span className="text-right">Cost</span>
<span className="text-right">Latency</span>
<span className="text-right">Started</span>
</div>
<ul>
{rows.map((r) => (
<li key={r.span_id} className="obs-row">
<Link
href={`/traces/${r.trace_id}`}
className="grid items-center gap-3 px-4 py-2"
style={{
gridTemplateColumns:
"minmax(140px, 1fr) 90px 80px 80px 80px 90px 90px 90px",
}}
title={r.span_id}
>
<span className="text-[12.5px] mono obs-row-cell-strong truncate flex items-center gap-2">
{r.status === "error" && (
<span
className="text-[10px] px-1.5 py-0.5 rounded obs-tag-error"
title={`${r.errors} error span(s) in subtree`}
>
err
</span>
)}
{r.agent ?? <span className="obs-row-cell-mute">—</span>}
{r.user_id && (
<span className="text-[11px] obs-row-cell-mute">
· {r.user_id}
</span>
)}
</span>
<span className="text-[12px] mono obs-row-cell-mid text-right">
{r.agent_calls === 0 ? (
<span className="obs-row-cell-mute">—</span>
) : (
r.agent_calls
)}
</span>
<span className="text-[12px] mono obs-row-cell-mid text-right">
{r.llm_calls === 0 ? (
<span className="obs-row-cell-mute">—</span>
) : (
r.llm_calls
)}
</span>
<span className="text-[12px] mono obs-row-cell-mid text-right">
{r.tool_calls === 0 ? (
<span className="obs-row-cell-mute">—</span>
) : (
r.tool_calls
)}
</span>
<span className="text-[12px] mono obs-row-cell-mid text-right">
{fmtTokens(r.total_tokens || r.input_tokens + r.output_tokens)}
</span>
<span className="text-[12px] mono obs-row-cell-strong text-right">
{fmtCost(r.cost_usd)}
</span>
<span className="text-[12px] mono obs-row-cell-mid text-right">
{fmtLatency(r.latency_ms)}
</span>
<span className="text-[12px] mono obs-row-cell-mute text-right">
{fmtRel(r.start_ts)}
</span>
</Link>
</li>
))}
</ul>
</div>
)}

<p className="text-[12px] obs-row-cell-mute mt-4 max-w-2xl">
Top-level agent_call spans only — nested subagents (their own
agent_call children) are summed into the parent&apos;s row. Cost is
rolled up across the entire subtree, including every llm_call
and tool_call descendant.
</p>
</>
);
}
1 change: 1 addition & 0 deletions app/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { usePathname } from "next/navigation";

const NAV = [
{ href: "/requests", label: "Requests", section: "obs" },
{ href: "/agents", label: "Agents", section: "obs" },
{ href: "/sessions", label: "Sessions", section: "obs" },
{ href: "/users", label: "Users", section: "obs" },
{ href: "/properties", label: "Properties", section: "obs" },
Expand Down
143 changes: 143 additions & 0 deletions app/lib/traces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -805,6 +805,149 @@ export async function judgeByNameAsync(name: string): Promise<JudgeRollup | null
return (await judgeRollupsAsync()).find((j) => j.name === name) ?? null;
}

/**
* Subagent cost rollup at the agent_call level — mirror of the
* Python `wikitrace.agents.tree_cost` / `agent_rollups` API.
*
* Top-level agent_call spans (those whose parent is null OR whose
* parent is NOT an agent_call) are rolled up to show the total
* cost the parent caused: sum of cost_usd, input/output tokens,
* count of nested agent_calls, llm_calls, tool_calls, errors,
* tree depth.
*
* This closes the Twitter feedback gap: "does it work for subagent
* convos? cost is dictated by those now." The data model already
* supported nesting; this rollup makes it usable.
*/
export type AgentRollup = {
span_id: string;
trace_id: string;
agent: string | null;
pipeline: string | null;
start_ts: number;
end_ts: number | null;
status: "ok" | "error";
// Rolled-up totals across the subtree.
cost_usd: number;
input_tokens: number;
output_tokens: number;
total_tokens: number;
// Structural counts.
descendants: number;
llm_calls: number;
tool_calls: number;
agent_calls: number; // nested subagents below this root
errors: number;
depth: number;
latency_ms: number | null;
// Session context (when present on the root agent_call).
session_id?: string;
user_id?: string;
};

function _treeCostFromSpans(spans: Span[], rootId: string): AgentRollup | null {
const byId = new Map(spans.map((s) => [s.id, s]));
const root = byId.get(rootId);
if (!root) return null;

// children index
const childrenOf = new Map<string | null, Span[]>();
for (const s of spans) {
const p = s.parent_id ?? null;
if (!childrenOf.has(p)) childrenOf.set(p, []);
childrenOf.get(p)!.push(s);
}

const a = root.attrs ?? {};
const rollup: AgentRollup = {
span_id: root.id,
trace_id: root.trace_id,
agent: (a.agent as string | undefined) ?? null,
pipeline: root.pipeline ?? null,
start_ts: root.start_ts ?? 0,
end_ts: root.end_ts ?? null,
status: (root.status as "ok" | "error") ?? "ok",
cost_usd: 0,
input_tokens: 0,
output_tokens: 0,
total_tokens: 0,
descendants: 0,
llm_calls: 0,
tool_calls: 0,
agent_calls: 0,
errors: 0,
depth: 0,
latency_ms:
root.start_ts != null && root.end_ts != null
? Math.round((root.end_ts - root.start_ts) * 1000)
: null,
session_id: a.session_id as string | undefined,
user_id: a.user_id as string | undefined,
};

type QItem = { node: Span; depth: number };
const queue: QItem[] = [{ node: root, depth: 0 }];
while (queue.length > 0) {
const { node, depth } = queue.shift()!;
const na = node.attrs ?? {};

const cost = Number(na.cost_usd);
if (Number.isFinite(cost)) rollup.cost_usd += cost;
const inT = Number(na.input_tokens);
if (Number.isFinite(inT)) rollup.input_tokens += inT;
const outT = Number(na.output_tokens);
if (Number.isFinite(outT)) rollup.output_tokens += outT;
const tot = Number(na.total_tokens);
if (Number.isFinite(tot)) rollup.total_tokens += tot;

if (node.id !== rootId) {
rollup.descendants += 1;
if (node.name === "llm_call") rollup.llm_calls += 1;
else if (node.name === "tool_call") rollup.tool_calls += 1;
else if (node.name === "agent_call") rollup.agent_calls += 1;
}
if (node.status === "error") rollup.errors += 1;
if (depth > rollup.depth) rollup.depth = depth;

for (const c of childrenOf.get(node.id) ?? []) {
queue.push({ node: c, depth: depth + 1 });
}
}

return rollup;
}

function _agentRollupsFromSpans(
spans: Span[],
opts?: { onlyTopLevel?: boolean; limit?: number },
): AgentRollup[] {
const onlyTopLevel = opts?.onlyTopLevel ?? true;
const byId = new Map(spans.map((s) => [s.id, s]));
const roots: Span[] = [];
for (const s of spans) {
if (s.name !== "agent_call") continue;
if (onlyTopLevel) {
const parent = s.parent_id ? byId.get(s.parent_id) : undefined;
if (parent && parent.name === "agent_call") continue;
}
roots.push(s);
}
const out: AgentRollup[] = [];
for (const r of roots) {
const rollup = _treeCostFromSpans(spans, r.id);
if (rollup) out.push(rollup);
}
out.sort((a, b) => b.start_ts - a.start_ts);
if (opts?.limit != null) return out.slice(0, opts.limit);
return out;
}

export async function agentRollupsAsync(
opts?: { onlyTopLevel?: boolean; limit?: number },
): Promise<AgentRollup[]> {
return _agentRollupsFromSpans(await loadSpansAsync(), opts);
}

export type EvalQuestion = {
id: string;
question: string;
Expand Down
Loading
Loading