From 141e25c3b01ff89bdb49f35c02e1f1cf36f43057 Mon Sep 17 00:00:00 2001 From: m4 Date: Tue, 11 Aug 2026 15:07:53 +0800 Subject: [PATCH] feat(webui): surface summarization budget events in compaction UI --- src/app/components/ActionGroup.tsx | 12 ++++- src/app/components/CompactionSummary.tsx | 38 +++++++++++++++- src/lib/summarization.test.ts | 56 ++++++++++++++++++++++++ src/lib/summarization.ts | 34 ++++++++++++++ 4 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 src/lib/summarization.test.ts diff --git a/src/app/components/ActionGroup.tsx b/src/app/components/ActionGroup.tsx index 504b799..f3113b4 100644 --- a/src/app/components/ActionGroup.tsx +++ b/src/app/components/ActionGroup.tsx @@ -23,6 +23,7 @@ import type { ToolCall, } from "@/app/types/types"; import type { SubAgentStep } from "@/lib/subAgentActivity"; +import type { SummarizationEvent } from "@/lib/summarization"; import { mapActionRequestsToToolCalls, summarizeActionGroup, @@ -66,7 +67,7 @@ interface ActionGroupProps { subAgentSteps: Record; ui: any[] | undefined; compactionAnchorId: string | null; - summarizationEvent: { content: string; cutoffIndex: number } | null; + summarizationEvent: SummarizationEvent | null; } const compactMarkdownClass = @@ -416,6 +417,15 @@ export const ActionGroup = React.memo(function ActionGroup({ )} {content.trim() && ( diff --git a/src/app/components/CompactionSummary.tsx b/src/app/components/CompactionSummary.tsx index e36bf11..328a151 100644 --- a/src/app/components/CompactionSummary.tsx +++ b/src/app/components/CompactionSummary.tsx @@ -11,6 +11,16 @@ interface CompactionSummaryProps { content: string; /** Number of earlier messages folded into this summary, if known. */ summarizedCount?: number; + /** Estimated message-text tokens right before compaction, if recorded. */ + estimatedTokensBefore?: number | null; + /** Estimated tokens of the compacted prompt actually sent, if recorded. */ + estimatedTokensAfter?: number | null; + /** Active hard message budget at compaction time, if recorded. */ + budgetHardTokens?: number | null; +} + +function formatTokens(value: number): string { + return value.toLocaleString("en-US"); } /** @@ -20,9 +30,18 @@ interface CompactionSummaryProps { * history intact, this is just the context the agent was handed after compaction. */ export const CompactionSummary = React.memo( - ({ content, summarizedCount }) => { + ({ + content, + summarizedCount, + estimatedTokensBefore, + estimatedTokensAfter, + budgetHardTokens, + }) => { const [open, setOpen] = useState(false); const body = extractSummaryBody(content); + const hasTokenStats = + typeof estimatedTokensBefore === "number" && + typeof estimatedTokensAfter === "number"; return (
@@ -45,6 +64,12 @@ export const CompactionSummary = React.memo( {summarizedCount === 1 ? "message" : "messages"} summarized )} + {hasTokenStats && ( + + · ≈{formatTokens(estimatedTokensBefore)} → ≈ + {formatTokens(estimatedTokensAfter)} tokens + + )} ( {open && (
+ {hasTokenStats && ( +

+ Prompt size (estimate): ≈{formatTokens(estimatedTokensBefore)}{" "} + tokens before compaction, ≈{formatTokens(estimatedTokensAfter)}{" "} + after + {typeof budgetHardTokens === "number" && + ` · hard message budget ≈${formatTokens(budgetHardTokens)} tokens`} + . Exact usage appears in token consumption once the next model + call completes. +

+ )}
)}
diff --git a/src/lib/summarization.test.ts b/src/lib/summarization.test.ts new file mode 100644 index 0000000..6b0f40b --- /dev/null +++ b/src/lib/summarization.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { parseSummarizationEvent } from "@/lib/summarization"; + +function rawEvent(overrides: Record = {}) { + return { + cutoff_index: 3, + summary_message: { + content: "Here is a summary of the conversation to date:\n\nbody", + additional_kwargs: { lc_source: "summarization" }, + }, + file_path: "/conversation_history/t.md", + ...overrides, + }; +} + +describe("parseSummarizationEvent token stats", () => { + it("parses estimated sizes and budget when the backend records them", () => { + const event = parseSummarizationEvent( + rawEvent({ + estimated_tokens_before: 5210, + estimated_tokens_after: 1280, + budget: { hard_tokens: 9000, soft_tokens: 6300, keep_tokens: 3150 }, + }) + ); + expect(event).not.toBeNull(); + expect(event!.estimatedTokensBefore).toBe(5210); + expect(event!.estimatedTokensAfter).toBe(1280); + expect(event!.budget).toEqual({ + hardTokens: 9000, + softTokens: 6300, + keepTokens: 3150, + }); + }); + + it("tolerates older events without token stats", () => { + const event = parseSummarizationEvent(rawEvent()); + expect(event).not.toBeNull(); + expect(event!.estimatedTokensBefore).toBeNull(); + expect(event!.estimatedTokensAfter).toBeNull(); + expect(event!.budget).toBeNull(); + }); + + it("drops malformed token stats but keeps the event", () => { + const event = parseSummarizationEvent( + rawEvent({ + estimated_tokens_before: "many", + estimated_tokens_after: -5, + budget: { hard_tokens: 9000, soft_tokens: "6300" }, + }) + ); + expect(event).not.toBeNull(); + expect(event!.estimatedTokensBefore).toBeNull(); + expect(event!.estimatedTokensAfter).toBeNull(); + expect(event!.budget).toBeNull(); + }); +}); diff --git a/src/lib/summarization.ts b/src/lib/summarization.ts index f65388e..6e67be0 100644 --- a/src/lib/summarization.ts +++ b/src/lib/summarization.ts @@ -32,12 +32,27 @@ import type { Message } from "@langchain/langgraph-sdk"; import { extractStringFromMessageContent } from "@/app/utils/utils"; +export interface SummarizationBudget { + hardTokens: number; + softTokens: number; + keepTokens: number; +} + export interface SummarizationEvent { cutoffIndex: number; /** The raw summary message content (wrapper + body). */ content: string; /** Path where the full history was offloaded, or null. */ filePath: string | null; + /** + * Estimated message-text tokens right before compaction, when the backend + * recorded them (character estimate, not the provider-measured count). + */ + estimatedTokensBefore: number | null; + /** Estimated tokens of the compacted prompt actually sent to the model. */ + estimatedTokensAfter: number | null; + /** The active message budget at compaction time, when recorded. */ + budget: SummarizationBudget | null; } /** @@ -111,6 +126,22 @@ export function extractSummaryBody(raw: string): string { return raw.trim(); } +function nonNegativeNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) && value >= 0 + ? value + : null; +} + +function parseBudget(raw: unknown): SummarizationBudget | null { + if (!raw || typeof raw !== "object") return null; + const budget = raw as Record; + const hard = nonNegativeNumber(budget["hard_tokens"]); + const soft = nonNegativeNumber(budget["soft_tokens"]); + const keep = nonNegativeNumber(budget["keep_tokens"]); + if (hard === null || soft === null || keep === null) return null; + return { hardTokens: hard, softTokens: soft, keepTokens: keep }; +} + /** * Validate + normalize the raw `_summarization_event` from thread state. * Returns null when absent or malformed. @@ -131,5 +162,8 @@ export function parseSummarizationEvent( cutoffIndex: typeof cutoff === "number" && cutoff >= 0 ? cutoff : 0, content, filePath: typeof filePath === "string" ? filePath : null, + estimatedTokensBefore: nonNegativeNumber(ev["estimated_tokens_before"]), + estimatedTokensAfter: nonNegativeNumber(ev["estimated_tokens_after"]), + budget: parseBudget(ev["budget"]), }; }