+ 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