diff --git a/app/app/(obs)/agents/page.tsx b/app/app/(obs)/agents/page.tsx new file mode 100644 index 0000000..6ad68c5 --- /dev/null +++ b/app/app/(obs)/agents/page.tsx @@ -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 ( + <> + + + {rows.length === 0 ? ( + + No agent_call spans yet. Wrap your agent entry-point with{" "} + + with wikitrace.span("agent_call", agent="your-agent"): + {" "} + — every nested LLM call below it will roll up here. + + ) : ( +
+
+ Agent + Subagents + LLM + Tools + Tokens + Cost + Latency + Started +
+ +
+ )} + +

+ Top-level agent_call spans only — nested subagents (their own + agent_call children) are summed into the parent's row. Cost is + rolled up across the entire subtree, including every llm_call + and tool_call descendant. +

+ + ); +} diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx index 5750067..597b637 100644 --- a/app/components/Sidebar.tsx +++ b/app/components/Sidebar.tsx @@ -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" }, diff --git a/app/lib/traces.ts b/app/lib/traces.ts index 70d92a6..8242fb4 100644 --- a/app/lib/traces.ts +++ b/app/lib/traces.ts @@ -805,6 +805,149 @@ export async function judgeByNameAsync(name: string): Promise 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(); + 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 { + return _agentRollupsFromSpans(await loadSpansAsync(), opts); +} + export type EvalQuestion = { id: string; question: string; diff --git a/tests/test_agents.py b/tests/test_agents.py new file mode 100644 index 0000000..d7a9455 --- /dev/null +++ b/tests/test_agents.py @@ -0,0 +1,162 @@ +"""Subagent cost rollup at the agent_call level. + +Closes the Twitter feedback gap: a top-level agent_call that spawns +subagents (each with their own llm_call children) should produce one +row showing the total cost the parent caused. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +import wikitrace as wt +from wikitrace.agents import tree_cost, agent_rollups, CostRollup + + +def _spans(trace_dir: Path) -> list[dict]: + p = trace_dir / "spans.jsonl" + return [json.loads(l) for l in p.read_text().splitlines()] if p.exists() else [] + + +def test_tree_cost_sums_descendants(trace_dir: Path): + """One agent_call -> one llm_call leaf. tree_cost should return + the leaf's cost as the rollup.""" + wt.init(pipeline="t", trace_dir=trace_dir) + with wt.span("agent_call", agent="rag-v1") as root: + with wt.span("llm_call", model="gpt-4o", + cost_usd=0.0025, input_tokens=100, + output_tokens=200, total_tokens=300): + pass + wt.end() + + spans = _spans(trace_dir) + root_id = next(s["id"] for s in spans if s["name"] == "agent_call") + + r = tree_cost(spans, root_id) + assert r is not None + assert r.cost_usd == pytest.approx(0.0025) + assert r.input_tokens == 100 + assert r.output_tokens == 200 + assert r.total_tokens == 300 + assert r.llm_calls == 1 + assert r.descendants == 1 + assert r.agent == "rag-v1" + + +def test_tree_cost_aggregates_subagent_fanout(trace_dir: Path): + """Top-level agent_call spawns 3 subagent_calls, each with its own + llm_call. Rollup should show 3 nested agent_calls + 3 llm_calls, + cost summed across all leaves.""" + wt.init(pipeline="t", trace_dir=trace_dir) + with wt.span("agent_call", agent="planner"): + for i in range(3): + with wt.span("agent_call", agent=f"worker-{i}"): + with wt.span("llm_call", model="gpt-4o-mini", + cost_usd=0.001, input_tokens=10, + output_tokens=20, total_tokens=30): + pass + wt.end() + + spans = _spans(trace_dir) + # The top-level planner is the only agent_call with parent_id None + # within this trace. + top = next(s for s in spans + if s["name"] == "agent_call" and s.get("parent_id") is None) + + r = tree_cost(spans, top["id"]) + assert r is not None + assert r.cost_usd == pytest.approx(0.003) + assert r.input_tokens == 30 + assert r.output_tokens == 60 + assert r.agent_calls == 3 # nested subagents + assert r.llm_calls == 3 + assert r.descendants == 6 # 3 subagents + 3 llm_calls + # Depth: planner(0) -> worker(1) -> llm_call(2) + assert r.depth == 2 + + +def test_tree_cost_unknown_root_returns_none(trace_dir: Path): + wt.init(pipeline="t", trace_dir=trace_dir) + with wt.span("agent_call"): + pass + wt.end() + spans = _spans(trace_dir) + assert tree_cost(spans, "definitely-not-a-real-id") is None + + +def test_tree_cost_handles_missing_cost_attrs(trace_dir: Path): + """Spans without cost_usd / token attrs contribute structurally + (descendant count, depth) but not to cost.""" + wt.init(pipeline="t", trace_dir=trace_dir) + with wt.span("agent_call"): + with wt.span("tool_call", tool="search"): # no cost + pass + with wt.span("llm_call", model="gpt-4o", cost_usd=0.001): + pass + wt.end() + spans = _spans(trace_dir) + root = next(s for s in spans if s["name"] == "agent_call") + r = tree_cost(spans, root["id"]) + assert r.cost_usd == pytest.approx(0.001) + assert r.tool_calls == 1 + assert r.llm_calls == 1 + assert r.descendants == 2 + + +def test_agent_rollups_top_level_only(trace_dir: Path): + """only_top_level=True (default) skips nested agent_calls.""" + wt.init(pipeline="t", trace_dir=trace_dir) + with wt.span("agent_call", agent="parent"): + with wt.span("agent_call", agent="child"): + with wt.span("llm_call", model="gpt-4o", cost_usd=0.01): + pass + wt.end() + + rollups = agent_rollups(trace_dir=trace_dir) + assert len(rollups) == 1 + assert rollups[0].agent == "parent" + assert rollups[0].cost_usd == pytest.approx(0.01) + assert rollups[0].agent_calls == 1 # the child + + +def test_agent_rollups_include_nested(trace_dir: Path): + """only_top_level=False produces a row per agent_call.""" + wt.init(pipeline="t", trace_dir=trace_dir) + with wt.span("agent_call", agent="parent"): + with wt.span("agent_call", agent="child"): + with wt.span("llm_call", model="gpt-4o", cost_usd=0.01): + pass + wt.end() + + rollups = agent_rollups(trace_dir=trace_dir, only_top_level=False) + assert len(rollups) == 2 + by_agent = {r.agent: r for r in rollups} + assert by_agent["parent"].cost_usd == pytest.approx(0.01) + assert by_agent["child"].cost_usd == pytest.approx(0.01) + # Parent's subtree includes 1 nested agent_call; child's doesn't. + assert by_agent["parent"].agent_calls == 1 + assert by_agent["child"].agent_calls == 0 + + +def test_agent_rollups_handles_no_trace_dir(tmp_path): + """If spans.jsonl doesn't exist, return [] not raise.""" + assert agent_rollups(trace_dir=tmp_path) == [] + + +def test_tree_cost_records_root_latency(trace_dir: Path): + """Root latency_ms should reflect the root span's wall time, not + a sum of children (children nest inside the root in time).""" + wt.init(pipeline="t", trace_dir=trace_dir) + with wt.span("agent_call"): + with wt.span("llm_call", model="gpt-4o", cost_usd=0.001): + pass + wt.end() + spans = _spans(trace_dir) + root = next(s for s in spans if s["name"] == "agent_call") + r = tree_cost(spans, root["id"]) + # Hard to assert exact ms; just ensure it's set and non-negative. + assert r.latency_ms is not None + assert r.latency_ms >= 0 diff --git a/wikitrace/__init__.py b/wikitrace/__init__.py index 7dfde80..8065a30 100644 --- a/wikitrace/__init__.py +++ b/wikitrace/__init__.py @@ -25,6 +25,7 @@ from .decorators import trace, tool, eval # noqa: A004 — shadowing builtin intentional from .budget import budget, BudgetExceeded, current_cost, remaining as budget_remaining, check as budget_check from .replay import replay_trace, ReplayResult +from .agents import tree_cost, agent_rollups, CostRollup from . import alerts # noqa: F401 side-effect-free, opt-in via alerts.enable() __version__ = "0.2.0" @@ -39,6 +40,7 @@ "budget", "BudgetExceeded", "current_cost", "budget_remaining", "budget_check", "replay_trace", "ReplayResult", + "tree_cost", "agent_rollups", "CostRollup", "alerts", "__version__", ] diff --git a/wikitrace/agents.py b/wikitrace/agents.py new file mode 100644 index 0000000..1363735 --- /dev/null +++ b/wikitrace/agents.py @@ -0,0 +1,203 @@ +"""Subagent cost rollup at the agent_call level. + +When an agent spawns subagents (planner -> tool -> child agent_call -> +... ), the cost data lives on individual ``llm_call`` leaves but no +single span carries the total spend the parent caused. ``tree_cost()`` +walks a span's descendants and sums the costs. + +This is a Twitter-feedback gap: people asked "does it work for +subagent convos? cost is dictated by those now." The data model +already supports arbitrary nesting via ``parent_id``; this module +surfaces the rollup that the dashboard and Python users actually +need. + +Usage:: + + from wikitrace.agents import tree_cost, agent_rollups + + # One agent_call, fully recursive cost across all descendants + rollup = tree_cost(spans, root_span_id="abc1234567890def") + print(rollup.cost_usd, rollup.input_tokens, rollup.llm_calls) + + # All top-level agent_call spans in a trace dir + rollups = agent_rollups(trace_dir=".wikitrace") + for r in rollups: + print(r.span_id, r.agent, r.cost_usd, r.depth) +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Iterable + + +@dataclass +class CostRollup: + """Cost / tokens / structural counts across a span and its + descendants. ``span_id`` is the root we rolled up from.""" + span_id: str + trace_id: str + agent: str | None + pipeline: str | None + name: str + start_ts: float + end_ts: float | None + status: str + # Self attrs (for context, not summed). + self_attrs: dict = field(default_factory=dict) + # Rolled-up totals across the subtree. + cost_usd: float = 0.0 + input_tokens: int = 0 + output_tokens: int = 0 + total_tokens: int = 0 + # Structural counts: how many of each interesting child kind appear + # below this root. + descendants: int = 0 + llm_calls: int = 0 + tool_calls: int = 0 + agent_calls: int = 0 # NESTED agent_calls (subagents) below this root + errors: int = 0 + # Tree depth (distance from root to deepest leaf). + depth: int = 0 + # Latency: end_ts - start_ts on the root span itself, in ms. The + # subtree's latency is bounded by the root because spans nest in time. + latency_ms: int | None = None + + +def _children_index(spans: list[dict]) -> dict[str | None, list[dict]]: + out: dict[str | None, list[dict]] = {} + for s in spans: + out.setdefault(s.get("parent_id"), []).append(s) + return out + + +def tree_cost(spans: Iterable[dict], root_span_id: str) -> CostRollup | None: + """Walk all descendants of ``root_span_id`` (inclusive) and sum + cost / tokens / structural counts. Returns ``None`` if the root + span isn't in ``spans``. + + This is pure: pass any iterable of span dicts (loaded from + ``spans.jsonl``, fetched from the cloud server, anything matching + the wikitrace shape). Cost / tokens are summed from each span's + ``attrs.cost_usd``, ``input_tokens``, ``output_tokens``, ``total_tokens`` + when present. Spans without these attrs contribute structurally + (descendant count, depth) but not to cost. + """ + span_list = list(spans) + by_id = {s["id"]: s for s in span_list} + root = by_id.get(root_span_id) + if root is None: + return None + + children_of = _children_index(span_list) + + rollup = CostRollup( + span_id=root["id"], + trace_id=root.get("trace_id", ""), + agent=(root.get("attrs") or {}).get("agent"), + pipeline=root.get("pipeline"), + name=root.get("name", ""), + start_ts=float(root.get("start_ts") or 0), + end_ts=root.get("end_ts"), + status=root.get("status", "ok"), + self_attrs=dict(root.get("attrs") or {}), + ) + if rollup.end_ts is not None and rollup.start_ts is not None: + rollup.latency_ms = int((rollup.end_ts - rollup.start_ts) * 1000) + + # BFS from root, summing as we go. Track depth. + queue: list[tuple[dict, int]] = [(root, 0)] + while queue: + node, depth = queue.pop(0) + a = node.get("attrs") or {} + # Sum cost / tokens. None and missing both treated as 0. + for src, dst in ( + ("cost_usd", "cost_usd"), + ("input_tokens", "input_tokens"), + ("output_tokens", "output_tokens"), + ("total_tokens", "total_tokens"), + ): + v = a.get(src) + if v is None: + continue + try: + if dst == "cost_usd": + rollup.cost_usd += float(v) + else: + setattr(rollup, dst, getattr(rollup, dst) + int(v)) + except (TypeError, ValueError): + pass + + # Count by kind. Don't double-count the root. + if node["id"] != root_span_id: + rollup.descendants += 1 + kind = node.get("name", "") + if kind == "llm_call": + rollup.llm_calls += 1 + elif kind == "tool_call": + rollup.tool_calls += 1 + elif kind == "agent_call": + rollup.agent_calls += 1 + + if node.get("status") == "error": + rollup.errors += 1 + + rollup.depth = max(rollup.depth, depth) + + for child in children_of.get(node["id"], []): + queue.append((child, depth + 1)) + + return rollup + + +def agent_rollups( + *, + trace_dir: str | Path = ".wikitrace", + only_top_level: bool = True, + limit: int | None = None, +) -> list[CostRollup]: + """Compute :class:`CostRollup` for every ``agent_call`` span in a + trace directory. + + Parameters + ---------- + trace_dir + Directory containing ``spans.jsonl``. + only_top_level + When True (default), only roll up agent_call spans whose + parent is None or whose parent is itself NOT an agent_call. + This is the typical "what did each user-visible agent run + cost end-to-end" view. Set to False to also produce rollups + for nested subagents (every agent_call gets its own row). + limit + Max number of rollups to return, most recent first. + """ + p = Path(trace_dir) / "spans.jsonl" + if not p.exists(): + return [] + spans = [json.loads(l) for l in p.read_text().splitlines() if l.strip()] + by_id = {s["id"]: s for s in spans} + + roots: list[dict] = [] + for s in spans: + if s.get("name") != "agent_call": + continue + if only_top_level: + parent = by_id.get(s.get("parent_id") or "") + if parent is not None and parent.get("name") == "agent_call": + continue # this is a nested subagent; skip + roots.append(s) + + rollups: list[CostRollup] = [] + for root in roots: + r = tree_cost(spans, root["id"]) + if r is not None: + rollups.append(r) + + # Most recent first — useful default for the dashboard list view. + rollups.sort(key=lambda r: r.start_ts, reverse=True) + if limit is not None: + rollups = rollups[:limit] + return rollups