-
Notifications
You must be signed in to change notification settings - Fork 3.4k
fix(acp): ACP client provider discards token usage from PromptResponse #8132
Description
Problem
When using an ACP backend (claude-acp, codex-acp), Provider::complete() always returns ProviderUsage with all-zero token counts. Token tracking, cost reporting, and context-window monitoring are broken for any ACP-based provider.
Root cause — three-point gap in crates/goose/src/acp/provider.rs
- Enum:
AcpUpdate::Complete(StopReason)only carriesStopReason, no room for usage (provider.rs L118) - Sender: When
PromptResponsearrives, onlyr.stop_reasonis forwarded;r.usageis silently dropped (provider.rs L1112) - Receiver: The stream handler breaks out of the loop on
Completewithout yielding anyProviderUsage(provider.rs L711-712)
Since the stream never yields a non-None usage, collect_stream() in providers/base.rs falls back to ProviderUsage::new("unknown", Usage::default()).
ACP protocol support exists
agent-client-protocol-schemav0.10.8 definesPromptResponse.usage: Option<Usage>behind theunstable_session_usagefeature- Goose enables this via
features = ["unstable"]incrates/goose/Cargo.toml - The
Usagestruct has:total_tokens,input_tokens,output_tokens, plus optionalthought_tokens,cached_read_tokens,cached_write_tokens
ACP agent adapter status
| Agent adapter | Populates PromptResponse.usage? |
Sends UsageUpdate notifications? |
|---|---|---|
| claude-agent-acp | Yes — accumulates from every Claude API result | Yes (context window + cost) |
| codex-acp (Zed) | No — returns bare PromptResponse::new(stop_reason) |
Yes (context window only) |
goose-acp server (crates/goose-acp/src/server.rs) |
No — returns bare PromptResponse::new(stop_reason) |
— |
Note: UsageUpdate (a mid-stream SessionNotification) only carries context-window-level data (used, size, optional cost), not per-turn input/output token breakdown. It cannot substitute for PromptResponse.usage.
Proposed fix — two parts
Part A: ACP client provider (crates/goose/src/acp/provider.rs) — consume usage
Three targeted changes:
1. Extend AcpUpdate::Complete to carry usage:
use agent_client_protocol_schema::Usage as AcpUsage;
enum AcpUpdate {
// ...existing variants...
Complete(StopReason, Option<AcpUsage>),
// ...
}2. Extract r.usage when sending the complete signal (~L1112):
Ok(r) => {
let _ = response_tx.try_send(AcpUpdate::Complete(r.stop_reason, r.usage));
}3. Map ACP Usage to ProviderUsage and yield on the final stream item (~L711):
AcpUpdate::Complete(_reason, acp_usage) => {
if let Some(u) = acp_usage {
let provider_usage = ProviderUsage::new(
model_config.model_name.clone(),
Usage {
input_tokens: Some(u.input_tokens as i32),
output_tokens: Some(u.output_tokens as i32),
total_tokens: Some(u.total_tokens as i32),
},
);
yield (None, Some(provider_usage));
}
break;
}Part B: ACP server (crates/goose-acp/src/server.rs) — emit usage
The Goose ACP server (when Goose acts as an ACP agent) returns bare PromptResponse::new(stop_reason) without populating .usage(). Since Goose already tracks ProviderUsage internally per agent turn, it should accumulate and return it:
let response = PromptResponse::new(stop_reason)
.usage(acp_schema::Usage::new(
total_tokens,
input_tokens,
output_tokens,
));
Ok(response)Notes
- The
Usagetype fromagent-client-protocol-schemausesu64while Goose's internalUsageusesOption<i32>. The mapping truncates, which is fine for practical token counts but worth noting. AcpUsagealso has optionalthought_tokens,cached_read_tokens,cached_write_tokenswhich Goose'sUsagedoes not model. These could be surfaced later.- For agents that don't populate
PromptResponse.usage(like codex-acp today), the fix degrades gracefully —r.usageisNone, noProviderUsageis yielded, and the existing"unknown"fallback applies. As those adapters add usage reporting, it starts working automatically.