diff --git a/api/server/controllers/agents/__tests__/client.contextMetadata.spec.js b/api/server/controllers/agents/__tests__/client.contextMetadata.spec.js
new file mode 100644
index 0000000000..6df38efb6c
--- /dev/null
+++ b/api/server/controllers/agents/__tests__/client.contextMetadata.spec.js
@@ -0,0 +1,139 @@
+const AgentClient = require('../client');
+
+/** Minimal post-(maybe-)summary snapshot. baseUsed = maxContextTokens(1000) -
+ * remainingContextTokens(700) = 300, so the marker (summaryUsedTokens) is 300. */
+const snapshot = (summaryTokens) => ({
+ runId: 'run-1',
+ agentId: 'agent-1',
+ breakdown: {
+ maxContextTokens: 1000,
+ instructionTokens: 50,
+ systemMessageTokens: 50,
+ dynamicInstructionTokens: 0,
+ toolSchemaTokens: 0,
+ summaryTokens,
+ toolCount: 0,
+ messageCount: 1,
+ messageTokens: 20,
+ availableForMessages: 900,
+ },
+ contextBudget: 1000,
+ remainingContextTokens: 700,
+ prePruneContextTokens: 300,
+ effectiveInstructionTokens: 50,
+ calibrationRatio: 1,
+});
+
+const primary = { input_tokens: 10, output_tokens: 5, total_tokens: 15 };
+const summarizationUsage = { ...primary, usage_type: 'summarization' };
+const primaryFor = (runId, output_tokens) => ({
+ input_tokens: 10,
+ output_tokens,
+ total_tokens: 10 + output_tokens,
+ provider: 'openAI',
+ runId,
+});
+
+function buildMeta({ snap, latestUsageIndex, usageEvents }) {
+ const self = {
+ collectedThoughtSignatures: null,
+ usageEmitSink: usageEvents,
+ contextUsageSink: snap
+ ? { latest: snap, count: 1, latestUsageIndex }
+ : { latest: null, count: 0 },
+ };
+ return AgentClient.prototype.buildResponseMetadata.call(self);
+}
+
+describe('AgentClient.buildResponseMetadata — snapshot persistence + summary marker', () => {
+ it('persists the snapshot when a primary usage follows it (normal turn)', () => {
+ const meta = buildMeta({ snap: snapshot(0), latestUsageIndex: 0, usageEvents: [primary] });
+ expect(meta.contextUsage).toBeDefined();
+ expect(meta.summaryUsedTokens).toBeUndefined();
+ });
+
+ it('persists the post-summary snapshot when the only pre-primary usage is the summarization', () => {
+ /** A summarized turn: the summarization usage precedes the post-summary
+ * snapshot (index 1), then the model's primary usage follows it. The old
+ * count guard miscounted and dropped this; the new guard keeps it. The
+ * marker subtracts the summarization output (5): the generated summary is in
+ * the snapshot baseline (summaryTokens) AND the response tokenCount, so
+ * 300 − 5 = 295 keeps the client estimate from counting it twice. */
+ const meta = buildMeta({
+ snap: snapshot(80),
+ latestUsageIndex: 1,
+ usageEvents: [summarizationUsage, primary],
+ });
+ expect(meta.contextUsage).toBeDefined();
+ expect(meta.summaryUsedTokens).toBe(295);
+ });
+
+ it('still emits the summary marker when the final call emitted no usage', () => {
+ /** Interrupted summarized turn: no primary usage follows the latest snapshot,
+ * so the snapshot is (correctly) not persisted — but the coarse marker
+ * survives so the client estimate still caps the discarded history. The
+ * summarization output (5) is subtracted (300 − 5 = 295). */
+ const meta = buildMeta({
+ snap: snapshot(80),
+ latestUsageIndex: 1,
+ usageEvents: [summarizationUsage],
+ });
+ expect(meta.contextUsage).toBeUndefined();
+ expect(meta.summaryUsedTokens).toBe(295);
+ });
+
+ it('drops the snapshot and emits no marker when the final call had no usage and no summary', () => {
+ const meta = buildMeta({ snap: snapshot(0), latestUsageIndex: 1, usageEvents: [primary] });
+ expect(meta.contextUsage).toBeUndefined();
+ expect(meta.summaryUsedTokens).toBeUndefined();
+ });
+
+ it('does not persist the snapshot when only a parallel run produced post-snapshot usage', () => {
+ /** A snapshot (run-1) → B snapshot (run-1 is latest) but the only following
+ * usage belongs to a sibling run (run-2). The guard must NOT persist run-1's
+ * snapshot with run-2's output — it falls back to the per-message estimate. */
+ const meta = buildMeta({
+ snap: snapshot(0),
+ latestUsageIndex: 0,
+ usageEvents: [primaryFor('run-2', 99)],
+ });
+ expect(meta.contextUsage).toBeUndefined();
+ });
+
+ it('persists with the snapshot run output when its own primary usage follows', () => {
+ const meta = buildMeta({
+ snap: snapshot(0),
+ latestUsageIndex: 0,
+ usageEvents: [primaryFor('run-2', 99), primaryFor('run-1', 7)],
+ });
+ expect(meta.contextUsage).toBeDefined();
+ expect(meta.contextUsage.completedOutputTokens).toBe(7);
+ });
+
+ it('subtracts earlier tool-loop output from the summary marker (interrupted turn)', () => {
+ /** Multi-call summarized turn stopped before the final usage: the earlier
+ * call (output 40) is baked into baseUsed (300), so the marker is 300 − 40 =
+ * 260. No primary follows the snapshot, so the full snapshot is not persisted
+ * and the client uses this marker — which must not double-count the 40 that
+ * the response tokenCount also carries. */
+ const meta = buildMeta({
+ snap: snapshot(80),
+ latestUsageIndex: 1,
+ usageEvents: [primaryFor('run-1', 40)],
+ });
+ expect(meta.contextUsage).toBeUndefined();
+ expect(meta.summaryUsedTokens).toBe(260);
+ });
+
+ it('subtracts only this run’s earlier output, not a parallel run’s', () => {
+ const meta = buildMeta({
+ snap: snapshot(80),
+ latestUsageIndex: 2,
+ usageEvents: [primaryFor('run-2', 999), primaryFor('run-1', 40), primaryFor('run-1', 5)],
+ });
+ /** baseUsed 300 − run-1's earlier 40 = 260; run-2's 999 is ignored. */
+ expect(meta.summaryUsedTokens).toBe(260);
+ /** run-1's own primary follows the snapshot → snapshot persisted with output 5. */
+ expect(meta.contextUsage.completedOutputTokens).toBe(5);
+ });
+});
diff --git a/api/server/controllers/agents/__tests__/usageEvents.integration.spec.js b/api/server/controllers/agents/__tests__/usageEvents.integration.spec.js
index 9023d210cd..046b236936 100644
--- a/api/server/controllers/agents/__tests__/usageEvents.integration.spec.js
+++ b/api/server/controllers/agents/__tests__/usageEvents.integration.spec.js
@@ -1,7 +1,7 @@
const { z } = require('zod');
const { tool } = require('@langchain/core/tools');
const { ChatGenerationChunk } = require('@langchain/core/outputs');
-const { HumanMessage, AIMessageChunk } = require('@langchain/core/messages');
+const { HumanMessage, AIMessage, AIMessageChunk } = require('@langchain/core/messages');
const {
Run,
Providers,
@@ -377,4 +377,95 @@ describe('usage events through the real agents pipeline', () => {
expect(resumeState.contextUsage.prePruneContextTokens).toBeGreaterThan(0);
}
});
+
+ /** Drives a real summarization (tight context + padded history); self-summarize
+ * reuses the overridden fake model so no API key is needed. */
+ async function runSummarizationLoop({ res, collectedUsage, contextUsageSink, usageEmitSink }) {
+ const { aggregateContent } = createContentAggregator();
+ const handlers = getDefaultHandlers({
+ res,
+ aggregateContent,
+ toolEndCallback: () => {},
+ collectedUsage,
+ contextUsageSink,
+ usageEmitSink,
+ summarizationOptions: { enabled: true },
+ });
+
+ const pad = 'context detail to overflow the tiny budget. '.repeat(40);
+ const history = [
+ new HumanMessage(`Turn 1 question. ${pad}`),
+ new AIMessage(`Turn 1 answer. ${pad}`),
+ new HumanMessage(`Turn 2 question. ${pad}`),
+ new AIMessage(`Turn 2 answer. ${pad}`),
+ new HumanMessage(`Final question after a lot of prior history. ${pad}`),
+ ];
+ const indexTokenCountMap = {};
+ history.forEach((message, i) => {
+ indexTokenCountMap[i] = charCounter(message);
+ });
+
+ const run = await Run.create({
+ runId: `summ-e2e-${Date.now()}`,
+ graphConfig: {
+ type: 'standard',
+ llmConfig: {
+ provider: Providers.OPENAI,
+ model: 'gpt-4o-mini',
+ streaming: true,
+ streamUsage: false,
+ },
+ instructions: 'You are a helpful assistant.',
+ maxContextTokens: 700,
+ summarizationEnabled: true,
+ summarizationConfig: { provider: Providers.OPENAI, model: 'gpt-4o-mini' },
+ },
+ returnContent: true,
+ customHandlers: handlers,
+ tokenCounter: charCounter,
+ indexTokenCountMap,
+ });
+
+ run.Graph.overrideModel = new UsageFakeModel(
+ { responses: ['## Summary\nPrior turns compacted.', 'Here is the final answer.'] },
+ [{ input_tokens: 40, output_tokens: 8, total_tokens: 48 }],
+ );
+
+ await run.processStream(
+ { messages: history },
+ {
+ configurable: { thread_id: 'summ-e2e-thread', user_id: 'user-1' },
+ streamMode: 'values',
+ version: 'v2',
+ },
+ );
+ return run;
+ }
+
+ /** A summarized turn compacts the context (summary tokens replace the older
+ * turns) and the reduced snapshot is persisted — the latest snapshot is
+ * followed by a primary usage, so the save guard keeps it and the client
+ * uses the snapshot (not the inflated whole-history estimate). */
+ test('persists the reduced (compacted) snapshot after summarization', async () => {
+ if (!hasContextUsageEvent) {
+ return;
+ }
+ const res = createMockRes();
+ const contextUsageSink = { latest: null, count: 0 };
+ const usageEmitSink = [];
+ await runSummarizationLoop({ res, collectedUsage: [], contextUsageSink, usageEmitSink });
+
+ const snapshot = contextUsageSink.latest;
+ /** Summarization fired: a summary exists and the kept message tokens are
+ * small (the compacted context, not the full history). */
+ expect(snapshot?.breakdown?.summaryTokens).toBeGreaterThan(0);
+ expect(snapshot?.breakdown?.messageTokens).toBeLessThan(snapshot?.breakdown?.summaryTokens);
+
+ /** The save guard keeps it: a primary usage follows the latest snapshot. */
+ const afterLatest = usageEmitSink.slice(contextUsageSink.latestUsageIndex ?? 0);
+ expect(afterLatest.some((e) => e.usage_type == null)).toBe(true);
+ expect(
+ buildPersistedContextUsage(snapshot, usageEmitSink).breakdown.summaryTokens,
+ ).toBeGreaterThan(0);
+ });
});
diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js
index e7cb3dfdaa..d0b2ea4aaa 100644
--- a/api/server/controllers/agents/callbacks.js
+++ b/api/server/controllers/agents/callbacks.js
@@ -546,16 +546,23 @@ function getDefaultHandlers({
checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node) ||
!metadata?.hide_sequential_outputs
) {
- await emitEvent(res, streamId, { event, data });
- /** Capture the latest visible snapshot (last-wins) + count visible
- * snapshots (one per model call). The count lets the save path persist
- * the breakdown only when the FINAL call emitted usage (primary usage
- * events === snapshots), so completedOutputTokens is a real
- * post-snapshot delta and reload doesn't over-report. */
+ /** Capture the latest visible snapshot (last-wins) and how many usage
+ * events preceded it BEFORE awaiting the emit. `emitEvent` can yield
+ * (resumable SSE / Redis publish); with parallel runs active this
+ * call's own primary usage could land in `usageEmitSink` during that
+ * yield, pushing `latestUsageIndex` past the very event that proves the
+ * snapshot completed — the save path would then slice it away and drop
+ * a valid breakdown. The recorded index lets the save path persist only
+ * when a PRIMARY usage follows this snapshot (the snapshot's call
+ * actually invoked the model); a summarization detour emits a snapshot
+ * whose only following usage is tagged `summarization`, which a plain
+ * snapshot-count would over-count and wrongly drop. */
if (contextUsageSink) {
contextUsageSink.latest = data;
contextUsageSink.count = (contextUsageSink.count ?? 0) + 1;
+ contextUsageSink.latestUsageIndex = usageEmitSink?.length ?? 0;
}
+ await emitEvent(res, streamId, { event, data });
}
},
};
diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js
index 0b18529e38..94d17fa3f5 100644
--- a/api/server/controllers/agents/client.js
+++ b/api/server/controllers/agents/client.js
@@ -26,6 +26,8 @@ const {
aggregateEmittedUsage,
resolveAgentTokenConfig,
buildPersistedContextUsage,
+ computeSummaryUsedTokens,
+ priorRunOutputTokens,
createSubagentUsageSink,
isDeepSeekReasoningProvider,
GenerationJobManager,
@@ -869,16 +871,49 @@ class AgentClient extends BaseClient {
metadata.thoughtSignatures = signatures;
}
const usageEvents = this.usageEmitSink ?? [];
- /** Persist the breakdown only when the FINAL visible call (the one the latest
- * snapshot precedes) emitted usage — i.e. as many primary usage events as
- * visible snapshots. If the final call emitted no usage_metadata (provider
- * gap, or interrupted after an earlier call did emit), `completedOutputTokens`
- * would be an earlier call's output the latest snapshot already counts, so
- * reload would over-report; fall back to the coarse per-message estimate. */
- const primaryUsageCount = usageEvents.filter((event) => event.usage_type == null).length;
- const snapshotCount = this.contextUsageSink?.count ?? 0;
- if (this.contextUsageSink?.latest && snapshotCount > 0 && primaryUsageCount >= snapshotCount) {
- metadata.contextUsage = buildPersistedContextUsage(this.contextUsageSink.latest, usageEvents);
+ /** Persist the breakdown only when the latest snapshot's OWN run completed —
+ * i.e. a PRIMARY usage event (usage_type == null) from that run's id arrived
+ * AFTER the snapshot. Matching by run id keeps `completedOutputTokens` a real
+ * post-snapshot delta even when parallel/direct runs interleave (A snapshot →
+ * B snapshot → A usage must NOT persist B's snapshot with A's output); an
+ * interrupted final call that emits no usage falls back to the per-message
+ * estimate. It still keeps the post-summary snapshot: the summarization detour
+ * emits an extra snapshot whose following primary usage shares that run's id,
+ * which the old snapshot-count guard miscounted and wrongly dropped. Events
+ * without a run id (older lib / resume) match any snapshot for back-compat. */
+ const latestSnapshot = this.contextUsageSink?.latest;
+ const latestSnapshotUsageIndex = this.contextUsageSink?.latestUsageIndex ?? 0;
+ const latestSnapshotRunId = latestSnapshot?.runId;
+ const hasPrimaryAfterSnapshot = usageEvents
+ .slice(latestSnapshotUsageIndex)
+ .some(
+ (event) =>
+ event.usage_type == null &&
+ (latestSnapshotRunId == null ||
+ event.runId == null ||
+ event.runId === latestSnapshotRunId),
+ );
+ if (latestSnapshot && hasPrimaryAfterSnapshot) {
+ metadata.contextUsage = buildPersistedContextUsage(latestSnapshot, usageEvents);
+ }
+ /** Lightweight summarization marker — persisted whenever this turn compacted
+ * the context, INDEPENDENT of the snapshot guard above. When the client has
+ * no usable snapshot on the branch and falls back to the per-message
+ * estimate, it caps the discarded pre-summary history at this baseline
+ * instead of re-summing it (the gauge otherwise reads 100% forever). Shared
+ * with the abort save path via `computeSummaryUsedTokens`. Subtract the
+ * response's earlier tool-loop outputs (the primaries that preceded the
+ * latest snapshot, same run): those tokens are inside the snapshot baseline
+ * AND in the response `tokenCount` the client estimate adds on top, so
+ * leaving them in the marker double-counts them on a multi-call turn. */
+ const priorOutputTokens = priorRunOutputTokens(
+ usageEvents,
+ latestSnapshotUsageIndex,
+ latestSnapshotRunId,
+ );
+ const summaryUsedTokens = computeSummaryUsedTokens(latestSnapshot, priorOutputTokens);
+ if (summaryUsedTokens != null) {
+ metadata.summaryUsedTokens = summaryUsedTokens;
}
const usage = aggregateEmittedUsage(usageEvents);
if (usage) {
diff --git a/client/src/components/Chat/Input/TokenUsage/Breakdown.tsx b/client/src/components/Chat/Input/TokenUsage/Breakdown.tsx
index cdc5a138ae..bc1c5ae3c8 100644
--- a/client/src/components/Chat/Input/TokenUsage/Breakdown.tsx
+++ b/client/src/components/Chat/Input/TokenUsage/Breakdown.tsx
@@ -133,6 +133,13 @@ export default function Breakdown({ view, showCost, currency }: BreakdownProps)
>
) : (
<>
+ {view.branchTotals.summaryBaseline > 0 && (
+
+ )}
{
expect(altTotals.output).toBe(1019);
});
+ it('caps the branch at a summary marker instead of re-summing compacted history', () => {
+ const summarized = {
+ messageId: 'a2',
+ parentMessageId: 'u2',
+ isCreatedByUser: false,
+ tokenCount: 40,
+ conversationId: CONVO,
+ text: '',
+ /** a2's turn compacted the history; pre-invoke context was 500 tokens. */
+ metadata: { summaryUsedTokens: 500 },
+ } as TMessage;
+ buildIndex(CONVO, [
+ msg('u1', Constants.NO_PARENT, true, 100),
+ msg('a1', 'u1', false, 9000) /** huge pre-summary history, now discarded */,
+ msg('u2', 'a1', true, 200),
+ summarized,
+ msg('u3', 'a2', true, 15),
+ msg('a3', 'u3', false, 25),
+ ]);
+
+ const totals = sumBranch(CONVO, 'a3');
+ /** Walk stops at a2: only its output + the post-summary turn are summed. */
+ expect(totals.summaryBaseline).toBe(500);
+ expect(totals.input).toBe(15);
+ expect(totals.output).toBe(65); // a3 (25) + a2 (40)
+ /** Estimate used = post-summary + compacted baseline = 580, not the 9380
+ * raw history sum that pinned the gauge at 100%. */
+ expect(totals.input + totals.output + totals.summaryBaseline).toBe(580);
+ });
+
+ it('keeps provider usage/cost across the full branch even past a summary marker', () => {
+ const summarized = {
+ messageId: 'a2',
+ parentMessageId: 'u2',
+ isCreatedByUser: false,
+ tokenCount: 40,
+ conversationId: CONVO,
+ text: '',
+ metadata: { usage: USAGE_B, summaryUsedTokens: 500 },
+ } as TMessage;
+ buildIndex(CONVO, [
+ msg('u1', Constants.NO_PARENT, true, 10),
+ responseMsg('a1', 'u1', 9000, USAGE_A) /** pre-summary spend */,
+ msg('u2', 'a1', true, 200),
+ summarized,
+ msg('u3', 'a2', true, 15),
+ responseMsg('a3', 'u3', 25, USAGE_A) /** post-summary spend */,
+ ]);
+
+ const totals = sumBranch(CONVO, 'a3');
+ /** Context is capped at the marker... */
+ expect(totals.summaryBaseline).toBe(500);
+ /** ...but cost/usage is cumulative spend and spans the WHOLE branch
+ * (a1 + a2 + a3 = 0.01 + 0.02 + 0.01), not truncated at the summary boundary. */
+ expect(totals.usage.cost).toBeCloseTo(0.04);
+ expect(totals.usage.input).toBe(400); // 100 + 200 + 100
+ });
+
it('flags whether the anchor message is on the branch', () => {
buildIndex(CONVO, [
msg('u1', Constants.NO_PARENT, true, 10),
@@ -99,6 +157,34 @@ describe('token index', () => {
expect(sumBranch(CONVO, 'a1-alt', 'a1').containsAnchor).toBe(false);
});
+ it('stops matching the anchor once the context is capped at a summary marker', () => {
+ const summarized = {
+ messageId: 'a2',
+ parentMessageId: 'u2',
+ isCreatedByUser: false,
+ tokenCount: 40,
+ conversationId: CONVO,
+ text: '',
+ metadata: { summaryUsedTokens: 500 },
+ } as TMessage;
+ buildIndex(CONVO, [
+ msg('u1', Constants.NO_PARENT, true, 100),
+ msg('a1', 'u1', false, 20),
+ msg('u2', 'a1', true, 200),
+ summarized,
+ msg('u3', 'a2', true, 15),
+ msg('a3', 'u3', false, 25),
+ ]);
+ /** a1 is older than the summary marker (a2): a snapshot anchored there is
+ * pre-summary, so it must NOT count as on-branch — else useTokenUsage revives
+ * that stale breakdown over the summary-baseline estimate. */
+ expect(sumBranch(CONVO, 'a3', 'a1').containsAnchor).toBe(false);
+ /** The summarized response's own (post-summary) snapshot still matches... */
+ expect(sumBranch(CONVO, 'a3', 'a2').containsAnchor).toBe(true);
+ /** ...as does a snapshot from a newer turn. */
+ expect(sumBranch(CONVO, 'a3', 'a3').containsAnchor).toBe(true);
+ });
+
it('tracks uncounted messages and tolerates missing parents', () => {
buildIndex(CONVO, [msg('u2', 'missing-parent', true, undefined), msg('a2', 'u2', false, 15)]);
@@ -159,6 +245,31 @@ describe('findBranchSnapshotAnchor', () => {
expect(findBranchSnapshotAnchor(CONVO, 'a1', new Map())).toBeNull();
expect(findBranchSnapshotAnchor('unknown', 'a1', new Map([['a1', 1]]))).toBeNull();
});
+
+ it('does not cross a summary marker to recover a stale pre-summary snapshot', () => {
+ const summarized = {
+ messageId: 'a2',
+ parentMessageId: 'u2',
+ isCreatedByUser: false,
+ tokenCount: 40,
+ conversationId: CONVO,
+ text: '',
+ metadata: { summaryUsedTokens: 500 },
+ } as TMessage;
+ buildIndex(CONVO, [
+ msg('u1', Constants.NO_PARENT, true, 10),
+ msg('a1', 'u1', false, 20) /** has a snapshot, but pre-summary */,
+ msg('u2', 'a1', true, 30),
+ summarized /** compacted here, no snapshot of its own */,
+ msg('u3', 'a2', true, 15),
+ msg('a3', 'u3', false, 25),
+ ]);
+ /** a1 is the only stored anchor, but it sits before the summary — the walk
+ * must stop at a2 and return null so the summary-baseline estimate is used. */
+ expect(findBranchSnapshotAnchor(CONVO, 'a3', new Map([['a1', 1]]))).toBeNull();
+ /** When the summarized response itself has a snapshot, return it. */
+ expect(findBranchSnapshotAnchor(CONVO, 'a3', new Map([['a2', 1]]))).toBe('a2');
+ });
});
describe('estimateTokens', () => {
diff --git a/client/src/utils/tokens.ts b/client/src/utils/tokens.ts
index 9bb129c6c9..adbf65a097 100644
--- a/client/src/utils/tokens.ts
+++ b/client/src/utils/tokens.ts
@@ -31,6 +31,10 @@ export interface TokenEntry {
parentMessageId: string | null;
/** Per-response provider usage from `metadata.usage` (response messages only) */
usage?: BranchUsage;
+ /** Pre-invoke compacted context size (`metadata.summaryUsedTokens`) for a
+ * response whose turn summarized. Caps the estimate so it stops re-summing
+ * the now-discarded pre-summary history. */
+ summaryUsedTokens?: number;
}
export interface BranchTotals {
@@ -47,6 +51,11 @@ export interface BranchTotals {
containsAnchor: boolean;
/** Provider usage/cost summed along the active branch */
usage: BranchUsage;
+ /** Compacted-context baseline from the deepest summarized response on the
+ * branch (0 if none). The branch walk stops there, so `input`/`output` cover
+ * only the post-summary messages; the estimate adds this to avoid counting
+ * the discarded pre-summary history. */
+ summaryBaseline: number;
}
export const EMPTY_BRANCH: BranchTotals = {
@@ -57,6 +66,7 @@ export const EMPTY_BRANCH: BranchTotals = {
tailId: null,
containsAnchor: false,
usage: EMPTY_USAGE,
+ summaryBaseline: 0,
};
/** Module-level token index: conversationId → messageId → entry. Not render state. */
@@ -128,11 +138,16 @@ function addUsage(target: BranchUsage, usage?: BranchUsage): void {
}
function toEntry(message: Partial): TokenEntry {
+ const summaryUsedTokens = message.metadata?.summaryUsedTokens;
return {
tokenCount: typeof message.tokenCount === 'number' ? message.tokenCount : 0,
isCreatedByUser: message.isCreatedByUser === true,
parentMessageId: message.parentMessageId ?? null,
usage: readPersistedUsage(message),
+ summaryUsedTokens:
+ typeof summaryUsedTokens === 'number' && summaryUsedTokens > 0
+ ? summaryUsedTokens
+ : undefined,
};
}
@@ -218,6 +233,11 @@ export function sumBranch(
const totals = { input: 0, output: 0, counted: 0, total: 0, containsAnchor: false };
const usage: BranchUsage = { ...EMPTY_USAGE };
+ let summaryBaseline = 0;
+ /** Once a summary marker is crossed, older turns are out of the CONTEXT WINDOW
+ * (subsumed by the baseline) — but their provider spend still happened, so the
+ * usage/cost walk continues to the root while context counting stops. */
+ let contextCapped = false;
let currentId: string | null = tailId;
let guard = index.size;
@@ -227,10 +247,15 @@ export function sumBranch(
break;
}
totals.total += 1;
- if (anchorId != null && currentId === anchorId) {
+ /** Only match the anchor while still inside the active context window. An
+ * anchor OLDER than the deepest summary marker belongs to a pre-summary
+ * snapshot; treating it as on-branch would let `useTokenUsage` revive that
+ * stale breakdown (discarded history) over the summary-baseline estimate
+ * that `findBranchSnapshotAnchor` correctly refuses to recover. */
+ if (!contextCapped && anchorId != null && currentId === anchorId) {
totals.containsAnchor = true;
}
- if (entry.tokenCount > 0) {
+ if (!contextCapped && entry.tokenCount > 0) {
totals.counted += 1;
if (entry.isCreatedByUser) {
totals.input += entry.tokenCount;
@@ -238,11 +263,19 @@ export function sumBranch(
totals.output += entry.tokenCount;
}
}
+ /** Cost/usage is cumulative spend — never truncated at the summary boundary. */
addUsage(usage, entry.usage);
+ /** This response's turn compacted the history: its own output is counted
+ * above; record the pre-invoke compacted baseline and stop counting context
+ * tokens for older (summarized-away) turns, but keep walking for cost. */
+ if (!contextCapped && entry.summaryUsedTokens != null) {
+ summaryBaseline = entry.summaryUsedTokens;
+ contextCapped = true;
+ }
currentId = entry.parentMessageId;
}
- return { ...totals, tailId, usage };
+ return { ...totals, tailId, usage, summaryBaseline };
}
/**
@@ -311,6 +344,12 @@ export function findBranchSnapshotAnchor(
if (!entry) {
break;
}
+ /** Stop at a summarized response that has no snapshot of its own: crossing it
+ * would recover an older PRE-summary snapshot (discarded history), which the
+ * summary-baseline estimate is meant to replace. */
+ if (entry.summaryUsedTokens != null) {
+ return null;
+ }
currentId = entry.parentMessageId;
}
diff --git a/packages/api/src/agents/usage.spec.ts b/packages/api/src/agents/usage.spec.ts
index 732db1da19..2a1ef75fec 100644
--- a/packages/api/src/agents/usage.spec.ts
+++ b/packages/api/src/agents/usage.spec.ts
@@ -10,6 +10,8 @@ import {
resolveAgentTokenConfig,
buildPersistedContextUsage,
buildAbortedResponseMetadata,
+ computeSummaryUsedTokens,
+ priorRunOutputTokens,
} from './usage';
describe('recordCollectedUsage', () => {
@@ -1752,6 +1754,157 @@ describe('buildPersistedContextUsage', () => {
it('omits completedOutputTokens when there are no primary calls', () => {
expect(buildPersistedContextUsage(baseSnapshot, []).completedOutputTokens).toBeUndefined();
});
+
+ it('attributes completedOutputTokens to the snapshot run, not a parallel run', () => {
+ /** Parallel/direct runs interleave: this snapshot is run-1, but run-2 emits a
+ * later primary usage. The persisted delta must be run-1's own output (40),
+ * never run-2's trailing output (99). */
+ const events: TTokenUsageEvent[] = [
+ {
+ input_tokens: 100,
+ output_tokens: 40,
+ total_tokens: 140,
+ provider: 'openAI',
+ runId: 'run-1',
+ },
+ {
+ input_tokens: 200,
+ output_tokens: 99,
+ total_tokens: 299,
+ provider: 'openAI',
+ runId: 'run-2',
+ },
+ ];
+ const result = buildPersistedContextUsage(baseSnapshot, events);
+ expect(result.completedOutputTokens).toBe(40);
+ });
+
+ it('omits completedOutputTokens when only other runs emitted usage', () => {
+ /** The snapshot run never completed (no matching primary); a sibling run's
+ * output must not be borrowed — fall back to the per-message estimate. */
+ const events: TTokenUsageEvent[] = [
+ {
+ input_tokens: 200,
+ output_tokens: 99,
+ total_tokens: 299,
+ provider: 'openAI',
+ runId: 'run-2',
+ },
+ ];
+ expect(buildPersistedContextUsage(baseSnapshot, events).completedOutputTokens).toBeUndefined();
+ });
+
+ it('matches untagged usage events for back-compat (older lib / resume)', () => {
+ /** Events without a runId predate run tagging; they match any snapshot so the
+ * last primary (25) is still recorded. */
+ const events: TTokenUsageEvent[] = [
+ { input_tokens: 100, output_tokens: 40, total_tokens: 140, provider: 'openAI' },
+ { input_tokens: 200, output_tokens: 25, total_tokens: 225, provider: 'openAI' },
+ ];
+ expect(buildPersistedContextUsage(baseSnapshot, events).completedOutputTokens).toBe(25);
+ });
+});
+
+describe('computeSummaryUsedTokens', () => {
+ const summarized = (over?: Partial): TContextUsageEvent => ({
+ runId: 'run-1',
+ breakdown: {
+ maxContextTokens: 1000,
+ instructionTokens: 50,
+ systemMessageTokens: 50,
+ dynamicInstructionTokens: 0,
+ toolSchemaTokens: 0,
+ summaryTokens: 80,
+ toolCount: 0,
+ messageCount: 1,
+ messageTokens: 20,
+ availableForMessages: 900,
+ },
+ contextBudget: 1000,
+ remainingContextTokens: 700,
+ effectiveInstructionTokens: 50,
+ ...over,
+ });
+
+ it('uses contextBudget − remainingContextTokens when available', () => {
+ expect(computeSummaryUsedTokens(summarized())).toBe(300);
+ });
+
+ it('falls back to instructions + summary + messages, including summaryTokens', () => {
+ /** summaryTokens is a separate breakdown field, so the no-remaining fallback
+ * must add it: 50 + 80 + 20 = 150, not 70. */
+ expect(computeSummaryUsedTokens(summarized({ remainingContextTokens: undefined }))).toBe(150);
+ });
+
+ it('returns undefined when the turn did not summarize', () => {
+ expect(
+ computeSummaryUsedTokens(
+ summarized({ breakdown: { ...summarized().breakdown, summaryTokens: 0 } }),
+ ),
+ ).toBeUndefined();
+ expect(computeSummaryUsedTokens(null)).toBeUndefined();
+ expect(computeSummaryUsedTokens(undefined)).toBeUndefined();
+ });
+
+ it('subtracts the response’s earlier tool-loop outputs from the marker', () => {
+ /** 300 baseUsed − 90 earlier outputs = 210, so the client estimate
+ * (summaryBaseline + full response tokenCount) doesn’t double-count them. */
+ expect(computeSummaryUsedTokens(summarized(), 90)).toBe(210);
+ });
+
+ it('clamps to undefined when the prior outputs exceed the baseline', () => {
+ expect(computeSummaryUsedTokens(summarized(), 5000)).toBeUndefined();
+ });
+});
+
+describe('priorRunOutputTokens', () => {
+ const ev = (over: Partial): TTokenUsageEvent => ({
+ input_tokens: 10,
+ output_tokens: 0,
+ total_tokens: 10,
+ provider: 'openAI',
+ ...over,
+ });
+
+ it('sums primary outputs before the index for the matching run', () => {
+ const events = [
+ ev({ output_tokens: 20, runId: 'run-1' }),
+ ev({ output_tokens: 30, runId: 'run-1' }),
+ ev({ output_tokens: 99, runId: 'run-1' }), // at/after the index — excluded
+ ];
+ expect(priorRunOutputTokens(events, 2, 'run-1')).toBe(50);
+ });
+
+ it('counts run-matched primary + summarization, skips subagent/sequential and other runs', () => {
+ /** Both the primary and the summarization output are in this run's tokenCount
+ * AND baseline, so both are subtracted; subagent/sequential are excluded from
+ * the reported output total; a parallel run's primary is not this snapshot's. */
+ const events = [
+ ev({ output_tokens: 20, runId: 'run-1' }), // primary, matches
+ ev({ output_tokens: 8, runId: 'run-1', usage_type: 'summarization' }), // counted
+ ev({ output_tokens: 5, runId: 'run-1', usage_type: 'subagent' }), // skipped
+ ev({ output_tokens: 7, runId: 'run-1', usage_type: 'sequential' }), // skipped
+ ev({ output_tokens: 40, runId: 'run-2' }), // other-run primary — skipped
+ ];
+ expect(priorRunOutputTokens(events, 5, 'run-1')).toBe(28);
+ });
+
+ it('does not subtract a parallel sibling run’s summarization output', () => {
+ /** The summarize detour inherits the graph run id (traceConfig), so a sibling
+ * run's summary carries a DIFFERENT runId; its summary is in the sibling's
+ * baseline, not this snapshot's, so subtracting it would under-report. */
+ const events = [
+ ev({ output_tokens: 20, runId: 'run-1' }),
+ ev({ output_tokens: 8, runId: 'run-2', usage_type: 'summarization' }), // sibling — skipped
+ ];
+ expect(priorRunOutputTokens(events, 2, 'run-1')).toBe(20);
+ });
+
+ it('matches untagged events for back-compat and returns 0 with no prior calls', () => {
+ const events = [ev({ output_tokens: 20 }), ev({ output_tokens: 30 })];
+ expect(priorRunOutputTokens(events, 2, 'run-1')).toBe(50);
+ expect(priorRunOutputTokens(events, 0, 'run-1')).toBe(0);
+ });
});
describe('buildAbortedResponseMetadata', () => {
@@ -1783,6 +1936,65 @@ describe('buildAbortedResponseMetadata', () => {
expect(result).toEqual({ usage: { input: 100, output: 20, cacheWrite: 0, cacheRead: 0 } });
expect((result as { contextUsage?: unknown }).contextUsage).toBeUndefined();
});
+
+ const abortSnapshot: TContextUsageEvent = {
+ runId: 'run-1',
+ breakdown: {
+ maxContextTokens: 1000,
+ instructionTokens: 50,
+ systemMessageTokens: 50,
+ dynamicInstructionTokens: 0,
+ toolSchemaTokens: 0,
+ summaryTokens: 80,
+ toolCount: 0,
+ messageCount: 1,
+ messageTokens: 20,
+ availableForMessages: 900,
+ },
+ contextBudget: 1000,
+ remainingContextTokens: 700,
+ };
+
+ it('persists the summary marker (but not the full snapshot) for a stopped summarized turn', () => {
+ /** A single-call stopped turn: the interrupted call emitted no usage, so the
+ * marker is the full baseUsed (300) with nothing to subtract. */
+ const result = buildAbortedResponseMetadata({
+ tokenUsage: JSON.stringify([]),
+ contextUsage: JSON.stringify(abortSnapshot),
+ });
+ expect(result?.summaryUsedTokens).toBe(300);
+ /** The full snapshot stays off the abort path (completedOutputTokens ambiguity). */
+ expect((result as { contextUsage?: unknown }).contextUsage).toBeUndefined();
+ });
+
+ it('does not subtract any output from the marker on a stopped turn', () => {
+ /** The abort tokenCount comes from countTokens(text) or is absent — it does
+ * NOT fold in summarization/earlier-call output the way recordCollectedUsage
+ * does. So the marker is the full baseUsed (300); subtracting the summarization
+ * (8) or the primary (20) here would under-report after reload. */
+ const events: TTokenUsageEvent[] = [
+ {
+ input_tokens: 100,
+ output_tokens: 20,
+ total_tokens: 120,
+ provider: 'openAI',
+ runId: 'run-1',
+ },
+ {
+ input_tokens: 60,
+ output_tokens: 8,
+ total_tokens: 68,
+ provider: 'openAI',
+ runId: 'run-1',
+ usage_type: 'summarization',
+ },
+ ];
+ const result = buildAbortedResponseMetadata({
+ tokenUsage: JSON.stringify(events),
+ contextUsage: JSON.stringify(abortSnapshot),
+ });
+ expect(result?.summaryUsedTokens).toBe(300);
+ });
});
describe('resolveAgentTokenConfig', () => {
diff --git a/packages/api/src/agents/usage.ts b/packages/api/src/agents/usage.ts
index e8b4583cff..a2fca29e4a 100644
--- a/packages/api/src/agents/usage.ts
+++ b/packages/api/src/agents/usage.ts
@@ -247,16 +247,23 @@ function normalizeEventUnits(event: TTokenUsageEvent): {
};
}
-/** Output tokens of the response's final primary model call — the call the
- * latest pre-invoke snapshot precedes. Persisted as the snapshot's
- * `completedOutputTokens` so a reloaded multi-call turn adds only this delta
- * (matching the live finalizer) instead of the full response `tokenCount`,
- * which the snapshot already counts for earlier steps. */
-function finalCallOutputTokens(events: ReadonlyArray): number {
+/** Output tokens of the final primary model call belonging to the snapshot's
+ * run — the call the latest pre-invoke snapshot precedes. Persisted as the
+ * snapshot's `completedOutputTokens` so a reloaded multi-call turn adds only
+ * this delta (matching the live finalizer) instead of the full response
+ * `tokenCount`, which the snapshot already counts for earlier steps. Filtering
+ * by `runId` prevents a parallel run's later usage from being attributed to this
+ * snapshot; untagged events (older lib / resume) match any run for back-compat. */
+function finalCallOutputTokens(events: ReadonlyArray, runId?: string): number {
for (let i = events.length - 1; i >= 0; i--) {
- if (events[i].usage_type == null) {
- return normalizeEventUnits(events[i]).output;
+ const event = events[i];
+ if (event.usage_type != null) {
+ continue;
}
+ if (runId != null && event.runId != null && event.runId !== runId) {
+ continue;
+ }
+ return normalizeEventUnits(event).output;
}
return 0;
}
@@ -273,7 +280,7 @@ export function buildPersistedContextUsage(
usageEvents: ReadonlyArray = [],
): TContextUsageEvent {
const { breakdown } = snapshot;
- const completedOutputTokens = finalCallOutputTokens(usageEvents);
+ const completedOutputTokens = finalCallOutputTokens(usageEvents, snapshot.runId);
let toolTokenCounts = breakdown.toolTokenCounts;
if (toolTokenCounts != null) {
const trimmed: Record = {};
@@ -291,6 +298,83 @@ export function buildPersistedContextUsage(
};
}
+/**
+ * Sum of this response's output tokens already folded into a later snapshot's
+ * pre-invoke baseline that the response message's `tokenCount` ALSO carries — the
+ * overlap `computeSummaryUsedTokens` subtracts from the marker so the live-path
+ * client estimate (`summaryBaseline + responseTokenCount`) doesn't double-count:
+ * - earlier tool-loop PRIMARY calls: a multi-call turn's first output sits in the
+ * kept-message context of the next call's snapshot AND in `tokenCount`.
+ * - the SUMMARIZATION call's generated summary: it sits in the snapshot baseline
+ * as `summaryTokens` AND in `tokenCount` (`recordCollectedUsage` folds
+ * summarization completion into the reported output total; subagent/sequential
+ * are kept out of that total, so they are excluded here too).
+ *
+ * Both are matched by `runId` and bounded by `beforeIndex` to the calls that
+ * preceded the snapshot. The summarize detour inherits the graph run id
+ * (`traceConfig` spreads `config.metadata.run_id`), so it shares the snapshot's
+ * `runId`; a parallel sibling run's summary carries a DIFFERENT `runId` and must
+ * NOT be subtracted (its summary lives in the sibling's baseline, not this one).
+ * Untagged events (older lib / resume) match any run for back-compat.
+ *
+ * Only the live path (which builds `tokenCount` via `recordCollectedUsage`) calls
+ * this; the abort path subtracts nothing — see {@link buildAbortedResponseMetadata}.
+ */
+export function priorRunOutputTokens(
+ events: ReadonlyArray,
+ beforeIndex: number,
+ runId?: string,
+): number {
+ let total = 0;
+ const end = Math.min(beforeIndex, events.length);
+ for (let i = 0; i < end; i++) {
+ const event = events[i];
+ if (event.usage_type != null && event.usage_type !== 'summarization') {
+ continue;
+ }
+ if (runId != null && event.runId != null && event.runId !== runId) {
+ continue;
+ }
+ total += normalizeEventUnits(event).output;
+ }
+ return total;
+}
+
+/**
+ * Pre-invoke compacted context size for a summarized turn (instructions +
+ * summary + kept messages), or `undefined` when the turn did not summarize.
+ * Persisted as the lightweight `summaryUsedTokens` marker so the client estimate
+ * fallback caps the discarded pre-summary history instead of re-summing it (the
+ * gauge otherwise reads 100% in perpetuity after a compaction). Pre-invoke, so
+ * it carries none of the `completedOutputTokens` ambiguity that keeps the full
+ * snapshot off some save paths. `summaryTokens` is a SEPARATE breakdown field, so
+ * the non-`remainingContextTokens` fallback adds it explicitly.
+ *
+ * `priorOutputTokens` (this response's earlier tool-loop outputs, see
+ * {@link priorRunOutputTokens}) is subtracted: those tokens are inside the
+ * baseline's kept messages AND in the response message's `tokenCount` the client
+ * adds on top, so leaving them in the marker double-counts them on a tool-loop
+ * summarized turn. Single-call turns pass 0 and are unaffected.
+ */
+export function computeSummaryUsedTokens(
+ snapshot: TContextUsageEvent | null | undefined,
+ priorOutputTokens = 0,
+): number | undefined {
+ const summaryTokens = snapshot?.breakdown?.summaryTokens ?? 0;
+ if (!snapshot || summaryTokens <= 0) {
+ return undefined;
+ }
+ const maxTokens = snapshot.contextBudget ?? snapshot.breakdown.maxContextTokens ?? 0;
+ const baseUsed =
+ snapshot.remainingContextTokens != null
+ ? maxTokens - snapshot.remainingContextTokens
+ : (snapshot.effectiveInstructionTokens ?? snapshot.breakdown.instructionTokens ?? 0) +
+ summaryTokens +
+ (snapshot.breakdown.messageTokens ?? 0);
+ const adjusted = baseUsed - Math.max(0, priorOutputTokens);
+ return adjusted > 0 ? Math.round(adjusted) : undefined;
+}
+
function parseUsageEvents(value?: string | null): TTokenUsageEvent[] {
if (typeof value !== 'string' || value.length === 0) {
return [];
@@ -309,21 +393,50 @@ function parseUsageEvents(value?: string | null): TTokenUsageEvent[] {
* reload (finding: stopped responses otherwise lose cost). Shared by every abort
* save path (agents abort route + legacy abort middleware).
*
- * Deliberately persists ONLY `usage`, not `contextUsage`: unlike the live path,
- * the abort path can't tell whether the FINAL call (the one the latest snapshot
- * precedes) emitted usage — the job stores only the latest snapshot, not the
- * snapshot count. If the final call emitted none, `completedOutputTokens` would
- * reuse an earlier call's output the snapshot already counts → reload
- * over-reports. A stopped/incomplete response therefore falls back to the coarse
- * per-message gauge estimate on reload, which is both safe and apt for an
- * interrupted turn that never reached a clean pre-invoke breakdown.
+ * Deliberately omits the full `contextUsage`: unlike the live path, the abort
+ * path can't tell whether the FINAL call (the one the latest snapshot precedes)
+ * emitted usage — the job stores only the latest snapshot, not the snapshot
+ * count. If the final call emitted none, `completedOutputTokens` would reuse an
+ * earlier call's output the snapshot already counts → reload over-reports. So a
+ * stopped response falls back to the per-message gauge estimate on reload.
+ *
+ * It DOES persist the `summaryUsedTokens` marker when the stopped turn had
+ * summarized: that marker is pre-invoke (no `completedOutputTokens` ambiguity),
+ * and without it the fallback estimate re-sums the history the compaction
+ * discarded — leaving a stopped summarized turn pinned at 100%. Unlike the live
+ * path, the abort `tokenCount` comes from `countTokens(text)` (abortMiddleware) or
+ * is absent (agents abort route) — it does NOT fold in the summarization or
+ * earlier-call output the way `recordCollectedUsage` does. So the marker subtracts
+ * NOTHING: the full pre-invoke baseline is correct, and the client adds only the
+ * partial answer text on top (no overlap to cancel).
*/
export function buildAbortedResponseMetadata(
- job: { tokenUsage?: string | null } | null | undefined,
-): { usage?: TResponseUsage } | undefined {
+ job: { tokenUsage?: string | null; contextUsage?: string | null } | null | undefined,
+): { usage?: TResponseUsage; summaryUsedTokens?: number } | undefined {
const events = parseUsageEvents(job?.tokenUsage);
const usage = aggregateEmittedUsage(events);
- return usage ? { usage } : undefined;
+
+ let snapshot: TContextUsageEvent | null = null;
+ if (typeof job?.contextUsage === 'string' && job.contextUsage.length > 0) {
+ try {
+ snapshot = JSON.parse(job.contextUsage) as TContextUsageEvent;
+ } catch {
+ snapshot = null;
+ }
+ }
+ /** Subtract nothing: the abort `tokenCount` (countTokens(text) or absent) does
+ * not fold in summarization/earlier-call output, so the full baseline is the
+ * marker and the client's partial-text addition has no overlap to cancel. */
+ const summaryUsedTokens = computeSummaryUsedTokens(snapshot);
+
+ const metadata: { usage?: TResponseUsage; summaryUsedTokens?: number } = {};
+ if (usage) {
+ metadata.usage = usage;
+ }
+ if (summaryUsedTokens != null) {
+ metadata.summaryUsedTokens = summaryUsedTokens;
+ }
+ return Object.keys(metadata).length > 0 ? metadata : undefined;
}
/**