Skip to content
Merged
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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@ Buzz spans five repos. This one (`block/buzz`) is the OSS source for the relay,
| Repo | Purpose |
|------|---------|
| [block/buzz](https://github.com/block/buzz) | OSS source — relay, desktop app, mobile app, CLI, agent harness |
| [squareup/sprout-releases](https://github.com/squareup/sprout-releases) | Buildkite pipeline producing Block-signed macOS + iOS builds with `-block` version suffix |
| [squareup/buzz-releases](https://github.com/squareup/buzz-releases) | Buildkite pipelines producing Block-signed macOS + iOS builds with `-block` desktop version suffix |
| [squareup/sprout-oss](https://github.com/squareup/sprout-oss) | CI pipeline building the relay Docker image and pushing to internal ECR |
| [squareup/block-coder-tf-stacks](https://github.com/squareup/block-coder-tf-stacks) | Terraform + ArgoCD deploying the relay to the staging Kubernetes cluster |
| [squareup/sprout-backend-blox](https://github.com/squareup/sprout-backend-blox) | Desktop backend provider script connecting Blox workstation agents to the relay |

```
block/buzz (source)
├─► sprout-releases (desktop + mobile builds → Artifactory, GitHub, Mobile Releases)
├─► buzz-releases (desktop + mobile builds → Artifactory, GitHub, Mobile Releases)
├─► sprout-oss (relay Docker image → ECR)
│ └─► block-coder-tf-stacks (Helm chart → ArgoCD → staging cluster)
└─── sprout-backend-blox (Blox compute provider for Desktop agent launch)
Expand Down
25 changes: 14 additions & 11 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,15 @@ or mobile GitHub Release.
updates the PR.
2. Review the recorded base and candidate SHA, the complete changelog, and CI.
The required **Desktop Release Candidate** check validates the exact head.
Authorization is either an approval on that exact head or a permitted Default
ruleset bypass at merge time. Any regeneration changes the head and requires
the checks—and, for the review path, approvalto run again.
A trusted repository member, owner, or collaborator must approve that exact
candidate head. Any regeneration or push changes the head, invalidates the
prior approval, and requires both the checks and approval to run again.
3. **Squash merge** the PR. The protected branch must still be exactly the
recorded base; otherwise regenerate the candidate from current `main`.
4. `auto-tag-on-release-pr-merge` verifies the frozen parent, full-tree identity,
required checks, and one of the two authorization paths, then tags the squash
commit as `desktop-v<version>`.
required checks, and trusted approval on the exact candidate head, then tags
the squash commit as `desktop-v<version>`. An admin or ruleset bypass does not
authorize desktop tagging.
5. The tag triggers `release.yml`. It builds and stages Apple Silicon and Intel
macOS, Windows, and Linux artifacts; publishes the versioned release only
after the complete set succeeds; then updates the rolling updater manifest
Expand Down Expand Up @@ -184,10 +185,12 @@ Buildkite pipeline accepts only an exact candidate tag.

For mobile, trigger the private
[Release Mobile pipeline](https://buildkite.com/runway/buzz-mobile-releases) with
an exact RC tag for the platform build being cut. For desktop, use
[Release Desktop](https://buildkite.com/runway/sprout-releases). See the
an exact RC tag for the platform build being cut. For desktop, start
[Release Desktop](https://buildkite.com/runway/sprout-releases) and enter the
exact public source tag as `desktop_ref=desktop-v<version>`; a generic
`v<version>` tag is intentionally rejected. See the
[buzz-releases README](https://github.com/squareup/buzz-releases#cutting-a-release)
for the private pipeline contract.
for the rest of the private pipeline contract.

---

Expand Down Expand Up @@ -269,9 +272,9 @@ actor list.

Do not update the branch manually and do not weaken the ruleset. Run
`just release-desktop <version>` again from current `main`; this regenerates the
candidate, reruns CI, and requires a fresh approval when using the review path.
The post-merge verifier refuses to tag a squash whose parent differs from the
recorded candidate base or whose tree differs from the validated PR head.
candidate, reruns CI, and requires a fresh trusted approval on the new exact
head. The post-merge verifier refuses to tag a squash whose parent differs from
the recorded candidate base or whose tree differs from the validated PR head.

### Local `just release-desktop` fails with "must be on main branch"
Switch to `main` and pull latest before running the release recipe.
Expand Down
52 changes: 50 additions & 2 deletions crates/buzz-agent/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ use crate::mcp::McpRegistry;
use crate::mcp::ResultBudget;

use crate::types::{
AgentError, ContentBlock, HistoryItem, ProviderStop, StopReason, ToolCall, ToolResult,
ToolResultContent, TurnTotalState,
AgentError, ContentBlock, HistoryItem, ProviderStop, SessionUsageBaseline, StopReason,
ToolCall, ToolResult, ToolResultContent, TurnTotalState,
};
use crate::wire::{self, WireSender};

Expand Down Expand Up @@ -150,9 +150,40 @@ pub struct RunCtx<'a> {
/// Reset to `Unseen` at turn start in `run()`. Callers must not derive a
/// total by summing input+output — that is the UI display approximation only.
pub turn_total_state: &'a mut TurnTotalState,
/// Session-cumulative counters as they stood when this turn began. Added to
/// the `turn_*` accumulators above to report a cumulative figure mid-turn;
/// the session's own copy is only advanced once, after the turn returns.
pub usage_baseline: SessionUsageBaseline,
}

impl RunCtx<'_> {
/// Send a session-cumulative `usage_update` reflecting everything observed
/// up to and including the most recent LLM response.
///
/// The figure is the turn-start baseline plus this turn's running
/// accumulators, which is exactly what `session/prompt` will fold into the
/// session once the turn returns — so a mid-turn notification and the
/// end-of-turn one agree, and a turn that never returns has still reported
/// everything but its final in-flight request.
async fn emit_usage_update(&self) {
let base = self.usage_baseline;
let payload = wire::usage_update_payload(
base.input_tokens
.saturating_add(self.turn_input_tokens.unwrap_or(0)),
base.output_tokens
.saturating_add(self.turn_output_tokens.unwrap_or(0)),
base.cached_input_tokens
.saturating_add(self.turn_cached_input_tokens.unwrap_or(0)),
base.total_state.merge_session(*self.turn_total_state),
self.effective_model,
);
wire::send(
self.wire,
wire::goose_session_update(self.session_id, payload),
)
.await;
}

pub async fn run(&mut self, prompt: Vec<ContentBlock>) -> Result<StopReason, AgentError> {
let user_text = prompt_to_text(prompt)?;
if user_text.len() > MAX_PROMPT_BYTES {
Expand Down Expand Up @@ -299,6 +330,23 @@ impl RunCtx<'_> {
// this gate rather than representing absent categories as zero.
if response.input_tokens.is_some() || response.output_tokens.is_some() {
*self.turn_total_state = self.turn_total_state.fold(response.total_tokens);
// Report what the turn has burned SO FAR, before running the
// next round. A turn is many provider round-trips over many
// minutes, and until this point the only report was the one
// `session/prompt` sends after the turn returns — so a turn
// that was cancelled, timed out, or whose process was killed
// reported nothing at all, and its tokens (already billed)
// existed only in this stack frame. Reporting per round bounds
// the loss to the single request in flight.
//
// Emitting more than one `usage_update` per turn is expected by
// the consumer: buzz-acp's UsageTracker advances its committed
// baseline only when the turn's metric is published, so every
// notification within a turn measures from the same frozen
// baseline and the last one seen is the turn's true total.
// goose behaves the same way, which is why the tracker was
// written to tolerate it.
self.emit_usage_update().await;
}

if !response.reasoning.is_empty() {
Expand Down
46 changes: 24 additions & 22 deletions crates/buzz-agent/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,7 @@ async fn run_prompt(app: Arc<App>, id: Value, params: Value, wire_tx: WireSender
effective_model_override,
run_id,
mut steer_rx,
usage_baseline,
) = match acquire_session(&app, &p.session_id).await {
Ok(v) => v,
Err(reason) => {
Expand Down Expand Up @@ -709,6 +710,7 @@ async fn run_prompt(app: Arc<App>, id: Value, params: Value, wire_tx: WireSender
turn_output_tokens: &mut turn_output_tokens,
turn_cached_input_tokens: &mut turn_cached_input_tokens,
turn_total_state: &mut turn_total_state,
usage_baseline,
};
let result = ctx.run(p.prompt).await;
if let Some(s) = app.sessions.lock().await.get_mut(&sid) {
Expand Down Expand Up @@ -766,28 +768,16 @@ async fn run_prompt(app: Arc<App>, id: Value, params: Value, wire_tx: WireSender
if let Some((accumulated_in, accumulated_out, accumulated_cached, accumulated_total)) =
accumulated
{
// Build the usage_update payload. `accumulatedTotalTokens` is only
// included when the cumulative is exactly known — never when Unseen
// (no total ever observed) or Unknown (at least one turn lacked a
// total). A goose consumer that doesn't recognise the field ignores it.
let mut update = serde_json::json!({
"sessionUpdate": "usage_update",
// used: total tokens as a context-usage proxy;
// contextLimit: 0 (buzz-agent has no context limit tracking).
"used": accumulated_in.saturating_add(accumulated_out),
"contextLimit": 0u64,
"accumulatedInputTokens": accumulated_in,
"accumulatedOutputTokens": accumulated_out,
// A subset of accumulatedInputTokens, not an addition to
// it. Extends goose's usage_update shape; a consumer that
// does not know the field ignores it and prices exactly as
// it did before.
"accumulatedCachedInputTokens": accumulated_cached,
"model": effective_model_str,
});
if let crate::types::TurnTotalState::Exact(total) = accumulated_total {
update["accumulatedTotalTokens"] = serde_json::json!(total);
}
// Same builder the run loop uses for its per-round reports, so the
// final notification is shape-identical to the ones that preceded
// it and a consumer taking the high-water mark lands on this one.
let update = wire::usage_update_payload(
accumulated_in,
accumulated_out,
accumulated_cached,
accumulated_total,
effective_model_str,
);
wire::send(&wire_tx, goose_session_update(&sid, update)).await;
}
}
Expand Down Expand Up @@ -821,6 +811,7 @@ async fn acquire_session(
Option<String>,
String,
mpsc::UnboundedReceiver<Vec<ContentBlock>>,
crate::types::SessionUsageBaseline,
),
&'static str,
> {
Expand Down Expand Up @@ -857,6 +848,17 @@ async fn acquire_session(
effective_model,
run_id,
steer_rx,
// Snapshot rather than a handle: the run loop reports cumulative usage
// after every LLM round, and taking the sessions lock on each of those
// would serialise concurrent sessions behind one another's provider
// round-trips. Nothing else advances these counters while this turn
// holds `busy`, so the snapshot cannot go stale under it.
crate::types::SessionUsageBaseline {
input_tokens: s.accumulated_input_tokens,
output_tokens: s.accumulated_output_tokens,
cached_input_tokens: s.accumulated_cached_input_tokens,
total_state: s.accumulated_total_state,
},
))
}

Expand Down
24 changes: 24 additions & 0 deletions crates/buzz-agent/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,30 @@ impl TurnTotalState {
}
}

/// The session-cumulative usage counters as of the START of a turn.
///
/// Copied out of the session under the lock when a turn begins and handed to
/// `RunCtx` by value, so the run loop can emit a cumulative `usage_update`
/// after every LLM round without reaching back into `App.sessions` (which it
/// holds no handle to, and which is locked by the turn's own bookkeeping at
/// both ends).
///
/// This exists so that usage is durable *during* a turn rather than only after
/// it. The counters a turn accrues live in the prompt task's stack frame until
/// the turn returns; a process killed mid-turn takes them with it and the
/// tokens are billed by the provider but recorded nowhere. That is not
/// hypothetical — it silently under-reported a long-horizon benchmark's cost by
/// several-fold, because every phase of a `continue_until_timeout` run is
/// terminated mid-turn by design.
#[derive(Debug, Clone, Copy, Default)]
pub struct SessionUsageBaseline {
pub input_tokens: u64,
pub output_tokens: u64,
/// The cache-served subset of `input_tokens`, not an addition to it.
pub cached_input_tokens: u64,
pub total_state: TurnTotalState,
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum StopReason {
EndTurn,
Expand Down
42 changes: 42 additions & 0 deletions crates/buzz-agent/src/wire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,48 @@ pub fn goose_session_update(sid: &str, update: Value) -> Value {
})
}

/// Build the `usage_update` payload for a `_goose/unstable/session/update`.
///
/// Shared by the two places that report usage — after each LLM round inside a
/// turn, and once more when the turn completes — so the wire shape cannot drift
/// between them. A consumer takes the high-water mark per session, so the
/// mid-turn payloads are supersets of each other and the final one wins; a
/// divergence in field names or units between the two call sites would instead
/// show up as tokens silently vanishing, which is the failure this reporting
/// exists to prevent.
///
/// All counts are SESSION-cumulative, matching goose, so buzz-acp's
/// `UsageTracker` can compute per-turn deltas symmetrically for both agents.
pub fn usage_update_payload(
accumulated_input_tokens: u64,
accumulated_output_tokens: u64,
accumulated_cached_input_tokens: u64,
accumulated_total: crate::types::TurnTotalState,
model: &str,
) -> Value {
let mut update = json!({
"sessionUpdate": "usage_update",
// used: total tokens as a context-usage proxy;
// contextLimit: 0 (buzz-agent has no context limit tracking).
"used": accumulated_input_tokens.saturating_add(accumulated_output_tokens),
"contextLimit": 0u64,
"accumulatedInputTokens": accumulated_input_tokens,
"accumulatedOutputTokens": accumulated_output_tokens,
// A subset of accumulatedInputTokens, not an addition to it. Extends
// goose's usage_update shape; a consumer that does not know the field
// ignores it and prices exactly as it did before.
"accumulatedCachedInputTokens": accumulated_cached_input_tokens,
"model": model,
});
// Only when the cumulative is exactly known — never when Unseen (no total
// ever observed) or Unknown (at least one turn lacked a total). A goose
// consumer that doesn't recognise the field ignores it.
if let Some(total) = accumulated_total.exact_value() {
update["accumulatedTotalTokens"] = json!(total);
}
update
}

/// A `session/update` notification carrying a `update._meta.goose.<key>` field.
/// Used to advertise `activeRunId` (so steer-capable clients can target the
/// in-flight run) and `queuedSteer` (so they can correlate an accepted steer
Expand Down
Loading
Loading