feat(webui): context-usage indicator fed by custom stream events
This commit is contained in:
@@ -27,7 +27,7 @@ export async function GET(request: NextRequest, context: RouteContext) {
|
||||
signal: request.signal,
|
||||
cancelOnDisconnect: false,
|
||||
lastEventId,
|
||||
streamMode: ["messages", "updates", "values", "tasks"],
|
||||
streamMode: ["messages", "updates", "values", "tasks", "custom"],
|
||||
});
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
|
||||
@@ -281,6 +281,9 @@ export async function POST(request: NextRequest, context: RouteContext) {
|
||||
metadata,
|
||||
config,
|
||||
multitaskStrategy: "enqueue",
|
||||
// langgraph dev only buffers the stream modes declared at run
|
||||
// creation; joinStream subscribers receive nothing outside them.
|
||||
streamMode: ["messages", "updates", "values", "tasks", "custom"],
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
import { ChatMessage } from "@/app/components/ChatMessage";
|
||||
import { ActionGroup } from "@/app/components/ActionGroup";
|
||||
import { CompactionSummary } from "@/app/components/CompactionSummary";
|
||||
import { ContextUsageIndicator } from "@/app/components/ContextUsageIndicator";
|
||||
import { extractMeasuredInputTokens } from "@/lib/contextUsage";
|
||||
import { ResearchDashboard } from "@/app/components/ResearchDashboard";
|
||||
import { isSummarizationMessage } from "@/lib/summarization";
|
||||
import { useCollapseAgentActions } from "@/lib/uiSettings";
|
||||
@@ -509,6 +511,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
subAgentActivity,
|
||||
asyncTasks,
|
||||
summarizationEvent,
|
||||
contextUsage,
|
||||
modelSelection,
|
||||
setModelSelection,
|
||||
} = useChatContext();
|
||||
@@ -771,6 +774,14 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
[selectableModels, modelSelection]
|
||||
);
|
||||
|
||||
// Provider-measured prompt size of the most recent model call — the
|
||||
// truthful numerator for the ctx pill (the middleware's stream event
|
||||
// only estimates message text + fixed reserves).
|
||||
const measuredInputTokens = useMemo(
|
||||
() => extractMeasuredInputTokens(messages),
|
||||
[messages]
|
||||
);
|
||||
|
||||
// Bind captured sub-agent activity (keyed by subgraph namespace) to each task
|
||||
// tool call → its live steps. B': match a finished sub-agent to a task by its
|
||||
// final text == the task's result; assign still-running sub-agents to the
|
||||
@@ -1811,11 +1822,21 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
<CompactionSummary
|
||||
content={summarizationEvent.content}
|
||||
summarizedCount={summarizationEvent.cutoffIndex}
|
||||
estimatedTokensBefore={
|
||||
summarizationEvent.estimatedTokensBefore
|
||||
}
|
||||
estimatedTokensAfter={
|
||||
summarizationEvent.estimatedTokensAfter
|
||||
}
|
||||
budgetHardTokens={
|
||||
summarizationEvent.budget?.hardTokens ?? null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<ChatMessage
|
||||
message={data.message}
|
||||
toolCalls={data.toolCalls}
|
||||
threadId={threadId}
|
||||
isLoading={isLoading}
|
||||
isStreaming={isLoading && isLastMessage && isAssistant}
|
||||
actionRequests={
|
||||
@@ -1840,6 +1861,15 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
<CompactionSummary
|
||||
content={summarizationEvent.content}
|
||||
summarizedCount={summarizationEvent.cutoffIndex}
|
||||
estimatedTokensBefore={
|
||||
summarizationEvent.estimatedTokensBefore
|
||||
}
|
||||
estimatedTokensAfter={
|
||||
summarizationEvent.estimatedTokensAfter
|
||||
}
|
||||
budgetHardTokens={
|
||||
summarizationEvent.budget?.hardTokens ?? null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{askUserQuestions && reviewMode !== "full" && (
|
||||
@@ -2326,6 +2356,12 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{threadId && (
|
||||
<ContextUsageIndicator
|
||||
usage={contextUsage}
|
||||
measuredTokens={measuredInputTokens}
|
||||
/>
|
||||
)}
|
||||
{threadId && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { CircleGauge } from "lucide-react";
|
||||
import {
|
||||
formatCompactTokens,
|
||||
type ContextUsageEvent,
|
||||
} from "@/lib/contextUsage";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Live context-occupancy pill.
|
||||
*
|
||||
* `measuredTokens` is the provider-measured prompt size of the most recent
|
||||
* model call (covers system prompt, memory and tools) and is preferred over
|
||||
* the middleware's message-text estimate, which systematically undercounts
|
||||
* whenever prompts carry large non-message sections. The denominator is the
|
||||
* snapshot-frozen `input_limit` carried by the stream event, so the pill
|
||||
* appears once the session's first model call completes.
|
||||
*/
|
||||
export const ContextUsageIndicator = React.memo<{
|
||||
usage: ContextUsageEvent | null;
|
||||
measuredTokens: number | null;
|
||||
}>(function ContextUsageIndicator({ usage, measuredTokens }) {
|
||||
if (!usage) return null;
|
||||
const estimated = usage.usedTokens + usage.reservedTokens;
|
||||
const occupied = measuredTokens ?? estimated;
|
||||
const limit = usage.inputLimit;
|
||||
|
||||
const percent = Math.max(0, Math.round((occupied / limit) * 100));
|
||||
const nearlyFull = occupied >= limit * 0.85;
|
||||
const label = `ctx ${percent}% · ${formatCompactTokens(
|
||||
occupied
|
||||
)}/${formatCompactTokens(limit)}`;
|
||||
const tooltip =
|
||||
(measuredTokens !== null
|
||||
? `Last model call used ${occupied.toLocaleString()} of ` +
|
||||
`${limit.toLocaleString()} tokens (measured)`
|
||||
: `Context used (estimate): ${occupied.toLocaleString()} of ` +
|
||||
`${limit.toLocaleString()} tokens`) +
|
||||
` · auto-compact messages at ≈${usage.softTokens.toLocaleString()}.`;
|
||||
|
||||
return (
|
||||
<span
|
||||
title={tooltip}
|
||||
aria-label={tooltip}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-1.5 rounded px-1.5 py-0.5",
|
||||
nearlyFull
|
||||
? "text-amber-700 dark:text-amber-300"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CircleGauge
|
||||
className="size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="hidden sm:inline">{label}</span>
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
ContextUsageIndicator.displayName = "ContextUsageIndicator";
|
||||
@@ -6,6 +6,10 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import type { TodoItem } from "@/app/types/types";
|
||||
import { useQueryState } from "nuqs";
|
||||
import { parseSummarizationEvent } from "@/lib/summarization";
|
||||
import {
|
||||
parseContextUsageEvent,
|
||||
type ContextUsageEvent,
|
||||
} from "@/lib/contextUsage";
|
||||
import { findActiveTurnId } from "@/lib/usageTurn";
|
||||
import { errorToast } from "@/lib/errorReporter";
|
||||
import type { ModelRef, ThreadModelSelection } from "@/lib/modelRegistry";
|
||||
@@ -356,6 +360,12 @@ export function useChat({
|
||||
null
|
||||
);
|
||||
const [fetchedThreadId, setFetchedThreadId] = useState<string | null>(null);
|
||||
// Latest context-occupancy event from the live run's custom stream, keyed
|
||||
// by thread so a stale value never leaks across a thread switch.
|
||||
const [contextUsageEntry, setContextUsageEntry] = useState<{
|
||||
threadId: string;
|
||||
usage: ContextUsageEvent;
|
||||
} | null>(null);
|
||||
const fetchedThreadIdRef = useRef<string | null>(null);
|
||||
fetchedThreadIdRef.current = fetchedThreadId;
|
||||
const recoveryRunRef = useRef(0);
|
||||
@@ -915,6 +925,10 @@ export function useChat({
|
||||
: undefined;
|
||||
messageAccumulator.add(event.data[0], metadata);
|
||||
}
|
||||
if (event.event === "custom") {
|
||||
const usage = parseContextUsageEvent(event.data);
|
||||
if (usage) setContextUsageEntry({ threadId: tracked.threadId, usage });
|
||||
}
|
||||
scheduleStreamFlush();
|
||||
if (event.event === "error") streamFailure = event.data;
|
||||
}
|
||||
@@ -1193,6 +1207,10 @@ export function useChat({
|
||||
fetchedValues?._summarization_event
|
||||
),
|
||||
ui: fetchedValues?.ui,
|
||||
contextUsage:
|
||||
contextUsageEntry && contextUsageEntry.threadId === threadId
|
||||
? contextUsageEntry.usage
|
||||
: null,
|
||||
setFiles,
|
||||
messages,
|
||||
isLoading: isRunLoading,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
extractMeasuredInputTokens,
|
||||
formatCompactTokens,
|
||||
parseContextUsageEvent,
|
||||
} from "@/lib/contextUsage";
|
||||
|
||||
function rawEvent(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
type: "evoscientist_context_usage",
|
||||
used_tokens: 12300,
|
||||
reserved_tokens: 16384,
|
||||
input_limit: 1015808,
|
||||
hard_tokens: 896000,
|
||||
soft_tokens: 627200,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("parseContextUsageEvent", () => {
|
||||
it("parses a well-formed event", () => {
|
||||
expect(parseContextUsageEvent(rawEvent())).toEqual({
|
||||
usedTokens: 12300,
|
||||
reservedTokens: 16384,
|
||||
inputLimit: 1015808,
|
||||
hardTokens: 896000,
|
||||
softTokens: 627200,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores custom events of other types", () => {
|
||||
expect(parseContextUsageEvent({ type: "something_else" })).toBeNull();
|
||||
expect(parseContextUsageEvent({})).toBeNull();
|
||||
expect(parseContextUsageEvent(null)).toBeNull();
|
||||
expect(parseContextUsageEvent("evoscientist_context_usage")).toBeNull();
|
||||
});
|
||||
|
||||
it("drops malformed stats", () => {
|
||||
expect(parseContextUsageEvent(rawEvent({ used_tokens: -1 }))).toBeNull();
|
||||
expect(
|
||||
parseContextUsageEvent(rawEvent({ used_tokens: "many" }))
|
||||
).toBeNull();
|
||||
expect(parseContextUsageEvent(rawEvent({ input_limit: 0 }))).toBeNull();
|
||||
expect(
|
||||
parseContextUsageEvent(rawEvent({ soft_tokens: undefined }))
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCompactTokens", () => {
|
||||
it("formats small, kilo and mega ranges", () => {
|
||||
expect(formatCompactTokens(999)).toBe("999");
|
||||
expect(formatCompactTokens(12300)).toBe("12k");
|
||||
expect(formatCompactTokens(54100)).toBe("54k");
|
||||
expect(formatCompactTokens(1015808)).toBe("1M");
|
||||
expect(formatCompactTokens(1536000)).toBe("1.5M");
|
||||
expect(formatCompactTokens(12582912)).toBe("13M");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractMeasuredInputTokens", () => {
|
||||
it("reads input_tokens from the last AI message with usage metadata", () => {
|
||||
const messages = [
|
||||
{ type: "human", content: "hi" },
|
||||
{ type: "ai", content: "a", usage_metadata: { input_tokens: 1000 } },
|
||||
{ type: "human", content: "again" },
|
||||
{ type: "ai", content: "b", usage_metadata: { input_tokens: 42000 } },
|
||||
];
|
||||
expect(extractMeasuredInputTokens(messages)).toBe(42000);
|
||||
});
|
||||
|
||||
it("falls back to response_metadata token_usage", () => {
|
||||
const messages = [
|
||||
{
|
||||
type: "ai",
|
||||
content: "a",
|
||||
response_metadata: { token_usage: { prompt_tokens: 777 } },
|
||||
},
|
||||
];
|
||||
expect(extractMeasuredInputTokens(messages)).toBe(777);
|
||||
});
|
||||
|
||||
it("skips malformed entries and returns null when nothing is measured", () => {
|
||||
expect(
|
||||
extractMeasuredInputTokens([
|
||||
{ type: "ai", content: "a", usage_metadata: { input_tokens: 0 } },
|
||||
{ type: "human", content: "hi" },
|
||||
])
|
||||
).toBeNull();
|
||||
expect(extractMeasuredInputTokens([])).toBeNull();
|
||||
expect(extractMeasuredInputTokens(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
// Live context-occupancy indicator support.
|
||||
//
|
||||
// The backend's MessageBudgetMiddleware emits one custom stream event per
|
||||
// model call carrying the character-estimated size of the prompt actually
|
||||
// sent (message text) plus the frozen budget of the run snapshot:
|
||||
//
|
||||
// {
|
||||
// type: "evoscientist_context_usage",
|
||||
// used_tokens: number, // message-text estimate of the sent prompt
|
||||
// reserved_tokens: number, // fixed system/tools/attachments reserves
|
||||
// input_limit: number, // snapshot's resolved input limit
|
||||
// hard_tokens: number, // auto-compaction ceiling (messages)
|
||||
// soft_tokens: number, // auto-compaction trigger (messages)
|
||||
// }
|
||||
//
|
||||
// `used_tokens + reserved_tokens` approximates total context occupancy; the
|
||||
// provider-measured sizes keep flowing through the usage pipeline and land in
|
||||
// the token-consumption view after the call completes.
|
||||
|
||||
export const CONTEXT_USAGE_EVENT_TYPE = "evoscientist_context_usage";
|
||||
|
||||
export interface ContextUsageEvent {
|
||||
usedTokens: number;
|
||||
reservedTokens: number;
|
||||
inputLimit: number;
|
||||
hardTokens: number;
|
||||
softTokens: number;
|
||||
}
|
||||
|
||||
function nonNegativeNumber(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate + normalize a raw custom-stream payload. Returns null for events
|
||||
* of other types or malformed stats so unknown custom events pass through
|
||||
* untouched.
|
||||
*/
|
||||
export function parseContextUsageEvent(
|
||||
raw: unknown
|
||||
): ContextUsageEvent | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const ev = raw as Record<string, unknown>;
|
||||
if (ev["type"] !== CONTEXT_USAGE_EVENT_TYPE) return null;
|
||||
const usedTokens = nonNegativeNumber(ev["used_tokens"]);
|
||||
const reservedTokens = nonNegativeNumber(ev["reserved_tokens"]);
|
||||
const inputLimit = nonNegativeNumber(ev["input_limit"]);
|
||||
const hardTokens = nonNegativeNumber(ev["hard_tokens"]);
|
||||
const softTokens = nonNegativeNumber(ev["soft_tokens"]);
|
||||
if (
|
||||
usedTokens === null ||
|
||||
reservedTokens === null ||
|
||||
inputLimit === null ||
|
||||
inputLimit <= 0 ||
|
||||
hardTokens === null ||
|
||||
softTokens === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { usedTokens, reservedTokens, inputLimit, hardTokens, softTokens };
|
||||
}
|
||||
|
||||
/** Compact token count for the ctx pill: 54000 → "54k", 1048576 → "1M". */
|
||||
export function formatCompactTokens(value: number): string {
|
||||
if (value >= 1_000_000) {
|
||||
const m = value / 1_000_000;
|
||||
return `${m >= 10 ? Math.round(m) : m.toFixed(1).replace(/\.0$/, "")}M`;
|
||||
}
|
||||
if (value >= 1_000) return `${Math.round(value / 1_000)}k`;
|
||||
return String(Math.round(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider-measured prompt size of the most recent model call, read from the
|
||||
* last AI message's `usage_metadata.input_tokens` (langchain stamps it on
|
||||
* every response; it covers the whole provider request — system prompt,
|
||||
* memory, tools — unlike the middleware's message-text estimate). Returns
|
||||
* null when no AI message carries measured usage yet.
|
||||
*/
|
||||
export function extractMeasuredInputTokens(messages: unknown): number | null {
|
||||
if (!Array.isArray(messages)) return null;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i] as Record<string, unknown> | null;
|
||||
if (!m || typeof m !== "object" || m["type"] !== "ai") continue;
|
||||
const usage = m["usage_metadata"] as Record<string, unknown> | undefined;
|
||||
const measured = nonNegativeNumber(usage?.["input_tokens"]);
|
||||
if (measured !== null && measured > 0) return measured;
|
||||
const responseMeta = m["response_metadata"] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const tokenUsage = responseMeta?.["token_usage"] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const promptTokens = nonNegativeNumber(tokenUsage?.["prompt_tokens"]);
|
||||
if (promptTokens !== null && promptTokens > 0) return promptTokens;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user