feat(webui): surface summarization budget events in compaction UI

This commit is contained in:
m4
2026-08-11 15:07:53 +08:00
parent e32dd49095
commit 141e25c3b0
4 changed files with 138 additions and 2 deletions
+11 -1
View File
@@ -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<string, SubAgentStep[]>;
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<ActionGroupProps>(function ActionGroup({
<CompactionSummary
content={summarizationEvent.content}
summarizedCount={summarizationEvent.cutoffIndex}
estimatedTokensBefore={
summarizationEvent.estimatedTokensBefore
}
estimatedTokensAfter={
summarizationEvent.estimatedTokensAfter
}
budgetHardTokens={
summarizationEvent.budget?.hardTokens ?? null
}
/>
)}
{content.trim() && (
+37 -1
View File
@@ -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<CompactionSummaryProps>(
({ 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 (
<div className="my-3 w-full">
@@ -45,6 +64,12 @@ export const CompactionSummary = React.memo<CompactionSummaryProps>(
{summarizedCount === 1 ? "message" : "messages"} summarized
</span>
)}
{hasTokenStats && (
<span className="text-muted-foreground/70">
· ≈{formatTokens(estimatedTokensBefore)} → ≈
{formatTokens(estimatedTokensAfter)} tokens
</span>
)}
<ChevronRight
className={cn(
"size-3.5 transition-transform",
@@ -58,6 +83,17 @@ export const CompactionSummary = React.memo<CompactionSummaryProps>(
{open && (
<div className="mt-2 overflow-hidden rounded-md border border-border bg-muted/20 px-4 py-3">
<MarkdownContent content={body} />
{hasTokenStats && (
<p className="mt-3 border-t border-border/60 pt-2 text-[11px] text-muted-foreground/80">
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.
</p>
)}
</div>
)}
</div>
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { parseSummarizationEvent } from "@/lib/summarization";
function rawEvent(overrides: Record<string, unknown> = {}) {
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();
});
});
+34
View File
@@ -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<string, unknown>;
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"]),
};
}