Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
219 changes: 201 additions & 18 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ use tokio::process::{Child, ChildStdin, ChildStdout};
use tokio_util::codec::{FramedRead, LinesCodec, LinesCodecError};

use crate::observer::{ObserverContext, ObserverHandle};
use crate::usage::{TurnUsage, UsageTracker};
use crate::usage::{
PromptResponseUsage, StandardAdapterKind, StandardUsageTracker, TurnUsage, UsageTracker,
};

/// Maximum allowed size of a single NDJSON line from the agent's stdout.
/// Lines exceeding this limit are rejected to prevent OOM from rogue agents.
Expand Down Expand Up @@ -206,11 +208,12 @@ pub struct AcpClient {
/// outside of a goose-native turn — the read loop's steer arm is
/// disabled in that case.
steer_rx: Option<tokio::sync::mpsc::Receiver<crate::pool::SteerRequest>>,
/// Usage tracker — accumulates cumulative token counts from
/// `_goose/unstable/session/update` notifications and computes per-turn
/// deltas. Both goose and buzz-agent emit this notification; goose gates
/// on client capability advertisement, buzz-agent emits unconditionally.
/// Usage tracker for goose/buzz-agent's cumulative notification format.
goose_usage: UsageTracker,
/// Per-turn prompt-response usage and Claude's optional cumulative cost.
standard_usage: StandardUsageTracker,
/// Known adapter identity for prompt-response usage mapping.
standard_adapter: Option<StandardAdapterKind>,
}

/// Recursively merge `overlay` into `base`, with `overlay` winning on scalar/shape
Expand Down Expand Up @@ -523,6 +526,14 @@ impl AcpClient {
// console-subsystem child process spawned from a GUI/non-console parent.
configure_no_window(&mut cmd);

let standard_adapter =
match crate::config::normalize_agent_command_identity(command).as_str() {
"claude-agent-acp" | "claude-code-acp" | "claude-code" | "claudecode" => {
Some(StandardAdapterKind::Claude)
}
"codex" | "codex-acp" => Some(StandardAdapterKind::Codex),
_ => None,
};
let mut child = cmd.spawn()?;

let stdin = child
Expand Down Expand Up @@ -550,6 +561,8 @@ impl AcpClient {
steering_supported: false,
steer_rx: None,
goose_usage: UsageTracker::default(),
standard_usage: StandardUsageTracker::default(),
standard_adapter,
})
}

Expand Down Expand Up @@ -776,6 +789,7 @@ impl AcpClient {
// prompt so that any setup notifications recorded earlier are not
// misattributed to this turn.
self.goose_usage.begin_turn(session_id);
self.standard_usage.begin_turn(session_id);

self.last_prompt_id = Some(self.next_id);
let id = self.next_id;
Expand Down Expand Up @@ -821,7 +835,7 @@ impl AcpClient {
self.current_hard_deadline = None;
}
}
self.parse_stop_reason(&result?)
self.parse_prompt_response(session_id, &result?)
}

/// Send a `session/cancel` **notification** (no `id` field, no response expected).
Expand Down Expand Up @@ -867,18 +881,13 @@ impl AcpClient {
self.steering_supported
}

/// Consume and return the per-turn usage record computed from the most
/// recent `_goose/unstable/session/update` notification.
///
/// Returns `None` if no usage update arrived since the last call (i.e.
/// the harness did not emit one for this turn, or this is not a goose
/// agent). Must be called at most once per turn; subsequent calls return
/// `None` until the next `usage_update` notification is recorded.
///
/// Intended for consumption by `publish_agent_turn_metric` in `pool.rs` to
/// publish a kind 44200 NIP-AM event.
/// Consume per-turn usage for NIP-AM publishing. Goose/buzz-agent is an
/// exclusive cumulative path; standard ACP prompt usage is used only when
/// goose emitted nothing for this turn.
pub fn take_turn_usage(&mut self) -> Option<TurnUsage> {
self.goose_usage.take()
let goose_usage = self.goose_usage.take();
let standard_usage = self.standard_usage.take();
goose_usage.or(standard_usage)
}

/// Install a per-turn steer request channel for goose-native
Expand Down Expand Up @@ -1038,7 +1047,7 @@ impl AcpClient {
remaining,
)
.await?;
self.parse_stop_reason(&result)
self.parse_prompt_response(session_id, &result)
}

/// Serialize `value` as a single NDJSON line and flush to the agent's stdin.
Expand Down Expand Up @@ -1818,6 +1827,10 @@ impl AcpClient {
}
false
}
"usage_update" => {
self.handle_standard_usage_update(msg);
false
}
"keepalive" => false,
other => {
tracing::debug!(target: "acp::update", "session/update: {other}");
Expand All @@ -1826,6 +1839,30 @@ impl AcpClient {
}
}

/// Record the standard ACP cumulative cost notification when emitted by
/// Claude. Unlike Goose's payload, `used`/`size` are context occupancy and
/// are intentionally not mapped to token accounting.
fn handle_standard_usage_update(&mut self, msg: &serde_json::Value) {
if self.standard_adapter != Some(StandardAdapterKind::Claude) {
return;
}
let session_id = match msg
.pointer("/params/sessionId")
.and_then(serde_json::Value::as_str)
{
Some(session_id) => session_id,
None => return,
};
let cost = match msg
.pointer("/params/update/cost/amount")
.and_then(serde_json::Value::as_f64)
{
Some(cost) => cost,
None => return,
};
self.standard_usage.record_cost(session_id, cost);
}

/// Parse a `_goose/unstable/session/update` notification and record the
/// usage snapshot in the per-session tracker.
///
Expand Down Expand Up @@ -1924,6 +1961,28 @@ impl AcpClient {
Ok(())
}

/// Parse a completed prompt response and retain its optional per-turn usage.
fn parse_prompt_response(
&mut self,
session_id: &str,
result: &serde_json::Value,
) -> Result<StopReason, AcpError> {
let stop_reason = self.parse_stop_reason(result)?;
if let Some(adapter) = self.standard_adapter {
match serde_json::from_value::<PromptResponseUsage>(result["usage"].clone()) {
Ok(usage) => self
.standard_usage
.record_prompt_usage(session_id, usage, adapter),
Err(_) if result.get("usage").is_some() => tracing::debug!(
target: "acp::usage",
"session/prompt response contained malformed standard usage"
),
Err(_) => {}
}
}
Ok(stop_reason)
}

/// Parse `stopReason` from a `session/prompt` result value.
fn parse_stop_reason(&self, result: &serde_json::Value) -> Result<StopReason, AcpError> {
let raw = result["stopReason"].as_str().ok_or_else(|| {
Expand Down Expand Up @@ -4298,6 +4357,130 @@ mod tests {
}
}

// ── Standard ACP prompt-response usage ─────────────────────────────────

fn prompt_response_usage(
input: u64,
output: u64,
total: u64,
cached_read: Option<u64>,
cached_write: Option<u64>,
) -> serde_json::Value {
let mut usage = serde_json::json!({
"inputTokens": input,
"outputTokens": output,
"totalTokens": total,
});
if let Some(cached_read) = cached_read {
usage["cachedReadTokens"] = serde_json::json!(cached_read);
}
if let Some(cached_write) = cached_write {
usage["cachedWriteTokens"] = serde_json::json!(cached_write);
}
serde_json::json!({"stopReason": "end_turn", "usage": usage})
}

fn standard_cost_update(session_id: &str, cost: f64) -> serde_json::Value {
serde_json::json!({
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": session_id,
"update": {
"sessionUpdate": "usage_update",
"cost": {"amount": cost, "currency": "USD"}
}
}
})
}

#[tokio::test]
async fn claude_prompt_response_usage_merges_with_cumulative_cost() {
let mut client = spawn_inert_client().await;
client.standard_adapter = Some(StandardAdapterKind::Claude);
client.standard_usage.begin_turn("claude-session");
client.handle_session_update(&standard_cost_update("claude-session", 0.042));
assert_eq!(
client
.parse_prompt_response(
"claude-session",
&prompt_response_usage(100, 20, 175, Some(30), Some(25)),
)
.unwrap(),
StopReason::EndTurn
);

let usage = client.take_turn_usage().expect("prompt usage");
assert!(usage.delta_reliable, "response tokens need no baseline");
assert_eq!(usage.turn_input_tokens, Some(155));
assert_eq!(usage.turn_output_tokens, Some(20));
assert_eq!(
usage.turn_total_tokens, None,
"Claude total is adapter-derived"
);
assert_eq!(usage.turn_cache_read_tokens, Some(30));
assert_eq!(usage.turn_cache_write_tokens, Some(25));
assert_eq!(usage.turn_cost_usd, None, "cost remains cumulative");
assert_eq!(usage.cumulative_cost_usd, Some(0.042));
assert!(usage.has_cumulative_usage);
assert!(!usage.cumulative_tokens_present);
}

#[tokio::test]
async fn codex_prompt_response_usage_preserves_provider_total_without_cost() {
let mut client = spawn_inert_client().await;
client.standard_adapter = Some(StandardAdapterKind::Codex);
client.standard_usage.begin_turn("codex-session");
client.handle_session_update(&standard_cost_update("codex-session", 0.042));
client
.parse_prompt_response(
"codex-session",
&prompt_response_usage(90, 10, 140, Some(40), None),
)
.unwrap();

let usage = client.take_turn_usage().expect("prompt usage");
assert!(usage.delta_reliable);
// NIP-AM.md:167-174 requires cache reads to be folded into inputTokens;
// the provider total can additionally include adapter-dropped reasoning tokens.
assert_eq!(usage.turn_input_tokens, Some(130));
assert_eq!(usage.turn_output_tokens, Some(10));
assert_eq!(usage.turn_total_tokens, Some(140));
assert_eq!(usage.turn_cache_read_tokens, Some(40));
assert_eq!(usage.turn_cache_write_tokens, None);
assert_eq!(
usage.cumulative_cost_usd, None,
"Codex cost update is ignored"
);
assert!(!usage.has_cumulative_usage);
}

#[tokio::test]
async fn goose_usage_stays_exclusive_and_drains_standard_usage() {
let mut client = spawn_inert_client().await;
client.standard_adapter = Some(StandardAdapterKind::Claude);
client.goose_usage.begin_turn("goose-session");
client.standard_usage.begin_turn("goose-session");
client.handle_goose_usage_update(&goose_usage_update_msg("goose-session", 1000, 200, None));
client
.parse_prompt_response(
"goose-session",
&prompt_response_usage(100, 20, 120, None, None),
)
.unwrap();

let usage = client.take_turn_usage().expect("goose usage");
assert_eq!(usage.cumulative_input_tokens, 1000);
assert_eq!(
usage.turn_input_tokens, None,
"goose first delta remains exclusive"
);
assert!(
client.take_turn_usage().is_none(),
"standard usage was drained"
);
}

// ── Goose usage notification integration ──────────────────────────────

/// Build a `_goose/unstable/session/update` JSON-RPC notification.
Expand Down
Loading
Loading