feat(webui): surface summarization budget events in compaction UI
This commit is contained in:
@@ -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() && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"]),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user