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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.7.2
0.7.3
2 changes: 1 addition & 1 deletion console/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "console",
"private": true,
"version": "0.7.2",
"version": "0.7.3",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
16 changes: 12 additions & 4 deletions console/src/hooks/use-agent-turns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AgentTurnCallItem[]>(`/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<AgentTurnCallItem[]>(`/api/traces/${id}/spans`, lite ? { lite: 1 } : {}, { signal }),
enabled: id != null && enabled,
})
}

Expand Down
4 changes: 3 additions & 1 deletion console/src/hooks/use-llm-call-detail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LlmCallDetail>(`/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<LlmCallDetail>(`/api/spans/${id}`, undefined, { signal }),
enabled: id != null,
})
}
10 changes: 9 additions & 1 deletion console/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(
path: string,
params?: Record<string, string | number | boolean | undefined>,
opts?: { signal?: AbortSignal },
): Promise<T> {
const url = new URL(path, window.location.origin)
if (params) {
Expand All @@ -25,7 +33,7 @@ export async function apiFetch<T>(
}
}

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)
Expand Down
37 changes: 31 additions & 6 deletions console/src/pages/agent-turn-detail-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
Loading