Skip to content

Commit 9c99667

Browse files
kevin-dpclaude
andcommitted
feat(agents): context-usage breakdown popover (system / tools / messages / free)
Hovering the usage indicator now reveals a per-part composition of the prompt, modelled on Claude Code's `/context`: a stacked bar + legend showing how much of the window the system prompt, tool definitions, conversation messages, and free space each occupy. The runtime persists an approximate decomposition of the stable request parts: pi-adapter estimates `{ system, tools }` token cost (char/4 via approxTokens) once per call and writes it to the step as `context_breakdown` alongside the cache-inclusive `context_input_tokens`. The UI derives the "messages" bucket as the real total minus those estimates, so the segments always sum to the gauge even though the part figures are approximate. New shared helpers `computeContextBreakdown` / `parseContextBreakdown` in token-accountant keep the math testable and out of the component. - entity-schema: additive `context_breakdown` string column on steps. - outbound-bridge / pi-adapter: compute + persist the estimate. - token-accountant: breakdown helpers + types, exported from the client entry. - UI: HoverCard popover (ContextUsageDetails) with a composition bar + legend. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8cef28e commit 9c99667

9 files changed

Lines changed: 391 additions & 24 deletions

File tree

packages/agents-runtime/src/client.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,18 @@ export {
5050
CONTEXT_USAGE_BACKGROUND_START,
5151
CONTEXT_USAGE_HARD_CEILING,
5252
computeContextUsage,
53+
computeContextBreakdown,
54+
parseContextBreakdown,
5355
contextUsageLevel,
5456
formatContextUsagePercent,
5557
} from './token-accountant'
5658
export type {
5759
ContextUsage,
5860
ContextUsageInput,
5961
ContextUsageLevel,
62+
ContextBreakdownKey,
63+
ContextBreakdownParts,
64+
ContextBreakdownSegment,
6065
} from './token-accountant'
6166
export type { GoalCommand } from './goal-command'
6267

packages/agents-runtime/src/entity-schema.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,12 @@ type StepValue = {
176176
// the usage so consumers can render a fraction without resolving the model
177177
// registry themselves.
178178
context_window?: number
179+
// JSON-encoded estimate of how the prompt decomposes across the stable
180+
// request parts — `{ system, tools }` token counts (approximate; char/4).
181+
// The UI derives the remaining "messages" bucket as the real cache-inclusive
182+
// total minus these, so the breakdown always sums to the gauge. Optional and
183+
// additive; older events without it stay valid.
184+
context_breakdown?: string
179185
}
180186
type TextValue = {
181187
key?: string
@@ -533,6 +539,7 @@ function createStepSchema(): Schema<StepValue> {
533539
duration_ms: z.number().int().optional(),
534540
input_tokens: z.number().int().nonnegative().optional(),
535541
output_tokens: z.number().int().nonnegative().optional(),
542+
context_breakdown: z.string().optional(),
536543
})
537544
}
538545

packages/agents-runtime/src/outbound-bridge.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,9 @@ export interface OutboundBridge {
163163
// Model context window for this step, persisted as `context_window`.
164164
contextWindow?: number
165165
durationMs?: number
166+
// Approx token decomposition of the stable request parts (system + tools),
167+
// persisted as `context_breakdown` for the usage-details popover.
168+
tokenBreakdown?: { system: number; tools: number }
166169
}) => void
167170
onTextStart: () => void
168171
onTextDelta: (delta: string) => void
@@ -332,6 +335,8 @@ export function createOutboundBridge(
332335
tokenContext?: number
333336
contextWindow?: number
334337
durationMs?: number
338+
/** Approximate token decomposition of the stable request parts. */
339+
tokenBreakdown?: { system: number; tools: number }
335340
}) {
336341
if (!currentStepKey) return
337342
writeEvent(
@@ -357,6 +362,9 @@ export function createOutboundBridge(
357362
...(opts?.contextWindow !== undefined && {
358363
context_window: opts.contextWindow,
359364
}),
365+
...(opts?.tokenBreakdown !== undefined && {
366+
context_breakdown: JSON.stringify(opts.tokenBreakdown),
367+
}),
360368
} as never,
361369
}) as ChangeEvent
362370
)

packages/agents-runtime/src/pi-adapter.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,15 @@ export function createPiAgentAdapter(
314314
const modelContextWindow =
315315
typeof model.contextWindow === `number` ? model.contextWindow : 0
316316

317+
// Approximate token cost of the stable, non-message request parts. These
318+
// are constant for the whole call, so estimate once and persist on each
319+
// step; the UI derives the "messages" bucket as the real cache-inclusive
320+
// total minus these (see token-accountant computeContextBreakdown).
321+
const tokenBreakdown = {
322+
system: approxTokens(opts.systemPrompt),
323+
tools: approxTokens(opts.tools),
324+
}
325+
317326
const transformContext =
318327
opts.onCompactContext && modelContextWindow > 0
319328
? async (
@@ -617,6 +626,7 @@ export function createPiAgentAdapter(
617626
tokenContext: usageContext,
618627
}),
619628
...(contextWindow !== undefined && { contextWindow }),
629+
tokenBreakdown,
620630
})
621631

622632
if (isError) {

packages/agents-runtime/src/token-accountant.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,80 @@ export function computeContextUsage(
7575
return { usedTokens, contextWindow: input.contextWindow, ratio }
7676
}
7777

78+
export type ContextBreakdownKey = `system` | `tools` | `messages` | `free`
79+
80+
export interface ContextBreakdownSegment {
81+
key: ContextBreakdownKey
82+
label: string
83+
tokens: number
84+
/** Fraction of the whole context window, [0, 1]. */
85+
ratio: number
86+
}
87+
88+
/** Estimated token cost of the stable request parts (from `context_breakdown`). */
89+
export interface ContextBreakdownParts {
90+
systemTokens?: number
91+
toolsTokens?: number
92+
}
93+
94+
const CONTEXT_BREAKDOWN_LABELS: Record<ContextBreakdownKey, string> = {
95+
system: `System prompt`,
96+
tools: `Tools`,
97+
messages: `Messages`,
98+
free: `Free space`,
99+
}
100+
101+
/**
102+
* Decompose a step's context usage into display segments: the *estimated*
103+
* stable parts (system prompt + tools, char/4 approximations persisted on the
104+
* step) plus the real "messages" remainder and the free space. The used
105+
* segments (everything but `free`) sum to `usage.usedTokens` and all four sum
106+
* to the window — so the breakdown always agrees with the gauge even though the
107+
* part estimates are approximate. Modelled on Claude Code's `/context` view.
108+
*/
109+
export function computeContextBreakdown(
110+
usage: ContextUsage,
111+
parts: ContextBreakdownParts = {}
112+
): Array<ContextBreakdownSegment> {
113+
const window = usage.contextWindow
114+
const used = Math.max(0, Math.min(usage.usedTokens, window))
115+
const system = Math.max(0, Math.min(parts.systemTokens ?? 0, used))
116+
const tools = Math.max(0, Math.min(parts.toolsTokens ?? 0, used - system))
117+
const messages = Math.max(0, used - system - tools)
118+
const free = Math.max(0, window - used)
119+
const segment = (
120+
key: ContextBreakdownKey,
121+
tokens: number
122+
): ContextBreakdownSegment => ({
123+
key,
124+
label: CONTEXT_BREAKDOWN_LABELS[key],
125+
tokens,
126+
ratio: window > 0 ? tokens / window : 0,
127+
})
128+
return [
129+
segment(`system`, system),
130+
segment(`tools`, tools),
131+
segment(`messages`, messages),
132+
segment(`free`, free),
133+
]
134+
}
135+
136+
/** Parse the persisted `context_breakdown` JSON into estimate parts (tolerant). */
137+
export function parseContextBreakdown(
138+
raw: string | undefined | null
139+
): ContextBreakdownParts {
140+
if (!raw) return {}
141+
try {
142+
const obj = JSON.parse(raw) as { system?: unknown; tools?: unknown }
143+
return {
144+
...(typeof obj.system === `number` && { systemTokens: obj.system }),
145+
...(typeof obj.tools === `number` && { toolsTokens: obj.tools }),
146+
}
147+
} catch {
148+
return {}
149+
}
150+
}
151+
78152
export type ContextUsageLevel = `normal` | `warning` | `critical`
79153

80154
/**

packages/agents-runtime/test/token-accountant.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import {
33
CONTEXT_USAGE_BACKGROUND_START,
44
CONTEXT_USAGE_HARD_CEILING,
55
computeContextUsage,
6+
computeContextBreakdown,
7+
parseContextBreakdown,
68
contextUsageLevel,
79
formatContextUsagePercent,
810
formatContextBudgetNotice,
@@ -13,6 +15,52 @@ import {
1315
} from '../src/token-accountant'
1416
import type { LLMMessage } from '../src/types'
1517

18+
describe(`computeContextBreakdown`, () => {
19+
const usage = { usedTokens: 50_000, contextWindow: 100_000, ratio: 0.5 }
20+
21+
it(`splits into system / tools / messages / free that sum to the window`, () => {
22+
const segs = computeContextBreakdown(usage, {
23+
systemTokens: 5_000,
24+
toolsTokens: 15_000,
25+
})
26+
expect(segs.map((s) => [s.key, s.tokens])).toEqual([
27+
[`system`, 5_000],
28+
[`tools`, 15_000],
29+
[`messages`, 30_000], // used (50k) − system − tools
30+
[`free`, 50_000], // window − used
31+
])
32+
// Used segments sum to usedTokens; all four sum to the window.
33+
expect(segs.slice(0, 3).reduce((n, s) => n + s.tokens, 0)).toBe(50_000)
34+
expect(segs.reduce((n, s) => n + s.tokens, 0)).toBe(100_000)
35+
expect(segs[2].ratio).toBeCloseTo(0.3)
36+
})
37+
38+
it(`defaults missing parts to zero (messages absorbs the whole used total)`, () => {
39+
const segs = computeContextBreakdown(usage)
40+
expect(segs.find((s) => s.key === `messages`)?.tokens).toBe(50_000)
41+
expect(segs.find((s) => s.key === `system`)?.tokens).toBe(0)
42+
})
43+
44+
it(`never produces negative messages when estimates exceed the real total`, () => {
45+
const segs = computeContextBreakdown(
46+
{ usedTokens: 1_000, contextWindow: 100_000, ratio: 0.01 },
47+
{ systemTokens: 5_000, toolsTokens: 15_000 }
48+
)
49+
// System is capped at used; tools/messages can't go below zero.
50+
expect(segs.find((s) => s.key === `messages`)?.tokens).toBe(0)
51+
expect(segs.every((s) => s.tokens >= 0)).toBe(true)
52+
})
53+
54+
it(`parseContextBreakdown reads persisted JSON and tolerates junk`, () => {
55+
expect(parseContextBreakdown(`{"system":12,"tools":34}`)).toEqual({
56+
systemTokens: 12,
57+
toolsTokens: 34,
58+
})
59+
expect(parseContextBreakdown(undefined)).toEqual({})
60+
expect(parseContextBreakdown(`not json`)).toEqual({})
61+
})
62+
})
63+
1664
describe(`computeContextUsage`, () => {
1765
it(`sums input + output against the window`, () => {
1866
const usage = computeContextUsage({
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
.panel {
2+
min-width: 230px;
3+
max-width: 280px;
4+
display: flex;
5+
flex-direction: column;
6+
gap: 0.5rem;
7+
font-variant-numeric: tabular-nums;
8+
}
9+
10+
.header {
11+
display: flex;
12+
align-items: baseline;
13+
justify-content: space-between;
14+
gap: 0.5rem;
15+
}
16+
17+
.title {
18+
font-size: 0.8rem;
19+
font-weight: 600;
20+
color: var(--text, inherit);
21+
}
22+
23+
.headline {
24+
font-size: 0.8rem;
25+
font-weight: 600;
26+
color: var(--text, inherit);
27+
}
28+
29+
.subhead {
30+
font-size: 0.72rem;
31+
color: var(--text-muted, rgba(0, 0, 0, 0.55));
32+
margin-top: -0.25rem;
33+
}
34+
35+
/* Stacked composition bar. */
36+
.bar {
37+
display: flex;
38+
width: 100%;
39+
height: 8px;
40+
border-radius: 4px;
41+
overflow: hidden;
42+
background: var(--surface-sunken, rgba(0, 0, 0, 0.06));
43+
}
44+
45+
.barSeg {
46+
height: 100%;
47+
display: block;
48+
}
49+
50+
.legend {
51+
list-style: none;
52+
margin: 0;
53+
padding: 0;
54+
display: flex;
55+
flex-direction: column;
56+
gap: 0.25rem;
57+
}
58+
59+
.legendRow {
60+
display: grid;
61+
grid-template-columns: 0.6rem 1fr auto auto;
62+
align-items: center;
63+
gap: 0.4rem;
64+
font-size: 0.72rem;
65+
}
66+
67+
.swatch {
68+
width: 0.6rem;
69+
height: 0.6rem;
70+
border-radius: 2px;
71+
flex-shrink: 0;
72+
}
73+
74+
.legendLabel {
75+
color: var(--text, inherit);
76+
overflow: hidden;
77+
text-overflow: ellipsis;
78+
white-space: nowrap;
79+
}
80+
81+
.legendTokens {
82+
color: var(--text-muted, rgba(0, 0, 0, 0.55));
83+
text-align: right;
84+
}
85+
86+
.legendPercent {
87+
color: var(--text-muted, rgba(0, 0, 0, 0.55));
88+
text-align: right;
89+
min-width: 2.4rem;
90+
}
91+
92+
.note {
93+
font-size: 0.66rem;
94+
color: var(--text-muted, rgba(0, 0, 0, 0.45));
95+
font-style: italic;
96+
}
97+
98+
/* Per-segment colours, shared by the bar fills and the legend swatches. */
99+
.system {
100+
background: var(--accent-9, #4c6ef5);
101+
}
102+
103+
.tools {
104+
background: var(--violet-9, #7048e8);
105+
}
106+
107+
.messages {
108+
background: var(--teal-9, #0ca678);
109+
}
110+
111+
.free {
112+
background: var(--surface-sunken, rgba(0, 0, 0, 0.12));
113+
}

0 commit comments

Comments
 (0)