fix(usage): tighten usage footer template handling

This commit is contained in:
Ayaan Zaidi
2026-06-13 14:37:23 +05:30
parent d4237cb14d
commit b477bfe84b
10 changed files with 64 additions and 151 deletions

View File

@@ -30,6 +30,23 @@ title: "Usage tracking"
- CLI: `openclaw channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
- macOS menu bar: "Usage" section under Context (only if available).
## Custom `/usage full` footer
Set `messages.usageTemplate` to customize the per-response `/usage full`
footer. The value can be an inline template object or a JSON file path:
```json
{
"messages": {
"usageTemplate": "~/.openclaw/usage-footer.json"
}
}
```
Templates read the `openclaw.usageLine.v1` contract and can use `scales`,
`aliases`, and `output.surfaces` to render channel-specific footers. Missing,
unreadable, invalid, or empty templates fall back to the built-in usage line.
## Providers + credentials
- **Anthropic (Claude)**: OAuth tokens in auth profiles.

View File

@@ -70,6 +70,9 @@ import type { OriginatingChannelType, TemplateContext } from "../templating.js";
import { resolveResponseUsageMode, type VerboseLevel } from "../thinking.js";
import { SILENT_REPLY_TOKEN } from "../tokens.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
import { buildUsageContract } from "../usage-bar/contract.js";
import { loadUsageBarTemplate } from "../usage-bar/template.js";
import { renderUsageBar } from "../usage-bar/translator.js";
import {
buildKnownAgentRunFailureReplyPayload,
runAgentTurnWithFallback,
@@ -90,9 +93,6 @@ import {
import { resetReplyRunSession } from "./agent-runner-session-reset.js";
import { appendUsageLine, formatResponseUsageLine } from "./agent-runner-usage-line.js";
import { resolveQueuedReplyExecutionConfig } from "./agent-runner-utils.js";
import { buildUsageContract } from "../usage-bar/contract.js";
import { loadUsageBarTemplate } from "../usage-bar/template.js";
import { renderUsageBar } from "../usage-bar/translator.js";
import { createAudioAsVoiceBuffer, createBlockReplyPipeline } from "./block-reply-pipeline.js";
import { resolveEffectiveBlockStreamingConfig } from "./block-streaming.js";
import {

View File

@@ -1,7 +1,3 @@
// Build the `openclaw.usageLine.v1` contract that the translator consumes from
// the per-turn `reply_payload_sending` usage snapshot. This is the in-core port
// of the usage-footer plugin's `buildContract`, so the same template renders
// identically whether driven by the plugin or by the native /usage full path.
import type { PluginHookReplyUsageState } from "../../plugins/hook-types.js";
import type { UsageContract } from "./translator.js";
@@ -16,15 +12,10 @@ export function buildUsageContract(
const cacheWrite = usage.cacheWrite;
const total = usage.total;
// cache_hit_pct: cacheRead only (writes are misses being cached). Matches
// core status-message.ts.
const promptTotal = (cacheRead ?? 0) + (cacheWrite ?? 0) + (input ?? 0);
const cacheHitPct =
promptTotal > 0 ? Math.round(((cacheRead ?? 0) / promptTotal) * 100) : undefined;
// Last-call usage (final model call only) so templates can render the last
// exchange instead of the turn aggregate. Its cache_hit_pct is computed over
// the last call's prompt total, same formula as the turn-level one.
const last = state.lastUsage;
const lastPromptTotal = last
? (last.cacheRead ?? 0) + (last.cacheWrite ?? 0) + (last.input ?? 0)
@@ -35,11 +26,6 @@ export function buildUsageContract(
: undefined;
const maxTokens = state.contextTokenBudget;
// Context occupancy is a point-in-time STATE, never an aggregate. Prefer the
// turn's real end-of-turn context size (final call's prompt tokens); fall back
// to the aggregate prompt total only for harnesses that don't report it
// (single-call turns, where the two coincide). The aggregate over a multi-call
// tool loop overstates occupancy — often past the window — pinning the gauge.
const usedTokens =
typeof state.contextUsedTokens === "number" && state.contextUsedTokens > 0
? state.contextUsedTokens
@@ -58,7 +44,6 @@ export function buildUsageContract(
return {
schema: "openclaw.usageLine.v1",
surface: surface ?? null,
// agentId is exposed flat so templates can key per-agent (e.g. emoji map).
agentId: state.agentId ?? null,
chat_type: state.chatType ?? null,
model: {
@@ -79,15 +64,12 @@ export function buildUsageContract(
compactions: typeof state.compactionCount === "number" ? state.compactionCount : null,
},
usage: {
// Turn aggregate: summed across every model call in the turn's tool loop.
input_tokens: input,
output_tokens: output,
cache_read_tokens: cacheRead,
cache_write_tokens: cacheWrite,
total_tokens: total,
cache_hit_pct: cacheHitPct,
// Final model call only. Templates choose `{usage.input_tokens}` (turn)
// vs `{usage.last.input_tokens}` (last exchange). Absent → segment drops.
last: last
? {
input_tokens: last.input,
@@ -117,6 +99,5 @@ export function buildUsageContract(
avatar: state.identity?.avatar ?? null,
},
session: { id: state.sessionId ?? null },
...(state.limits ? { limits: state.limits } : {}),
};
}

View File

@@ -4,8 +4,6 @@ import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { clearUsageBarTemplateCacheForTest, loadUsageBarTemplate } from "./template.js";
// Two structurally-valid templates (isUsableTemplate accepts either an `output`
// object or a `segments` array) so we can tell which one resolved.
const tplA = { segments: [{ text: "A" }] };
const tplB = { output: { lines: [] } };
@@ -46,25 +44,23 @@ describe("loadUsageBarTemplate", () => {
expect(loadUsageBarTemplate(path)).toBeUndefined();
});
it("does not cache a missing file, so a later-created template is picked up", () => {
it("caches a missing path as no template", () => {
dir = mkdtempSync(join(tmpdir(), "usage-template-"));
const missing = join(dir, "missing.json");
expect(loadUsageBarTemplate(missing)).toBeUndefined();
writeFileSync(missing, JSON.stringify(tplB));
expect(loadUsageBarTemplate(missing)).toBeUndefined();
clearUsageBarTemplateCacheForTest();
expect(loadUsageBarTemplate(missing)).toMatchObject(tplB);
});
it("serves the cached template on the hot path without re-reading the file", () => {
it("serves the cached template without re-reading the file", () => {
const path = tmpFile("t.json", JSON.stringify(tplA));
expect(loadUsageBarTemplate(path)).toMatchObject(tplA); // first load caches
expect(loadUsageBarTemplate(path)).toMatchObject(tplA);
// Change the file on disk. The reply path must NOT re-read synchronously, so
// the very next call still returns the cached value (the watcher refresh is
// async and has not fired within this synchronous sequence).
writeFileSync(path, JSON.stringify(tplB));
expect(loadUsageBarTemplate(path)).toMatchObject(tplA);
// After an explicit cache reset the fresh content loads.
clearUsageBarTemplateCacheForTest();
expect(loadUsageBarTemplate(path)).toMatchObject(tplB);
});

View File

@@ -1,9 +1,3 @@
// Resolve the usage-bar template from config (`messages.usageTemplate`): either
// an inline template object, or a path to a JSON file. For a path, the template
// is read ONCE into memory and then kept fresh by a filesystem watcher, so the
// per-reply render path never touches disk — no synchronous stat/read in the
// latency-sensitive reply-delivery path. When no usable template resolves, the
// caller falls back to the built-in (boring) usage line.
import { type FSWatcher, readFileSync, watch } from "node:fs";
import { homedir } from "node:os";
import { isAbsolute, resolve } from "node:path";
@@ -12,9 +6,6 @@ import type { UsageBarTemplate } from "./translator.js";
export type UsageTemplateConfig = string | Record<string, unknown> | undefined;
type CacheEntry = { template: UsageBarTemplate | undefined; watcher?: FSWatcher };
// Keyed by resolved path. A present entry means the file was read at least once;
// the reply path then serves `template` synchronously with zero filesystem
// access, and a watcher refreshes it off the hot path on change.
const fileCache = new Map<string, CacheEntry>();
function expandPath(p: string): string {
@@ -27,7 +18,6 @@ function expandPath(p: string): string {
return isAbsolute(p) ? p : resolve(p);
}
// A usable template must carry a layout the engine understands.
function isUsableTemplate(value: unknown): value is UsageBarTemplate {
if (typeof value !== "object" || value === null) {
return false;
@@ -37,24 +27,40 @@ function isUsableTemplate(value: unknown): value is UsageBarTemplate {
return hasOutput || Array.isArray(obj.segments);
}
// Read + parse a template file into a usable template, or undefined for
// unreadable/invalid contents. Only called off the reply path: at first load and
// from the watcher callback.
function readTemplateFile(path: string): UsageBarTemplate | undefined {
let raw: string;
try {
raw = readFileSync(path, "utf8");
} catch {
return undefined; // removed/unreadable -> boring fallback
return undefined;
}
try {
const parsed: unknown = JSON.parse(raw);
return isUsableTemplate(parsed) ? parsed : undefined;
} catch {
return undefined; // invalid JSON -> boring fallback
return undefined;
}
}
function cacheTemplateFile(path: string): UsageBarTemplate | undefined {
const entry: CacheEntry = { template: readTemplateFile(path) };
if (entry.template) {
try {
const watcher = watch(path, { persistent: false }, () => {
entry.template = readTemplateFile(path);
});
watcher.on("error", () => {
watcher.close();
});
entry.watcher = watcher;
} catch {
// Cache remains valid without live refresh.
}
}
fileCache.set(path, entry);
return entry.template;
}
export function loadUsageBarTemplate(
configured: UsageTemplateConfig,
): UsageBarTemplate | undefined {
@@ -67,44 +73,9 @@ export function loadUsageBarTemplate(
const path = expandPath(configured);
const cached = fileCache.get(path);
if (cached) {
return cached.template; // hot path: in-memory, no filesystem access
return cached.template;
}
// First resolution for this path. Probe once; if the file is missing/unreadable
// we do NOT cache, so a later-created template is still picked up on a
// subsequent call (the only path that stats per reply is the misconfigured
// "configured but absent" one, never the normal one).
let raw: string;
try {
raw = readFileSync(path, "utf8");
} catch {
return undefined;
}
let template: UsageBarTemplate | undefined;
try {
const parsed: unknown = JSON.parse(raw);
template = isUsableTemplate(parsed) ? parsed : undefined;
} catch {
template = undefined;
}
// The file exists and was read once; from here the reply path is filesystem
// free. Keep the in-memory copy fresh via a watcher (off the hot path). A watch
// failure (unsupported FS, race) just leaves the one-time load with no live
// refresh — still strictly better than a stat on every reply.
const entry: CacheEntry = { template };
try {
const watcher = watch(path, { persistent: false }, () => {
entry.template = readTemplateFile(path);
});
watcher.on("error", () => {
// Best-effort: keep the last-known template rather than throwing on a
// watch error (e.g. the file being removed).
});
entry.watcher = watcher;
} catch {
// Unwatchable path: cache the one-time load anyway (no refresh until restart).
}
fileCache.set(path, entry);
return template;
return cacheTemplateFile(path);
}
export function clearUsageBarTemplateCacheForTest(): void {

View File

@@ -90,25 +90,23 @@ describe("usage-bar segment forms", () => {
it("each with item_scales picks a scale per window by position", () => {
const seg = [
{
text: "📊",
each: "limits.windows",
text: "W",
each: "windows",
item: "{pct_left|meter:1:*}{resets_in_s|dur}",
item_scales: ["weather", "plants"],
},
];
const out = render(seg, {
limits: {
windows: [
{ pct_left: 92, resets_in_s: 17100 },
{ pct_left: 70, resets_in_s: 570240 },
],
},
windows: [
{ pct_left: 92, resets_in_s: 17100 },
{ pct_left: 70, resets_in_s: 570240 },
],
});
expect(out).toBe("📊4h45m 🍀6.6d");
expect(out).toBe("W4h45m 🍀6.6d");
});
it("each drops the whole segment when the array is empty", () => {
expect(render([{ text: "📊", each: "limits.windows", item: "{x}" }], { limits: {} })).toBe("");
expect(render([{ text: "W", each: "windows", item: "{x}" }], {})).toBe("");
});
});
@@ -122,15 +120,9 @@ describe("usage-bar end-to-end with buildUsageContract", () => {
fastMode: false,
fallbackUsed: false,
contextTokenBudget: 272000,
contextUsedTokens: 204000,
usage: { input: 204000, output: 15, cacheRead: 0, cacheWrite: 0, total: 204015 },
limits: {
available: true,
source: "core",
windows: [
{ label: "5h", used_pct: 8, pct_left: 92, resets_in_s: 17100 },
{ label: "week", used_pct: 30, pct_left: 70, resets_in_s: 570240 },
],
},
turnUsd: 0.03771985,
},
"discord",
);
@@ -141,15 +133,8 @@ describe("usage-bar end-to-end with buildUsageContract", () => {
{ when: "model.reasoning", text: "{model.reasoning|alias:reasoning}" },
{ map: "state.fast_mode", cases: { true: "⚡", false: "🐌" } },
{ text: " | 📚 [{context.pct_used|meter:5:braille}]{context.max_tokens|num}" },
{
text: " | 📊",
each: "limits.windows",
item: "{pct_left|meter:1:*}{resets_in_s|dur}",
item_scales: ["weather", "plants"],
},
{ text: " | ${cost.turn_usd|fixed:4}" },
];
expect(renderUsageBar(tpl(pieces), contract)).toBe(
"opus46 | med🐌 | 📚 [⣿⣿⣿⣧⠐]272k | 📊 ☀4h45m 🍀6.6d",
);
expect(renderUsageBar(tpl(pieces), contract)).toBe("opus46 | med🐌 | 📚 [⣿⣿⣿⣧⠐]272k | $0.0377");
});
});

View File

@@ -1,20 +1,3 @@
// Declarative usage-bar translator — the in-core port of the reference
// `usage_bar.py` engine. It is a TRANSLATOR, not a dictionary: it contains no
// glyphs, no layout, and no default footer — only mechanisms. All *content*
// (which glyphs make a meter, the segment order, the framing) is DATA in the
// template (`scales` / `aliases` / `output`). See usage-bar/template.ts.
//
// Verbs, used as {path|verb:args|fallback}:
// num 3000 -> "3.0k" (compact count)
// fixed:N 0.03771985 -> "0.0377" (fixed-decimal; N digits, default 2)
// dur 14820 -> "4h07m" (seconds -> reset)
// pct 96 -> "96%"
// inv 100-value complement (88 -> 12); pipe before another verb
// alias:TABLE look value up in aliases[TABLE]; echo raw value if unlisted
// meter:WIDTH:SCALE 0-100 value -> WIDTH cells from scales[SCALE] (graded
// boundary cell); meter:1 = a single glyph
// Segment forms: text / when / map+cases / each(+item, item_scales).
export type UsageBarTemplate = Record<string, unknown>;
export type UsageContract = Record<string, unknown>;
type Vocab = Record<string, unknown>;
@@ -22,23 +5,16 @@ type Vocab = Record<string, unknown>;
const isObject = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null && !Array.isArray(v);
// A "scale" is an ordered glyph vocabulary. Strings must be split by CODE POINT
// (not UTF-16 unit) so astral glyphs like 🌑 stay intact — JS string indexing
// would slice a surrogate pair in half.
function toGlyphs(scale: unknown): string[] {
if (Array.isArray(scale)) {
return scale.filter((g): g is string => typeof g === "string");
}
if (typeof scale === "string") {
// Array.from iterates by code point (like spread) so astral glyphs survive,
// without tripping no-misused-spread. Scales are single-code-point glyphs;
// multi-code-point emoji (e.g. ☀️) are supplied as array scales instead.
return Array.from(scale);
}
return [];
}
// --- number formatters (algorithms, not content) ----------------------------
function num(value: unknown): string {
if (value === null || value === undefined || value === "") {
return "";
@@ -111,7 +87,6 @@ function norm(value: unknown): number {
return Math.max(0, Math.min(100, n)) / 100;
}
// --- meter mechanism (glyphs supplied by the template, not here) --------------
function meter(value: unknown, width: number, scale: unknown): string {
const glyphs = toGlyphs(scale);
if (glyphs.length < 2 || width < 1) {
@@ -171,7 +146,6 @@ function applyVerb(name: string, args: string[], value: unknown, vocab: Vocab):
}
}
// --- template walker ----------------------------------------------------------
function getPath(ctx: unknown, path: string): unknown {
let cur: unknown = ctx;
for (const part of path.split(".")) {
@@ -273,7 +247,6 @@ function resolveLayout(
const sep = typeof output.sep === "string" ? output.sep : "";
return { sep, pieces: Array.isArray(pieces) ? (pieces as Segment[]) : [] };
}
// legacy: top-level surfaces.<surface>.{sep,segments} over top-level sep/segments
const ov =
typeof surface === "string" &&
isObject(template.surfaces) &&
@@ -290,11 +263,6 @@ function resolveLayout(
return { sep, pieces: segments as Segment[] };
}
/**
* Render a usage footer from a template + contract. Returns "" when the template
* produces nothing (caller falls back to the boring built-in footer). Never
* throws for malformed templates — best-effort, fail-open.
*/
export function renderUsageBar(template: UsageBarTemplate, contract: UsageContract): string {
try {
const { sep, pieces } = resolveLayout(template, contract.surface);

View File

@@ -1873,6 +1873,8 @@ export const FIELD_HELP: Record<string, string> = {
'Controls visible source replies across direct, group, and channel conversations. "message_tool" requires message(action=send) for visible output and keeps normal final text private. "automatic" posts normal replies as before.',
"messages.responsePrefix":
"Prefix text prepended to outbound assistant replies before sending to channels. Use for lightweight branding/context tags and avoid long prefixes that reduce content density.",
"messages.usageTemplate":
"Custom /usage full footer template, either an inline object or a JSON file path. Invalid or unavailable templates fall back to the built-in usage line.",
"messages.groupChat":
"Group-message handling controls including mention triggers and history window sizing. Keep mention patterns narrow so group channels do not trigger on every message.",
"messages.groupChat.mentionPatterns":

View File

@@ -967,6 +967,7 @@ export const FIELD_LABELS: Record<string, string> = {
"messages.messagePrefix": "Inbound Message Prefix",
"messages.visibleReplies": "Visible Replies",
"messages.responsePrefix": "Outbound Response Prefix",
"messages.usageTemplate": "Usage Footer Template",
"messages.groupChat": "Group Chat Rules",
"messages.groupChat.mentionPatterns": "Group Mention Patterns",
"messages.groupChat.historyLimit": "Group History Limit",

View File

@@ -139,15 +139,7 @@ export type MessagesConfig = {
* Default: none
*/
responsePrefix?: string;
/**
* Custom `/usage full` footer template (the declarative `openclaw.usageBar.v1`
* format: `scales` / `aliases` / `output.surfaces`). When set and usable, the
* per-reply footer is rendered from it instead of the built-in line; an absent,
* unreadable, or invalid template falls back to the built-in (fail-open).
*
* - string: path to a JSON template file (supports a leading `~`).
* - object: an inline template.
*/
/** Custom `/usage full` footer template, inline or JSON file path. */
usageTemplate?: string | Record<string, unknown>;
groupChat?: GroupChatConfig;
queue?: QueueConfig;