Skip to content

ACP has no token-usage field, and no way to express that a provider cannot report usage #2397

Description

@albrand

Summary

The standard ACP surface carries no token-consumption field, so ACP threads produce no thread/tokenUsage/updated events. Nothing expresses that, so downstream an unmeasured turn looks the same as a turn that used nothing.

To be clear about what this is not: the bridge is not dropping anything. On main (42d2c1b), ACP's only usage message is

// packages/provider-bridge-acp/src/wire.ts:246-253
acpUsageUpdateSchema = { sessionUpdate: "usage_update", used, size }   // .passthrough()

— context-window occupancy — and delta-translation.ts:970-983 maps it to { kind: "contextWindow", used, size, estimated: false }. That mapping looks correct. There is simply no token data on the standard surface to forward.

Observation

From bb.db on one host:

SELECT t.provider_id,
       count(DISTINCT t.id) AS threads,
       sum(CASE WHEN e.type LIKE '%turn/completed'        THEN 1 ELSE 0 END) AS completed_turns,
       sum(CASE WHEN e.type = 'thread/tokenUsage/updated' THEN 1 ELSE 0 END) AS usage_events
FROM events e JOIN threads t ON t.id = e.thread_id
GROUP BY t.provider_id ORDER BY completed_turns DESC;
provider threads completed turns tokenUsage/updated
claude-code 62 1932 96
codex 153 570 368
acp-opencode 124 307 0
acp-hermes-agent 38 305 0

Scope of that data, so it isn't read as more than it is: both ACP providers are locally-registered agents rather than anything bb ships, and both run through the same bridge and translation path — so the zeros follow from the schema above rather than from two independent agents. The non-ACP rows are not 1:1 either (claude-code shows 1932 completions against 96 usage events), so no per-turn denominator should be read from this table. The distinct threads reporting a context window are 106 (acp-opencode) and 38 (acp-hermes-agent), so these threads are observed — only their cost is absent.

Why it's awkward downstream

A consumer can detect the absence by joining on events, as the query above does. The cost falls on consumers that instead collapse a missing report to a numeric zero, which is easy to write when the providers you integrate against first all report. That happened in a plugin I maintain: its usage columns are NOT NULL DEFAULT 0, so every ACP row reads as a measured zero and share-style metrics return a confident 0% for ACP threads.

That coercion is mine, not bb's. The general point is only that nothing in the event stream states "this turn produced no report", so correct handling depends on a consumer knowing to look for an absence.

Possible improvement

Optional capability metadata on the provider descriptor:

reportsTokenUsage?: boolean;   // absent = unknown; false = known not to

Absence should mean unknown rather than true — existing and third-party descriptors omit the field, and reading those as capable would assert something unestablished.

A capability rather than a per-turn marker because dialect.ts already lets a vendor stamp _meta side channels and acpUsageUpdateSchema is .passthrough(), so an agent could carry token data without a protocol change. A dialect that does surface tokens sets true; the generic ACP dialect sets false. That way the signal improves as vendors improve instead of hard-coding a limitation, and a consumer decides once per provider.

Its limit, stated plainly: a capability describes what a provider can do, not whether a given turn was measured. If those diverge within a provider it can't express that — I have no evidence they do.

Tests it would need: descriptor parse/serialize round-trip with the field present and absent, and a legacy descriptor with no field staying unknown rather than coerced.

Metadata

Metadata

Assignees

No one assigned

    Labels

    provider-acpBuilt-in plugin: provider-acpprovidersCross-provider bridges, models, login

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions