diff --git a/docs/concepts/usage-tracking.md b/docs/concepts/usage-tracking.md index 46af075b5a51..6cdf9ff94447 100644 --- a/docs/concepts/usage-tracking.md +++ b/docs/concepts/usage-tracking.md @@ -48,8 +48,87 @@ footer when valid: } ``` -Missing, unreadable, invalid, or empty templates fall back to the built-in -footer. +Missing or empty templates fall back to the built-in footer quietly. Unreadable +or invalid configured templates also fall back to the built-in footer and emit an +operator warning. + +Start custom templates from the built-in shape, then edit the parts you want to +change: + +```jsonc +{ + "schema": "openclaw.usageBar.v1", + "scales": { + "braille": "⠐⡀⡄⡆⡇⣇⣧⣷⣿", + "block": "░▏▎▍▌▋▊▉█", + "shade": "░▒▓█", + "moon": "🌑🌘🌗🌖🌕", + "level": "▁▂▃▄▅▆▇█", + "weather": ["🥶", "☁️", "🌥", "⛅️", "🌤", "☀️"], + "plants": ["🪾", "🍂", "🌱", "☘️", "🍀", "🌿"], + "moons6": ["🌑", "🌚", "🌘", "🌗", "🌖", "🌝"], + }, + "aliases": { + "models": { + "claude-opus-4-6": "opus46", + "claude-opus-4-8": "opus48", + "claude-sonnet-4-6": "sonnet46", + "claude-haiku-4-5": "haiku45", + "gpt-5.5": "gpt5.5", + }, + "reasoning": { + "off": "🌑", + "minimal": "🌚", + "low": "🌘", + "medium": "🌗", + "high": "🌕", + "xhigh": "🌝", + }, + }, + "output": { + "sep": "", + "default": [ + { "text": "{model.provider}{identity.emoji|🤖} {model.display_name|alias:models}" }, + { "map": "model.is_fallback", "cases": { "true": " 🔄" } }, + { "map": "model.is_override", "cases": { "true": " 📌" } }, + { "when": "model.reasoning", "text": " {model.reasoning|alias:reasoning}" }, + { "map": "state.fast_mode", "cases": { "true": " ⚡", "false": " 🐌" } }, + { + "when": "context.max_tokens", + "text": " | 📚 [{context.pct_used|meter:5:braille}]{context.max_tokens|num}", + }, + { + "when": "usage.has_split_tokens", + "text": " ↕️ {usage.input_tokens|num|?}/{usage.output_tokens|num|?}", + }, + { "when": "usage.has_total_only_tokens", "text": " ↕️ {usage.total_tokens|num}" }, + { "when": "usage.cache_hit_pct", "text": " 🗄 {usage.cache_hit_pct|pct}" }, + { "when": "cost.turn_usd", "text": " 💰{cost.turn_usd|fixed:4}" }, + ], + "surfaces": { + "discord": [ + { "text": "-# -\n" }, + { "text": "-# {model.provider}{identity.emoji|🤖} {model.display_name|alias:models}" }, + { "map": "model.is_fallback", "cases": { "true": "🔄" } }, + { "map": "model.is_override", "cases": { "true": "📌" } }, + { "when": "model.reasoning", "text": " {model.reasoning|alias:reasoning}" }, + { "map": "state.fast_mode", "cases": { "true": " ⚡️", "false": " 🐌" } }, + { + "when": "context.max_tokens", + "text": " | 📚 [{context.pct_used|meter:5:braille}]{context.max_tokens|num}", + }, + { + "when": "usage.has_split_tokens", + "text": " ↕️ {usage.input_tokens|num|?}/{usage.output_tokens|num|?}", + }, + { "when": "usage.has_total_only_tokens", "text": " ↕️ {usage.total_tokens|num}" }, + { "when": "usage.cache_hit_pct", "text": " 🗄 {usage.cache_hit_pct|pct}" }, + { "when": "cost.turn_usd", "text": " 💰{cost.turn_usd|fixed:4}" }, + ], + }, + }, +} +``` ### Shape @@ -92,7 +171,8 @@ empty (so a `when` guard or a `|fallback` keeps the piece clean). | `model.is_fallback` / `model.is_override` | bool: fallback used / model pinned | | `state.fast_mode` | bool: fast vs slow | | `context.max_tokens` / `context.pct_used` | window budget / 0-100 used | -| `usage.input_tokens` / `usage.output_tokens` / `usage.cache_hit_pct` | turn aggregate | +| `usage.input_tokens` / `usage.output_tokens` / `usage.total_tokens` | turn aggregate | +| `usage.has_split_tokens` / `usage.has_total_only_tokens` / `usage.cache_hit_pct` | token display guards and cache percent | | `usage.last.input_tokens` / `usage.last.output_tokens` / `usage.last.cache_hit_pct` | final model call only | | `cost.turn_usd` | estimated turn cost | | `identity.name` / `identity.emoji` | agent name / chosen emoji | diff --git a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts index 7e01e863c2d8..aa55442332cd 100644 --- a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts +++ b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts @@ -2729,6 +2729,29 @@ describe("runReplyAgent response usage footer", () => { expect(text).not.toContain("Usage:"); }); + it("shows aggregate-only token totals in the built-in full footer", async () => { + runEmbeddedAgentMock.mockResolvedValueOnce({ + payloads: [{ text: "ok" }], + meta: { + agentMeta: { + provider: "anthropic", + model: "claude", + usage: { total: 1250 }, + }, + }, + }); + + const res = await createRun({ + responseUsage: "full", + sessionKey: "agent:main:whatsapp:dm:+1000", + }); + const payload = Array.isArray(res) ? res[0] : res; + const text = payload?.text ?? ""; + expect(text).toContain("↕️ 1.3k"); + expect(text).not.toContain("↕️ ?/?"); + expect(text).not.toContain("Usage:"); + }); + it("shows configured costs for aws-sdk providers when responseUsage=full", async () => { runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [{ text: "ok" }], diff --git a/src/auto-reply/usage-bar/contract.ts b/src/auto-reply/usage-bar/contract.ts index 2b3b4ebdf91e..b43942b8e46c 100644 --- a/src/auto-reply/usage-bar/contract.ts +++ b/src/auto-reply/usage-bar/contract.ts @@ -11,12 +11,10 @@ export function buildUsageContract( const cacheRead = usage.cacheRead; const cacheWrite = usage.cacheWrite; const total = usage.total; + const hasSplitTokens = input !== undefined || output !== undefined; + const hasTotalOnlyTokens = !hasSplitTokens && total !== undefined; const hasTokens = - input !== undefined || - output !== undefined || - cacheRead !== undefined || - cacheWrite !== undefined || - total !== undefined; + hasSplitTokens || cacheRead !== undefined || cacheWrite !== undefined || total !== undefined; const promptTotal = (cacheRead ?? 0) + (cacheWrite ?? 0) + (input ?? 0); const cacheHitPct = @@ -77,6 +75,8 @@ export function buildUsageContract( total_tokens: total, cache_hit_pct: cacheHitPct, has_tokens: hasTokens, + has_split_tokens: hasSplitTokens, + has_total_only_tokens: hasTotalOnlyTokens, last: last ? { input_tokens: last.input, diff --git a/src/auto-reply/usage-bar/default-template.ts b/src/auto-reply/usage-bar/default-template.ts index f18f43187f5e..9a042c8e87bc 100644 --- a/src/auto-reply/usage-bar/default-template.ts +++ b/src/auto-reply/usage-bar/default-template.ts @@ -35,9 +35,10 @@ export const DEFAULT_USAGE_BAR_TEMPLATE: UsageBarTemplate = { text: " | 📚 [{context.pct_used|meter:5:braille}]{context.max_tokens|num}", }, { - when: "usage.has_tokens", + when: "usage.has_split_tokens", text: " ↕️ {usage.input_tokens|num|?}/{usage.output_tokens|num|?}", }, + { when: "usage.has_total_only_tokens", text: " ↕️ {usage.total_tokens|num}" }, { when: "usage.cache_hit_pct", text: " 🗄 {usage.cache_hit_pct|pct}" }, { when: "cost.turn_usd", text: " 💰{cost.turn_usd|fixed:4}" }, ], @@ -54,9 +55,10 @@ export const DEFAULT_USAGE_BAR_TEMPLATE: UsageBarTemplate = { text: " | 📚 [{context.pct_used|meter:5:braille}]{context.max_tokens|num}", }, { - when: "usage.has_tokens", + when: "usage.has_split_tokens", text: " ↕️ {usage.input_tokens|num|?}/{usage.output_tokens|num|?}", }, + { when: "usage.has_total_only_tokens", text: " ↕️ {usage.total_tokens|num}" }, { when: "usage.cache_hit_pct", text: " 🗄 {usage.cache_hit_pct|pct}" }, { when: "cost.turn_usd", text: " 💰{cost.turn_usd|fixed:4}" }, ], diff --git a/src/auto-reply/usage-bar/template.test.ts b/src/auto-reply/usage-bar/template.test.ts index 3da4fef3df21..535db1c93a08 100644 --- a/src/auto-reply/usage-bar/template.test.ts +++ b/src/auto-reply/usage-bar/template.test.ts @@ -1,10 +1,16 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_USAGE_BAR_TEMPLATE } from "./default-template.js"; import { clearUsageBarTemplateCacheForTest, loadUsageBarTemplate } from "./template.js"; +const warnSpy = vi.hoisted(() => vi.fn()); + +vi.mock("../../logging/subsystem.js", () => ({ + createSubsystemLogger: () => ({ warn: warnSpy }), +})); + const tplA = { segments: [{ text: "A" }] }; const tplB = { output: { default: [{ text: "B" }] } }; @@ -12,6 +18,7 @@ let dir: string | undefined; afterEach(() => { clearUsageBarTemplateCacheForTest(); + warnSpy.mockClear(); if (dir) { rmSync(dir, { recursive: true, force: true }); dir = undefined; @@ -36,8 +43,17 @@ describe("loadUsageBarTemplate", () => { it("falls back to the built-in template for an unusable inline object", () => { expect(loadUsageBarTemplate({ nope: true })).toBe(DEFAULT_USAGE_BAR_TEMPLATE); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0]).toMatchObject([ + "configured usage template could not be used; using built-in footer", + { source: "inline", reason: "unsupported-shape" }, + ]); + }); + + it("falls back quietly for an empty inline template", () => { expect(loadUsageBarTemplate({ output: {} })).toBe(DEFAULT_USAGE_BAR_TEMPLATE); expect(loadUsageBarTemplate({ output: { default: [] } })).toBe(DEFAULT_USAGE_BAR_TEMPLATE); + expect(warnSpy).not.toHaveBeenCalled(); }); it("loads and parses a template file", () => { @@ -48,17 +64,24 @@ describe("loadUsageBarTemplate", () => { it("falls back to the built-in template for invalid JSON", () => { const path = tmpFile("bad.json", "{ not json"); expect(loadUsageBarTemplate(path)).toBe(DEFAULT_USAGE_BAR_TEMPLATE); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0]).toMatchObject([ + "configured usage template could not be used; using built-in footer", + { source: "file", reason: "invalid-json", path }, + ]); }); it("falls back to the built-in template for an empty template file", () => { const path = tmpFile("empty.json", JSON.stringify({ output: { default: [] } })); expect(loadUsageBarTemplate(path)).toBe(DEFAULT_USAGE_BAR_TEMPLATE); + expect(warnSpy).not.toHaveBeenCalled(); }); it("reloads a path after an initial miss", () => { dir = mkdtempSync(join(tmpdir(), "usage-template-")); const missing = join(dir, "missing.json"); expect(loadUsageBarTemplate(missing)).toBe(DEFAULT_USAGE_BAR_TEMPLATE); + expect(warnSpy).not.toHaveBeenCalled(); writeFileSync(missing, JSON.stringify(tplB)); expect(loadUsageBarTemplate(missing)).toMatchObject(tplB); }); @@ -66,6 +89,7 @@ describe("loadUsageBarTemplate", () => { it("reloads a path after invalid JSON is fixed", () => { const path = tmpFile("bad.json", "{ not json"); expect(loadUsageBarTemplate(path)).toBe(DEFAULT_USAGE_BAR_TEMPLATE); + expect(warnSpy).toHaveBeenCalledTimes(1); writeFileSync(path, JSON.stringify(tplB)); expect(loadUsageBarTemplate(path)).toMatchObject(tplB); }); diff --git a/src/auto-reply/usage-bar/template.ts b/src/auto-reply/usage-bar/template.ts index 721c18bbea0c..2878870b2144 100644 --- a/src/auto-reply/usage-bar/template.ts +++ b/src/auto-reply/usage-bar/template.ts @@ -1,6 +1,7 @@ import { type FSWatcher, readFileSync, watch } from "node:fs"; import { homedir } from "node:os"; import { isAbsolute, resolve } from "node:path"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; import { DEFAULT_USAGE_BAR_TEMPLATE } from "./default-template.js"; import type { UsageBarTemplate } from "./translator.js"; @@ -8,6 +9,8 @@ export type UsageTemplateConfig = string | Record | undefined; type CacheEntry = { template: UsageBarTemplate | undefined; watcher?: FSWatcher }; const fileCache = new Map(); +const warnedTemplateOverrides = new Set(); +const usageTemplateLog = createSubsystemLogger("usage-template"); function expandPath(p: string): string { if (p === "~") { @@ -41,6 +44,20 @@ function hasOutputPieces(output: unknown): boolean { ); } +function isEmptyTemplate(value: unknown): boolean { + if (!isPlainObject(value)) { + return false; + } + if (Object.keys(value).length === 0) { + return true; + } + if ("segments" in value && Array.isArray(value.segments)) { + return value.segments.length === 0; + } + const output = value.output; + return isPlainObject(output) && !hasOutputPieces(output); +} + function isUsableTemplate(value: unknown): value is UsageBarTemplate { if (!isPlainObject(value)) { return false; @@ -55,27 +72,68 @@ function isUsableTemplate(value: unknown): value is UsageBarTemplate { ); } -function readTemplateFile(path: string): UsageBarTemplate | undefined { +type InvalidTemplateReason = "invalid-json" | "unreadable" | "unsupported-shape"; +type TemplateReadResult = { template?: UsageBarTemplate; reason?: InvalidTemplateReason }; + +function getErrorCode(error: unknown): string | undefined { + if (typeof error !== "object" || error === null || !("code" in error)) { + return undefined; + } + const code = error.code; + return typeof code === "string" ? code : undefined; +} + +function warnInvalidUsageTemplate(source: "inline" | "file", reason: string, path?: string): void { + const key = `${source}:${reason}:${path ?? ""}`; + if (warnedTemplateOverrides.has(key)) { + return; + } + warnedTemplateOverrides.add(key); + usageTemplateLog.warn("configured usage template could not be used; using built-in footer", { + source, + reason, + ...(path ? { path } : {}), + }); +} + +function parseTemplate(value: unknown): TemplateReadResult { + if (isUsableTemplate(value)) { + return { template: value }; + } + return isEmptyTemplate(value) ? {} : { reason: "unsupported-shape" }; +} + +function readTemplateFile(path: string): TemplateReadResult { let raw: string; try { raw = readFileSync(path, "utf8"); - } catch { - return undefined; + } catch (error) { + return getErrorCode(error) === "ENOENT" ? {} : { reason: "unreadable" }; + } + if (raw.trim().length === 0) { + return {}; } try { - const parsed: unknown = JSON.parse(raw); - return isUsableTemplate(parsed) ? parsed : undefined; + return parseTemplate(JSON.parse(raw)); } catch { - return undefined; + return { reason: "invalid-json" }; } } function cacheTemplateFile(path: string): UsageBarTemplate | undefined { - const entry: CacheEntry = { template: readTemplateFile(path) }; + const result = readTemplateFile(path); + if (result.reason) { + warnInvalidUsageTemplate("file", result.reason, path); + } + const entry: CacheEntry = { template: result.template }; if (entry.template) { try { const watcher = watch(path, { persistent: false }, () => { - entry.template = readTemplateFile(path); + const next = readTemplateFile(path); + if (next.reason) { + warnInvalidUsageTemplate("file", next.reason, path); + } + entry.template = next.template; }); watcher.on("error", () => { watcher.close(); @@ -94,7 +152,11 @@ export function loadUsageBarTemplate(configured: UsageTemplateConfig): UsageBarT return DEFAULT_USAGE_BAR_TEMPLATE; } if (typeof configured === "object") { - return isUsableTemplate(configured) ? configured : DEFAULT_USAGE_BAR_TEMPLATE; + const result = parseTemplate(configured); + if (result.reason) { + warnInvalidUsageTemplate("inline", result.reason); + } + return result.template ?? DEFAULT_USAGE_BAR_TEMPLATE; } const path = expandPath(configured); const cached = fileCache.get(path); @@ -110,4 +172,5 @@ export function clearUsageBarTemplateCacheForTest(): void { entry.watcher?.close(); } fileCache.clear(); + warnedTemplateOverrides.clear(); }