diff --git a/src/agents/cli-runner.ts b/src/agents/cli-runner.ts index 18cd8c775..2d908826f 100644 --- a/src/agents/cli-runner.ts +++ b/src/agents/cli-runner.ts @@ -261,6 +261,13 @@ export async function runCliAgent(params: { ); } + log.info( + `[cli-runner] streaming result usage: input=${streamResult.usage?.input} ` + + `output=${streamResult.usage?.output} cacheRead=${streamResult.usage?.cacheRead} ` + + `cacheWrite=${streamResult.usage?.cacheWrite} total=${streamResult.usage?.total} ` + + `sessionId=${streamResult.sessionId}`, + ); + return { text: streamResult.text, sessionId: streamResult.sessionId, @@ -327,6 +334,12 @@ export async function runCliAgent(params: { const text = output.text?.trim(); const payloads = text ? [{ text }] : undefined; + log.info( + `[cli-runner] FINAL agentMeta.usage: input=${output.usage?.input} ` + + `output=${output.usage?.output} cacheRead=${output.usage?.cacheRead} ` + + `cacheWrite=${output.usage?.cacheWrite} total=${output.usage?.total}`, + ); + return { payloads, meta: { diff --git a/src/agents/usage.ts b/src/agents/usage.ts index aa7dce767..5ec241873 100644 --- a/src/agents/usage.ts +++ b/src/agents/usage.ts @@ -93,3 +93,22 @@ export function derivePromptTokens(usage?: { const sum = input + cacheRead + cacheWrite; return sum > 0 ? sum : undefined; } + +/** + * CLI-specific context token calculation. + * For CLI, we use the latest turn's values directly (not accumulated across turns) + * since cache_read_input_tokens already represents the full cached context. + * Formula: cacheRead + cacheWrite + input = total context for this turn + */ +export function deriveCliContextTokens(usage?: { + input?: number; + cacheRead?: number; + cacheWrite?: number; +}): number | undefined { + if (!usage) return undefined; + const input = usage.input ?? 0; + const cacheRead = usage.cacheRead ?? 0; + const cacheWrite = usage.cacheWrite ?? 0; + const sum = input + cacheRead + cacheWrite; + return sum > 0 ? sum : undefined; +} diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 90df7c8c8..bb245430e 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -211,6 +211,11 @@ export async function runAgentTurnWithFallback(params: { // CLI backends bypass SessionManager, so persist response to transcript // directly to ensure it's available on TUI reload. // Record even empty responses to maintain message turn consistency. + const usageForTranscript = result.meta.agentMeta?.usage; + logVerbose( + `[agent-runner-execution] CLI transcript persist: usage=${JSON.stringify(usageForTranscript)} ` + + `provider=${provider} model=${model}`, + ); const assistantPersistResult = appendAssistantMessageToTranscript({ message: cliText || "", sessionId: params.followupRun.run.sessionId, @@ -219,7 +224,7 @@ export async function runAgentTurnWithFallback(params: { createIfMissing: true, provider, model, - usage: result.meta.agentMeta?.usage, + usage: usageForTranscript, }); if (!assistantPersistResult.ok) { logVerbose( diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 227e6f17e..448701ad4 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -41,6 +41,7 @@ import { incrementCompactionCount } from "./session-updates.js"; import type { TypingController } from "./typing.js"; import { createTypingSignaler } from "./typing-mode.js"; import { emitDiagnosticEvent, isDiagnosticsEnabled } from "../../infra/diagnostic-events.js"; +import { logVerbose } from "../../globals.js"; const BLOCK_REPLY_SEND_TIMEOUT_MS = 15_000; @@ -377,6 +378,12 @@ export async function runReplyAgent(params: { activeSessionEntry?.contextTokens ?? DEFAULT_CONTEXT_TOKENS; + logVerbose( + `[agent-runner] usage from runResult: input=${usage?.input} output=${usage?.output} ` + + `cacheRead=${usage?.cacheRead} cacheWrite=${usage?.cacheWrite} total=${usage?.total} ` + + `provider=${providerUsed} model=${modelUsed} cliSessionId=${cliSessionId}`, + ); + await persistSessionUsageUpdate({ storePath, sessionKey, diff --git a/src/auto-reply/reply/session-usage.ts b/src/auto-reply/reply/session-usage.ts index 3d5226258..537f463ec 100644 --- a/src/auto-reply/reply/session-usage.ts +++ b/src/auto-reply/reply/session-usage.ts @@ -1,5 +1,10 @@ import { setCliSessionId } from "../../agents/cli-session.js"; -import { hasNonzeroUsage, type NormalizedUsage } from "../../agents/usage.js"; +import { isCliProvider } from "../../agents/model-selection.js"; +import { + deriveCliContextTokens, + hasNonzeroUsage, + type NormalizedUsage, +} from "../../agents/usage.js"; import { type SessionSystemPromptReport, type SessionEntry, @@ -19,10 +24,13 @@ export async function persistSessionUsageUpdate(params: { logLabel?: string; }): Promise { const { storePath, sessionKey } = params; - if (!storePath || !sessionKey) return; + if (!storePath || !sessionKey) { + return; + } const label = params.logLabel ? `${params.logLabel} ` : ""; - if (hasNonzeroUsage(params.usage)) { + const hasUsage = hasNonzeroUsage(params.usage); + if (hasUsage) { try { await updateSessionStoreEntry({ storePath, @@ -30,12 +38,18 @@ export async function persistSessionUsageUpdate(params: { update: async (entry) => { const input = params.usage?.input ?? 0; const output = params.usage?.output ?? 0; - const promptTokens = - input + (params.usage?.cacheRead ?? 0) + (params.usage?.cacheWrite ?? 0); + const cacheRead = params.usage?.cacheRead ?? 0; + const cacheWrite = params.usage?.cacheWrite ?? 0; + // For CLI providers, use the full context for this turn (cacheRead + cacheWrite + input) + const isCli = isCliProvider(params.providerUsed ?? "", undefined); + const promptTokens = isCli + ? (deriveCliContextTokens(params.usage) ?? input) + : input + cacheRead + cacheWrite; + const newTotalTokens = promptTokens > 0 ? promptTokens : (params.usage?.total ?? input); const patch: Partial = { inputTokens: input, outputTokens: output, - totalTokens: promptTokens > 0 ? promptTokens : (params.usage?.total ?? input), + totalTokens: newTotalTokens, modelProvider: params.providerUsed ?? entry.modelProvider, model: params.modelUsed ?? entry.model, contextTokens: params.contextTokensUsed ?? entry.contextTokens, diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index 733205c8c..9cba3a40f 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -3,9 +3,14 @@ import fs from "node:fs"; import { lookupContextTokens } from "../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { resolveModelAuthMode } from "../agents/model-auth.js"; -import { resolveConfiguredModelRef } from "../agents/model-selection.js"; +import { isCliProvider, resolveConfiguredModelRef } from "../agents/model-selection.js"; import { resolveSandboxRuntimeStatus } from "../agents/sandbox.js"; -import { derivePromptTokens, normalizeUsage, type UsageLike } from "../agents/usage.js"; +import { + deriveCliContextTokens, + derivePromptTokens, + normalizeUsage, + type UsageLike, +} from "../agents/usage.js"; import type { ClawdbotConfig } from "../config/config.js"; import { resolveMainSessionKey, @@ -29,6 +34,7 @@ import { resolveModelCostConfig, } from "../utils/usage-format.js"; import { VERSION } from "../version.js"; +import { logVerbose } from "../globals.js"; import { listChatCommands, listChatCommandsForConfig } from "./commands-registry.js"; import { listPluginCommands } from "../plugins/commands.js"; import type { SkillCommandSpec } from "../agents/skills.js"; @@ -155,6 +161,7 @@ const formatQueueDetails = (queue?: QueueStatus) => { const readUsageFromSessionLog = ( sessionId?: string, sessionEntry?: SessionEntry, + provider?: string, ): | { input: number; @@ -165,17 +172,26 @@ const readUsageFromSessionLog = ( } | undefined => { // Transcripts are stored at the session file path (fallback: ~/.clawdbot/sessions/.jsonl) - if (!sessionId) return undefined; + if (!sessionId) { + logVerbose(`[status/readUsageFromSessionLog] no sessionId, returning undefined`); + return undefined; + } const logPath = resolveSessionFilePath(sessionId, sessionEntry); - if (!fs.existsSync(logPath)) return undefined; + logVerbose(`[status/readUsageFromSessionLog] reading transcript: ${logPath}`); + if (!fs.existsSync(logPath)) { + logVerbose(`[status/readUsageFromSessionLog] transcript file not found`); + return undefined; + } try { const lines = fs.readFileSync(logPath, "utf-8").split(/\n+/); + logVerbose(`[status/readUsageFromSessionLog] transcript has ${lines.length} lines`); let input = 0; let output = 0; let promptTokens = 0; let model: string | undefined; let lastUsage: ReturnType | undefined; + let usageEntriesFound = 0; for (const line of lines) { if (!line.trim()) continue; @@ -190,18 +206,37 @@ const readUsageFromSessionLog = ( }; const usageRaw = parsed.message?.usage ?? parsed.usage; const usage = normalizeUsage(usageRaw); - if (usage) lastUsage = usage; + if (usage) { + usageEntriesFound++; + logVerbose( + `[status/readUsageFromSessionLog] entry #${usageEntriesFound}: ` + + `input=${usage.input} output=${usage.output} cacheRead=${usage.cacheRead} ` + + `cacheWrite=${usage.cacheWrite} total=${usage.total}`, + ); + lastUsage = usage; + } model = parsed.message?.model ?? parsed.model ?? model; } catch { // ignore bad lines } } - if (!lastUsage) return undefined; + logVerbose(`[status/readUsageFromSessionLog] found ${usageEntriesFound} usage entries`); + if (!lastUsage) { + logVerbose(`[status/readUsageFromSessionLog] no lastUsage found, returning undefined`); + return undefined; + } input = lastUsage.input ?? 0; output = lastUsage.output ?? 0; - promptTokens = derivePromptTokens(lastUsage) ?? lastUsage.total ?? input + output; + // For CLI providers, use full context for this turn (cacheRead + cacheWrite + input) + const isCli = isCliProvider(provider ?? "", undefined); + promptTokens = isCli + ? (deriveCliContextTokens(lastUsage) ?? lastUsage.total ?? input + output) + : (derivePromptTokens(lastUsage) ?? lastUsage.total ?? input + output); const total = lastUsage.total ?? promptTokens + output; + logVerbose( + `[status/readUsageFromSessionLog] lastUsage: input=${lastUsage.input} output=${lastUsage.output} cacheRead=${lastUsage.cacheRead} cacheWrite=${lastUsage.cacheWrite} total=${lastUsage.total} → derived promptTokens=${promptTokens} total=${total}`, + ); if (promptTokens === 0 && total === 0) return undefined; return { input, output, promptTokens, total, model }; } catch { @@ -299,9 +334,12 @@ export function buildStatusMessage(args: StatusArgs): string { // Prefer prompt-size tokens from the session transcript when it looks larger // (cached prompt tokens are often missing from agent meta/store). if (args.includeTranscriptUsage) { - const logUsage = readUsageFromSessionLog(entry?.sessionId, entry); + const logUsage = readUsageFromSessionLog(entry?.sessionId, entry, provider); if (logUsage) { const candidate = logUsage.promptTokens || logUsage.total; + logVerbose( + `[status] transcript: promptTokens=${logUsage.promptTokens} total=${logUsage.total} candidate=${candidate} currentTotal=${totalTokens} willReplace=${!totalTokens || totalTokens === 0 || candidate > totalTokens}`, + ); if (!totalTokens || totalTokens === 0 || candidate > totalTokens) { totalTokens = candidate; } diff --git a/src/gateway/session-utils.fs.ts b/src/gateway/session-utils.fs.ts index 6eb1ac155..aec86ebf7 100644 --- a/src/gateway/session-utils.fs.ts +++ b/src/gateway/session-utils.fs.ts @@ -4,7 +4,8 @@ import os from "node:os"; import path from "node:path"; import { CURRENT_SESSION_VERSION } from "@mariozechner/pi-coding-agent"; -import type { NormalizedUsage } from "../agents/usage.js"; +import { isCliProvider } from "../agents/model-selection.js"; +import { deriveCliContextTokens, type NormalizedUsage } from "../agents/usage.js"; import { resolveSessionTranscriptPath } from "../config/sessions.js"; import { stripEnvelope } from "./chat-sanitize.js"; import type { SessionPreviewItem } from "./session-utils.types.js"; @@ -141,13 +142,21 @@ function appendMessageToTranscriptImpl( if (params.role === "assistant") { messageBody.stopReason = params.stopReason ?? "cli_backend"; const u = params.usage; - messageBody.usage = { + // For CLI providers, use CLI-specific calculation (cacheRead + cacheWrite + input) + // representing the full context for this turn + const isCli = isCliProvider(params.provider ?? "", undefined); + const derivedTotal = isCli + ? (deriveCliContextTokens(u) ?? 0) + : (u?.input ?? 0) + (u?.cacheRead ?? 0) + (u?.cacheWrite ?? 0); + const usageToWrite = { input: u?.input ?? 0, output: u?.output ?? 0, cacheRead: u?.cacheRead, cacheWrite: u?.cacheWrite, - totalTokens: u?.total ?? (u?.input ?? 0) + (u?.output ?? 0), + totalTokens: + u?.total ?? (derivedTotal > 0 ? derivedTotal : (u?.input ?? 0) + (u?.output ?? 0)), }; + messageBody.usage = usageToWrite; if (params.provider) messageBody.provider = params.provider; if (params.model) messageBody.model = params.model; }