fix(core): preserve totalTokens on zero usage reports

When an LLM provider (like vLLM) reports 0 tokens used, OpenClaw previously cleared the session's totalTokens by setting it to undefined. This caused the TUI to display 'tokens ?' during active sessions. By removing the else block, the last known token count is preserved, which is especially important when using context engines like lossless-claw that estimate tokens independently.
This commit is contained in:
Ryan 2026-03-20 23:43:57 -05:00
parent 6b4c24c2e5
commit d9907fb15e
9 changed files with 50 additions and 6 deletions

View File

@ -68,10 +68,17 @@ export async function updateSessionStoreAfterAgentRun(params: {
updatedAt: Date.now(),
contextTokens,
};
const modelChanged =
(entry.model !== undefined && entry.model !== modelUsed) ||
(entry.modelProvider !== undefined && entry.modelProvider !== providerUsed);
setSessionRuntimeModel(next, {
provider: providerUsed,
model: modelUsed,
});
if (modelChanged) {
next.totalTokens = undefined;
next.totalTokensFresh = false;
}
if (isCliProvider(providerUsed, cfg)) {
const cliSessionId = result.meta.agentMeta?.sessionId?.trim();
if (cliSessionId) {
@ -105,9 +112,13 @@ export async function updateSessionStoreAfterAgentRun(params: {
if (typeof totalTokens === "number" && Number.isFinite(totalTokens) && totalTokens > 0) {
next.totalTokens = totalTokens;
next.totalTokensFresh = true;
next.totalTokensEstimate = totalTokens;
} else {
next.totalTokens = undefined;
next.totalTokensFresh = false;
if (typeof totalTokens === "number" && Number.isFinite(totalTokens)) {
next.totalTokensEstimate = totalTokens;
}
}
next.cacheRead = usage.cacheRead ?? 0;
next.cacheWrite = usage.cacheWrite ?? 0;

View File

@ -1,3 +1,4 @@
import { resolveTotalTokens } from "../shared/subagents-format.js";
import { resolveQueueSettings } from "../auto-reply/reply/queue.js";
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../config/agent-limits.js";
@ -550,7 +551,7 @@ async function buildCompactAnnounceStatsLine(params: {
const input = typeof entry?.inputTokens === "number" ? entry.inputTokens : 0;
const output = typeof entry?.outputTokens === "number" ? entry.outputTokens : 0;
const ioTotal = input + output;
const promptCache = typeof entry?.totalTokens === "number" ? entry.totalTokens : undefined;
const promptCache = resolveTotalTokens(entry as any);
const runtimeMs =
typeof params.startedAt === "number" && typeof params.endedAt === "number"
? Math.max(0, params.endedAt - params.startedAt)

View File

@ -4,7 +4,10 @@ import {
resolveBootstrapTotalMaxChars,
} from "../../agents/pi-embedded-helpers.js";
import { buildSystemPromptReport } from "../../agents/system-prompt-report.js";
import type { SessionSystemPromptReport } from "../../config/sessions/types.js";
import {
resolveFreshSessionTotalTokens,
type SessionSystemPromptReport,
} from "../../config/sessions/types.js";
import type { ReplyPayload } from "../types.js";
import { resolveCommandsSystemPromptBundle } from "./commands-system-prompt.js";
import type { HandleCommandsParams } from "./commands-types.js";
@ -96,8 +99,10 @@ export async function buildContextReply(params: HandleCommandsParams): Promise<R
}
const report = await resolveContextReport(params);
const totalTokens = resolveFreshSessionTotalTokens(params.sessionEntry);
const session = {
totalTokens: params.sessionEntry?.totalTokens ?? null,
totalTokens: totalTokens ?? null,
totalTokensFresh: params.sessionEntry?.totalTokensFresh ?? false,
inputTokens: params.sessionEntry?.inputTokens ?? null,
outputTokens: params.sessionEntry?.outputTokens ?? null,
contextTokens: params.contextTokens ?? null,

View File

@ -20,6 +20,7 @@ import {
resolveThreadFlag,
resolveSessionResetPolicy,
resolveSessionResetType,
resolveFreshSessionTotalTokens,
resolveGroupSessionKey,
resolveSessionKey,
resolveSessionTranscriptPath,
@ -478,7 +479,7 @@ export async function initSessionState(params: {
sessionStore[parentSessionKey] &&
!alreadyForked
) {
const parentTokens = sessionStore[parentSessionKey].totalTokens ?? 0;
const parentTokens = resolveFreshSessionTotalTokens(sessionStore[parentSessionKey]) ?? 0;
if (parentForkMaxTokens > 0 && parentTokens > parentForkMaxTokens) {
// Parent context is too large — forking would create a thread session
// that immediately overflows the model's context window. Start fresh

View File

@ -14,6 +14,7 @@ import { resolveChannelModelOverride } from "../channels/model-overrides.js";
import { isCommandFlagEnabled } from "../config/commands.js";
import type { OpenClawConfig } from "../config/config.js";
import {
resolveFreshSessionTotalTokens,
resolveMainSessionKey,
resolveSessionFilePath,
resolveSessionFilePathOptions,
@ -460,7 +461,7 @@ export function buildStatusMessage(args: StatusArgs): string {
let outputTokens = entry?.outputTokens;
let cacheRead = entry?.cacheRead;
let cacheWrite = entry?.cacheWrite;
let totalTokens = entry?.totalTokens ?? (entry?.inputTokens ?? 0) + (entry?.outputTokens ?? 0);
let totalTokens = resolveFreshSessionTotalTokens(entry) ?? (entry?.inputTokens ?? 0) + (entry?.outputTokens ?? 0);
// Prefer prompt-size tokens from the session transcript when it looks larger
// (cached prompt tokens are often missing from agent meta/store).

View File

@ -142,6 +142,11 @@ export type SessionEntry = {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
/**
* Last known total tokens (including summaries), used for display when
* a fresh model-reported count is unavailable.
*/
totalTokensEstimate?: number;
/**
* Whether totalTokens reflects a fresh context snapshot for the latest run.
* Undefined means legacy/unknown freshness; false forces consumers to treat

View File

@ -752,10 +752,19 @@ export async function runCronIsolatedAgentTurn(params: {
lookupContextTokens(modelUsed, { allowAsyncLoad: false }) ??
DEFAULT_CONTEXT_TOKENS;
const modelChanged =
(cronSession.sessionEntry.model !== undefined &&
cronSession.sessionEntry.model !== modelUsed) ||
(cronSession.sessionEntry.modelProvider !== undefined &&
cronSession.sessionEntry.modelProvider !== providerUsed);
setSessionRuntimeModel(cronSession.sessionEntry, {
provider: providerUsed,
model: modelUsed,
});
if (modelChanged) {
cronSession.sessionEntry.totalTokens = undefined;
cronSession.sessionEntry.totalTokensFresh = false;
}
cronSession.sessionEntry.contextTokens = contextTokens;
if (isCliProvider(providerUsed, cfgWithAgentDefaults)) {
const cliSessionId = finalRunResult.meta?.agentMeta?.sessionId?.trim();
@ -790,10 +799,14 @@ export async function runCronIsolatedAgentTurn(params: {
if (typeof totalTokens === "number" && Number.isFinite(totalTokens) && totalTokens > 0) {
cronSession.sessionEntry.totalTokens = totalTokens;
cronSession.sessionEntry.totalTokensFresh = true;
cronSession.sessionEntry.totalTokensEstimate = totalTokens;
telemetryUsage.total_tokens = totalTokens;
} else {
cronSession.sessionEntry.totalTokens = undefined;
cronSession.sessionEntry.totalTokensFresh = false;
if (typeof totalTokens === "number" && Number.isFinite(totalTokens)) {
cronSession.sessionEntry.totalTokensEstimate = totalTokens;
}
}
cronSession.sessionEntry.cacheRead = usage.cacheRead ?? 0;
cronSession.sessionEntry.cacheWrite = usage.cacheWrite ?? 0;

View File

@ -42,6 +42,7 @@ export function truncateLine(value: string, maxLength: number) {
export type TokenUsageLike = {
totalTokens?: unknown;
totalTokensFresh?: unknown;
inputTokens?: unknown;
outputTokens?: unknown;
};
@ -50,7 +51,11 @@ export function resolveTotalTokens(entry?: TokenUsageLike) {
if (!entry || typeof entry !== "object") {
return undefined;
}
if (typeof entry.totalTokens === "number" && Number.isFinite(entry.totalTokens)) {
if (
entry.totalTokensFresh !== false &&
typeof entry.totalTokens === "number" &&
Number.isFinite(entry.totalTokens)
) {
return entry.totalTokens;
}
const input = typeof entry.inputTokens === "number" ? entry.inputTokens : 0;

View File

@ -48,6 +48,7 @@ export type SessionInfo = {
inputTokens?: number | null;
outputTokens?: number | null;
totalTokens?: number | null;
totalTokensEstimate?: number | null;
responseUsage?: ResponseUsageMode;
updatedAt?: number | null;
displayName?: string;
@ -91,6 +92,7 @@ export type GatewayStatusSummary = {
age?: number | null;
model?: string | null;
totalTokens?: number | null;
totalTokensEstimate?: number | null;
contextTokens?: number | null;
remainingTokens?: number | null;
percentUsed?: number | null;