diff --git a/CHANGELOG.md b/CHANGELOG.md index fa2e2279..4ca57cf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.7.3] — 2026-08-17 + +### Fixed + +- **Opening an agent turn made the timeline wait on every request and response + body in it.** The detail panel fetched the calls list body-bearing, and the + timeline, stat cards, agent breakdown and collapsed cards are built from + scalars — none of them reads a body. Measured against a production store, that + list is 2–20 MB and 0.4–4.8 s where the same list without bodies is 16–60 KB + and ~10 ms; the panel then parsed those megabytes on the main thread. It now + paints off the small shape and pulls bodies afterwards as a background + upgrade, so nothing the reader is waiting for is behind them. +- **Turns over the 50-call threshold fetched the bodies anyway, then threw them + away.** Whether to ask for bodies was decided from `call_count`, which arrives + from a *different* request — so on mount the answer defaulted to "yes" and the + body-bearing fetch went out for every turn, to be abandoned a few ms later + when the count came back over the threshold. `apiFetch` passed no + `AbortSignal`, so "abandoned" still meant downloading, parsing and caching the + full response: a 102-call turn pulled 20.6 MB it could never render. The span + and body endpoints now pass the signal, and the query no longer fires before + its own precondition is known — the same turn now costs 60 KB to open. + ## [0.7.2] — 2026-08-15 ### Added diff --git a/VERSION b/VERSION index 7486fdbc..f38fc539 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.2 +0.7.3 diff --git a/console/package.json b/console/package.json index 755ef3f9..a7cba0ec 100644 --- a/console/package.json +++ b/console/package.json @@ -1,7 +1,7 @@ { "name": "console", "private": true, - "version": "0.7.2", + "version": "0.7.3", "type": "module", "scripts": { "dev": "vite", diff --git a/console/src/hooks/use-agent-turns.ts b/console/src/hooks/use-agent-turns.ts index 38dd60a5..a3ce4e0f 100644 --- a/console/src/hooks/use-agent-turns.ts +++ b/console/src/hooks/use-agent-turns.ts @@ -70,13 +70,21 @@ export function useAgentTurnDetail(id: string | null) { * expanded call body in lite mode should fall back to * `useLlmCallDetail(callId)` to fetch that one call's full bodies on * demand. + * + * The two shapes are separate cache entries, so a caller can hold both: + * lite to paint with, full as a later upgrade. `enabled` exists for that + * second one — a query whose desirability isn't known until some other + * request answers must not fire in the meantime (see the detail panel). */ -export function useAgentTurnCalls(id: string | null, lite = false) { +export function useAgentTurnCalls(id: string | null, lite = false, enabled = true) { return useQuery({ queryKey: ["agent-turn-calls", id, lite], - queryFn: () => - apiFetch(`/api/traces/${id}/spans`, lite ? { lite: 1 } : {}), - enabled: id != null, + // The body-bearing shape runs to tens of MB, and this query goes unobserved + // often — the panel closes, the reader clicks straight to the next turn. + // Without the signal each of those leaves a full download running. + queryFn: ({ signal }) => + apiFetch(`/api/traces/${id}/spans`, lite ? { lite: 1 } : {}, { signal }), + enabled: id != null && enabled, }) } diff --git a/console/src/hooks/use-llm-call-detail.ts b/console/src/hooks/use-llm-call-detail.ts index 22ffd611..c5ba2dd6 100644 --- a/console/src/hooks/use-llm-call-detail.ts +++ b/console/src/hooks/use-llm-call-detail.ts @@ -5,7 +5,9 @@ import type { LlmCallDetail } from "@/types/api" export function useLlmCallDetail(id: string | null) { return useQuery({ queryKey: ["llm-call-detail", id], - queryFn: () => apiFetch(`/api/spans/${id}`), + // Carries both bodies, and CallCard enables/disables it as cards expand and + // collapse — so abandoned fetches are the normal case, not the edge one. + queryFn: ({ signal }) => apiFetch(`/api/spans/${id}`, undefined, { signal }), enabled: id != null, }) } diff --git a/console/src/lib/api.ts b/console/src/lib/api.ts index d00d9b2f..b5fe003d 100644 --- a/console/src/lib/api.ts +++ b/console/src/lib/api.ts @@ -12,9 +12,17 @@ export class ApiError extends Error { } } +/** + * `signal` is worth passing for any endpoint that can answer with megabytes: + * TanStack Query aborts it when a query loses its last observer — the panel + * closes, the queryKey changes — and without it the browser downloads, parses + * and caches a response nothing will ever render. A KB-scale list can skip it; + * the span/body endpoints cannot. + */ export async function apiFetch( path: string, params?: Record, + opts?: { signal?: AbortSignal }, ): Promise { const url = new URL(path, window.location.origin) if (params) { @@ -25,7 +33,7 @@ export async function apiFetch( } } - const res = await fetch(`${BASE_URL}${url.pathname}${url.search}`) + const res = await fetch(`${BASE_URL}${url.pathname}${url.search}`, { signal: opts?.signal }) if (!res.ok) { const body = await res.json().catch(() => ({ code: res.status, message: res.statusText })) throw new ApiError(body.code ?? res.status, body.message ?? res.statusText) diff --git a/console/src/pages/agent-turn-detail-panel.tsx b/console/src/pages/agent-turn-detail-panel.tsx index bbefcd60..974c823e 100644 --- a/console/src/pages/agent-turn-detail-panel.tsx +++ b/console/src/pages/agent-turn-detail-panel.tsx @@ -191,11 +191,10 @@ function TabButton({ ) } -/// Above this call_count threshold, the calls list switches to lite -/// mode — server NULLs the four heavy body/header fields so a -/// mega-turn (hundreds of agentic iterations × hundreds of KB -/// request_body each) doesn't OOM the browser. Individual call bodies -/// are still reachable per-card via `useLlmCallDetail`. +/// Above this call_count threshold the panel never fetches bodies for the +/// list at all; individual call bodies stay reachable per-card via +/// `useLlmCallDetail`. Below it they're still fetched, but as a background +/// upgrade (see below) rather than something the panel waits on. /// /// The threshold was set from browser-side cost alone, which is only half the /// bill. Measured against a live store, a 184-call turn takes ~6s to answer @@ -207,8 +206,34 @@ const CALLS_LITE_THRESHOLD = 50 export function AgentTurnDetailPanel({ id, onClose }: Props) { const { data: turn, isLoading: loadingTurn, isError: errorTurn } = useAgentTurnDetail(id) + + // Two fetches of the same list, and the panel paints off the cheap one. + // + // `?lite=1` is 140-350x smaller than the body-bearing shape and answers in + // ~10 ms against a production store where the full shape takes 0.4-4.8 s + // (2-20 MB over the wire, then a main-thread JSON.parse of the same). The + // timeline, stats, agent breakdown and every collapsed card are built from + // scalars only — none of them reads a body — so making them wait on those + // megabytes cost seconds of blank panel for nothing. + // + // Three views DO derive from bodies: the timeline's call-type icons, + // StatsCards' tool/text/final counts, and the tool index. They upgrade in + // place when `bodiedCalls` lands. Above the threshold it never lands and + // they degrade — as they already did before this split, since a turn that + // large was fetched lite regardless. + // + // The lite fetch is unconditional. Deriving *whether to fetch bodies* from + // `turn.call_count` means it can't be decided until the detail query + // answers, and a query gated on another query's result must not fire on the + // default in the meantime: `useAgentTurnCalls(id, liteMode)` did exactly + // that, issuing the full-body request for every turn and then abandoning it + // when call_count came back over the threshold. `apiFetch` passes no + // AbortSignal, so "abandoned" meant the browser still downloaded, parsed and + // cached ~20 MB it would never show. + const { data: liteCalls = [], isLoading: loadingCalls } = useAgentTurnCalls(id, true) const liteMode = (turn?.call_count ?? 0) > CALLS_LITE_THRESHOLD - const { data: calls = [], isLoading: loadingCalls } = useAgentTurnCalls(id, liteMode) + const { data: bodiedCalls } = useAgentTurnCalls(id, false, turn != null && !liteMode) + const calls = bodiedCalls ?? liteCalls // Call-level proxy-duplicate fold: when two captured calls represent // the same LLM round-trip (e.g. client→litellm + litellm→upstream), diff --git a/server/Cargo.toml b/server/Cargo.toml index 4fd67a3f..ea95b58a 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -8,7 +8,7 @@ exclude = ["h-ebpf-prog"] resolver = "2" [workspace.package] -version = "0.7.2" +version = "0.7.3" edition = "2021" license = "Apache-2.0"