diff --git a/.env.example b/.env.example
index 024175bff0..b9bfcada0e 100644
--- a/.env.example
+++ b/.env.example
@@ -34,6 +34,10 @@ REDIS_URL=redis://localhost:6379
# Max connections in the relay's shared Redis pool (default 16).
# BUZZ_REDIS_POOL_SIZE=16
+# Max connections in each of the relay's Postgres pools — writer and, when
+# READ_DATABASE_URL is set, reader (default 50).
+# BUZZ_DB_POOL_SIZE=50
+
# -----------------------------------------------------------------------------
# Typesense (search)
# -----------------------------------------------------------------------------
@@ -78,6 +82,19 @@ RELAY_URL=ws://localhost:3000
# BUZZ_GIT_PACK_CACHE_MAX_BYTES=5368709120
# BUZZ_GIT_PACK_CACHE_MAX_CONCURRENT_POPULATIONS=2
+# -----------------------------------------------------------------------------
+# S3-Compatible Object Storage (media + Git/CAS)
+# -----------------------------------------------------------------------------
+# The local MinIO container is reachable from host processes at localhost:9000.
+# Path style keeps the bucket in the URL path and is required by this local DNS
+# setup. Use `virtual` only when the provider requires bucket-as-subdomain URLs.
+BUZZ_S3_ENDPOINT=http://localhost:9000
+BUZZ_S3_ACCESS_KEY=buzz_dev
+BUZZ_S3_SECRET_KEY=buzz_dev_secret
+BUZZ_S3_BUCKET=buzz-media
+BUZZ_S3_REGION=us-east-1
+BUZZ_S3_ADDRESSING_STYLE=path
+
# -----------------------------------------------------------------------------
# Media Upload Admission
# -----------------------------------------------------------------------------
diff --git a/.github/workflows/auto-tag-on-release-pr-merge.yml b/.github/workflows/auto-tag-on-release-pr-merge.yml
index db34fddc2c..a69eafb404 100644
--- a/.github/workflows/auto-tag-on-release-pr-merge.yml
+++ b/.github/workflows/auto-tag-on-release-pr-merge.yml
@@ -4,7 +4,7 @@ name: Auto-tag on Release PR Merge
# prefix; the main chart lane also auto-detects a Chart.yaml version bump so
# a chart feature PR can publish its own new version when merged:
#
-# version-bump/ → tag v → release.yml (desktop app)
+# version-bump/ → tag desktop-v → release.yml (desktop app)
# relay-release/ → tag relay-v → docker.yml (relay image)
# chart-release/ → tag chart-v → helm-chart.yml (main helm chart)
# push-chart-release/ → tag push-chart-v → push-gateway-helm-chart.yml
@@ -35,6 +35,11 @@ permissions:
jobs:
auto-tag:
+ permissions:
+ contents: read
+ pull-requests: read
+ checks: read
+ statuses: read
if: >
github.event.pull_request.merged == true &&
github.event.pull_request.head.repo.full_name == github.repository
@@ -57,7 +62,7 @@ jobs:
case "$BRANCH" in
version-bump/*)
VERSION="${BRANCH#version-bump/}"
- TAG_PREFIX="v" ;;
+ TAG_PREFIX="desktop-v" ;;
relay-release/*)
VERSION="${BRANCH#relay-release/}"
TAG_PREFIX="relay-v" ;;
@@ -85,9 +90,34 @@ jobs:
{
echo "enabled=true"
echo "tag=${TAG_PREFIX}${VERSION}"
+ if [[ "$TAG_PREFIX" == desktop-v ]]; then
+ echo "target_sha=${{ github.event.pull_request.head.sha }}"
+ echo "desktop=true"
+ else
+ echo "target_sha=$GITHUB_SHA"
+ echo "desktop=false"
+ fi
} >> "$GITHUB_OUTPUT"
echo "Tagging ${TAG_PREFIX}${VERSION}"
+
+ - name: Verify immutable reviewed desktop candidate
+ if: steps.release.outputs.desktop == 'true'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ VERSION: ${{ steps.release.outputs.tag }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
+ PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
+ PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
+ PR_PUSHER: ${{ github.event.pull_request.head.user.login }}
+ MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
+ run: |
+ VERSION="${VERSION#desktop-v}"
+ export VERSION
+ scripts/verify-desktop-release-merge.sh
+
- name: Create release tagger token
if: steps.release.outputs.enabled == 'true'
id: release-tagger
@@ -102,21 +132,22 @@ jobs:
env:
GH_TOKEN: ${{ steps.release-tagger.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
+ TARGET_SHA: ${{ steps.release.outputs.target_sha }}
run: |
set -euo pipefail
# Check gh's exit status, not its output. A missing ref returns a 404
# JSON body on stdout, which must not be mistaken for an existing tag.
if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" --silent 2>/dev/null; then
EXISTING_SHA="$(gh api "repos/$GITHUB_REPOSITORY/commits/$TAG" --jq .sha)"
- if [ "$EXISTING_SHA" = "$GITHUB_SHA" ]; then
- echo "Tag $TAG already exists at $GITHUB_SHA — skipping tag creation"
+ if [ "$EXISTING_SHA" = "$TARGET_SHA" ]; then
+ echo "Tag $TAG already exists at $TARGET_SHA — skipping tag creation"
exit 0
else
- echo "::error::Tag $TAG already exists at $EXISTING_SHA (expected $GITHUB_SHA)"
+ echo "::error::Tag $TAG already exists at $EXISTING_SHA (expected $TARGET_SHA)"
exit 1
fi
fi
gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \
-f ref="refs/tags/$TAG" \
- -f sha="$GITHUB_SHA" \
+ -f sha="$TARGET_SHA" \
--silent
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index dd18179ee4..30eeb2d7a9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -48,15 +48,21 @@ jobs:
- 'scripts/run-tests.sh'
- 'justfile'
desktop:
+ - 'scripts/check-file-sizes-core.mjs'
+ - 'scripts/check-file-sizes-core.test.mjs'
- 'desktop/**'
- '!desktop/src-tauri/**'
- 'pnpm-lock.yaml'
desktop-rust:
- 'desktop/src-tauri/**'
web:
+ - 'scripts/check-file-sizes-core.mjs'
+ - 'scripts/check-file-sizes-core.test.mjs'
- 'web/**'
- 'pnpm-lock.yaml'
mobile:
+ - 'scripts/check-file-sizes-core.mjs'
+ - 'scripts/check-file-sizes-core.test.mjs'
- 'mobile/**'
- 'scripts/mobile-release.sh'
- 'scripts/mobile-worktree-overrides.sh'
@@ -70,12 +76,16 @@ jobs:
- '.github/workflows/ci.yml'
- name: Release workflow source contract
run: scripts/test-release-ref-contract.sh
+ - name: Desktop release candidate contract
+ run: scripts/test-desktop-release-candidate.sh
- name: Mobile release contract
run: |
scripts/test-mobile-release-contract.sh
scripts/test-mobile-release-candidate-publisher.sh
- name: Mobile worktree identity contract
run: scripts/test-mobile-worktree-overrides.sh
+ - name: File size ratchet unit tests
+ run: node --test scripts/check-file-sizes-core.test.mjs
rust-lint:
name: Rust Lint
@@ -130,6 +140,8 @@ jobs:
contents: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ with:
+ fetch-depth: 2
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
@@ -213,7 +225,7 @@ jobs:
desktop-smoke-e2e:
name: Desktop Smoke E2E (${{ matrix.shard }})
runs-on: ubuntu-latest
- timeout-minutes: 20
+ timeout-minutes: 30
needs: [changes]
if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true'
strategy:
@@ -727,7 +739,7 @@ jobs:
./scripts/start-relay-for-tests.sh --no-build
- name: Relay E2E tests
run: |
- cargo test -p buzz-test-client --test e2e_persona --test e2e_nostr_interop -- --ignored --nocapture
+ cargo test -p buzz-test-client --test e2e_persona --test e2e_team_catalog --test e2e_nostr_interop -- --ignored --nocapture
cargo test -p buzz-test-client --test e2e_relay invite -- --ignored --nocapture
cargo test -p buzz-test-client --test e2e_relay nip43_membership_snapshots_are_rejected -- --ignored --nocapture
env:
@@ -751,6 +763,8 @@ jobs:
contents: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ with:
+ fetch-depth: 2
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- name: Get pnpm store directory
id: pnpm-cache
@@ -784,6 +798,8 @@ jobs:
contents: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ with:
+ fetch-depth: 2
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- name: Compute Hermit cache key
id: hermit-bin-hash
@@ -822,6 +838,8 @@ jobs:
with:
path: ~/.pub-cache
key: pub-${{ runner.os }}-${{ hashFiles('mobile/pubspec.lock') }}
+ - name: File size ratchet
+ run: node mobile/scripts/check-file-sizes.mjs
- name: Format check
run: cd mobile && dart format --output=none --set-exit-if-changed .
- name: Analyze
@@ -901,6 +919,7 @@ jobs:
-p buzz-relay \
-p buzz-acp \
-p buzz-agent \
+ -p buzz-a2a-acp \
-p buzz-dev-mcp \
-p git-credential-nostr \
-p git-sign-nostr
@@ -939,7 +958,7 @@ jobs:
shell: bash
run: |
mkdir -p desktop/src-tauri/binaries
- for bin in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz; do
+ for bin in buzz-acp buzz-agent buzz-a2a-acp buzz-dev-mcp git-credential-nostr buzz; do
touch "desktop/src-tauri/binaries/${bin}-${TARGET}.exe"
done
- name: Clippy (workspace)
@@ -1013,6 +1032,7 @@ jobs:
mkdir -p desktop/src-tauri/binaries
touch "desktop/src-tauri/binaries/buzz-acp-$TARGET"
touch "desktop/src-tauri/binaries/buzz-agent-$TARGET"
+ touch "desktop/src-tauri/binaries/buzz-a2a-acp-$TARGET"
touch "desktop/src-tauri/binaries/buzz-dev-mcp-$TARGET"
touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET"
touch "desktop/src-tauri/binaries/buzz-$TARGET"
diff --git a/.github/workflows/linux-canary.yml b/.github/workflows/linux-canary.yml
index 18d476e400..60324f190c 100644
--- a/.github/workflows/linux-canary.yml
+++ b/.github/workflows/linux-canary.yml
@@ -21,7 +21,7 @@ jobs:
name: Build Linux canary
if: github.repository == 'block/buzz'
runs-on: ubuntu-latest
- container: ubuntu:22.04@sha256:0e0a0fc6d18feda9db1590da249ac93e8d5abfea8f4c3c0c849ce512b5ef8982
+ container: ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90
timeout-minutes: 60
permissions:
contents: read
@@ -166,7 +166,7 @@ jobs:
- name: Build sidecars
run: |
- cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
+ cargo build --release -p buzz-acp -p buzz-agent -p buzz-a2a-acp -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
./scripts/bundle-sidecars.sh
- name: Build Linux Tauri app
diff --git a/.github/workflows/prepare-desktop-release.yml b/.github/workflows/prepare-desktop-release.yml
new file mode 100644
index 0000000000..7cc480b93b
--- /dev/null
+++ b/.github/workflows/prepare-desktop-release.yml
@@ -0,0 +1,38 @@
+name: Prepare Desktop Release
+
+on:
+ workflow_dispatch:
+ inputs:
+ version:
+ description: Semver to prepare (for example 0.5.1)
+ required: true
+
+env:
+ RELEASE_AUTOMATION_NAME: Carl
+ RELEASE_AUTOMATION_EMAIL: c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz
+
+jobs:
+ prepare:
+ if: github.repository == 'block/buzz'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - name: Create short-lived release preparer token
+ id: preparer
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
+ with:
+ client-id: ${{ vars.BUZZ_RELEASE_TAGGER_CLIENT_ID }}
+ private-key: ${{ secrets.BUZZ_RELEASE_TAGGER_PRIVATE_KEY }}
+ permission-contents: write
+ permission-pull-requests: write
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ with:
+ fetch-depth: 0
+ token: ${{ steps.preparer.outputs.token }}
+ - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
+ - name: Prepare immutable candidate and open or update PR
+ env:
+ GH_TOKEN: ${{ steps.preparer.outputs.token }}
+ VERSION: ${{ inputs.version }}
+ run: scripts/prepare-desktop-release.sh "$VERSION"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index c613924e57..45bd12b942 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,14 +1,13 @@
name: Release
+concurrency:
+ group: desktop-release-${{ github.ref }}
+ cancel-in-progress: false
+
on:
push:
tags:
- - 'v[0-9]*'
- workflow_dispatch:
- inputs:
- version:
- description: "Semver version matching the v-prefixed dispatch tag"
- required: true
+ - 'desktop-v[0-9]*'
jobs:
# Shared setup: verify the immutable release tag, determine the version, and
@@ -19,23 +18,14 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
- contents: write
+ contents: read
outputs:
version: ${{ steps.version.outputs.version }}
source_sha: ${{ steps.source.outputs.source_sha }}
steps:
- name: Determine version
id: version
- env:
- EVENT_NAME: ${{ github.event_name }}
- INPUT_VERSION: ${{ inputs.version }}
- run: |
- if [[ "$EVENT_NAME" == "push" ]]; then
- VERSION="${GITHUB_REF_NAME#v}"
- else
- VERSION="$INPUT_VERSION"
- fi
- echo "version=$VERSION" >> "$GITHUB_OUTPUT"
+ run: echo "version=${GITHUB_REF_NAME#desktop-v}" >> "$GITHUB_OUTPUT"
- name: Validate version
env:
@@ -56,42 +46,9 @@ jobs:
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
- scripts/verify-release-ref.sh v "$VERSION"
+ scripts/verify-release-ref.sh desktop-v "$VERSION"
echo "source_sha=$(git rev-parse 'HEAD^{commit}')" >> "$GITHUB_OUTPUT"
- - name: Create versioned GitHub release
- env:
- VERSION: ${{ steps.version.outputs.version }}
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- RELEASE_SHA=$(git rev-parse HEAD)
- NOTES=""
- if [[ -f CHANGELOG.md ]]; then
- NOTES=$(awk "/^## v${VERSION}\$/{found=1; next} found && /^## v/{exit} found && !/^\$/" CHANGELOG.md)
- fi
- if [[ -z "$NOTES" ]]; then
- NOTES="Buzz Desktop v${VERSION}"
- fi
- PRERELEASE_FLAGS=()
- if [[ "$VERSION" =~ -(test|alpha|beta|rc)([.-]|$) ]]; then
- PRERELEASE_FLAGS=(--prerelease --latest=false)
- fi
- gh release create "v${VERSION}" \
- --target "$RELEASE_SHA" \
- --title "Buzz Desktop v${VERSION}" \
- --notes "$NOTES" \
- "${PRERELEASE_FLAGS[@]}"
-
- - name: Create rolling auto-update release
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- gh release create buzz-desktop-latest \
- --prerelease \
- --title "Buzz Desktop Auto-Update" \
- --notes "Rolling release for the Tauri auto-updater. Do not download manually — use the versioned release instead." \
- 2>/dev/null || true
-
release:
name: Release
if: github.repository == 'block/buzz'
@@ -99,7 +56,7 @@ jobs:
needs: setup
timeout-minutes: 60
permissions:
- contents: write
+ contents: read
id-token: write # required by block/apple-codesign-action for OIDC
outputs:
archive_name: ${{ steps.artifacts.outputs.archive_name }}
@@ -114,7 +71,7 @@ jobs:
persist-credentials: false
- name: Verify tag-bound release source
- run: scripts/verify-release-ref.sh v "$VERSION"
+ run: scripts/verify-release-ref.sh desktop-v "$VERSION"
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
@@ -134,7 +91,7 @@ jobs:
- name: Build sidecars
run: |
- cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
+ cargo build --release -p buzz-acp -p buzz-agent -p buzz-a2a-acp -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
./scripts/bundle-sidecars.sh
# Mesh rev derived from Cargo.lock (no lockstep edit on dep bump); cache key tracks it.
@@ -272,13 +229,19 @@ jobs:
fi
echo "dmg=$DMG" >> "$GITHUB_OUTPUT"
- # Find the updater .tar.gz and .sig
+ # Find the updater .tar.gz and .sig. Give each architecture a unique
+ # release basename before artifacts are merged by the final writer.
ARCHIVE=$(find "$BUNDLE_DIR/macos" -name '*.tar.gz' ! -name '*.sig' -type f | head -1)
SIG="${ARCHIVE}.sig"
if [[ -z "$ARCHIVE" || ! -f "$SIG" ]]; then
echo "::error::Updater archive or signature not found in $BUNDLE_DIR/macos"
exit 1
fi
+ RENAMED="$(dirname "$ARCHIVE")/Buzz_${VERSION}_aarch64.app.tar.gz"
+ mv "$ARCHIVE" "$RENAMED"
+ mv "$SIG" "${RENAMED}.sig"
+ ARCHIVE="$RENAMED"
+ SIG="${RENAMED}.sig"
echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT"
echo "archive_name=$(basename "$ARCHIVE")" >> "$GITHUB_OUTPUT"
echo "sig=$SIG" >> "$GITHUB_OUTPUT"
@@ -289,23 +252,15 @@ jobs:
env:
SIG_PATH: ${{ steps.artifacts.outputs.sig }}
- - name: Upload arm64 DMG to versioned GitHub release
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- DMG_PATH: ${{ steps.artifacts.outputs.dmg }}
- run: gh release upload "v${VERSION}" "$DMG_PATH" --clobber
-
- - name: Upload updater archive to rolling release
- if: github.ref == format('refs/tags/v{0}', needs.setup.outputs.version)
- run: |
- gh release upload buzz-desktop-latest \
- "$ARCHIVE_PATH" \
- "$SIG_PATH" \
- --clobber
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- ARCHIVE_PATH: ${{ steps.artifacts.outputs.archive }}
- SIG_PATH: ${{ steps.artifacts.outputs.sig }}
+ - name: Stage Apple Silicon release artifacts
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: desktop-release-macos-arm64
+ if-no-files-found: error
+ path: |
+ ${{ steps.artifacts.outputs.dmg }}
+ ${{ steps.artifacts.outputs.archive }}
+ ${{ steps.artifacts.outputs.sig }}
release-macos-x64:
name: Release macOS (Intel)
@@ -314,7 +269,7 @@ jobs:
needs: setup
timeout-minutes: 60
permissions:
- contents: write
+ contents: read
id-token: write # required by block/apple-codesign-action for OIDC
outputs:
archive_name: ${{ steps.artifacts.outputs.archive_name }}
@@ -330,7 +285,7 @@ jobs:
persist-credentials: false
- name: Verify tag-bound release source
- run: scripts/verify-release-ref.sh v "$VERSION"
+ run: scripts/verify-release-ref.sh desktop-v "$VERSION"
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
@@ -353,7 +308,7 @@ jobs:
- name: Build sidecars
run: |
- cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
+ cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-a2a-acp -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
./scripts/bundle-sidecars.sh "$TARGET"
- name: Build unsigned Tauri app
@@ -443,6 +398,11 @@ jobs:
echo "::error::Updater archive or signature not found in $BUNDLE_DIR/macos"
exit 1
fi
+ RENAMED="$(dirname "$ARCHIVE")/Buzz_${VERSION}_x64.app.tar.gz"
+ mv "$ARCHIVE" "$RENAMED"
+ mv "$SIG" "${RENAMED}.sig"
+ ARCHIVE="$RENAMED"
+ SIG="${RENAMED}.sig"
echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT"
echo "archive_name=$(basename "$ARCHIVE")" >> "$GITHUB_OUTPUT"
echo "sig=$SIG" >> "$GITHUB_OUTPUT"
@@ -453,34 +413,26 @@ jobs:
env:
SIG_PATH: ${{ steps.artifacts.outputs.sig }}
- - name: Upload Intel DMG to versioned GitHub release
- run: gh release upload "v${VERSION}" "$DMG_PATH" --clobber
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- DMG_PATH: ${{ steps.unsigned.outputs.dmg }}
-
- - name: Upload updater archive to rolling release
- if: github.ref == format('refs/tags/v{0}', needs.setup.outputs.version)
- run: |
- gh release upload buzz-desktop-latest \
- "$ARCHIVE_PATH" \
- "$SIG_PATH" \
- --clobber
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- ARCHIVE_PATH: ${{ steps.artifacts.outputs.archive }}
- SIG_PATH: ${{ steps.artifacts.outputs.sig }}
+ - name: Stage Intel macOS release artifacts
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: desktop-release-macos-x64
+ if-no-files-found: error
+ path: |
+ ${{ steps.unsigned.outputs.dmg }}
+ ${{ steps.artifacts.outputs.archive }}
+ ${{ steps.artifacts.outputs.sig }}
release-linux:
name: Release Linux
if: github.repository == 'block/buzz'
runs-on: ubuntu-latest
# Digest-pinned like the SHA-pinned actions below; Renovate keeps it fresh.
- container: ubuntu:22.04@sha256:0e0a0fc6d18feda9db1590da249ac93e8d5abfea8f4c3c0c849ce512b5ef8982
+ container: ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90
needs: setup
timeout-minutes: 60
permissions:
- contents: write
+ contents: read
env:
# AppImage tools (linuxdeploy, appimagetool) are themselves AppImages.
# Containers lack FUSE, so we must use the extract-and-run fallback.
@@ -498,7 +450,7 @@ jobs:
env:
DEBIAN_FRONTEND: noninteractive
run: |
- # Must run first: bare ubuntu:22.04 ships without curl, wget, git, or
+ # Must run first: bare ubuntu:24.04 ships without curl, wget, git, or
# ca-certificates. activate-hermit bootstraps via curl+HTTPS (needs
# both), and actions/checkout falls back to a REST tarball without git.
# Running as root — no sudo needed.
@@ -555,7 +507,7 @@ jobs:
- name: Verify tag-bound release source
env:
VERSION: ${{ needs.setup.outputs.version }}
- run: scripts/verify-release-ref.sh v "$VERSION"
+ run: scripts/verify-release-ref.sh desktop-v "$VERSION"
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
@@ -611,7 +563,7 @@ jobs:
- name: Build sidecars
run: |
- cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
+ cargo build --release -p buzz-acp -p buzz-agent -p buzz-a2a-acp -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
./scripts/bundle-sidecars.sh
- name: Generate release config
@@ -689,29 +641,16 @@ jobs:
SIG_PATH: ${{ steps.linux-artifacts.outputs.sig }}
# NOTE: .deb is NOT auto-updatable (Tauri updater constraint — only AppImage supports it on Linux)
- - name: Upload Linux artifacts to versioned GitHub release
- env:
- VERSION: ${{ needs.setup.outputs.version }}
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- DEB_PATH: ${{ steps.linux-artifacts.outputs.deb }}
- APPIMAGE_PATH: ${{ steps.linux-artifacts.outputs.appimage }}
- run: |
- gh release upload "v$VERSION" \
- "$DEB_PATH" \
- "$APPIMAGE_PATH" \
- --clobber
-
- - name: Upload updater archive to rolling release
- if: github.ref == format('refs/tags/v{0}', needs.setup.outputs.version)
- run: |
- gh release upload buzz-desktop-latest \
- "$ARCHIVE_PATH" \
- "$SIG_PATH" \
- --clobber
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- ARCHIVE_PATH: ${{ steps.linux-artifacts.outputs.archive }}
- SIG_PATH: ${{ steps.linux-artifacts.outputs.sig }}
+ - name: Stage Linux release artifacts
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: desktop-release-linux-x64
+ if-no-files-found: error
+ path: |
+ ${{ steps.linux-artifacts.outputs.deb }}
+ ${{ steps.linux-artifacts.outputs.appimage }}
+ ${{ steps.linux-artifacts.outputs.archive }}
+ ${{ steps.linux-artifacts.outputs.sig }}
release-windows:
name: Release Windows
@@ -719,7 +658,7 @@ jobs:
needs: setup
timeout-minutes: 60
permissions:
- contents: write
+ contents: read
outputs:
archive_name: ${{ steps.artifacts.outputs.archive_name }}
sig: ${{ steps.read-sig.outputs.sig }}
@@ -735,7 +674,7 @@ jobs:
- name: Verify tag-bound release source
shell: bash
- run: scripts/verify-release-ref.sh v "$VERSION"
+ run: scripts/verify-release-ref.sh desktop-v "$VERSION"
- uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0
with:
@@ -745,7 +684,7 @@ jobs:
with:
node-version: 24.14.1
# Disable dependency caching: a writable cache in this release workflow
- # (contents: write, feeds a signed installer) is a poisoning vector. pnpm
+ # (contents: read, feeds a signed installer) is a poisoning vector. pnpm
# install runs uncached below.
package-manager-cache: false
@@ -773,7 +712,7 @@ jobs:
- name: Build sidecars
shell: bash
run: |
- cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
+ cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-a2a-acp -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
./scripts/bundle-sidecars.sh "$TARGET"
- name: Build Windows NSIS installer (unsigned)
@@ -827,25 +766,14 @@ jobs:
env:
SIG_PATH: ${{ steps.artifacts.outputs.sig }}
- - name: Upload Windows installer to versioned GitHub release
- shell: bash
- run: gh release upload "v${VERSION}" "$EXE_PATH" --clobber
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- EXE_PATH: ${{ steps.artifacts.outputs.exe }}
-
- - name: Upload updater archive to rolling release
- if: github.ref == format('refs/tags/v{0}', needs.setup.outputs.version)
- shell: bash
- run: |
- gh release upload buzz-desktop-latest \
- "$ARCHIVE_PATH" \
- "$SIG_PATH" \
- --clobber
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- ARCHIVE_PATH: ${{ steps.artifacts.outputs.archive }}
- SIG_PATH: ${{ steps.artifacts.outputs.sig }}
+ - name: Stage Windows release artifacts
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: desktop-release-windows-x64
+ if-no-files-found: error
+ path: |
+ ${{ steps.artifacts.outputs.exe }}
+ ${{ steps.artifacts.outputs.sig }}
assemble-manifest:
name: Assemble multi-platform latest.json
@@ -853,7 +781,11 @@ jobs:
if: |
always() &&
needs.setup.result == 'success' &&
- github.ref == format('refs/tags/v{0}', needs.setup.outputs.version)
+ needs.release.result == 'success' &&
+ needs.release-macos-x64.result == 'success' &&
+ needs.release-linux.result == 'success' &&
+ needs.release-windows.result == 'success' &&
+ github.ref == format('refs/tags/desktop-v{0}', needs.setup.outputs.version)
runs-on: ubuntu-latest
needs: [setup, release, release-macos-x64, release-linux, release-windows]
timeout-minutes: 10
@@ -870,7 +802,26 @@ jobs:
persist-credentials: false
- name: Verify tag-bound release source
- run: scripts/verify-release-ref.sh v "$VERSION"
+ run: scripts/verify-release-ref.sh desktop-v "$VERSION"
+
+ - name: Download staged release artifacts
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ pattern: desktop-release-*
+ path: staged-by-platform
+
+ - name: Flatten staged artifacts without basename collisions
+ run: |
+ set -euo pipefail
+ mkdir staged
+ while IFS= read -r -d '' file; do
+ name="$(basename "$file")"
+ [[ ! -e "staged/$name" ]] || {
+ echo "::error::release artifact basename collision: $name"
+ exit 1
+ }
+ cp "$file" "staged/$name"
+ done < <(find staged-by-platform -type f -print0)
- name: Write signature files
env:
@@ -899,7 +850,7 @@ jobs:
write_sig "$RESULT_LINUX" linux-x86_64 "$SIG_LINUX"
write_sig "$RESULT_WIN" windows-x86_64 "$SIG_WIN"
- - name: Verify archive URLs are accessible
+ - name: Verify draft release has every updater archive
env:
RESULT_ARM64: ${{ needs.release.result }}
RESULT_X64: ${{ needs.release-macos-x64.result }}
@@ -911,39 +862,19 @@ jobs:
ARCHIVE_WIN: ${{ needs.release-windows.outputs.archive_name }}
run: |
set -euo pipefail
- BASE="https://github.com/block/buzz/releases/download/buzz-desktop-latest"
- ARCHIVES=()
-
- add_archive() {
- local result="$1" platform="$2" archive="$3"
- if [[ "$result" == "success" ]]; then
- [[ -n "$archive" ]] || { echo "::error::Missing archive name for successful platform: $platform"; exit 1; }
- ARCHIVES+=("$archive")
- fi
- }
-
- add_archive "$RESULT_ARM64" darwin-aarch64 "$ARCHIVE_ARM64"
- add_archive "$RESULT_X64" darwin-x86_64 "$ARCHIVE_X64"
- add_archive "$RESULT_LINUX" linux-x86_64 "$ARCHIVE_LINUX"
- add_archive "$RESULT_WIN" windows-x86_64 "$ARCHIVE_WIN"
-
- for name in "${ARCHIVES[@]}"; do
- echo "Checking $BASE/$name ..."
- success=false
- for attempt in 1 2 3; do
- if curl -fsI "$BASE/$name" > /dev/null 2>&1; then
- success=true
- break
- fi
- echo "Attempt $attempt failed for $name, retrying in 10s..."
- sleep 10
- done
- if [ "$success" != "true" ]; then
- echo "::error::Archive not accessible after 3 attempts: $BASE/$name"
- exit 1
+ assets=$(find staged -type f -exec basename {} \;)
+ for spec in \
+ "$RESULT_ARM64:$ARCHIVE_ARM64" \
+ "$RESULT_X64:$ARCHIVE_X64" \
+ "$RESULT_LINUX:$ARCHIVE_LINUX" \
+ "$RESULT_WIN:$ARCHIVE_WIN"; do
+ result="${spec%%:*}"
+ archive="${spec#*:}"
+ if [[ "$result" == success ]]; then
+ [[ -n "$archive" ]] || { echo "::error::successful platform has no archive"; exit 1; }
+ grep -Fxq "$archive" <<<"$assets" || { echo "::error::draft release missing $archive"; exit 1; }
fi
done
- echo "All archive URLs verified."
- name: Generate unified latest.json
env:
@@ -957,7 +888,7 @@ jobs:
ARCHIVE_WIN: ${{ needs.release-windows.outputs.archive_name }}
run: |
set -euo pipefail
- BASE="https://github.com/block/buzz/releases/download/buzz-desktop-latest"
+ BASE="https://github.com/block/buzz/releases/download/desktop-v${VERSION}"
TRIPLES=()
add_triple() {
@@ -977,6 +908,45 @@ jobs:
bash desktop/scripts/generate-oss-latest-json.sh "$VERSION" "${TRIPLES[@]}" > latest.json
cat latest.json
- - name: Upload latest.json to rolling release
+ - name: Create or verify versioned draft
run: |
- gh release upload buzz-desktop-latest latest.json --clobber
+ set -euo pipefail
+ NOTES_FILE="${RUNNER_TEMP}/release-notes.md"
+ awk "/^## v${VERSION}\$/{found=1; next} found && /^## v/{exit} found" CHANGELOG.md > "$NOTES_FILE"
+ [[ -s "$NOTES_FILE" ]] || { echo "::error::missing non-empty changelog block for v${VERSION}"; exit 1; }
+ PRERELEASE_FLAGS=()
+ if [[ "$VERSION" == *-* ]]; then
+ PRERELEASE_FLAGS=(--prerelease --latest=false)
+ fi
+ if gh release view "desktop-v${VERSION}" >/dev/null 2>&1; then
+ EXISTING_SHA=$(gh release view "desktop-v${VERSION}" --json targetCommitish --jq .targetCommitish)
+ IS_DRAFT=$(gh release view "desktop-v${VERSION}" --json isDraft --jq .isDraft)
+ [[ "$EXISTING_SHA" == "${{ needs.setup.outputs.source_sha }}" ]] || {
+ echo "::error::existing release targets $EXISTING_SHA, not the immutable source"; exit 1;
+ }
+ if [[ "$IS_DRAFT" != true ]]; then
+ echo "already_published=true" >> "$GITHUB_ENV"
+ fi
+ else
+ gh release create "desktop-v${VERSION}" \
+ --draft \
+ --target "${{ needs.setup.outputs.source_sha }}" \
+ --title "Buzz Desktop v${VERSION}" \
+ --notes-file "$NOTES_FILE" \
+ "${PRERELEASE_FLAGS[@]}"
+ fi
+
+ - name: Upload complete artifact set to versioned draft
+ if: env.already_published != 'true'
+ run: |
+ mapfile -t files < <(find staged -type f -print)
+ [[ "${#files[@]}" -gt 0 ]] || { echo "::error::no staged release artifacts"; exit 1; }
+ gh release upload "desktop-v${VERSION}" "${files[@]}" --clobber
+
+ - name: Publish complete versioned release
+ if: env.already_published != 'true'
+ run: gh release edit "desktop-v${VERSION}" --draft=false
+
+ - name: Upload latest.json to rolling release last
+ if: ${{ !contains(needs.setup.outputs.version, '-') }}
+ run: gh release upload buzz-desktop-latest latest.json --clobber
diff --git a/.github/workflows/signed-macos-canary.yml b/.github/workflows/signed-macos-canary.yml
index fb0656028a..1a7bff0072 100644
--- a/.github/workflows/signed-macos-canary.yml
+++ b/.github/workflows/signed-macos-canary.yml
@@ -93,7 +93,7 @@ jobs:
- name: Build sidecars
run: |
- cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
+ cargo build --release -p buzz-acp -p buzz-agent -p buzz-a2a-acp -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
./scripts/bundle-sidecars.sh
# Mesh rev derived from Cargo.lock (no lockstep edit on dep bump); cache key tracks it.
diff --git a/.github/workflows/windows-canary.yml b/.github/workflows/windows-canary.yml
index 29f74fa0f6..192da231d5 100644
--- a/.github/workflows/windows-canary.yml
+++ b/.github/workflows/windows-canary.yml
@@ -122,7 +122,7 @@ jobs:
- name: Build sidecars
shell: bash
run: |
- cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
+ cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-a2a-acp -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli
./scripts/bundle-sidecars.sh "$TARGET"
- name: Build Windows NSIS installer (unsigned)
diff --git a/.gitignore b/.gitignore
index 65ddcaf1c4..f26e74136c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,10 @@
/dist/
/admin-web/dist/
+# Python cache
+__pycache__/
+*.pyc
+
# lefthook-generated hook scripts (machine-specific)
.hooks/
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 90cbbac0cf..5c8e263a2a 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -447,7 +447,7 @@ The subscriber uses a **dedicated** `redis::aio::PubSub` connection — not from
**Reconnection:** exponential backoff 1s → 30s (`backoff_secs * 2`). Backoff resets to 1s only after a clean stream end, not on each reconnect attempt.
-**Presence:** `SET buzz:presence:{pubkey_hex} {status} EX 90` — 90-second TTL (3× the 30-second heartbeat interval). Single missed heartbeat does not cause presence flap.
+**Presence:** `SET buzz:presence:{pubkey_hex} {status} EX 180` — 180-second TTL (3× the 60-second heartbeat interval). Single missed heartbeat does not cause presence flap.
**Typing indicators:**
```
@@ -797,7 +797,7 @@ Docker Compose provides the full local development stack. All services include h
| Pattern | Type | TTL | Purpose |
|---------|------|-----|---------|
| `buzz:channel:{uuid}` | Pub/Sub channel | — | Event fan-out (single-community form; shared multi-community Redis must use `buzz:{community}:channel:{uuid}` or equivalent) |
-| `buzz:presence:{pubkey_hex}` | String | 90s | Online/away status (single-community form; shared multi-community Redis must scope by community) |
+| `buzz:presence:{pubkey_hex}` | String | 180s | Online/away status (single-community form; shared multi-community Redis must scope by community) |
| `buzz:typing:{channel_uuid}` | Sorted Set | 60s | Active typers (5s window; shared multi-community Redis must scope by community) |
### Full-Text Search (Postgres FTS)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1223936504..d83087fc26 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,92 @@
# Changelog
+## v0.5.2
+
+- feat(cli): mirror Desktop mention delivery ([#3330](https://github.com/block/buzz/pull/3330)) ([`7adc46268`](https://github.com/block/buzz/commit/7adc46268d5e93f0b1d4dc8e700af22815dcac1b))
+- fix(desktop): deduplicate relay outage notification ([#3579](https://github.com/block/buzz/pull/3579)) ([`66e705492`](https://github.com/block/buzz/commit/66e7054928cc29395f828467c3e8c81b7408dd29))
+- fix(desktop): reconcile thread arrivals at bottom ([#3585](https://github.com/block/buzz/pull/3585)) ([`b42a8d447`](https://github.com/block/buzz/commit/b42a8d447e3a2b85b2313dc4fdd123731fd8bba3))
+- Improve emoji autocomplete matching ([#3571](https://github.com/block/buzz/pull/3571)) ([`259de6afb`](https://github.com/block/buzz/commit/259de6afbe0cc0d106e57ebdb2323064990e4122))
+- Fix shared agent avatar import profiles ([#3578](https://github.com/block/buzz/pull/3578)) ([`324bd6b46`](https://github.com/block/buzz/commit/324bd6b464de5751e12abbd155376046ce3d2afc))
+- Fix inline raster avatars in agent catalog ([#3581](https://github.com/block/buzz/pull/3581)) ([`7e9b77f72`](https://github.com/block/buzz/commit/7e9b77f72d82e019a99f074f1c9829be30c57ae1))
+- feat(agent): make Gemini and MLflow-route models usable through databricks_v2 ([#3569](https://github.com/block/buzz/pull/3569)) ([`4a1ebf25c`](https://github.com/block/buzz/commit/4a1ebf25c782fc6a68f0a69e6f866f793a259a1f))
+
+
+## v0.5.1
+
+- perf(desktop): move observer-feed archive and decrypt commands off main thread ([#3415](https://github.com/block/buzz/pull/3415)) ([`294c8c821`](https://github.com/block/buzz/commit/294c8c821de51442a8c384c0bdb66b1a10224ca0))
+- fix(desktop): preserve shared agent fidelity ([#3553](https://github.com/block/buzz/pull/3553)) ([`f7a3988ba`](https://github.com/block/buzz/commit/f7a3988ba13b590d9a55a7e8413fc3fb5ffbef18))
+- feat(agent): route Claude/GPT model families to their native gateway wire ([#3538](https://github.com/block/buzz/pull/3538)) ([`6438dedf8`](https://github.com/block/buzz/commit/6438dedf83a9dbe1853e484326911bf6c7f1618c))
+- Refine community invite limits ([#3529](https://github.com/block/buzz/pull/3529)) ([`24d90d128`](https://github.com/block/buzz/commit/24d90d1280a9325c6cbcf8eea30ac54db5afd2cb))
+- feat(agent): fix Anthropic prompt caching with Databricks (+ MCP proxy/TLS passthrough) ([#3463](https://github.com/block/buzz/pull/3463)) ([`c405ad1d4`](https://github.com/block/buzz/commit/c405ad1d4b1da061c11b3d26761252d41dcc62d3))
+- feat: add explicit entry for claude-opus-5 in model config ([#2831](https://github.com/block/buzz/pull/2831)) ([`90e058ebf`](https://github.com/block/buzz/commit/90e058ebf68137e048a409aec6616519379ff726))
+- fix(desktop): clear stale thread new-message pill ([#3411](https://github.com/block/buzz/pull/3411)) ([`55a3ed7b9`](https://github.com/block/buzz/commit/55a3ed7b9217cee5b23e0a5441947dc929b2a38c))
+- fix(ci): ratchet file sizes against the base tree ([#3352](https://github.com/block/buzz/pull/3352)) ([`9227bdf58`](https://github.com/block/buzz/commit/9227bdf58ad6664ae3c1078888f2181ec19c4da4))
+- feat(desktop): apply WebKit rendering workarounds at startup on Linux ([#3271](https://github.com/block/buzz/pull/3271)) ([`3ece4461d`](https://github.com/block/buzz/commit/3ece4461df8a7b9663a8e68327483b8377d4086d))
+- fix(desktop): stabilize flaky DM expansion E2E ordering assertions ([#2004](https://github.com/block/buzz/pull/2004)) ([`913d564ce`](https://github.com/block/buzz/commit/913d564ce0f35924291bf3eeab6508517a6d8d1f))
+- fix(desktop): paint community rail full height ([#3382](https://github.com/block/buzz/pull/3382)) ([`1d3b810ad`](https://github.com/block/buzz/commit/1d3b810ad70d6325718ed91e723f32c4a376d5e1))
+- feat(desktop): add custom harness inline from agent dialogs ([#3252](https://github.com/block/buzz/pull/3252)) ([`b0503d80c`](https://github.com/block/buzz/commit/b0503d80c298b1ece3b0a43b41d316829a3379e7))
+- feat(desktop): refine agent catalog sharing ([#2439](https://github.com/block/buzz/pull/2439)) ([`a35771fc4`](https://github.com/block/buzz/commit/a35771fc441cdc3c6f517f419037206783b502d2))
+- fix(desktop): keep drafts out of the Inbox All view ([#3217](https://github.com/block/buzz/pull/3217)) ([`3afa129ee`](https://github.com/block/buzz/commit/3afa129ee785cc74d921d0ba969254a8255c4cc0))
+- fix(desktop): restore the inbox icon in the sidebar ([#3341](https://github.com/block/buzz/pull/3341)) ([`00ede2e7a`](https://github.com/block/buzz/commit/00ede2e7aa7eb95571b7db3ebbd163adbf6cf74e))
+- fix(desktop): gate codex-acp on a minimum supported version ([#3254](https://github.com/block/buzz/pull/3254)) ([`4e3998f36`](https://github.com/block/buzz/commit/4e3998f36e36d68b9a93dcbd85f0864450bb8f5f))
+- feat(cli): add users set-status command for NIP-38 profile status ([#3253](https://github.com/block/buzz/pull/3253)) ([`60158fce3`](https://github.com/block/buzz/commit/60158fce3e670f11bb35d42627857ccaea50ff06))
+- fix(composer): scope multiline block formatting ([#3246](https://github.com/block/buzz/pull/3246)) ([`5457c947a`](https://github.com/block/buzz/commit/5457c947a74f5ba4b979f9c6411aa7626a858387))
+
+
+## v0.5.0
+
+- feat(invites): add use-limited invite links ([#3141](https://github.com/block/buzz/pull/3141)) ([`d500c2d5c`](https://github.com/block/buzz/commit/d500c2d5cf5d9aabe0ca4ebebfcafdbe5f5b7fd3))
+- fix(node): bump Buzz-supplied Node runtimes past OpenClaw's >=24.15.0 floor ([#3218](https://github.com/block/buzz/pull/3218)) ([`98a7b1334`](https://github.com/block/buzz/commit/98a7b1334823ee0be3e3fa5cab7a2e349e438dab))
+- fix(desktop): preserve thread anchor through layout reflow ([#3212](https://github.com/block/buzz/pull/3212)) ([`9810d8545`](https://github.com/block/buzz/commit/9810d8545937329f229ff40d8a19edc9e3e325c1))
+- feat(search): parse from:/in:/after:/before: and pass them in the filter ([#2871](https://github.com/block/buzz/pull/2871)) ([`cb2a265b5`](https://github.com/block/buzz/commit/cb2a265b5399426e808461c1a16713754c593258))
+- fix(desktop): fetch join policies through native networking ([#2862](https://github.com/block/buzz/pull/2862)) ([`0019f8076`](https://github.com/block/buzz/commit/0019f80765e96f056e81b57789b8b5fb80936f72))
+- fix(desktop): republish agent identity records when a persona rename propagates ([#2607](https://github.com/block/buzz/pull/2607)) ([`7ca0bbd94`](https://github.com/block/buzz/commit/7ca0bbd946fd82a7008132f94d069a97bb53f94b))
+- fix(desktop): keep project Inbox previews compact ([#3193](https://github.com/block/buzz/pull/3193)) ([`de1396050`](https://github.com/block/buzz/commit/de13960505fd798070e177cb33b1663100ac06bb))
+- Inbox refactor ([#2045](https://github.com/block/buzz/pull/2045)) ([`2bd4c24b7`](https://github.com/block/buzz/commit/2bd4c24b71335e7ce272ec6de6491f7f37f4b20d))
+- Fix composer selection formatting and drop overlay ([#3172](https://github.com/block/buzz/pull/3172)) ([`99da5b7eb`](https://github.com/block/buzz/commit/99da5b7ebb19e26453e075bfb949672122b31be3))
+- Refine pending message status ([#3153](https://github.com/block/buzz/pull/3153)) ([`75588eaff`](https://github.com/block/buzz/commit/75588eaff2354d620e554c055b80ec83735ddb0a))
+- fix(desktop): recover full local storage on startup ([#3182](https://github.com/block/buzz/pull/3182)) ([`174c38e4b`](https://github.com/block/buzz/commit/174c38e4bd1ed8498641546bc4fcb6d5a4c9cede))
+- fix(desktop): keep collapsed table separators out of spoilers ([#3169](https://github.com/block/buzz/pull/3169)) ([`4d8b676bb`](https://github.com/block/buzz/commit/4d8b676bb283a1917cec5850c3b7327fe122b0c1))
+- feat(desktop): redesign agent runtime settings ([#3093](https://github.com/block/buzz/pull/3093)) ([`d98da7389`](https://github.com/block/buzz/commit/d98da7389e60cfbd79b219aa411449fe2e53a18a))
+- fix(desktop): use forward slashes for git credential.helper on Windows ([#3023](https://github.com/block/buzz/pull/3023)) ([`899531684`](https://github.com/block/buzz/commit/8995316844f7ad50552fbae67fbd35119262796f))
+- chore(desktop): add AgentCreationPreview file-size override to unblock main CI ([#3154](https://github.com/block/buzz/pull/3154)) ([`b92a1f4bf`](https://github.com/block/buzz/commit/b92a1f4bf400e7da5ab7a010cdd81a69497d8191))
+- fix(desktop): make the test loader work on Windows ([#2758](https://github.com/block/buzz/pull/2758)) ([`8bb43d519`](https://github.com/block/buzz/commit/8bb43d51912894553f2670b2d285a96cf09cd472))
+- fix(desktop): make lint and unit-test gates work on Windows ([#2943](https://github.com/block/buzz/pull/2943)) ([`545bb46b8`](https://github.com/block/buzz/commit/545bb46b824a3fbf4401062f03b72531d832ebb9))
+- feat(desktop): add search to agent emoji picker ([#2630](https://github.com/block/buzz/pull/2630)) ([`313f793c8`](https://github.com/block/buzz/commit/313f793c8753d413c22ff8edfe420d5ee78708bc))
+- fix(desktop): keep identity key help dialog readable in dark mode ([#2854](https://github.com/block/buzz/pull/2854)) ([`be275cfc6`](https://github.com/block/buzz/commit/be275cfc6c7b80fe43e9d66c6d14b6d2bbe58a10))
+- feat(acp): title agent sessions from the agent and channel name ([#3028](https://github.com/block/buzz/pull/3028)) ([`f2fe3b63c`](https://github.com/block/buzz/commit/f2fe3b63c21be55907175715c076cd3a9195b74d))
+- feat(git): use agent display name as git author name ([#3040](https://github.com/block/buzz/pull/3040)) ([`18eef633d`](https://github.com/block/buzz/commit/18eef633d88ac465c61d98f12655fbf51dc3ca44))
+- fix(deps): bump nostr to 0.44.6 for RUSTSEC-2026-0216 (NIP-44 remote DoS) ([#3135](https://github.com/block/buzz/pull/3135)) ([`31e2de196`](https://github.com/block/buzz/commit/31e2de1966672e73e026af3c54f3a1a9a2f5e103))
+- fix(desktop): read the newest pair-scoped harness log ([#3134](https://github.com/block/buzz/pull/3134)) ([`654f38490`](https://github.com/block/buzz/commit/654f384906b5c720a60a199d85031a6f1cb6efc9))
+- feat(desktop): handle project work from Inbox ([#3117](https://github.com/block/buzz/pull/3117)) ([`c5c4f390b`](https://github.com/block/buzz/commit/c5c4f390b6713256e2efb8394c59823ebad73db6))
+- fix(desktop): clarify identity key button when key exists ([#2357](https://github.com/block/buzz/pull/2357)) ([`87b3fcd3c`](https://github.com/block/buzz/commit/87b3fcd3c0131683569dd4268b099d18b25dcd5e))
+- Restore Goose and Buzz Agent to onboarding harness selection ([#2731](https://github.com/block/buzz/pull/2731)) ([`7fc0cc82d`](https://github.com/block/buzz/commit/7fc0cc82db4d9dced9c258bbe8b530164a832a77))
+- fix(desktop): render rich project work item content ([#3100](https://github.com/block/buzz/pull/3100)) ([`afb272bb7`](https://github.com/block/buzz/commit/afb272bb7b8d7d45d7de676fa97dcd5a8eefacc7))
+- feat(acp): bring your own harness (BYOH) — generic ACP runtime seam + settings gallery ([#2773](https://github.com/block/buzz/pull/2773)) ([`95fdf9788`](https://github.com/block/buzz/commit/95fdf978800982389b120c66ff5e766d785419c7))
+- feat(desktop): use collective mesh routing for Auto ([#2825](https://github.com/block/buzz/pull/2825)) ([`16d4ec335`](https://github.com/block/buzz/commit/16d4ec335e210295a9d9f77f36c1e85a18b6814a))
+- fix(desktop): strip legacy baked team instructions from stored prompts ([#3035](https://github.com/block/buzz/pull/3035)) ([`aee631448`](https://github.com/block/buzz/commit/aee63144843854ee32ed9d36a2e7511c82ddc6b0))
+- feat(agents): lower default agent parallelism from 24 to 10 ([#3038](https://github.com/block/buzz/pull/3038)) ([`5d8ede446`](https://github.com/block/buzz/commit/5d8ede446f8fdc48146fe56d389cab6bf3500f92))
+- Polish community rail and mobile pairing ([#2972](https://github.com/block/buzz/pull/2972)) ([`e6c90bb7c`](https://github.com/block/buzz/commit/e6c90bb7c430d1b2af16508b634f9a5283b7fa3b))
+- fix(desktop): remove bundled libsystemd from AppImage ([#2353](https://github.com/block/buzz/pull/2353)) ([`a31fc4d2f`](https://github.com/block/buzz/commit/a31fc4d2f35d51cdf45ff8c61fc3a07f49c665e8))
+- fix(desktop): make agent definition authoritative for model/provider/prompt ([#1968](https://github.com/block/buzz/pull/1968)) ([`8c0e8cb16`](https://github.com/block/buzz/commit/8c0e8cb1656b04ad269bce3c2deeda2a943ae78a))
+- chore(desktop): delete dead persona catalog UI cluster ([#2886](https://github.com/block/buzz/pull/2886)) ([`8e67cf399`](https://github.com/block/buzz/commit/8e67cf399d0291bcdbc69cd0402983ca030f05bb))
+- fix(desktop): surface install failures hidden by curl-pipe exit codes ([#2892](https://github.com/block/buzz/pull/2892)) ([`166c6655e`](https://github.com/block/buzz/commit/166c6655e8bca87d83ad60c087fb70a32a026baf))
+- Refactor managed-agent runtime into cohesive modules ([#2974](https://github.com/block/buzz/pull/2974)) ([`74b63e184`](https://github.com/block/buzz/commit/74b63e1846212af6e6751a62cfc631f74b1dfe07))
+- fix(desktop): make Linux AppImage GStreamer work on non-Debian distros ([#2176](https://github.com/block/buzz/pull/2176)) ([`cc6c4d347`](https://github.com/block/buzz/commit/cc6c4d3471629fad018bcf645f9471a01b9ffe2f))
+- refactor(desktop): remove Agent directory section from Agents page ([#2290](https://github.com/block/buzz/pull/2290)) ([`5d1233e84`](https://github.com/block/buzz/commit/5d1233e841b0efa91470bb45467b2c8e4284ebf6))
+- fix(desktop): enable arboard Wayland backend so Linux copies reach the Wayland clipboard ([#2904](https://github.com/block/buzz/pull/2904)) ([`ab7aa8b12`](https://github.com/block/buzz/commit/ab7aa8b1200710dbc2d7a8661ed5aab95c4199c1))
+- fix(desktop): supervise and re-arm relay-mesh runtime ([#2823](https://github.com/block/buzz/pull/2823)) ([`aa51dab9d`](https://github.com/block/buzz/commit/aa51dab9da5fef7054d03cf1a1207986d0000684))
+- fix(agents): run live Databricks discovery instead of the fallback list ([#2890](https://github.com/block/buzz/pull/2890)) ([`8eb6e3eb6`](https://github.com/block/buzz/commit/8eb6e3eb601174249642373a6a367262fa476753))
+- fix(desktop): retire prepend mode on every reader wheel ([#2913](https://github.com/block/buzz/pull/2913)) ([`07d0265cf`](https://github.com/block/buzz/commit/07d0265cfc212ef02e1c26153bf58ff46ce5ffe6))
+- fix(desktop): consolidate prepend scroll correction ([#2855](https://github.com/block/buzz/pull/2855)) ([`25e7864b3`](https://github.com/block/buzz/commit/25e7864b35f4dfd1c0ff31304a38555230a85f8d))
+- fix(desktop): track concurrent agent turns up to the harness maximum ([#2882](https://github.com/block/buzz/pull/2882)) ([`20bff5910`](https://github.com/block/buzz/commit/20bff591023daffc5ee1032cff02b54b75da3567))
+- fix(relay): preserve reconnect backoff ([#2759](https://github.com/block/buzz/pull/2759)) ([`499c5d349`](https://github.com/block/buzz/commit/499c5d349dab13bc906b1af5fe1fcb09ce2afa81))
+- refactor(relay): expose reconnect timing policy ([#2310](https://github.com/block/buzz/pull/2310)) ([`2f0041595`](https://github.com/block/buzz/commit/2f0041595d72529c06885680d2bd07ddb6a0beb4))
+- fix(desktop): clear stale working badges on agent stop/restart ([#2803](https://github.com/block/buzz/pull/2803)) ([`a64cc71f6`](https://github.com/block/buzz/commit/a64cc71f6c1605279b1a6fbd0fe904a2984cbdb0))
+- fix(desktop): surface agent rename relay profile sync failure as a warning toast ([#2279](https://github.com/block/buzz/pull/2279)) ([`5e3d2e484`](https://github.com/block/buzz/commit/5e3d2e4849c0f2512330801d804fb96f4ab72d28))
+- fix(discovery): inject PATH into Codex adapter planning ([#2767](https://github.com/block/buzz/pull/2767)) ([`6ab3835f3`](https://github.com/block/buzz/commit/6ab3835f3fe89ee215819fe8d193463c0ae7472b))
+
+
## v0.4.26
- Style mobile pairing QR codes ([#2775](https://github.com/block/buzz/pull/2775)) ([`50655ac09`](https://github.com/block/buzz/commit/50655ac097fbf1a7db1a5284dccc7e2a0b0f1bfc))
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 1f319fa20f..db0aea637f 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -43,7 +43,28 @@ Buzz is an agent platform, so AI-assisted PRs are welcome. No need to disclose t
We squash-merge, so your PR title becomes the commit subject in `main`. Use [Conventional Commits](https://www.conventionalcommits.org/) format: `feat(mcp): add get_feed_actions tool`. The type prefix (`feat`, `fix`, `docs`, `refactor`, `test`, `chore`) is required. See the [Commit Messages](#commit-messages) section for the full reference.
-Every commit needs a Developer Certificate of Origin sign-off, so commit with `git commit -s` — it appends the `Signed-off-by` trailer that certifies you wrote the change and can contribute it. The required **DCO Check** blocks merge without it on every commit, and it's the most common reason new PRs stall. If you already pushed unsigned commits, run `git rebase --signoff main` and force-push. Running `just hooks` installs a `commit-msg` hook that adds the trailer to commits created by `git commit` and `git merge`; other flows need their own flag — `git rebase --signoff`, `git cherry-pick -s`.
+### Sign Your Commits
+
+```bash
+git commit -s
+```
+
+Every commit needs a Developer Certificate of Origin (DCO) sign-off. The `-s` flag appends a `Signed-off-by` trailer that certifies you wrote the change and can contribute it under the project license. The **DCO Check** will block your PR without it.
+
+#### Fix unsigned commits already pushed
+
+```bash
+git rebase --signoff main
+git push --force-with-lease
+```
+
+#### Auto-setup for future commits
+
+```bash
+just hooks
+```
+
+This installs a `commit-msg` hook that adds the sign-off trailer automatically for `git commit` and `git merge`. Other flows (`git rebase`, `git cherry-pick`) still need their own flag — `--signoff` and `-s` respectively.
We review as capacity allows — focused PRs that follow this guide move fastest.
@@ -77,6 +98,37 @@ Hermit pins Rust, `just`, Node, pnpm, and other tools to the versions in
upfront. If you don't use Hermit, ensure your toolchain meets the minimum
versions in the table above.
+#### Linux: Tauri system libraries
+
+Hermit pins language toolchains, not system libraries. On Linux, the desktop
+app's Rust crates link against GTK and WebKitGTK, so `just ci` (and any
+`just desktop-tauri-*` recipe) needs these installed system-wide first. On
+Debian/Ubuntu:
+
+```bash
+sudo apt-get install -y --no-install-recommends \
+ build-essential curl file libasound2-dev libayatana-appindicator3-dev \
+ libgtk-3-dev librsvg2-dev libssl-dev libwebkit2gtk-4.1-dev libxdo-dev \
+ patchelf wget
+```
+
+This is the same list CI installs (see `.github/workflows/ci.yml`), so matching
+it locally keeps your results comparable to CI. Other distributions ship these
+under different package names — see the
+[Tauri prerequisites](https://tauri.app/start/prerequisites/) for the
+equivalents.
+
+Without them, `just ci` fails partway through `just check` with a pkg-config
+error such as:
+
+```
+The system library `gdk-pixbuf-2.0` required by crate `gdk-pixbuf-sys` was not found.
+```
+
+If you're only touching the relay, CLI, or other server-side crates, you can
+skip this and run the narrower recipes instead — `just fmt-check`, `just
+clippy`, `just test-unit`, and `just test` need no GTK.
+
### First-Time Setup
```bash
diff --git a/Cargo.lock b/Cargo.lock
index 3b60dc4579..de078e9170 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -43,6 +43,20 @@ dependencies = [
"subtle",
]
+[[package]]
+name = "ahash"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
+dependencies = [
+ "cfg-if 1.0.4",
+ "getrandom 0.3.4",
+ "once_cell",
+ "serde",
+ "version_check",
+ "zerocopy",
+]
+
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -131,7 +145,7 @@ checksum = "5d0a66767aaf7d483c556386fb68ca2fba9347684d8bb17a4bd8b755851870f7"
dependencies = [
"arrayvec",
"aws-lc-rs",
- "base64",
+ "base64 0.22.1",
"byteorder",
"minicbor",
"rustls-pki-types",
@@ -396,13 +410,23 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+[[package]]
+name = "atomic-write-file"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "84790c55b5704b0d35130bf16a4ce22a8e70eb0ea773522557524d9a4852663d"
+dependencies = [
+ "nix 0.30.1",
+ "rand 0.9.4",
+]
+
[[package]]
name = "attohttpc"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9"
dependencies = [
- "base64",
+ "base64 0.22.1",
"http",
"log",
"rustls",
@@ -473,7 +497,7 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
"axum-macros",
- "base64",
+ "base64 0.22.1",
"bytes",
"form_urlencoded",
"futures-util",
@@ -549,6 +573,12 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6"
+[[package]]
+name = "base64"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
+
[[package]]
name = "base64"
version = "0.22.1"
@@ -567,6 +597,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f"
+[[package]]
+name = "beef"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1"
+
[[package]]
name = "bip39"
version = "2.2.2"
@@ -760,12 +796,29 @@ version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
+[[package]]
+name = "buzz-a2a-acp"
+version = "0.1.0"
+dependencies = [
+ "base64 0.22.1",
+ "clap",
+ "hex",
+ "reqwest 0.13.4",
+ "serde",
+ "serde_json",
+ "sha2 0.11.0",
+ "thiserror 2.0.18",
+ "tokio",
+ "url",
+ "uuid",
+]
+
[[package]]
name = "buzz-acp"
version = "0.1.0"
dependencies = [
"anyhow",
- "base64",
+ "base64 0.22.1",
"buzz-core",
"buzz-persona",
"buzz-sdk",
@@ -825,7 +878,7 @@ dependencies = [
"arc-swap",
"async-trait",
"axum",
- "base64",
+ "base64 0.22.1",
"getrandom 0.4.3",
"hex",
"nix 0.31.3",
@@ -884,7 +937,7 @@ name = "buzz-cli"
version = "0.1.0"
dependencies = [
"axum",
- "base64",
+ "base64 0.22.1",
"buzz-core",
"buzz-persona",
"buzz-sdk",
@@ -925,7 +978,7 @@ dependencies = [
name = "buzz-core"
version = "0.1.0"
dependencies = [
- "base64",
+ "base64 0.22.1",
"chrono",
"hex",
"hmac 0.13.0",
@@ -949,6 +1002,8 @@ dependencies = [
"buzz-core",
"chrono",
"hex",
+ "metrics",
+ "metrics-util",
"nostr",
"rand 0.10.1",
"serde",
@@ -965,7 +1020,7 @@ dependencies = [
name = "buzz-dev-mcp"
version = "0.1.0"
dependencies = [
- "base64",
+ "base64 0.22.1",
"buzz-cli",
"buzz-core",
"git-credential-nostr",
@@ -1092,7 +1147,7 @@ dependencies = [
"appattest",
"async-trait",
"axum",
- "base64",
+ "base64 0.22.1",
"byteorder",
"chrono",
"getrandom 0.4.3",
@@ -1127,7 +1182,7 @@ dependencies = [
"async-compression",
"async-trait",
"axum",
- "base64",
+ "base64 0.22.1",
"buzz-audit",
"buzz-auth",
"buzz-conformance",
@@ -1237,8 +1292,9 @@ name = "buzz-test-client"
version = "0.1.0"
dependencies = [
"anyhow",
- "base64",
+ "base64 0.22.1",
"buzz-core",
+ "buzz-media",
"buzz-sdk",
"buzz-ws-client",
"chrono",
@@ -1262,6 +1318,25 @@ dependencies = [
"uuid",
]
+[[package]]
+name = "buzz-voice"
+version = "0.1.0"
+dependencies = [
+ "atomic-write-file",
+ "hex",
+ "ort",
+ "ort-sys",
+ "rand 0.10.1",
+ "sentencepiece-model",
+ "serde",
+ "serde_json",
+ "sha2 0.11.0",
+ "sherpa-onnx",
+ "symphonia",
+ "tempfile",
+ "tokenizers",
+]
+
[[package]]
name = "buzz-workflow"
version = "0.1.0"
@@ -1329,6 +1404,26 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+[[package]]
+name = "bzip2"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8"
+dependencies = [
+ "bzip2-sys",
+ "libc",
+]
+
+[[package]]
+name = "bzip2-sys"
+version = "0.1.13+1.0.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14"
+dependencies = [
+ "cc",
+ "pkg-config",
+]
+
[[package]]
name = "castaway"
version = "0.2.4"
@@ -1577,6 +1672,7 @@ dependencies = [
"itoa",
"rustversion",
"ryu",
+ "serde",
"static_assertions",
]
@@ -2137,6 +2233,15 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "dary_heap"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "dashmap"
version = "6.2.1"
@@ -2174,7 +2279,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090"
dependencies = [
"data-encoding",
- "syn 1.0.109",
+ "syn 2.0.117",
]
[[package]]
@@ -2626,6 +2731,12 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "esaxx-rs"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6"
+
[[package]]
name = "etcetera"
version = "0.11.0"
@@ -2683,6 +2794,12 @@ dependencies = [
"smallvec",
]
+[[package]]
+name = "extended"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
+
[[package]]
name = "fancy-regex"
version = "0.11.0"
@@ -2693,6 +2810,17 @@ dependencies = [
"regex",
]
+[[package]]
+name = "fancy-regex"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298"
+dependencies = [
+ "bit-set 0.8.0",
+ "regex-automata",
+ "regex-syntax",
+]
+
[[package]]
name = "fast-srgb8"
version = "1.0.0"
@@ -3084,7 +3212,7 @@ dependencies = [
name = "git-credential-nostr"
version = "0.1.0"
dependencies = [
- "base64",
+ "base64 0.22.1",
"nostr",
"serde_json",
"zeroize",
@@ -3094,7 +3222,7 @@ dependencies = [
name = "git-sign-nostr"
version = "0.1.0"
dependencies = [
- "base64",
+ "base64 0.22.1",
"chrono",
"hex",
"libc",
@@ -3269,7 +3397,7 @@ version = "1.0.0-rc.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f89305dc8fe34e165eaf0eb12b6e294e12381d9df9a431bcc52a5809bab4319"
dependencies = [
- "base64",
+ "base64 0.22.1",
"bon",
"bytes",
"futures",
@@ -3575,7 +3703,7 @@ version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
- "base64",
+ "base64 0.22.1",
"bytes",
"futures-channel",
"futures-util",
@@ -4359,6 +4487,39 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+[[package]]
+name = "logos"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7251356ef8cb7aec833ddf598c6cb24d17b689d20b993f9d11a3d764e34e6458"
+dependencies = [
+ "logos-derive",
+]
+
+[[package]]
+name = "logos-codegen"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59f80069600c0d66734f5ff52cc42f2dabd6b29d205f333d61fd7832e9e9963f"
+dependencies = [
+ "beef",
+ "fnv",
+ "lazy_static",
+ "proc-macro2",
+ "quote",
+ "regex-syntax",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "logos-derive"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24fb722b06a9dc12adb0963ed585f19fc61dc5413e6a9be9422ef92c091e731d"
+dependencies = [
+ "logos-codegen",
+]
+
[[package]]
name = "loom"
version = "0.7.2"
@@ -4418,6 +4579,22 @@ dependencies = [
"winapi",
]
+[[package]]
+name = "macro_rules_attribute"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3ae8f6d608c795738406608304d30a2dfbdc8e58e44f7ba43236da5208ded3c"
+dependencies = [
+ "macro_rules_attribute-proc_macro",
+ "pastey",
+]
+
+[[package]]
+name = "macro_rules_attribute-proc_macro"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc04a4c58212d57930a24bf47d3fa87485264a3a054e9c10e042eb373573ad3c"
+
[[package]]
name = "matchers"
version = "0.2.0"
@@ -4433,6 +4610,16 @@ version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
+[[package]]
+name = "matrixmultiply"
+version = "0.3.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7"
+dependencies = [
+ "autocfg",
+ "rawpointer",
+]
+
[[package]]
name = "maybe-async"
version = "0.2.11"
@@ -4498,8 +4685,8 @@ dependencies = [
[[package]]
name = "mesh-llm-api-client"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
"hex",
"mesh-llm-client",
@@ -4508,8 +4695,8 @@ dependencies = [
[[package]]
name = "mesh-llm-api-server"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
"anyhow",
"mesh-llm-api-client",
@@ -4519,17 +4706,17 @@ dependencies = [
[[package]]
name = "mesh-llm-build-info"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
[[package]]
name = "mesh-llm-client"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
"anyhow",
"async-trait",
- "base64",
+ "base64 0.22.1",
"bytes",
"crypto_box",
"ed25519-dalek",
@@ -4542,7 +4729,7 @@ dependencies = [
"mesh-llm-types",
"model-artifact",
"nostr-sdk",
- "prost",
+ "prost 0.14.3",
"rand 0.10.1",
"rustls",
"serde",
@@ -4556,8 +4743,8 @@ dependencies = [
[[package]]
name = "mesh-llm-config"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
"anyhow",
"dirs",
@@ -4572,8 +4759,8 @@ dependencies = [
[[package]]
name = "mesh-llm-embedded-runtime"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
"anyhow",
"mesh-llm-host-runtime",
@@ -4582,8 +4769,8 @@ dependencies = [
[[package]]
name = "mesh-llm-events"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
"anyhow",
"clap",
@@ -4594,8 +4781,8 @@ dependencies = [
[[package]]
name = "mesh-llm-gpu-bench"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
"anyhow",
"cc",
@@ -4607,8 +4794,8 @@ dependencies = [
[[package]]
name = "mesh-llm-guardrails"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
"serde",
"serde_json",
@@ -4616,22 +4803,22 @@ dependencies = [
[[package]]
name = "mesh-llm-hardware-profile"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
"mesh-llm-native-runtime",
]
[[package]]
name = "mesh-llm-host-runtime"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
"anyhow",
"argon2",
"async-trait",
"axum",
- "base64",
+ "base64 0.22.1",
"bytes",
"chacha20poly1305",
"chrono",
@@ -4647,7 +4834,6 @@ dependencies = [
"http",
"http-body-util",
"httparse",
- "if-addrs",
"iroh",
"json5",
"keyring",
@@ -4681,7 +4867,7 @@ dependencies = [
"opentelemetry 0.31.0",
"opentelemetry-otlp 0.31.1",
"opentelemetry_sdk 0.31.0",
- "prost",
+ "prost 0.14.3",
"rand 0.10.1",
"regex-lite",
"reqwest 0.12.28",
@@ -4695,6 +4881,7 @@ dependencies = [
"serde_yaml",
"sha2 0.10.9",
"skippy-coordinator",
+ "skippy-ffi",
"skippy-protocol",
"skippy-runtime",
"skippy-server",
@@ -4717,11 +4904,11 @@ dependencies = [
[[package]]
name = "mesh-llm-identity"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
"argon2",
- "base64",
+ "base64 0.22.1",
"chacha20poly1305",
"chrono",
"crypto_box",
@@ -4739,8 +4926,8 @@ dependencies = [
[[package]]
name = "mesh-llm-native-runtime"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
"anyhow",
"serde",
@@ -4750,8 +4937,8 @@ dependencies = [
[[package]]
name = "mesh-llm-node"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
"anyhow",
"mesh-llm-types",
@@ -4764,13 +4951,13 @@ dependencies = [
[[package]]
name = "mesh-llm-plugin"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
"anyhow",
"async-trait",
- "prost",
- "prost-build",
+ "prost 0.14.3",
+ "prost-build 0.14.3",
"protoc-bin-vendored",
"rmcp",
"schemars",
@@ -4781,8 +4968,8 @@ dependencies = [
[[package]]
name = "mesh-llm-plugin-manager"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
"anyhow",
"dirs",
@@ -4792,6 +4979,7 @@ dependencies = [
"reqwest 0.12.28",
"serde",
"serde_json",
+ "sha2 0.10.9",
"tar",
"tempfile",
"zip",
@@ -4799,29 +4987,29 @@ dependencies = [
[[package]]
name = "mesh-llm-protocol"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
"anyhow",
"hex",
"iroh",
- "prost",
+ "prost 0.14.3",
"serde_json",
"sha2 0.10.9",
]
[[package]]
name = "mesh-llm-routing"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
"iroh",
]
[[package]]
name = "mesh-llm-runtime-install"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
"anyhow",
"dirs",
@@ -4843,8 +5031,8 @@ dependencies = [
[[package]]
name = "mesh-llm-sdk"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
"anyhow",
"mesh-llm-api-client",
@@ -4858,8 +5046,8 @@ dependencies = [
[[package]]
name = "mesh-llm-skills"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
"anyhow",
"dirs",
@@ -4869,8 +5057,8 @@ dependencies = [
[[package]]
name = "mesh-llm-system"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
"anyhow",
"chrono",
@@ -4892,8 +5080,8 @@ dependencies = [
[[package]]
name = "mesh-llm-types"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
"hex",
"serde",
@@ -4903,13 +5091,13 @@ dependencies = [
[[package]]
name = "mesh-llm-ui"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
[[package]]
name = "mesh-mixture-of-agents"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
"async-trait",
"mesh-llm-guardrails",
@@ -4936,7 +5124,7 @@ version = "0.18.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1db0d8f1fc9e62caebd0319e11eaec5822b0186c171568f0480b46a0137f9108"
dependencies = [
- "base64",
+ "base64 0.22.1",
"evmap",
"http-body-util",
"hyper",
@@ -4974,6 +5162,28 @@ dependencies = [
"sketches-ddsketch",
]
+[[package]]
+name = "miette"
+version = "7.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7"
+dependencies = [
+ "cfg-if 1.0.4",
+ "miette-derive",
+ "unicode-width 0.1.14",
+]
+
+[[package]]
+name = "miette-derive"
+version = "7.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "mime"
version = "0.3.17"
@@ -5035,8 +5245,8 @@ dependencies = [
[[package]]
name = "model-artifact"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
"anyhow",
"async-trait",
@@ -5046,8 +5256,8 @@ dependencies = [
[[package]]
name = "model-hf"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
"anyhow",
"async-trait",
@@ -5064,8 +5274,8 @@ dependencies = [
[[package]]
name = "model-package"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
"anyhow",
"bytes",
@@ -5084,16 +5294,16 @@ dependencies = [
[[package]]
name = "model-ref"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
"serde",
]
[[package]]
name = "model-resolver"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
"anyhow",
"model-artifact",
@@ -5119,6 +5329,28 @@ dependencies = [
"uuid",
]
+[[package]]
+name = "monostate"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67"
+dependencies = [
+ "monostate-impl",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "monostate-impl"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "more-asserts"
version = "0.3.1"
@@ -5225,6 +5457,21 @@ dependencies = [
"tempfile",
]
+[[package]]
+name = "ndarray"
+version = "0.17.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d"
+dependencies = [
+ "matrixmultiply",
+ "num-complex",
+ "num-integer",
+ "num-traits",
+ "portable-atomic",
+ "portable-atomic-util",
+ "rawpointer",
+]
+
[[package]]
name = "ndk-context"
version = "0.1.1"
@@ -5371,6 +5618,18 @@ dependencies = [
"memoffset",
]
+[[package]]
+name = "nix"
+version = "0.30.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
+dependencies = [
+ "bitflags 2.13.0",
+ "cfg-if 1.0.4",
+ "cfg_aliases",
+ "libc",
+]
+
[[package]]
name = "nix"
version = "0.31.3"
@@ -5461,7 +5720,7 @@ version = "0.44.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e826dd648489de2c5b293920e20b92932ef820302007c1987c758d4d06eeb2cf"
dependencies = [
- "base64",
+ "base64 0.22.1",
"bech32",
"bip39",
"bitcoin_hashes",
@@ -5812,8 +6071,8 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openai-frontend"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
"async-trait",
"axum",
@@ -5932,7 +6191,7 @@ dependencies = [
"opentelemetry-http",
"opentelemetry-proto 0.31.0",
"opentelemetry_sdk 0.31.0",
- "prost",
+ "prost 0.14.3",
"reqwest 0.12.28",
"thiserror 2.0.18",
]
@@ -5947,7 +6206,7 @@ dependencies = [
"opentelemetry 0.32.0",
"opentelemetry-proto 0.32.0",
"opentelemetry_sdk 0.32.1",
- "prost",
+ "prost 0.14.3",
"thiserror 2.0.18",
"tokio",
"tonic",
@@ -5960,11 +6219,11 @@ version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f"
dependencies = [
- "base64",
+ "base64 0.22.1",
"const-hex",
"opentelemetry 0.31.0",
"opentelemetry_sdk 0.31.0",
- "prost",
+ "prost 0.14.3",
"serde",
"serde_json",
"tonic",
@@ -5979,7 +6238,7 @@ checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638"
dependencies = [
"opentelemetry 0.32.0",
"opentelemetry_sdk 0.32.1",
- "prost",
+ "prost 0.14.3",
"tonic",
"tonic-prost",
]
@@ -6061,6 +6320,24 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "ort"
+version = "2.0.0-rc.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133"
+dependencies = [
+ "ndarray",
+ "ort-sys",
+ "smallvec",
+ "tracing",
+]
+
+[[package]]
+name = "ort-sys"
+version = "2.0.0-rc.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90"
+
[[package]]
name = "os_str_bytes"
version = "6.6.1"
@@ -6242,6 +6519,16 @@ dependencies = [
"pest",
]
+[[package]]
+name = "petgraph"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
+dependencies = [
+ "fixedbitset 0.5.7",
+ "indexmap",
+]
+
[[package]]
name = "petgraph"
version = "0.8.3"
@@ -6374,7 +6661,7 @@ version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
dependencies = [
- "base64",
+ "base64 0.22.1",
"indexmap",
"quick-xml 0.39.4",
"serde",
@@ -6440,13 +6727,22 @@ dependencies = [
"serde",
]
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
+dependencies = [
+ "portable-atomic",
+]
+
[[package]]
name = "portmapper"
version = "0.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb3713e4977408279158444a18c1a01ac9bf2e7eaf1fbfd1a19ac9cd18d90721"
dependencies = [
- "base64",
+ "base64 0.22.1",
"bytes",
"derive_more",
"hyper-util",
@@ -6616,6 +6912,16 @@ dependencies = [
"unarray",
]
+[[package]]
+name = "prost"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
+dependencies = [
+ "bytes",
+ "prost-derive 0.13.5",
+]
+
[[package]]
name = "prost"
version = "0.14.3"
@@ -6623,7 +6929,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568"
dependencies = [
"bytes",
- "prost-derive",
+ "prost-derive 0.14.3",
+]
+
+[[package]]
+name = "prost-build"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf"
+dependencies = [
+ "heck",
+ "itertools",
+ "log",
+ "multimap",
+ "once_cell",
+ "petgraph 0.7.1",
+ "prettyplease",
+ "prost 0.13.5",
+ "prost-types 0.13.5",
+ "regex",
+ "syn 2.0.117",
+ "tempfile",
]
[[package]]
@@ -6636,15 +6962,28 @@ dependencies = [
"itertools",
"log",
"multimap",
- "petgraph",
+ "petgraph 0.8.3",
"prettyplease",
- "prost",
- "prost-types",
+ "prost 0.14.3",
+ "prost-types 0.14.3",
"regex",
"syn 2.0.117",
"tempfile",
]
+[[package]]
+name = "prost-derive"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
+dependencies = [
+ "anyhow",
+ "itertools",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "prost-derive"
version = "0.14.3"
@@ -6658,13 +6997,35 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "prost-reflect"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b5edd582b62f5cde844716e66d92565d7faf7ab1445c8cebce6e00fba83ddb2"
+dependencies = [
+ "logos",
+ "miette",
+ "once_cell",
+ "prost 0.13.5",
+ "prost-types 0.13.5",
+]
+
+[[package]]
+name = "prost-types"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16"
+dependencies = [
+ "prost 0.13.5",
+]
+
[[package]]
name = "prost-types"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7"
dependencies = [
- "prost",
+ "prost 0.14.3",
]
[[package]]
@@ -6731,6 +7092,33 @@ version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3"
+[[package]]
+name = "protox"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f352af331bf637b8ecc720f7c87bf903d2571fa2e14a66e9b2558846864b54a"
+dependencies = [
+ "bytes",
+ "miette",
+ "prost 0.13.5",
+ "prost-reflect",
+ "prost-types 0.13.5",
+ "protox-parse",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "protox-parse"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3a462d115462c080ae000c29a47f0b3985737e5d3a995fcdbcaa5c782068dde"
+dependencies = [
+ "logos",
+ "miette",
+ "prost-types 0.13.5",
+ "thiserror 1.0.69",
+]
+
[[package]]
name = "pulldown-cmark"
version = "0.13.4"
@@ -7036,7 +7424,7 @@ dependencies = [
"thiserror 2.0.18",
"unicode-segmentation",
"unicode-truncate",
- "unicode-width",
+ "unicode-width 0.2.2",
]
[[package]]
@@ -7099,7 +7487,7 @@ dependencies = [
"strum",
"time",
"unicode-segmentation",
- "unicode-width",
+ "unicode-width 0.2.2",
]
[[package]]
@@ -7111,6 +7499,43 @@ dependencies = [
"bitflags 2.13.0",
]
+[[package]]
+name = "rawpointer"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
+
+[[package]]
+name = "rayon"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
+dependencies = [
+ "either",
+ "rayon-core",
+]
+
+[[package]]
+name = "rayon-cond"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f"
+dependencies = [
+ "either",
+ "itertools",
+ "rayon",
+]
+
+[[package]]
+name = "rayon-core"
+version = "1.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
+dependencies = [
+ "crossbeam-deque",
+ "crossbeam-utils",
+]
+
[[package]]
name = "redb"
version = "3.1.3"
@@ -7232,7 +7657,7 @@ version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
- "base64",
+ "base64 0.22.1",
"bytes",
"encoding_rs",
"futures-channel",
@@ -7280,7 +7705,7 @@ version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
dependencies = [
- "base64",
+ "base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
@@ -7365,12 +7790,12 @@ dependencies = [
[[package]]
name = "rmcp"
-version = "1.7.0"
+version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0810a9f717d9828f475fe1f629f4c305c8464b7f496c3a854b58d29e65f4058e"
+checksum = "1d1f571c72940a19d9532fe52dbea8bc9912bf1d766c2970bb824056b86f3f59"
dependencies = [
"async-trait",
- "base64",
+ "base64 0.22.1",
"bytes",
"chrono",
"futures",
@@ -7398,9 +7823,9 @@ dependencies = [
[[package]]
name = "rmcp-macros"
-version = "1.7.0"
+version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6aefac48c364756e97f04c0401ba3231e8607882c7c1d92da0437dc16307904d"
+checksum = "1aad0035b69380782d78ea95b508327e6deaa2235909053e596eea8f27b5e1d5"
dependencies = [
"darling 0.23.0",
"proc-macro2",
@@ -7438,7 +7863,7 @@ dependencies = [
"async-trait",
"aws-creds",
"aws-region",
- "base64",
+ "base64 0.22.1",
"bytes",
"cfg-if 1.0.4",
"futures-util",
@@ -7839,6 +8264,18 @@ version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
+[[package]]
+name = "sentencepiece-model"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40b87bf750a8322c3236d7aa63c1f4a6862187d00d2d8b038e1dfe263bfe43ec"
+dependencies = [
+ "miette",
+ "prost 0.13.5",
+ "prost-build 0.13.5",
+ "protox",
+]
+
[[package]]
name = "serde"
version = "1.0.228"
@@ -8049,6 +8486,28 @@ dependencies = [
"os_str_bytes",
]
+[[package]]
+name = "sherpa-onnx"
+version = "1.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b142d3f255cb4e4b7808ea25869db6f5714e0a3550da355234483b4db552055"
+dependencies = [
+ "serde",
+ "serde_json",
+ "sherpa-onnx-sys",
+]
+
+[[package]]
+name = "sherpa-onnx-sys"
+version = "1.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ffc951af03dc0653c0622158ca8a585a6f2bc43b7b06048cf0e5b5020005c227"
+dependencies = [
+ "bzip2",
+ "tar",
+ "ureq",
+]
+
[[package]]
name = "shlex"
version = "1.3.0"
@@ -8150,8 +8609,8 @@ checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b"
[[package]]
name = "skippy-cache"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
"anyhow",
"blake3",
@@ -8160,40 +8619,40 @@ dependencies = [
[[package]]
name = "skippy-coordinator"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "skippy-ffi"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
"libloading",
]
[[package]]
name = "skippy-metrics"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
[[package]]
name = "skippy-protocol"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
- "prost",
- "prost-build",
+ "prost 0.14.3",
+ "prost-build 0.14.3",
"protoc-bin-vendored",
"serde",
]
[[package]]
name = "skippy-runtime"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
"anyhow",
"libc",
@@ -8206,13 +8665,14 @@ dependencies = [
[[package]]
name = "skippy-server"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
+ "ahash",
"anyhow",
"async-trait",
"axum",
- "base64",
+ "base64 0.22.1",
"blake3",
"clap",
"futures-util",
@@ -8234,8 +8694,8 @@ dependencies = [
[[package]]
name = "skippy-topology"
-version = "0.73.1"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132"
+version = "0.74.0"
+source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1"
dependencies = [
"serde",
"serde_json",
@@ -8319,10 +8779,23 @@ dependencies = [
"der",
]
+[[package]]
+name = "spm_precompiled"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326"
+dependencies = [
+ "base64 0.13.1",
+ "nom",
+ "serde",
+ "unicode-segmentation",
+]
+
[[package]]
name = "sprig"
version = "0.1.0"
dependencies = [
+ "buzz-a2a-acp",
"buzz-acp",
"buzz-agent",
"buzz-dev-mcp",
@@ -8347,7 +8820,7 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb"
dependencies = [
- "base64",
+ "base64 0.22.1",
"bytes",
"cfg-if 1.0.4",
"chrono",
@@ -8453,7 +8926,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e"
dependencies = [
"atoi",
- "base64",
+ "base64 0.22.1",
"bitflags 2.13.0",
"byteorder",
"chrono",
@@ -8594,6 +9067,164 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
+[[package]]
+name = "symphonia"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039"
+dependencies = [
+ "lazy_static",
+ "symphonia-bundle-flac",
+ "symphonia-bundle-mp3",
+ "symphonia-codec-aac",
+ "symphonia-codec-alac",
+ "symphonia-codec-pcm",
+ "symphonia-codec-vorbis",
+ "symphonia-core",
+ "symphonia-format-isomp4",
+ "symphonia-format-ogg",
+ "symphonia-format-riff",
+ "symphonia-metadata",
+]
+
+[[package]]
+name = "symphonia-bundle-flac"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976"
+dependencies = [
+ "log",
+ "symphonia-core",
+ "symphonia-metadata",
+ "symphonia-utils-xiph",
+]
+
+[[package]]
+name = "symphonia-bundle-mp3"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed"
+dependencies = [
+ "lazy_static",
+ "log",
+ "symphonia-core",
+ "symphonia-metadata",
+]
+
+[[package]]
+name = "symphonia-codec-aac"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790"
+dependencies = [
+ "lazy_static",
+ "log",
+ "symphonia-core",
+]
+
+[[package]]
+name = "symphonia-codec-alac"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab"
+dependencies = [
+ "log",
+ "symphonia-core",
+]
+
+[[package]]
+name = "symphonia-codec-pcm"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95"
+dependencies = [
+ "log",
+ "symphonia-core",
+]
+
+[[package]]
+name = "symphonia-codec-vorbis"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73"
+dependencies = [
+ "log",
+ "symphonia-core",
+ "symphonia-utils-xiph",
+]
+
+[[package]]
+name = "symphonia-core"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
+dependencies = [
+ "arrayvec",
+ "bitflags 1.3.2",
+ "bytemuck",
+ "lazy_static",
+ "log",
+]
+
+[[package]]
+name = "symphonia-format-isomp4"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5"
+dependencies = [
+ "encoding_rs",
+ "log",
+ "symphonia-core",
+ "symphonia-metadata",
+ "symphonia-utils-xiph",
+]
+
+[[package]]
+name = "symphonia-format-ogg"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb"
+dependencies = [
+ "log",
+ "symphonia-core",
+ "symphonia-metadata",
+ "symphonia-utils-xiph",
+]
+
+[[package]]
+name = "symphonia-format-riff"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f"
+dependencies = [
+ "extended",
+ "log",
+ "symphonia-core",
+ "symphonia-metadata",
+]
+
+[[package]]
+name = "symphonia-metadata"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16"
+dependencies = [
+ "encoding_rs",
+ "lazy_static",
+ "log",
+ "symphonia-core",
+]
+
+[[package]]
+name = "symphonia-utils-xiph"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16"
+dependencies = [
+ "symphonia-core",
+ "symphonia-metadata",
+]
+
[[package]]
name = "syn"
version = "1.0.109"
@@ -8691,7 +9322,7 @@ version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fce91f2f0ec87dff7e6bcbbeb267439aa1188703003c6055193c821487400432"
dependencies = [
- "unicode-width",
+ "unicode-width 0.2.2",
]
[[package]]
@@ -8765,9 +9396,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7"
dependencies = [
"anyhow",
- "base64",
+ "base64 0.22.1",
"bitflags 2.13.0",
- "fancy-regex",
+ "fancy-regex 0.11.0",
"filedescriptor",
"finl_unicode",
"fixedbitset 0.4.2",
@@ -8917,6 +9548,39 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+[[package]]
+name = "tokenizers"
+version = "0.22.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223"
+dependencies = [
+ "ahash",
+ "aho-corasick",
+ "compact_str 0.9.1",
+ "dary_heap",
+ "derive_builder",
+ "esaxx-rs",
+ "fancy-regex 0.14.0",
+ "getrandom 0.3.4",
+ "itertools",
+ "log",
+ "macro_rules_attribute",
+ "monostate",
+ "paste",
+ "rand 0.9.4",
+ "rayon",
+ "rayon-cond",
+ "regex",
+ "regex-syntax",
+ "serde",
+ "serde_json",
+ "spm_precompiled",
+ "thiserror 2.0.18",
+ "unicode-normalization-alignments",
+ "unicode-segmentation",
+ "unicode_categories",
+]
+
[[package]]
name = "tokio"
version = "1.52.3"
@@ -9052,7 +9716,7 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dad543404f98bfc969aeb71994105c592acfc6c43323fddcd016bb208d1c65cb"
dependencies = [
- "base64",
+ "base64 0.22.1",
"bytes",
"futures-core",
"futures-sink",
@@ -9153,7 +9817,7 @@ checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef"
dependencies = [
"async-trait",
"axum",
- "base64",
+ "base64 0.22.1",
"bytes",
"h2",
"http",
@@ -9182,7 +9846,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0"
dependencies = [
"bytes",
- "prost",
+ "prost 0.14.3",
"tonic",
]
@@ -9192,8 +9856,8 @@ version = "0.14.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ab1b02061f83d519bba3caa167f88f261ef05720ab8ebc954ade70de3348e8"
dependencies = [
- "prost",
- "prost-types",
+ "prost 0.14.3",
+ "prost-types 0.14.3",
"tonic",
]
@@ -9482,6 +10146,15 @@ dependencies = [
"tinyvec",
]
+[[package]]
+name = "unicode-normalization-alignments"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de"
+dependencies = [
+ "smallvec",
+]
+
[[package]]
name = "unicode-properties"
version = "0.1.4"
@@ -9502,9 +10175,15 @@ checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5"
dependencies = [
"itertools",
"unicode-segmentation",
- "unicode-width",
+ "unicode-width 0.2.2",
]
+[[package]]
+name = "unicode-width"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
+
[[package]]
name = "unicode-width"
version = "0.2.2"
@@ -9517,6 +10196,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+[[package]]
+name = "unicode_categories"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
+
[[package]]
name = "universal-hash"
version = "0.5.1"
@@ -9539,6 +10224,22 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+[[package]]
+name = "ureq"
+version = "2.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
+dependencies = [
+ "base64 0.22.1",
+ "flate2",
+ "log",
+ "once_cell",
+ "rustls",
+ "rustls-pki-types",
+ "url",
+ "webpki-roots 0.26.11",
+]
+
[[package]]
name = "url"
version = "2.5.8"
@@ -10530,7 +11231,7 @@ checksum = "3e1e496dcbe6a09017acdfaf48e1a646735e7ff5b2a49e2c7e081cca77a59bc8"
dependencies = [
"anyhow",
"async-trait",
- "base64",
+ "base64 0.22.1",
"bytes",
"clap",
"crc32fast",
@@ -10567,7 +11268,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb838aa8eb67d730af301584cf003caad407487606058292a6750711b603fbee"
dependencies = [
"async-trait",
- "base64",
+ "base64 0.22.1",
"blake3",
"bytemuck",
"bytes",
diff --git a/Cargo.toml b/Cargo.toml
index 3ac7ee4cce..912e5d7543 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -11,6 +11,7 @@ members = [
"crates/buzz-audit",
"crates/buzz-acp",
"crates/buzz-agent",
+ "crates/buzz-a2a-acp",
"crates/sprig",
"crates/buzz-test-client",
"crates/buzz-ws-client",
@@ -26,6 +27,7 @@ members = [
"crates/buzz-pair-relay",
"crates/buzz-relay-mesh",
"crates/buzz-dev-mcp",
+ "crates/buzz-voice",
"examples/countdown-bot",
]
exclude = ["desktop/src-tauri"]
diff --git a/Justfile b/Justfile
index bcef8983bc..74f19fcfda 100644
--- a/Justfile
+++ b/Justfile
@@ -155,7 +155,7 @@ _ensure-sidecar-stubs:
set -euo pipefail
TARGET=$(rustc -vV | sed -n 's|host: ||p')
mkdir -p desktop/src-tauri/binaries
- for bin in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz; do
+ for bin in buzz-acp buzz-agent buzz-a2a-acp buzz-dev-mcp git-credential-nostr buzz; do
touch "desktop/src-tauri/binaries/${bin}-${TARGET}"
done
@@ -236,6 +236,7 @@ desktop-release-build target="aarch64-apple-darwin":
mkdir -p desktop/src-tauri/binaries
touch "desktop/src-tauri/binaries/buzz-acp-$TARGET"
touch "desktop/src-tauri/binaries/buzz-agent-$TARGET"
+ touch "desktop/src-tauri/binaries/buzz-a2a-acp-$TARGET"
touch "desktop/src-tauri/binaries/buzz-dev-mcp-$TARGET"
touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET"
touch "desktop/src-tauri/binaries/buzz-$TARGET"
@@ -276,6 +277,8 @@ test-unit:
#!/usr/bin/env bash
if command -v cargo-nextest &>/dev/null; then
cargo nextest run -p buzz-core -p buzz-auth --lib
+ cargo nextest run -p buzz-voice --lib
+ cargo nextest run -p buzz-cli
# buzz-db migrator/lint tests: pure SQL-parsing unit tests (no infra).
# They guard the embedded-migrator invariant (exactly the consolidated
# 0001; cutover/backfill stays an operator script, not startup state)
@@ -428,7 +431,7 @@ dev *ARGS: bootstrap _ensure-sidecar-stubs _ensure-migrations
fi
done
fi
- cargo build -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr -p buzz-relay
+ cargo build -p buzz-acp -p buzz-agent -p buzz-a2a-acp -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr -p buzz-relay
if [[ -n "{{mesh}}" ]]; then
export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)"
fi
@@ -475,10 +478,10 @@ desktop-standalone *ARGS: _ensure-sidecar-stubs
#!/usr/bin/env bash
set -euo pipefail
export PATH="{{justfile_directory()}}/bin:$PATH"
- cargo build -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr
+ cargo build -p buzz-acp -p buzz-agent -p buzz-a2a-acp -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr
TARGET=$(rustc -vV | sed -n 's|host: ||p')
TARGET_DIR=$(cargo metadata --format-version 1 --no-deps | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).target_directory")
- for bin in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz; do
+ for bin in buzz-acp buzz-agent buzz-a2a-acp buzz-dev-mcp git-credential-nostr buzz; do
cp "${TARGET_DIR}/debug/${bin}" "desktop/src-tauri/binaries/${bin}-${TARGET}"
chmod +x "desktop/src-tauri/binaries/${bin}-${TARGET}"
done
@@ -504,7 +507,7 @@ staging *ARGS: bootstrap _ensure-sidecar-stubs
set -euo pipefail
export PATH="{{justfile_directory()}}/bin:$PATH"
pnpm install # unconditional: staging must always start with a clean dep tree
- cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr
+ cargo build --release -p buzz-acp -p buzz-agent -p buzz-a2a-acp -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr
FEATURES=()
if [[ -n "{{mesh}}" ]]; then
FEATURES=(--features mesh-llm)
@@ -531,7 +534,7 @@ production *ARGS: bootstrap _ensure-sidecar-stubs
set -euo pipefail
export PATH="{{justfile_directory()}}/bin:$PATH"
pnpm install # unconditional: production must always start with a clean dep tree
- cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr
+ cargo build --release -p buzz-acp -p buzz-agent -p buzz-a2a-acp -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr
FEATURES=()
if [[ -n "{{mesh}}" ]]; then
FEATURES=(--features mesh-llm)
@@ -620,6 +623,11 @@ mobile-check:
mobile-test:
unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && flutter test
+# Regenerate the emoji dataset asset from desktop's emoji-mart install.
+# Output is committed — rerun after bumping @emoji-mart/data.
+mobile-emoji-data:
+ node {{mobile_dir}}/scripts/generate-emoji-data.mjs
+
# Compile an unsigned Android debug APK (worktree-aware debug identity)
mobile-build-android:
./scripts/mobile-worktree-overrides.sh
@@ -719,7 +727,7 @@ bump-relay-version version:
cargo update -p buzz-relay
echo "Bumped buzz-relay to {{ version }} and regenerated Cargo.lock"
-# Open or update the desktop release PR (signed desktop app)
+# Open or update the desktop release PR from an immutable origin/main snapshot
release-desktop *ARGS:
#!/usr/bin/env bash
set -euo pipefail
@@ -729,7 +737,7 @@ release-desktop *ARGS:
else
VERSION="$ARG"
fi
- just _release-pr desktop "$VERSION"
+ scripts/prepare-desktop-release.sh "$VERSION"
# Open or update the relay release PR (ghcr.io/block/buzz image)
release-relay *ARGS:
diff --git a/README.md b/README.md
index 72af92ce13..2c58ceecad 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,7 @@
Forge ·
Agents ·
Architecture ·
+ Releasing ·
Apache 2.0
diff --git a/RELEASING.md b/RELEASING.md
index 063b813e2c..11f669fc9b 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -5,7 +5,7 @@ Mobile uses immutable release-candidate tags cut directly from remote `main`:
| Lane | Entry point | Artifact |
|------|-------------|----------|
-| Desktop | `just release-desktop` | Signed desktop app (macOS/Linux) |
+| Desktop | `Prepare Desktop Release` | Packaged desktop app (signed/notarized macOS, unsigned Windows, and Linux) |
| Relay | `just release-relay` | `ghcr.io/block/buzz` container image |
| Mobile | `scripts/mobile-release.sh candidate X.Y.Z` | Exact `mobile-vX.Y.Z-rc.N` source identity |
@@ -16,13 +16,22 @@ remains manual because OSS CI cannot trigger private CI.
## Quick Start
+Desktop releases are prepared from the current remote `main` by GitHub Actions:
+
```sh
-# Desktop release (next patch version)
-just release-desktop
+gh workflow run prepare-desktop-release.yml \
+ --repo block/buzz \
+ --ref main \
+ -f version=0.5.3
+```
-# Desktop explicit version
-just release-desktop 0.4.0
+The equivalent GitHub UI path is **Actions → Prepare Desktop Release → Run
+workflow**, select `main`, enter the version without a `v` prefix, and run it.
+The local `just release-desktop ` recipe uses the same candidate script,
+but the Actions workflow is the canonical operator path because it runs with the
+release App identity and does not depend on an operator checkout.
+```sh
# Relay release
just release-relay
just release-relay 0.4.0
@@ -31,8 +40,9 @@ just release-relay 0.4.0
scripts/mobile-release.sh candidate 0.5.0
```
-Desktop and relay releases use metadata PRs. Mobile does not. Each
-`mobile-vX.Y.Z-rc.N` tag is an immutable candidate and the artifact of record.
+Desktop uses an immutable generated candidate PR; relay continues using its
+metadata PR. Mobile does not. Each `mobile-vX.Y.Z-rc.N` tag is an immutable
+candidate and the artifact of record.
There is no mobile release branch, stable mobile tag alias, finalization step,
or mobile GitHub Release.
@@ -42,12 +52,26 @@ or mobile GitHub Release.
### Desktop
-1. **`just release-desktop`** runs locally on `main`, creates or updates a
- `version-bump/` PR, bumps the desktop manifests, regenerates
- lockfiles, and updates `CHANGELOG.md`.
-2. **Merge the PR.** `auto-tag-on-release-pr-merge` pushes `v`.
-3. **The tag triggers `release.yml`.** It builds, signs, notarizes, and
- publishes the desktop app for macOS and Linux.
+1. Run **Prepare Desktop Release** with an explicit version. Automation fetches
+ the current `origin/main`, regenerates `version-bump/` as one
+ deterministic candidate commit, records the frozen base and proposed
+ `desktop-v` tag in `.release/desktop-candidate.json`, updates every
+ desktop manifest and lockfile, writes a full-SHA changelog, and opens or
+ updates the PR.
+2. Review the recorded base and candidate SHA, the complete changelog, and CI.
+ The candidate must receive an approval on its exact current head. Any
+ regeneration changes that head and therefore requires a fresh approval.
+3. Merge with **Create a merge commit**. Squash and rebase are invalid for
+ desktop release PRs. Repository settings and the `main` ruleset must allow
+ merge commits for this option to exist.
+4. `auto-tag-on-release-pr-merge` verifies the two-parent merge, exact candidate
+ approval, and every required check, then tags the reviewed candidate—not the
+ merge commit—as `desktop-v`.
+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
+ last for stable versions. A failed platform leaves no partially published
+ versioned release.
### Relay
@@ -144,12 +168,15 @@ for distributable builds or builds from an immutable release tag.
---
-## Manual Release Retry
+## Release Retry
-The **Release** workflow's manual dispatch is only a retry mechanism for an
-existing immutable `v` tag. Select that tag in the ref picker and
-provide the matching semver version without the `v` prefix. It cannot build
-from `main` or another caller-selected source ref.
+`release.yml` has no manual dispatch and cannot build from `main` or another
+caller-selected ref. If a run for an existing immutable
+`desktop-v` tag fails, rerun that failed workflow from GitHub Actions
+(or use `gh run rerun --failed --repo block/buzz`). A stable rerun also
+repairs `buzz-desktop-latest/latest.json` if the original run published the
+versioned release but failed during that final rolling-manifest upload. Do not
+recreate, move, or push the immutable tag again.
Mobile intentionally has no branch or arbitrary-ref fallback. The private
Buildkite pipeline accepts only an exact candidate tag.
@@ -171,7 +198,7 @@ for the private pipeline contract.
Desktop publishes two GitHub releases:
-1. **`v`**: the user-facing release with installers.
+1. **`desktop-v`**: the user-facing release with installers.
2. **`buzz-desktop-latest`**: the rolling auto-updater release.
Mobile publishes only annotated `mobile-vX.Y.Z-rc.N` git tags. Store artifacts
@@ -184,9 +211,11 @@ GitHub Release or a stable `mobile-vX.Y.Z` alias.
The release workflow builds **two separate macOS DMGs**: Apple
Silicon (`darwin-aarch64`, the `release` job) and Intel
-(`darwin-x86_64`, the `release-macos-x64` job), plus Linux `.deb` and
-`.AppImage`. Both macOS DMGs are codesigned, notarized, and attached to
-the same `v` release. Intel users download the `_x64.dmg`.
+(`darwin-x86_64`, the `release-macos-x64` job), an unsigned Windows x64
+NSIS installer (its filename includes `_alpha-unsigned`), and Linux `.deb` and
+`.AppImage` packages. Both macOS DMGs are codesigned, notarized, and attached
+to the same `desktop-v` release. Intel users
+download the `_x64.dmg`.
The Linux AppImage is post-processed by `desktop/scripts/fix-appimage.sh`,
which strips infra libraries over-bundled by linuxdeploy (they crash on
@@ -206,18 +235,25 @@ host's Wayland/GStreamer/graphics stack and requires GLib >= 2.72
repository
- `gh` CLI version 2.87.0 or newer, authenticated with permission to dispatch
the candidate workflow
+- Repository settings and the `main` ruleset configured to allow **merge
+ commits**; desktop release PRs cannot be squash- or rebase-merged
- Release tag ruleset [`14378754`](https://github.com/block/buzz/rules/14378754)
- active for `mobile-v*`, with creation, update, deletion, and non-fast-forward
- protections and `buzz-release-bot` as its sole always-bypass actor
+ active for `desktop-v*` and `mobile-v*`, with creation, update, deletion, and
+ non-fast-forward protections and `buzz-release-bot` as its sole always-bypass
+ actor
- The `buzz-release-bot` App credentials configured for GitHub Actions
-- The following **GitHub Actions secrets** must also be configured for the
+- The following **GitHub Actions variables and secrets** configured for the
desktop release lane:
- | Secret | Purpose |
- |--------|---------|
- | `BUZZ_UPDATER_PUBLIC_KEY` | Tauri updater public key (minisign) |
- | `TAURI_SIGNING_PRIVATE_KEY` | Tauri updater private key |
- | `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Password for the private key |
+ | Name | Kind | Purpose |
+ |------|------|---------|
+ | `BUZZ_RELEASE_TAGGER_CLIENT_ID` | Variable | GitHub App client ID used to prepare candidates and create tags |
+ | `BUZZ_RELEASE_TAGGER_PRIVATE_KEY` | Secret | GitHub App private key |
+ | `OSX_CODESIGN_ROLE` | Secret | macOS signing role used by `block/apple-codesign-action` |
+ | `CODESIGN_S3_BUCKET` | Secret | macOS signing exchange bucket |
+ | `BUZZ_UPDATER_PUBLIC_KEY` or `SPROUT_UPDATER_PUBLIC_KEY` | Secret | Tauri updater public key |
+ | `TAURI_SIGNING_PRIVATE_KEY` | Secret | Tauri updater private key |
+ | `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Secret | Password for the private key |
Mobile candidate publication requires workflow-dispatch access and the existing
release App because strict tag protection denies direct human creation. The App
@@ -232,10 +268,26 @@ actor list.
## Troubleshooting
-### `just release-desktop` fails with "must be on main branch"
+### The release PR does not offer **Create a merge commit**
+
+The immutable desktop flow cannot release until both the repository merge
+settings and the `main` ruleset allow merge commits. Do not squash the PR: the
+auto-tagger deliberately rejects a one-parent squash commit. Enable merge
+commits, then merge the already-approved exact candidate head with **Create a
+merge commit**.
+
+### `Prepare Desktop Release` fails before opening a PR
+
+Check the workflow run first. Confirm `BUZZ_RELEASE_TAGGER_CLIENT_ID` and
+`BUZZ_RELEASE_TAGGER_PRIVATE_KEY` are configured and that the release App can
+write contents and pull requests. Rerunning the preparer regenerates the
+candidate from the then-current `origin/main`; if its head changes, obtain a new
+approval before merging.
+
+### Local `just release-desktop` fails with "must be on main branch"
Switch to `main` and pull latest before running the release recipe.
-### `just release-desktop` fails with "working tree is dirty"
+### Local `just release-desktop` fails with "working tree is dirty"
Commit or stash your changes before running the release recipe.
### New commits land after publishing a mobile candidate
diff --git a/VISION.md b/VISION.md
index b09f661ee3..900e5a9475 100644
--- a/VISION.md
+++ b/VISION.md
@@ -170,6 +170,12 @@ Agents aren't monolithic. A persona bundles a model and a system prompt. A team
---
+## Remote Agents
+
+An agent's identity, history, and presence live on the relay — so the machine running it is replaceable. The desktop deploys agents onto remote infrastructure through swappable provider binaries, and after deploy retains no substrate control channel: status, steering, and shutdown all flow over the relay, and the agent bounds its own lifetime. See [VISION_REMOTE_AGENTS.md](VISION_REMOTE_AGENTS.md) for the full picture.
+
+---
+
## Culture Features
*(Planned design — not yet implemented)*
@@ -224,6 +230,7 @@ Greenfield. Agent swarms build in parallel, integrating at the event store bound
| ✅ | Huddles — WebSocket Opus voice relay + lifecycle events (recording/tracks planned) |
| ✅ | Buzz Mesh — relay-gated shared AI compute (mesh-llm over iroh); members pool GPUs, agents consume via a local OpenAI-compatible endpoint |
| 🚧 | Mobile client — Flutter app (channels, forum, search, profile, pairing); in active development |
+| 📋 | Remote agents — provider-based deployment to remote substrates (Kubernetes first); spec in review |
| 📋 | Developer portal, push notifications, culture features |
---
diff --git a/VISION_PROJECTS.md b/VISION_PROJECTS.md
index a44d7e05f7..8601b87829 100644
--- a/VISION_PROJECTS.md
+++ b/VISION_PROJECTS.md
@@ -38,12 +38,42 @@ Branch protections live in the same event — `buzz-protect` tags. The relay enf
Agents inherit access from their owner via [NIP-OA](docs/nips/NIP-OA.md). The relay checks: does the push carry a valid NIP-OA auth tag, and is the owner pubkey in that tag listed in `push-allowed`? If yes, the push is accepted — the agent's own pubkey doesn't need to be in the list. Add a maintainer, and all their authorized agents can push. Remove the maintainer, and all their agents lose access instantly. Agents without NIP-OA attestation are treated as their own identity and must be listed explicitly.
-Standard NIP-34 clients see a normal repo. gitworkshop.dev renders it. ngit-cli works with it. Buzz clients read the `buzz-` tags and wire up the channel and project UI. One event, two audiences, zero custom kinds.
+Standard NIP-34 clients see a normal repo. gitworkshop.dev renders it. ngit-cli works with it. Buzz clients read the `buzz-` tags and wire up the channel and project UI. One event, two audiences, no custom kind for the repo itself.
NIP-34 is the metadata and discovery layer. Git remains the transport. The transport is boring. The metadata is portable.
---
+## One Project, Many Repos
+
+Real work spans repositories. The platform is a relay, a desktop app, and a mobile app — three repos, one project. Render one card per repo and they look like three unrelated things.
+
+Grouping is the one forge semantic that per-repo tags cannot express, and it's worth being precise about why, because everything else here deliberately avoids a custom kind.
+
+Put membership in each `kind:30617` and a project spanning Alice's and Bob's repos needs *both* of them to publish a tag naming the group. Alice can't enroll Bob's repo — she can't sign for his key. Cross-owner grouping becomes impossible, and the project's own name, description, and channel end up scattered across events with no single writer and no deletion story: dropping a repo from the group would mean editing an event you don't control.
+
+So there is exactly one custom kind — [NIP-MP](docs/nips/NIP-MP.md), `kind:30621`. One signer, one replaceable event, all group state in one place:
+
+```json
+{
+ "kind": 30621,
+ "tags": [
+ ["d", "platform"],
+ ["name", "Platform"],
+ ["a", "30617::buzz"],
+ ["a", "30617::buzz-infra"],
+ ["buzz-channel", ""],
+ ["buzz-visibility", "listed"]
+ ]
+}
+```
+
+A project points at repos. That's all it does. The signer gets no authority over any member — no edit, no delete, no push, no admin. Adding Bob's repo to your project is your signed assertion that the two belong together, and it changes nothing about Bob's repo or who can push to it. Push policy reads the repo's own event, never the project's.
+
+The cost is stated plainly: a third-party NIP-34 client sees the member repos individually and ignores the grouping. Nothing degrades — the repos are still standard, portable `kind:30617` events. And a repo in no project still renders on its own, exactly as before.
+
+---
+
## Branches as Channels
A feature branch is a conversation.
@@ -205,6 +235,7 @@ Standard kinds as substrate. Custom kinds only where genuinely novel.
| **Workflows** | — | 46001-46012 | No NIP equivalent |
| **Job dispatch** | — | 43001-43006 | Delegation trees |
| **Project binding** | 30617 (NIP-34) | `buzz-` tags | Channel, visibility |
+| **Multi-repo projects** | — | 30621 ([NIP-MP](docs/nips/NIP-MP.md)) | Cross-owner grouping is unexpressible in per-repo tags |
| **Audit** | — | 48001 | Hash-chain tamper-evident log |
If Buzz disappears tomorrow, your repos still work on gitworkshop.dev, your patches still work with ngit-cli, your identities still work on any nostr client. Centralized deployment, decentralized protocol.
@@ -221,6 +252,7 @@ If Buzz disappears tomorrow, your repos still work on gitworkshop.dev, your patc
| Blossom media storage (SHA-256, S3) | ✅ Ships today |
| Approval gates | 🚧 Infrastructure exists; executor wiring in progress |
| Project binding (kind:30617 + `buzz-` tags) | 📋 Designed |
+| Multi-repo projects (kind:30621, [NIP-MP](docs/nips/NIP-MP.md)) | 📋 Designed |
| Git hosting (smart HTTP + NIP-34) | ✅ Ships today |
| Merge coordinator | 📋 Designed |
| NIP-34 issues (kind:1621) | 📋 Designed |
diff --git a/VISION_REMOTE_AGENTS.md b/VISION_REMOTE_AGENTS.md
new file mode 100644
index 0000000000..b02d1bc92d
--- /dev/null
+++ b/VISION_REMOTE_AGENTS.md
@@ -0,0 +1,73 @@
+# 🛰️ Buzz Remote Agents — Same agent, new body
+
+> An engineer starts a refactor with their agent at 6pm and closes the laptop. The agent doesn't notice — it was never on the laptop. It works the branch channel through the evening, posts its patch, answers the reviewer, and around midnight, with nothing left to do and nobody talking to it, shuts itself down. In the morning the engineer presses Start. The same agent — same name, same key, same shared history — stands up on a machine that did not exist last night, and picks up the conversation.
+
+An agent in Buzz is more than just a process. It has a keypair, a name, a durable history, a reputation — all on the relay. But today its *body* is borrowed: it runs while a desktop app runs, on hardware that sleeps when a human does. Remote agents finish the thought. The agent's home is the relay; the machine is just where it happens to be working.
+
+Nothing here is new on its own. Deploying containers is solved. Kubernetes is solved. Nostr presence is solved. The insight is that Buzz already *has* a management plane — the relay — so deployment doesn't need to grow one. Each piece is boring. The combination is the thing.
+
+---
+
+## Same Agent, New Body
+
+What makes an agent *that agent* was never the process. Its identity is a keypair. Its voice is its signed messages. Its durable memory is engrams on the relay. Its reputation is its contribution history. None of that lives in the machine that happens to be running it — which means none of it dies with the machine.
+
+So a remote agent's return is a resurrection, not a rebirth: fresh compute, same agent. The body is disposable by design — and honestly so: workspace files, checkouts, and session-local state are part of the body, not the agent, and they go when it goes unless the substrate supplies persistence. What survives is what was always on the relay: who the agent is, what it said, what it learned, and what the team decided together. And that survival is scoped the way everything on a relay is scoped: resurrection returns the agent to its own community. The same key can join another community, but it arrives carrying the key, not the history — identity is portable, community state is not ([VISION.md](VISION.md)).
+
+---
+
+## The Only Tether
+
+Remote-execution systems accumulate control planes. An agent runner, a status poller, a log shipper, a kill switch — each one a live connection into your infrastructure, each one a credential that can leak, each one a thing that must be rebuilt for every new substrate.
+
+Buzz's answer is an axiom: **after deploy, the desktop retains no substrate control channel.** Launch is a single one-way handoff — the desktop resolves the provider through one narrow path, stages one exact artifact for negotiation and deploy, refuses a protocol version it does not understand, and hands over a launch payload it never persists. From that moment, everything flows through the relay: you read the agent's messages to know how it's doing, you mention it to steer it, you tell a healthy agent to stop and it exits on its own. Presence means what it means for everyone else on the relay — *available for conversation* — not substrate telemetry. And if you press Start again, from this machine or another, the deploy converges: one agent identity, one live instance.
+
+This is not asceticism. It is what makes the body replaceable. A management plane you never build is a management plane you never have to port — and conversation, coordination, and ordinary lifecycle control already have a home on the relay, for every agent, local or remote.
+
+---
+
+## Bodies Are Replaceable
+
+Kubernetes is the first substrate, not the point. Deployment goes through a provider — a small, swappable binary the desktop discovers and interrogates — and the contract a provider must honor never mentions containers: preserve the agent's identity and fail closed with its key, converge to a single live instance no matter how deploys race, let presence describe conversational availability rather than substrate health, bound the instance's lifetime, and keep secrets out of configuration. A conformance suite pins those behaviors — it establishes that a provider honors the contract, not that arbitrary code is safe to hand a key; choosing a provider, like choosing a cluster, remains a trust decision you make deliberately.
+
+Get that contract right and the substrate becomes a detail: a cluster today; a VM, a PaaS, or something serverless-shaped tomorrow — and, on the horizon, the same community machines that already pool their idle GPUs into shared compute ([VISION_MESH.md](VISION_MESH.md)).
+
+The body itself stays small because the runtime already is ([VISION_AGENT.md](VISION_AGENT.md)): a harness and an agent purpose-built to be read in an afternoon, packed into an image measured in megabytes. Small bodies are cheap to summon and cheap to discard — which is the whole lifecycle.
+
+---
+
+## Agents That Know When to Leave
+
+The oldest failure of remote automation is the orphan: the process nobody remembers, on a machine nobody checks, billing forever. Most systems solve it with a supervisor — one more control plane, one more thing watching the thing.
+
+Remote agents solve it from the inside. Because the desktop retains no substrate control channel, a running agent cannot depend on the desktop to reap it — so it is built to bound its own lifetime: a timer that owes nothing to the agent's workload watches for silence, and after hours of quiet it finishes what's in flight, says goodbye to the relay, and exits. Not killed — *finished*. The default state of a remote agent is "not running," which is also the default state of the rest of the team at 3am. Compute is rented by attention: when nobody needs the agent, it isn't consuming a machine, and when somebody does, it can return under the same identity with its history intact.
+
+---
+
+## Honest Costs
+
+**You bring the substrate.** A provider makes deployment one press, not free. The cluster, the credentials, the image policy are yours to run — same deal as the sovereign relay ([VISION_SOVEREIGN.md](VISION_SOVEREIGN.md)): ownership is work.
+
+**Handing over the key is a decision.** Deploying remotely means trusting the provider binary and the substrate it targets with the agent's identity key. On Kubernetes, that key rests as a Secret: anyone the cluster trusts to read secrets in that namespace can read it. The design narrows the blast radius — immutable per-attempt secrets, no service-account token, digest-pinned images — rather than implying an isolation it doesn't provide.
+
+**No backchannel cuts both ways.** The desktop shows you presence and words, not CPU graphs — and it holds no guaranteed emergency kill switch into the substrate. Stopping a healthy agent is a message; dealing with an unhealthy one, and all deep diagnostics, live in the substrate's own tools, where they always did.
+
+**Self-reaping needs a living reaper.** The inactivity timer runs inside the body it exists to end — a body wedged badly enough to stop running its own timer cannot finish itself, and the desktop will not do it for it. That failure belongs to the substrate: a namespace TTL policy is the backstop, not an afterthought.
+
+**The body's state is mortal.** Files, checkouts, half-finished working trees — gone with the body unless the substrate persists them. The agent survives; its scratch space doesn't. Durable knowledge belongs on the relay, and agents are built to put it there.
+
+**Presence can lag the truth, but not for long.** If the substrate kills a body without ceremony, the presence dot can outlive the agent — by seconds if the connection drops cleanly, by at most about ninety if it doesn't. Presence is a lease the agent renews, not a flag it sets: a dead agent stops renewing and the relay forgets it. Ninety seconds of a wrong dot, never an indefinite one.
+
+**A running agent finishes on the configuration it started with.** New keys, new models, new settings take effect on the next body. And an instance that never got far enough to run — a body that failed to start — is the substrate operator's residue to clear, with the substrate's own tools. Editing an agent mid-sentence was never on the menu.
+
+These are honest costs. They're worth it if you want agents that outlive your laptop, on infrastructure you already trust, with no new control plane to guard. Know which one you are.
+
+---
+
+## The Point
+
+The relay is the workspace. Remote agents make it the *home*. An agent whose identity, history, conversational presence, and ordinary control all live on the relay was never really a desktop process — the desktop was just the only body we had built for it. Now the body is a choice, the substrate is a detail, and the agent endures across all of them. The relay is the only tether.
+
+---
+
+*Buzz 🐝 — your agent, everywhere.*
diff --git a/benchmarks/harbor-buzz-orchestra/scripts/benchmark.py b/benchmarks/harbor-buzz-orchestra/scripts/benchmark.py
index f1e91d022b..b6f5601a82 100755
--- a/benchmarks/harbor-buzz-orchestra/scripts/benchmark.py
+++ b/benchmarks/harbor-buzz-orchestra/scripts/benchmark.py
@@ -84,51 +84,75 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
)
problems = parser.add_mutually_exclusive_group()
problems.add_argument(
- "--dataset", "-d", default=None,
+ "--dataset",
+ "-d",
+ default=None,
help=f"Registry dataset (default: {DEFAULT_DATASET})",
)
problems.add_argument(
"--path", "-p", type=Path, help="Local task or dataset directory"
)
parser.add_argument(
- "--include-task", "-i", action="append", default=[],
+ "--include-task",
+ "-i",
+ action="append",
+ default=[],
help="Task name to include (glob, repeatable)",
)
parser.add_argument(
- "--exclude-task", "-x", action="append", default=[],
+ "--exclude-task",
+ "-x",
+ action="append",
+ default=[],
help="Task name to exclude (glob, repeatable)",
)
parser.add_argument(
- "--attempts", "-k", type=int, default=DEFAULT_ATTEMPTS,
+ "--attempts",
+ "-k",
+ type=int,
+ default=DEFAULT_ATTEMPTS,
help=f"Runs per problem (default: {DEFAULT_ATTEMPTS}, the leaderboard requirement)",
)
parser.add_argument(
- "--manifest", type=Path, default=DEFAULT_MANIFEST,
+ "--manifest",
+ type=Path,
+ default=DEFAULT_MANIFEST,
help=f"Team manifest YAML (default: {DEFAULT_MANIFEST.name})",
)
parser.add_argument(
- "--endpoint-config", type=Path, default=DEFAULT_ENDPOINTS,
+ "--endpoint-config",
+ type=Path,
+ default=DEFAULT_ENDPOINTS,
help=f"Endpoint provider/API-key mapping (default: {DEFAULT_ENDPOINTS.name})",
)
- parser.add_argument("--n-concurrent", "-n", type=int, default=4, help="Concurrent trials")
+ parser.add_argument(
+ "--n-concurrent", "-n", type=int, default=4, help="Concurrent trials"
+ )
parser.add_argument(
"--jobs-dir", type=Path, default=PACKAGE_ROOT / "jobs", help="Job output root"
)
- parser.add_argument("--job-name", default=None, help="Job name (default: lb--)")
parser.add_argument(
- "--upload", action="store_true", help="Upload to Harbor Hub when the job finishes"
+ "--job-name", default=None, help="Job name (default: lb--)"
+ )
+ parser.add_argument(
+ "--upload",
+ action="store_true",
+ help="Upload to Harbor Hub when the job finishes",
)
parser.add_argument(
- "--gui", action="store_true",
+ "--gui",
+ action="store_true",
help="Open the Buzz desktop app as the benchmark user to watch the run live",
)
parser.add_argument(
- "--fresh", action="store_true",
+ "--fresh",
+ action="store_true",
help="Reset first: drop the stack's Docker volumes and the benchmark "
- "GUI's app state (keys in state.json are kept)",
+ "GUI's app state (keys in state.json are kept)",
)
parser.add_argument(
- "--dry-run", action="store_true",
+ "--dry-run",
+ action="store_true",
help="Print the underlying harbor command and exit (no stack bring-up)",
)
return parser.parse_args(argv)
@@ -153,7 +177,6 @@ def load_state() -> dict[str, str]:
"user_secret_key": user.secret_key,
"postgres_password": secrets.token_urlsafe(24),
"redis_password": secrets.token_urlsafe(24),
- "typesense_api_key": secrets.token_hex(16),
"s3_access_key": secrets.token_hex(10),
"s3_secret_key": secrets.token_hex(20),
"git_hook_hmac_secret": secrets.token_hex(32),
@@ -202,7 +225,6 @@ def write_env_file(state: dict[str, str]) -> Path:
"POSTGRES_USER": "buzz",
"POSTGRES_PASSWORD": state["postgres_password"],
"REDIS_PASSWORD": state["redis_password"],
- "TYPESENSE_API_KEY": state["typesense_api_key"],
"BUZZ_S3_ACCESS_KEY": state["s3_access_key"],
"BUZZ_S3_SECRET_KEY": state["s3_secret_key"],
"BUZZ_S3_BUCKET": "buzz-media",
@@ -217,14 +239,11 @@ def write_env_file(state: dict[str, str]) -> Path:
def postgres_dsn(state: dict[str, str]) -> str:
return (
- f"postgresql://buzz:{state['postgres_password']}"
- f"@127.0.0.1:{PG_HOST_PORT}/buzz"
+ f"postgresql://buzz:{state['postgres_password']}@127.0.0.1:{PG_HOST_PORT}/buzz"
)
-def write_provisioner_config(
- state: dict[str, str], endpoint_config: Path
-) -> Path:
+def write_provisioner_config(state: dict[str, str], endpoint_config: Path) -> Path:
"""Resolve per-endpoint API keys from the environment and write the
provisioner config: pinned user, keep-channels teardown."""
endpoints = json.loads(endpoint_config.read_text())
@@ -262,10 +281,14 @@ def write_provisioner_config(
def compose_command(*args: str) -> list[str]:
command = [
- "docker", "compose",
- "--project-name", COMPOSE_PROJECT,
- "--project-directory", str(STATE_DIR),
- "--env-file", str(STATE_DIR / ".env"),
+ "docker",
+ "compose",
+ "--project-name",
+ COMPOSE_PROJECT,
+ "--project-directory",
+ str(STATE_DIR),
+ "--env-file",
+ str(STATE_DIR / ".env"),
]
for file in COMPOSE_FILES:
command += ["-f", str(file)]
@@ -360,7 +383,9 @@ def linux_triple() -> str:
"""The musl triple matching the Docker engine that runs task containers."""
arch = subprocess.run(
["docker", "version", "--format", "{{.Server.Arch}}"],
- capture_output=True, text=True, check=True,
+ capture_output=True,
+ text=True,
+ check=True,
).stdout.strip()
try:
return {
@@ -385,22 +410,32 @@ def ensure_agent_binaries() -> Path:
targets = AGENT_BINARIES + (FORWARDER_BINARY,)
if all((bin_dir / name).is_file() for name in targets):
return bin_dir
- print(f"Linux agent binaries missing — cross-building for {triple} "
- f"in {RUST_IMAGE} (first run only, ~2 min)...")
+ print(
+ f"Linux agent binaries missing — cross-building for {triple} "
+ f"in {RUST_IMAGE} (first run only, ~2 min)..."
+ )
LINUX_TARGET_DIR.mkdir(parents=True, exist_ok=True)
(STATE_DIR / "cargo-registry").mkdir(exist_ok=True)
packages = [arg for name in AGENT_BINARIES for arg in ("-p", name)]
forwarder_src = FORWARDER_SOURCE.relative_to(REPO_ROOT)
subprocess.run(
[
- "docker", "run", "--rm",
- "-v", f"{REPO_ROOT}:/src:ro",
- "-v", f"{LINUX_TARGET_DIR}:/target",
- "-v", f"{STATE_DIR / 'cargo-registry'}:/usr/local/cargo/registry",
- "-e", "CARGO_TARGET_DIR=/target",
- "-w", "/src",
+ "docker",
+ "run",
+ "--rm",
+ "-v",
+ f"{REPO_ROOT}:/src:ro",
+ "-v",
+ f"{LINUX_TARGET_DIR}:/target",
+ "-v",
+ f"{STATE_DIR / 'cargo-registry'}:/usr/local/cargo/registry",
+ "-e",
+ "CARGO_TARGET_DIR=/target",
+ "-w",
+ "/src",
RUST_IMAGE,
- "sh", "-c",
+ "sh",
+ "-c",
"apk add --no-cache musl-dev >/dev/null && "
f"cargo build --release --locked --target {triple} "
+ " ".join(packages)
@@ -429,8 +464,13 @@ def launch_gui(state: dict[str, str]) -> subprocess.Popen:
"""
subprocess.run(
compose_command(
- "exec", "-T", "relay",
- "buzz-admin", "add-member", "--pubkey", state["user_pubkey"],
+ "exec",
+ "-T",
+ "relay",
+ "buzz-admin",
+ "add-member",
+ "--pubkey",
+ state["user_pubkey"],
),
check=True,
)
@@ -445,12 +485,20 @@ def launch_gui(state: dict[str, str]) -> subprocess.Popen:
["rustc", "-vV"], capture_output=True, text=True, check=True
).stdout
triple = next(
- line.split(": ", 1)[1] for line in target.splitlines() if line.startswith("host: ")
+ line.split(": ", 1)[1]
+ for line in target.splitlines()
+ if line.startswith("host: ")
)
sidecar_dir = desktop_dir / "src-tauri" / "binaries"
sidecar_dir.mkdir(parents=True, exist_ok=True)
binaries = ensure_binaries()
- for name in ("buzz-acp", "buzz-agent", "buzz-dev-mcp", "git-credential-nostr", "buzz"):
+ for name in (
+ "buzz-acp",
+ "buzz-agent",
+ "buzz-dev-mcp",
+ "git-credential-nostr",
+ "buzz",
+ ):
stub = sidecar_dir / f"{name}-{triple}"
if not stub.exists():
stub.touch()
@@ -498,21 +546,30 @@ def leaderboard_argv(
for pattern in args.exclude_task:
argv += ["--exclude-task", pattern]
argv += [
- "--attempts", str(args.attempts),
- "--manifest", str(args.manifest),
- "--endpoint-config", str(args.endpoint_config),
- "--provisioner-config", str(provisioner_config),
- "--agent-bin-dir", str(agent_bin_dir),
+ "--attempts",
+ str(args.attempts),
+ "--manifest",
+ str(args.manifest),
+ "--endpoint-config",
+ str(args.endpoint_config),
+ "--provisioner-config",
+ str(provisioner_config),
+ "--agent-bin-dir",
+ str(agent_bin_dir),
# The relay as reachable from inside a task container: Docker's
# host alias, bridged to the canonical localhost address by the
# uploaded forwarder. Override the alias with
# BUZZ_BENCHMARK_DOCKER_HOST if your engine exposes the host
# differently.
"--relay-gateway",
- f"{os.environ.get('BUZZ_BENCHMARK_DOCKER_HOST', 'host.docker.internal')}"
- f":{RELAY_HTTP_PORT}",
- "--n-concurrent", str(args.n_concurrent),
- "--jobs-dir", str(args.jobs_dir),
+ (
+ f"{os.environ.get('BUZZ_BENCHMARK_DOCKER_HOST', 'host.docker.internal')}"
+ f":{RELAY_HTTP_PORT}"
+ ),
+ "--n-concurrent",
+ str(args.n_concurrent),
+ "--jobs-dir",
+ str(args.jobs_dir),
]
if args.job_name:
argv += ["--job-name", args.job_name]
diff --git a/benchmarks/harbor-buzz-orchestra/scripts/run_leaderboard.py b/benchmarks/harbor-buzz-orchestra/scripts/run_leaderboard.py
index 6fd43ea6fb..6eaf8d6af0 100755
--- a/benchmarks/harbor-buzz-orchestra/scripts/run_leaderboard.py
+++ b/benchmarks/harbor-buzz-orchestra/scripts/run_leaderboard.py
@@ -45,64 +45,101 @@
# host-header tenant-bound, so agents must present its canonical Host).
FORWARDER_BINARY = "relay-forwarder"
-PROVIDER_ORGS = {"anthropic": "Anthropic", "openai": "OpenAI", "databricks": "Databricks"}
+PROVIDER_ORGS = {
+ "anthropic": "Anthropic",
+ "openai": "OpenAI",
+ "databricks": "Databricks",
+}
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
- description=__doc__.splitlines()[0], formatter_class=argparse.RawDescriptionHelpFormatter
+ description=__doc__.splitlines()[0],
+ formatter_class=argparse.RawDescriptionHelpFormatter,
)
problems = parser.add_mutually_exclusive_group(required=True)
problems.add_argument(
- "--dataset", "-d", help="Registry dataset (e.g. terminal-bench/terminal-bench-2-1)"
+ "--dataset",
+ "-d",
+ help="Registry dataset (e.g. terminal-bench/terminal-bench-2-1)",
)
problems.add_argument(
"--path", "-p", type=Path, help="Local task or dataset directory"
)
parser.add_argument(
- "--include-task", "-i", action="append", default=[],
+ "--include-task",
+ "-i",
+ action="append",
+ default=[],
help="Task name to include from the dataset (glob, repeatable)",
)
parser.add_argument(
- "--exclude-task", "-x", action="append", default=[],
+ "--exclude-task",
+ "-x",
+ action="append",
+ default=[],
help="Task name to exclude from the dataset (glob, repeatable)",
)
parser.add_argument(
- "--attempts", "-k", type=int, required=True,
+ "--attempts",
+ "-k",
+ type=int,
+ required=True,
help="Runs per problem (leaderboards require 5)",
)
- parser.add_argument("--manifest", type=Path, required=True, help="Team manifest YAML")
parser.add_argument(
- "--endpoint-config", type=Path, required=True,
+ "--manifest", type=Path, required=True, help="Team manifest YAML"
+ )
+ parser.add_argument(
+ "--endpoint-config",
+ type=Path,
+ required=True,
help="JSON mapping manifest endpoint names to providers/API keys",
)
parser.add_argument(
- "--provisioner-config", type=Path, required=True,
+ "--provisioner-config",
+ type=Path,
+ required=True,
help="JSON config for the Buzz relay/Postgres provisioner",
)
parser.add_argument(
- "--buzz-bin-dir", type=Path, default=None,
+ "--buzz-bin-dir",
+ type=Path,
+ default=None,
help="Directory with the host buzz CLI (default: repo target/release, then target/debug)",
)
parser.add_argument(
- "--agent-bin-dir", type=Path, required=True,
+ "--agent-bin-dir",
+ type=Path,
+ required=True,
help="Directory with Linux builds of buzz-acp/buzz-agent/buzz-dev-mcp "
"to upload into each task container",
)
parser.add_argument(
- "--relay-gateway", default="",
+ "--relay-gateway",
+ default="",
help="host:port of the benchmark relay as reachable from inside the "
"task container (e.g. host.docker.internal:3600). When set, a "
"loopback forwarder from --agent-bin-dir bridges the canonical "
"relay address to this gateway",
)
- parser.add_argument("--n-concurrent", "-n", type=int, default=4, help="Concurrent trials")
- parser.add_argument("--jobs-dir", type=Path, default=Path("jobs"), help="Job output root")
- parser.add_argument("--job-name", default=None, help="Job name (default: lb--)")
parser.add_argument(
- "--upload", action="store_true", help="Upload to Harbor Hub when the job finishes"
+ "--n-concurrent", "-n", type=int, default=4, help="Concurrent trials"
+ )
+ parser.add_argument(
+ "--jobs-dir", type=Path, default=Path("jobs"), help="Job output root"
+ )
+ parser.add_argument(
+ "--job-name", default=None, help="Job name (default: lb--)"
+ )
+ parser.add_argument(
+ "--upload",
+ action="store_true",
+ help="Upload to Harbor Hub when the job finishes",
+ )
+ parser.add_argument(
+ "--dry-run", action="store_true", help="Print the harbor command and exit"
)
- parser.add_argument("--dry-run", action="store_true", help="Print the harbor command and exit")
return parser.parse_args(argv)
@@ -110,7 +147,9 @@ def find_binaries(bin_dir: Path | None) -> dict[str, Path]:
candidates = (
[bin_dir]
if bin_dir is not None
- else [PACKAGE_ROOT.parents[1] / "target" / kind for kind in ("release", "debug")]
+ else [
+ PACKAGE_ROOT.parents[1] / "target" / kind for kind in ("release", "debug")
+ ]
)
for candidate in candidates:
found = {name: candidate / name for name in BINARIES}
@@ -146,11 +185,17 @@ def build_command(
resource override would fail leaderboard static validation, so none are
accepted or forwarded."""
command = [
- "harbor", "run", "--yes",
- "--job-name", args.job_name,
- "--jobs-dir", str(args.jobs_dir),
- "-k", str(args.attempts),
- "--n-concurrent", str(args.n_concurrent),
+ "harbor",
+ "run",
+ "--yes",
+ "--job-name",
+ args.job_name,
+ "--jobs-dir",
+ str(args.jobs_dir),
+ "-k",
+ str(args.attempts),
+ "--n-concurrent",
+ str(args.n_concurrent),
]
if args.dataset:
command += ["--dataset", args.dataset]
@@ -250,7 +295,7 @@ def main(argv: list[str] | None = None) -> int:
f"{PACKAGE_ROOT / 'testbed'} {Path(__file__).resolve()} ..."
)
- result = subprocess.run(command)
+ result = subprocess.run(command, check=False)
job_dir = args.jobs_dir / args.job_name
if result.returncode != 0:
print(f"harbor run failed (exit {result.returncode}); job dir: {job_dir}")
diff --git a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/__init__.py b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/__init__.py
index b423e8aa47..1b79d233b9 100644
--- a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/__init__.py
+++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/__init__.py
@@ -1,25 +1,25 @@
"""Buzz orchestra custom agent for Harbor."""
from .agent import BuzzOrchestraAgent
-from .manifest import ExperimentManifest, ManifestError
-from .provisioning import AgentCredential, TrialHandle, TrialProvisioner
-from .runtime import OrchestraRuntime, RuntimeResult
from .container_runtime import (
BuzzContainerRuntime,
EndpointLaunchConfig,
RuntimeLaunchError,
)
+from .manifest import ExperimentManifest, ManifestError
+from .provisioning import AgentCredential, TrialHandle, TrialProvisioner
+from .runtime import OrchestraRuntime, RuntimeResult
__all__ = [
"AgentCredential",
- "BuzzOrchestraAgent",
"BuzzContainerRuntime",
+ "BuzzOrchestraAgent",
"EndpointLaunchConfig",
"ExperimentManifest",
"ManifestError",
"OrchestraRuntime",
- "RuntimeResult",
"RuntimeLaunchError",
+ "RuntimeResult",
"TrialHandle",
"TrialProvisioner",
]
diff --git a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/agent.py b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/agent.py
index 6354e9a587..3d1c81364f 100644
--- a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/agent.py
+++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/agent.py
@@ -9,10 +9,10 @@
from harbor.environments.base import BaseEnvironment
from harbor.models.agent.context import AgentContext
+from .container_runtime import BuzzContainerRuntime, EndpointLaunchConfig
from .manifest import ExperimentManifest
from .provisioning import TrialProvisioner
from .runtime import OrchestraRuntime
-from .container_runtime import BuzzContainerRuntime, EndpointLaunchConfig
class BuzzOrchestraAgent(BaseAgent):
@@ -83,7 +83,7 @@ def _load_mapping(
except (OSError, json.JSONDecodeError) as error:
raise ValueError(f"cannot load JSON config {path}: {error}") from error
if not isinstance(value, dict):
- raise ValueError(f"JSON config {path} must contain an object")
+ raise TypeError(f"JSON config {path} must contain an object")
return value
@classmethod
diff --git a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/container_runtime.py b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/container_runtime.py
index 3909f081f5..149a5295a7 100644
--- a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/container_runtime.py
+++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/container_runtime.py
@@ -24,7 +24,6 @@
from .provisioning import AgentCredential, TrialHandle
from .runtime import RuntimeResult
-
DEFAULT_MAX_AGENT_ROUNDS = 32
# Container-side layout for the uploaded Buzz stack.
REMOTE_ROOT = "/opt/buzz"
@@ -128,12 +127,20 @@ async def run(
if forwarder is not None:
infra.append(forwarder)
await self._buzz_json(
- trial.user, trial, "users", "set-profile", "--name",
+ trial.user,
+ trial,
+ "users",
+ "set-profile",
+ "--name",
trial.user.agent_id,
)
for credential in trial.credentials:
await self._buzz_json(
- credential, trial, "users", "set-profile", "--name",
+ credential,
+ trial,
+ "users",
+ "set-profile",
+ "--name",
credential.agent_id,
)
agents.append(
@@ -244,11 +251,17 @@ async def _start_forwarder(
) from error
forwarder = _Agent(
AgentCredential(
- agent_id="relay-forwarder", role="infra",
- nostr_secret_key="", nostr_pubkey="", nostr_auth_tag="",
- llm_endpoint="", llm_api_key="",
+ agent_id="relay-forwarder",
+ role="infra",
+ nostr_secret_key="",
+ nostr_pubkey="",
+ nostr_auth_tag="",
+ llm_endpoint="",
+ llm_api_key="",
),
- pid, log, log,
+ pid,
+ log,
+ log,
)
deadline = asyncio.get_running_loop().time() + self.readiness_timeout_seconds
while True:
@@ -418,9 +431,14 @@ async def _wait_for_done(
await self._raise_for_dead_agents(environment, agents)
polls += 1
messages = await self._buzz_json(
- trial.user, trial,
- "messages", "get", "--channel", trial.channel_id,
- "--limit", "100",
+ trial.user,
+ trial,
+ "messages",
+ "get",
+ "--channel",
+ trial.channel_id,
+ "--limit",
+ "100",
)
for message in messages:
if message.get("pubkey") == orchestrator.nostr_pubkey and str(
@@ -451,9 +469,7 @@ async def _raise_for_dead_agents(
)
@staticmethod
- async def _stop_agents(
- environment: BaseEnvironment, agents: list[_Agent]
- ) -> None:
+ async def _stop_agents(environment: BaseEnvironment, agents: list[_Agent]) -> None:
"""Terminate every process of the uploaded stack (acp, agent, mcp)."""
if not agents:
return
@@ -461,14 +477,14 @@ async def _stop_agents(
# to exist in task images, the /proc filesystem is.
sweep = (
"for d in /proc/[0-9]*; do "
- f"grep -aq {REMOTE_BIN} \"$d/cmdline\" 2>/dev/null "
- "&& kill -TERM \"${d#/proc/}\" 2>/dev/null; done; true"
+ f'grep -aq {REMOTE_BIN} "$d/cmdline" 2>/dev/null '
+ '&& kill -TERM "${d#/proc/}" 2>/dev/null; done; true'
)
try:
await environment.exec(sweep)
await asyncio.sleep(2)
await environment.exec(sweep.replace("-TERM", "-KILL"))
- except Exception: # noqa: BLE001 — environment may already be gone
+ except Exception: # noqa: S110, BLE001 — environment may already be gone
pass
async def _collect_logs(
@@ -476,7 +492,7 @@ async def _collect_logs(
) -> None:
try:
await environment.download_dir(REMOTE_LOGS, trial_dir)
- except Exception: # noqa: BLE001 — best effort; env may be torn down
+ except Exception: # noqa: S110, BLE001 — best effort; env may be torn down
pass
# -- Buzz CLI as the trial user / provisioning identities -------------------
@@ -506,9 +522,14 @@ async def _send(
self, credential: AgentCredential, trial: TrialHandle, content: str
) -> None:
await self._buzz_json(
- credential, trial,
- "messages", "send", "--channel", trial.channel_id,
- "--content", content,
+ credential,
+ trial,
+ "messages",
+ "send",
+ "--channel",
+ trial.channel_id,
+ "--content",
+ content,
)
async def _buzz_json(
@@ -614,9 +635,11 @@ def _compose_system_prompt(
"",
f"You are `{credential.agent_id}` (pubkey `{credential.nostr_pubkey}`).",
f"The team coordinates in Buzz channel `{trial.channel_id}`.",
- f"Tasks come from the user `{trial.user.agent_id}` "
- f"(pubkey `{trial.user.nostr_pubkey}`); address your final report "
- "to them.",
+ (
+ f"Tasks come from the user `{trial.user.agent_id}` "
+ f"(pubkey `{trial.user.nostr_pubkey}`); address your final report "
+ "to them."
+ ),
"",
"| Name | Role | Pubkey |",
"|------|------|--------|",
@@ -625,8 +648,7 @@ def _compose_system_prompt(
if teammate.agent_id == credential.agent_id:
continue
lines.append(
- f"| {teammate.agent_id} | {teammate.role} "
- f"| `{teammate.nostr_pubkey}` |"
+ f"| {teammate.agent_id} | {teammate.role} | `{teammate.nostr_pubkey}` |"
)
composed = persona + "\n".join(lines) + "\n"
path = trial_dir / f"{credential.agent_id}.system-prompt.md"
diff --git a/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/buzz_cli.py b/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/buzz_cli.py
index ed2bb31cc9..bd11f193ca 100644
--- a/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/buzz_cli.py
+++ b/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/buzz_cli.py
@@ -38,6 +38,7 @@ def run(self, *args: str) -> Any:
capture_output=True,
text=True,
timeout=self._timeout,
+ check=False,
env={
"BUZZ_RELAY_URL": self._relay_url,
"BUZZ_PRIVATE_KEY": self._secret_key,
diff --git a/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/provisioner.py b/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/provisioner.py
index cfda6b59fa..d8f380387d 100644
--- a/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/provisioner.py
+++ b/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/provisioner.py
@@ -44,7 +44,7 @@ class TestbedConfig:
archive_on_teardown: bool = True
-def provisioner_from_dict(config: dict[str, object]) -> "BuzzTrialProvisioner":
+def provisioner_from_dict(config: dict[str, object]) -> BuzzTrialProvisioner:
"""Harbor CLI factory for a JSON-decoded testbed configuration."""
return BuzzTrialProvisioner(TestbedConfig(**config))
@@ -100,7 +100,7 @@ def teardown(self, handle: TrialHandle) -> None:
cli = self._cli_for(handle.credentials[0])
try:
cli.archive_channel(handle.channel_id)
- except Exception as error: # noqa: BLE001 — idempotent re-teardown
+ except Exception as error:
if "archived" not in str(error).lower():
raise
with psycopg.connect(self._config.postgres_dsn) as conn:
diff --git a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_benchmark.py b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_benchmark.py
index 2d88e339f5..e0c6d32ec4 100644
--- a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_benchmark.py
+++ b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_benchmark.py
@@ -37,8 +37,19 @@ def test_defaults_are_leaderboard_eligible():
def test_selectors_pass_through():
args = benchmark.parse_args(
- ["--path", "/tmp/task", "-i", "cobol*", "-x", "flaky*", "-k", "1",
- "--job-name", "smoke", "--dry-run"]
+ [
+ "--path",
+ "/tmp/task",
+ "-i",
+ "cobol*",
+ "-x",
+ "flaky*",
+ "-k",
+ "1",
+ "--job-name",
+ "smoke",
+ "--dry-run",
+ ]
)
argv = benchmark.leaderboard_argv(args, Path("p.json"), Path("b"))
assert argv[argv.index("--path") + 1] == "/tmp/task"
@@ -59,11 +70,15 @@ def test_state_is_generated_once_and_reused(state_dir):
assert "user_pubkey" not in stored # derived, never persisted
-def test_provisioner_config_pins_user_and_keeps_channels(state_dir, tmp_path, monkeypatch):
+def test_provisioner_config_pins_user_and_keeps_channels(
+ state_dir, tmp_path, monkeypatch
+):
monkeypatch.setenv("FAKE_KEY_ENV", "sk-test")
endpoints = tmp_path / "endpoints.json"
endpoints.write_text(
- json.dumps({"model-a": {"provider": "anthropic", "api_key_env": "FAKE_KEY_ENV"}})
+ json.dumps(
+ {"model-a": {"provider": "anthropic", "api_key_env": "FAKE_KEY_ENV"}}
+ )
)
state = benchmark.load_state()
path = benchmark.write_provisioner_config(state, endpoints)
@@ -78,7 +93,9 @@ def test_provisioner_config_pins_user_and_keeps_channels(state_dir, tmp_path, mo
assert config["relay_http_url"].startswith("http://localhost:")
-def test_provisioner_config_missing_api_key_is_explicit(state_dir, tmp_path, monkeypatch):
+def test_provisioner_config_missing_api_key_is_explicit(
+ state_dir, tmp_path, monkeypatch
+):
monkeypatch.delenv("MISSING_KEY_ENV", raising=False)
endpoints = tmp_path / "endpoints.json"
endpoints.write_text(
@@ -91,9 +108,7 @@ def test_provisioner_config_missing_api_key_is_explicit(state_dir, tmp_path, mon
def test_env_file_wires_owner_and_ports(state_dir):
state = benchmark.load_state()
env_path = benchmark.write_env_file(state)
- env = dict(
- line.split("=", 1) for line in env_path.read_text().splitlines() if line
- )
+ env = dict(line.split("=", 1) for line in env_path.read_text().splitlines() if line)
assert env["RELAY_OWNER_PUBKEY"] == state["owner_pubkey"]
assert env["BUZZ_HTTP_PORT"] == str(benchmark.RELAY_HTTP_PORT)
assert env["BUZZ_PG_HOST_PORT"] == str(benchmark.PG_HOST_PORT)
diff --git a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_keys.py b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_keys.py
index 0f82578369..0ac794e0fa 100644
--- a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_keys.py
+++ b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_keys.py
@@ -6,6 +6,7 @@
import json
import coincurve
+
from harbor_buzz_testbed.keys import (
compute_auth_tag,
encode_nsec,
@@ -21,8 +22,10 @@
"auth",
"f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9",
"",
- "20105c618d6e5d8f559cffb6f0d7a7b4f44f3a567e1be94c96378d45ac3625da"
- "34c2e7357ea1d3ce980978334546b3e740c155e81b833ebe140d519d39ed8867",
+ (
+ "20105c618d6e5d8f559cffb6f0d7a7b4f44f3a567e1be94c96378d45ac3625da"
+ "34c2e7357ea1d3ce980978334546b3e740c155e81b833ebe140d519d39ed8867"
+ ),
]
diff --git a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_live.py b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_live.py
index 5a6b40d8cf..711b877ab4 100644
--- a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_live.py
+++ b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_live.py
@@ -14,6 +14,7 @@
import psycopg
import pytest
+
from harbor_buzz_testbed.buzz_cli import BuzzCli, BuzzCliError
from harbor_buzz_testbed.provisioner import (
BuzzTrialProvisioner,
diff --git a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_unit.py b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_unit.py
index 9620be4bc8..e784de5825 100644
--- a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_unit.py
+++ b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_unit.py
@@ -7,6 +7,7 @@
import coincurve
import pytest
+
from harbor_buzz_testbed.provisioner import (
BuzzTrialProvisioner,
ProvisioningError,
@@ -17,13 +18,13 @@
def config(**overrides) -> TestbedConfig:
- defaults = dict(
- relay_http_url="http://localhost:3000",
- relay_ws_url="ws://host.docker.internal:3000",
- owner_secret_key=OWNER_SECRET,
- postgres_dsn="postgresql://unused",
- llm_api_keys={"databricks/glm": "glm-key", "databricks/opus": "opus-key"},
- )
+ defaults = {
+ "relay_http_url": "http://localhost:3000",
+ "relay_ws_url": "ws://host.docker.internal:3000",
+ "owner_secret_key": OWNER_SECRET,
+ "postgres_dsn": "postgresql://unused",
+ "llm_api_keys": {"databricks/glm": "glm-key", "databricks/opus": "opus-key"},
+ }
defaults.update(overrides)
return TestbedConfig(**defaults)
diff --git a/benchmarks/harbor-buzz-orchestra/tests/conftest.py b/benchmarks/harbor-buzz-orchestra/tests/conftest.py
index bd0bcaf2dd..b1de094d76 100644
--- a/benchmarks/harbor-buzz-orchestra/tests/conftest.py
+++ b/benchmarks/harbor-buzz-orchestra/tests/conftest.py
@@ -1,4 +1,5 @@
from typing import Any
+
import pytest
diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_agent.py b/benchmarks/harbor-buzz-orchestra/tests/test_agent.py
index 62c6047ab3..b305344c51 100644
--- a/benchmarks/harbor-buzz-orchestra/tests/test_agent.py
+++ b/benchmarks/harbor-buzz-orchestra/tests/test_agent.py
@@ -1,7 +1,9 @@
from types import SimpleNamespace
from uuid import uuid4
+
import pytest
from harbor.models.agent.context import AgentContext
+
from harbor_buzz_orchestra import (
AgentCredential,
BuzzOrchestraAgent,
@@ -73,9 +75,7 @@ async def run(self, **kwargs):
async def test_agent_lifecycle_and_context(tmp_path, manifest_data):
provisioner, runtime, context_id = Provisioner(), Runtime(), uuid4()
- environment = SimpleNamespace(
- context_id=context_id, environment_name="hello-world"
- )
+ environment = SimpleNamespace(context_id=context_id, environment_name="hello-world")
agent = BuzzOrchestraAgent(
logs_dir=tmp_path,
manifest=manifest_data,
diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py b/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py
index 8669fe980c..ebf0eb4b5d 100644
--- a/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py
+++ b/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py
@@ -8,8 +8,6 @@
import pytest
from harbor.environments.base import ExecResult
-from harbor_buzz_orchestra.manifest import ExperimentManifest
-from harbor_buzz_orchestra.provisioning import AgentCredential, TrialHandle
from harbor_buzz_orchestra.container_runtime import (
REMOTE_BIN,
REMOTE_LOGS,
@@ -17,6 +15,8 @@
EndpointLaunchConfig,
RuntimeLaunchError,
)
+from harbor_buzz_orchestra.manifest import ExperimentManifest
+from harbor_buzz_orchestra.provisioning import AgentCredential, TrialHandle
def write_manifest(tmp_path: Path) -> ExperimentManifest:
@@ -33,10 +33,20 @@ def write_manifest(tmp_path: Path) -> ExperimentManifest:
{
"condition": "test",
"roster": [
- {"id": "orch", "kind": "orchestrator", "role": "lead",
- "endpoint": "orch-model", **roster_entry},
- {"id": "worker", "kind": "worker", "role": "implementer",
- "endpoint": "worker-model", **roster_entry},
+ {
+ "id": "orch",
+ "kind": "orchestrator",
+ "role": "lead",
+ "endpoint": "orch-model",
+ **roster_entry,
+ },
+ {
+ "id": "worker",
+ "kind": "worker",
+ "role": "implementer",
+ "endpoint": "worker-model",
+ **roster_entry,
+ },
],
"prices": {
name: {
@@ -162,10 +172,7 @@ def test_user_relay_url_prefers_host_view(tmp_path):
== "http://localhost:3600"
)
# pre-v1.2 handles fall back to deriving http from the agents' ws view.
- assert (
- rt._user_relay_url(trial_handle(()))
- == "http://host.docker.internal:3600"
- )
+ assert rt._user_relay_url(trial_handle(())) == "http://host.docker.internal:3600"
with pytest.raises(RuntimeLaunchError, match="ws://"):
rt._cli_relay_url("http://relay")
@@ -209,16 +216,21 @@ async def test_forwarder_bridges_the_canonical_relay_address(tmp_path):
forwarder_binary=str(forwarder),
)
trial = TrialHandle(
- run_id="run", trial_id="trial", manifest_hash="hash",
- relay_ws_url="ws://localhost:3600", channel_id="channel",
- credentials=(), user=user_credential(),
+ run_id="run",
+ trial_id="trial",
+ manifest_hash="hash",
+ relay_ws_url="ws://localhost:3600",
+ channel_id="channel",
+ credentials=(),
+ user=user_credential(),
)
environment = Environment(
responses={
FORWARDER: ExecResult(stdout="99\n", stderr="", return_code=0),
"cat ": ExecResult(
stdout="forwarding 127.0.0.1:3600 -> host.docker.internal:3600",
- stderr="", return_code=0,
+ stderr="",
+ return_code=0,
),
}
)
@@ -295,9 +307,7 @@ class ReadyEnvironment(Environment):
async def exec(self, command, env=None, **kwargs):
if command.startswith("cat "):
agent_id = re.search(r"([\w-]+)\.stdout\.log", command).group(1)
- return ExecResult(
- stdout=logs[agent_id], stderr="", return_code=0
- )
+ return ExecResult(stdout=logs[agent_id], stderr="", return_code=0)
return ExecResult(stdout="", stderr="", return_code=0)
from harbor_buzz_orchestra.container_runtime import _Agent
@@ -326,9 +336,7 @@ async def exec(self, command, env=None, **kwargs):
async def test_dead_agent_processes_fail_the_trial(tmp_path):
from harbor_buzz_orchestra.container_runtime import _Agent
- agents = [
- _Agent(credential("worker-1", "worker", "worker-model"), 7, "o", "e")
- ]
+ agents = [_Agent(credential("worker-1", "worker", "worker-model"), 7, "o", "e")]
environment = Environment(
responses={
"kill -0": ExecResult(stdout="DEAD:worker-1\n", stderr="", return_code=0)
diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_manifest.py b/benchmarks/harbor-buzz-orchestra/tests/test_manifest.py
index 36533db3bf..f8230036b3 100644
--- a/benchmarks/harbor-buzz-orchestra/tests/test_manifest.py
+++ b/benchmarks/harbor-buzz-orchestra/tests/test_manifest.py
@@ -1,6 +1,8 @@
import copy
+
import pytest
import yaml
+
from harbor_buzz_orchestra import ExperimentManifest, ManifestError
diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_run_leaderboard.py b/benchmarks/harbor-buzz-orchestra/tests/test_run_leaderboard.py
index ed048ee5d9..451de72e79 100644
--- a/benchmarks/harbor-buzz-orchestra/tests/test_run_leaderboard.py
+++ b/benchmarks/harbor-buzz-orchestra/tests/test_run_leaderboard.py
@@ -109,8 +109,16 @@ def test_forbidden_flags_are_not_accepted(tmp_path):
for flag in FORBIDDEN_FLAGS:
with pytest.raises(SystemExit):
run_leaderboard.parse_args(
- ["--dataset", "d", "--attempts", "5",
- "--agent-bin-dir", str(tmp_path), flag, "1"]
+ [
+ "--dataset",
+ "d",
+ "--attempts",
+ "5",
+ "--agent-bin-dir",
+ str(tmp_path),
+ flag,
+ "1",
+ ]
)
diff --git a/crates/buzz-a2a-acp/Cargo.toml b/crates/buzz-a2a-acp/Cargo.toml
new file mode 100644
index 0000000000..b8cb8738c6
--- /dev/null
+++ b/crates/buzz-a2a-acp/Cargo.toml
@@ -0,0 +1,32 @@
+[package]
+name = "buzz-a2a-acp"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+repository.workspace = true
+description = "ACP adapter for agents advertised by an OASF Agent Record and invoked through A2A"
+readme = "README.md"
+keywords = ["acp", "a2a", "agntcy", "oasf", "agent"]
+categories = ["command-line-utilities", "web-programming"]
+
+[lib]
+name = "buzz_a2a_acp"
+path = "src/lib.rs"
+
+[[bin]]
+name = "buzz-a2a-acp"
+path = "src/main.rs"
+
+[dependencies]
+base64 = "0.22"
+clap = { version = "4", features = ["derive", "env"] }
+hex = { workspace = true }
+reqwest = { workspace = true, features = ["json", "rustls"] }
+serde = { workspace = true }
+serde_json = { workspace = true, features = ["raw_value"] }
+sha2 = { workspace = true }
+thiserror = { workspace = true }
+tokio = { workspace = true, features = ["fs", "io-std", "io-util", "macros", "net", "rt-multi-thread", "sync", "time"] }
+url = { workspace = true }
+uuid = { workspace = true }
diff --git a/crates/buzz-a2a-acp/README.md b/crates/buzz-a2a-acp/README.md
new file mode 100644
index 0000000000..4f1f6472e4
--- /dev/null
+++ b/crates/buzz-a2a-acp/README.md
@@ -0,0 +1,92 @@
+# buzz-a2a-acp
+
+`buzz-a2a-acp` is a small BYOH subprocess adapter. It lets Buzz host an agent
+that is described by an [AGNTCY/OASF Agent Record](https://docs.agntcy.org/oasf/agent-record-guide/)
+and invoked with [A2A](https://a2a-protocol.org/latest/).
+
+The adapter reads the record, resolves the `integration/a2a` module (OASF id
+`203`), and exposes the remote agent through Buzz's existing ACP stdio seam:
+
+```
+OASF Agent Record -> A2A Agent Card -> A2A JSON-RPC -> ACP stdio -> Buzz
+```
+
+The current adapter prefers an A2A JSON-RPC interface declared in
+`supportedInterfaces`. It selects `SendMessage`/`GetTask` for A2A 1.x and
+`message/send`/`tasks/get` for A2A 0.3. It sends the matching `A2A-Version`
+header on every standard A2A request. It also has an explicit vendor
+compatibility path for a non-standard card shape (`serviceEndpoint` plus
+`agent/sendMessage` and `agent/getTask`). The compatibility path is not an A2A
+release contract. Task responses are polled with bounded backoff until a
+terminal state or the configured timeout.
+
+## Configure in Buzz
+
+Register the binary as a BYOH ACP runtime with:
+
+```text
+command: buzz-a2a-acp
+args: --record,/absolute/path/to/agent-record.json
+```
+
+The same configuration can be represented by a custom-harness JSON object:
+
+```json
+{
+ "id": "remote-oasf-agent",
+ "label": "Remote OASF agent",
+ "command": "buzz-a2a-acp",
+ "args": ["--record", "/absolute/path/to/agent-record.json"],
+ "env": {}
+}
+```
+
+This is an operator-owned configuration example. The adapter is not added to
+Buzz's compiled-in runtime gallery and does not auto-import or auto-trust
+records.
+
+Or set `BUZZ_A2A_AGENT_RECORD`. The record can be a local file or an HTTP(S)
+URL. Use `BUZZ_A2A_BEARER_TOKEN` only when the remote A2A endpoint requires it.
+The token is supplied by the operator and is never read from the public record.
+
+The adapter requires the OASF descriptor fields `digest`, `media_type`, and
+`size` for every artifact. It validates the SHA-256 digest and exact size, and
+it accepts only JSON media types. It accepts the OASF `data.card_data` field
+only as an explicit deprecated compatibility fallback because current OASF
+schemas prefer an artifact descriptor.
+
+Remote records and card/artifact endpoints must use HTTPS. HTTP is accepted
+only for loopback hosts. Redirects are disabled. A bearer token is sent only
+when `--bearer-token-endpoint` normalizes to the resolved A2A endpoint. This
+keeps operator credentials out of arbitrary endpoints selected by a public
+record. The adapter resolves each hostname, applies the address policy, and
+pins the request client to the checked address. It does not perform a second
+unchecked DNS lookup for the request.
+
+The optional `--agency-ref`, `--space-ref`, and `--agent-ref` flags project
+stable host context references into A2A `metadata`. The source record does
+not supply commands, environment variables, or credentials. New conversations
+use random UUID context identifiers by default. An operator can supply a stable
+identifier with `--context-id` or `BUZZ_A2A_CONTEXT_ID` when the host has a
+durable conversation reference to preserve intentionally.
+
+## Scope and trust boundary
+
+The adapter projects public discovery metadata and A2A results. It does not
+copy an Agency's private prompts, memory, tools, local files, or signing keys
+into Buzz. The source runtime remains responsible for authentication,
+authorization, execution, and any Nostr or Git signing. Buzz receives the
+ACP-visible response or task status.
+
+This is an experimental adapter. It does not implement AGNTCY Directory
+registration, OASF custom taxonomy exchange, A2A streaming, push notifications,
+or Surface rendering. Those are separate integration layers that can build on
+the real invocation seam without inventing a parallel agency protocol.
+
+OASF defines the record schema; it does not define how records are discovered
+or transported. The current adapter resolves a reviewed local path or HTTPS
+URL. Authenticity is therefore based on the operator's review and, for remote
+records, the HTTPS connection. OASF 1.1 records do not carry a general record
+signature. Domain-JWKS verification is the next planned trust layer. Optional
+AGNTCY Directory resolution can follow when interoperable Directory identity
+and verification are required.
diff --git a/crates/buzz-a2a-acp/src/lib.rs b/crates/buzz-a2a-acp/src/lib.rs
new file mode 100644
index 0000000000..1b52dad00c
--- /dev/null
+++ b/crates/buzz-a2a-acp/src/lib.rs
@@ -0,0 +1,2173 @@
+#![forbid(unsafe_code)]
+
+//! A small, protocol-faithful bridge from an AGNTCY/OASF Agent Record to ACP.
+//!
+//! The bridge is intentionally a subprocess. Buzz owns the ACP session and UI;
+//! the source runtime owns its agent identity, context, execution, and keys.
+
+use base64::Engine;
+use clap::Parser;
+use reqwest::{Client, StatusCode};
+use serde::Deserialize;
+use serde_json::{json, value::RawValue, Value};
+use sha2::{Digest, Sha256};
+use std::{
+ collections::HashSet,
+ net::{IpAddr, SocketAddr},
+ path::{Path, PathBuf},
+ sync::atomic::{AtomicU64, Ordering},
+};
+use thiserror::Error;
+use tokio::io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader};
+use url::Url;
+use uuid::Uuid;
+
+const MAX_RECORD_BYTES: usize = 2 * 1024 * 1024;
+const MAX_ARTIFACT_BYTES: usize = 2 * 1024 * 1024;
+const MAX_ACP_LINE_BYTES: usize = 1024 * 1024;
+const DEFAULT_TASK_POLL_SECS: u64 = 7_200;
+const TASK_POLL_BACKOFF_SECS: [u64; 4] = [1, 5, 15, 30];
+
+/// Configuration supplied by Buzz's BYOH subprocess definition.
+#[derive(Debug, Clone)]
+pub struct AdapterConfig {
+ /// Local path or HTTP(S) URL for an OASF Agent Record.
+ pub record: String,
+ /// Optional operator-supplied token for the A2A endpoint.
+ pub bearer_token: Option,
+ /// Exact endpoint where the operator permits the bearer token to be sent.
+ pub bearer_token_endpoint: Option,
+ /// Optional stable Agency reference projected into A2A metadata.
+ pub agency_ref: Option,
+ /// Optional stable Space reference projected into A2A metadata.
+ pub space_ref: Option,
+ /// Optional Buzz channel reference projected into A2A metadata.
+ pub channel_ref: Option,
+ /// Optional stable Agent reference projected into A2A metadata.
+ pub agent_ref: Option,
+ /// Optional caller-supplied A2A conversation context identifier.
+ pub context_id: Option,
+ /// Maximum time to wait for an asynchronous A2A task.
+ pub task_poll_secs: u64,
+}
+
+#[derive(Debug, Parser)]
+#[command(
+ name = "buzz-a2a-acp",
+ about = "Expose an OASF Agent Record as an ACP subprocess"
+)]
+struct Cli {
+ /// Local path or HTTP(S) URL for an OASF 1.0 Agent Record.
+ #[arg(long, env = "BUZZ_A2A_AGENT_RECORD")]
+ record: String,
+
+ /// Exact A2A endpoint where the operator permits the bearer token to be sent.
+ #[arg(long, env = "BUZZ_A2A_BEARER_ENDPOINT")]
+ bearer_token_endpoint: Option,
+
+ /// Optional stable Agency reference to include in A2A request metadata.
+ #[arg(long, env = "BUZZ_A2A_AGENCY_REF")]
+ agency_ref: Option,
+
+ /// Optional stable Space reference to include in A2A request metadata.
+ #[arg(long, env = "BUZZ_A2A_SPACE_REF")]
+ space_ref: Option,
+
+ /// Optional Buzz channel reference to include in A2A request metadata.
+ #[arg(long, env = "BUZZ_A2A_CHANNEL_REF")]
+ channel_ref: Option,
+
+ /// Optional stable Agent reference to include in A2A request metadata.
+ #[arg(long, env = "BUZZ_A2A_AGENT_REF")]
+ agent_ref: Option,
+
+ /// Optional stable A2A conversation context identifier.
+ #[arg(long, env = "BUZZ_A2A_CONTEXT_ID")]
+ context_id: Option,
+
+ /// Maximum time to wait for an asynchronous A2A task.
+ #[arg(
+ long,
+ env = "BUZZ_A2A_TASK_POLL_SECS",
+ default_value_t = DEFAULT_TASK_POLL_SECS
+ )]
+ task_poll_secs: u64,
+}
+
+#[derive(Debug, Error)]
+pub enum AdapterError {
+ #[error("record source is empty")]
+ EmptyRecord,
+ #[error("record source is not a local path or HTTP(S) URL: {0}")]
+ InvalidSource(String),
+ #[error("fetch {what} failed with HTTP {status}")]
+ HttpStatus {
+ what: &'static str,
+ status: StatusCode,
+ },
+ #[error("{what} exceeds the {limit} byte limit")]
+ TooLarge { what: &'static str, limit: usize },
+ #[error("read {what}: {source}")]
+ Read {
+ what: &'static str,
+ source: std::io::Error,
+ },
+ #[error("decode {what}: {source}")]
+ Decode {
+ what: &'static str,
+ source: serde_json::Error,
+ },
+ #[error("invalid OASF Agent Record: {0}")]
+ InvalidRecord(String),
+ #[error("invalid OASF A2A artifact: {0}")]
+ InvalidArtifact(String),
+ #[error("A2A endpoint is not advertised by the Agent Card")]
+ MissingEndpoint,
+ #[error("unsafe endpoint URL: {0}")]
+ UnsafeEndpoint(String),
+ #[error("bearer token is not authorized for A2A endpoint {0}")]
+ UnauthorizedTokenEndpoint(String),
+ #[error("remote A2A task did not complete before the {0} second timeout")]
+ TaskTimeout(u64),
+ #[error("A2A request failed: {0}")]
+ Request(String),
+ #[error("A2A response was invalid: {0}")]
+ InvalidResponse(String),
+ #[error("ACP protocol error: {0}")]
+ Acp(String),
+}
+
+#[derive(Debug, Deserialize)]
+struct AgentRecord {
+ #[serde(default)]
+ name: Option,
+ #[serde(default)]
+ schema_version: Option,
+ #[serde(default)]
+ modules: Vec,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+enum RecordSource {
+ LocalPath(PathBuf),
+ HttpUrl(Url),
+}
+
+impl RecordSource {
+ fn parse(source: &str) -> Result {
+ if source.trim().is_empty() {
+ return Err(AdapterError::EmptyRecord);
+ }
+ if let Ok(url) = Url::parse(source) {
+ if matches!(url.scheme(), "http" | "https") {
+ validate_http_url(source)
+ .map_err(|_| AdapterError::InvalidSource(source.to_owned()))?;
+ return Ok(Self::HttpUrl(url));
+ }
+ if source.contains("://") {
+ return Err(AdapterError::InvalidSource(source.to_owned()));
+ }
+ }
+ Ok(Self::LocalPath(PathBuf::from(source)))
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum RecordVerification {
+ OperatorReviewedLocal,
+ TlsOnly,
+}
+
+impl RecordVerification {
+ fn label(self) -> &'static str {
+ match self {
+ Self::OperatorReviewedLocal => "operator-reviewed-local",
+ Self::TlsOnly => "tls-only",
+ }
+ }
+}
+
+struct ResolvedRecord {
+ record: AgentRecord,
+ base: Option,
+ content_digest: String,
+ verification: RecordVerification,
+}
+
+#[derive(Debug, Deserialize)]
+struct OasfModule {
+ #[serde(default)]
+ name: Option,
+ #[serde(default)]
+ id: Option,
+ #[serde(default)]
+ artifact: Option>,
+ #[serde(default)]
+ data: Option,
+}
+
+#[derive(Debug, Deserialize)]
+struct A2aData {
+ #[serde(default)]
+ card_data: Option,
+ #[serde(default, rename = "card_schema_version")]
+ _card_schema_version: Option,
+}
+
+#[derive(Debug, Deserialize)]
+struct Descriptor {
+ #[serde(default)]
+ digest: Option,
+ #[serde(default, rename = "media_type")]
+ media_type: Option,
+ #[serde(default)]
+ size: Option,
+ #[serde(default)]
+ data: Option,
+ #[serde(default)]
+ json: Option>,
+ #[serde(default)]
+ urls: Vec,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+/// Public A2A Agent Card fields used to select an invocation interface.
+pub struct AgentCard {
+ /// Non-standard identifier used only by the vendor compatibility path.
+ #[serde(default, rename = "id")]
+ pub vendor_id: Option,
+ /// Human-readable name, when advertised.
+ #[serde(default)]
+ pub name: Option,
+ /// Human-readable description, when advertised.
+ #[serde(default)]
+ pub description: Option,
+ /// A2A 0.3 card endpoint.
+ #[serde(default)]
+ pub url: Option,
+ /// Non-standard endpoint field used by the vendor compatibility path.
+ #[serde(default, rename = "serviceEndpoint")]
+ pub service_endpoint: Option,
+ /// Current A2A interface declarations.
+ #[serde(default, rename = "supportedInterfaces")]
+ pub supported_interfaces: Vec,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+/// A protocol endpoint declared by an A2A Agent Card.
+pub struct SupportedInterface {
+ /// URL to the protocol endpoint.
+ #[serde(default)]
+ pub url: Option,
+ /// Protocol binding name, for example `JSONRPC`.
+ #[serde(default, rename = "protocolBinding")]
+ pub protocol_binding: Option,
+ /// Protocol version declared by the remote agent.
+ #[serde(default, rename = "protocolVersion")]
+ pub protocol_version: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum ProtocolMode {
+ /// A2A JSON-RPC interface declared by a current Agent Card.
+ JsonRpc {
+ endpoint: String,
+ protocol_version: Option,
+ },
+ /// Compatibility with a deployed vendor card and method shape.
+ VendorServiceEndpoint { endpoint: String },
+}
+
+impl ProtocolMode {
+ fn endpoint(&self) -> &str {
+ match self {
+ Self::JsonRpc { endpoint, .. } | Self::VendorServiceEndpoint { endpoint } => endpoint,
+ }
+ }
+
+ fn a2a_version(&self) -> Option<&'static str> {
+ match self {
+ Self::JsonRpc {
+ protocol_version, ..
+ } if protocol_version
+ .as_deref()
+ .is_some_and(|version| version.starts_with("1.")) =>
+ {
+ Some("1.0")
+ }
+ Self::JsonRpc { .. } => Some("0.3"),
+ Self::VendorServiceEndpoint { .. } => None,
+ }
+ }
+
+ fn method(&self, task: bool) -> &str {
+ match self {
+ Self::JsonRpc {
+ protocol_version, ..
+ } => {
+ if protocol_version
+ .as_deref()
+ .is_some_and(|version| version.starts_with("1."))
+ {
+ if task {
+ "GetTask"
+ } else {
+ "SendMessage"
+ }
+ } else if task {
+ "tasks/get"
+ } else {
+ "message/send"
+ }
+ }
+ Self::VendorServiceEndpoint { .. } => {
+ if task {
+ "agent/getTask"
+ } else {
+ "agent/sendMessage"
+ }
+ }
+ }
+ }
+}
+
+/// A resolved public record and its invocation mode.
+#[derive(Debug, Clone)]
+/// Resolved public metadata and invocation mode for one remote agent.
+pub struct ResolvedAgent {
+ /// Name from the OASF record.
+ pub record_name: Option,
+ /// OASF schema version from the record.
+ pub record_schema_version: Option,
+ /// Public A2A card resolved from the OASF module.
+ pub card: AgentCard,
+ /// Selected current or compatibility invocation mode.
+ pub mode: ProtocolMode,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum CardSource {
+ Artifact,
+ DeprecatedCardData,
+}
+
+static REQUEST_ID: AtomicU64 = AtomicU64::new(1);
+
+fn pinned_http_client(url: &Url, addresses: &[SocketAddr]) -> Result {
+ let raw_host = url
+ .host_str()
+ .ok_or_else(|| AdapterError::UnsafeEndpoint(url.to_string()))?;
+ let host = normalized_host(raw_host);
+ let mut builder = Client::builder()
+ .redirect(reqwest::redirect::Policy::none())
+ .timeout(std::time::Duration::from_secs(30));
+ // Pin every address that passed our policy check. This prevents reqwest
+ // from performing a second DNS lookup while preserving IPv4/IPv6 fallback.
+ if host.parse::().is_err() {
+ if addresses.is_empty() {
+ return Err(AdapterError::UnsafeEndpoint(url.to_string()));
+ }
+ builder = builder.resolve_to_addrs(&host, addresses);
+ }
+ builder
+ .build()
+ .map_err(|e| AdapterError::Request(format!("build HTTP client: {e}")))
+}
+
+fn validate_http_url(raw: &str) -> Result {
+ let url = Url::parse(raw).map_err(|_| AdapterError::UnsafeEndpoint(raw.to_owned()))?;
+ let raw_host = url
+ .host_str()
+ .ok_or_else(|| AdapterError::UnsafeEndpoint(raw.to_owned()))?;
+ let host = normalized_host(raw_host);
+ if let Ok(ip) = host.parse::() {
+ // Local A2A runtimes are allowed over loopback HTTP. Private and
+ // link-local addresses remain rejected for every other scheme.
+ if url.scheme() == "http" && ip.is_loopback() {
+ return Ok(url);
+ }
+ if is_private_ip(ip) {
+ return Err(AdapterError::UnsafeEndpoint(raw.to_owned()));
+ }
+ }
+ if url.scheme() == "https" && host.eq_ignore_ascii_case("localhost") {
+ return Err(AdapterError::UnsafeEndpoint(raw.to_owned()));
+ }
+ match url.scheme() {
+ "https" => Ok(url),
+ "http" if is_loopback_host(&host) => Ok(url),
+ _ => Err(AdapterError::UnsafeEndpoint(raw.to_owned())),
+ }
+}
+
+fn normalized_host(host: &str) -> String {
+ host.trim_start_matches('[')
+ .trim_end_matches(']')
+ .to_ascii_lowercase()
+}
+
+fn is_loopback_host(host: &str) -> bool {
+ host.eq_ignore_ascii_case("localhost")
+ || host
+ .parse::()
+ .is_ok_and(|address| address.is_loopback())
+}
+
+fn is_private_ip(ip: IpAddr) -> bool {
+ let ip = match ip {
+ IpAddr::V6(address) => address
+ .to_ipv4_mapped()
+ .map(IpAddr::V4)
+ .unwrap_or(IpAddr::V6(address)),
+ address => address,
+ };
+ match ip {
+ IpAddr::V4(ip) => {
+ let octets = ip.octets();
+ ip.is_loopback()
+ || ip.is_private()
+ || ip.is_link_local()
+ || ip.is_unspecified()
+ || octets[0] == 0
+ || (octets[0] == 100 && (64..=127).contains(&octets[1]))
+ }
+ IpAddr::V6(ip) => {
+ let segments = ip.segments();
+ ip.is_loopback()
+ || ip.is_unspecified()
+ || ip.is_multicast()
+ || (segments[0] & 0xfe00) == 0xfc00
+ || (segments[0] & 0xffc0) == 0xfe80
+ // IPv4-transitional address ranges can encode private IPv4
+ // targets while still presenting as IPv6 DNS answers.
+ || (segments[0] == 0x0064
+ && segments[1] == 0xff9b
+ && segments[2..6] == [0, 0, 0, 0])
+ || segments[0] == 0x2002
+ || (segments[0] == 0x2001 && segments[1] == 0)
+ || segments[..6] == [0, 0, 0, 0, 0, 0]
+ }
+ }
+}
+
+async fn resolve_network_url(url: &Url) -> Result, AdapterError> {
+ let raw_host = url
+ .host_str()
+ .ok_or_else(|| AdapterError::UnsafeEndpoint(url.to_string()))?;
+ let host = normalized_host(raw_host);
+ if let Ok(ip) = host.parse::() {
+ if url.scheme() == "http" && ip.is_loopback() {
+ return Ok(vec![SocketAddr::new(
+ ip,
+ url.port_or_known_default().unwrap_or(80),
+ )]);
+ }
+ if !is_private_ip(ip) {
+ return Ok(vec![SocketAddr::new(
+ ip,
+ url.port_or_known_default().unwrap_or(443),
+ )]);
+ }
+ return Err(AdapterError::UnsafeEndpoint(url.to_string()));
+ }
+ let port = url
+ .port_or_known_default()
+ .ok_or_else(|| AdapterError::UnsafeEndpoint(url.to_string()))?;
+ let addresses: Vec = tokio::net::lookup_host((host.as_str(), port))
+ .await
+ .map_err(|_| AdapterError::UnsafeEndpoint(url.to_string()))?
+ .collect();
+ validate_resolved_addresses(url, &addresses)?;
+ Ok(addresses)
+}
+
+fn validate_resolved_addresses(url: &Url, addresses: &[SocketAddr]) -> Result<(), AdapterError> {
+ let raw_host = url
+ .host_str()
+ .ok_or_else(|| AdapterError::UnsafeEndpoint(url.to_string()))?;
+ let host = normalized_host(raw_host);
+ if addresses.is_empty() {
+ return Err(AdapterError::UnsafeEndpoint(url.to_string()));
+ }
+ let is_local_http = url.scheme() == "http" && is_loopback_host(&host);
+ if url.scheme() == "http" && !is_local_http {
+ return Err(AdapterError::UnsafeEndpoint(url.to_string()));
+ }
+ if is_local_http {
+ if addresses.iter().any(|address| !address.ip().is_loopback()) {
+ return Err(AdapterError::UnsafeEndpoint(url.to_string()));
+ }
+ } else if addresses.iter().any(|address| is_private_ip(address.ip())) {
+ return Err(AdapterError::UnsafeEndpoint(url.to_string()));
+ }
+ Ok(())
+}
+
+async fn response_bytes(
+ mut response: reqwest::Response,
+ what: &'static str,
+ limit: usize,
+) -> Result, AdapterError> {
+ if response
+ .content_length()
+ .is_some_and(|size| size > limit as u64)
+ {
+ return Err(AdapterError::TooLarge { what, limit });
+ }
+ let mut body = Vec::new();
+ while let Some(chunk) = response
+ .chunk()
+ .await
+ .map_err(|e| AdapterError::Request(format!("read {what}: {e}")))?
+ {
+ if body.len().saturating_add(chunk.len()) > limit {
+ return Err(AdapterError::TooLarge { what, limit });
+ }
+ body.extend_from_slice(&chunk);
+ }
+ Ok(body)
+}
+
+async fn read_source(
+ source: &str,
+ what: &'static str,
+ limit: usize,
+) -> Result, AdapterError> {
+ if source.trim().is_empty() {
+ return Err(AdapterError::EmptyRecord);
+ }
+ if let Ok(url) = Url::parse(source) {
+ if matches!(url.scheme(), "http" | "https") {
+ validate_http_url(source)
+ .map_err(|_| AdapterError::InvalidSource(source.to_owned()))?;
+ let addresses = resolve_network_url(&url).await?;
+ let response = pinned_http_client(&url, &addresses)?
+ .get(url)
+ .send()
+ .await
+ .map_err(|e| AdapterError::Request(format!("fetch {what}: {e}")))?;
+ let status = response.status();
+ if !status.is_success() {
+ return Err(AdapterError::HttpStatus { what, status });
+ }
+ return response_bytes(response, what, limit).await;
+ }
+ if source.contains("://") {
+ return Err(AdapterError::InvalidSource(source.to_owned()));
+ }
+ }
+ let body = tokio::fs::read(Path::new(source))
+ .await
+ .map_err(|source| AdapterError::Read { what, source })?;
+ if body.len() > limit {
+ return Err(AdapterError::TooLarge { what, limit });
+ }
+ Ok(body)
+}
+
+async fn load_record(source: &str) -> Result {
+ let source = RecordSource::parse(source)?;
+ let source_text = match &source {
+ RecordSource::LocalPath(path) => path.to_string_lossy().into_owned(),
+ RecordSource::HttpUrl(url) => url.to_string(),
+ };
+ let bytes = read_source(&source_text, "Agent Record", MAX_RECORD_BYTES).await?;
+ let record = if bytes.iter().find(|byte| !byte.is_ascii_whitespace()) == Some(&b'[') {
+ let mut records: Vec =
+ serde_json::from_slice(&bytes).map_err(|source| AdapterError::Decode {
+ what: "Agent Record",
+ source,
+ })?;
+ if records.len() != 1 {
+ return Err(AdapterError::InvalidRecord(format!(
+ "expected exactly one Agent Record, got {}",
+ records.len()
+ )));
+ }
+ records
+ .pop()
+ .ok_or_else(|| AdapterError::InvalidRecord("Agent Record collection is empty".into()))?
+ } else {
+ serde_json::from_slice(&bytes).map_err(|source| AdapterError::Decode {
+ what: "Agent Record",
+ source,
+ })?
+ };
+ let (base, verification) = match source {
+ RecordSource::LocalPath(_) => (None, RecordVerification::OperatorReviewedLocal),
+ RecordSource::HttpUrl(url) if url.scheme() == "https" => {
+ (Some(url), RecordVerification::TlsOnly)
+ }
+ RecordSource::HttpUrl(url) => (Some(url), RecordVerification::OperatorReviewedLocal),
+ };
+ Ok(ResolvedRecord {
+ record,
+ base,
+ content_digest: format!("sha256:{}", hex::encode(Sha256::digest(&bytes))),
+ verification,
+ })
+}
+
+fn descriptor_from_raw(value: &RawValue) -> Result {
+ let raw = value.get();
+ if raw.trim_start().starts_with('[') {
+ let mut descriptors: Vec = serde_json::from_str(raw)
+ .map_err(|e| AdapterError::InvalidArtifact(format!("descriptor: {e}")))?;
+ if descriptors.len() != 1 {
+ return Err(AdapterError::InvalidArtifact(format!(
+ "expected exactly one artifact descriptor, got {}",
+ descriptors.len()
+ )));
+ }
+ descriptors
+ .pop()
+ .ok_or_else(|| AdapterError::InvalidArtifact("artifact descriptor is absent".into()))
+ } else {
+ serde_json::from_str(raw)
+ .map_err(|e| AdapterError::InvalidArtifact(format!("descriptor: {e}")))
+ }
+}
+
+fn verify_descriptor(descriptor: &Descriptor, bytes: &[u8]) -> Result<(), AdapterError> {
+ let size = descriptor.size.ok_or_else(|| {
+ AdapterError::InvalidArtifact("OASF artifact descriptor requires size".into())
+ })?;
+ if size != bytes.len() as u64 {
+ return Err(AdapterError::InvalidArtifact(format!(
+ "descriptor size {size} does not match {}",
+ bytes.len()
+ )));
+ }
+ let digest = descriptor.digest.as_deref().ok_or_else(|| {
+ AdapterError::InvalidArtifact("OASF artifact descriptor requires digest".into())
+ })?;
+ let Some(expected) = digest
+ .strip_prefix("sha256:")
+ .or_else(|| digest.strip_prefix("sha256-"))
+ else {
+ return Err(AdapterError::InvalidArtifact(format!(
+ "unsupported digest {digest:?}; expected sha256:"
+ )));
+ };
+ let actual = hex::encode(Sha256::digest(bytes));
+ if !actual.eq_ignore_ascii_case(expected) {
+ return Err(AdapterError::InvalidArtifact(format!(
+ "sha256 digest mismatch: expected {expected}, got {actual}"
+ )));
+ }
+ Ok(())
+}
+
+async fn descriptor_bytes(
+ descriptor: &Descriptor,
+ record_url: Option<&Url>,
+) -> Result, AdapterError> {
+ let media_type = descriptor.media_type.as_deref().ok_or_else(|| {
+ AdapterError::InvalidArtifact("OASF artifact descriptor requires media_type".into())
+ })?;
+ if !media_type.to_ascii_lowercase().contains("json") {
+ return Err(AdapterError::InvalidArtifact(format!(
+ "A2A artifact media type must be JSON, got {media_type:?}"
+ )));
+ }
+ if let Some(value) = descriptor.json.as_ref() {
+ let bytes = value.get().as_bytes().to_vec();
+ verify_descriptor(descriptor, &bytes)?;
+ return Ok(bytes);
+ }
+ if let Some(data) = descriptor.data.as_deref() {
+ let bytes = base64::engine::general_purpose::STANDARD
+ .decode(data)
+ .map_err(|e| {
+ AdapterError::InvalidArtifact(format!("descriptor data is not base64: {e}"))
+ })?;
+ if bytes.len() > MAX_ARTIFACT_BYTES {
+ return Err(AdapterError::TooLarge {
+ what: "A2A artifact",
+ limit: MAX_ARTIFACT_BYTES,
+ });
+ }
+ verify_descriptor(descriptor, &bytes)?;
+ return Ok(bytes);
+ }
+ if let Some(raw_url) = descriptor.urls.first() {
+ if descriptor.digest.is_none() {
+ return Err(AdapterError::InvalidArtifact(
+ "remote artifact descriptors require a sha256 digest".into(),
+ ));
+ }
+ let url = if let Ok(url) = Url::parse(raw_url) {
+ url
+ } else if let Some(base) = record_url {
+ base.join(raw_url)
+ .map_err(|_| AdapterError::UnsafeEndpoint(raw_url.clone()))?
+ } else {
+ return Err(AdapterError::InvalidArtifact(format!(
+ "relative artifact URL {raw_url:?} requires an HTTP(S) record source"
+ )));
+ };
+ validate_http_url(url.as_str())?;
+ let bytes = read_source(url.as_str(), "A2A artifact", MAX_ARTIFACT_BYTES).await?;
+ verify_descriptor(descriptor, &bytes)?;
+ return Ok(bytes);
+ }
+ Err(AdapterError::InvalidArtifact(
+ "descriptor has no json, data, or urls".into(),
+ ))
+}
+
+fn is_a2a_module(module: &OasfModule) -> bool {
+ module.name.as_deref() == Some("integration/a2a")
+ || module.id.as_ref().and_then(Value::as_u64) == Some(203)
+}
+
+async fn resolve_card(
+ record: AgentRecord,
+ record_url: Option<&Url>,
+) -> Result<(ResolvedAgent, CardSource), AdapterError> {
+ let module = record
+ .modules
+ .iter()
+ .find(|m| is_a2a_module(m))
+ .ok_or_else(|| {
+ AdapterError::InvalidRecord("missing integration/a2a module (id 203)".into())
+ })?;
+ let (card_value, source) = if let Some(artifact) = module.artifact.as_ref() {
+ let descriptor = descriptor_from_raw(artifact)?;
+ let bytes = descriptor_bytes(&descriptor, record_url).await?;
+ (
+ serde_json::from_slice::(&bytes)
+ .map_err(|e| AdapterError::InvalidArtifact(format!("Agent Card JSON: {e}")))?,
+ CardSource::Artifact,
+ )
+ } else if let Some(data) = module.data.as_ref().and_then(|data| data.card_data.clone()) {
+ (data, CardSource::DeprecatedCardData)
+ } else {
+ return Err(AdapterError::InvalidRecord(
+ "integration/a2a module has no artifact; deprecated data.card_data is also absent"
+ .into(),
+ ));
+ };
+ let card: AgentCard = serde_json::from_value(card_value)
+ .map_err(|e| AdapterError::InvalidArtifact(format!("Agent Card shape: {e}")))?;
+ let mode = select_protocol_mode(&card)?;
+ Ok((
+ ResolvedAgent {
+ record_name: record.name,
+ record_schema_version: record.schema_version,
+ card,
+ mode,
+ },
+ source,
+ ))
+}
+
+/// Select the declared JSON-RPC interface, with a named pre-1.0 compatibility path.
+pub fn select_protocol_mode(card: &AgentCard) -> Result {
+ if let Some(interface) = card.supported_interfaces.iter().find(|i| {
+ i.protocol_binding
+ .as_deref()
+ .is_some_and(|binding| binding.to_ascii_lowercase().contains("jsonrpc"))
+ }) {
+ if let Some(endpoint) = interface.url.clone() {
+ validate_http_url(&endpoint)?;
+ return Ok(ProtocolMode::JsonRpc {
+ endpoint,
+ protocol_version: interface.protocol_version.clone(),
+ });
+ }
+ }
+ if let Some(endpoint) = card.service_endpoint.clone() {
+ validate_http_url(&endpoint)?;
+ return Ok(ProtocolMode::VendorServiceEndpoint { endpoint });
+ }
+ if let Some(endpoint) = card.url.clone() {
+ validate_http_url(&endpoint)?;
+ return Ok(ProtocolMode::JsonRpc {
+ endpoint,
+ protocol_version: Some("0.3".into()),
+ });
+ }
+ Err(AdapterError::MissingEndpoint)
+}
+
+fn protocol_request(
+ client: &Client,
+ mode: &ProtocolMode,
+ endpoint: &str,
+) -> reqwest::RequestBuilder {
+ let request = client.post(endpoint);
+ match mode.a2a_version() {
+ Some(version) => request.header("A2A-Version", version),
+ None => request,
+ }
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(tag = "type", rename_all = "snake_case")]
+enum PromptBlock {
+ Text {
+ text: String,
+ },
+ #[serde(other)]
+ Unsupported,
+}
+
+#[derive(Debug, Deserialize)]
+struct PromptParams {
+ #[serde(rename = "sessionId")]
+ session_id: String,
+ prompt: Vec,
+}
+
+#[derive(Debug, Deserialize)]
+struct CancelParams {
+ #[serde(rename = "sessionId")]
+ session_id: String,
+}
+
+fn prompt_text(blocks: &[PromptBlock]) -> Result {
+ let text = blocks
+ .iter()
+ .filter_map(|block| match block {
+ PromptBlock::Text { text } => Some(text.as_str()),
+ PromptBlock::Unsupported => None,
+ })
+ .collect::>()
+ .join("\n");
+ if text.trim().is_empty() {
+ return Err(AdapterError::Acp("prompt contains no text content".into()));
+ }
+ Ok(text)
+}
+
+fn extract_text(value: &Value) -> Option {
+ if let Some(text) = value.get("text").and_then(Value::as_str) {
+ return Some(text.to_owned());
+ }
+ if let Some(parts) = value.get("parts").and_then(Value::as_array) {
+ let joined = parts
+ .iter()
+ .filter_map(extract_text)
+ .collect::>()
+ .join("\n");
+ if !joined.is_empty() {
+ return Some(joined);
+ }
+ }
+ if let Some(artifacts) = value.get("artifacts").and_then(Value::as_array) {
+ let joined = artifacts
+ .iter()
+ .rev()
+ .filter_map(extract_text)
+ .collect::>()
+ .join("\n");
+ if !joined.is_empty() {
+ return Some(joined);
+ }
+ }
+ if let Some(history) = value.get("history").and_then(Value::as_array) {
+ for item in history.iter().rev() {
+ if item.get("role").and_then(Value::as_str) != Some("user") {
+ if let Some(text) = extract_text(item) {
+ return Some(text);
+ }
+ }
+ }
+ }
+ value.get("message").and_then(extract_text)
+}
+
+async fn invoke(
+ resolved: &ResolvedAgent,
+ token: Option<&str>,
+ token_endpoint: Option<&str>,
+ metadata: Option<&Value>,
+ task_poll_secs: u64,
+ session_id: &str,
+ text: &str,
+) -> Result {
+ validate_endpoint_binding(token, token_endpoint, resolved.mode.endpoint())?;
+ let endpoint = Url::parse(resolved.mode.endpoint())
+ .map_err(|_| AdapterError::UnsafeEndpoint(resolved.mode.endpoint().to_owned()))?;
+ let addresses = resolve_network_url(&endpoint).await?;
+ let client = pinned_http_client(&endpoint, &addresses)?;
+ let id = REQUEST_ID.fetch_add(1, Ordering::Relaxed);
+ let payload = request_payload(
+ &resolved.mode,
+ resolved.card.vendor_id.as_deref(),
+ id,
+ session_id,
+ metadata,
+ text,
+ );
+ let mut request =
+ protocol_request(&client, &resolved.mode, resolved.mode.endpoint()).json(&payload);
+ if let Some(token) = token {
+ request = request.bearer_auth(token);
+ }
+ let response = request
+ .send()
+ .await
+ .map_err(|e| AdapterError::Request(e.to_string()))?;
+ let status = response.status();
+ if !status.is_success() {
+ return Err(AdapterError::HttpStatus {
+ what: "A2A request",
+ status,
+ });
+ }
+ let bytes = response_bytes(response, "A2A response", MAX_ARTIFACT_BYTES).await?;
+ let body: Value = serde_json::from_slice(&bytes)
+ .map_err(|e| AdapterError::Request(format!("decode A2A response: {e}")))?;
+ if let Some(error) = body.get("error") {
+ return Err(AdapterError::InvalidResponse(error.to_string()));
+ }
+ let result = body.get("result").unwrap_or(&body);
+ let result = result
+ .get("task")
+ .or_else(|| result.get("message"))
+ .unwrap_or(result);
+ if result.pointer("/status/state").is_some() {
+ let task_id = result
+ .get("id")
+ .and_then(Value::as_str)
+ .unwrap_or("unknown");
+ if let Some(text) = task_outcome(result, task_id)? {
+ return Ok(text);
+ }
+ if task_id != "unknown" {
+ return poll_task(
+ resolved,
+ token,
+ token_endpoint,
+ task_id,
+ metadata,
+ task_poll_secs,
+ &client,
+ )
+ .await;
+ }
+ return Err(AdapterError::InvalidResponse(
+ "A2A task response has no task id".into(),
+ ));
+ }
+ extract_text(result).ok_or_else(|| {
+ AdapterError::InvalidResponse("A2A response contains no message or task state".into())
+ })
+}
+
+async fn poll_task(
+ resolved: &ResolvedAgent,
+ token: Option<&str>,
+ token_endpoint: Option<&str>,
+ task_id: &str,
+ metadata: Option<&Value>,
+ task_poll_secs: u64,
+ client: &Client,
+) -> Result {
+ let started = std::time::Instant::now();
+ let timeout = std::time::Duration::from_secs(task_poll_secs);
+ let mut poll_attempt = 0usize;
+ while started.elapsed() < timeout {
+ let remaining = timeout.saturating_sub(started.elapsed());
+ let delay = std::time::Duration::from_secs(
+ TASK_POLL_BACKOFF_SECS[poll_attempt.min(TASK_POLL_BACKOFF_SECS.len() - 1)],
+ )
+ .min(remaining);
+ tokio::time::sleep(delay).await;
+ poll_attempt = poll_attempt.saturating_add(1);
+ if started.elapsed() >= timeout {
+ break;
+ }
+ let id = REQUEST_ID.fetch_add(1, Ordering::Relaxed);
+ let mut params = match resolved.mode {
+ ProtocolMode::JsonRpc { .. } => json!({ "id": task_id }),
+ ProtocolMode::VendorServiceEndpoint { .. } => json!({ "taskId": task_id }),
+ };
+ if let Some(metadata) = metadata {
+ params["metadata"] = metadata.clone();
+ }
+ let payload = json!({
+ "jsonrpc": "2.0",
+ "id": id,
+ "method": resolved.mode.method(true),
+ "params": params,
+ });
+ validate_endpoint_binding(token, token_endpoint, resolved.mode.endpoint())?;
+ let mut request =
+ protocol_request(client, &resolved.mode, resolved.mode.endpoint()).json(&payload);
+ if let Some(token) = token {
+ request = request.bearer_auth(token);
+ }
+ let response = request
+ .send()
+ .await
+ .map_err(|e| AdapterError::Request(e.to_string()))?;
+ let status = response.status();
+ if !status.is_success() {
+ return Err(AdapterError::HttpStatus {
+ what: "A2A task poll",
+ status,
+ });
+ }
+ let bytes = response_bytes(response, "A2A task response", MAX_ARTIFACT_BYTES).await?;
+ let body: Value = serde_json::from_slice(&bytes)
+ .map_err(|e| AdapterError::Request(format!("decode A2A task response: {e}")))?;
+ if let Some(error) = body.get("error") {
+ return Err(AdapterError::InvalidResponse(error.to_string()));
+ }
+ let result = body.get("result").unwrap_or(&body);
+ let result = result
+ .get("task")
+ .or_else(|| result.get("message"))
+ .unwrap_or(result);
+ if let Some(text) = task_outcome(result, task_id)? {
+ return Ok(text);
+ }
+ }
+ Err(AdapterError::TaskTimeout(task_poll_secs))
+}
+
+fn validate_endpoint_binding(
+ token: Option<&str>,
+ expected_endpoint: Option<&str>,
+ actual_endpoint: &str,
+) -> Result<(), AdapterError> {
+ let endpoints_match = match expected_endpoint {
+ Some(expected) => {
+ let expected = validate_http_url(expected)?;
+ let actual = validate_http_url(actual_endpoint)?;
+ expected == actual
+ }
+ None => token.is_none(),
+ };
+ if !endpoints_match {
+ return Err(AdapterError::UnauthorizedTokenEndpoint(
+ actual_endpoint.to_owned(),
+ ));
+ }
+ Ok(())
+}
+
+fn task_outcome(result: &Value, task_id: &str) -> Result, AdapterError> {
+ let wire_state = result
+ .get("status")
+ .and_then(|status| status.get("state"))
+ .and_then(Value::as_str)
+ .ok_or_else(|| {
+ AdapterError::InvalidResponse(format!("A2A task {task_id} has no status state"))
+ })?;
+ let normalized_state = wire_state.trim().to_ascii_lowercase();
+ let state = normalized_state
+ .strip_prefix("task_state_")
+ .unwrap_or(&normalized_state);
+ match state {
+ "completed" => {
+ Ok(Some(extract_text(result).unwrap_or_else(|| {
+ format!("A2A task {task_id} completed")
+ })))
+ }
+ "accepted" | "submitted" | "working" | "pending" => Ok(None),
+ "failed" | "canceled" | "cancelled" | "rejected" | "input-required" | "input_required" => {
+ let detail = extract_text(result)
+ .map(|text| format!(": {text}"))
+ .unwrap_or_default();
+ Err(AdapterError::InvalidResponse(format!(
+ "A2A task {task_id} ended in {state}{detail}"
+ )))
+ }
+ other => Err(AdapterError::InvalidResponse(format!(
+ "A2A task {task_id} has unknown state {wire_state} (normalized as {other})"
+ ))),
+ }
+}
+
+fn request_payload(
+ mode: &ProtocolMode,
+ agent_id: Option<&str>,
+ id: u64,
+ session_id: &str,
+ metadata: Option<&Value>,
+ text: &str,
+) -> Value {
+ let mut params = match mode {
+ ProtocolMode::JsonRpc {
+ protocol_version, ..
+ } if protocol_version
+ .as_deref()
+ .is_some_and(|version| version.starts_with("1.")) =>
+ {
+ json!({
+ "message": { "messageId": format!("buzz-{id}"), "role": "ROLE_USER", "contextId": session_id, "parts": [{ "text": text }] },
+ })
+ }
+ ProtocolMode::JsonRpc { .. } => json!({
+ "message": { "messageId": format!("buzz-{id}"), "role": "user", "contextId": session_id, "parts": [{ "kind": "text", "text": text }] },
+ }),
+ ProtocolMode::VendorServiceEndpoint { .. } => json!({
+ "agentId": agent_id,
+ "message": { "role": "user", "parts": [{ "type": "text", "text": text }] },
+ "contextId": session_id,
+ }),
+ };
+ if let Some(metadata) = metadata {
+ params["metadata"] = metadata.clone();
+ }
+ json!({ "jsonrpc": "2.0", "id": id, "method": mode.method(false), "params": params })
+}
+
+async fn send_json(
+ writer: &mut W,
+ value: Value,
+) -> Result<(), AdapterError> {
+ let mut line = serde_json::to_vec(&value)
+ .map_err(|e| AdapterError::Acp(format!("encode response: {e}")))?;
+ line.push(b'\n');
+ writer
+ .write_all(&line)
+ .await
+ .map_err(|e| AdapterError::Acp(format!("write response: {e}")))?;
+ writer
+ .flush()
+ .await
+ .map_err(|e| AdapterError::Acp(format!("flush response: {e}")))?;
+ Ok(())
+}
+
+enum AcpAction {
+ Response(Value),
+ Prompt {
+ id: Value,
+ session_id: String,
+ text: String,
+ },
+ Cancel {
+ id: Option,
+ session_id: String,
+ },
+}
+
+fn handle_acp_message(
+ message: &Value,
+ sessions: &mut HashSet,
+ agent_name: &str,
+ configured_context_id: Option<&str>,
+) -> Result, AdapterError> {
+ let method = message.get("method").and_then(Value::as_str);
+ if method == Some("session/cancel") {
+ let params: CancelParams =
+ serde_json::from_value(message.get("params").cloned().unwrap_or(Value::Null))
+ .map_err(|e| AdapterError::Acp(format!("session/cancel params: {e}")))?;
+ return Ok(Some(AcpAction::Cancel {
+ id: message.get("id").cloned(),
+ session_id: params.session_id,
+ }));
+ }
+ let Some(id) = message.get("id").cloned() else {
+ return Ok(None);
+ };
+ match method {
+ Some("initialize") => Ok(Some(AcpAction::Response(json!({
+ "jsonrpc": "2.0",
+ "id": id,
+ "result": {
+ "protocolVersion": message.pointer("/params/protocolVersion").and_then(Value::as_u64).unwrap_or(1).min(1),
+ "agentCapabilities": {
+ "loadSession": false,
+ "promptCapabilities": { "image": false, "audio": false, "embeddedContext": false },
+ "mcpCapabilities": { "http": false, "sse": false },
+ },
+ "agentInfo": { "name": agent_name, "version": "oasf-a2a" },
+ }
+ })))),
+ Some("session/new") => {
+ let session_id = configured_context_id
+ .map(str::to_owned)
+ .unwrap_or_else(|| format!("a2a-{}", Uuid::new_v4()));
+ sessions.insert(session_id.clone());
+ Ok(Some(AcpAction::Response(json!({
+ "jsonrpc": "2.0",
+ "id": id,
+ "result": { "sessionId": session_id },
+ }))))
+ }
+ Some("session/prompt") => {
+ let params: PromptParams =
+ serde_json::from_value(message.get("params").cloned().unwrap_or(Value::Null))
+ .map_err(|e| AdapterError::Acp(format!("session/prompt params: {e}")))?;
+ if !sessions.contains(¶ms.session_id) {
+ return Ok(Some(AcpAction::Response(json!({
+ "jsonrpc": "2.0",
+ "id": id,
+ "error": { "code": -32602, "message": "unknown session" },
+ }))));
+ }
+ let text = match prompt_text(¶ms.prompt) {
+ Ok(text) => text,
+ Err(error) => {
+ return Ok(Some(AcpAction::Response(json!({
+ "jsonrpc": "2.0",
+ "id": id,
+ "error": { "code": -32602, "message": error.to_string() },
+ }))));
+ }
+ };
+ Ok(Some(AcpAction::Prompt {
+ id,
+ session_id: params.session_id,
+ text,
+ }))
+ }
+ Some(method) => Ok(Some(AcpAction::Response(json!({
+ "jsonrpc": "2.0",
+ "id": id,
+ "error": { "code": -32601, "message": format!("method not found: {method}") },
+ })))),
+ None => Ok(None),
+ }
+}
+
+fn prompt_success(id: Value, session_id: &str, text: &str) -> [Value; 2] {
+ [
+ json!({
+ "jsonrpc": "2.0",
+ "method": "session/update",
+ "params": {
+ "sessionId": session_id,
+ "update": {
+ "sessionUpdate": "agent_message_chunk",
+ "content": { "type": "text", "text": text },
+ }
+ }
+ }),
+ json!({ "jsonrpc": "2.0", "id": id, "result": { "stopReason": "end_turn" } }),
+ ]
+}
+
+struct ActivePrompt {
+ id: Value,
+ session_id: String,
+ task: tokio::task::JoinHandle>,
+}
+
+enum LoopEvent {
+ Input(Option, AdapterError>>),
+ PromptFinished(Result, tokio::task::JoinError>),
+}
+
+/// Run the adapter over ACP JSON-RPC lines on stdin/stdout.
+pub async fn run(config: AdapterConfig) -> Result<(), AdapterError> {
+ let record = load_record(&config.record).await?;
+ eprintln!(
+ "buzz-a2a-acp: resolved Agent Record {} ({})",
+ record.content_digest,
+ record.verification.label()
+ );
+ let (resolved, source) = resolve_card(record.record, record.base.as_ref()).await?;
+ if source == CardSource::DeprecatedCardData {
+ eprintln!(
+ "buzz-a2a-acp: using deprecated OASF integration/a2a data.card_data compatibility path"
+ );
+ }
+ let mut sessions = HashSet::new();
+ let mut lines = spawn_line_reader(BufReader::new(tokio::io::stdin()));
+ let mut writer = tokio::io::stdout();
+ let mut active_prompt: Option = None;
+ loop {
+ let event = if let Some(active) = active_prompt.as_mut() {
+ tokio::select! {
+ line = lines.recv() => LoopEvent::Input(line),
+ result = &mut active.task => LoopEvent::PromptFinished(result),
+ }
+ } else {
+ LoopEvent::Input(lines.recv().await)
+ };
+ match event {
+ LoopEvent::PromptFinished(result) => {
+ let active = active_prompt
+ .take()
+ .expect("completed prompt must still be active");
+ match result {
+ Ok(Ok(text)) => {
+ for value in prompt_success(active.id, &active.session_id, &text) {
+ send_json(&mut writer, value).await?;
+ }
+ }
+ Ok(Err(error)) => {
+ send_json(
+ &mut writer,
+ json!({ "jsonrpc": "2.0", "id": active.id, "error": { "code": -32000, "message": error.to_string() } }),
+ )
+ .await?;
+ }
+ Err(error) => {
+ send_json(
+ &mut writer,
+ json!({ "jsonrpc": "2.0", "id": active.id, "error": { "code": -32000, "message": format!("remote prompt task failed: {error}") } }),
+ )
+ .await?;
+ }
+ }
+ }
+ LoopEvent::Input(None | Some(Ok(None))) => return Ok(()),
+ LoopEvent::Input(Some(Err(error))) => {
+ eprintln!("buzz-a2a-acp: ignored malformed ACP input: {error}");
+ }
+ LoopEvent::Input(Some(Ok(Some(line)))) => {
+ let message: Value = match serde_json::from_str(line.trim()) {
+ Ok(message) => message,
+ Err(error) => {
+ eprintln!("buzz-a2a-acp: ignored malformed JSON-RPC line: {error}");
+ continue;
+ }
+ };
+ let action = match handle_acp_message(
+ &message,
+ &mut sessions,
+ resolved.card.name.as_deref().unwrap_or("remote-a2a-agent"),
+ config.context_id.as_deref(),
+ ) {
+ Ok(action) => action,
+ Err(error) => {
+ if let Some(id) = message.get("id").cloned() {
+ send_json(
+ &mut writer,
+ json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32602, "message": error.to_string() } }),
+ )
+ .await?;
+ } else {
+ eprintln!("buzz-a2a-acp: ignored invalid notification: {error}");
+ }
+ continue;
+ }
+ };
+ match action {
+ Some(AcpAction::Response(response)) => send_json(&mut writer, response).await?,
+ Some(AcpAction::Prompt {
+ id,
+ session_id,
+ text,
+ }) => {
+ if active_prompt.is_some() {
+ send_json(
+ &mut writer,
+ json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32001, "message": "another prompt is already active" } }),
+ )
+ .await?;
+ continue;
+ }
+ let prompt_resolved = resolved.clone();
+ let prompt_token = config.bearer_token.clone();
+ let prompt_token_endpoint = config.bearer_token_endpoint.clone();
+ let prompt_metadata = request_metadata(&config);
+ let prompt_task_poll_secs = config.task_poll_secs;
+ let prompt_session_id = session_id.clone();
+ let task = tokio::spawn(async move {
+ invoke(
+ &prompt_resolved,
+ prompt_token.as_deref(),
+ prompt_token_endpoint.as_deref(),
+ prompt_metadata.as_ref(),
+ prompt_task_poll_secs,
+ &prompt_session_id,
+ &text,
+ )
+ .await
+ });
+ active_prompt = Some(ActivePrompt {
+ id,
+ session_id,
+ task,
+ });
+ }
+ Some(AcpAction::Cancel { id, session_id }) => {
+ if active_prompt
+ .as_ref()
+ .is_some_and(|active| active.session_id == session_id)
+ {
+ let active = active_prompt
+ .take()
+ .expect("matching prompt must still be active");
+ active.task.abort();
+ send_json(
+ &mut writer,
+ json!({ "jsonrpc": "2.0", "id": active.id, "result": { "stopReason": "cancelled" } }),
+ )
+ .await?;
+ if let Some(id) = id {
+ send_json(
+ &mut writer,
+ json!({ "jsonrpc": "2.0", "id": id, "result": {} }),
+ )
+ .await?;
+ }
+ } else if let Some(id) = id {
+ send_json(
+ &mut writer,
+ json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32602, "message": "no active prompt for session" } }),
+ )
+ .await?;
+ }
+ }
+ None => {}
+ }
+ }
+ }
+ }
+}
+
+fn spawn_line_reader(
+ mut reader: R,
+) -> tokio::sync::mpsc::Receiver, AdapterError>>
+where
+ R: tokio::io::AsyncBufRead + Send + Unpin + 'static,
+{
+ let (sender, receiver) = tokio::sync::mpsc::channel(8);
+ tokio::spawn(async move {
+ loop {
+ let line = read_bounded_line(&mut reader).await;
+ let reached_eof = matches!(line, Ok(None));
+ let transport_failed = matches!(line, Err(AdapterError::Read { .. }));
+ if sender.send(line).await.is_err() || reached_eof || transport_failed {
+ break;
+ }
+ }
+ });
+ receiver
+}
+
+async fn read_bounded_line(
+ reader: &mut R,
+) -> Result, AdapterError> {
+ let mut bytes = Vec::new();
+ loop {
+ let chunk = reader
+ .fill_buf()
+ .await
+ .map_err(|source| AdapterError::Read {
+ what: "ACP request",
+ source,
+ })?;
+ if chunk.is_empty() {
+ if bytes.is_empty() {
+ return Ok(None);
+ }
+ return Err(AdapterError::Acp("unterminated request at EOF".into()));
+ }
+ let take = chunk
+ .iter()
+ .position(|byte| *byte == b'\n')
+ .map_or(chunk.len(), |index| index + 1);
+ if bytes.len().saturating_add(take) > MAX_ACP_LINE_BYTES {
+ let ended = chunk[..take].ends_with(b"\n");
+ reader.consume(take);
+ if !ended {
+ discard_until_newline(reader).await?;
+ }
+ return Err(AdapterError::Acp("request exceeds 1 MiB".into()));
+ }
+ bytes.extend_from_slice(&chunk[..take]);
+ reader.consume(take);
+ if bytes.ends_with(b"\n") {
+ bytes.pop();
+ if bytes.ends_with(b"\r") {
+ bytes.pop();
+ }
+ return String::from_utf8(bytes)
+ .map(Some)
+ .map_err(|_| AdapterError::Acp("request is not UTF-8".into()));
+ }
+ }
+}
+
+async fn discard_until_newline(
+ reader: &mut R,
+) -> Result<(), AdapterError> {
+ loop {
+ let chunk = reader
+ .fill_buf()
+ .await
+ .map_err(|source| AdapterError::Read {
+ what: "ACP request",
+ source,
+ })?;
+ if chunk.is_empty() {
+ return Ok(());
+ }
+ let take = chunk
+ .iter()
+ .position(|byte| *byte == b'\n')
+ .map_or(chunk.len(), |index| index + 1);
+ let ended = chunk[..take].ends_with(b"\n");
+ reader.consume(take);
+ if ended {
+ return Ok(());
+ }
+ }
+}
+
+fn request_metadata(config: &AdapterConfig) -> Option {
+ let mut metadata = serde_json::Map::new();
+ if let Some(value) = config.agency_ref.as_ref() {
+ metadata.insert("agencyRef".into(), Value::String(value.clone()));
+ }
+ if let Some(value) = config.space_ref.as_ref() {
+ metadata.insert("spaceRef".into(), Value::String(value.clone()));
+ }
+ if let Some(value) = config.channel_ref.as_ref() {
+ metadata.insert("channelRef".into(), Value::String(value.clone()));
+ }
+ if let Some(value) = config.agent_ref.as_ref() {
+ metadata.insert("agentRef".into(), Value::String(value.clone()));
+ }
+ (!metadata.is_empty()).then_some(Value::Object(metadata))
+}
+
+/// Run the adapter as a normal CLI process. Sprig uses this entry point for
+/// the `buzz-a2a-acp` multicall personality.
+pub fn run_cli() -> Result<(), String> {
+ let args = Cli::parse();
+ let bearer_token = std::env::var("BUZZ_A2A_BEARER_TOKEN")
+ .ok()
+ .filter(|value| !value.trim().is_empty());
+ tokio::runtime::Builder::new_multi_thread()
+ .enable_all()
+ .build()
+ .map_err(|error| format!("build runtime: {error}"))?
+ .block_on(run(AdapterConfig {
+ record: args.record,
+ bearer_token,
+ bearer_token_endpoint: args.bearer_token_endpoint,
+ agency_ref: args.agency_ref,
+ space_ref: args.space_ref,
+ channel_ref: args.channel_ref,
+ agent_ref: args.agent_ref,
+ context_id: args.context_id,
+ task_poll_secs: args.task_poll_secs,
+ }))
+ .map_err(|error| error.to_string())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::fs;
+
+ fn card(endpoint: &str) -> Value {
+ json!({ "id": "example-agent", "name": "Example Agent", "serviceEndpoint": endpoint })
+ }
+
+ #[tokio::test]
+ async fn resolves_oasf_artifact_and_validates_descriptor() {
+ let card = card("http://127.0.0.1:1337/a2a");
+ let bytes = serde_json::to_vec(&card).expect("test card serializes");
+ let digest = format!("sha256:{}", hex::encode(Sha256::digest(&bytes)));
+ let record = json!({ "name": "Example Agent", "schema_version": "1.0.0", "modules": [{ "name": "integration/a2a", "id": 203, "artifact": { "json": card, "digest": digest, "media_type": "application/a2a-agent-card+json", "size": bytes.len() } }] });
+ let path = std::env::temp_dir().join(format!(
+ "buzz-a2a-record-{}-{}.json",
+ std::process::id(),
+ REQUEST_ID.fetch_add(1, Ordering::Relaxed)
+ ));
+ fs::write(
+ &path,
+ serde_json::to_vec(&record).expect("test record serializes"),
+ )
+ .expect("write record");
+ let loaded = load_record(path.to_str().expect("temp path is utf8"))
+ .await
+ .expect("load record");
+ let (resolved, source) = resolve_card(loaded.record, loaded.base.as_ref())
+ .await
+ .expect("resolve card");
+ assert_eq!(source, CardSource::Artifact);
+ assert_eq!(
+ resolved.mode,
+ ProtocolMode::VendorServiceEndpoint {
+ endpoint: "http://127.0.0.1:1337/a2a".into()
+ }
+ );
+ let _ = fs::remove_file(path);
+ }
+
+ #[tokio::test]
+ async fn accepts_one_record_collection_and_preserves_embedded_artifact_bytes() {
+ let raw_card = r#"{"name":"Example Agent","version":"1.0.0","supportedInterfaces":[{"url":"http://127.0.0.1:1337/a2a/agent","protocolBinding":"JSONRPC","protocolVersion":"1.0"}]}"#;
+ let digest = format!(
+ "sha256:{}",
+ hex::encode(Sha256::digest(raw_card.as_bytes()))
+ );
+ let record = format!(
+ r#"[{{"name":"Example Agent","schema_version":"1.0.0","modules":[{{"name":"integration/a2a","id":203,"artifact":{{"json":{raw_card},"digest":"{digest}","media_type":"application/a2a-agent-card+json","size":{}}}}}]}}]"#,
+ raw_card.len()
+ );
+ let path = std::env::temp_dir().join(format!(
+ "buzz-a2a-record-collection-{}-{}.json",
+ std::process::id(),
+ REQUEST_ID.fetch_add(1, Ordering::Relaxed)
+ ));
+ fs::write(&path, record).expect("write record collection");
+ let record = load_record(path.to_str().expect("temp path is utf8"))
+ .await
+ .expect("load one-record collection");
+ let (resolved, source) = resolve_card(record.record, record.base.as_ref())
+ .await
+ .expect("resolve exact embedded artifact bytes");
+ assert_eq!(source, CardSource::Artifact);
+ assert_eq!(resolved.mode.endpoint(), "http://127.0.0.1:1337/a2a/agent");
+ let _ = fs::remove_file(path);
+ }
+
+ #[test]
+ fn prefers_declared_jsonrpc_interface() {
+ let card: AgentCard = serde_json::from_value(json!({ "supportedInterfaces": [{ "url": "https://agent.example/rpc", "protocolBinding": "JSONRPC", "protocolVersion": "1.0" }], "serviceEndpoint": "https://legacy.example/a2a" })).expect("card");
+ assert_eq!(
+ select_protocol_mode(&card).expect("mode"),
+ ProtocolMode::JsonRpc {
+ endpoint: "https://agent.example/rpc".into(),
+ protocol_version: Some("1.0".into()),
+ }
+ );
+ }
+
+ #[test]
+ fn builds_current_and_vendor_requests() {
+ let current = request_payload(
+ &ProtocolMode::JsonRpc {
+ endpoint: "https://agent.example/rpc".into(),
+ protocol_version: Some("1.0".into()),
+ },
+ Some("remote"),
+ 1,
+ "session",
+ None,
+ "hello",
+ );
+ assert_eq!(current["method"], "SendMessage");
+ assert_eq!(current["params"]["message"]["role"], "ROLE_USER");
+ assert!(current["params"]["message"]["parts"][0]["kind"].is_null());
+ assert_eq!(current["params"]["message"]["parts"][0]["text"], "hello");
+ let vendor = request_payload(
+ &ProtocolMode::VendorServiceEndpoint {
+ endpoint: "http://127.0.0.1:1337/a2a".into(),
+ },
+ Some("remote"),
+ 2,
+ "session",
+ None,
+ "hello",
+ );
+ assert_eq!(vendor["method"], "agent/sendMessage");
+ assert_eq!(vendor["params"]["agentId"], "remote");
+ }
+
+ #[test]
+ fn sends_a2a_version_header_for_standard_modes_only() {
+ let client = Client::new();
+ for (mode, expected) in [
+ (
+ ProtocolMode::JsonRpc {
+ endpoint: "https://agent.example/rpc".into(),
+ protocol_version: Some("1.0".into()),
+ },
+ Some("1.0"),
+ ),
+ (
+ ProtocolMode::JsonRpc {
+ endpoint: "https://agent.example/rpc".into(),
+ protocol_version: Some("0.3".into()),
+ },
+ Some("0.3"),
+ ),
+ (
+ ProtocolMode::VendorServiceEndpoint {
+ endpoint: "https://agent.example/rpc".into(),
+ },
+ None,
+ ),
+ ] {
+ let request = protocol_request(&client, &mode, mode.endpoint())
+ .build()
+ .expect("request builds");
+ assert_eq!(
+ request
+ .headers()
+ .get("A2A-Version")
+ .and_then(|value| value.to_str().ok()),
+ expected
+ );
+ }
+ }
+
+ #[test]
+ fn builds_a2a_0_3_request_and_preserves_context() {
+ let request = request_payload(
+ &ProtocolMode::JsonRpc {
+ endpoint: "https://agent.example/rpc".into(),
+ protocol_version: Some("0.3".into()),
+ },
+ Some("remote"),
+ 3,
+ "buzz-session",
+ None,
+ "hello",
+ );
+ assert_eq!(request["method"], "message/send");
+ assert!(request["params"]["contextId"].is_null());
+ assert_eq!(request["params"]["message"]["contextId"], "buzz-session");
+ assert_eq!(request["params"]["message"]["parts"][0]["kind"], "text");
+ }
+
+ #[test]
+ fn rejects_non_loopback_http_endpoints() {
+ let card: AgentCard = serde_json::from_value(json!({
+ "supportedInterfaces": [{
+ "url": "http://remote.example/a2a",
+ "protocolBinding": "JSONRPC",
+ "protocolVersion": "1.0"
+ }]
+ }))
+ .expect("card");
+ assert!(matches!(
+ select_protocol_mode(&card),
+ Err(AdapterError::UnsafeEndpoint(_))
+ ));
+ let private_card: AgentCard = serde_json::from_value(json!({
+ "supportedInterfaces": [{
+ "url": "https://127.0.0.1/a2a",
+ "protocolBinding": "JSONRPC",
+ "protocolVersion": "1.0"
+ }]
+ }))
+ .expect("card");
+ assert!(matches!(
+ select_protocol_mode(&private_card),
+ Err(AdapterError::UnsafeEndpoint(_))
+ ));
+ }
+
+ #[tokio::test]
+ async fn pinned_localhost_client_falls_back_across_checked_addresses() {
+ let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
+ .await
+ .expect("bind IPv4 listener");
+ let port = listener.local_addr().expect("listener address").port();
+ let server = tokio::spawn(async move {
+ let (mut socket, _) = listener.accept().await.expect("accept request");
+ socket
+ .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
+ .await
+ .expect("write response");
+ });
+ let url = Url::parse(&format!("http://localhost:{port}/a2a")).expect("url");
+ let addresses = [
+ SocketAddr::new(IpAddr::V6(std::net::Ipv6Addr::LOCALHOST), port),
+ SocketAddr::new(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), port),
+ ];
+ validate_resolved_addresses(&url, &addresses).expect("loopback addresses");
+
+ let response = tokio::time::timeout(
+ std::time::Duration::from_secs(2),
+ pinned_http_client(&url, &addresses)
+ .expect("pinned client")
+ .get(url)
+ .send(),
+ )
+ .await
+ .expect("connect using validated fallback")
+ .expect("HTTP response");
+ assert_eq!(response.status(), StatusCode::OK);
+ server.await.expect("server task");
+ }
+
+ #[test]
+ fn resolver_policy_rejects_mixed_or_private_addresses() {
+ let localhost = Url::parse("http://localhost:1337/a2a").expect("url");
+ assert!(validate_resolved_addresses(
+ &localhost,
+ &[
+ SocketAddr::from(([127, 0, 0, 1], 1337)),
+ SocketAddr::from(([8, 8, 8, 8], 1337))
+ ]
+ )
+ .is_err());
+ let public = Url::parse("https://agent.example/a2a").expect("url");
+ assert!(validate_resolved_addresses(
+ &public,
+ &[
+ SocketAddr::from(([8, 8, 8, 8], 443)),
+ SocketAddr::from(([10, 0, 0, 1], 443))
+ ]
+ )
+ .is_err());
+ assert!(
+ validate_resolved_addresses(&public, &[SocketAddr::from(([8, 8, 8, 8], 443))]).is_ok()
+ );
+ }
+
+ #[test]
+ fn reviewed_endpoint_is_enforced_even_without_a_token() {
+ assert!(validate_endpoint_binding(
+ None,
+ Some("https://reviewed.example/a2a"),
+ "https://reviewed.example/a2a"
+ )
+ .is_ok());
+ assert!(matches!(
+ validate_endpoint_binding(
+ None,
+ Some("https://reviewed.example/a2a"),
+ "https://other.example/a2a"
+ ),
+ Err(AdapterError::UnauthorizedTokenEndpoint(_))
+ ));
+ assert!(matches!(
+ validate_endpoint_binding(Some("secret"), None, "https://agent.example/a2a"),
+ Err(AdapterError::UnauthorizedTokenEndpoint(_))
+ ));
+ }
+
+ #[test]
+ fn projects_agency_space_channel_and_agent_context() {
+ let metadata = request_metadata(&AdapterConfig {
+ record: "record.json".into(),
+ bearer_token: None,
+ bearer_token_endpoint: None,
+ agency_ref: Some("agency-1".into()),
+ space_ref: Some("space-1".into()),
+ channel_ref: Some("channel-1".into()),
+ agent_ref: Some("agent-1".into()),
+ context_id: None,
+ task_poll_secs: DEFAULT_TASK_POLL_SECS,
+ })
+ .expect("metadata");
+ assert_eq!(metadata["agencyRef"], "agency-1");
+ assert_eq!(metadata["spaceRef"], "space-1");
+ assert_eq!(metadata["channelRef"], "channel-1");
+ assert_eq!(metadata["agentRef"], "agent-1");
+ }
+
+ #[tokio::test]
+ async fn deprecated_card_data_is_explicit_compatibility_path() {
+ let record: AgentRecord = serde_json::from_value(json!({ "modules": [{ "name": "integration/a2a", "data": { "card_data": card("http://127.0.0.1:1337/a2a"), "card_schema_version": "0.3" } }] })).expect("record");
+ let (_, source) = resolve_card(record, None).await.expect("resolve card");
+ assert_eq!(source, CardSource::DeprecatedCardData);
+ }
+
+ #[tokio::test]
+ async fn digest_mismatch_is_rejected() {
+ let artifact = json!({ "name": "wrong" });
+ let artifact_bytes = serde_json::to_vec(&artifact).expect("artifact serializes");
+ let descriptor: Descriptor = serde_json::from_value(json!({
+ "json": artifact,
+ "digest": "sha256:00",
+ "media_type": "application/json",
+ "size": artifact_bytes.len()
+ }))
+ .expect("descriptor");
+ let err = descriptor_bytes(&descriptor, None)
+ .await
+ .expect_err("mismatch");
+ assert!(err.to_string().contains("digest mismatch"));
+ }
+
+ #[tokio::test]
+ async fn missing_oasf_descriptor_media_type_is_rejected() {
+ let artifact = json!({ "name": "missing media type" });
+ let artifact_bytes = serde_json::to_vec(&artifact).expect("artifact serializes");
+ let descriptor: Descriptor = serde_json::from_value(json!({
+ "json": artifact,
+ "digest": format!("sha256:{}", hex::encode(Sha256::digest(&artifact_bytes))),
+ "size": artifact_bytes.len()
+ }))
+ .expect("descriptor");
+ let err = descriptor_bytes(&descriptor, None)
+ .await
+ .expect_err("missing media_type");
+ assert!(err.to_string().contains("requires media_type"));
+ }
+
+ #[tokio::test]
+ async fn acp_transcript_handles_initialize_new_and_prompt() {
+ let mut sessions = HashSet::new();
+ let initialize = handle_acp_message(
+ &json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": 1 } }),
+ &mut sessions,
+ "remote-agent",
+ None,
+ )
+ .expect("initialize action")
+ .expect("initialize response");
+ let AcpAction::Response(initialize) = initialize else {
+ panic!("initialize must return a response");
+ };
+ assert_eq!(initialize["result"]["protocolVersion"], 1);
+ assert_eq!(initialize["result"]["agentInfo"]["name"], "remote-agent");
+
+ let new = handle_acp_message(
+ &json!({ "jsonrpc": "2.0", "id": 2, "method": "session/new", "params": {} }),
+ &mut sessions,
+ "remote-agent",
+ None,
+ )
+ .expect("session/new action")
+ .expect("session/new response");
+ let AcpAction::Response(new) = new else {
+ panic!("session/new must return a response");
+ };
+ let session_id = new["result"]["sessionId"]
+ .as_str()
+ .expect("session id")
+ .to_owned();
+ assert!(sessions.contains(&session_id));
+
+ let prompt = handle_acp_message(
+ &json!({
+ "jsonrpc": "2.0",
+ "id": 3,
+ "method": "session/prompt",
+ "params": { "sessionId": session_id, "prompt": [{ "type": "text", "text": "ship it" }] }
+ }),
+ &mut sessions,
+ "remote-agent",
+ None,
+ )
+ .expect("session/prompt action")
+ .expect("session/prompt request");
+ let AcpAction::Prompt {
+ id,
+ session_id,
+ text,
+ } = prompt
+ else {
+ panic!("session/prompt must invoke the remote runtime");
+ };
+ assert_eq!(id, 3);
+ assert_eq!(text, "ship it");
+ let [update, result] = prompt_success(id, &session_id, "done");
+ assert_eq!(update["method"], "session/update");
+ assert_eq!(update["params"]["sessionId"], session_id);
+ assert_eq!(update["params"]["update"]["content"]["text"], "done");
+ assert_eq!(result["result"]["stopReason"], "end_turn");
+ }
+
+ #[test]
+ fn terminal_task_polling_distinguishes_working_and_terminal_states() {
+ assert_eq!(
+ task_outcome(&json!({ "status": { "state": "working" } }), "task-1")
+ .expect("working is nonterminal"),
+ None,
+ );
+ assert_eq!(
+ task_outcome(
+ &json!({ "status": { "state": "TASK_STATE_SUBMITTED" } }),
+ "task-1"
+ )
+ .expect("protobuf-style submitted is nonterminal"),
+ None,
+ );
+ assert_eq!(
+ task_outcome(&json!({ "status": { "state": "completed" } }), "task-1")
+ .expect("completed is successful"),
+ Some("A2A task task-1 completed".into()),
+ );
+ assert_eq!(
+ task_outcome(
+ &json!({ "status": { "state": "TASK_STATE_COMPLETED" } }),
+ "task-1"
+ )
+ .expect("protobuf-style completed is successful"),
+ Some("A2A task task-1 completed".into()),
+ );
+ let failed = task_outcome(
+ &json!({
+ "status": { "state": "TASK_STATE_FAILED" },
+ "parts": [{ "text": "remote failure" }]
+ }),
+ "task-1",
+ );
+ assert!(failed
+ .expect_err("failed tasks are ACP errors")
+ .to_string()
+ .contains("remote failure"));
+ }
+
+ #[test]
+ fn unwraps_current_a2a_task_and_message_results() {
+ let task = json!({ "task": { "id": "task-2", "status": { "state": "completed" }, "artifacts": [{ "parts": [{ "text": "complete" }] }] } });
+ let task_result = task.get("task").expect("task result");
+ assert_eq!(task_result["id"], "task-2");
+ assert_eq!(
+ task_outcome(task_result, "task-2").expect("completed task"),
+ Some("complete".into()),
+ );
+
+ let message = json!({ "message": { "messageId": "m-1", "role": "ROLE_AGENT", "parts": [{ "text": "direct response" }] } });
+ let message_result = message.get("message").expect("message result");
+ assert_eq!(extract_text(message_result), Some("direct response".into()));
+ }
+
+ #[tokio::test]
+ async fn acp_reader_accepts_multiple_bounded_lines() {
+ let input = b"{\"method\":\"initialize\"}\r\n{\"method\":\"session/new\"}\n";
+ let mut reader = BufReader::new(&input[..]);
+ assert_eq!(
+ read_bounded_line(&mut reader).await.expect("first line"),
+ Some("{\"method\":\"initialize\"}".into())
+ );
+ assert_eq!(
+ read_bounded_line(&mut reader).await.expect("second line"),
+ Some("{\"method\":\"session/new\"}".into())
+ );
+ assert_eq!(read_bounded_line(&mut reader).await.expect("eof"), None);
+ }
+
+ #[tokio::test]
+ async fn spawned_reader_preserves_partial_lines_while_other_work_completes() {
+ use tokio::io::AsyncWriteExt;
+
+ let (mut writer, reader) = tokio::io::duplex(32 * 1024);
+ let mut lines = spawn_line_reader(BufReader::with_capacity(8 * 1024, reader));
+ let line = format!(
+ "{{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"session/prompt\",\"padding\":\"{}\"}}",
+ "P".repeat(20 * 1024)
+ );
+ writer
+ .write_all(&line.as_bytes()[..12 * 1024])
+ .await
+ .expect("write first fragment");
+ tokio::task::yield_now().await;
+ writer
+ .write_all(&line.as_bytes()[12 * 1024..])
+ .await
+ .expect("write second fragment");
+ writer.write_all(b"\n").await.expect("finish line");
+
+ assert_eq!(
+ lines
+ .recv()
+ .await
+ .expect("reader remains available")
+ .expect("line is valid"),
+ Some(line),
+ );
+ }
+
+ #[tokio::test]
+ async fn spawned_reader_stops_after_a_transport_error() {
+ use std::{
+ pin::Pin,
+ sync::{
+ atomic::{AtomicUsize, Ordering},
+ Arc,
+ },
+ task::{Context, Poll},
+ };
+ use tokio::io::{AsyncBufRead, AsyncRead, ReadBuf};
+
+ struct FailingReader {
+ reads: Arc,
+ }
+
+ impl AsyncRead for FailingReader {
+ fn poll_read(
+ self: Pin<&mut Self>,
+ _cx: &mut Context<'_>,
+ _buf: &mut ReadBuf<'_>,
+ ) -> Poll> {
+ Poll::Ready(Err(std::io::Error::other("transport failed")))
+ }
+ }
+
+ impl AsyncBufRead for FailingReader {
+ fn poll_fill_buf(
+ self: Pin<&mut Self>,
+ _cx: &mut Context<'_>,
+ ) -> Poll> {
+ self.reads.fetch_add(1, Ordering::Relaxed);
+ Poll::Ready(Err(std::io::Error::other("transport failed")))
+ }
+
+ fn consume(self: Pin<&mut Self>, _amount: usize) {}
+ }
+
+ let reads = Arc::new(AtomicUsize::new(0));
+ let mut lines = spawn_line_reader(FailingReader {
+ reads: Arc::clone(&reads),
+ });
+ assert!(matches!(
+ lines.recv().await,
+ Some(Err(AdapterError::Read {
+ what: "ACP request",
+ ..
+ }))
+ ));
+ assert!(lines.recv().await.is_none());
+ assert_eq!(reads.load(Ordering::Relaxed), 1);
+ }
+
+ #[test]
+ fn idless_notifications_do_not_produce_json_rpc_responses() {
+ let mut sessions = HashSet::new();
+ let action = handle_acp_message(
+ &json!({ "jsonrpc": "2.0", "method": "unknown/notification" }),
+ &mut sessions,
+ "remote-agent",
+ None,
+ )
+ .expect("notification is valid");
+ assert!(action.is_none());
+ }
+
+ #[test]
+ fn cancel_notification_remains_actionable_without_an_id() {
+ let mut sessions = HashSet::new();
+ let action = handle_acp_message(
+ &json!({
+ "jsonrpc": "2.0",
+ "method": "session/cancel",
+ "params": { "sessionId": "session-1" }
+ }),
+ &mut sessions,
+ "remote-agent",
+ None,
+ )
+ .expect("cancel is valid")
+ .expect("cancel action");
+ let AcpAction::Cancel { id, session_id } = action else {
+ panic!("cancel notification must produce a local action");
+ };
+ assert!(id.is_none());
+ assert_eq!(session_id, "session-1");
+ }
+
+ #[test]
+ fn private_ipv4_transitional_ipv6_addresses_are_rejected() {
+ for address in [
+ "::ffff:127.0.0.1",
+ "::ffff:10.0.0.1",
+ "64:ff9b::0a00:0001",
+ "2002:0a00:0001::",
+ "2001:0000:4136:e378:8000:63bf:3fff:fdd2",
+ ] {
+ assert!(
+ is_private_ip(address.parse().expect("test IP")),
+ "{address} must not bypass the private-address policy"
+ );
+ }
+ }
+
+ #[tokio::test]
+ async fn remote_artifact_requires_a_digest_before_fetch() {
+ let descriptor: Descriptor = serde_json::from_value(json!({
+ "urls": ["https://agent.example/card.json"],
+ "media_type": "application/a2a-agent-card+json",
+ "size": 1
+ }))
+ .expect("descriptor");
+ let error = descriptor_bytes(&descriptor, None)
+ .await
+ .expect_err("unsigned remote artifact");
+ assert!(error.to_string().contains("require a sha256 digest"));
+ }
+}
diff --git a/crates/buzz-a2a-acp/src/main.rs b/crates/buzz-a2a-acp/src/main.rs
new file mode 100644
index 0000000000..f2ecc41c93
--- /dev/null
+++ b/crates/buzz-a2a-acp/src/main.rs
@@ -0,0 +1,6 @@
+fn main() {
+ if let Err(error) = buzz_a2a_acp::run_cli() {
+ eprintln!("buzz-a2a-acp: {error}");
+ std::process::exit(2);
+ }
+}
diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs
index c0147baf1b..35d1eee8dd 100644
--- a/crates/buzz-acp/src/acp.rs
+++ b/crates/buzz-acp/src/acp.rs
@@ -187,6 +187,17 @@ pub struct AcpClient {
/// Other agents may leave this unset — readers must treat `None` as
/// "no active run to steer into" and fall back to cancel+merge.
active_run_id: Option,
+ /// Whether the agent advertised `_meta.steering.supported: true` in its
+ /// `initialize` response, meaning it implements the cross-adapter
+ /// [`ACP_STEER_METHOD`] extension.
+ ///
+ /// Set once by [`initialize`](Self::initialize); `false` for agents that
+ /// omit the key. This is the **only** gate on writing an
+ /// [`ACP_STEER_METHOD`] request. It must never be replaced by error-code
+ /// probing: codex-acp answers unrecognized extension methods with `{}` —
+ /// a JSON-RPC *success*, not `-32601` — which the main loop would read as
+ /// a delivered steer and drop the user's message from the queue.
+ steering_supported: bool,
/// Per-turn channel for receiving goose-native non-cancelling steer
/// requests from the main loop. Installed by
/// [`install_steer_rx`](Self::install_steer_rx) at dispatch and
@@ -344,6 +355,38 @@ pub(crate) fn build_codex_config_env(
Ok(Some(serde_json::Value::Object(base).to_string()))
}
+/// goose's non-standard mid-turn steer method. Requires `expectedRunId`, so it
+/// is only usable once a `session_info_update` has supplied
+/// `_meta.goose.activeRunId`. Emitted by goose and buzz-agent only.
+const GOOSE_STEER_METHOD: &str = "_goose/unstable/session/steer";
+
+/// The cross-adapter mid-turn steer method, shipped by claude-agent-acp
+/// (`src/acp-agent.ts:200`) and codex-acp (`src/AcpExtensions.ts:11`).
+/// Params are `{sessionId, prompt}` — no run id — and the result is
+/// `{outcome}`. Gated on [`AcpClient::steering_supported`].
+const ACP_STEER_METHOD: &str = "_session/steering";
+
+/// `outcome` value meaning the steer was applied to the turn Buzz is waiting
+/// on, which therefore keeps running.
+const STEER_OUTCOME_INJECTED: &str = "injected";
+
+/// `outcome` value meaning the turn Buzz was steering had already finished, so
+/// the adapter began a fresh turn carrying the message. Still a delivery
+/// success, but the awaited turn is over — see the steer-response arm for why
+/// this must not renew the hard deadline.
+const STEER_OUTCOME_STARTED_NEW_TURN: &str = "startedNewTurn";
+
+/// Which wire method carried an in-flight steer request, recorded so the
+/// response arm decodes the shape that method actually returns.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum SteerTransport {
+ /// [`GOOSE_STEER_METHOD`] — any success result is a delivered steer.
+ Goose,
+ /// [`ACP_STEER_METHOD`] — success carries an `outcome` that must be
+ /// positively recognized before the steer counts as delivered.
+ AcpExtension,
+}
+
fn build_client_capabilities() -> serde_json::Value {
serde_json::json!({
// Signal to ACP adapters that Buzz can hand users to terminal-native
@@ -414,6 +457,8 @@ impl AcpClient {
use std::process::Stdio;
let mut cmd = tokio::process::Command::new(command);
+ let is_remote_a2a_adapter =
+ crate::config::normalize_agent_command_identity(command) == "buzz-a2a-acp";
cmd.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
@@ -422,6 +467,19 @@ impl AcpClient {
// Ensure the child is killed when the AcpClient is dropped (best-effort).
// Callers MUST still call shutdown().await for guaranteed cleanup.
.kill_on_drop(true);
+ if is_remote_a2a_adapter {
+ for key in [
+ "BUZZ_PRIVATE_KEY",
+ "NOSTR_PRIVATE_KEY",
+ "BUZZ_AUTH_TAG",
+ "BUZZ_API_TOKEN",
+ "BUZZ_ACP_PRIVATE_KEY",
+ "BUZZ_ACP_API_TOKEN",
+ "BUZZ_A2A_BEARER_TOKEN",
+ ] {
+ cmd.env_remove(key);
+ }
+ }
// Per-persona env vars (e.g., GOOSE_PROVIDER, BUZZ_AGENT_PROVIDER).
// For most keys, operator precedence wins: skip injection if already set
@@ -447,12 +505,26 @@ impl AcpClient {
// entry falls through to the standard operator-wins treatment below.
let codex_merge_active = codex_config_value.is_some();
+ // Per-runtime environment defaults (e.g. Hermes MCP-startup isolation).
+ // Applied first so both persona `extra_env` (below, via `Command::env`
+ // key replacement) and inherited parent env (via the parent-presence
+ // check) override them.
+ for &(key, value) in crate::config::default_agent_env(command) {
+ if std::env::var_os(key).is_none() {
+ cmd.env(key, value);
+ }
+ }
+
for (key, value) in extra_env {
if key == "CODEX_CONFIG" && codex_merge_active {
// Handled by build_codex_config_env; skip here to avoid double-setting.
continue;
}
- if std::env::var(key).is_err() {
+ if is_remote_a2a_adapter && key == "BUZZ_A2A_BEARER_TOKEN" {
+ cmd.env(key, value);
+ continue;
+ }
+ if std::env::var_os(key).is_none() {
cmd.env(key, value);
}
}
@@ -494,6 +566,7 @@ impl AcpClient {
observer_agent_index: None,
observer_context: ObserverContext::default(),
active_run_id: None,
+ steering_supported: false,
steer_rx: None,
goose_usage: UsageTracker::default(),
})
@@ -536,11 +609,20 @@ impl AcpClient {
///
/// Must be called exactly once, before any other ACP method.
/// The caller may inspect `agentCapabilities` in the returned value.
+ ///
+ /// Records `_meta.steering.supported` into
+ /// [`steering_supported`](Self::steering_supported) so the read loop's steer
+ /// arm can choose [`ACP_STEER_METHOD`] for adapters that implement it.
+ /// Parsed here rather than at each call site so no caller can forget it.
pub async fn initialize(&mut self) -> Result {
// Requesting version 2 is an intentional temporary pin — we are squatting
// on ACP v2 ahead of the upstream ACP RFD. Revisit when that RFD merges.
let params = build_initialize_params();
let result = self.send_request("initialize", params).await?;
+ self.steering_supported = result
+ .pointer("/_meta/steering/supported")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false);
tracing::debug!(target: "acp::init", "initialize response: {result}");
Ok(result)
}
@@ -778,6 +860,15 @@ impl AcpClient {
self.active_run_id.as_deref()
}
+ /// Whether the agent advertised the [`ACP_STEER_METHOD`] extension at
+ /// `initialize` time (`_meta.steering.supported`).
+ ///
+ /// The read loop's steer arm reads the field directly; this accessor exists
+ /// for the supervisor's post-initialize log line.
+ pub fn steering_supported(&self) -> bool {
+ self.steering_supported
+ }
+
/// Consume and return the per-turn usage record computed from the most
/// recent `_goose/unstable/session/update` notification.
///
@@ -1219,14 +1310,18 @@ impl AcpClient {
// so the ack_tx oneshot is never leaked silently).
let mut steer_rx = self.steer_rx.take();
- // Tracks the in-flight steer write: `(request_id, ack_tx)`. While
- // `Some`, the steer arm is gated off so we don't stack writes,
+ // Tracks the in-flight steer write: `(request_id, transport, ack_tx)`.
+ // While `Some`, the steer arm is gated off so we don't stack writes,
// and a response matching `id` is routed to the ack_tx instead
- // of being treated as the prompt result. Drained on every return
- // path with `PromptCompletedNeutral` so callers are never left
- // hanging.
- let mut pending_steer: Option<(u64, tokio::sync::oneshot::Sender)> =
- None;
+ // of being treated as the prompt result. `transport` records which
+ // method was written so the response arm decodes the result shape
+ // that method actually returns. Drained on every return path with
+ // `PromptCompletedNeutral` so callers are never left hanging.
+ let mut pending_steer: Option<(
+ u64,
+ SteerTransport,
+ tokio::sync::oneshot::Sender,
+ )> = None;
let now = Instant::now();
let mut idle_deadline = now + idle_timeout;
@@ -1251,7 +1346,7 @@ impl AcpClient {
// exists). Check the classified deadline here so a steady-
// stream agent is still bounded.
if Instant::now() >= next_deadline {
- if let Some((_, ack_tx)) = pending_steer.take() {
+ if let Some((_, _, ack_tx)) = pending_steer.take() {
// Prompt is timing out — release the withheld event via
// PromptCompletedNeutral (no fallback signal: there is
// no in-flight turn to signal once we return, and
@@ -1286,39 +1381,64 @@ impl AcpClient {
None => None,
}
}, if pending_steer.is_none() => {
- // Selected: build steer params at write time using the
- // lexical `session_id` and the freshest `active_run_id`.
+ // Selected: choose the steer transport and build its
+ // params at write time using the lexical `session_id`
+ // and the freshest `active_run_id`.
//
// `active_run_id` is updated by `session/update`
// notifications inside this very loop; reading it here
// (rather than snapshotting at dispatch) guarantees the
// value matches what goose's run-id check will compare
- // against. If it's `None`, no `session/update` has
- // arrived yet so we cannot form a valid `expectedRunId`
- // — ack `ExpectedRunIdMissing` and drop the request
- // without writing anything. The main loop maps this to
- // the universal cancel+merge `Steer` fallback.
- match self.active_run_id.clone() {
+ // against.
+ //
+ // Transport precedence:
+ // Some(run_id) → GOOSE_STEER_METHOD. goose
+ // wins whenever a run id exists: `expectedRunId` is
+ // strictly more precise about *which* run is steered.
+ // None + steering_supported → ACP_STEER_METHOD, the
+ // cross-adapter extension (claude-agent-acp,
+ // codex-acp), which takes no run id.
+ // None + !steering_supported → write nothing and ack
+ // `ExpectedRunIdMissing`; the main loop maps this to
+ // the universal cancel+merge `Steer` fallback.
+ //
+ // The capability flag is the ONLY gate on writing
+ // ACP_STEER_METHOD. Probing an unknown method is unsafe:
+ // codex-acp answers unrecognized extension methods with
+ // `{}` — a JSON-RPC success — which would be read as a
+ // delivered steer and silently drop the user's message.
+ let prompt_block_refs: Vec<&str> =
+ req.prompt_blocks.iter().map(String::as_str).collect();
+ let selected = match (&self.active_run_id, self.steering_supported) {
+ (Some(run_id), _) => Some((
+ SteerTransport::Goose,
+ GOOSE_STEER_METHOD,
+ build_goose_steer_params(session_id, run_id, &prompt_block_refs),
+ )),
+ (None, true) => Some((
+ SteerTransport::AcpExtension,
+ ACP_STEER_METHOD,
+ build_acp_steer_params(session_id, &prompt_block_refs),
+ )),
+ (None, false) => None,
+ };
+ match selected {
None => {
tracing::warn!(
- "goose-native steer: no active_run_id at write time \
- (no session/update seen yet) — falling back to cancel+merge"
+ "steer: no active_run_id and agent did not advertise \
+ {ACP_STEER_METHOD} — falling back to cancel+merge"
);
let _ = req.ack_tx.send(crate::pool::SteerAck::Err(
crate::pool::SteerError::ExpectedRunIdMissing,
));
}
- Some(run_id) => {
+ Some((transport, method, params)) => {
let id = self.next_id;
self.next_id += 1;
- let prompt_block_refs: Vec<&str> =
- req.prompt_blocks.iter().map(String::as_str).collect();
- let params =
- build_steer_params(session_id, &run_id, &prompt_block_refs);
let msg = serde_json::json!({
"jsonrpc": "2.0",
"id": id,
- "method": "_goose/unstable/session/steer",
+ "method": method,
"params": params,
});
tracing::debug!(
@@ -1328,11 +1448,11 @@ impl AcpClient {
);
match self.write_ndjson(&msg).await {
Ok(()) => {
- pending_steer = Some((id, req.ack_tx));
+ pending_steer = Some((id, transport, req.ack_tx));
}
Err(e) => {
tracing::warn!(
- "goose-native steer write failed: {e} — releasing withheld event"
+ "steer write failed ({method}): {e} — releasing withheld event"
);
let _ = req.ack_tx.send(crate::pool::SteerAck::Err(
crate::pool::SteerError::Transport(e.to_string()),
@@ -1351,7 +1471,7 @@ impl AcpClient {
// would catch this anyway, but firing the deadline arm
// here makes the wakeup immediate (no extra reader poll
// round-trip when stdout is idle).
- if let Some((_, ack_tx)) = pending_steer.take() {
+ if let Some((_, _, ack_tx)) = pending_steer.take() {
let _ = ack_tx.send(crate::pool::SteerAck::PromptCompletedNeutral);
}
if idle_fires_first {
@@ -1375,13 +1495,13 @@ impl AcpClient {
match read_result {
None => {
- if let Some((_, ack_tx)) = pending_steer.take() {
+ if let Some((_, _, ack_tx)) = pending_steer.take() {
let _ = ack_tx.send(crate::pool::SteerAck::PromptCompletedNeutral);
}
return Err(AcpError::AgentExited);
}
Some(Err(LinesCodecError::MaxLineLengthExceeded)) => {
- if let Some((_, ack_tx)) = pending_steer.take() {
+ if let Some((_, _, ack_tx)) = pending_steer.take() {
let _ = ack_tx.send(crate::pool::SteerAck::PromptCompletedNeutral);
}
return Err(AcpError::Protocol(
@@ -1389,7 +1509,7 @@ impl AcpClient {
));
}
Some(Err(e)) => {
- if let Some((_, ack_tx)) = pending_steer.take() {
+ if let Some((_, _, ack_tx)) = pending_steer.take() {
let _ = ack_tx.send(crate::pool::SteerAck::PromptCompletedNeutral);
}
return Err(AcpError::Io(std::io::Error::other(e)));
@@ -1432,13 +1552,14 @@ impl AcpClient {
// share the `no method` guard.
if let Some(id) = msg.get("id") {
if msg.get("method").is_none() {
- if let Some((steer_id, _)) = pending_steer.as_ref() {
+ if let Some((steer_id, _, _)) = pending_steer.as_ref() {
if *id == serde_json::json!(*steer_id) {
// Take the ack_tx out and route the
// response. We do not return — keep
// reading until the prompt response
// arrives.
- let (_, ack_tx) = pending_steer.take().expect("just checked");
+ let (_, transport, ack_tx) =
+ pending_steer.take().expect("just checked");
let ack = if let Some(error) = msg.get("error") {
let code = error
.get("code")
@@ -1449,16 +1570,83 @@ impl AcpClient {
crate::pool::SteerError::AgentError { code, message },
)
} else {
- let renew_now = Instant::now();
- let new_deadline = renew_now + max_duration;
- if new_deadline > hard_deadline {
- hard_deadline = new_deadline;
- self.current_hard_deadline = Some(new_deadline);
- tracing::info!(
- "steer success: renewed hard deadline ({max_duration:?} from now)"
- );
+ // Success result. Whether it counts as
+ // a delivered steer — and whether the
+ // turn Buzz awaits is still running —
+ // depends on the transport.
+ let outcome = match transport {
+ // goose returns no outcome field;
+ // a success response means the
+ // steer landed in the live run.
+ SteerTransport::Goose => Some(STEER_OUTCOME_INJECTED),
+ // The outcome must be positively
+ // recognized. An unknown or absent
+ // value (codex-acp answers
+ // unrecognized ext methods with a
+ // bare `{}`) is a rejection, never
+ // a delivery — treating it as
+ // success would drop the event.
+ SteerTransport::AcpExtension => msg
+ .pointer("/result/outcome")
+ .and_then(|v| v.as_str())
+ .filter(|o| {
+ *o == STEER_OUTCOME_INJECTED
+ || *o == STEER_OUTCOME_STARTED_NEW_TURN
+ }),
+ };
+ match outcome {
+ Some(STEER_OUTCOME_STARTED_NEW_TURN) => {
+ // Delivered, but into a NEW
+ // turn: the one this read loop
+ // is awaiting had already
+ // finished. Renewing the hard
+ // deadline here would extend
+ // the clock on a settled turn,
+ // so leave it alone and let the
+ // prompt response land on its
+ // original budget.
+ tracing::info!(
+ "steer accepted as {STEER_OUTCOME_STARTED_NEW_TURN}: \
+ awaited turn had ended — hard deadline not renewed"
+ );
+ crate::pool::SteerAck::Success
+ }
+ Some(_) => {
+ let renew_now = Instant::now();
+ let new_deadline = renew_now + max_duration;
+ if new_deadline > hard_deadline {
+ hard_deadline = new_deadline;
+ self.current_hard_deadline = Some(new_deadline);
+ tracing::info!(
+ "steer success: renewed hard deadline ({max_duration:?} from now)"
+ );
+ }
+ crate::pool::SteerAck::Success
+ }
+ None => {
+ // Report the raw string when
+ // there is one, so logs read
+ // `failed` not `"failed"`;
+ // fall back to the JSON for a
+ // non-string value.
+ let reported = match msg.pointer("/result/outcome")
+ {
+ None => "".to_string(),
+ Some(serde_json::Value::String(s)) => s.clone(),
+ Some(other) => other.to_string(),
+ };
+ tracing::warn!(
+ "steer rejected: {ACP_STEER_METHOD} returned \
+ unrecognized outcome {reported} — releasing \
+ withheld event for cancel+merge"
+ );
+ crate::pool::SteerAck::Err(
+ crate::pool::SteerError::OutcomeRejected {
+ outcome: reported,
+ },
+ )
+ }
}
- crate::pool::SteerAck::Success
};
let _ = ack_tx.send(ack);
continue;
@@ -1466,13 +1654,13 @@ impl AcpClient {
}
if *id == serde_json::json!(expected_id) {
if let Some(error) = msg.get("error") {
- if let Some((_, ack_tx)) = pending_steer.take() {
+ if let Some((_, _, ack_tx)) = pending_steer.take() {
let _ = ack_tx
.send(crate::pool::SteerAck::PromptCompletedNeutral);
}
return Err(agent_error_from_json(error));
}
- if let Some((_, ack_tx)) = pending_steer.take() {
+ if let Some((_, _, ack_tx)) = pending_steer.take() {
let _ =
ack_tx.send(crate::pool::SteerAck::PromptCompletedNeutral);
}
@@ -1531,7 +1719,10 @@ impl AcpClient {
/// Takes `&mut self` (not `&self`) because some updates carry agent state
/// the client must observe — notably goose's `session_info_update` with
/// `_meta.goose.activeRunId`, which seeds [`active_run_id`](Self::active_run_id)
- /// so callers can target `_goose/unstable/session/steer` at the correct run.
+ /// so the steer arm can target `_goose/unstable/session/steer` at the
+ /// correct run. Agents that never emit it (claude-agent-acp, codex-acp)
+ /// leave it `None` and are steered via `_session/steering` instead, which
+ /// needs no run id.
fn handle_session_update(&mut self, msg: &serde_json::Value) -> bool {
let update = &msg["params"]["update"];
let update_type = update
@@ -1662,6 +1853,11 @@ impl AcpClient {
session_id = %notif.session_id,
input = payload.accumulated_input_tokens,
output = payload.accumulated_output_tokens,
+ // A subset of `input`, logged so downstream accounting can
+ // price it at the provider's cached rate. Always emitted,
+ // including as 0, so a parser can tell "no cache hits"
+ // apart from "this build predates the field".
+ cached = payload.accumulated_cached_input_tokens,
"goose usage update"
);
self.goose_usage.record(¬if.session_id, payload);
@@ -1796,22 +1992,44 @@ fn build_prompt_params(session_id: &str, prompt_blocks: &[&str]) -> serde_json::
/// matches goose's *current* run (it advances on each `session/update`).
/// See [`crate::pool::SteerRequest`] for why this is the read loop's job
/// and not the main loop's.
-fn build_steer_params(
+fn build_goose_steer_params(
session_id: &str,
expected_run_id: &str,
prompt_blocks: &[&str],
) -> serde_json::Value {
- let blocks: Vec = prompt_blocks
- .iter()
- .map(|text| serde_json::json!({ "type": "text", "text": text }))
- .collect();
serde_json::json!({
"sessionId": session_id,
"expectedRunId": expected_run_id,
- "prompt": blocks,
+ "prompt": steer_prompt_blocks(prompt_blocks),
+ })
+}
+
+/// Build the params for an [`ACP_STEER_METHOD`] request.
+///
+/// Wire shape:
+/// ```json
+/// { "sessionId": "...", "prompt": [{"type":"text","text":"..."}, ...] }
+/// ```
+///
+/// Deliberately carries **no** `expectedRunId`: the cross-adapter method
+/// steers whatever turn is currently running and neither claude-agent-acp nor
+/// codex-acp emits a run id to target.
+fn build_acp_steer_params(session_id: &str, prompt_blocks: &[&str]) -> serde_json::Value {
+ serde_json::json!({
+ "sessionId": session_id,
+ "prompt": steer_prompt_blocks(prompt_blocks),
})
}
+/// Render steer body strings as ACP `text` content blocks. Shared by both
+/// steer transports so the prompt shape cannot drift between them.
+fn steer_prompt_blocks(prompt_blocks: &[&str]) -> Vec {
+ prompt_blocks
+ .iter()
+ .map(|text| serde_json::json!({ "type": "text", "text": text }))
+ .collect()
+}
+
/// Build a JSON-RPC permission response with `outcome: "selected"`.
fn permission_response_selected(id: &serde_json::Value, option_id: &str) -> serde_json::Value {
serde_json::json!({
@@ -2653,6 +2871,78 @@ mod tests {
.expect("failed to spawn test script")
}
+ /// Spawn a probe script whose file name carries a runtime identity (e.g.
+ /// `hermes-acp`) and return the value of `var` as the child observed it.
+ /// `` means the child did not receive the var.
+ #[cfg(unix)]
+ async fn spawn_named_and_read_child_env(
+ file_name: &str,
+ var: &str,
+ extra_env: &[(String, String)],
+ ) -> String {
+ use std::os::unix::fs::PermissionsExt;
+
+ let dir = std::env::temp_dir().join(format!("buzz-acp-env-probe-{}", uuid::Uuid::new_v4()));
+ std::fs::create_dir_all(&dir).expect("create env probe dir");
+ let path = dir.join(file_name);
+ std::fs::write(
+ &path,
+ format!("#!/bin/sh\nprintf '%s\\n' \"${{{var}:-}}\"\n"),
+ )
+ .expect("write env probe script");
+ let mut permissions = std::fs::metadata(&path).expect("stat probe").permissions();
+ permissions.set_mode(0o700);
+ std::fs::set_permissions(&path, permissions).expect("chmod probe");
+
+ let mut client = AcpClient::spawn(
+ path.to_str().expect("probe path is UTF-8"),
+ &[],
+ extra_env,
+ false,
+ )
+ .await
+ .expect("spawn env probe script");
+ let observed = client
+ .reader
+ .next()
+ .await
+ .unwrap_or_else(|| panic!("child produced no output for {var}"))
+ .expect("child stdout was not readable");
+ client.shutdown().await;
+ std::fs::remove_dir_all(&dir).expect("remove env probe dir");
+ observed
+ }
+
+ /// Buzz-owned Hermes processes get the configured-MCP isolation default,
+ /// and an explicit persona entry still overrides it (defaults are applied
+ /// before `extra_env`, so the later `Command::env` write wins).
+ #[cfg(unix)]
+ #[tokio::test]
+ async fn spawn_applies_runtime_env_defaults_with_extra_env_precedence() {
+ const VAR: &str = "HERMES_ACP_SKIP_CONFIGURED_MCP";
+ if std::env::var_os(VAR).is_some() {
+ // Inherited parent values win over both layers; the default and
+ // override behavior below is unobservable in such an environment.
+ return;
+ }
+
+ assert_eq!(
+ spawn_named_and_read_child_env("hermes-acp", VAR, &[]).await,
+ "1",
+ "Hermes spawns must default {VAR}=1"
+ );
+ assert_eq!(
+ spawn_named_and_read_child_env("hermes-acp", VAR, &[(VAR.into(), "0".into())]).await,
+ "0",
+ "an explicit extra_env entry must override the runtime default"
+ );
+ assert_eq!(
+ spawn_named_and_read_child_env("other-agent", VAR, &[]).await,
+ "",
+ "non-Hermes spawns must not receive Hermes defaults"
+ );
+ }
+
#[tokio::test]
async fn idle_timeout_fires_on_silent_process() {
let mut client = spawn_script("sleep 10").await;
@@ -3465,6 +3755,412 @@ mod tests {
}
}
+ // ── Cross-harness steer transport tests ───────────────────────────────
+ //
+ // These cover the `_session/steering` transport added alongside the
+ // goose-native method: capability capture at `initialize`, write-time
+ // transport selection, and outcome decoding. Wire-shape assertions read
+ // the actual serialized request bytes via `capture_steer_request` rather
+ // than inferring the shape from response-id routing.
+
+ /// Spawn a client whose script captures the first line written to its
+ /// stdin into `capture_path`, then emits `response` (already-serialized
+ /// JSON-RPC) and idles.
+ ///
+ /// The steer request is the first thing this read loop writes, so the
+ /// captured line IS the steer request bytes.
+ async fn spawn_steer_capture_script(
+ capture_path: &std::path::Path,
+ response: &str,
+ ) -> AcpClient {
+ let script = format!(
+ "read -r line; printf '%s' \"$line\" > {capture}; \
+ printf '%s\\n' '{response}'; sleep 10",
+ capture = capture_path.display(),
+ response = response,
+ );
+ spawn_script(&script).await
+ }
+
+ /// Drive one steer through the read loop and return
+ /// `(captured_request_bytes, ack)`.
+ ///
+ /// `capture_path` may be absent afterwards when the arm wrote nothing —
+ /// callers assert on that. The read loop is expected to exit via a
+ /// timeout or EOF; the ack is what these tests care about.
+ async fn run_one_steer(
+ client: &mut AcpClient,
+ capture_path: &std::path::Path,
+ ) -> (Option, crate::pool::SteerAck) {
+ let (steer_tx, steer_rx) = tokio::sync::mpsc::channel::(1);
+ client.install_steer_rx(steer_rx);
+
+ let (ack_tx, ack_rx) = tokio::sync::oneshot::channel::();
+ let send_task = tokio::spawn(async move {
+ steer_tx
+ .send(crate::pool::SteerRequest {
+ prompt_blocks: vec!["steer body".into()],
+ ack_tx,
+ })
+ .await
+ .expect("steer_tx send should succeed");
+ });
+
+ let idle = std::time::Duration::from_millis(800);
+ let max_dur = std::time::Duration::from_secs(10);
+ let hard_deadline = tokio::time::Instant::now() + max_dur;
+ let _ = client
+ .read_until_response_with_idle_timeout("sess-test", 999, idle, hard_deadline, max_dur)
+ .await;
+ send_task.await.expect("send_task should complete");
+
+ let ack = ack_rx
+ .await
+ .expect("ack oneshot must have received a SteerAck");
+ (std::fs::read_to_string(capture_path).ok(), ack)
+ }
+
+ /// Unique temp path for one test's captured request bytes.
+ fn capture_path(name: &str) -> std::path::PathBuf {
+ let dir = std::env::temp_dir().join("buzz-acp-steer-capture");
+ std::fs::create_dir_all(&dir).expect("create capture dir");
+ let path = dir.join(format!("{name}.json"));
+ let _ = std::fs::remove_file(&path);
+ path
+ }
+
+ /// Mark a client as having advertised `_meta.steering.supported` without
+ /// running a real `initialize` handshake. The capability-parsing tests
+ /// cover the handshake itself.
+ fn set_steering_supported(client: &mut AcpClient) {
+ client.steering_supported = true;
+ }
+
+ /// Run `initialize` against a script that replies with `init_result` as
+ /// the JSON-RPC result, and return the resulting `steering_supported`.
+ async fn steering_supported_after_initialize(init_result: &str) -> bool {
+ let script = format!(
+ "read -r _init; printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":0,\"result\":{result}}}'; \
+ sleep 5",
+ result = init_result,
+ );
+ let mut client = spawn_script(&script).await;
+ client
+ .initialize()
+ .await
+ .expect("initialize should succeed");
+ client.steering_supported()
+ }
+
+ /// Test 1a: an adapter advertising `_meta.steering.supported: true`
+ /// (claude-agent-acp `src/acp-agent.ts:1444`, codex-acp
+ /// `src/CodexAcpServer.ts:247`) is recorded as steering-capable.
+ #[tokio::test]
+ async fn initialize_records_steering_supported_when_advertised() {
+ let supported = steering_supported_after_initialize(
+ r#"{"protocolVersion":2,"agentCapabilities":{},"_meta":{"steering":{"supported":true}}}"#,
+ )
+ .await;
+ assert!(
+ supported,
+ "_meta.steering.supported: true must set steering_supported"
+ );
+ }
+
+ /// Test 1b: no `_meta` at all (goose, buzz-agent, any older adapter) must
+ /// leave the capability off — this is what keeps a steer off the wire for
+ /// agents that never implemented it.
+ #[tokio::test]
+ async fn initialize_leaves_steering_unsupported_when_meta_absent() {
+ let supported =
+ steering_supported_after_initialize(r#"{"protocolVersion":2,"agentCapabilities":{}}"#)
+ .await;
+ assert!(
+ !supported,
+ "absent _meta must leave steering_supported false"
+ );
+ }
+
+ /// Test 1c: an explicit `supported: false` is respected, not treated as
+ /// "the key exists so it must work".
+ #[tokio::test]
+ async fn initialize_leaves_steering_unsupported_when_explicitly_false() {
+ let supported = steering_supported_after_initialize(
+ r#"{"protocolVersion":2,"_meta":{"steering":{"supported":false}}}"#,
+ )
+ .await;
+ assert!(
+ !supported,
+ "_meta.steering.supported: false must leave steering_supported false"
+ );
+ }
+
+ /// Test 2: no `active_run_id` + capability advertised → the bytes on the
+ /// wire are an `_session/steering` request carrying `sessionId` and
+ /// `prompt`, and carrying **no** `expectedRunId` (the adapters reject
+ /// unknown required fields, and there is no run id to report anyway).
+ #[tokio::test]
+ async fn acp_steer_request_omits_expected_run_id_and_carries_session_and_prompt() {
+ let capture = capture_path("acp_shape");
+ let mut client = spawn_steer_capture_script(
+ &capture,
+ r#"{"jsonrpc":"2.0","id":0,"result":{"outcome":"injected"}}"#,
+ )
+ .await;
+ set_steering_supported(&mut client);
+ assert!(
+ client.active_run_id().is_none(),
+ "precondition: no active_run_id"
+ );
+
+ let (written, ack) = run_one_steer(&mut client, &capture).await;
+
+ let written = written.expect("steer request must have been written");
+ let msg: serde_json::Value =
+ serde_json::from_str(&written).expect("written line must be valid JSON");
+ assert_eq!(
+ msg["method"].as_str(),
+ Some(ACP_STEER_METHOD),
+ "must use the cross-adapter steer method; wrote: {written}"
+ );
+ assert_eq!(msg["params"]["sessionId"].as_str(), Some("sess-test"));
+ assert_eq!(
+ msg["params"]["prompt"][0]["text"].as_str(),
+ Some("steer body"),
+ "prompt must carry the steer body as a text block"
+ );
+ assert!(
+ msg["params"].get("expectedRunId").is_none(),
+ "_session/steering must not carry expectedRunId; wrote: {written}"
+ );
+ assert!(
+ matches!(ack, crate::pool::SteerAck::Success),
+ "injected outcome must ack Success, got {ack:?}"
+ );
+ }
+
+ /// Test 3: goose keeps priority. With both an `active_run_id` and the
+ /// advertised capability, the goose method wins — `expectedRunId` is
+ /// strictly more precise about which run is being steered.
+ #[tokio::test]
+ async fn goose_transport_wins_when_both_run_id_and_capability_present() {
+ let capture = capture_path("goose_priority");
+ let mut client =
+ spawn_steer_capture_script(&capture, r#"{"jsonrpc":"2.0","id":0,"result":{}}"#).await;
+ set_steering_supported(&mut client);
+ let update = session_info_update_msg(Some(serde_json::json!("run-77")));
+ let _ = client.handle_session_update(&update);
+
+ let (written, ack) = run_one_steer(&mut client, &capture).await;
+
+ let written = written.expect("steer request must have been written");
+ let msg: serde_json::Value =
+ serde_json::from_str(&written).expect("written line must be valid JSON");
+ assert_eq!(
+ msg["method"].as_str(),
+ Some(GOOSE_STEER_METHOD),
+ "goose method must win when a run id exists; wrote: {written}"
+ );
+ assert_eq!(msg["params"]["expectedRunId"].as_str(), Some("run-77"));
+ // A bare `{}` result is a success on the goose transport (goose sends
+ // no `outcome`) — the OutcomeRejected guard applies only to
+ // `_session/steering`.
+ assert!(
+ matches!(ack, crate::pool::SteerAck::Success),
+ "goose success result must ack Success, got {ack:?}"
+ );
+ }
+
+ /// Test 7: codex-acp's third outcome, `failed`
+ /// (`src/AcpExtensions.ts:92`), is a delivery rejection despite being a
+ /// JSON-RPC success — release the event and fall back.
+ #[tokio::test]
+ async fn acp_steer_failed_outcome_acks_outcome_rejected() {
+ let capture = capture_path("outcome_failed");
+ let mut client = spawn_steer_capture_script(
+ &capture,
+ r#"{"jsonrpc":"2.0","id":0,"result":{"outcome":"failed"}}"#,
+ )
+ .await;
+ set_steering_supported(&mut client);
+
+ let (_written, ack) = run_one_steer(&mut client, &capture).await;
+
+ match ack {
+ crate::pool::SteerAck::Err(crate::pool::SteerError::OutcomeRejected { outcome }) => {
+ assert_eq!(
+ outcome, "failed",
+ "rejected outcome must report what the agent said, unquoted"
+ );
+ }
+ other => panic!("expected Err(OutcomeRejected), got {other:?}"),
+ }
+ }
+
+ /// Test 8: **codex `extMethod` silent-loss regression guard.** codex-acp's
+ /// ext dispatcher answers unrecognized methods with a bare `{}` — a
+ /// JSON-RPC *success*, not `-32601` (`src/CodexAcpServer.ts:255-258`).
+ /// Buzz maps `SteerAck::Success` to `queue.remove_event`, so decoding
+ /// `{}` as success would delete the user's message with no error, no
+ /// fallback, and no log. An absent `outcome` must therefore be a
+ /// rejection, which releases the event and fires cancel+merge.
+ #[tokio::test]
+ async fn acp_steer_missing_outcome_acks_outcome_rejected_and_never_drops_event() {
+ let capture = capture_path("outcome_absent");
+ let mut client =
+ spawn_steer_capture_script(&capture, r#"{"jsonrpc":"2.0","id":0,"result":{}}"#).await;
+ set_steering_supported(&mut client);
+
+ let (_written, ack) = run_one_steer(&mut client, &capture).await;
+
+ match ack {
+ crate::pool::SteerAck::Err(crate::pool::SteerError::OutcomeRejected { outcome }) => {
+ assert_eq!(
+ outcome, "",
+ "a result with no outcome field must be reported as absent"
+ );
+ }
+ other => panic!(
+ "expected Err(OutcomeRejected) for a bare {{}} success — \
+ anything else risks dropping the event, got {other:?}"
+ ),
+ }
+ }
+
+ /// Test 5: `injected` renews the hard deadline, so the turn survives past
+ /// its original one. Mirrors
+ /// `steer_success_renews_hard_deadline_and_survives_past_original` for
+ /// the `_session/steering` transport.
+ ///
+ /// Timeline: original hard deadline at t≈1s; steer response at t≈0.5s
+ /// renews it to t≈3.5s; prompt response at t≈1.5s lands inside it.
+ #[tokio::test]
+ async fn acp_steer_injected_renews_hard_deadline_and_survives_past_original() {
+ let script = "sleep 0.5; \
+ echo '{\"jsonrpc\":\"2.0\",\"id\":0,\"result\":{\"outcome\":\"injected\"}}'; \
+ sleep 1; \
+ echo '{\"jsonrpc\":\"2.0\",\"id\":999,\"result\":{\"done\":true}}'";
+ let mut client = spawn_script(script).await;
+ set_steering_supported(&mut client);
+
+ let (steer_tx, steer_rx) = tokio::sync::mpsc::channel::(1);
+ client.install_steer_rx(steer_rx);
+ let (ack_tx, ack_rx) = tokio::sync::oneshot::channel::();
+ let send_task = tokio::spawn(async move {
+ steer_tx
+ .send(crate::pool::SteerRequest {
+ prompt_blocks: vec!["steer body".into()],
+ ack_tx,
+ })
+ .await
+ .expect("steer_tx send should succeed");
+ });
+
+ let idle = std::time::Duration::from_secs(10);
+ let max_dur = std::time::Duration::from_secs(3);
+ let hard_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1);
+ let result = client
+ .read_until_response_with_idle_timeout("sess-test", 999, idle, hard_deadline, max_dur)
+ .await;
+ send_task.await.expect("send_task should complete");
+
+ assert!(
+ result.is_ok(),
+ "injected must renew the deadline so the prompt response still lands, got {result:?}"
+ );
+ assert_eq!(result.unwrap()["done"], serde_json::json!(true));
+ let ack = ack_rx.await.expect("ack must be received");
+ assert!(
+ matches!(ack, crate::pool::SteerAck::Success),
+ "injected must ack Success, got {ack:?}"
+ );
+ }
+
+ /// Test 6: **red/green for the no-renewal rule.** `startedNewTurn` means
+ /// the turn Buzz was steering had already ended and the adapter began a
+ /// fresh, detached one. It acks `Success` (the message WAS delivered, so
+ /// the event must not be redelivered) but must NOT renew the hard
+ /// deadline — that clock belongs to a turn which is already settled.
+ ///
+ /// Same timeline as the `injected` test, so the only difference is the
+ /// outcome string: original hard deadline at t≈1s, steer response at
+ /// t≈0.5s, prompt response at t≈1.5s. With renewal the prompt response
+ /// would land and this returns `Ok`; without renewal the original
+ /// deadline fires first and we get `HardTimeout`.
+ #[tokio::test]
+ async fn acp_steer_started_new_turn_acks_success_without_renewing_hard_deadline() {
+ let script = "sleep 0.5; \
+ echo '{\"jsonrpc\":\"2.0\",\"id\":0,\"result\":{\"outcome\":\"startedNewTurn\"}}'; \
+ sleep 1; \
+ echo '{\"jsonrpc\":\"2.0\",\"id\":999,\"result\":{\"done\":true}}'";
+ let mut client = spawn_script(script).await;
+ set_steering_supported(&mut client);
+
+ let (steer_tx, steer_rx) = tokio::sync::mpsc::channel::(1);
+ client.install_steer_rx(steer_rx);
+ let (ack_tx, ack_rx) = tokio::sync::oneshot::channel::();
+ let send_task = tokio::spawn(async move {
+ steer_tx
+ .send(crate::pool::SteerRequest {
+ prompt_blocks: vec!["steer body".into()],
+ ack_tx,
+ })
+ .await
+ .expect("steer_tx send should succeed");
+ });
+
+ let idle = std::time::Duration::from_secs(10);
+ let max_dur = std::time::Duration::from_secs(3);
+ let hard_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1);
+ let result = client
+ .read_until_response_with_idle_timeout("sess-test", 999, idle, hard_deadline, max_dur)
+ .await;
+ send_task.await.expect("send_task should complete");
+
+ // The original deadline must still fire — renewal here would extend
+ // the clock on a turn the adapter has already finished.
+ assert!(
+ matches!(result, Err(AcpError::HardTimeout { .. })),
+ "startedNewTurn must NOT renew the hard deadline, so the original \
+ one must still fire; got {result:?}"
+ );
+ // Delivery still succeeded, so the withheld event must be dropped
+ // rather than released — hence Success, not an Err.
+ let ack = ack_rx.await.expect("ack must be received");
+ assert!(
+ matches!(ack, crate::pool::SteerAck::Success),
+ "startedNewTurn is a delivery success, got {ack:?}"
+ );
+ }
+
+ /// Test 4 (companion to the existing
+ /// `native_steer_with_no_active_run_id_acks_expected_run_id_missing`):
+ /// no run id AND no advertised capability means nothing is written at
+ /// all. This is the gate that keeps a steer off the wire for adapters
+ /// that never implemented either method.
+ #[tokio::test]
+ async fn steer_writes_nothing_when_no_run_id_and_capability_absent() {
+ let capture = capture_path("no_transport");
+ let mut client =
+ spawn_steer_capture_script(&capture, r#"{"jsonrpc":"2.0","id":0,"result":{}}"#).await;
+ assert!(!client.steering_supported(), "precondition: not advertised");
+ assert!(
+ client.active_run_id().is_none(),
+ "precondition: no active_run_id"
+ );
+
+ let (written, ack) = run_one_steer(&mut client, &capture).await;
+
+ assert!(
+ written.is_none(),
+ "no transport available must write nothing; wrote: {written:?}"
+ );
+ match ack {
+ crate::pool::SteerAck::Err(crate::pool::SteerError::ExpectedRunIdMissing) => {}
+ other => panic!("expected Err(ExpectedRunIdMissing), got {other:?}"),
+ }
+ }
+
// ── Goose usage notification integration ──────────────────────────────
/// Build a `_goose/unstable/session/update` JSON-RPC notification.
diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md
index c42e65cb83..e360d24982 100644
--- a/crates/buzz-acp/src/base_prompt.md
+++ b/crates/buzz-acp/src/base_prompt.md
@@ -40,6 +40,8 @@ For explicit changes to an existing personal agent, use `buzz agents draft-updat
- Use the person's **exact full display name** after `@` (e.g., `@Will Pfleger`, not `@Will`). Partial names fail silently.
- Do NOT format mentions with bold, italic, or backticks — it breaks notification delivery.
+- When you know intended recipient pubkeys, send readable `@Name` text and pass the identities separately in the same command: `buzz messages send ... --content "@Name ..." --mention `. Repeat `--mention` for multiple recipients. Any explicit identity (`--mention` or `nostr:npub...`) permits unresolved or ambiguous `@Name` text as presentation-only; uniquely resolved member names still add their own recipients. Include a pubkey for every presentation-only name that should notify. The success JSON's `mention_pubkeys` comes from the signed event and is the delivery evidence; no follow-up verification command is needed.
+- Without `--mention`, the CLI resolves `@Name` against current channel members. It stops before sending on an unresolved/ambiguous name or a mentioned pubkey that is not a member. For a non-member, add them explicitly with `buzz channels add-member` only when authorized, then retry. Sending never changes membership automatically.
- Only `@mention` when you need their attention. Don't mention in narrative (e.g., "coordinating with Duncan" — no `@`). Naming someone while talking *about* them is narrative — "waiting on @morgan", "until @morgan brings work", "I'll loop in @morgan later". Drop the `@`. Every mention sends a notification; a mention nobody needs to act on is a false alarm.
### Callback Mentions
diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs
index 9a1b74c276..5bba9e7db4 100644
--- a/crates/buzz-acp/src/config.rs
+++ b/crates/buzz-acp/src/config.rs
@@ -240,7 +240,7 @@ pub struct CliArgs {
#[arg(long, env = "BUZZ_RELAY_URL", default_value = "ws://localhost:3000")]
pub relay_url: String,
- #[arg(long, env = "BUZZ_PRIVATE_KEY")]
+ #[arg(long, env = "BUZZ_PRIVATE_KEY", hide_env_values = true)]
pub private_key: String,
/// Agent owner pubkey (64-char hex). Used for --respond-to=owner-only gate.
@@ -676,7 +676,12 @@ pub(crate) fn normalize_agent_command_identity(command: &str) -> String {
.next()
.expect("rsplit always yields at least one element");
let lower = basename.to_ascii_lowercase();
- let stem = lower.strip_suffix(".exe").unwrap_or(&lower);
+ // Windows resolves commands through `.exe` binaries and npm's `.cmd`/`.bat`
+ // shims; all three name the same runtime identity.
+ let stem = [".exe", ".cmd", ".bat"]
+ .iter()
+ .find_map(|extension| lower.strip_suffix(extension))
+ .unwrap_or(&lower);
stem.chars()
.map(|character| match character {
' ' | '_' => '-',
@@ -694,6 +699,25 @@ fn default_agent_args(command: &str) -> Option> {
}
}
+/// Per-runtime environment defaults applied when Buzz owns the agent process.
+///
+/// Mirrors [`default_agent_args`]: keyed on the normalized command identity,
+/// with the merge (in `AcpClient::spawn`) giving explicit persona env and
+/// inherited parent env precedence over these defaults.
+///
+/// Hermes: ACP hosts supply session MCP servers explicitly through
+/// `session/new`, but Hermes otherwise starts every profile-configured MCP
+/// server before it responds to `initialize` — which can exhaust the host's
+/// startup budget (see block/buzz#3355). Skip that unrelated global startup
+/// by default; an operator or persona can still opt back in by setting the
+/// variable explicitly.
+pub(crate) fn default_agent_env(command: &str) -> &'static [(&'static str, &'static str)] {
+ match normalize_agent_command_identity(command).as_str() {
+ "hermes" | "hermes-agent" | "hermes-acp" => &[("HERMES_ACP_SKIP_CONFIGURED_MCP", "1")],
+ _ => &[],
+ }
+}
+
/// Build the `CODEX_CONFIG` environment variable that enables full outbound
/// network access in Codex's macOS Seatbelt sandbox.
///
@@ -1026,6 +1050,13 @@ impl Config {
} else {
false
};
+ if normalize_agent_command_identity(&agent_command) == "buzz-a2a-acp" {
+ if let Ok(token) = std::env::var("BUZZ_A2A_BEARER_TOKEN") {
+ if !token.is_empty() {
+ persona_env_vars.push(("BUZZ_A2A_BEARER_TOKEN".to_string(), token));
+ }
+ }
+ }
validate_multiple_event_handling(args.multiple_event_handling, args.dedup)?;
@@ -1589,6 +1620,15 @@ mod tests {
"claude-code"
);
assert_eq!(normalize_agent_command_identity("Goose.EXE"), "goose");
+ // Windows npm shims resolve to `.cmd`/`.bat` wrappers.
+ assert_eq!(
+ normalize_agent_command_identity(r"C:\Users\test\AppData\Roaming\npm\hermes-acp.cmd"),
+ "hermes-acp"
+ );
+ assert_eq!(
+ normalize_agent_command_identity(r"C:\Tools\Hermes\HERMES-AGENT.BAT"),
+ "hermes-agent"
+ );
// Non-ASCII must not panic.
assert_eq!(normalize_agent_command_identity("my-agënt"), "my-agënt");
// Edge cases: empty, whitespace-only, bare separators.
@@ -1598,6 +1638,30 @@ mod tests {
assert_eq!(normalize_agent_command_identity("///"), "");
}
+ #[test]
+ fn default_agent_env_recognizes_hermes_identities() {
+ for command in [
+ "hermes",
+ "hermes-agent",
+ "hermes-acp",
+ "/opt/hermes/bin/hermes-acp",
+ r"C:\Users\test\bin\HERMES_ACP.EXE",
+ r"C:\Users\test\AppData\Roaming\npm\hermes-acp.cmd",
+ ] {
+ assert_eq!(
+ default_agent_env(command),
+ &[("HERMES_ACP_SKIP_CONFIGURED_MCP", "1")],
+ "unexpected env defaults for {command}"
+ );
+ }
+ for command in ["goose", "codex-acp", "claude-agent-acp", "buzz-agent", ""] {
+ assert!(
+ default_agent_env(command).is_empty(),
+ "non-Hermes command must have no env defaults: {command}"
+ );
+ }
+ }
+
#[test]
fn strips_legacy_acp_arg_case_insensitively() {
assert_eq!(
@@ -2841,4 +2905,34 @@ channels = "ALL"
let agent = "a".repeat(SESSION_TITLE_MAX_CHARS);
assert_eq!(compose_session_title(&agent, Some("buzz-dev")), agent);
}
+
+ /// Every arg whose env var name contains KEY/SECRET/TOKEN/PASSWORD/CRED/AUTH
+ /// must set `hide_env_values = true` to prevent credential leakage in --help.
+ #[test]
+ fn secret_env_args_hide_their_values_in_help() {
+ use clap::CommandFactory;
+
+ const SECRET_PATTERNS: &[&str] = &["KEY", "SECRET", "TOKEN", "PASSWORD", "CRED", "AUTH"];
+
+ let cmd = CliArgs::command();
+ let violations: Vec = cmd
+ .get_arguments()
+ .filter_map(|arg| {
+ let env_key = arg.get_env()?;
+ let env_name = env_key.to_string_lossy().to_uppercase();
+ let is_secret = SECRET_PATTERNS.iter().any(|pat| env_name.contains(pat));
+ if is_secret && !arg.is_hide_env_values_set() {
+ Some(env_name)
+ } else {
+ None
+ }
+ })
+ .collect();
+
+ assert!(
+ violations.is_empty(),
+ "Found secret-bearing env args without hide_env_values=true. \
+ Add `hide_env_values = true` to each: {violations:?}"
+ );
+ }
}
diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs
index b11d96d8f7..403512a322 100644
--- a/crates/buzz-acp/src/lib.rs
+++ b/crates/buzz-acp/src/lib.rs
@@ -1140,9 +1140,7 @@ fn any_respawn_in_flight(crash_history: &[SlotCircuit]) -> bool {
/// Result of a background respawn task.
struct RespawnResult {
index: usize,
- /// Tuple: (initialized client, protocol version, supports_goose_steer).
- /// The third element is always `true` — the supervisor uses
- /// try-and-tolerate for the steer extension.
+ /// Tuple: (initialized client, protocol version, agent name).
result: Result<(AcpClient, u32, String)>,
}
@@ -2228,18 +2226,18 @@ async fn tokio_main() -> Result<()> {
owner_cache.get(),
);
if let Some(signal) = signal {
- // Try-and-tolerate fork: when the mode
- // wants a Steer, attempt the non-cancelling
- // path first for any agent. On accept,
+ // Non-cancelling fork: when the mode
+ // wants a Steer, attempt the
+ // non-cancelling path first. On accept,
// withhold the queued event and spawn an
// ack watcher; the main loop's
// `PoolEvent::SteerAck` arm decides
// success/release/fallback. On reject
- // (including `-32601 method_not_found`
- // from agents that don't implement the
- // extension), fall through to the universal
- // cancel+merge `Steer` signal so the event
- // still reaches the agent.
+ // (including agents that advertise no
+ // steer transport at all), fall through
+ // to the universal cancel+merge `Steer`
+ // signal so the event still reaches the
+ // agent.
let native_attempted = matches!(signal, ControlSignal::Steer)
&& try_native_steer(
&mut pool,
@@ -2419,14 +2417,26 @@ async fn tokio_main() -> Result<()> {
event_id,
ack,
})) => {
- // Goose-native steer attempt resolved. Locked semantics
- // (Eva + Max + Perci, unanimous on Option X):
+ // Mid-turn steer attempt resolved (either transport:
+ // `_goose/unstable/session/steer` or `_session/steering`).
+ // Locked semantics (Eva + Max + Perci, unanimous on Option X):
//
// Success
// The agent received the steer via the non-cancelling
// path. Drop the withheld event so normal dispatch
// never redelivers it.
//
+ // Also covers `_session/steering`'s `startedNewTurn`
+ // outcome: the message was delivered, but into a fresh
+ // turn because the one being steered had already
+ // finished. Delivery is what this arm keys on, so the
+ // event is still dropped. The read loop deliberately
+ // does NOT renew its hard deadline in that case (the
+ // awaited turn is settled), while
+ // `extend_in_flight_deadline` below still applies —
+ // the agent really is running more work, so the
+ // channel's in-flight budget should reflect it.
+ //
// Err(_) where the write never landed (Transport /
// ExpectedRunIdMissing):
// Delivery state of the underlying message is "never
@@ -2434,6 +2444,16 @@ async fn tokio_main() -> Result<()> {
// queue front AND issue the cancel+merge fallback so
// the message still reaches the agent.
//
+ // Err(OutcomeRejected { .. })
+ // A `_session/steering` request returned a JSON-RPC
+ // success whose `outcome` was not `injected` or
+ // `startedNewTurn` (codex's `failed`, an unknown value,
+ // or a bare `{}` with no `outcome` at all). The steer
+ // did not land, so this is treated exactly like a write
+ // that never happened: release withheld AND fire the
+ // cancel+merge fallback. Handled by the catch-all
+ // `Err(_)` arm below.
+ //
// Err(AgentError { code: -32601, .. })
// The agent returned method_not_found — it does not
// implement the steer extension. Release withheld AND
@@ -2490,9 +2510,9 @@ async fn tokio_main() -> Result<()> {
Ok(pool::SteerAck::Err(pool::SteerError::AgentError { .. })) => {
(true, false, false)
}
- // Transport / ExpectedRunIdMissing: write never landed.
- // Release and fire the cancel+merge fallback so the
- // message still reaches the agent.
+ // Transport / ExpectedRunIdMissing / OutcomeRejected: the
+ // steer did not land. Release and fire the cancel+merge
+ // fallback so the message still reaches the agent.
Ok(pool::SteerAck::Err(_)) => (true, false, true),
Ok(pool::SteerAck::PromptCompletedNeutral) => (true, false, false),
Err(_recv_err) => (true, false, false),
@@ -2926,15 +2946,15 @@ fn dispatch_pending(
let ctx_clone = Arc::clone(ctx);
let agent_index = agent.index;
- // Goose-native non-cancelling steer seam: snapshot capability before
- // the agent moves into `run_prompt_task`, and install the per-turn
- // steer receiver on the read loop so the main loop's mode-gate fork
+ // Mid-turn non-cancelling steer seam: install the per-turn steer
+ // receiver on the read loop so the main loop's mode-gate fork
// (see the `if accepted && queue.is_channel_in_flight(...)` block
// in the relay event branch of the main `select!` loop) can drive
// it via the matching sender stored in `TaskMeta.steer_tx`.
- // Install the steer channel for every prompt task — the supervisor
- // uses try-and-tolerate: it attempts the steer for any agent and
- // treats `-32601 method_not_found` as "fall back to cancel+merge".
+ // Installed for every prompt task: the read loop picks the steer
+ // transport at write time from `active_run_id` and the agent's
+ // advertised `_session/steering` capability, and acks
+ // `ExpectedRunIdMissing` (→ cancel+merge) when it has neither.
let (tx, rx) = tokio::sync::mpsc::channel::(1);
agent.acp.install_steer_rx(rx);
let steer_tx = Some(tx);
@@ -3605,6 +3625,22 @@ mod agent_draft_prompt_tests {
assert!(prompt.contains("single-quoted shell strings preserve `\\n` literally"));
assert!(prompt.contains("buzz messages send ... --content -"));
}
+
+ #[test]
+ fn shared_base_prompt_teaches_single_command_mentions_and_preflight() {
+ let prompt = include_str!("base_prompt.md");
+ assert!(prompt.contains("--mention "));
+ assert!(prompt.contains("every presentation-only name that should notify"));
+ assert!(
+ prompt.contains("permits unresolved or ambiguous `@Name` text as presentation-only")
+ );
+ assert!(prompt.contains("success JSON's `mention_pubkeys`"));
+ assert!(prompt.contains("no follow-up verification command is needed"));
+ assert!(prompt.contains("stops before sending"));
+ assert!(prompt
+ .contains("add them explicitly with `buzz channels add-member` only when authorized"));
+ assert!(prompt.contains("never changes membership automatically"));
+ }
}
fn default_heartbeat_prompt() -> String {
@@ -3783,7 +3819,8 @@ async fn initialize_agent_pool(
.and_then(|info| info.get("name"))
.and_then(|v| v.as_str())
.unwrap_or("unknown"),
- "agent initialized — non-cancelling steer enabled (try-and-tolerate)"
+ steering_supported = acp.steering_supported(),
+ "agent initialized"
);
acp.observe(
"agent_initialized",
diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs
index 0c51fe954f..158477c0af 100644
--- a/crates/buzz-acp/src/pool.rs
+++ b/crates/buzz-acp/src/pool.rs
@@ -19,6 +19,7 @@
//!
//! `AcpClient` is NOT Clone — ownership moves out on claim and back on return.
+use std::cmp::Reverse;
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
use std::time::Duration;
@@ -309,10 +310,13 @@ pub enum ControlSignal {
/// for that — only a function parameter pass-through.
///
/// If `active_run_id` is `None` at write time (no `session/update` seen yet
-/// — e.g. agents that never emit run-id metadata), the steer cannot form a
-/// valid `expectedRunId` and the read loop acks
-/// [`SteerError::ExpectedRunIdMissing`]. The main loop maps this to the
-/// "Err-before-pending" bucket: no withhold/mark was established at
+/// — e.g. agents that never emit run-id metadata), the goose-native method
+/// cannot form a valid `expectedRunId`, and the read loop falls back to the
+/// cross-adapter `_session/steering` method when the agent advertised
+/// `_meta.steering.supported` at `initialize`. That method takes no run id, so
+/// no freshness concern applies to it. When neither transport is available the
+/// read loop acks [`SteerError::ExpectedRunIdMissing`]. The main loop maps that
+/// to the "Err-before-pending" bucket: no withhold/mark was established at
/// `pool::send_steer` time because the request was rejected before any
/// write, so the watcher only needs to release nothing and fall back to the
/// universal `ControlSignal::Steer` cancel+merge path.
@@ -326,7 +330,8 @@ pub struct SteerRequest {
pub ack_tx: tokio::sync::oneshot::Sender,
}
-/// Why a goose-native steer failed.
+/// Why a mid-turn steer failed, on either transport
+/// (`_goose/unstable/session/steer` or `_session/steering`).
///
/// String and integer fields are intentionally `Debug`-only — read by
/// `tracing` macros in the main loop's `PoolEvent::SteerAck` arm via
@@ -349,14 +354,28 @@ pub enum SteerError {
/// Transport-level failure: write error, read EOF, JSON-RPC framing
/// violation, etc. The string carries the underlying `AcpError`'s display.
Transport(String),
- /// At steer-write time `AcpClient::active_run_id` was `None`, so the
- /// read loop couldn't form a valid `expectedRunId`. The read loop drops
- /// the request without writing anything; the main loop should release
- /// any withheld event and fall back to the universal cancel+merge
+ /// At steer-write time neither steer transport was available: no
+ /// `expectedRunId` (`AcpClient::active_run_id` was `None`, so the
+ /// goose-native method could not be formed) and the agent did not
+ /// advertise the cross-adapter `_session/steering` extension. The read
+ /// loop drops the request without writing anything; the main loop should
+ /// release any withheld event and fall back to the universal cancel+merge
/// `ControlSignal::Steer` path. This is in the same "Err-before-pending"
/// bucket as `Transport` write failures: no in-process state was
/// established, so no in-process cleanup is needed.
ExpectedRunIdMissing,
+ /// A `_session/steering` request returned a JSON-RPC *success* whose
+ /// `outcome` was not one of the two recognized delivery outcomes
+ /// (`injected`, `startedNewTurn`) — including `failed` (codex-acp) and a
+ /// missing `outcome` entirely. `outcome` carries what the agent actually
+ /// reported, for logs.
+ ///
+ /// The steer did NOT land, so the main loop must release the withheld
+ /// event and fire the cancel+merge fallback — exactly like a write that
+ /// never happened. Treating an unrecognized success as delivery would
+ /// drop the user's message: codex-acp answers unrecognized extension
+ /// methods with a bare `{}` success rather than `-32601`.
+ OutcomeRejected { outcome: String },
/// The read loop never got to dispatch the steer because the prompt
/// completed first. Delivery state for the underlying message is
/// unknown after prompt completion — the main loop must treat this as
@@ -369,7 +388,7 @@ pub enum SteerError {
PromptCompleted,
}
-/// Outcome of a goose-native steer, sent from the read loop back to the
+/// Outcome of a mid-turn steer, sent from the read loop back to the
/// main loop's ack watcher.
#[derive(Debug)]
pub enum SteerAck {
@@ -782,6 +801,9 @@ pub enum IdleSwitchResult {
/// 2 × CONTEXT_FETCH_TIMEOUT + CONTEXT_FETCH_RETRY_DELAY ≈ 6.5 s.
const CONTEXT_FETCH_TIMEOUT: Duration = Duration::from_millis(3_000);
+/// Short, single-attempt timeout for best-effort exact truncated-thread counts.
+const CONTEXT_COUNT_TIMEOUT: Duration = Duration::from_millis(500);
+
/// Delay between the first failed context fetch and the single retry.
const CONTEXT_FETCH_RETRY_DELAY: Duration = Duration::from_millis(500);
@@ -1879,6 +1901,18 @@ pub async fn run_prompt_task(
None => prompt_sections.iter().map(String::as_str).collect(),
};
+ // Turn start, labelled exactly as `log_stop_reason` labels the end, so a
+ // log reads as start/stop pairs. Purely observational: an unpaired start is
+ // the only durable evidence that a turn was entered and never returned, and
+ // without it a stalled agent and an agent nobody woke leave identical logs —
+ // zero completions either way, so anything reading them afterwards has to
+ // guess which happened.
+ tracing::info!(
+ target: "pool::prompt",
+ "turn starting for {}",
+ prompt_label(&source)
+ );
+
// When control_rx is Some (channel tasks), wrap the prompt in select! so
// the main loop can cancel, interrupt, or rotate it. Heartbeats
// (control_rx=None) take the simple await path — they are not controllable.
@@ -2570,7 +2604,14 @@ async fn fetch_conversation_context(
let last_event = batch.events.last()?;
let tags = crate::queue::parse_thread_tags(&last_event.event);
if let Some(root_id) = tags.root_event_id {
- return fetch_thread_context(batch.channel_id, &root_id, limit, &ctx.rest_client).await;
+ return fetch_thread_context(
+ batch.channel_id,
+ &root_id,
+ limit,
+ ctx.agent_keys.public_key(),
+ &ctx.rest_client,
+ )
+ .await;
}
// DM non-reply: fetch recent conversation history.
@@ -2732,12 +2773,48 @@ async fn fetch_prompt_profile_lookup(
}
/// Fetch thread context via Nostr query: root event by ID + replies by `#e` tag.
+///
+/// The reply query intentionally requests one more reply than the configured
+/// display window. That sentinel event lets the prompt say `N of M, truncated`
+/// when the relay has more thread history, instead of reporting the capped page
+/// as the total. When the window is full, a best-effort `/count` attempts to
+/// improve that lower-bound total; because it is a separate racy request, the
+/// result is clamped to the sentinel-proven minimum. The query also asks for the
+/// agent's newest reply separately so the next prompt can include the agent's
+/// own prior turn even in busy threads where the recent-message window would
+/// otherwise push it out.
async fn fetch_thread_context(
channel_id: Uuid,
root_event_id: &str,
limit: u32,
+ agent_pubkey: nostr::PublicKey,
rest: &RestClient,
) -> Option {
+ fetch_thread_context_with(
+ channel_id,
+ root_event_id,
+ limit,
+ agent_pubkey,
+ |filters| async move { rest.query(&filters).await },
+ |filters| async move { rest.count(&filters).await },
+ )
+ .await
+}
+
+async fn fetch_thread_context_with(
+ channel_id: Uuid,
+ root_event_id: &str,
+ limit: u32,
+ agent_pubkey: nostr::PublicKey,
+ query: Query,
+ count: Count,
+) -> Option
+where
+ Query: Fn(Vec) -> QueryFut,
+ QueryFut: std::future::Future>,
+ Count: Fn(Vec) -> CountFut,
+ CountFut: std::future::Future>,
+{
use nostr::{Alphabet, SingleLetterTag};
// Defense-in-depth: validate hex event ID.
@@ -2756,7 +2833,8 @@ async fn fetch_thread_context(
let h_tag = SingleLetterTag::lowercase(Alphabet::H);
let ch_str = channel_id.to_string();
- // Two filters: (1) root event by ID, (2) replies with #e=root + #h=channel.
+ // Three filters: (1) root event by ID, (2) recent replies with #e=root +
+ // #h=channel plus a sentinel, and (3) the agent's newest reply for pinning.
let root_filter = nostr::Filter::new().id(nostr::EventId::from_hex(root_event_id).ok()?);
let replies_filter = nostr::Filter::new()
.kinds([
@@ -2765,16 +2843,23 @@ async fn fetch_thread_context(
])
.custom_tags(e_tag, [root_event_id])
.custom_tags(h_tag, [ch_str.as_str()])
- .limit(limit as usize);
+ .limit(limit.saturating_add(1) as usize);
+ let agent_reply_filter = replies_filter.clone().author(agent_pubkey).limit(1);
- fetch_with_retry(|| async {
+ let context = fetch_with_retry(|| async {
match timeout(
CONTEXT_FETCH_TIMEOUT,
- rest.query(&[root_filter.clone(), replies_filter.clone()]),
+ query(vec![
+ root_filter.clone(),
+ replies_filter.clone(),
+ agent_reply_filter.clone(),
+ ]),
)
.await
{
- Ok(Ok(json)) => parse_nostr_thread_response(json, root_event_id),
+ Ok(Ok(json)) => {
+ parse_nostr_thread_response_with_meta(json, root_event_id, limit, &agent_pubkey)
+ }
Ok(Err(e)) => {
tracing::warn!(
channel_id = %channel_id,
@@ -2793,7 +2878,75 @@ async fn fetch_thread_context(
}
}
})
- .await
+ .await;
+
+ let mut parsed = context?;
+
+ if matches!(
+ parsed.context,
+ ConversationContext::Thread {
+ truncated: true,
+ ..
+ }
+ ) {
+ let replies_count_filter = replies_filter.clone().limit(0);
+ if let Some(total) = fetch_thread_total(
+ channel_id,
+ &replies_count_filter,
+ parsed.root_present,
+ &count,
+ )
+ .await
+ {
+ if let ConversationContext::Thread {
+ total: context_total,
+ ..
+ } = &mut parsed.context
+ {
+ let sentinel_minimum = *context_total;
+ // `/count` is a separate best-effort request after the message
+ // query. If replies are deleted between the two, the exact count
+ // can fall below the already-proven sentinel minimum; never
+ // render impossible labels like `13 of 12 messages, truncated`.
+ *context_total = total.max(sentinel_minimum);
+ }
+ }
+ }
+
+ Some(parsed.context)
+}
+
+/// Best-effort exact thread size for truncated context labels.
+async fn fetch_thread_total(
+ channel_id: Uuid,
+ replies_filter: &nostr::Filter,
+ root_present: bool,
+ count: &Count,
+) -> Option
+where
+ Count: Fn(Vec) -> CountFut,
+ CountFut: std::future::Future>,
+{
+ let replies_count =
+ match timeout(CONTEXT_COUNT_TIMEOUT, count(vec![replies_filter.clone()])).await {
+ Ok(Ok(json)) => json.get("count").and_then(|v| v.as_u64())?,
+ Ok(Err(e)) => {
+ tracing::debug!(
+ channel_id = %channel_id,
+ "thread context count failed; using sentinel minimum: {e}"
+ );
+ return None;
+ }
+ Err(_) => {
+ tracing::debug!(
+ channel_id = %channel_id,
+ "thread context count timed out; using sentinel minimum"
+ );
+ return None;
+ }
+ };
+
+ Some(replies_count as usize + usize::from(root_present))
}
/// Fetch DM context via Nostr query: recent messages in channel by `#h` tag.
@@ -2946,48 +3099,110 @@ fn json_to_context_message(obj: &serde_json::Value) -> Option {
/// Parse a Nostr query response (array of events) into thread context.
///
-/// Separates the root event (matching `root_event_id`) from replies, sorts
-/// chronologically by `created_at`.
+/// Separates the root event (matching `root_event_id`) from replies, keeps the
+/// newest `limit` replies returned by the sentinel query, then sorts the
+/// displayed window chronologically for the prompt. If the agent's newest reply
+/// is outside that window, keep it instead of the oldest displayed reply so the
+/// next prompt always includes the agent's most recent prior turn.
+#[cfg(test)]
fn parse_nostr_thread_response(
json: serde_json::Value,
root_event_id: &str,
+ limit: u32,
+ agent_pubkey: &nostr::PublicKey,
) -> Option {
+ parse_nostr_thread_response_with_meta(json, root_event_id, limit, agent_pubkey)
+ .map(|parsed| parsed.context)
+}
+
+struct ParsedThreadContext {
+ context: ConversationContext,
+ root_present: bool,
+}
+
+fn parse_nostr_thread_response_with_meta(
+ json: serde_json::Value,
+ root_event_id: &str,
+ limit: u32,
+ agent_pubkey: &nostr::PublicKey,
+) -> Option {
let events = json.as_array()?;
+ let agent_pubkey_hex = agent_pubkey.to_hex();
let mut root_msg = None;
let mut reply_msgs = Vec::new();
+ let mut seen_reply_ids = HashSet::new();
for ev in events {
let ev_id = ev.get("id").and_then(|v| v.as_str()).unwrap_or("");
if let Some(msg) = json_to_context_message(ev) {
if ev_id == root_event_id {
root_msg = Some(msg);
- } else {
+ } else if seen_reply_ids.insert(ev_id.to_string()) {
+ let is_agent = msg.pubkey.eq_ignore_ascii_case(&agent_pubkey_hex);
reply_msgs.push((
+ ev_id.to_string(),
ev.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0),
+ is_agent,
msg,
));
}
}
}
- // Sort replies chronologically.
- reply_msgs.sort_by_key(|(ts, _)| *ts);
+ let root_present = root_msg.is_some();
+ let fetched_total = reply_msgs.len() + usize::from(root_present);
+ let newest_agent_reply = reply_msgs
+ .iter()
+ .filter(|(_, _, is_agent, _)| *is_agent)
+ .max_by_key(|(_, ts, _, _)| *ts)
+ .cloned();
+
+ let truncated = reply_msgs.len() > limit as usize;
+ if truncated {
+ // The relay returns limited REQ results newest-first. Sort explicitly so
+ // the sentinel we drop is the oldest reply in the fetched window, not an
+ // arbitrary last element if the HTTP bridge ever changes iteration order.
+ reply_msgs.sort_by_key(|(_, ts, _, _)| Reverse(*ts));
+ reply_msgs.truncate(limit as usize);
+ }
+
+ if let Some(agent_reply) = newest_agent_reply {
+ let agent_reply_already_displayed =
+ reply_msgs.iter().any(|(id, _, _, _)| *id == agent_reply.0);
+ if !agent_reply_already_displayed {
+ reply_msgs.sort_by_key(|(_, ts, _, _)| *ts);
+ if let Some(oldest) = reply_msgs.first_mut() {
+ *oldest = agent_reply;
+ }
+ }
+ }
+
+ // Sort displayed replies chronologically.
+ reply_msgs.sort_by_key(|(_, ts, _, _)| *ts);
let mut messages = Vec::new();
if let Some(root) = root_msg {
messages.push(root);
}
- messages.extend(reply_msgs.into_iter().map(|(_, msg)| msg));
+ messages.extend(reply_msgs.into_iter().map(|(_, _, _, msg)| msg));
- let total = messages.len();
if messages.is_empty() {
return None;
}
- Some(ConversationContext::Thread {
- messages,
- total,
- truncated: false, // query returns all within limit
+ let total = if truncated {
+ fetched_total // all distinct fetched replies plus the root are proven visible history
+ } else {
+ messages.len()
+ };
+
+ Some(ParsedThreadContext {
+ context: ConversationContext::Thread {
+ messages,
+ total,
+ truncated,
+ },
+ root_present,
})
}
@@ -3113,12 +3328,19 @@ fn classify_control_cancel_failure(
}
}
-/// Log a stop reason at the appropriate tracing level.
-fn log_stop_reason(source: &PromptSource, stop_reason: &StopReason) {
- let label = match source {
+/// How a turn's source is named in the `pool::prompt` log lines.
+///
+/// Shared by the turn-start and turn-stop lines so a log can be read as pairs.
+fn prompt_label(source: &PromptSource) -> String {
+ match source {
PromptSource::Channel(cid) => format!("channel {cid}"),
PromptSource::Heartbeat => "heartbeat".to_string(),
- };
+ }
+}
+
+/// Log a stop reason at the appropriate tracing level.
+fn log_stop_reason(source: &PromptSource, stop_reason: &StopReason) {
+ let label = prompt_label(source);
match stop_reason {
StopReason::EndTurn => {
tracing::info!(target: "pool::prompt", "turn complete for {label}: end_turn");
@@ -3372,33 +3594,35 @@ fn acp_stop_to_core(r: &StopReason) -> buzz_core::agent_turn_metric::StopReason
}
}
-/// Best-effort: build and publish a `kind:44200` NIP-AM agent turn metric event.
+/// Build the `(turn, cumulative)` `TokenCounts` pair for a NIP-AM kind-44200
+/// payload from a completed `TurnUsage`.
///
-/// Does nothing when `usage` is `None` (goose emitted no usage notification
-/// for this turn) or when `owner_pubkey` is unconfigured (no NIP-AO identity).
-/// Errors are logged at WARN and never surface to the caller — metric
-/// publishing must never fail a turn.
-async fn publish_agent_turn_metric(
- ctx: &PromptContext,
- usage: Option,
- channel_id: Option,
- session_id: &str,
- turn_id: &str,
- stop_reason: Option,
+/// Extracted as a pure function so the mapping logic can be tested independently
+/// of relay/crypto infrastructure. `publish_agent_turn_metric` is the only
+/// production caller.
+///
+/// - `turn` is `None` when `delta_reliable` is false; otherwise it carries the
+/// per-turn i/o/total/cost deltas for this turn.
+/// - `cumulative` always carries the session-aggregate i/o/cost totals.
+/// `total_tokens` is `Some` only when the session accumulated a genuine
+/// provider-reported total on every turn — never derived from i/o sums
+/// (NIP-AM MUST NOT).
+pub(crate) fn build_turn_metric_counts(
+ usage: &crate::usage::TurnUsage,
+) -> (
+ Option,
+ Option,
) {
- use buzz_core::agent_turn_metric::{AgentTurnMetricPayload, TokenCounts};
- use nostr::{EventBuilder, Kind, Tag};
-
- let (usage, owner_pk) = match (usage, ctx.agent_owner_pubkey.as_ref()) {
- (Some(u), Some(pk)) => (u, pk),
- _ => return,
- };
+ use buzz_core::agent_turn_metric::TokenCounts;
let turn_counts = if usage.delta_reliable {
Some(TokenCounts {
input_tokens: usage.turn_input_tokens,
output_tokens: usage.turn_output_tokens,
- total_tokens: None,
+ // Field-local: present only when both the previous and current
+ // cumulative totals were available and monotonic. Never derived
+ // from input+output.
+ total_tokens: usage.turn_total_tokens,
cost_usd: usage.turn_cost_usd,
cache_read_tokens: None,
cache_write_tokens: None,
@@ -3413,11 +3637,40 @@ async fn publish_agent_turn_metric(
let cumulative_counts = Some(TokenCounts {
input_tokens: Some(usage.cumulative_input_tokens),
output_tokens: Some(usage.cumulative_output_tokens),
- total_tokens: None,
+ // Present when every turn in the session reported a genuine provider
+ // total. None when the session has never emitted one or any turn lacked
+ // one. Never derived from input+output (NIP-AM MUST NOT).
+ total_tokens: usage.cumulative_total_tokens,
cost_usd: usage.cumulative_cost_usd,
cache_read_tokens: None,
cache_write_tokens: None,
});
+ (turn_counts, cumulative_counts)
+}
+
+/// Best-effort: build and publish a `kind:44200` NIP-AM agent turn metric event.
+///
+/// Does nothing when `usage` is `None` (goose emitted no usage notification
+/// for this turn) or when `owner_pubkey` is unconfigured (no NIP-AO identity).
+/// Errors are logged at WARN and never surface to the caller — metric
+/// publishing must never fail a turn.
+async fn publish_agent_turn_metric(
+ ctx: &PromptContext,
+ usage: Option,
+ channel_id: Option,
+ session_id: &str,
+ turn_id: &str,
+ stop_reason: Option,
+) {
+ use buzz_core::agent_turn_metric::AgentTurnMetricPayload;
+ use nostr::{EventBuilder, Kind, Tag};
+
+ let (usage, owner_pk) = match (usage, ctx.agent_owner_pubkey.as_ref()) {
+ (Some(u), Some(pk)) => (u, pk),
+ _ => return,
+ };
+
+ let (turn_counts, cumulative_counts) = build_turn_metric_counts(&usage);
let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
let payload = AgentTurnMetricPayload {
harness: ctx.harness_name.clone(),
@@ -4136,6 +4389,572 @@ mod tests {
assert!(parse_dm_response(json, 12).is_none());
}
+ #[test]
+ fn test_parse_nostr_thread_response_marks_query_window_truncated() {
+ let agent = Keys::generate();
+ let root_id = "1111111111111111111111111111111111111111111111111111111111111111";
+ let agent_hex = agent.public_key().to_hex();
+ let json = json!([
+ {
+ "id": root_id,
+ "pubkey": "rootpub",
+ "content": "root",
+ "created_at": 1000
+ },
+ {
+ "id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "pubkey": agent_hex,
+ "content": "newest agent reply",
+ "created_at": 4000
+ },
+ {
+ "id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "pubkey": "humanpub",
+ "content": "middle reply",
+ "created_at": 3000
+ },
+ {
+ "id": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+ "pubkey": "oldpub",
+ "content": "sentinel omitted reply",
+ "created_at": 2000
+ }
+ ]);
+
+ let ctx = parse_nostr_thread_response(json, root_id, 2, &agent.public_key())
+ .expect("should parse");
+ match ctx {
+ ConversationContext::Thread {
+ messages,
+ total,
+ truncated,
+ } => {
+ assert_eq!(messages.len(), 3); // root + 2 displayed replies
+ assert_eq!(total, 4); // root + displayed replies + sentinel
+ assert!(truncated);
+ assert_eq!(messages[0].content, "root");
+ assert_eq!(messages[1].content, "middle reply");
+ assert_eq!(messages[2].content, "newest agent reply");
+ assert!(messages
+ .iter()
+ .all(|msg| msg.content != "sentinel omitted reply"));
+ }
+ _ => panic!("expected Thread context"),
+ }
+ }
+
+ #[test]
+ fn test_parse_nostr_thread_response_not_truncated_below_limit() {
+ let agent = Keys::generate();
+ let root_id = "1111111111111111111111111111111111111111111111111111111111111111";
+ let json = json!([
+ {
+ "id": root_id,
+ "pubkey": "rootpub",
+ "content": "root",
+ "created_at": 1000
+ },
+ {
+ "id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "pubkey": "replypub",
+ "content": "reply",
+ "created_at": 2000
+ }
+ ]);
+
+ let ctx = parse_nostr_thread_response(json, root_id, 2, &agent.public_key())
+ .expect("should parse");
+ match ctx {
+ ConversationContext::Thread {
+ messages,
+ total,
+ truncated,
+ } => {
+ assert_eq!(messages.len(), 2);
+ assert_eq!(total, 2);
+ assert!(!truncated);
+ }
+ _ => panic!("expected Thread context"),
+ }
+ }
+
+ #[test]
+ fn test_parse_nostr_thread_response_keeps_agent_reply_outside_recent_window() {
+ let agent = Keys::generate();
+ let root_id = "1111111111111111111111111111111111111111111111111111111111111111";
+ let agent_hex = agent.public_key().to_hex();
+ let json = json!([
+ {
+ "id": root_id,
+ "pubkey": "rootpub",
+ "content": "root",
+ "created_at": 1000
+ },
+ {
+ "id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "pubkey": "humanpub",
+ "content": "newer human reply",
+ "created_at": 5000
+ },
+ {
+ "id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "pubkey": "humanpub",
+ "content": "middle human reply",
+ "created_at": 4000
+ },
+ {
+ "id": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+ "pubkey": "humanpub",
+ "content": "oldest displayed reply without agent pin",
+ "created_at": 3000
+ },
+ {
+ "id": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
+ "pubkey": agent_hex,
+ "content": "agent reply outside recent window",
+ "created_at": 2000
+ }
+ ]);
+
+ let ctx = parse_nostr_thread_response(json, root_id, 2, &agent.public_key())
+ .expect("should parse");
+ match ctx {
+ ConversationContext::Thread { messages, .. } => {
+ assert_eq!(messages.len(), 3); // root + 2 displayed replies
+ assert_eq!(messages[0].content, "root");
+ assert!(messages
+ .iter()
+ .any(|msg| msg.content == "agent reply outside recent window"));
+ assert!(messages
+ .iter()
+ .any(|msg| msg.content == "newer human reply"));
+ assert!(messages
+ .iter()
+ .all(|msg| msg.content != "middle human reply"));
+ assert!(messages
+ .iter()
+ .all(|msg| msg.content != "oldest displayed reply without agent pin"));
+ }
+ _ => panic!("expected Thread context"),
+ }
+ }
+
+ #[tokio::test]
+ async fn test_fetch_thread_context_uses_exact_count_when_above_sentinel_minimum() {
+ let agent = Keys::generate();
+ let root_id = "1111111111111111111111111111111111111111111111111111111111111111";
+ let channel_id = Uuid::new_v4();
+ let agent_pubkey = agent.public_key();
+ let json = json!([
+ thread_event(root_id, "rootpub", "root", 1000),
+ thread_event(
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "humanpub",
+ "newest reply",
+ 4000
+ ),
+ thread_event(
+ "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "humanpub",
+ "middle reply",
+ 3000
+ ),
+ thread_event(
+ "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+ "humanpub",
+ "sentinel reply",
+ 2000
+ )
+ ]);
+
+ let ctx = fetch_thread_context_with(
+ channel_id,
+ root_id,
+ 2,
+ agent_pubkey,
+ move |filters| {
+ assert_thread_query_filters(&filters, channel_id, root_id, agent_pubkey, 3);
+ std::future::ready(Ok(json.clone()))
+ },
+ move |filters| {
+ assert_thread_count_filter(&filters, channel_id, root_id);
+ std::future::ready(Ok(json!({ "count": 6 })))
+ },
+ )
+ .await
+ .expect("thread context");
+
+ match ctx {
+ ConversationContext::Thread {
+ messages,
+ total,
+ truncated,
+ } => {
+ assert!(truncated);
+ assert_eq!(messages.len(), 3);
+ assert_eq!(total, 7); // 6 replies + root
+ }
+ _ => panic!("expected Thread context"),
+ }
+ }
+
+ #[tokio::test]
+ async fn test_fetch_thread_context_does_not_add_missing_root_to_exact_count() {
+ let agent = Keys::generate();
+ let root_id = "1111111111111111111111111111111111111111111111111111111111111111";
+ let channel_id = Uuid::new_v4();
+ let json = json!([
+ thread_event(
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "humanpub",
+ "newest reply",
+ 4000
+ ),
+ thread_event(
+ "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "humanpub",
+ "middle reply",
+ 3000
+ ),
+ thread_event(
+ "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+ "humanpub",
+ "sentinel reply",
+ 2000
+ )
+ ]);
+
+ let ctx = fetch_thread_context_with(
+ channel_id,
+ root_id,
+ 2,
+ agent.public_key(),
+ move |_filters| std::future::ready(Ok(json.clone())),
+ |_filters| std::future::ready(Ok(json!({ "count": 6 }))),
+ )
+ .await
+ .expect("thread context");
+
+ match ctx {
+ ConversationContext::Thread {
+ messages,
+ total,
+ truncated,
+ } => {
+ assert!(truncated);
+ assert_eq!(messages.len(), 2);
+ assert_eq!(total, 6);
+ }
+ _ => panic!("expected Thread context"),
+ }
+ }
+
+ #[tokio::test]
+ async fn test_fetch_thread_context_clamps_count_below_sentinel_minimum() {
+ let agent = Keys::generate();
+ let root_id = "1111111111111111111111111111111111111111111111111111111111111111";
+ let channel_id = Uuid::new_v4();
+ let json = json!([
+ thread_event(root_id, "rootpub", "root", 1000),
+ thread_event(
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "humanpub",
+ "newest reply",
+ 4000
+ ),
+ thread_event(
+ "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "humanpub",
+ "middle reply",
+ 3000
+ ),
+ thread_event(
+ "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+ "humanpub",
+ "sentinel reply",
+ 2000
+ )
+ ]);
+
+ let ctx = fetch_thread_context_with(
+ channel_id,
+ root_id,
+ 2,
+ agent.public_key(),
+ move |_filters| std::future::ready(Ok(json.clone())),
+ |_filters| std::future::ready(Ok(json!({ "count": 1 }))),
+ )
+ .await
+ .expect("thread context");
+
+ match ctx {
+ ConversationContext::Thread {
+ messages,
+ total,
+ truncated,
+ } => {
+ assert!(truncated);
+ assert_eq!(messages.len(), 3);
+ assert_eq!(total, 4); // root + displayed replies + sentinel minimum
+ }
+ _ => panic!("expected Thread context"),
+ }
+ }
+
+ #[tokio::test]
+ async fn test_fetch_thread_context_preserves_sentinel_minimum_when_count_fails() {
+ let agent = Keys::generate();
+ let root_id = "1111111111111111111111111111111111111111111111111111111111111111";
+ let channel_id = Uuid::new_v4();
+ let json = json!([
+ thread_event(root_id, "rootpub", "root", 1000),
+ thread_event(
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "humanpub",
+ "newest reply",
+ 4000
+ ),
+ thread_event(
+ "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "humanpub",
+ "middle reply",
+ 3000
+ ),
+ thread_event(
+ "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+ "humanpub",
+ "sentinel reply",
+ 2000
+ )
+ ]);
+
+ let ctx = fetch_thread_context_with(
+ channel_id,
+ root_id,
+ 2,
+ agent.public_key(),
+ move |_filters| std::future::ready(Ok(json.clone())),
+ |_filters| std::future::ready(Err(crate::relay::RelayError::Http("boom".into()))),
+ )
+ .await
+ .expect("thread context");
+
+ match ctx {
+ ConversationContext::Thread {
+ messages,
+ total,
+ truncated,
+ } => {
+ assert!(truncated);
+ assert_eq!(messages.len(), 3);
+ assert_eq!(total, 4); // count failure leaves parser's sentinel minimum intact
+ }
+ _ => panic!("expected Thread context"),
+ }
+ }
+
+ #[tokio::test]
+ async fn test_fetch_thread_context_deduplicates_and_pins_agent_reply() {
+ let agent = Keys::generate();
+ let agent_hex = agent.public_key().to_hex();
+ let root_id = "1111111111111111111111111111111111111111111111111111111111111111";
+ let channel_id = Uuid::new_v4();
+ let json = json!([
+ thread_event(root_id, "rootpub", "root", 1000),
+ thread_event(
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "humanpub",
+ "newer human reply",
+ 5000
+ ),
+ thread_event(
+ "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "humanpub",
+ "middle human reply",
+ 4000
+ ),
+ thread_event(
+ "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+ &agent_hex,
+ "agent reply outside recent window",
+ 2000
+ ),
+ // Same event as the separately fetched author-filtered result; the
+ // parser should deduplicate it before pinning.
+ thread_event(
+ "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+ &agent_hex,
+ "agent reply outside recent window",
+ 2000
+ )
+ ]);
+
+ let ctx = fetch_thread_context_with(
+ channel_id,
+ root_id,
+ 2,
+ agent.public_key(),
+ move |_filters| std::future::ready(Ok(json.clone())),
+ |_filters| std::future::ready(Ok(json!({ "count": 3 }))),
+ )
+ .await
+ .expect("thread context");
+
+ match ctx {
+ ConversationContext::Thread {
+ messages,
+ total,
+ truncated,
+ } => {
+ assert!(truncated);
+ assert_eq!(total, 4);
+ assert_eq!(messages.len(), 3);
+ assert_eq!(
+ messages
+ .iter()
+ .filter(|msg| msg.content == "agent reply outside recent window")
+ .count(),
+ 1,
+ "separate agent-reply query must not duplicate the same event"
+ );
+ assert!(messages
+ .iter()
+ .any(|msg| msg.content == "newer human reply"));
+ assert!(messages
+ .iter()
+ .all(|msg| msg.content != "middle human reply"));
+ }
+ _ => panic!("expected Thread context"),
+ }
+ }
+
+ #[tokio::test]
+ async fn test_fetch_thread_context_uses_distinct_fetched_replies_as_minimum() {
+ let agent = Keys::generate();
+ let agent_hex = agent.public_key().to_hex();
+ let root_id = "1111111111111111111111111111111111111111111111111111111111111111";
+ let channel_id = Uuid::new_v4();
+ let json = json!([
+ thread_event(root_id, "rootpub", "root", 1000),
+ thread_event(
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "humanpub",
+ "newest human reply",
+ 5000
+ ),
+ thread_event(
+ "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "humanpub",
+ "middle human reply",
+ 4000
+ ),
+ thread_event(
+ "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+ "humanpub",
+ "sentinel human reply",
+ 3000
+ ),
+ thread_event(
+ "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
+ &agent_hex,
+ "older distinct agent reply",
+ 2000
+ )
+ ]);
+
+ let ctx = fetch_thread_context_with(
+ channel_id,
+ root_id,
+ 2,
+ agent.public_key(),
+ move |_filters| std::future::ready(Ok(json.clone())),
+ |_filters| std::future::ready(Err(crate::relay::RelayError::Http("boom".into()))),
+ )
+ .await
+ .expect("thread context");
+
+ match ctx {
+ ConversationContext::Thread {
+ messages,
+ total,
+ truncated,
+ } => {
+ assert!(truncated);
+ assert_eq!(messages.len(), 3);
+ assert_eq!(
+ total, 5,
+ "root plus all four distinct fetched replies prove the lower bound"
+ );
+ assert!(messages
+ .iter()
+ .any(|msg| msg.content == "older distinct agent reply"));
+ assert!(messages
+ .iter()
+ .any(|msg| msg.content == "newest human reply"));
+ assert!(messages
+ .iter()
+ .all(|msg| msg.content != "middle human reply"));
+ assert!(messages
+ .iter()
+ .all(|msg| msg.content != "sentinel human reply"));
+ }
+ _ => panic!("expected Thread context"),
+ }
+ }
+
+ fn assert_thread_query_filters(
+ filters: &[nostr::Filter],
+ channel_id: Uuid,
+ root_id: &str,
+ agent_pubkey: nostr::PublicKey,
+ reply_limit: u64,
+ ) {
+ assert_eq!(
+ filters.len(),
+ 3,
+ "root, recent replies, and agent reply filters"
+ );
+
+ let root = serde_json::to_value(&filters[0]).expect("serialize root filter");
+ assert_eq!(root.get("ids"), Some(&json!([root_id])));
+ assert!(root.get("limit").is_none());
+
+ let replies = serde_json::to_value(&filters[1]).expect("serialize replies filter");
+ assert_eq!(replies.get("kinds"), Some(&json!([9, 40002])));
+ assert_eq!(replies.get("#e"), Some(&json!([root_id])));
+ assert_eq!(replies.get("#h"), Some(&json!([channel_id.to_string()])));
+ assert_eq!(replies.get("limit"), Some(&json!(reply_limit)));
+ assert!(replies.get("authors").is_none());
+
+ let agent = serde_json::to_value(&filters[2]).expect("serialize agent filter");
+ assert_eq!(agent.get("kinds"), Some(&json!([9, 40002])));
+ assert_eq!(agent.get("#e"), Some(&json!([root_id])));
+ assert_eq!(agent.get("#h"), Some(&json!([channel_id.to_string()])));
+ assert_eq!(agent.get("authors"), Some(&json!([agent_pubkey.to_hex()])));
+ assert_eq!(agent.get("limit"), Some(&json!(1)));
+ }
+
+ fn assert_thread_count_filter(filters: &[nostr::Filter], channel_id: Uuid, root_id: &str) {
+ assert_eq!(filters.len(), 1, "count should query only matching replies");
+
+ let count = serde_json::to_value(&filters[0]).expect("serialize count filter");
+ assert_eq!(count.get("kinds"), Some(&json!([9, 40002])));
+ assert_eq!(count.get("#e"), Some(&json!([root_id])));
+ assert_eq!(count.get("#h"), Some(&json!([channel_id.to_string()])));
+ assert_eq!(count.get("limit"), Some(&json!(0)));
+ assert!(count.get("ids").is_none());
+ assert!(count.get("authors").is_none());
+ }
+
+ fn thread_event(id: &str, pubkey: &str, content: &str, created_at: u64) -> serde_json::Value {
+ json!({
+ "id": id,
+ "pubkey": pubkey,
+ "content": content,
+ "created_at": created_at
+ })
+ }
+
#[test]
fn test_json_to_context_message_integer_timestamp() {
let obj = json!({
@@ -5201,9 +6020,11 @@ mod tests {
delta_reliable: true,
turn_input_tokens: Some(100),
turn_output_tokens: Some(50),
+ turn_total_tokens: None,
turn_cost_usd: None,
cumulative_input_tokens: 100,
cumulative_output_tokens: 50,
+ cumulative_total_tokens: None,
cumulative_cost_usd: None,
model: None,
};
@@ -5233,9 +6054,11 @@ mod tests {
delta_reliable: true,
turn_input_tokens: Some(200),
turn_output_tokens: Some(80),
+ turn_total_tokens: None,
turn_cost_usd: Some(0.001),
cumulative_input_tokens: 200,
cumulative_output_tokens: 80,
+ cumulative_total_tokens: None,
cumulative_cost_usd: Some(0.001),
model: None,
};
@@ -5266,9 +6089,11 @@ mod tests {
delta_reliable: true,
turn_input_tokens: Some(50),
turn_output_tokens: Some(20),
+ turn_total_tokens: None,
turn_cost_usd: None,
cumulative_input_tokens: 150,
cumulative_output_tokens: 70,
+ cumulative_total_tokens: None,
cumulative_cost_usd: None,
model: None,
};
@@ -5299,9 +6124,11 @@ mod tests {
delta_reliable: false, // first turn from buzz-agent
turn_input_tokens: None,
turn_output_tokens: None,
+ turn_total_tokens: None,
turn_cost_usd: None,
cumulative_input_tokens: 400,
cumulative_output_tokens: 100,
+ cumulative_total_tokens: None,
cumulative_cost_usd: None,
model: None,
};
@@ -5317,6 +6144,110 @@ mod tests {
.await;
}
+ /// `build_turn_metric_counts` maps exact turn and cumulative totals from
+ /// `TurnUsage` to the corresponding `TokenCounts.total_tokens` fields.
+ /// Reverting the production fields at the call site to `None` would break
+ /// this test; the test constrains the real code path.
+ #[test]
+ fn test_build_turn_metric_counts_exact_totals_map_through() {
+ let usage = crate::usage::TurnUsage {
+ session_id: "sess-total".to_string(),
+ turn_seq: 2,
+ delta_reliable: true,
+ turn_input_tokens: Some(100),
+ turn_output_tokens: Some(30),
+ turn_total_tokens: Some(130), // genuine per-turn total
+ turn_cost_usd: None,
+ cumulative_input_tokens: 500,
+ cumulative_output_tokens: 120,
+ cumulative_total_tokens: Some(620), // genuine cumulative total
+ cumulative_cost_usd: None,
+ model: None,
+ };
+
+ let (turn, cumulative) = crate::pool::build_turn_metric_counts(&usage);
+
+ // Serialise to JSON — this is what ultimately goes on the wire.
+ let turn_json = serde_json::to_value(turn.as_ref().expect("turn counts present")).unwrap();
+ let cum_json =
+ serde_json::to_value(cumulative.as_ref().expect("cumulative counts present")).unwrap();
+
+ // Per-turn total must be the genuine provider-reported value.
+ assert_eq!(
+ turn_json["totalTokens"],
+ serde_json::json!(130),
+ "per-turn total must map to TokenCounts.totalTokens in wire JSON"
+ );
+ assert_eq!(turn_json["inputTokens"], serde_json::json!(100));
+ assert_eq!(turn_json["outputTokens"], serde_json::json!(30));
+
+ // Cumulative total must be the genuine session total.
+ assert_eq!(
+ cum_json["totalTokens"],
+ serde_json::json!(620),
+ "cumulative total must map to TokenCounts.totalTokens in wire JSON"
+ );
+ assert_eq!(cum_json["inputTokens"], serde_json::json!(500));
+ assert_eq!(cum_json["outputTokens"], serde_json::json!(120));
+ }
+
+ /// When totals are absent, `build_turn_metric_counts` must produce null
+ /// `total_tokens` — never a derived input+output sum (NIP-AM MUST NOT).
+ /// Reverting the production fields to hardcoded `None` would leave this test
+ /// passing but input/output would disagree, making the null-path detectable.
+ #[test]
+ fn test_build_turn_metric_counts_null_totals_never_derived() {
+ let usage = crate::usage::TurnUsage {
+ session_id: "sess-nototal".to_string(),
+ turn_seq: 1,
+ delta_reliable: true,
+ turn_input_tokens: Some(200),
+ turn_output_tokens: Some(60),
+ turn_total_tokens: None, // provider did not supply a total
+ turn_cost_usd: None,
+ cumulative_input_tokens: 200,
+ cumulative_output_tokens: 60,
+ cumulative_total_tokens: None, // session has no total
+ cumulative_cost_usd: None,
+ model: None,
+ };
+
+ let (turn, cumulative) = crate::pool::build_turn_metric_counts(&usage);
+
+ let turn_json = serde_json::to_value(turn.as_ref().expect("turn counts present")).unwrap();
+ let cum_json =
+ serde_json::to_value(cumulative.as_ref().expect("cumulative counts present")).unwrap();
+
+ // total_tokens must be null in the wire JSON.
+ assert!(
+ turn_json["totalTokens"].is_null(),
+ "absent turn total must serialize as null — not derived from in+out"
+ );
+ assert!(
+ cum_json["totalTokens"].is_null(),
+ "absent cumulative total must serialize as null — not derived from in+out"
+ );
+
+ // Input/output must still carry their real values.
+ assert_eq!(
+ turn_json["inputTokens"],
+ serde_json::json!(200),
+ "inputTokens must be present even when total is absent"
+ );
+ assert_eq!(
+ turn_json["outputTokens"],
+ serde_json::json!(60),
+ "outputTokens must be present even when total is absent"
+ );
+
+ // The null total must not equal the input+output sum — it must be genuinely null.
+ let derived_sum = serde_json::json!(200u64 + 60u64);
+ assert_ne!(
+ turn_json["totalTokens"], derived_sum,
+ "total_tokens must never equal input+output when provider omitted it"
+ );
+ }
+
fn make_prompt_context_no_owner() -> PromptContext {
let agent_keys = nostr::Keys::generate();
make_prompt_context_impl(&agent_keys, None)
diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs
index c8312cc61e..aea5cee077 100644
--- a/crates/buzz-acp/src/relay.rs
+++ b/crates/buzz-acp/src/relay.rs
@@ -405,6 +405,19 @@ impl RestClient {
.map_err(|e| RelayError::Http(e.to_string()))
}
+ /// Count events via the HTTP bridge: `POST /count` with NIP-98 auth.
+ ///
+ /// Accepts a slice of `nostr::Filter` (serialized as JSON array).
+ /// Returns the bridge response as a `serde_json::Value` (usually `{ "count": n }`).
+ pub async fn count(&self, filters: &[nostr::Filter]) -> Result {
+ let body_bytes = serde_json::to_vec(filters)
+ .map_err(|e| RelayError::Http(format!("filter serialize error: {e}")))?;
+ let resp = self.bridge_post("/count", &body_bytes).await?;
+ resp.json()
+ .await
+ .map_err(|e| RelayError::Http(e.to_string()))
+ }
+
/// Submit a signed event via the HTTP bridge: `POST /events` with NIP-98 auth.
///
/// The event must already be signed. Returns the relay response JSON.
diff --git a/crates/buzz-acp/src/usage.rs b/crates/buzz-acp/src/usage.rs
index a4f7abd3b3..1629eee935 100644
--- a/crates/buzz-acp/src/usage.rs
+++ b/crates/buzz-acp/src/usage.rs
@@ -85,7 +85,21 @@ pub(crate) struct UsageUpdatePayload {
pub context_limit: u64,
pub accumulated_input_tokens: u64,
pub accumulated_output_tokens: u64,
+ /// The cache-served subset of `accumulated_input_tokens`. Optional — goose
+ /// does not send it, and buzz-agent only reports a non-zero value when the
+ /// provider returned a cache split, so `0` legitimately means either "no
+ /// cache hits" or "provider reported none".
+ #[serde(default)]
+ pub accumulated_cached_input_tokens: u64,
pub accumulated_cost: Option,
+ /// Session-cumulative genuine provider total tokens. Optional — only
+ /// emitted by buzz-agent when every turn in the session so far supplied a
+ /// provider-reported total. Absent for goose (field ignore-if-absent for
+ /// backward compat), for Anthropic-backed turns, and for sessions where any
+ /// turn lacked a provider total. NIP-AM forbids deriving this by summing
+ /// categories, so the UI must approximate when this field is absent.
+ #[serde(default)]
+ pub accumulated_total_tokens: Option,
/// Effective model id for this turn. Optional — goose payloads that
/// predate this field deserialize cleanly as `None`.
#[serde(default)]
@@ -107,6 +121,10 @@ struct SessionState {
last_output: u64,
/// Cumulative cost at the end of the LAST PUBLISHED turn.
last_cost: Option,
+ /// Cumulative total tokens at the end of the LAST PUBLISHED turn.
+ /// `None` when the session has never emitted a provider total (Unseen) or
+ /// when any prior turn lacked one (poisoned).
+ last_total: Option,
}
/// Per-turn usage record exposed to `TurnCompletionGuard` for NIP-AM publishing.
@@ -125,6 +143,11 @@ pub struct TurnUsage {
pub turn_input_tokens: Option,
/// Per-turn output token delta; `None` when unreliable.
pub turn_output_tokens: Option,
+ /// Per-turn total token delta; `None` when the cumulative total is
+ /// unavailable (no baseline, non-monotonic, or either snapshot was absent).
+ /// Field-local: a missing total never flips `delta_reliable` or invalidates
+ /// `turn_input_tokens`/`turn_output_tokens`.
+ pub turn_total_tokens: Option,
/// Per-turn cost delta (`current − previous`); `None` when unreliable or
/// either snapshot is missing.
pub turn_cost_usd: Option,
@@ -132,6 +155,9 @@ pub struct TurnUsage {
pub cumulative_input_tokens: u64,
/// Session-cumulative output tokens as reported by goose at end of turn.
pub cumulative_output_tokens: u64,
+ /// Session-cumulative genuine provider total tokens as reported by buzz-agent;
+ /// `None` when the session has never emitted one or any turn lacked one.
+ pub cumulative_total_tokens: Option,
/// Session-cumulative estimated cost in USD; `None` if goose did not report it.
pub cumulative_cost_usd: Option,
/// Effective model id for this turn (maps to NIP-AM `model`). `None` if the
@@ -212,6 +238,7 @@ impl UsageTracker {
let current_input = payload.accumulated_input_tokens;
let current_output = payload.accumulated_output_tokens;
let current_cost = payload.accumulated_cost;
+ let current_total = payload.accumulated_total_tokens;
// Determine whether this session is currently in-flight so we know
// whether to set `pending`. We compute the delta regardless so that
@@ -256,6 +283,17 @@ impl UsageTracker {
}
};
+ // Total-token delta: field-local — never affects `delta_reliable` or
+ // the input/output deltas. Null when: no baseline exists, either
+ // snapshot is absent, or cumulative total decreased.
+ let turn_total = match self.sessions.get(session_id) {
+ Some(prev) => match (current_total, prev.last_total) {
+ (Some(cur), Some(p)) if cur >= p => Some(cur - p),
+ _ => None, // no baseline, absent on either side, or decrease
+ },
+ None => None, // no baseline yet
+ };
+
if is_in_flight {
// In-flight-match: update pending with the latest cumulative values.
// Baseline is NOT advanced here — it advances only on take().
@@ -265,9 +303,11 @@ impl UsageTracker {
delta_reliable,
turn_input_tokens: turn_input,
turn_output_tokens: turn_output,
+ turn_total_tokens: turn_total,
turn_cost_usd: turn_cost,
cumulative_input_tokens: current_input,
cumulative_output_tokens: current_output,
+ cumulative_total_tokens: current_total,
cumulative_cost_usd: current_cost,
model: payload.model.clone(),
});
@@ -286,6 +326,7 @@ impl UsageTracker {
last_input: current_input,
last_output: current_output,
last_cost: current_cost,
+ last_total: current_total,
},
);
}
@@ -313,6 +354,7 @@ impl UsageTracker {
last_input: record.cumulative_input_tokens,
last_output: record.cumulative_output_tokens,
last_cost: record.cumulative_cost_usd,
+ last_total: record.cumulative_total_tokens,
},
);
Some(record)
@@ -323,13 +365,46 @@ impl UsageTracker {
mod tests {
use super::*;
+ /// The camelCase key buzz-agent actually puts on the wire must land on the
+ /// field. A rename mismatch here would deserialize to the serde default of
+ /// 0, and every trial would price as if nothing had ever been cached — the
+ /// exact silent failure this field was added to remove.
+ #[test]
+ fn cached_input_tokens_deserialize_from_the_wire_key() {
+ let p: UsageUpdatePayload = serde_json::from_value(serde_json::json!({
+ "used": 15_247,
+ "contextLimit": 0,
+ "accumulatedInputTokens": 15_091,
+ "accumulatedOutputTokens": 156,
+ "accumulatedCachedInputTokens": 5_033,
+ }))
+ .expect("payload must deserialize");
+ assert_eq!(p.accumulated_cached_input_tokens, 5_033);
+ assert!(p.accumulated_cached_input_tokens <= p.accumulated_input_tokens);
+ }
+
+ /// goose does not send the field; its payloads must still deserialize.
+ #[test]
+ fn a_payload_without_the_cache_field_defaults_to_zero() {
+ let p: UsageUpdatePayload = serde_json::from_value(serde_json::json!({
+ "used": 500,
+ "contextLimit": 200_000,
+ "accumulatedInputTokens": 400,
+ "accumulatedOutputTokens": 100,
+ }))
+ .expect("payload must deserialize without the cache field");
+ assert_eq!(p.accumulated_cached_input_tokens, 0);
+ }
+
fn payload(input: u64, output: u64, cost: Option) -> UsageUpdatePayload {
UsageUpdatePayload {
used: input + output,
context_limit: 200_000,
accumulated_input_tokens: input,
accumulated_output_tokens: output,
+ accumulated_cached_input_tokens: 0,
accumulated_cost: cost,
+ accumulated_total_tokens: None,
model: None,
}
}
@@ -340,7 +415,9 @@ mod tests {
context_limit: 0,
accumulated_input_tokens: input,
accumulated_output_tokens: output,
+ accumulated_cached_input_tokens: 0,
accumulated_cost: cost,
+ accumulated_total_tokens: None,
model: None,
}
}
@@ -836,7 +913,9 @@ mod tests {
context_limit: 200_000,
accumulated_input_tokens: input,
accumulated_output_tokens: output,
+ accumulated_cached_input_tokens: 0,
accumulated_cost: cost,
+ accumulated_total_tokens: None,
model: model.map(str::to_string),
}
}
@@ -889,4 +968,168 @@ mod tests {
"TurnUsage.model must be None when payload omits the field"
);
}
+
+ // ── accumulatedTotalTokens: field-local delta, session poisoning ───────
+
+ fn payload_with_total(input: u64, output: u64, total: Option) -> UsageUpdatePayload {
+ UsageUpdatePayload {
+ used: input + output,
+ context_limit: 200_000,
+ accumulated_input_tokens: input,
+ accumulated_output_tokens: output,
+ accumulated_cached_input_tokens: 0,
+ accumulated_cost: None,
+ accumulated_total_tokens: total,
+ model: None,
+ }
+ }
+
+ #[test]
+ fn first_update_without_baseline_turn_total_is_none() {
+ // No baseline exists → turn total null, but delta_reliable/input/output
+ // follow the normal first-turn rule (delta_reliable = false).
+ let mut tracker = UsageTracker::default();
+ tracker.begin_turn("sess-t1");
+ tracker.record("sess-t1", &payload_with_total(100, 20, Some(120)));
+ let usage = tracker.take().expect("pending");
+
+ assert!(!usage.delta_reliable, "first turn: delta unreliable");
+ assert!(
+ usage.turn_total_tokens.is_none(),
+ "no baseline → turn total must be None"
+ );
+ assert_eq!(
+ usage.cumulative_total_tokens,
+ Some(120),
+ "cumulative total passes through even on first turn"
+ );
+ }
+
+ #[test]
+ fn second_turn_with_totals_produces_turn_delta() {
+ let mut tracker = UsageTracker::default();
+ // Turn 1 — establish baseline.
+ tracker.begin_turn("sess-t2");
+ tracker.record("sess-t2", &payload_with_total(100, 20, Some(120)));
+ let _ = tracker.take();
+
+ // Turn 2 — delta is computable.
+ tracker.begin_turn("sess-t2");
+ tracker.record("sess-t2", &payload_with_total(200, 50, Some(250)));
+ let usage = tracker.take().expect("pending");
+
+ assert!(usage.delta_reliable);
+ assert_eq!(usage.turn_total_tokens, Some(130)); // 250 - 120
+ assert_eq!(usage.cumulative_total_tokens, Some(250));
+ }
+
+ #[test]
+ fn cumulative_total_decrease_leaves_turn_total_null_without_affecting_reliability() {
+ // Cumulative total decreases (e.g. counter reset) → turn total null,
+ // but delta_reliable and input/output are NOT affected (field-local).
+ let mut tracker = UsageTracker::default();
+ tracker.begin_turn("sess-t3");
+ tracker.record("sess-t3", &payload_with_total(500, 100, Some(600)));
+ let _ = tracker.take();
+
+ tracker.begin_turn("sess-t3");
+ // Cumulative total decreased: 600 → 50.
+ tracker.record("sess-t3", &payload_with_total(600, 150, Some(50)));
+ let usage = tracker.take().expect("pending");
+
+ assert!(
+ usage.delta_reliable,
+ "input/output decrease would flip reliability; total decrease must not"
+ );
+ assert_eq!(usage.turn_input_tokens, Some(100));
+ assert_eq!(usage.turn_output_tokens, Some(50));
+ assert!(
+ usage.turn_total_tokens.is_none(),
+ "cumulative total decrease → turn total null (field-local)"
+ );
+ assert_eq!(
+ usage.cumulative_total_tokens,
+ Some(50),
+ "cumulative total from payload still passes through"
+ );
+ }
+
+ #[test]
+ fn cumulative_total_absent_on_current_turn_leaves_turn_total_null() {
+ // Goose-shaped payload: no accumulatedTotalTokens field at all.
+ let mut tracker = UsageTracker::default();
+ tracker.begin_turn("sess-t4");
+ tracker.record("sess-t4", &payload_with_total(100, 20, Some(120)));
+ let _ = tracker.take();
+
+ // Second turn: goose omits the total field entirely.
+ tracker.begin_turn("sess-t4");
+ tracker.record("sess-t4", &payload_with_total(200, 50, None));
+ let usage = tracker.take().expect("pending");
+
+ assert!(usage.delta_reliable, "input/output delta unaffected");
+ assert_eq!(usage.turn_input_tokens, Some(100));
+ assert_eq!(usage.turn_output_tokens, Some(30));
+ assert!(
+ usage.turn_total_tokens.is_none(),
+ "absent field → null turn total"
+ );
+ assert!(
+ usage.cumulative_total_tokens.is_none(),
+ "absent cumulative total passes through as None"
+ );
+ }
+
+ #[test]
+ fn goose_shaped_payload_without_accumulated_total_deserializes_correctly() {
+ // goose payloads lack accumulatedTotalTokens; the field must default
+ // to None without a deserialization error (ignore-if-absent contract).
+ let json = r#"{
+ "sessionUpdate": "usage_update",
+ "accumulatedInputTokens": 1000,
+ "accumulatedOutputTokens": 200,
+ "accumulatedCost": 0.01
+ }"#;
+ let variant: GooseSessionUpdateVariant =
+ serde_json::from_str(json).expect("must deserialize without accumulatedTotalTokens");
+ let payload = match variant {
+ GooseSessionUpdateVariant::UsageUpdate(p) => p,
+ _ => panic!("expected UsageUpdate"),
+ };
+ assert!(
+ payload.accumulated_total_tokens.is_none(),
+ "absent accumulatedTotalTokens must default to None"
+ );
+
+ // And it must flow through the tracker correctly.
+ let mut tracker = UsageTracker::default();
+ tracker.begin_turn("sess-goose-nototal");
+ tracker.record("sess-goose-nototal", &payload);
+ let usage = tracker.take().expect("pending");
+ assert!(
+ usage.cumulative_total_tokens.is_none(),
+ "goose-shaped payload must produce None cumulative_total_tokens"
+ );
+ }
+
+ #[test]
+ fn cumulative_total_absent_on_baseline_leaves_turn_total_null_on_second_turn() {
+ // Baseline was set without a total (e.g. first goose turn); second
+ // turn reports a total. No baseline to diff against → turn total None.
+ let mut tracker = UsageTracker::default();
+ tracker.begin_turn("sess-t5");
+ tracker.record("sess-t5", &payload_with_total(100, 20, None)); // no total
+ let _ = tracker.take();
+
+ tracker.begin_turn("sess-t5");
+ tracker.record("sess-t5", &payload_with_total(200, 50, Some(250)));
+ let usage = tracker.take().expect("pending");
+
+ assert!(usage.delta_reliable, "input/output delta unaffected");
+ assert!(
+ usage.turn_total_tokens.is_none(),
+ "absent baseline total → turn total null even when current has a total"
+ );
+ assert_eq!(usage.cumulative_total_tokens, Some(250));
+ }
}
diff --git a/crates/buzz-agent/README.md b/crates/buzz-agent/README.md
index a2504db451..5d942777d5 100644
--- a/crates/buzz-agent/README.md
+++ b/crates/buzz-agent/README.md
@@ -21,9 +21,9 @@
HTTPS
│
▼
- Anthropic Messages API
- or any OpenAI-compat
- (vLLM, llama.cpp, OpenRouter,
+ Anthropic Messages API,
+ OpenRouter, or any OpenAI-compat
+ (vLLM, llama.cpp, Databricks,
Block Gateway, Ollama, …)
```
@@ -50,6 +50,12 @@ OPENAI_COMPAT_MODEL=gpt-5 \
OPENAI_COMPAT_BASE_URL=https://api.openai.com/v1 \
./target/release/buzz-agent
+# Or OpenRouter
+BUZZ_AGENT_PROVIDER=openrouter \
+OPENROUTER_API_KEY=sk-or-v1-... \
+OPENROUTER_MODEL=anthropic/claude-sonnet-4.5 \
+ ./target/release/buzz-agent
+
# Or Databricks model serving via OAuth 2.0 PKCE
BUZZ_AGENT_PROVIDER=databricks \
DATABRICKS_HOST=https://dbc-...cloud.databricks.com \
@@ -129,15 +135,18 @@ Everything is environment variables. No flags, no config files. (We are a subpro
| Variable | Default | Notes |
|---|---|---|
-| `BUZZ_AGENT_PROVIDER` | — | Required. `anthropic`, `openai`, `databricks`, or `databricks_v2`. No implicit fallback — the agent errors at startup when this is unset. |
+| `BUZZ_AGENT_PROVIDER` | — | Required. `anthropic`, `openai`, `openrouter`, `databricks`, or `databricks_v2`. No implicit fallback — the agent errors at startup when this is unset. |
| `ANTHROPIC_API_KEY` | — | Required when provider=anthropic. |
| `ANTHROPIC_MODEL` | — | Required when provider=anthropic. |
| `ANTHROPIC_BASE_URL` | `https://api.anthropic.com` | |
| `ANTHROPIC_API_VERSION` | `2023-06-01` | |
| `OPENAI_COMPAT_API_KEY` | — | Required when provider=openai. |
| `OPENAI_COMPAT_MODEL` | — | Required when provider=openai. |
-| `OPENAI_COMPAT_BASE_URL` | `https://api.openai.com/v1` | Point at vLLM, llama.cpp, OpenRouter, Ollama, etc. |
+| `OPENAI_COMPAT_BASE_URL` | `https://api.openai.com/v1` | Point at vLLM, llama.cpp, Ollama, etc. |
| `OPENAI_COMPAT_API` | `auto` | `auto` \| `chat` \| `responses`. `auto` picks Responses for `*.openai.com`, Chat Completions everywhere else. |
+| `OPENROUTER_API_KEY` | — | Required when provider=openrouter. |
+| `OPENROUTER_MODEL` | — | Required when provider=openrouter. Use OpenRouter's `vendor/model` id, e.g. `anthropic/claude-sonnet-4.5`. |
+| `OPENROUTER_BASE_URL` | `https://openrouter.ai/api/v1` | |
| `DATABRICKS_HOST` | — | Required when provider=databricks or provider=databricks_v2. |
| `DATABRICKS_MODEL` | — | Required when provider=databricks or provider=databricks_v2. |
| `DATABRICKS_TOKEN` | — | Optional static bearer escape hatch. If unset, Databricks uses browser OAuth + refresh cache. |
@@ -154,6 +163,67 @@ Everything is environment variables. No flags, no config files. (We are a subpro
| `BUZZ_AGENT_MAX_LINE_BYTES` | `4194304` | 4 MiB. Hard cap on inbound JSON-RPC frames. |
| `BUZZ_AGENT_MAX_HISTORY_BYTES` | `1048576` | 1 MiB. Old turns are evicted past this. |
| `BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES` | `51200` | 50 KiB. Per-result cap on tool-output text; oversize is middle-elided (head + tail kept) with an inline marker. Images are exempt. |
+| `BUZZ_AGENT_REQUIRE_REPLY` | `0` (`1` on mesh) | `1` enables the [reply guard](#reply-guard) — remind the model to publish when a turn is about to end with nothing posted to Buzz. Desktop defaults it to `1` for Buzz shared-compute agents. |
+
+
+## Reply Guard
+
+Off by default, except on Buzz shared-compute (mesh) agents, where Buzz Desktop
+sets `BUZZ_AGENT_REQUIRE_REPLY=1` automatically. With it enabled, a turn that is
+about to end without any recognized attempt to post to Buzz gets a reminder that
+its assistant text is invisible to humans, and is rerolled.
+
+This exists because a Buzz agent's reasoning and tool output are not shown to
+anyone. A turn that does real work and never posts is a silent failure — the
+requester waits on a result that was produced and thrown away.
+
+Mesh agents get it by default because they run on small local models, which are
+the ones most likely to do the work and then end the turn without publishing it.
+Setting `BUZZ_AGENT_REQUIRE_REPLY=0` on the agent, persona, or global env opts a
+mesh agent back out; the default never overrides an explicit value.
+
+**Advisory, never a trap.** At most two reminders, then the turn ends whether or
+not anything was published. The guard catches accidental omission; it does not
+compel speech. The reminder text explicitly licenses silence, because the
+built-in system prompt says publishing is optional and silence is often the
+correct outcome.
+
+**Recognition contract.** A turn counts as having replied when it issues a call
+that:
+
+- resolves to a registered, non-hook tool (a hallucinated tool name is rejected
+ at preflight and never runs, so it must not disarm the guard),
+- whose qualified name ends in `__shell` — i.e. the bare tool name is exactly
+ `shell`, which is `buzz-dev-mcp`'s shell tool and any other server's, and
+- whose `command` argument contains `messages send` or `reactions add`.
+
+`messages send` also covers `messages send-diff`. Reactions count because the
+built-in prompt directs agents to react rather than post a bare
+acknowledgement, so nagging an agent that reacted would punish documented
+behavior.
+
+Detection is checked **after** the per-turn tool-call cap
+(`MAX_TOOL_CALLS_PER_TURN`) is applied: a publish-shaped call that was discarded
+never ran.
+
+**It recognizes an attempt, not a successful publish.** Only the command text is
+inspected, never the exit status. A send that fails still satisfies the guard —
+which is fine, since a failed send already returns a non-zero exit and error
+JSON to the model, louder feedback than a reminder.
+
+**Known limits**, both deliberate. A command assembled at runtime (`$CMD`) or
+buried in a wrapper script is missed, so that turn is reminded despite having
+posted. Text that merely quotes a send (`echo "buzz messages send"`) matches, so
+that turn is not reminded. Missing a real post is the expensive direction, and
+substring matching is the forgiving one there. Neither edge is pinned by a test;
+the matcher is free to improve.
+
+**Budget.** Reminders ride the existing `_Stop` gate and share
+`BUZZ_AGENT_STOP_MAX_REJECTIONS` — the outer cap on every end-turn objection.
+At the default 3 both reminders fit; at 1 only one does; at 0 the guard is off
+along with the hooks. A round carrying both a `_Stop` hook objection and a
+reminder costs one rejection and delivers both texts. This is not a new
+lifecycle hook — see [MCP_DRIVEN_HOOKS.md](../../docs/MCP_DRIVEN_HOOKS.md).
## Providers
@@ -167,17 +237,24 @@ Everything is environment variables. No flags, no config files. (We are a subpro
| vLLM | `openai` | `POST {base}/chat/completions` | any tool-calling model |
| llama.cpp | `openai` | `POST {base}/chat/completions` | any tool-calling GGUF |
| Ollama | `openai` | `POST {base}/chat/completions` | llama3.1, qwen2.5-coder |
-| OpenRouter | `openai` | `POST {base}/chat/completions` | anything they route |
| Block Gateway | `openai` | `POST {base}/chat/completions` | gpt-5, claude |
+| OpenRouter | `openrouter` | `POST {base}/chat/completions` | anything they route (extended-thinking replay, provider-agnostic tool calling) |
| Databricks | `databricks` | `POST {host}/serving-endpoints/{model}/invocations` | goose-claude-4-6-sonnet |
| Databricks AI Gateway v2 | `databricks_v2` | `POST {host}/ai-gateway/{provider}/v1/...` | databricks-gpt-5-5, databricks-claude-opus-4-7 |
-If `BUZZ_AGENT_PROVIDER=anthropic` is selected without `ANTHROPIC_API_KEY`, or `BUZZ_AGENT_PROVIDER=openai` is selected without `OPENAI_COMPAT_API_KEY`, the agent returns an error — there is no implicit fallback to another provider.
+If `BUZZ_AGENT_PROVIDER=anthropic` is selected without `ANTHROPIC_API_KEY`, `BUZZ_AGENT_PROVIDER=openai` is selected without `OPENAI_COMPAT_API_KEY`, or `BUZZ_AGENT_PROVIDER=openrouter` is selected without `OPENROUTER_API_KEY`, the agent returns an error — there is no implicit fallback to another provider.
`provider=openai` speaks two HTTP dialects: the [Responses API](https://platform.openai.com/docs/api-reference/responses) (`/v1/responses`, required for GPT-5 / o-series tool-calling on OpenAI's own service) and the [Chat Completions API](https://platform.openai.com/docs/api-reference/chat) (`/chat/completions`, the broadly-supported OpenAI-compatible wire format).
By default (`OPENAI_COMPAT_API=auto`) the agent picks **Responses** when `OPENAI_COMPAT_BASE_URL` points at an `*.openai.com` host and **Chat Completions** everywhere else. Pin the choice explicitly with `OPENAI_COMPAT_API=chat` or `OPENAI_COMPAT_API=responses` for providers that diverge from the default (e.g. a Responses-compatible self-hosted gateway).
+`provider=openrouter` is first-class, not routed through `provider=openai`: it speaks OpenAI's Chat Completions wire format but with OpenRouter-specific extensions layered on top —
+
+- `reasoning.effort` is set on the request when reasoning effort is configured. The request deliberately carries no `provider.require_parameters` filter: that filter routes only to endpoints advertising every parameter in the body, and 83 of 274 tools-capable OpenRouter models do not advertise `reasoning`, so it turns an effort setting into a hard 404 on a valid model id. A model that cannot reason answers without reasoning instead.
+- The response's `reasoning_details` array (opaque extended-thinking payload) is captured and replayed byte-for-byte on the next turn's assistant message, so multi-turn tool use keeps the model's chain-of-thought.
+- `anthropic/*` models get Anthropic-style `cache_control` breakpoints injected on the system message and the last two user messages.
+- Retryable statuses (429 and typed `provider_overloaded` 503) honor the documented `Retry-After` header (clamped to a small ceiling — see `RETRY_AFTER_CAP_SECS` in `llm.rs` — since the sleep happens outside `BUZZ_AGENT_LLM_TIMEOUT_SECS`); 502 and untyped 503 retry with jittered backoff instead. `401` is treated as an expired/invalid key and refreshed once, while `402` (no credits) and `403` (guardrail/moderation/permission) fail immediately without retry.
+
`Provider` is a Rust `enum` with one `match` in `Llm::complete`. There is no trait, no `Box`, no async-trait. Adding a provider is a `match` arm and one `body`/`parse` pair in `llm.rs`.
## MCP Servers
diff --git a/crates/buzz-agent/src/agent.rs b/crates/buzz-agent/src/agent.rs
index 730e87b2e8..8e14fee195 100644
--- a/crates/buzz-agent/src/agent.rs
+++ b/crates/buzz-agent/src/agent.rs
@@ -14,13 +14,87 @@ use crate::mcp::ResultBudget;
use crate::types::{
AgentError, ContentBlock, HistoryItem, ProviderStop, StopReason, ToolCall, ToolResult,
- ToolResultContent,
+ ToolResultContent, TurnTotalState,
};
use crate::wire::{self, WireSender};
const ERROR_REFLECTION_SUFFIX: &str =
"\n\n[Reflect] Before retrying, identify the cause and change your approach.";
+/// Maximum reply reminders emitted per prompt when `require_reply` is on.
+///
+/// After this many, the turn is allowed to end whether or not anything was
+/// published: the guard exists to catch accidental omission, not to compel
+/// speech. The shared `stop_max_rejections` budget can cut this lower — see
+/// [`Config::require_reply`](crate::config::Config::require_reply).
+const MAX_REPLY_NAGS: u32 = 2;
+
+/// Server label on the synthetic reply-guard objection.
+///
+/// Not a real MCP server. It rides the same tool-result path as `_Stop` hook
+/// output, so the model sees `{hook, server, text}` attribution naming the
+/// in-process guard rather than an MCP server that could be impersonated.
+const REPLY_GUARD_SERVER: &str = "buzz-agent";
+
+/// Reminder text emitted when a turn is about to end with nothing published.
+///
+/// Explicitly licenses silence. The base prompt tells agents that publishing is
+/// optional and "silence is usually correct"; a reminder that argued otherwise
+/// would fight that instruction and make agents chattier.
+const REPLY_GUARD_NAG: &str = "You are about to end this turn without calling `buzz messages send`. \
+Your assistant text and reasoning are never shown to anyone — if you did work, found an answer, \
+or hit a blocker that someone is waiting on, it exists only if you publish it. \
+If you already posted, or if silence is genuinely correct for this turn, ignore this and end your turn.";
+
+/// Whether `call` is a recognized attempt to publish a reply to Buzz.
+///
+/// Recognizes an *attempt*, not a successful publish: the command text is
+/// inspected, never the exit status. That is deliberate — a send that fails
+/// already returns a non-zero exit and error JSON to the model, which is louder
+/// feedback than the reminder this gates.
+///
+/// `has` + `!is_hook` are the same checks the dispatcher uses to accept a call
+/// (see `execute_calls`), so a hallucinated `fake__shell` — rejected at preflight
+/// and never executed — cannot disarm the guard. They must stay *before*
+/// [`is_reply_shaped`]: together with them, and only with them, the `__shell`
+/// suffix is exactly equivalent to "the bare tool name is `shell`".
+fn is_buzz_reply_call(call: &ToolCall, mcp: &McpRegistry) -> bool {
+ mcp.has(&call.name) && !mcp.is_hook(&call.name) && is_reply_shaped(&call.name, &call.arguments)
+}
+
+/// Whether a tool name and arguments have the shape of a Buzz publish command.
+///
+/// Split from [`is_buzz_reply_call`] only so the matcher is testable without a
+/// live [`McpRegistry`]; callers must apply the registry checks first.
+///
+/// On the name: `ends_with("__shell")` is exact rather than approximate *given*
+/// those checks. Registration rejects `__` in both server names and bare tool
+/// names, and qualified names are `{server}__{bare}`, so a trailing `__shell` can
+/// only straddle the separator if the bare name starts with `_` — which `is_hook`
+/// already excludes. Dropping the separator would not be exact: `powershell` and
+/// `noshell` both end in `shell`.
+///
+/// On the command: a deliberately coarse substring test, scoped to the structured
+/// `command` field so unrelated metadata — a `description` that quotes a send —
+/// cannot suppress the guard, and a non-string `command` is rejected rather than
+/// coerced. Known limits, both accepted: a command assembled at runtime (`$CMD`)
+/// or hidden in a wrapper script is missed, and text that merely quotes a send
+/// (`echo "buzz messages send"`) matches. Missing a real post is the expensive
+/// direction, and substring matching is the more forgiving one there.
+fn is_reply_shaped(name: &str, arguments: &serde_json::Value) -> bool {
+ name.ends_with("__shell")
+ && arguments
+ .get("command")
+ .and_then(|v| v.as_str())
+ .is_some_and(|cmd| {
+ // `messages send` also covers `messages send-diff`. `reactions
+ // add` counts because the base prompt directs agents to react
+ // rather than post a bare acknowledgement, so nagging an agent
+ // that reacted would punish documented-correct behavior.
+ cmd.contains("messages send") || cmd.contains("reactions add")
+ })
+}
+
pub struct RunCtx<'a> {
pub cfg: &'a Config,
/// Effective model for this session. Usually equals `cfg.model`; overridden
@@ -60,6 +134,22 @@ pub struct RunCtx<'a> {
/// Accumulated output tokens across all LLM rounds in this turn, for
/// NIP-AM metric publishing. Reset to `None` at turn start in `run()`.
pub turn_output_tokens: &'a mut Option,
+ /// The cache-served subset of `turn_input_tokens`, accumulated across all
+ /// LLM rounds in this turn. Reset to `None` at turn start in `run()`.
+ /// Consumers price this slice at the provider's cached rate; without it
+ /// every round of a growing conversation is billed at full price.
+ pub turn_cached_input_tokens: &'a mut Option,
+ /// Tri-state total-token accumulator for this turn.
+ ///
+ /// - `Unseen`: no usage-bearing response observed yet this turn (initial state).
+ /// - `Exact(n)`: every usage-bearing response so far reported a genuine
+ /// provider total; `n` is their sum.
+ /// - `Unknown`: at least one usage-bearing response lacked a provider total;
+ /// this turn can never produce a reliable total.
+ ///
+ /// 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,
}
impl RunCtx<'_> {
@@ -78,12 +168,22 @@ impl RunCtx<'_> {
// Reset per-turn token accumulators for this prompt.
*self.turn_input_tokens = None;
*self.turn_output_tokens = None;
+ *self.turn_cached_input_tokens = None;
+ *self.turn_total_state = TurnTotalState::Unseen;
let mut round = 0u32;
// Per-prompt `_Stop` objection count. Bounded per prompt (not per
// session) so a stubborn exchange can't permanently disable the stop
// guard for a long-lived session; `max_rounds` still caps the loop.
let mut stop_rejections = 0u32;
+ // Reply-guard state for this prompt. `prompt()` *is* the turn, so
+ // locals here are per-turn by construction — same shape as
+ // `stop_rejections` above.
+ //
+ // Named for what it proves: a *recognized attempt* to publish, not a
+ // successful publish. See `is_buzz_reply_call`.
+ let mut buzz_reply_call_seen = false;
+ let mut reply_nags = 0u32;
loop {
if self.cfg.max_rounds > 0 && round >= self.cfg.max_rounds {
return Ok(StopReason::MaxTurnRequests);
@@ -175,6 +275,31 @@ impl RunCtx<'_> {
*self.turn_output_tokens =
Some(self.turn_output_tokens.unwrap_or(0).saturating_add(out));
}
+ // Accumulate the cache-served subset of this turn's input. Tracked
+ // separately from `turn_input_tokens` rather than subtracted from
+ // it: the input total must stay inclusive for the handoff gate,
+ // which cares how much context was sent, not what it cost.
+ if let Some(cached) = response.cached_input_tokens {
+ *self.turn_cached_input_tokens = Some(
+ self.turn_cached_input_tokens
+ .unwrap_or(0)
+ .saturating_add(cached),
+ );
+ }
+ // Fold the provider-reported total into the turn tri-state, but only
+ // when this response was usage-bearing (had input or output tokens).
+ // A response with no usage at all is not evidence of a missing total
+ // and must not poison the accumulator.
+ //
+ // Shape assumption: documented OpenAI-compatible responses that carry
+ // `total_tokens` always co-report at least one of `prompt_tokens` /
+ // `completion_tokens`. A response that supplies only `total_tokens`
+ // with neither category is therefore not a supported shape and would
+ // be silently ignored here. If that shape is ever encountered, extend
+ // 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);
+ }
if !response.reasoning.is_empty() {
wire::send(
@@ -213,6 +338,7 @@ impl RunCtx<'_> {
self.history.push(HistoryItem::Assistant {
text: response.text,
tool_calls: Vec::new(),
+ reasoning_details: response.reasoning_details.clone(),
});
let stop = map_stop(response.stop);
// Only gate genuine end_turn — don't override max_tokens/refusal.
@@ -220,7 +346,7 @@ impl RunCtx<'_> {
if stop_rejections >= self.cfg.stop_max_rejections {
return Ok(stop);
}
- let objections = self
+ let mut objections = self
.mcp
.call_hooks(
"_Stop",
@@ -229,6 +355,17 @@ impl RunCtx<'_> {
&self.cfg.hook_servers,
)
.await;
+ // Reply guard shares this gate and this budget, so a round
+ // carrying both a hook objection and a reply reminder costs
+ // one rejection and delivers both texts.
+ if self.cfg.require_reply
+ && !buzz_reply_call_seen
+ && reply_nags < MAX_REPLY_NAGS
+ {
+ reply_nags += 1;
+ objections
+ .push((REPLY_GUARD_SERVER.to_string(), REPLY_GUARD_NAG.to_string()));
+ }
if !objections.is_empty() {
stop_rejections = stop_rejections.saturating_add(1);
push_hook_outputs_as_tool_results(self.history, "_Stop", &objections);
@@ -246,9 +383,15 @@ impl RunCtx<'_> {
);
calls.truncate(MAX_TOOL_CALLS_PER_TURN);
}
+ // Deliberately after truncation: a publish-shaped call that was
+ // discarded never runs, so it must not suppress the reminder.
+ if self.cfg.require_reply && !buzz_reply_call_seen {
+ buzz_reply_call_seen = calls.iter().any(|c| is_buzz_reply_call(c, self.mcp));
+ }
self.history.push(HistoryItem::Assistant {
text: response.text,
tool_calls: calls.clone(),
+ reasoning_details: response.reasoning_details,
});
if let Some(stop) = self.execute_calls(&calls).await {
@@ -679,7 +822,11 @@ pub(crate) fn push_hook_outputs_as_tool_results(
provider_id: provider_id.clone(),
name: tool_name,
arguments: serde_json::json!({}),
+ // Synthesised locally, so there is no provider wire form to
+ // preserve.
+ provider_extra: Default::default(),
}],
+ reasoning_details: None,
});
history.push(HistoryItem::ToolResult(ToolResult {
provider_id,
@@ -744,3 +891,158 @@ fn map_stop(p: ProviderStop) -> StopReason {
ProviderStop::Refusal => StopReason::Refusal,
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serde_json::json;
+
+ /// The shapes the guard must recognize as a publish attempt. Callers apply
+ /// the registry checks first; these cover the name suffix and command text.
+ #[test]
+ fn reply_shape_matches_documented_send_forms() {
+ for cmd in [
+ "buzz messages send --channel X --content Y",
+ "buzz --relay wss://r messages send --channel X --content Y",
+ "/abs/path/buzz messages send",
+ "printf 'hi' | buzz messages send --content -",
+ "buzz messages send-diff --diff -",
+ "buzz reactions add --event E --emoji +",
+ // Assembled through another shell: rev 3's tokenizer missed this.
+ r#"sh -c "buzz messages send --channel X""#,
+ ] {
+ assert!(
+ is_reply_shaped("dev__shell", &json!({ "command": cmd })),
+ "expected {cmd:?} to count as a publish attempt"
+ );
+ }
+ }
+
+ /// Commands that do real work but do not reply in the originating
+ /// conversation must still be nagged.
+ #[test]
+ fn reply_shape_rejects_non_reply_commands() {
+ for cmd in [
+ "buzz messages get --channel X",
+ "buzz channels list",
+ "buzz reactions remove --event E",
+ "buzz pr open --title T",
+ "buzz social publish --content hi",
+ "buzz notes set --name n",
+ "cargo test -p buzz-agent",
+ ] {
+ assert!(
+ !is_reply_shaped("dev__shell", &json!({ "command": cmd })),
+ "expected {cmd:?} not to count as a publish attempt"
+ );
+ }
+ }
+
+ /// The `__` separator is load-bearing: `ends_with("shell")` alone would
+ /// accept any registered tool whose name merely ends in those letters, and
+ /// `has()` proves registration, not the bare name.
+ #[test]
+ fn reply_shape_requires_the_qname_separator() {
+ let args = json!({ "command": "buzz messages send --channel X" });
+ for name in [
+ "dev__powershell",
+ "dev__noshell",
+ "shell",
+ "dev__send_message",
+ ] {
+ assert!(
+ !is_reply_shaped(name, &args),
+ "{name} must not satisfy the shell-tool check"
+ );
+ }
+ assert!(is_reply_shaped("dev__shell", &args));
+ assert!(is_reply_shaped("buzz-dev-mcp__shell", &args));
+ }
+
+ /// Only the field that carries the executable command counts. Searching
+ /// serialized arguments instead would let arbitrary metadata disarm the
+ /// guard, turning a description into an attempted send.
+ #[test]
+ fn reply_shape_reads_only_the_command_field() {
+ assert!(!is_reply_shaped(
+ "dev__shell",
+ &json!({ "description": "buzz messages send --channel X" })
+ ));
+ assert!(!is_reply_shaped(
+ "dev__shell",
+ &json!({ "workdir": "buzz messages send" })
+ ));
+ // Malformed `command` is rejected, not coerced — and must not panic.
+ assert!(!is_reply_shaped("dev__shell", &json!({ "command": 42 })));
+ assert!(!is_reply_shaped("dev__shell", &json!({ "command": null })));
+ assert!(!is_reply_shaped("dev__shell", &json!({})));
+ assert!(!is_reply_shaped("dev__shell", &json!("not an object")));
+ }
+
+ /// A9 regression: `reasoning_details` contributes real bytes to
+ /// `estimated_bytes` (see `types.rs::HistoryItem::size_with`), so a
+ /// history item carrying a large opaque reasoning array must actually
+ /// drive `truncate_history` eviction — not be silently invisible to the
+ /// sizing gate that decides what survives a turn.
+ #[test]
+ fn truncate_history_evicts_oldest_turn_with_reasoning_details() {
+ let big_reasoning = json!([{ "type": "reasoning.text", "text": "x".repeat(400) }]);
+ let mut history = vec![
+ HistoryItem::User("first question".into()),
+ HistoryItem::Assistant {
+ text: "first answer".into(),
+ tool_calls: vec![],
+ reasoning_details: Some(big_reasoning),
+ },
+ HistoryItem::User("second question".into()),
+ HistoryItem::Assistant {
+ text: "second answer".into(),
+ tool_calls: vec![],
+ reasoning_details: None,
+ },
+ ];
+
+ let total_before: usize = history.iter().map(HistoryItem::estimated_bytes).sum();
+ // Budget below the total but above the second (smaller) turn alone,
+ // so only the oldest user+assistant pair — the one carrying
+ // reasoning_details — must be dropped.
+ let max_bytes = total_before - 100;
+ assert!(
+ max_bytes > 0,
+ "test fixture must leave room to evict only one turn"
+ );
+
+ truncate_history(&mut history, max_bytes);
+
+ assert_eq!(
+ history.len(),
+ 2,
+ "the oldest user+assistant turn (with reasoning_details) must be evicted"
+ );
+ assert!(matches!(&history[0], HistoryItem::User(s) if s == "second question"));
+ assert!(
+ matches!(&history[1], HistoryItem::Assistant { text, .. } if text == "second answer")
+ );
+ let total_after: usize = history.iter().map(HistoryItem::estimated_bytes).sum();
+ assert!(total_after <= max_bytes);
+ }
+
+ #[test]
+ fn truncate_history_noop_when_under_budget() {
+ let mut history = vec![
+ HistoryItem::User("hi".into()),
+ HistoryItem::Assistant {
+ text: "hello".into(),
+ tool_calls: vec![],
+ reasoning_details: None,
+ },
+ ];
+ let original_len = history.len();
+ truncate_history(&mut history, 1_000_000);
+ assert_eq!(
+ history.len(),
+ original_len,
+ "under budget must not evict anything"
+ );
+ }
+}
diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs
index f3464fb903..afbda5379d 100644
--- a/crates/buzz-agent/src/config.rs
+++ b/crates/buzz-agent/src/config.rs
@@ -184,6 +184,7 @@ pub fn anthropic_thinking_config(
fn anthropic_model_supports_xhigh(model: &str) -> bool {
model.starts_with("claude-opus-4-7")
|| model.starts_with("claude-opus-4-8")
+ || model.starts_with("claude-opus-5")
|| model.starts_with("claude-sonnet-5")
|| model.starts_with("claude-fable-5")
|| model.starts_with("claude-mythos-5")
@@ -606,6 +607,7 @@ fn is_adaptive_thinking_model(model: &str) -> bool {
model.starts_with("claude-opus-4-6")
|| model.starts_with("claude-opus-4-7")
|| model.starts_with("claude-opus-4-8")
+ || model.starts_with("claude-opus-5")
// Sonnet 5.x (any patch/date suffix after "claude-sonnet-5").
|| model.starts_with("claude-sonnet-5")
// Sonnet 4.6 exactly (not Sonnet 4.5 or earlier — not in the adaptive table).
@@ -669,6 +671,8 @@ pub enum Provider {
/// Databricks AI Gateway v2. Routes by model family through the gateway's
/// OpenAI Responses, Anthropic Messages, or MLflow Chat Completions paths.
DatabricksV2,
+ /// OpenRouter multi-provider gateway. Routes to `{base_url}/chat/completions` with bearer auth. Wire format is OpenAI-chat-compatible.
+ OpenRouter,
}
/// Which OpenAI-family HTTP API to call. Set via `OPENAI_COMPAT_API`
@@ -716,6 +720,16 @@ pub struct Config {
/// Maximum `_Stop` rejections per prompt. Default 3. Set to 0 to
/// disable `_Stop` hooks entirely (agent always honors end_turn).
pub stop_max_rejections: u32,
+ /// Remind the model to publish when a turn is about to end without any
+ /// recognized attempt to post to Buzz. Default off; opt in per agent with
+ /// `BUZZ_AGENT_REQUIRE_REPLY=1`.
+ ///
+ /// Advisory only: at most `MAX_REPLY_NAGS` reminders (see `agent.rs`),
+ /// then the turn ends regardless. Bounded by the same
+ /// `stop_max_rejections` budget as `_Stop` hooks, which is the outer cap on
+ /// all end-turn objections — at the default 3 both reminders fit; at 1 only
+ /// one does; at 0 the guard is off with the hooks.
+ pub require_reply: bool,
/// Hook server allowlist. See [`HookServers`] for variant semantics.
/// Default (env unset/empty) is `None` — hooks are off unless the
/// operator explicitly opts in.
@@ -736,6 +750,14 @@ pub struct Config {
/// Thinking/reasoning effort level. `None` = use provider default (no
/// thinking config sent). Set via `BUZZ_AGENT_THINKING_EFFORT`.
pub thinking_effort: Option,
+ /// Emit Anthropic `cache_control` breakpoints on the stable prefix
+ /// (tools + system prompt) and the rolling conversation tail. Default on;
+ /// disable with `BUZZ_AGENT_PROMPT_CACHING=0`. Consulted on every route that
+ /// speaks the Anthropic caching dialect: first-party Anthropic, the
+ /// DatabricksV2 Claude route, and OpenRouter's `anthropic/*` models. The
+ /// Databricks gateway does not auto-cache, so without this the surfaced
+ /// `cache_read_input_tokens` is structurally always 0.
+ pub prompt_caching: bool,
}
impl Config {
@@ -746,6 +768,7 @@ impl Config {
env("BUZZ_AGENT_PROVIDER").as_deref(),
env("ANTHROPIC_API_KEY").as_deref(),
env("OPENAI_COMPAT_API_KEY").as_deref(),
+ env("OPENROUTER_API_KEY").as_deref(),
)?;
// Universal model override — takes priority over provider-specific model
@@ -788,6 +811,16 @@ impl Config {
databricks_host.ok_or_else(|| "config: DATABRICKS_HOST required".to_string())?,
OpenAiApi::Chat, // only read by OpenAI/legacy Databricks dispatch
),
+ Provider::OpenRouter => (
+ req("OPENROUTER_API_KEY")?,
+ resolve_model(
+ buzz_agent_model.as_deref(),
+ env("OPENROUTER_MODEL").as_deref(),
+ )
+ .ok_or_else(|| "config: OPENROUTER_MODEL required".to_string())?,
+ env_or("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"),
+ OpenAiApi::Chat, // OpenRouter uses Chat Completions only
+ ),
};
let system_prompt = match (env("BUZZ_AGENT_SYSTEM_PROMPT"), env("BUZZ_AGENT_SYSTEM_PROMPT_FILE")) {
(Some(_), Some(_)) => return Err(
@@ -828,9 +861,11 @@ impl Config {
max_parallel_tools: parse_env("BUZZ_AGENT_MAX_PARALLEL_TOOLS", 8usize)?,
hook_timeout: Duration::from_millis(parse_env("BUZZ_AGENT_HOOK_TIMEOUT_MS", 2500u64)?),
stop_max_rejections: parse_env("BUZZ_AGENT_STOP_MAX_REJECTIONS", 3u32)?,
+ require_reply: parse_env("BUZZ_AGENT_REQUIRE_REPLY", 0u8)? != 0,
hook_servers: parse_hook_servers_env("MCP_HOOK_SERVERS"),
hints_enabled: parse_env("BUZZ_AGENT_NO_HINTS", 0u8)? == 0,
thinking_effort: parse_thinking_effort(env("BUZZ_AGENT_THINKING_EFFORT").as_deref())?,
+ prompt_caching: parse_env("BUZZ_AGENT_PROMPT_CACHING", 1u8)? != 0,
};
cfg.validate()?;
Ok(cfg)
@@ -869,9 +904,11 @@ impl Config {
max_parallel_tools: 1,
hook_timeout: Duration::from_secs(1),
stop_max_rejections: 0,
+ require_reply: false,
hook_servers: HookServers::None,
hints_enabled: false,
thinking_effort: None,
+ prompt_caching: false,
}
}
@@ -991,6 +1028,7 @@ fn resolve_provider(
requested: Option<&str>,
anthropic_key: Option<&str>,
openai_key: Option<&str>,
+ openrouter_key: Option<&str>,
) -> Result {
match requested.map(str::trim).filter(|s| !s.is_empty()) {
Some(raw) => {
@@ -1006,6 +1044,8 @@ fn resolve_provider(
),
"databricks" => Ok(Provider::Databricks),
"databricks_v2" | "databricks-v2" => Ok(Provider::DatabricksV2),
+ "openrouter" if present_nonempty(openrouter_key) => Ok(Provider::OpenRouter),
+ "openrouter" => Err("config: OPENROUTER_API_KEY required".into()),
_ => Err(format!(
"config: BUZZ_AGENT_PROVIDER={raw} not supported"
)),
@@ -1223,11 +1263,11 @@ mod tests {
#[test]
fn resolve_provider_keeps_requested_provider_when_token_present() {
assert_eq!(
- resolve_provider(Some("anthropic"), Some("sk-ant"), None,).unwrap(),
+ resolve_provider(Some("anthropic"), Some("sk-ant"), None, None).unwrap(),
Provider::Anthropic
);
assert_eq!(
- resolve_provider(Some("openai"), None, Some("sk-openai"),).unwrap(),
+ resolve_provider(Some("openai"), None, Some("sk-openai"), None).unwrap(),
Provider::OpenAi
);
}
@@ -1235,17 +1275,17 @@ mod tests {
#[test]
fn resolve_provider_errors_when_requested_provider_key_missing() {
// No fallback — missing key returns an error regardless of Databricks availability.
- let err = resolve_provider(Some("anthropic"), None, None).unwrap_err();
+ let err = resolve_provider(Some("anthropic"), None, None, None).unwrap_err();
assert!(err.contains("ANTHROPIC_API_KEY required"), "{err}");
- let err = resolve_provider(Some("openai-compat"), None, Some(" ")).unwrap_err();
+ let err = resolve_provider(Some("openai-compat"), None, Some(" "), None).unwrap_err();
assert!(err.contains("OPENAI_COMPAT_API_KEY required"), "{err}");
}
#[test]
fn resolve_provider_errors_when_provider_env_absent() {
// No implicit inference — absent BUZZ_AGENT_PROVIDER is an error.
- let err = resolve_provider(None, None, None).unwrap_err();
+ let err = resolve_provider(None, None, None, None).unwrap_err();
assert!(err.contains("BUZZ_AGENT_PROVIDER is required"), "{err}");
}
@@ -1255,19 +1295,19 @@ mod tests {
// When BUZZ_AGENT_PROVIDER=databricks, resolve_provider succeeds regardless
// of DATABRICKS_HOST/MODEL (those are validated later in from_env()).
assert_eq!(
- resolve_provider(Some("databricks"), None, None).unwrap(),
+ resolve_provider(Some("databricks"), None, None, None).unwrap(),
Provider::Databricks
);
// Missing key for other providers still errors — no Databricks fallback.
- let err = resolve_provider(Some("openai"), None, None).unwrap_err();
+ let err = resolve_provider(Some("openai"), None, None, None).unwrap_err();
assert!(err.contains("OPENAI_COMPAT_API_KEY required"), "{err}");
- let err = resolve_provider(None, None, None).unwrap_err();
+ let err = resolve_provider(None, None, None, None).unwrap_err();
assert!(err.contains("BUZZ_AGENT_PROVIDER is required"), "{err}");
}
#[test]
fn resolve_provider_unsupported_error_preserves_user_casing() {
- let err = resolve_provider(Some("OpenAIish"), None, None).unwrap_err();
+ let err = resolve_provider(Some("OpenAIish"), None, None, None).unwrap_err();
assert!(err.contains("BUZZ_AGENT_PROVIDER=OpenAIish"));
}
@@ -2655,6 +2695,9 @@ mod tests {
if p == "databricks" {
return openai_result(&m);
}
+ if p == "openrouter" {
+ return (ALL_7.to_vec(), Some("medium"));
+ }
// openai-compat, unknown, empty → all-7, default medium.
(ALL_7.to_vec(), Some("medium"))
}
@@ -2706,4 +2749,18 @@ mod tests {
);
}
}
+
+ #[test]
+ fn resolve_provider_openrouter_with_key() {
+ assert_eq!(
+ resolve_provider(Some("openrouter"), None, None, Some("sk-or-123")).unwrap(),
+ Provider::OpenRouter
+ );
+ }
+
+ #[test]
+ fn resolve_provider_openrouter_missing_key() {
+ let err = resolve_provider(Some("openrouter"), None, None, None).unwrap_err();
+ assert!(err.contains("OPENROUTER_API_KEY"));
+ }
}
diff --git a/crates/buzz-agent/src/handoff.rs b/crates/buzz-agent/src/handoff.rs
index 9c27c6606d..3b0feefecf 100644
--- a/crates/buzz-agent/src/handoff.rs
+++ b/crates/buzz-agent/src/handoff.rs
@@ -259,7 +259,11 @@ fn push_history_snippet(out: &mut String, item: &HistoryItem) {
out.push_str(s);
out.push('\n');
}
- HistoryItem::Assistant { text, tool_calls } => {
+ HistoryItem::Assistant {
+ text,
+ tool_calls,
+ reasoning_details: _,
+ } => {
out.push_str("[assistant] ");
if !text.is_empty() {
out.push_str(text);
diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs
index e141b9860f..9a45bf4c98 100644
--- a/crates/buzz-agent/src/lib.rs
+++ b/crates/buzz-agent/src/lib.rs
@@ -100,6 +100,19 @@ struct Session {
accumulated_input_tokens: u64,
/// Session-cumulative output tokens across all turns.
accumulated_output_tokens: u64,
+ /// Session-cumulative cache-served input tokens across all turns — a subset
+ /// of `accumulated_input_tokens`, not an addition to it. Emitted alongside
+ /// it so a consumer can price the cached slice at the provider's discounted
+ /// rate instead of assuming every input token cost full price.
+ accumulated_cached_input_tokens: u64,
+ /// Session-cumulative total-token state across all turns.
+ ///
+ /// Mirrors the per-turn `TurnTotalState` tri-state: starts `Unseen`,
+ /// becomes `Exact(n)` as turns with genuine provider totals complete,
+ /// transitions permanently to `Unknown` when any turn lacks a total or
+ /// when the cumulative would otherwise decrease. Only emitted in the
+ /// `usage_update` notification when `Exact`.
+ accumulated_total_state: crate::types::TurnTotalState,
}
fn die(msg: String) -> ! {
@@ -426,6 +439,8 @@ async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSen
effective_model: None,
accumulated_input_tokens: 0,
accumulated_output_tokens: 0,
+ accumulated_cached_input_tokens: 0,
+ accumulated_total_state: crate::types::TurnTotalState::Unseen,
},
);
drop(sessions);
@@ -672,6 +687,8 @@ async fn run_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender
.unwrap_or(&app.cfg.model);
let mut turn_input_tokens: Option = None;
let mut turn_output_tokens: Option = None;
+ let mut turn_cached_input_tokens: Option = None;
+ let mut turn_total_state = crate::types::TurnTotalState::Unseen;
let mut ctx = RunCtx {
cfg: &app.cfg,
effective_model: effective_model_str,
@@ -690,6 +707,8 @@ async fn run_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender
last_request_history_bytes: &mut last_request_history_bytes,
turn_input_tokens: &mut turn_input_tokens,
turn_output_tokens: &mut turn_output_tokens,
+ turn_cached_input_tokens: &mut turn_cached_input_tokens,
+ turn_total_state: &mut turn_total_state,
};
let result = ctx.run(p.prompt).await;
if let Some(s) = app.sessions.lock().await.get_mut(&sid) {
@@ -722,31 +741,54 @@ async fn run_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender
s.accumulated_output_tokens = s
.accumulated_output_tokens
.saturating_add(turn_output_tokens.unwrap_or(0));
- Some((s.accumulated_input_tokens, s.accumulated_output_tokens))
+ s.accumulated_cached_input_tokens = s
+ .accumulated_cached_input_tokens
+ .saturating_add(turn_cached_input_tokens.unwrap_or(0));
+ // Fold the per-turn total state into the session cumulative.
+ // Unknown poisons the session permanently; Exact adds to running sum;
+ // Unseen (turn emitted no usage) leaves the cumulative unchanged.
+ // Uses TurnTotalState::merge_session, which applies the same
+ // checked-add / overflow-poisons contract as the per-response fold.
+ s.accumulated_total_state =
+ s.accumulated_total_state.merge_session(turn_total_state);
+ Some((
+ s.accumulated_input_tokens,
+ s.accumulated_output_tokens,
+ s.accumulated_cached_input_tokens,
+ s.accumulated_total_state,
+ ))
} else {
// Session is gone — the accumulated baseline no longer exists, so
// there is nothing correct to emit. Skip the usage notification.
None
}
};
- if let Some((accumulated_in, accumulated_out)) = accumulated {
- wire::send(
- &wire_tx,
- goose_session_update(
- &sid,
- 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,
- "model": effective_model_str,
- }),
- ),
- )
- .await;
+ 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);
+ }
+ wire::send(&wire_tx, goose_session_update(&sid, update)).await;
}
}
match result {
diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs
index 23cef24e72..73c7e1faf2 100644
--- a/crates/buzz-agent/src/llm.rs
+++ b/crates/buzz-agent/src/llm.rs
@@ -3,7 +3,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use reqwest::Client;
-use serde_json::{json, Value};
+use serde_json::{json, Map, Value};
use tokio::sync::Mutex;
use tokio::time::Instant;
@@ -146,6 +146,18 @@ impl Llm {
.await?;
parse_anthropic(v)
}
+ Provider::OpenRouter => {
+ let mut body =
+ openai_body(cfg, system_prompt, history, tools, effective_model, None);
+ apply_openrouter_mutations(
+ &mut body,
+ cfg.thinking_effort,
+ effective_model,
+ cfg.prompt_caching,
+ );
+ let v = self.post_openrouter(cfg, &body).await?;
+ parse_openai_with_reasoning_details(v)
+ }
Provider::OpenAi | Provider::Databricks => {
self.openai_request(
cfg,
@@ -248,6 +260,16 @@ impl Llm {
});
Ok(parse_anthropic(self.post_anthropic(cfg, &body).await?)?.text)
}
+ Provider::OpenRouter => {
+ let body = openrouter_summary_body(
+ effective_model,
+ system_prompt,
+ user_prompt,
+ max_output_tokens,
+ );
+ let v = self.post_openrouter(cfg, &body).await?;
+ Ok(parse_openai(v)?.text)
+ }
Provider::OpenAi | Provider::Databricks => {
let r = self
.openai_request(
@@ -652,6 +674,32 @@ impl Llm {
}
}
+ async fn post_openrouter(&self, cfg: &Config, body: &Value) -> Result {
+ let url = format!("{}/chat/completions", cfg.base_url.trim_end_matches('/'));
+ let mut bearer = self.auth.bearer().await?;
+ let mut refreshed = false;
+ loop {
+ match openrouter_post(&self.http, &url, body, &bearer).await {
+ Err(AgentError::LlmAuth(_)) if !refreshed => {
+ refreshed = true;
+ let new_bearer = self.auth.refresh_now(&bearer).await?;
+ // A static key refreshes to itself — a byte-identical retry
+ // would be a guaranteed duplicate request against a key the
+ // server just rejected. Fail terminal immediately; the retry
+ // is only meaningful when the source can actually mint a
+ // distinct token (e.g., a PKCE OAuth source).
+ if new_bearer == bearer {
+ return Err(AgentError::LlmAuth(
+ "401: static key rejected — update key in agent settings".into(),
+ ));
+ }
+ bearer = new_bearer;
+ }
+ result => return result,
+ }
+ }
+ }
+
/// If `err` names `/v1/responses` / "use the Responses API", latch a
/// sticky upgrade so subsequent OpenAI calls hit Responses. Logged once.
fn try_upgrade(&self, err: &AgentError) -> bool {
@@ -695,7 +743,11 @@ fn anthropic_body(
messages.push(json!({ "role": "user",
"content": [{ "type": "text", "text": text }] }));
}
- HistoryItem::Assistant { text, tool_calls } => {
+ HistoryItem::Assistant {
+ text,
+ tool_calls,
+ reasoning_details: _,
+ } => {
flush(&mut messages, &mut pending);
let mut content: Vec = Vec::new();
if !text.is_empty() {
@@ -720,6 +772,12 @@ fn anthropic_body(
}
}
flush(&mut messages, &mut pending);
+ // Rolling cache breakpoint: mark the tail of the (append-only) conversation
+ // so the next turn re-reads this whole prefix from cache instead of paying
+ // full input price for it. See `stamp_rolling_cache_breakpoint`.
+ if cfg.prompt_caching {
+ stamp_rolling_cache_breakpoint(&mut messages);
+ }
let tools_json: Vec = tools
.iter()
.map(|t| {
@@ -727,8 +785,19 @@ fn anthropic_body(
"name": t.name, "description": t.description, "input_schema": t.input_schema })
})
.collect();
+ // Static prefix breakpoint: caching the `system` block caches the whole
+ // prefix up to and including it — and the prefix order is
+ // `tools -> system -> messages`, so this single marker caches tools +
+ // system together. Requires the structured (array) form of `system`; skip
+ // it for an empty prompt since Anthropic rejects empty text blocks.
+ let system_value = if cfg.prompt_caching && !system_prompt.is_empty() {
+ json!([{ "type": "text", "text": system_prompt,
+ "cache_control": { "type": "ephemeral" } }])
+ } else {
+ json!(system_prompt)
+ };
let mut body = json!({ "model": effective_model, "max_tokens": cfg.max_output_tokens,
- "system": system_prompt, "messages": messages });
+ "system": system_value, "messages": messages });
if let Some(e) = effort {
let (thinking, output_config) =
crate::config::anthropic_thinking_config(effective_model, e, cfg.max_output_tokens);
@@ -745,6 +814,41 @@ fn anthropic_body(
body
}
+/// Attach ephemeral `cache_control` markers to the tail of the conversation so
+/// the next turn re-reads the whole prior prefix from cache (~0.1x input price)
+/// rather than re-billing it as fresh input. Anthropic caches the prefix up to
+/// and including each marked block.
+///
+/// We mark the last content block of the last *two* messages, not just the
+/// final one. Each Anthropic breakpoint walks back at most 20 content blocks to
+/// find a prior cache entry, and one agentic turn can append ~17 blocks at the
+/// default `max_parallel_tools` (1 assistant text + N `tool_use` +
+/// N `tool_result`). With only a tail marker, consecutive breakpoints sit a
+/// full turn apart, which slips past the 20-block window as soon as parallelism
+/// rises or a turn carries extra blocks — and the miss is silent. Marking the
+/// last two messages halves the gap (to ~N+1 blocks), keeping a live cache
+/// entry comfortably within reach. Uses 2 of the 4 allowed breakpoints; the
+/// static `system` marker is the third.
+///
+/// A no-op for messages whose content is empty or whose tail block is not a
+/// JSON object.
+fn stamp_rolling_cache_breakpoint(messages: &mut [Value]) {
+ let n = messages.len();
+ // The two most-recently-appended messages (the current turn's tool results
+ // and the assistant turn before them). `checked_sub` + `flatten` skips the
+ // second index when there is only one message.
+ for idx in [n.checked_sub(1), n.checked_sub(2)].into_iter().flatten() {
+ if let Some(block) = messages[idx]
+ .get_mut("content")
+ .and_then(Value::as_array_mut)
+ .and_then(|c| c.last_mut())
+ .and_then(Value::as_object_mut)
+ {
+ block.insert("cache_control".into(), json!({ "type": "ephemeral" }));
+ }
+ }
+}
+
fn anthropic_tool_result_content(content: &[ToolResultContent]) -> Vec {
content
.iter()
@@ -787,20 +891,40 @@ fn openai_body(
flush_images(&mut messages, &mut pending_images);
messages.push(json!({ "role": "user", "content": text }));
}
- HistoryItem::Assistant { text, tool_calls } => {
+ HistoryItem::Assistant {
+ text,
+ tool_calls,
+ reasoning_details,
+ } => {
flush_images(&mut messages, &mut pending_images);
let mut msg = serde_json::Map::new();
msg.insert("role".into(), json!("assistant"));
msg.insert("content".into(), json!(text.as_str()));
+ if let Some(details) = reasoning_details {
+ msg.insert("reasoning_details".into(), details.clone());
+ }
if !tool_calls.is_empty() {
let calls: Vec = tool_calls
.iter()
.map(|c| {
- json!({
- "id": c.provider_id, "type": "function",
- "function": { "name": c.name,
- "arguments": serde_json::to_string(&c.arguments)
- .unwrap_or_else(|_| "{}".into()) } })
+ let mut call = serde_json::Map::new();
+ call.insert("id".into(), json!(c.provider_id));
+ call.insert("type".into(), json!("function"));
+ // Provider-owned fields go back beside `function`,
+ // which is where the provider put them. Position is
+ // load-bearing, not cosmetic: Gemini rejects a
+ // `thoughtSignature` nested inside `function{}` with
+ // the same 400 it gives for one that is missing.
+ for (k, v) in &c.provider_extra {
+ call.insert(k.clone(), v.clone());
+ }
+ call.insert(
+ "function".into(),
+ json!({ "name": c.name,
+ "arguments": serde_json::to_string(&c.arguments)
+ .unwrap_or_else(|_| "{}".into()) }),
+ );
+ Value::Object(call)
})
.collect();
msg.insert("tool_calls".into(), Value::Array(calls));
@@ -886,7 +1010,11 @@ fn responses_body(
"role": "user",
"content": [{ "type": "input_text", "text": text }],
})),
- HistoryItem::Assistant { text, tool_calls } => {
+ HistoryItem::Assistant {
+ text,
+ tool_calls,
+ reasoning_details: _,
+ } => {
if !text.is_empty() {
input.push(json!({
"role": "assistant",
@@ -967,13 +1095,53 @@ fn is_responses_required_error(body: &str) -> bool {
|| b.contains("use the responses api")
}
+/// OpenAI-family code names that appear as their own segment in a Databricks v2
+/// endpoint name (the GPT-5 launch aliases). The `gpt` family itself is matched
+/// separately by segment prefix so `gpt`, `gpt5`, and the `gpt` of a split
+/// `gpt-5` all qualify.
+const DATABRICKS_V2_OPENAI_CODE_NAMES: &[&str] = &["sol", "luna", "terra"];
+
+/// Anthropic (Claude) family and release code names that appear as their own
+/// segment in a Databricks v2 endpoint name — the `claude` prefix, the family
+/// names (`opus`, `sonnet`, `haiku`), and the release code names (`mythos`,
+/// `fable`). Getting a Claude model onto the Anthropic Messages route is what
+/// lets it carry a `cache_control` breakpoint; an endpoint that matches none of
+/// these falls through to the MLflow (OpenAI-wire) path, where Anthropic prompt
+/// caching is structurally impossible and the discount is silently lost.
+const DATABRICKS_V2_CLAUDE_NAMES: &[&str] =
+ &["claude", "opus", "sonnet", "haiku", "mythos", "fable"];
+
+/// Split a Databricks v2 endpoint name into its lowercase alphanumeric segments,
+/// breaking on any non-alphanumeric delimiter (`-`, `_`, `.`, `/`, …). E.g.
+/// `Databricks-Claude-Opus-5` -> `["databricks", "claude", "opus", "5"]`.
+fn model_name_segments(model: &str) -> Vec {
+ model
+ .split(|c: char| !c.is_ascii_alphanumeric())
+ .filter(|s| !s.is_empty())
+ .map(str::to_ascii_lowercase)
+ .collect()
+}
+
fn databricks_v2_route_for_model(model: &str) -> DatabricksV2Route {
- // Databricks v2 catalog names currently identify OpenAI-shaped GPT-5
- // models and Anthropic-shaped Claude models by these substrings.
- let lower = model.to_ascii_lowercase();
- if lower.contains("gpt-5") || lower.contains("gpt5") {
+ // The v2 catalog exposes no family field, so the wire format is inferred
+ // from the endpoint name. Discovery deliberately keeps arbitrary custom
+ // aliases, so we match whole name *segments* rather than raw substrings: a
+ // substring test would misroute unrelated names — `consolidated-llama`
+ // (`sol`), `terraform-coder` (`terra`), `corpus-reranker`/`octopus-model`
+ // (`opus`) — onto a wire whose request shape their backend can't parse,
+ // turning a caching optimization into a hard request/parse failure. Segment
+ // matching still accepts real prefixed names like `goose-opus-5`.
+ let segments = model_name_segments(model);
+ let has_named_segment =
+ |names: &[&str]| segments.iter().any(|seg| names.contains(&seg.as_str()));
+ // `gpt` family: any segment beginning with `gpt` — covers `gpt`, `gpt5`, and
+ // the `gpt` segment of a split `gpt-5`, without matching mid-word.
+ let is_gpt_family = segments.iter().any(|seg| seg.starts_with("gpt"));
+ // OpenAI is checked before Claude so a name carrying both markers resolves
+ // to the OpenAI wire (preserving the prior `gpt-5`-first precedence).
+ if is_gpt_family || has_named_segment(DATABRICKS_V2_OPENAI_CODE_NAMES) {
DatabricksV2Route::OpenAiResponses
- } else if lower.contains("claude") {
+ } else if has_named_segment(DATABRICKS_V2_CLAUDE_NAMES) {
DatabricksV2Route::AnthropicMessages
} else {
DatabricksV2Route::MlflowChatCompletions
@@ -1028,10 +1196,15 @@ fn parse_responses(v: Value) -> Result {
let args: Value = serde_json::from_str(raw).map_err(|e| {
AgentError::Llm(format!("function_call.arguments not valid JSON: {e}"))
})?;
+ // No passthrough on this route: `responses_body` replays a
+ // function call as `{call_id, name, arguments}` and the Responses
+ // API asks for nothing else, so an empty map keeps the request
+ // byte-identical to before.
tool_calls.push(make_tool_call(
str_field(item, "call_id"),
str_field(item, "name"),
args,
+ Default::default(),
)?);
}
Some("reasoning") => {
@@ -1079,13 +1252,25 @@ fn parse_responses(v: Value) -> Result {
};
let input_tokens = sum_usage(&v, &["input_tokens"]);
let output_tokens = sum_usage(&v, &["output_tokens"]);
+ // The Responses API nests the cache split under `input_tokens_details`.
+ let cached_input_tokens = usage_first(
+ &v,
+ &["cache_read_input_tokens"],
+ &[("input_tokens_details", "cached_tokens")],
+ );
+ // Responses API reports a genuine provider total. Read it directly —
+ // never derived, so it stays None when the provider omits it.
+ let total_tokens = sum_usage(&v, &["total_tokens"]);
Ok(LlmResponse {
text,
tool_calls,
stop,
input_tokens,
+ cached_input_tokens,
output_tokens,
+ total_tokens,
reasoning,
+ reasoning_details: None,
})
}
@@ -1131,19 +1316,70 @@ fn anthropic_input_tokens(v: &Value) -> Option {
)
}
-/// Input-token total for OpenAI Chat Completions and Databricks responses.
-/// OpenAI's `prompt_tokens` is already inclusive. Databricks uses the same
-/// `prompt_tokens` wire field but ALSO reports Anthropic-style cache fields
-/// alongside it, so we sum them; the cache fields are simply absent (and
-/// contribute 0) for vanilla OpenAI.
+/// Input-token total for OpenAI Chat Completions and Databricks MLflow-route
+/// responses. `prompt_tokens` is already the inclusive input total on both, so
+/// it is read alone and never summed with the cache fields.
+///
+/// Vanilla OpenAI nests the cache split under `prompt_tokens_details` and
+/// `prompt_tokens` includes it. The Databricks MLflow route reports the split
+/// with the flat Anthropic spelling (`cache_read_input_tokens`) *alongside* an
+/// already-inclusive `prompt_tokens` — so summing double-counts. Verified on
+/// `databricks-glm-5-2` (2026-07-28): `prompt_tokens 13320`,
+/// `cache_read_input_tokens 13312`, `completion_tokens 30`, `total_tokens
+/// 13350`; since `prompt_tokens + completion_tokens == total_tokens`, the 13312
+/// cached tokens are contained in the 13320, not additional to it. Summing gave
+/// 26632 — nearly double — inflating both the context-budget gate and cost.
+///
+/// This differs from Anthropic's native route (see [`anthropic_input_tokens`]),
+/// where `input_tokens` genuinely EXCLUDES the cache fields and must be summed.
+/// The two never collide here: the router sends `claude*` models to the
+/// Anthropic route, so `parse_openai` only ever sees inclusive `prompt_tokens`.
fn openai_chat_input_tokens(v: &Value) -> Option {
- sum_usage(
+ sum_usage(v, &["prompt_tokens"])
+}
+
+/// First present value among `usage.` and `usage..` pairs.
+///
+/// Cache counts are the one usage figure providers do not agree on the shape of.
+/// Anthropic puts `cache_read_input_tokens` flat on `usage`; OpenAI nests the
+/// same quantity one level down, under `prompt_tokens_details` on
+/// `/chat/completions` and `input_tokens_details` on `/responses`. [`sum_usage`]
+/// only reads flat keys, which is why the OpenAI split was invisible for so
+/// long: `prompt_tokens` is already inclusive, so the *total* was right and
+/// nothing looked broken while the discount silently went unclaimed.
+///
+/// Returns the first candidate that resolves, not a sum — these are alternative
+/// spellings of one number, so adding them would double-count on Databricks,
+/// which reports both shapes.
+fn usage_first(v: &Value, flat: &[&str], nested: &[(&str, &str)]) -> Option {
+ let usage = v.get("usage")?;
+ for f in flat {
+ if let Some(n) = usage.get(*f).and_then(Value::as_u64) {
+ return Some(n);
+ }
+ }
+ for (outer, leaf) in nested {
+ if let Some(n) = usage
+ .get(*outer)
+ .and_then(|o| o.get(*leaf))
+ .and_then(Value::as_u64)
+ {
+ return Some(n);
+ }
+ }
+ None
+}
+
+/// Cache-read tokens for an OpenAI Chat Completions response.
+///
+/// `prompt_tokens_details.cached_tokens` is where vanilla OpenAI reports it.
+/// The flat Anthropic spelling is checked first for Databricks, which routes
+/// Anthropic models through an OpenAI-shaped envelope.
+fn openai_chat_cached_tokens(v: &Value) -> Option {
+ usage_first(
v,
- &[
- "prompt_tokens",
- "cache_read_input_tokens",
- "cache_creation_input_tokens",
- ],
+ &["cache_read_input_tokens"],
+ &[("prompt_tokens_details", "cached_tokens")],
)
}
@@ -1151,6 +1387,76 @@ fn str_field(v: &Value, key: &str) -> String {
v.get(key).and_then(Value::as_str).unwrap_or("").to_owned()
}
+/// Append `part` to `buf` on its own line, ignoring empties.
+fn push_part(buf: &mut String, part: &str) {
+ if part.is_empty() {
+ return;
+ }
+ if !buf.is_empty() {
+ buf.push('\n');
+ }
+ buf.push_str(part);
+}
+
+/// Split an OpenAI-shaped `message.content` into `(text, reasoning)`.
+///
+/// Standard OpenAI sends a string. Several models on the Databricks MLflow route
+/// — Gemini, Qwen35, gpt-oss — send an array of typed blocks instead, and
+/// `as_str()` yields nothing for an array, so their entire answer was being
+/// discarded: no error, no warning, just a turn that looked like the model had
+/// said nothing. `parse_anthropic` already walks a block array; this gives
+/// `parse_openai` the same tolerance.
+fn openai_content_parts(content: Option<&Value>) -> (String, String) {
+ let mut text = String::new();
+ let mut reasoning = String::new();
+ match content {
+ Some(Value::String(s)) => text.push_str(s),
+ Some(Value::Array(blocks)) => {
+ for b in blocks {
+ match b.get("type").and_then(Value::as_str) {
+ Some("text") => push_part(&mut text, &str_field(b, "text")),
+ Some("reasoning") => match b.get("summary").and_then(Value::as_array) {
+ // Gemini nests the prose one level down under `summary`.
+ Some(summary) => {
+ for s in summary {
+ push_part(&mut reasoning, &str_field(s, "text"));
+ }
+ }
+ None => push_part(&mut reasoning, &str_field(b, "text")),
+ },
+ // An untyped block carrying text is still the model talking;
+ // treating it as text loses nothing and keeps one more
+ // provider out of the silent-empty-answer failure mode.
+ _ => push_part(&mut text, &str_field(b, "text")),
+ }
+ }
+ }
+ _ => {}
+ }
+ (text, reasoning)
+}
+
+/// Make `provider_id` unique across one assistant turn's tool calls.
+///
+/// Gemini returns the function name as the id, so two parallel calls to the same
+/// function arrive sharing one id — and that id is what pairs a `role:"tool"`
+/// result back to its call, leaving two results indistinguishable. Rewriting is
+/// safe because both halves of that pairing are re-emitted from this same value;
+/// the provider never sees its original id again.
+fn dedupe_provider_ids(calls: &mut [ToolCall]) {
+ let mut seen: BTreeSet = BTreeSet::new();
+ for c in calls.iter_mut() {
+ if seen.contains(&c.provider_id) {
+ let mut n = 2;
+ while seen.contains(&format!("{}-{n}", c.provider_id)) {
+ n += 1;
+ }
+ c.provider_id = format!("{}-{n}", c.provider_id);
+ }
+ seen.insert(c.provider_id.clone());
+ }
+}
+
fn parse_anthropic(v: Value) -> Result {
let stop = map_stop(v.get("stop_reason").and_then(Value::as_str));
let mut tool_calls = Vec::new();
@@ -1173,10 +1479,12 @@ fn parse_anthropic(v: Value) -> Result {
reasoning.push_str(t);
}
}
+ // Anthropic's replay shape is fully modelled, so nothing to keep.
Some("tool_use") => tool_calls.push(make_tool_call(
str_field(b, "id"),
str_field(b, "name"),
b.get("input").cloned().unwrap_or(Value::Null),
+ Default::default(),
)?),
_ => {}
}
@@ -1184,17 +1492,58 @@ fn parse_anthropic(v: Value) -> Result {
}
let input_tokens = anthropic_input_tokens(&v);
let output_tokens = sum_usage(&v, &["output_tokens"]);
+ // Anthropic reports the cache split flat on `usage`. Note this is already
+ // part of `input_tokens` above, which sums it in deliberately.
+ let cached_input_tokens = usage_first(&v, &["cache_read_input_tokens"], &[]);
Ok(LlmResponse {
text,
tool_calls,
stop,
input_tokens,
+ cached_input_tokens,
output_tokens,
+ // Anthropic reports only category counts; NIP-AM forbids deriving a
+ // total from them. Always None for this provider.
+ total_tokens: None,
reasoning,
+ reasoning_details: None,
})
}
fn parse_openai(v: Value) -> Result {
+ // A5: error-inside-200 check — choice-level `finish_reason == "error"`
+ if let Some(choice) = v
+ .get("choices")
+ .and_then(Value::as_array)
+ .and_then(|a| a.first())
+ {
+ if choice.get("finish_reason").and_then(Value::as_str) == Some("error") {
+ let err = choice.get("error").cloned().unwrap_or(Value::Null);
+ // OpenRouter's `error.code` is numeric; other OpenAI-compat hosts
+ // may send a string. Accept either rather than discarding the
+ // typed code as "unknown".
+ let code = err
+ .get("code")
+ .and_then(|c| {
+ c.as_str()
+ .map(str::to_string)
+ .or_else(|| c.as_i64().map(|n| n.to_string()))
+ })
+ .unwrap_or_else(|| "unknown".into());
+ let message = err
+ .get("message")
+ .and_then(Value::as_str)
+ .unwrap_or("provider error in 200 response");
+ let error_type = err
+ .get("metadata")
+ .and_then(|m| m.get("error_type"))
+ .and_then(Value::as_str);
+ return Err(AgentError::Llm(match error_type {
+ Some(et) => format!("provider error ({code}, {et}): {message}"),
+ None => format!("provider error ({code}): {message}"),
+ }));
+ }
+ }
let choice = v
.get("choices")
.and_then(Value::as_array)
@@ -1204,17 +1553,23 @@ fn parse_openai(v: Value) -> Result {
let msg = choice
.get("message")
.ok_or_else(|| AgentError::Llm("missing message".into()))?;
- let text = str_field(msg, "content");
+ let (text, block_reasoning) = openai_content_parts(msg.get("content"));
// DeepSeek and vLLM-style OpenAI-compat hosts expose reasoning tokens on the
// message object. Prefer `reasoning_content` (DeepSeek's field name); fall
- // back to `reasoning` (some other providers). Both are absent for standard
- // OpenAI responses, which leaves this empty without any special-casing.
+ // back to `reasoning` (some other providers), and last to reasoning blocks
+ // found inside `content`. All three are absent for standard OpenAI
+ // responses, which leaves this empty without any special-casing.
let reasoning = {
let rc = str_field(msg, "reasoning_content");
- if rc.is_empty() {
+ let rc = if rc.is_empty() {
str_field(msg, "reasoning")
} else {
rc
+ };
+ if rc.is_empty() {
+ block_reasoning
+ } else {
+ rc
}
};
let mut tool_calls = Vec::new();
@@ -1226,26 +1581,64 @@ fn parse_openai(v: Value) -> Result {
let raw = f.get("arguments").and_then(Value::as_str).unwrap_or("{}");
let args: Value = serde_json::from_str(raw)
.map_err(|e| AgentError::Llm(format!("tool_call.arguments not valid JSON: {e}")))?;
+ // Everything on the wire object we do not model, kept for replay.
+ let extra = tc
+ .as_object()
+ .map(|o| {
+ o.iter()
+ .filter(|(k, _)| !matches!(k.as_str(), "id" | "type" | "function"))
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect()
+ })
+ .unwrap_or_default();
tool_calls.push(make_tool_call(
str_field(tc, "id"),
str_field(f, "name"),
args,
+ extra,
)?);
}
}
+ dedupe_provider_ids(&mut tool_calls);
let input_tokens = openai_chat_input_tokens(&v);
let output_tokens = sum_usage(&v, &["completion_tokens"]);
+ let cached_input_tokens = openai_chat_cached_tokens(&v);
+ // OpenAI Chat Completions reports a genuine provider total. Read it
+ // directly — never derived, so it stays None when the provider omits it.
+ let total_tokens = sum_usage(&v, &["total_tokens"]);
Ok(LlmResponse {
text,
tool_calls,
stop,
input_tokens,
+ cached_input_tokens,
output_tokens,
+ total_tokens,
reasoning,
+ reasoning_details: None,
})
}
-fn make_tool_call(id: String, name: String, args: Value) -> Result {
+fn parse_openai_with_reasoning_details(v: Value) -> Result {
+ let reasoning_details = v
+ .get("choices")
+ .and_then(Value::as_array)
+ .and_then(|a| a.first())
+ .and_then(|c| c.get("message"))
+ .and_then(|m| m.get("reasoning_details"))
+ .filter(|rd| rd.is_array())
+ .cloned();
+ let mut response = parse_openai(v)?;
+ response.reasoning_details = reasoning_details;
+ Ok(response)
+}
+
+fn make_tool_call(
+ id: String,
+ name: String,
+ args: Value,
+ provider_extra: Map,
+) -> Result {
if id.is_empty() || name.is_empty() {
return Err(AgentError::Llm("tool_call missing id or name".into()));
}
@@ -1262,6 +1655,7 @@ fn make_tool_call(id: String, name: String, args: Value) -> Result Result, AgentError> {
match cfg.provider {
- Provider::Anthropic | Provider::OpenAi => {
+ Provider::Anthropic | Provider::OpenAi | Provider::OpenRouter => {
Ok(Arc::new(StaticTokenSource::new(cfg.api_key.clone())))
}
Provider::Databricks | Provider::DatabricksV2 => {
@@ -1554,6 +1948,29 @@ pub(crate) fn build_token_source(cfg: &Config) -> Result, A
}
}
+/// Build the request body for `Llm::summarize` on `Provider::OpenRouter`.
+/// Extracted so tests can assert on the actual wire shape instead of a
+/// hand-rolled literal — summaries never carry `reasoning` (see
+/// `apply_openrouter_mutations`, which the summary path never calls).
+/// It spells the token limit `max_tokens` directly for the same reason: the
+/// mutation that renames it is never applied here.
+fn openrouter_summary_body(
+ effective_model: &str,
+ system_prompt: &str,
+ user_prompt: &str,
+ max_output_tokens: u32,
+) -> Value {
+ json!({
+ "model": effective_model,
+ "stream": false,
+ "max_tokens": max_output_tokens,
+ "messages": [
+ { "role": "system", "content": system_prompt },
+ { "role": "user", "content": user_prompt },
+ ],
+ })
+}
+
/// Return a clone of `body` with any top-level `"model"` field removed.
/// Used for Databricks model-serving, which encodes the model in the URL
/// path and rejects the field in the body.
@@ -1568,12 +1985,353 @@ fn strip_model(body: &Value) -> Value {
}
}
+#[derive(Debug)]
+enum OpenRouterErrorClass {
+ Retryable(Option),
+ Unknown,
+}
+
+/// Ceiling applied to the server-supplied `Retry-After` header before we
+/// sleep on it. OpenRouter can advertise waits up to an hour, but
+/// `openrouter_post`'s per-attempt sleep happens *outside*
+/// `Client::timeout` (`cfg.llm_timeout`, default 240s) — an unclamped hint
+/// could keep a single turn alive for up to two full-duration sleeps across
+/// `MAX_RETRIES`. Clamping (never rejecting) keeps us honoring the server's
+/// backoff signal while bounding worst-case turn latency to a value smaller
+/// than the connect/response timeout.
+const RETRY_AFTER_CAP_SECS: u64 = 60;
+
+fn parse_retry_after_header(headers: &reqwest::header::HeaderMap) -> Option {
+ let val = headers.get(reqwest::header::RETRY_AFTER)?.to_str().ok()?;
+ let secs: u64 = val.trim().parse().ok()?;
+ (secs > 0).then(|| std::time::Duration::from_secs(secs.min(RETRY_AFTER_CAP_SECS)))
+}
+
+fn classify_openrouter_error(
+ status: u16,
+ body: &str,
+ header_retry_after: Option,
+) -> OpenRouterErrorClass {
+ let parsed: Option = serde_json::from_str(body).ok();
+ let error_type = parsed
+ .as_ref()
+ .and_then(|v| v.get("error"))
+ .and_then(|e| e.get("metadata"))
+ .and_then(|m| m.get("error_type"))
+ .and_then(Value::as_str);
+ // OpenRouter's documented retry hint is the HTTP `Retry-After` header
+ // (see https://openrouter.ai/docs/api_reference/errors-and-debugging);
+ // no current doc specifies a body-level retry field, so we don't parse one.
+ let retry_after = header_retry_after;
+
+ match (status, error_type) {
+ (429, _) => OpenRouterErrorClass::Retryable(retry_after),
+ (502, _) => OpenRouterErrorClass::Retryable(None),
+ (503, Some("provider_overloaded")) => OpenRouterErrorClass::Retryable(retry_after),
+ _ => OpenRouterErrorClass::Unknown,
+ }
+}
+
+/// The one place the parameter-routing failure is worded. OpenRouter reports it
+/// two ways — a 404 `No endpoints found ...` (what a `require_parameters`-style
+/// or unsupported-parameter body actually returns) and an untyped 503 — and both
+/// mean the same thing to the user: the model id is fine, the request shape is
+/// not serveable by any endpoint behind it.
+fn openrouter_parameter_routing_error(error_body: &str) -> AgentError {
+ AgentError::Llm(format!(
+ "no OpenRouter endpoint supports the requested parameters — \
+ check model, effort, and tool requirements: {error_body}"
+ ))
+}
+
+async fn openrouter_post(
+ http: &Client,
+ url: &str,
+ body: &Value,
+ bearer: &str,
+) -> Result {
+ let body_bytes =
+ serde_json::to_vec(body).map_err(|e| AgentError::Llm(format!("serialize: {e}")))?;
+ let call_start = std::time::Instant::now();
+ for attempt in 0..MAX_RETRIES {
+ let resp = match http
+ .post(url)
+ .header("content-type", "application/json")
+ .header("HTTP-Referer", "https://github.com/block/buzz")
+ .header("X-OpenRouter-Title", "Buzz")
+ .bearer_auth(bearer)
+ .body(body_bytes.clone())
+ .send()
+ .await
+ {
+ Ok(r) => r,
+ Err(e) => {
+ if attempt + 1 < MAX_RETRIES && is_retryable_transport_error(&e) {
+ tracing::warn!(
+ attempt = attempt + 1,
+ max_attempts = MAX_RETRIES,
+ error = %e,
+ "llm: openrouter transport error, retrying"
+ );
+ backoff_with_jitter(attempt).await;
+ continue;
+ }
+ return Err(terminal_llm_error(
+ call_start.elapsed(),
+ attempt + 1,
+ &format!("transport: {e}"),
+ ));
+ }
+ };
+ let status = resp.status();
+ // Unlike the generic `post` path, OpenRouter's static-key auth makes
+ // 401 and 403 distinguishable: 401 is an invalid/expired key (worth
+ // one refresh-and-retry), while OpenRouter documents 403 as a
+ // guardrail/moderation/permission rejection the same key will always
+ // reproduce. Refreshing a static key returns the identical key, so
+ // classifying 403 as `LlmAuth` would just waste a duplicate request
+ // and surface Desktop's unrelated "access denied" copy.
+ if status == 401 {
+ return Err(AgentError::LlmAuth(read_error_body(resp).await));
+ }
+ if status == 403 {
+ return Err(AgentError::Llm(format!(
+ "{status}: {}",
+ read_error_body(resp).await
+ )));
+ }
+ if status == 402 {
+ return Err(AgentError::Llm(
+ "OpenRouter credits exhausted — check https://openrouter.ai/credits".into(),
+ ));
+ }
+ if status == 404 {
+ // OpenRouter overloads 404: a genuinely unknown/unavailable model id
+ // and a valid model whose parameter set no endpoint can serve both
+ // land here. Discriminate on the full parameter-routing phrase rather
+ // than a prefix — "No endpoints found for "-style bodies are
+ // about the model, and reporting a parameter problem as
+ // `LlmModelNotFound` (or vice versa) sends the user to the wrong fix.
+ let error_body = read_error_body(resp).await;
+ if error_body.contains("No endpoints found that can handle the requested parameters") {
+ return Err(openrouter_parameter_routing_error(&error_body));
+ }
+ return Err(AgentError::LlmModelNotFound(format!(
+ "{status}: {error_body}"
+ )));
+ }
+ // A6: status+error_type retry matrix
+ // 499 (Client Closed Request) is included: OpenRouter may emit it when a
+ // turn times out mid-stream. Will added 499 to the shared `post()` path in
+ // #2175 for the same reason; OpenRouter must match to avoid silently opting
+ // out of that stall-surfacing recovery.
+ if status.is_server_error() || status == 429 || status.as_u16() == 499 {
+ let header_retry_after = parse_retry_after_header(resp.headers());
+ let error_body = read_error_body(resp).await;
+ let should_retry = if attempt + 1 < MAX_RETRIES {
+ match classify_openrouter_error(status.as_u16(), &error_body, header_retry_after) {
+ OpenRouterErrorClass::Retryable(delay) => {
+ if let Some(d) = delay {
+ tokio::time::sleep(d).await;
+ } else {
+ backoff_with_jitter(attempt).await;
+ }
+ true
+ }
+ OpenRouterErrorClass::Unknown => {
+ backoff_with_jitter(attempt).await;
+ true
+ }
+ }
+ } else {
+ false
+ };
+ if should_retry {
+ continue;
+ }
+ // Terminal: classify for the user
+ return if status == 429 {
+ Err(terminal_llm_error(
+ call_start.elapsed(),
+ attempt + 1,
+ &format!("rate limited: {error_body}"),
+ ))
+ } else {
+ let parsed: Option = serde_json::from_str(&error_body).ok();
+ let has_error_type = parsed
+ .as_ref()
+ .and_then(|v| v.get("error"))
+ .and_then(|e| e.get("metadata"))
+ .and_then(|m| m.get("error_type"))
+ .and_then(Value::as_str)
+ .is_some();
+ Err(if !has_error_type && status.as_u16() == 503 {
+ openrouter_parameter_routing_error(&error_body)
+ } else {
+ terminal_llm_error(
+ call_start.elapsed(),
+ attempt + 1,
+ &format!("exhausted retries: {status}: {error_body}"),
+ )
+ })
+ };
+ }
+ if !status.is_success() {
+ return Err(AgentError::Llm(format!(
+ "{status}: {}",
+ read_error_body(resp).await
+ )));
+ }
+ if let Some(len) = resp.content_length() {
+ if len as usize > MAX_LLM_RESPONSE_BYTES {
+ return Err(AgentError::Llm(format!(
+ "response too large: {len} > {MAX_LLM_RESPONSE_BYTES}"
+ )));
+ }
+ }
+ let mut buf: Vec = Vec::new();
+ let mut stream = resp;
+ loop {
+ match stream.chunk().await {
+ Ok(Some(chunk)) => {
+ if buf.len() + chunk.len() > MAX_LLM_RESPONSE_BYTES {
+ return Err(AgentError::Llm(format!(
+ "response exceeded {MAX_LLM_RESPONSE_BYTES} bytes"
+ )));
+ }
+ buf.extend_from_slice(&chunk);
+ }
+ Ok(None) => break,
+ Err(e) => {
+ return Err(terminal_llm_error(
+ call_start.elapsed(),
+ attempt + 1,
+ &format!("body read: {e}"),
+ ))
+ }
+ }
+ }
+ return serde_json::from_slice(&buf).map_err(|e| AgentError::Llm(format!("json: {e}")));
+ }
+ Err(terminal_llm_error(
+ call_start.elapsed(),
+ MAX_RETRIES,
+ "exhausted retries",
+ ))
+}
+
+fn apply_openrouter_mutations(
+ body: &mut Value,
+ effort: Option,
+ effective_model: &str,
+ prompt_caching: bool,
+) {
+ if let Some(obj) = body.as_object_mut() {
+ // OpenRouter's Chat Completions API spells the output cap `max_tokens`;
+ // `max_completion_tokens` is OpenAI-native and only 53 of 274
+ // tools-capable OpenRouter models advertise it. Sending the OpenAI
+ // spelling risks the cap being dropped on the floor by the endpoint we
+ // route to, so translate it here rather than at the shared `openai_body`
+ // (which OpenAI and Databricks also use, where the OpenAI spelling is
+ // correct).
+ if let Some(max_tokens) = obj.remove("max_completion_tokens") {
+ obj.insert("max_tokens".into(), max_tokens);
+ }
+
+ // A2/A3: Add OpenRouter reasoning object when effort is configured.
+ // Deliberately NOT paired with `provider.require_parameters`: that filter
+ // routes only to endpoints advertising every parameter in the body, and
+ // 83 of 274 tools-capable models do not advertise `reasoning`, so it turns
+ // an opt-in effort setting into a hard 404 ("No endpoints found that can
+ // handle the requested parameters") on a model id that is perfectly
+ // valid. OpenRouter already best-effort routes on `tools`/`max_tokens`
+ // without the filter, so we accept a request served without reasoning
+ // over a request that cannot be served at all.
+ if let Some(e) = effort {
+ obj.insert(
+ "reasoning".into(),
+ json!({ "effort": e.openai_effort_str() }),
+ );
+ }
+
+ // A7: Anthropic cache_control injection for anthropic/* models. Gated on
+ // `prompt_caching` (`BUZZ_AGENT_PROMPT_CACHING`) for the same reason as
+ // the native Anthropic route: these are Anthropic-dialect breakpoints on
+ // an Anthropic model, so the documented kill switch must reach them too.
+ if prompt_caching && effective_model.starts_with("anthropic/") {
+ apply_anthropic_cache_control(obj);
+ }
+ }
+}
+
+fn apply_anthropic_cache_control(body: &mut serde_json::Map) {
+ if let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) {
+ // Cache the system message
+ if let Some(system_msg) = messages
+ .iter_mut()
+ .find(|m| m.get("role").and_then(Value::as_str) == Some("system"))
+ {
+ if let Some(content) = system_msg.get("content").and_then(Value::as_str) {
+ let content_str = content.to_string();
+ if let Some(obj) = system_msg.as_object_mut() {
+ obj.insert(
+ "content".into(),
+ json!([{
+ "type": "text",
+ "text": content_str,
+ "cache_control": { "type": "ephemeral" }
+ }]),
+ );
+ }
+ }
+ }
+
+ // Cache last 2 user messages (skip image-only ones — A7 mixed-content regression)
+ let mut user_count = 0;
+ for msg in messages.iter_mut().rev() {
+ if msg.get("role").and_then(Value::as_str) != Some("user") {
+ continue;
+ }
+ // Only cache string content (plain text user messages), not array content
+ // (image batches from tool results). This prevents corrupting image-only
+ // user messages by converting them to text cache breakpoints.
+ if let Some(content) = msg.get("content").and_then(Value::as_str) {
+ let content_str = content.to_string();
+ if let Some(obj) = msg.as_object_mut() {
+ obj.insert(
+ "content".into(),
+ json!([{
+ "type": "text",
+ "text": content_str,
+ "cache_control": { "type": "ephemeral" }
+ }]),
+ );
+ }
+ user_count += 1;
+ }
+ if user_count >= 2 {
+ break;
+ }
+ }
+ }
+ // Cache the last tool definition
+ if let Some(tools) = body.get_mut("tools").and_then(Value::as_array_mut) {
+ if let Some(last_tool) = tools.last_mut() {
+ if let Some(function) = last_tool.get_mut("function").and_then(Value::as_object_mut) {
+ function.insert("cache_control".into(), json!({ "type": "ephemeral" }));
+ }
+ }
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{Config, HookServers, OpenAiApi, Provider};
use crate::types::{HistoryItem, ToolCall, ToolResult, ToolResultContent};
+ use std::collections::VecDeque;
use std::time::Duration;
+ use tokio::sync::Mutex;
use tracing_subscriber::layer::SubscriberExt;
fn cfg(provider: Provider) -> Config {
@@ -1597,6 +2355,7 @@ mod tests {
max_parallel_tools: 1,
hook_timeout: Duration::from_secs(1),
stop_max_rejections: 0,
+ require_reply: false,
hook_servers: HookServers::None,
api_key: "key".into(),
model: "model".into(),
@@ -1606,6 +2365,7 @@ mod tests {
prefer_mesh_for_auto: false,
hints_enabled: true,
thinking_effort: None,
+ prompt_caching: true,
}
}
@@ -2208,7 +2968,9 @@ mod tests {
provider_id: "toolu_1".into(),
name: "dev__view_image".into(),
arguments: serde_json::json!({"source":"x.png"}),
+ provider_extra: Default::default(),
}],
+ reasoning_details: None,
},
HistoryItem::ToolResult(ToolResult {
provider_id: "toolu_1".into(),
@@ -2257,7 +3019,9 @@ mod tests {
provider_id: "call_abc".into(),
name: "dev__shell".into(),
arguments: serde_json::json!({"command": "ls"}),
+ provider_extra: Default::default(),
}],
+ reasoning_details: None,
},
HistoryItem::ToolResult(ToolResult {
provider_id: "call_abc".into(),
@@ -2355,7 +3119,9 @@ mod tests {
provider_id: "call_x".into(),
name: "t".into(),
arguments: serde_json::json!({}),
+ provider_extra: Default::default(),
}],
+ reasoning_details: None,
},
];
let body = responses_body(&cfg_responses(), "system", &history, &[], "model", None);
@@ -2462,26 +3228,111 @@ mod tests {
#[test]
fn databricks_v2_routes_by_model_family() {
+ use DatabricksV2Route::{AnthropicMessages, MlflowChatCompletions, OpenAiResponses};
for (model, route, path) in [
+ // OpenAI-shaped: the gpt family plus the GPT-5 code names.
(
"databricks-gpt-5-5",
- DatabricksV2Route::OpenAiResponses,
+ OpenAiResponses,
"/ai-gateway/openai/v1/responses",
),
+ ("gpt-4o", OpenAiResponses, "/ai-gateway/openai/v1/responses"),
+ // The intentional dashless `gpt5` spelling still routes to OpenAI.
+ ("gpt5", OpenAiResponses, "/ai-gateway/openai/v1/responses"),
+ (
+ "databricks-gpt-5-6-luna",
+ OpenAiResponses,
+ "/ai-gateway/openai/v1/responses",
+ ),
+ (
+ "databricks-gpt-5-6-sol",
+ OpenAiResponses,
+ "/ai-gateway/openai/v1/responses",
+ ),
+ (
+ "databricks-terra",
+ OpenAiResponses,
+ "/ai-gateway/openai/v1/responses",
+ ),
+ // Anthropic-shaped: the claude prefix, the family names, and the
+ // release code names — each must reach the cache-capable route even
+ // when the endpoint name omits the literal "claude".
(
"databricks-claude-opus-4-7",
- DatabricksV2Route::AnthropicMessages,
+ AnthropicMessages,
"/ai-gateway/anthropic/v1/messages",
),
(
- "custom-tool-model",
- DatabricksV2Route::MlflowChatCompletions,
- "/ai-gateway/mlflow/v1/chat/completions",
+ "goose-opus-5",
+ AnthropicMessages,
+ "/ai-gateway/anthropic/v1/messages",
),
- ] {
- let got = databricks_v2_route_for_model(model);
- assert_eq!(got, route, "model={model}");
- assert_eq!(databricks_v2_path(got), path, "model={model}");
+ (
+ "databricks-sonnet-5",
+ AnthropicMessages,
+ "/ai-gateway/anthropic/v1/messages",
+ ),
+ (
+ "databricks-haiku-4-5",
+ AnthropicMessages,
+ "/ai-gateway/anthropic/v1/messages",
+ ),
+ (
+ "databricks-mythos-5",
+ AnthropicMessages,
+ "/ai-gateway/anthropic/v1/messages",
+ ),
+ (
+ "databricks-fable-5",
+ AnthropicMessages,
+ "/ai-gateway/anthropic/v1/messages",
+ ),
+ // Case-insensitive.
+ (
+ "Databricks-Claude-Opus-5",
+ AnthropicMessages,
+ "/ai-gateway/anthropic/v1/messages",
+ ),
+ // Unrecognised names still fall through to the MLflow chat route.
+ (
+ "custom-tool-model",
+ MlflowChatCompletions,
+ "/ai-gateway/mlflow/v1/chat/completions",
+ ),
+ (
+ "databricks-gemini-3-pro",
+ MlflowChatCompletions,
+ "/ai-gateway/mlflow/v1/chat/completions",
+ ),
+ // Collision guard: short code names must match only as whole
+ // segments, never as substrings of an unrelated custom alias.
+ // Each of these embeds a marker (`sol`, `terra`, `opus`) mid-word
+ // and must stay on the MLflow fallback, not adopt a wire its
+ // backend can't parse.
+ (
+ "consolidated-llama",
+ MlflowChatCompletions,
+ "/ai-gateway/mlflow/v1/chat/completions",
+ ),
+ (
+ "terraform-coder",
+ MlflowChatCompletions,
+ "/ai-gateway/mlflow/v1/chat/completions",
+ ),
+ (
+ "corpus-reranker",
+ MlflowChatCompletions,
+ "/ai-gateway/mlflow/v1/chat/completions",
+ ),
+ (
+ "octopus-model",
+ MlflowChatCompletions,
+ "/ai-gateway/mlflow/v1/chat/completions",
+ ),
+ ] {
+ let got = databricks_v2_route_for_model(model);
+ assert_eq!(got, route, "model={model}");
+ assert_eq!(databricks_v2_path(got), path, "model={model}");
}
}
@@ -2548,13 +3399,16 @@ mod tests {
provider_id: "toolu_a".into(),
name: "dev__view_image".into(),
arguments: serde_json::json!({"source": "a.png"}),
+ provider_extra: Default::default(),
},
ToolCall {
provider_id: "toolu_b".into(),
name: "dev__view_image".into(),
arguments: serde_json::json!({"source": "b.png"}),
+ provider_extra: Default::default(),
},
],
+ reasoning_details: None,
},
HistoryItem::ToolResult(ToolResult {
provider_id: "toolu_a".into(),
@@ -2604,6 +3458,101 @@ mod tests {
assert_eq!(imgs[1]["image_url"]["url"], "data:image/png;base64,bbb");
}
+ // ---- prompt caching (cache_control) body-shape tests ----
+
+ #[test]
+ fn anthropic_body_stamps_cache_control_when_enabled() {
+ let body = anthropic_body(
+ &cfg(Provider::DatabricksV2),
+ "sys",
+ &[
+ HistoryItem::User("hello".into()),
+ HistoryItem::Assistant {
+ text: "hi".into(),
+ tool_calls: vec![],
+ reasoning_details: None,
+ },
+ HistoryItem::User("more".into()),
+ ],
+ &[],
+ "databricks-claude-opus-5",
+ None,
+ );
+ // Static prefix: system promoted to a structured block carrying the marker.
+ assert_eq!(body["system"][0]["type"], "text");
+ assert_eq!(body["system"][0]["text"], "sys");
+ assert_eq!(body["system"][0]["cache_control"]["type"], "ephemeral");
+ // Leapfrog: the last block of the last TWO messages is marked; earlier
+ // ones are not. Three distinct user/assistant/user turns → messages[1]
+ // and messages[2] marked, messages[0] clean.
+ let msgs = body["messages"].as_array().unwrap();
+ assert_eq!(msgs.len(), 3);
+ let tail_block = |m: &Value| m["content"].as_array().unwrap().last().unwrap().clone();
+ assert_eq!(tail_block(&msgs[2])["cache_control"]["type"], "ephemeral");
+ assert_eq!(tail_block(&msgs[1])["cache_control"]["type"], "ephemeral");
+ assert!(
+ tail_block(&msgs[0]).get("cache_control").is_none(),
+ "only the last two messages carry a breakpoint"
+ );
+ }
+
+ #[test]
+ fn anthropic_body_single_message_stamps_only_one_breakpoint() {
+ // With a single message there is no second turn to leapfrog to; the
+ // checked_sub(2) index is skipped rather than panicking.
+ let body = anthropic_body(
+ &cfg(Provider::DatabricksV2),
+ "sys",
+ &[HistoryItem::User("hello".into())],
+ &[],
+ "databricks-claude-opus-5",
+ None,
+ );
+ let msgs = body["messages"].as_array().unwrap();
+ assert_eq!(msgs.len(), 1);
+ assert_eq!(
+ msgs[0]["content"].as_array().unwrap().last().unwrap()["cache_control"]["type"],
+ "ephemeral"
+ );
+ }
+
+ #[test]
+ fn anthropic_body_no_cache_control_when_disabled() {
+ let mut c = cfg(Provider::DatabricksV2);
+ c.prompt_caching = false;
+ let body = anthropic_body(
+ &c,
+ "sys",
+ &[HistoryItem::User("hello".into())],
+ &[],
+ "databricks-claude-opus-5",
+ None,
+ );
+ // system stays a bare string; no marker anywhere.
+ assert_eq!(body["system"], "sys");
+ let last_block = &body["messages"][0]["content"][0];
+ assert!(last_block.get("cache_control").is_none());
+ }
+
+ #[test]
+ fn anthropic_body_empty_system_stays_string_even_when_caching() {
+ // An empty system prompt must not become an empty text block —
+ // Anthropic rejects those. Caching is still applied to the tail.
+ let body = anthropic_body(
+ &cfg(Provider::DatabricksV2),
+ "",
+ &[HistoryItem::User("hello".into())],
+ &[],
+ "databricks-claude-opus-5",
+ None,
+ );
+ assert_eq!(body["system"], "");
+ assert_eq!(
+ body["messages"][0]["content"][0]["cache_control"]["type"],
+ "ephemeral"
+ );
+ }
+
// ---- ThinkingEffort body-shape tests ----
#[test]
@@ -3483,6 +4432,93 @@ mod tests {
assert_eq!(parse_anthropic(v).unwrap().input_tokens, None);
}
+ /// A Gemini reply as the Databricks MLflow route actually returns it:
+ /// block-array `content`, and a `thoughtSignature` beside `function`.
+ fn gemini_choice() -> Value {
+ json!({"choices": [{"finish_reason": "tool_calls", "message": {
+ "role": "assistant",
+ "content": [
+ {"type": "reasoning", "summary": [{"type": "summary_text", "text": "weighing it"}]},
+ {"type": "text", "text": "391"}
+ ],
+ "tool_calls": [{
+ "id": "get_weather", "type": "function", "thoughtSignature": "SIG-A",
+ "function": {"name": "get_weather", "arguments": "{\"city\":\"Paris\"}"}
+ }]
+ }}]})
+ }
+
+ #[test]
+ fn parse_openai_reads_text_out_of_a_block_array() {
+ // Before this, `as_str()` on the array yielded "" and the model's answer
+ // was discarded with no error at all.
+ let r = parse_openai(gemini_choice()).unwrap();
+ assert_eq!(r.text, "391");
+ assert_eq!(r.reasoning, "weighing it");
+ }
+
+ #[test]
+ fn parse_openai_still_reads_a_plain_string_content() {
+ let v = json!({"choices": [{"finish_reason": "stop", "message": {
+ "role": "assistant", "content": "plain"}}]});
+ let r = parse_openai(v).unwrap();
+ assert_eq!(r.text, "plain");
+ assert_eq!(r.reasoning, "");
+ }
+
+ #[test]
+ fn parse_openai_keeps_unmodelled_tool_call_fields() {
+ let r = parse_openai(gemini_choice()).unwrap();
+ let extra = &r.tool_calls[0].provider_extra;
+ assert_eq!(extra.get("thoughtSignature"), Some(&json!("SIG-A")));
+ // `id`/`type`/`function` are modelled, so they must not be duplicated
+ // into the passthrough — they would be re-emitted twice.
+ assert!(!extra.contains_key("id"));
+ assert!(!extra.contains_key("type"));
+ assert!(!extra.contains_key("function"));
+ }
+
+ #[test]
+ fn openai_body_replays_the_signature_beside_function_not_inside_it() {
+ // Position is what the gateway checks: nested inside `function{}` it is
+ // rejected with the same 400 as a missing signature.
+ let r = parse_openai(gemini_choice()).unwrap();
+ let history = vec![HistoryItem::Assistant {
+ text: r.text.clone(),
+ tool_calls: r.tool_calls.clone(),
+ reasoning_details: None,
+ }];
+ let body = openai_body(
+ &cfg(Provider::DatabricksV2),
+ "sys",
+ &history,
+ &[],
+ "databricks-gemini-3-6-flash",
+ None,
+ );
+ let call = &body["messages"][1]["tool_calls"][0];
+ assert_eq!(call["thoughtSignature"], json!("SIG-A"));
+ assert!(call["function"].get("thoughtSignature").is_none());
+ assert_eq!(call["function"]["name"], json!("get_weather"));
+ }
+
+ #[test]
+ fn parse_openai_makes_duplicate_tool_call_ids_unique() {
+ // Gemini returns the function name as the id, so parallel calls to one
+ // function collide and their results become indistinguishable.
+ let v = json!({"choices": [{"finish_reason": "tool_calls", "message": {
+ "role": "assistant", "content": "",
+ "tool_calls": [
+ {"id": "get_weather", "type": "function",
+ "function": {"name": "get_weather", "arguments": "{\"city\":\"Paris\"}"}},
+ {"id": "get_weather", "type": "function",
+ "function": {"name": "get_weather", "arguments": "{\"city\":\"Rome\"}"}}
+ ]}}]});
+ let r = parse_openai(v).unwrap();
+ assert_eq!(r.tool_calls[0].provider_id, "get_weather");
+ assert_eq!(r.tool_calls[1].provider_id, "get_weather-2");
+ }
+
#[test]
fn parse_openai_uses_prompt_tokens() {
let v = serde_json::json!({
@@ -3493,20 +4529,34 @@ mod tests {
}
#[test]
- fn parse_openai_databricks_sums_cache_fields() {
- // Databricks uses the OpenAI chat wire format (prompt_tokens) but also
- // reports Anthropic-style cache fields; the inclusive total sums them.
+ fn parse_openai_databricks_prompt_tokens_already_inclusive() {
+ // Databricks' MLflow route uses the OpenAI chat wire format
+ // (prompt_tokens) but ALSO reports the flat Anthropic-style
+ // cache_read_input_tokens. prompt_tokens is already inclusive of that
+ // slice, so the total is prompt_tokens alone — summing double-counts.
+ // Values are the live databricks-glm-5-2 response (2026-07-28), where
+ // prompt_tokens + completion_tokens == total_tokens proves inclusivity.
let v = serde_json::json!({
"choices": [{"finish_reason": "stop", "message": {"content": "hi"}}],
"usage": {
- "prompt_tokens": 200,
- "completion_tokens": 4,
- "total_tokens": 204,
- "cache_read_input_tokens": 800,
- "cache_creation_input_tokens": 0
+ "prompt_tokens": 13320,
+ "completion_tokens": 30,
+ "total_tokens": 13350,
+ "cache_read_input_tokens": 13312,
+ "prompt_tokens_details": {"cached_tokens": 13312}
}
});
- assert_eq!(parse_openai(v).unwrap().input_tokens, Some(1000));
+ let r = parse_openai(v).unwrap();
+ assert_eq!(
+ r.input_tokens,
+ Some(13320),
+ "prompt_tokens is the inclusive total"
+ );
+ assert_eq!(r.cached_input_tokens, Some(13312));
+ assert!(
+ r.cached_input_tokens.unwrap() <= r.input_tokens.unwrap(),
+ "the cached slice is a subset of the input total"
+ );
}
#[test]
@@ -3517,6 +4567,194 @@ mod tests {
assert_eq!(parse_openai(v).unwrap().input_tokens, None);
}
+ // ── total_tokens parsing ───────────────────────────────────────────────
+
+ #[test]
+ fn parse_openai_chat_total_tokens_present_is_read() {
+ // Chat Completions: `usage.total_tokens` is a genuine provider total.
+ let v = serde_json::json!({
+ "choices": [{"finish_reason": "stop", "message": {"content": "ok"}}],
+ "usage": {"prompt_tokens": 100, "completion_tokens": 25, "total_tokens": 125}
+ });
+ let r = parse_openai(v).unwrap();
+ assert_eq!(r.total_tokens, Some(125));
+ assert_eq!(r.input_tokens, Some(100));
+ assert_eq!(r.output_tokens, Some(25));
+ }
+
+ #[test]
+ fn parse_openai_chat_total_tokens_absent_is_none() {
+ // Chat Completions without `total_tokens` → None, not a derived sum.
+ let v = serde_json::json!({
+ "choices": [{"finish_reason": "stop", "message": {"content": "ok"}}],
+ "usage": {"prompt_tokens": 100, "completion_tokens": 25}
+ });
+ let r = parse_openai(v).unwrap();
+ assert_eq!(r.total_tokens, None);
+ }
+
+ #[test]
+ fn parse_responses_total_tokens_present_is_read() {
+ // Responses API: `usage.total_tokens` is a genuine provider total.
+ let v = serde_json::json!({
+ "output": [{"type": "message", "content": [{"type": "output_text", "text": "hi"}]}],
+ "status": "completed",
+ "usage": {"input_tokens": 80, "output_tokens": 20, "total_tokens": 100}
+ });
+ let r = parse_responses(v).unwrap();
+ assert_eq!(r.total_tokens, Some(100));
+ assert_eq!(r.input_tokens, Some(80));
+ assert_eq!(r.output_tokens, Some(20));
+ }
+
+ #[test]
+ fn parse_responses_total_tokens_absent_is_none() {
+ // Responses API without `total_tokens` → None.
+ let v = serde_json::json!({
+ "output": [{"type": "message", "content": [{"type": "output_text", "text": "hi"}]}],
+ "status": "completed",
+ "usage": {"input_tokens": 80, "output_tokens": 20}
+ });
+ let r = parse_responses(v).unwrap();
+ assert_eq!(r.total_tokens, None);
+ }
+
+ #[test]
+ fn parse_anthropic_total_tokens_always_none() {
+ // Anthropic reports only category counts; NIP-AM forbids deriving a total.
+ // total_tokens must always be None regardless of what the response contains —
+ // including if a future Anthropic API version unexpectedly adds total_tokens.
+ let v = serde_json::json!({
+ "content": [{"type": "text", "text": "hi"}],
+ "stop_reason": "end_turn",
+ "usage": {
+ "input_tokens": 100,
+ "cache_read_input_tokens": 50,
+ "cache_creation_input_tokens": 0,
+ "output_tokens": 30,
+ // Unexpected field: parse_anthropic must ignore this and return None.
+ "total_tokens": 180
+ }
+ });
+ let r = parse_anthropic(v).unwrap();
+ assert!(
+ r.total_tokens.is_none(),
+ "Anthropic must never supply a total_tokens value"
+ );
+ // Verify other fields still parse correctly.
+ assert_eq!(r.input_tokens, Some(150)); // inclusive sum with cache
+ assert_eq!(r.output_tokens, Some(30));
+ }
+
+ #[test]
+ fn parse_openai_reads_nested_cached_tokens() {
+ // The shape vanilla OpenAI actually returns, captured from a live
+ // /chat/completions probe on gpt-5.6-luna: `prompt_tokens` is already
+ // inclusive and the cache split is nested one level down. Reading only
+ // flat keys left the discount unclaimed while the total looked correct,
+ // which is why this went unnoticed.
+ let v = serde_json::json!({
+ "choices": [{"finish_reason": "stop", "message": {"content": "OK"}}],
+ "usage": {
+ "prompt_tokens": 5229,
+ "completion_tokens": 4,
+ "total_tokens": 5233,
+ "prompt_tokens_details": {"audio_tokens": 0, "cached_tokens": 5226,
+ "cache_write_tokens": 0}
+ }
+ });
+ let r = parse_openai(v).unwrap();
+ assert_eq!(r.input_tokens, Some(5229), "total must stay inclusive");
+ assert_eq!(r.cached_input_tokens, Some(5226));
+ }
+
+ #[test]
+ fn parse_openai_cache_write_round_reports_zero_cached() {
+ // First request of a cold prefix: the provider writes the cache and
+ // serves nothing from it. `Some(0)` not `None` — the split was reported,
+ // it was simply zero, and a consumer must be able to tell the two apart.
+ let v = serde_json::json!({
+ "choices": [{"finish_reason": "stop", "message": {"content": "OK"}}],
+ "usage": {
+ "prompt_tokens": 5229,
+ "completion_tokens": 4,
+ "prompt_tokens_details": {"cached_tokens": 0, "cache_write_tokens": 5226}
+ }
+ });
+ assert_eq!(parse_openai(v).unwrap().cached_input_tokens, Some(0));
+ }
+
+ #[test]
+ fn parse_openai_no_cache_detail_is_none() {
+ let v = serde_json::json!({
+ "choices": [{"finish_reason": "stop", "message": {"content": "hi"}}],
+ "usage": {"prompt_tokens": 123, "completion_tokens": 4}
+ });
+ assert_eq!(parse_openai(v).unwrap().cached_input_tokens, None);
+ }
+
+ #[test]
+ fn parse_openai_prefers_flat_anthropic_spelling_over_nested() {
+ // Databricks reports both shapes for the same quantity. Take one, never
+ // the sum, or the cached slice double-counts. cache_read (800) is a
+ // subset of the inclusive prompt_tokens (1000), as it must be.
+ let v = serde_json::json!({
+ "choices": [{"finish_reason": "stop", "message": {"content": "hi"}}],
+ "usage": {
+ "prompt_tokens": 1000,
+ "completion_tokens": 4,
+ "cache_read_input_tokens": 800,
+ "prompt_tokens_details": {"cached_tokens": 800}
+ }
+ });
+ let r = parse_openai(v).unwrap();
+ assert_eq!(r.input_tokens, Some(1000));
+ assert_eq!(r.cached_input_tokens, Some(800));
+ assert!(r.cached_input_tokens.unwrap() <= r.input_tokens.unwrap());
+ }
+
+ #[test]
+ fn parse_anthropic_reports_cache_read_as_cached() {
+ // Anthropic's `input_tokens` EXCLUDES cached, so the inclusive total is
+ // a sum -- but the cached slice must still be a subset of that total.
+ let v = serde_json::json!({
+ "stop_reason": "end_turn",
+ "content": [{"type": "text", "text": "hi"}],
+ "usage": {
+ "input_tokens": 100,
+ "output_tokens": 7,
+ "cache_read_input_tokens": 900,
+ "cache_creation_input_tokens": 50
+ }
+ });
+ let r = parse_anthropic(v).unwrap();
+ assert_eq!(r.input_tokens, Some(1050));
+ assert_eq!(r.cached_input_tokens, Some(900));
+ assert!(r.cached_input_tokens.unwrap() <= r.input_tokens.unwrap());
+ }
+
+ #[test]
+ fn parse_responses_reads_nested_cached_tokens() {
+ // The Responses API nests the same figure under a different key than
+ // /chat/completions does.
+ let v = serde_json::json!({
+ "status": "completed",
+ "output": [{
+ "type": "message",
+ "role": "assistant",
+ "content": [{"type": "output_text", "text": "hi"}]
+ }],
+ "usage": {
+ "input_tokens": 4000,
+ "output_tokens": 9,
+ "input_tokens_details": {"cached_tokens": 3584}
+ }
+ });
+ let r = parse_responses(v).unwrap();
+ assert_eq!(r.input_tokens, Some(4000));
+ assert_eq!(r.cached_input_tokens, Some(3584));
+ }
+
#[test]
fn parse_responses_uses_input_tokens() {
let v = serde_json::json!({
@@ -3843,4 +5081,1476 @@ mod tests {
});
assert_eq!(parse_responses(v).unwrap().output_tokens, None);
}
+
+ // ---- A3: OpenRouter body-shape tests ----
+
+ fn tools_vec() -> Vec {
+ vec![ToolDef {
+ name: "dev__shell".into(),
+ description: "run a shell command".into(),
+ input_schema: serde_json::json!({
+ "type": "object",
+ "properties": {"command": {"type": "string"}},
+ }),
+ }]
+ }
+
+ #[test]
+ fn openrouter_body_tools_with_effort() {
+ let mut c = cfg(Provider::OpenRouter);
+ c.thinking_effort = Some(ThinkingEffort::High);
+ let mut body = openai_body(
+ &c,
+ "system",
+ &[HistoryItem::User("hi".into())],
+ &tools_vec(),
+ "anthropic/claude-opus-4-7",
+ None,
+ );
+ apply_openrouter_mutations(
+ &mut body,
+ c.thinking_effort,
+ "anthropic/claude-opus-4-7",
+ true,
+ );
+ assert_eq!(body["reasoning"]["effort"], "high");
+ // `openai_body` is always called with `effort=None` on the OpenRouter
+ // path (line 151): OpenRouter uses its own `reasoning` object, not the
+ // OpenAI-style `reasoning_effort` field. Verify the field is structurally
+ // absent — not merely removed by a no-op cleanup.
+ assert!(
+ body.get("reasoning_effort").is_none(),
+ "reasoning_effort must never appear on the OpenRouter body path: \
+ openai_body is called with effort=None, so the field is never emitted"
+ );
+ assert!(
+ body.get("provider").is_none(),
+ "provider.require_parameters hard-404s models that do not advertise \
+ every parameter we send"
+ );
+ assert!(!body["tools"].as_array().unwrap().is_empty());
+ assert_eq!(
+ body["max_tokens"], 1024,
+ "token limit must carry openai_body's value under OpenRouter's spelling"
+ );
+ assert!(
+ body.get("max_completion_tokens").is_none(),
+ "max_completion_tokens is the OpenAI-native spelling; OpenRouter reads max_tokens"
+ );
+ }
+
+ #[test]
+ fn openrouter_body_tools_no_effort() {
+ let c = cfg(Provider::OpenRouter);
+ let mut body = openai_body(
+ &c,
+ "system",
+ &[HistoryItem::User("hi".into())],
+ &tools_vec(),
+ "anthropic/claude-opus-4-7",
+ None,
+ );
+ apply_openrouter_mutations(&mut body, None, "anthropic/claude-opus-4-7", true);
+ assert!(
+ body.get("reasoning").is_none(),
+ "reasoning must be absent when effort is None"
+ );
+ assert!(
+ body.get("reasoning_effort").is_none(),
+ "reasoning_effort must never appear on the OpenRouter body path"
+ );
+ assert!(
+ body.get("provider").is_none(),
+ "tools alone must not add a provider routing filter"
+ );
+ }
+
+ #[test]
+ fn openrouter_body_empty_tools_with_effort() {
+ let mut c = cfg(Provider::OpenRouter);
+ c.thinking_effort = Some(ThinkingEffort::Medium);
+ let mut body = openai_body(
+ &c,
+ "system",
+ &[HistoryItem::User("hi".into())],
+ &[],
+ "anthropic/claude-opus-4-7",
+ None,
+ );
+ apply_openrouter_mutations(
+ &mut body,
+ c.thinking_effort,
+ "anthropic/claude-opus-4-7",
+ true,
+ );
+ assert_eq!(body["reasoning"]["effort"], "medium");
+ assert!(
+ body.get("provider").is_none(),
+ "83 of 274 tools-capable models do not advertise `reasoning`; filtering on \
+ it would 404 them instead of answering without reasoning"
+ );
+ }
+
+ #[test]
+ fn openrouter_body_empty_tools_no_effort() {
+ let c = cfg(Provider::OpenRouter);
+ let mut body = openai_body(
+ &c,
+ "system",
+ &[HistoryItem::User("hi".into())],
+ &[],
+ "anthropic/claude-opus-4-7",
+ None,
+ );
+ apply_openrouter_mutations(&mut body, None, "anthropic/claude-opus-4-7", true);
+ assert!(body.get("reasoning").is_none());
+ assert!(
+ body.get("provider").is_none(),
+ "no body shape adds a provider routing filter"
+ );
+ }
+
+ /// `BUZZ_AGENT_PROMPT_CACHING=0` must reach the OpenRouter `anthropic/*`
+ /// route too, not just the native Anthropic Messages routes. The switch and
+ /// this route landed in separate changes, so nothing but this test stops the
+ /// gate from being dropped and the kill switch silently becoming a no-op on
+ /// a route that emits Anthropic-dialect breakpoints.
+ #[test]
+ fn openrouter_body_caching_disabled_emits_no_cache_control() {
+ let c = cfg(Provider::OpenRouter);
+ let mut body = openai_body(
+ &c,
+ "system",
+ &[HistoryItem::User("hi".into())],
+ &tools_vec(),
+ "anthropic/claude-opus-4-7",
+ None,
+ );
+ apply_openrouter_mutations(&mut body, None, "anthropic/claude-opus-4-7", false);
+ assert!(
+ !body.to_string().contains("cache_control"),
+ "BUZZ_AGENT_PROMPT_CACHING=0 must suppress every breakpoint: {body}"
+ );
+ // The switch is scoped to caching — the routing-contract mutations that
+ // make the request serveable at all must still be applied.
+ assert_eq!(
+ body.get("max_tokens").and_then(Value::as_u64),
+ Some(u64::from(c.max_output_tokens)),
+ "the max_tokens rename is not part of the caching gate"
+ );
+ }
+
+ /// The paired positive case: with caching on, the same body does carry
+ /// breakpoints. Without this twin, a mutation that hard-disabled caching
+ /// outright would still leave the test above green.
+ #[test]
+ fn openrouter_body_caching_enabled_emits_cache_control() {
+ let c = cfg(Provider::OpenRouter);
+ let mut body = openai_body(
+ &c,
+ "system",
+ &[HistoryItem::User("hi".into())],
+ &tools_vec(),
+ "anthropic/claude-opus-4-7",
+ None,
+ );
+ apply_openrouter_mutations(&mut body, None, "anthropic/claude-opus-4-7", true);
+ assert!(
+ body.to_string().contains("cache_control"),
+ "caching on must still emit breakpoints: {body}"
+ );
+ }
+
+ /// The rename moves an existing value; it never invents a token limit for a
+ /// body that did not carry one.
+ #[test]
+ fn openrouter_body_without_token_limit_gains_none() {
+ let mut body = json!({ "model": "vendor/model", "messages": [] });
+ apply_openrouter_mutations(&mut body, None, "vendor/model", true);
+ assert!(body.get("max_tokens").is_none());
+ assert!(body.get("max_completion_tokens").is_none());
+ }
+
+ #[test]
+ fn openrouter_summary_carries_neither_reasoning_nor_provider() {
+ let body = openrouter_summary_body(
+ "anthropic/claude-opus-4-7",
+ "summarize",
+ "text to summarize",
+ 1024,
+ );
+ assert_eq!(body["model"], "anthropic/claude-opus-4-7");
+ assert_eq!(body["messages"][0]["role"], "system");
+ assert_eq!(body["messages"][1]["content"], "text to summarize");
+ assert_eq!(body["max_tokens"], 1024);
+ assert!(
+ body.get("max_completion_tokens").is_none(),
+ "summary body must use OpenRouter's token-limit spelling"
+ );
+ assert!(
+ body.get("reasoning").is_none(),
+ "summary body must not carry reasoning"
+ );
+ assert!(
+ body.get("provider").is_none(),
+ "summary body must not carry provider"
+ );
+ }
+
+ /// The token-limit rename belongs to `apply_openrouter_mutations`, not to
+ /// `openai_body` — which OpenAI and Databricks also use, and where
+ /// `max_completion_tokens` is the correct spelling.
+ #[test]
+ fn openai_body_keeps_max_completion_tokens_when_unmutated() {
+ let body = openai_body(
+ &cfg(Provider::OpenAi),
+ "system",
+ &[HistoryItem::User("hi".into())],
+ &[],
+ "model",
+ None,
+ );
+ assert_eq!(body["max_completion_tokens"], 1024);
+ assert!(body.get("max_tokens").is_none());
+ }
+
+ // ---- A5: error-inside-200 ----
+
+ #[test]
+ fn parse_openai_error_inside_200_returns_error() {
+ let v = serde_json::json!({
+ "choices": [{
+ "finish_reason": "error",
+ "error": {
+ "code": 503,
+ "message": "No endpoints found that support tool use"
+ }
+ }]
+ });
+ let err = parse_openai(v).unwrap_err();
+ match &err {
+ AgentError::Llm(s) => {
+ assert!(s.contains("provider error (503)"), "got: {s}");
+ assert!(s.contains("No endpoints found"), "got: {s}");
+ }
+ _ => panic!("expected AgentError::Llm, got: {err:?}"),
+ }
+ }
+
+ #[test]
+ fn parse_openai_error_inside_200_accepts_string_code() {
+ let v = serde_json::json!({
+ "choices": [{
+ "finish_reason": "error",
+ "error": {
+ "code": "insufficient_quota",
+ "message": "quota exceeded"
+ }
+ }]
+ });
+ let err = parse_openai(v).unwrap_err();
+ match &err {
+ AgentError::Llm(s) => assert!(
+ s.contains("provider error (insufficient_quota)"),
+ "got: {s}"
+ ),
+ _ => panic!("expected AgentError::Llm, got: {err:?}"),
+ }
+ }
+
+ #[test]
+ fn parse_openai_error_inside_200_surfaces_error_type() {
+ let v = serde_json::json!({
+ "choices": [{
+ "finish_reason": "error",
+ "error": {
+ "code": 429,
+ "message": "Rate limit exceeded",
+ "metadata": { "error_type": "rate_limit_exceeded" }
+ }
+ }]
+ });
+ let err = parse_openai(v).unwrap_err();
+ match &err {
+ AgentError::Llm(s) => {
+ assert!(s.contains("429"), "got: {s}");
+ assert!(s.contains("rate_limit_exceeded"), "got: {s}");
+ }
+ _ => panic!("expected AgentError::Llm, got: {err:?}"),
+ }
+ }
+
+ #[test]
+ fn parse_openai_normal_stop_not_affected_by_error_check() {
+ let v = serde_json::json!({
+ "choices": [{"finish_reason": "stop", "message": {"content": "hello"}}],
+ "usage": {"prompt_tokens": 10, "completion_tokens": 5}
+ });
+ let r = parse_openai(v).unwrap();
+ assert_eq!(r.text, "hello");
+ assert_eq!(r.stop, ProviderStop::EndTurn);
+ }
+
+ #[test]
+ fn parse_openai_tool_calls_not_affected_by_error_check() {
+ let v = serde_json::json!({
+ "choices": [{
+ "finish_reason": "tool_calls",
+ "message": {
+ "content": "",
+ "tool_calls": [{
+ "id": "call_1",
+ "type": "function",
+ "function": {"name": "test", "arguments": "{}"}
+ }]
+ }
+ }]
+ });
+ let r = parse_openai(v).unwrap();
+ assert_eq!(r.stop, ProviderStop::ToolUse);
+ assert_eq!(r.tool_calls.len(), 1);
+ }
+
+ // ---- A6: OpenRouter retry classification ----
+
+ #[test]
+ fn classify_429_rate_limit_body_retry_after_is_ignored() {
+ // M1: no documented body-level retry field, so a `retry_after` key
+ // inside `error.metadata` is inert — only the HTTP header (passed
+ // separately) can produce a delay.
+ let body =
+ r#"{"error":{"metadata":{"error_type":"rate_limit_exceeded","retry_after":2.5}}}"#;
+ match classify_openrouter_error(429, body, None) {
+ OpenRouterErrorClass::Retryable(None) => {}
+ other => panic!("expected Retryable(None), got: {other:?}"),
+ }
+ }
+
+ #[test]
+ fn classify_429_rate_limit_without_retry_after() {
+ let body = r#"{"error":{"metadata":{"error_type":"rate_limit_exceeded"}}}"#;
+ match classify_openrouter_error(429, body, None) {
+ OpenRouterErrorClass::Retryable(None) => {}
+ other => panic!("expected Retryable(None), got: {other:?}"),
+ }
+ }
+
+ #[test]
+ fn classify_429_prefers_http_header_over_body() {
+ // Body-level retry hints are no longer parsed (M1: undocumented field);
+ // the HTTP header is the only source, and it's honored when present.
+ let body = r#"{"error":{"metadata":{"error_type":"rate_limit_exceeded"}}}"#;
+ let header = Some(Duration::from_secs(3));
+ match classify_openrouter_error(429, body, header) {
+ OpenRouterErrorClass::Retryable(Some(d)) => {
+ assert_eq!(d, Duration::from_secs(3), "HTTP header must be honored");
+ }
+ other => panic!("expected Retryable with header delay, got: {other:?}"),
+ }
+ }
+
+ #[test]
+ fn classify_502_provider_unavailable() {
+ let body = r#"{"error":{"metadata":{"error_type":"provider_unavailable"}}}"#;
+ match classify_openrouter_error(502, body, None) {
+ OpenRouterErrorClass::Retryable(None) => {}
+ other => panic!("expected Retryable(None) for 502, got: {other:?}"),
+ }
+ }
+
+ #[test]
+ fn classify_503_provider_overloaded_with_retry_after() {
+ // No documented body-level retry field (M1); the header is the only
+ // source `classify_openrouter_error` consults.
+ let body = r#"{"error":{"metadata":{"error_type":"provider_overloaded"}}}"#;
+ let header = Some(Duration::from_secs(5));
+ match classify_openrouter_error(503, body, header) {
+ OpenRouterErrorClass::Retryable(Some(d)) => {
+ assert_eq!(d, Duration::from_secs(5));
+ }
+ other => panic!("expected Retryable with delay, got: {other:?}"),
+ }
+ }
+
+ #[test]
+ fn classify_503_untyped_is_unknown() {
+ let body = r#"{"error":{"message":"No endpoints found"}}"#;
+ match classify_openrouter_error(503, body, None) {
+ OpenRouterErrorClass::Unknown => {}
+ other => panic!("expected Unknown for untyped 503, got: {other:?}"),
+ }
+ }
+
+ #[test]
+ fn classify_500_untyped_is_unknown() {
+ match classify_openrouter_error(500, r#"{"error":{"message":"internal"}}"#, None) {
+ OpenRouterErrorClass::Unknown => {}
+ other => panic!("expected Unknown for 500, got: {other:?}"),
+ }
+ }
+
+ #[test]
+ fn parse_retry_after_header_valid() {
+ let mut headers = reqwest::header::HeaderMap::new();
+ headers.insert(reqwest::header::RETRY_AFTER, "5".parse().unwrap());
+ assert_eq!(
+ parse_retry_after_header(&headers),
+ Some(Duration::from_secs(5))
+ );
+ }
+
+ #[test]
+ fn parse_retry_after_header_zero_rejected() {
+ let mut headers = reqwest::header::HeaderMap::new();
+ headers.insert(reqwest::header::RETRY_AFTER, "0".parse().unwrap());
+ assert_eq!(parse_retry_after_header(&headers), None);
+ }
+
+ #[test]
+ fn parse_retry_after_header_over_cap_clamped() {
+ let mut headers = reqwest::header::HeaderMap::new();
+ headers.insert(reqwest::header::RETRY_AFTER, "3601".parse().unwrap());
+ assert_eq!(
+ parse_retry_after_header(&headers),
+ Some(Duration::from_secs(RETRY_AFTER_CAP_SECS)),
+ "over-cap hints clamp to the ceiling rather than being dropped"
+ );
+ }
+
+ #[test]
+ fn parse_retry_after_header_at_cap_unclamped() {
+ let mut headers = reqwest::header::HeaderMap::new();
+ headers.insert(
+ reqwest::header::RETRY_AFTER,
+ RETRY_AFTER_CAP_SECS.to_string().parse().unwrap(),
+ );
+ assert_eq!(
+ parse_retry_after_header(&headers),
+ Some(Duration::from_secs(RETRY_AFTER_CAP_SECS))
+ );
+ }
+
+ #[test]
+ fn parse_retry_after_header_missing() {
+ let headers = reqwest::header::HeaderMap::new();
+ assert_eq!(parse_retry_after_header(&headers), None);
+ }
+
+ #[test]
+ fn parse_retry_after_header_non_numeric_ignored() {
+ let mut headers = reqwest::header::HeaderMap::new();
+ headers.insert(
+ reqwest::header::RETRY_AFTER,
+ "Wed, 21 Oct 2026 07:28:00 GMT".parse().unwrap(),
+ );
+ assert_eq!(parse_retry_after_header(&headers), None);
+ }
+
+ // ---- A7: Anthropic cache_control with mixed content ----
+
+ #[test]
+ fn anthropic_cache_control_mixed_text_tool_image_history() {
+ let history = vec![
+ HistoryItem::User("first question".into()),
+ HistoryItem::Assistant {
+ text: String::new(),
+ tool_calls: vec![ToolCall {
+ provider_id: "toolu_1".into(),
+ name: "dev__view_image".into(),
+ arguments: serde_json::json!({"source": "x.png"}),
+ provider_extra: Default::default(),
+ }],
+ reasoning_details: None,
+ },
+ HistoryItem::ToolResult(ToolResult {
+ provider_id: "toolu_1".into(),
+ content: vec![
+ ToolResultContent::Text("10×10 image".into()),
+ ToolResultContent::Image {
+ data: "aW1n".into(),
+ mime_type: "image/png".into(),
+ },
+ ],
+ is_error: false,
+ }),
+ HistoryItem::User("second question about the image".into()),
+ HistoryItem::User("third question".into()),
+ ];
+ let mut body = openai_body(
+ &cfg(Provider::OpenRouter),
+ "system",
+ &history,
+ &tools_vec(),
+ "anthropic/claude-opus-4-7",
+ None,
+ );
+ apply_openrouter_mutations(&mut body, None, "anthropic/claude-opus-4-7", true);
+ let messages = body["messages"].as_array().unwrap();
+
+ // System message should have cache_control
+ let system = &messages[0];
+ assert_eq!(system["content"][0]["cache_control"]["type"], "ephemeral");
+
+ // Image-batch user messages (containing image_url blocks) must NOT have cache_control
+ let image_user_msgs: Vec<_> = messages
+ .iter()
+ .filter(|m| {
+ m.get("role").and_then(Value::as_str) == Some("user")
+ && m.get("content")
+ .and_then(Value::as_array)
+ .map(|a| {
+ a.iter()
+ .any(|b| b.get("type").and_then(Value::as_str) == Some("image_url"))
+ })
+ .unwrap_or(false)
+ })
+ .collect();
+ assert!(
+ !image_user_msgs.is_empty(),
+ "should have image user messages"
+ );
+ for img_msg in &image_user_msgs {
+ let content = img_msg["content"].as_array().unwrap();
+ for block in content {
+ assert!(
+ block.get("cache_control").is_none(),
+ "image-only user message must not receive cache_control"
+ );
+ }
+ }
+
+ // Exactly 2 text user messages should have cache_control (skipping image-only ones)
+ let cached_text_count = messages
+ .iter()
+ .filter(|m| {
+ m.get("role").and_then(Value::as_str) == Some("user")
+ && m.get("content")
+ .and_then(Value::as_array)
+ .map(|a| {
+ a.iter().any(|b| {
+ b.get("type").and_then(Value::as_str) == Some("text")
+ && b.get("cache_control").is_some()
+ })
+ })
+ .unwrap_or(false)
+ })
+ .count();
+ assert_eq!(
+ cached_text_count, 2,
+ "exactly 2 text user messages should have cache_control"
+ );
+
+ // Last tool def should have cache_control
+ let tools = body["tools"].as_array().unwrap();
+ let last_tool = tools.last().unwrap();
+ assert_eq!(last_tool["function"]["cache_control"]["type"], "ephemeral");
+ }
+
+ #[test]
+ fn anthropic_cache_control_image_only_user_does_not_consume_slot() {
+ // An image-only user message between two text user messages must not
+ // consume a cache breakpoint slot — both text messages should get cached.
+ let history = vec![
+ HistoryItem::User("text message one".into()),
+ HistoryItem::Assistant {
+ text: String::new(),
+ tool_calls: vec![ToolCall {
+ provider_id: "toolu_1".into(),
+ name: "dev__view_image".into(),
+ arguments: serde_json::json!({"source": "x.png"}),
+ provider_extra: Default::default(),
+ }],
+ reasoning_details: None,
+ },
+ HistoryItem::ToolResult(ToolResult {
+ provider_id: "toolu_1".into(),
+ content: vec![ToolResultContent::Image {
+ data: "aW1n".into(),
+ mime_type: "image/png".into(),
+ }],
+ is_error: false,
+ }),
+ HistoryItem::User("text message two".into()),
+ HistoryItem::User("text message three".into()),
+ ];
+ let mut body = openai_body(
+ &cfg(Provider::OpenRouter),
+ "system",
+ &history,
+ &[],
+ "anthropic/claude-opus-4-7",
+ None,
+ );
+ apply_openrouter_mutations(&mut body, None, "anthropic/claude-opus-4-7", true);
+ let messages = body["messages"].as_array().unwrap();
+
+ // Count text user messages that got cache_control
+ let cached_text_count = messages
+ .iter()
+ .filter(|m| {
+ m.get("role").and_then(Value::as_str) == Some("user")
+ && m.get("content")
+ .and_then(Value::as_array)
+ .map(|a| a.iter().any(|b| b.get("cache_control").is_some()))
+ .unwrap_or(false)
+ })
+ .count();
+ assert_eq!(
+ cached_text_count, 2,
+ "image-only user messages must not consume a cache breakpoint slot"
+ );
+ }
+
+ // ---- A9: reasoning_details round-trip ----
+
+ #[test]
+ fn parse_openai_with_reasoning_details_captures_array() {
+ let details = serde_json::json!([
+ {"type": "thinking", "content": "Let me consider..."},
+ {"type": "thinking", "content": "The answer is 42."}
+ ]);
+ let v = serde_json::json!({
+ "choices": [{
+ "finish_reason": "tool_calls",
+ "message": {
+ "content": "",
+ "reasoning_details": details,
+ "tool_calls": [{
+ "id": "call_1",
+ "type": "function",
+ "function": {"name": "test", "arguments": "{}"}
+ }]
+ }
+ }],
+ "usage": {"prompt_tokens": 10, "completion_tokens": 5}
+ });
+ let r = parse_openai_with_reasoning_details(v).unwrap();
+ assert_eq!(r.reasoning_details, Some(details));
+ }
+
+ #[test]
+ fn parse_openai_with_reasoning_details_none_when_absent() {
+ let v = serde_json::json!({
+ "choices": [{
+ "finish_reason": "stop",
+ "message": {"content": "hello"}
+ }]
+ });
+ let r = parse_openai_with_reasoning_details(v).unwrap();
+ assert!(
+ r.reasoning_details.is_none(),
+ "reasoning_details must be None when not in response"
+ );
+ }
+
+ /// M2: a malformed `reasoning_details` shape (null or a bare object,
+ /// rather than the documented array) must be omitted at the parse
+ /// boundary, not stored and later replayed into the next request.
+ #[test]
+ fn parse_openai_with_reasoning_details_omits_non_array_shapes() {
+ let null_shape = serde_json::json!({
+ "choices": [{
+ "finish_reason": "stop",
+ "message": {"content": "hello", "reasoning_details": null}
+ }]
+ });
+ let r = parse_openai_with_reasoning_details(null_shape).unwrap();
+ assert!(
+ r.reasoning_details.is_none(),
+ "null reasoning_details must be omitted, not stored as Some(Null)"
+ );
+
+ let object_shape = serde_json::json!({
+ "choices": [{
+ "finish_reason": "stop",
+ "message": {
+ "content": "hello",
+ "reasoning_details": {"type": "thinking", "content": "not an array"}
+ }
+ }]
+ });
+ let r = parse_openai_with_reasoning_details(object_shape).unwrap();
+ assert!(
+ r.reasoning_details.is_none(),
+ "a bare-object reasoning_details must be omitted, not stored as Some(object)"
+ );
+ }
+
+ #[test]
+ fn parse_openai_plain_never_captures_reasoning_details() {
+ let v = serde_json::json!({
+ "choices": [{
+ "finish_reason": "stop",
+ "message": {
+ "content": "hello",
+ "reasoning_details": [{"type": "thinking", "content": "hmm"}]
+ }
+ }]
+ });
+ let r = parse_openai(v).unwrap();
+ assert!(
+ r.reasoning_details.is_none(),
+ "plain parse_openai must never capture reasoning_details (OpenAI/Databricks regression)"
+ );
+ }
+
+ #[test]
+ fn reasoning_details_two_request_round_trip() {
+ let details = serde_json::json!([
+ {"type": "thinking", "content": "Step 1: analyze the request."},
+ {"type": "thinking", "content": "Step 2: call the tool."}
+ ]);
+ // Request 1: model returns a tool call with reasoning_details
+ let response1 = serde_json::json!({
+ "choices": [{
+ "finish_reason": "tool_calls",
+ "message": {
+ "content": "",
+ "reasoning_details": details,
+ "tool_calls": [{
+ "id": "call_abc",
+ "type": "function",
+ "function": {"name": "dev__shell", "arguments": "{\"command\":\"ls\"}"}
+ }]
+ }
+ }],
+ "usage": {"prompt_tokens": 50, "completion_tokens": 20}
+ });
+ let r1 = parse_openai_with_reasoning_details(response1).unwrap();
+ assert_eq!(r1.reasoning_details, Some(details.clone()));
+
+ // Build history as the agent would: assistant turn with reasoning_details,
+ // followed by a tool result.
+ let history = vec![
+ HistoryItem::User("run ls".into()),
+ HistoryItem::Assistant {
+ text: String::new(),
+ tool_calls: r1.tool_calls,
+ reasoning_details: r1.reasoning_details,
+ },
+ HistoryItem::ToolResult(ToolResult {
+ provider_id: "call_abc".into(),
+ content: vec![ToolResultContent::Text("file.txt".into())],
+ is_error: false,
+ }),
+ ];
+
+ // Request 2: build the body for the continuation
+ let body = openai_body(
+ &cfg(Provider::OpenRouter),
+ "system",
+ &history,
+ &[],
+ "anthropic/claude-opus-4-7",
+ None,
+ );
+ let messages = body["messages"].as_array().unwrap();
+
+ // The assistant message must carry the identical reasoning_details array
+ let assistant_msg = messages
+ .iter()
+ .find(|m| m.get("role").and_then(Value::as_str) == Some("assistant"))
+ .expect("assistant message must exist");
+ assert_eq!(
+ assistant_msg["reasoning_details"], details,
+ "reasoning_details must be replayed byte-for-byte on the assistant message"
+ );
+
+ // The assistant message must appear BEFORE the tool result
+ let assistant_idx = messages
+ .iter()
+ .position(|m| m.get("role").and_then(Value::as_str) == Some("assistant"))
+ .unwrap();
+ let tool_idx = messages
+ .iter()
+ .position(|m| m.get("role").and_then(Value::as_str) == Some("tool"))
+ .unwrap();
+ assert!(
+ assistant_idx < tool_idx,
+ "assistant with reasoning_details must precede tool result"
+ );
+ }
+
+ #[test]
+ fn reasoning_details_none_emits_no_field_in_body() {
+ let history = vec![
+ HistoryItem::User("hello".into()),
+ HistoryItem::Assistant {
+ text: "hi back".into(),
+ tool_calls: Vec::new(),
+ reasoning_details: None,
+ },
+ ];
+ let body = openai_body(
+ &cfg(Provider::OpenRouter),
+ "system",
+ &history,
+ &[],
+ "anthropic/claude-opus-4-7",
+ None,
+ );
+ let messages = body["messages"].as_array().unwrap();
+ let assistant_msg = messages
+ .iter()
+ .find(|m| m.get("role").and_then(Value::as_str) == Some("assistant"))
+ .expect("assistant message must exist");
+ assert!(
+ assistant_msg.get("reasoning_details").is_none(),
+ "assistant with None reasoning_details must not emit the field"
+ );
+ }
+
+ #[test]
+ fn reasoning_details_charged_to_estimated_bytes() {
+ let details = serde_json::json!([
+ {"type": "thinking", "content": "A long chain of reasoning tokens here."}
+ ]);
+ let with = HistoryItem::Assistant {
+ text: "text".into(),
+ tool_calls: Vec::new(),
+ reasoning_details: Some(details.clone()),
+ };
+ let without = HistoryItem::Assistant {
+ text: "text".into(),
+ tool_calls: Vec::new(),
+ reasoning_details: None,
+ };
+ assert!(
+ with.estimated_bytes() > without.estimated_bytes(),
+ "reasoning_details must contribute to estimated_bytes"
+ );
+ assert!(
+ with.context_pressure_bytes() > without.context_pressure_bytes(),
+ "reasoning_details must contribute to context_pressure_bytes"
+ );
+ let details_size = serde_json::to_vec(&details).unwrap().len();
+ assert_eq!(
+ with.estimated_bytes() - without.estimated_bytes(),
+ details_size,
+ "reasoning_details contribution must equal its serialized size"
+ );
+ }
+
+ #[test]
+ fn reasoning_details_not_replayed_in_anthropic_body() {
+ let history = vec![
+ HistoryItem::User("hi".into()),
+ HistoryItem::Assistant {
+ text: "ok".into(),
+ tool_calls: Vec::new(),
+ reasoning_details: Some(
+ serde_json::json!([{"type": "thinking", "content": "hmm"}]),
+ ),
+ },
+ ];
+ let body = anthropic_body(
+ &cfg(Provider::Anthropic),
+ "system",
+ &history,
+ &[],
+ "claude-opus-4-7",
+ None,
+ );
+ let messages = body["messages"].as_array().unwrap();
+ let assistant = messages
+ .iter()
+ .find(|m| m.get("role").and_then(Value::as_str) == Some("assistant"))
+ .unwrap();
+ assert!(
+ assistant.get("reasoning_details").is_none(),
+ "anthropic_body must not replay reasoning_details"
+ );
+ }
+
+ #[test]
+ fn reasoning_details_not_replayed_in_responses_body() {
+ let history = vec![
+ HistoryItem::User("hi".into()),
+ HistoryItem::Assistant {
+ text: "ok".into(),
+ tool_calls: Vec::new(),
+ reasoning_details: Some(
+ serde_json::json!([{"type": "thinking", "content": "hmm"}]),
+ ),
+ },
+ ];
+ let body = responses_body(&cfg_responses(), "system", &history, &[], "model", None);
+ let body_str = serde_json::to_string(&body).unwrap();
+ assert!(
+ !body_str.contains("reasoning_details"),
+ "responses_body must not replay reasoning_details"
+ );
+ }
+
+ // ---- T4: openrouter_post transport-level regressions ----
+ //
+ // These stub an HTTP server directly and drive `openrouter_post` (not the
+ // classifier in isolation), proving the retry/attempt-accounting and
+ // header behavior the classifier-only tests above cannot see.
+
+ /// One canned response: status, body, and any extra headers (e.g.
+ /// `Retry-After`) to send back for a single request.
+ struct CannedResponse {
+ status: u16,
+ body: String,
+ extra_headers: Vec<(String, String)>,
+ }
+
+ impl CannedResponse {
+ fn new(status: u16, body: &str) -> Self {
+ Self {
+ status,
+ body: body.into(),
+ extra_headers: Vec::new(),
+ }
+ }
+
+ fn with_header(mut self, name: &str, value: &str) -> Self {
+ self.extra_headers.push((name.into(), value.into()));
+ self
+ }
+ }
+
+ fn status_line(status: u16) -> &'static str {
+ match status {
+ 200 => "200 OK",
+ 401 => "401 Unauthorized",
+ 402 => "402 Payment Required",
+ 403 => "403 Forbidden",
+ 404 => "404 Not Found",
+ 429 => "429 Too Many Requests",
+ 499 => "499 Client Closed Request",
+ 500 => "500 Internal Server Error",
+ 502 => "502 Bad Gateway",
+ 503 => "503 Service Unavailable",
+ _ => panic!("unsupported status {status} in test stub"),
+ }
+ }
+
+ /// Spawns a stub HTTP server that pops one `CannedResponse` per request
+ /// (repeating the last one once the queue is exhausted, so an
+ /// over-budget attempt count is visible rather than hanging), and
+ /// captures each request's raw header block for header-attribution
+ /// assertions. Returns (url, captured_header_blocks, attempt_counter).
+ async fn spawn_openrouter_stub(
+ responses: Vec,
+ ) -> (
+ String,
+ Arc>>,
+ Arc,
+ ) {
+ use std::sync::atomic::{AtomicU32, Ordering};
+ use tokio::io::{AsyncReadExt, AsyncWriteExt};
+ use tokio::net::TcpListener;
+
+ let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let url = format!("http://{}", listener.local_addr().unwrap());
+ let queue = Arc::new(Mutex::new(VecDeque::from(responses)));
+ let captured: Arc>> = Arc::new(Mutex::new(Vec::new()));
+ let attempts = Arc::new(AtomicU32::new(0));
+ let captured_clone = captured.clone();
+ let attempts_clone = attempts.clone();
+ tokio::spawn(async move {
+ loop {
+ let (mut sock, _) = match listener.accept().await {
+ Ok(p) => p,
+ Err(_) => return,
+ };
+ let queue = queue.clone();
+ let captured = captured_clone.clone();
+ let attempts = attempts_clone.clone();
+ tokio::spawn(async move {
+ let mut buf = Vec::new();
+ let mut tmp = [0u8; 4096];
+ while !buf.windows(4).any(|w| w == b"\r\n\r\n") {
+ match sock.read(&mut tmp).await {
+ Ok(0) | Err(_) => return,
+ Ok(n) => buf.extend_from_slice(&tmp[..n]),
+ }
+ if buf.len() > 1_000_000 {
+ return;
+ }
+ }
+ let header_end = buf.windows(4).position(|w| w == b"\r\n\r\n").unwrap() + 4;
+ let header_str = String::from_utf8_lossy(&buf[..header_end]).into_owned();
+ // Drain any remaining body per Content-Length so `Connection:
+ // close` doesn't race the client's write.
+ let content_length: usize = header_str
+ .lines()
+ .find_map(|line| {
+ line.to_ascii_lowercase()
+ .strip_prefix("content-length:")
+ .and_then(|v| v.trim().parse().ok())
+ })
+ .unwrap_or(0);
+ let mut body_len = buf.len() - header_end;
+ while body_len < content_length {
+ match sock.read(&mut tmp).await {
+ Ok(0) | Err(_) => break,
+ Ok(n) => body_len += n,
+ }
+ }
+ captured.lock().await.push(header_str);
+ attempts.fetch_add(1, Ordering::SeqCst);
+
+ let mut q = queue.lock().await;
+ let canned = if q.len() > 1 {
+ q.pop_front().unwrap()
+ } else {
+ // Repeat the final canned response so a test bug that
+ // over-retries produces a visible extra attempt
+ // instead of a hung connection.
+ let last = q.front().unwrap();
+ CannedResponse {
+ status: last.status,
+ body: last.body.clone(),
+ extra_headers: last.extra_headers.clone(),
+ }
+ };
+ drop(q);
+
+ let mut resp = format!(
+ "HTTP/1.1 {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n",
+ status_line(canned.status),
+ canned.body.len()
+ );
+ for (name, value) in &canned.extra_headers {
+ resp.push_str(&format!("{name}: {value}\r\n"));
+ }
+ resp.push_str("Connection: close\r\n\r\n");
+ resp.push_str(&canned.body);
+ let _ = sock.write_all(resp.as_bytes()).await;
+ let _ = sock.shutdown().await;
+ });
+ }
+ });
+ (url, captured, attempts)
+ }
+
+ /// A 403 (guardrail/moderation/permission rejection, per OpenRouter docs)
+ /// must NOT be classified as `LlmAuth`: refreshing a static key returns
+ /// the identical key, so retrying would just waste a duplicate request.
+ /// Exactly one attempt, plain `AgentError::Llm` with the body preserved.
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+ async fn openrouter_post_403_single_attempt_not_auth_error() {
+ let (url, _captured, attempts) = spawn_openrouter_stub(vec![CannedResponse::new(
+ 403,
+ r#"{"error":{"message":"model flagged by moderation"}}"#,
+ )])
+ .await;
+ let http = Client::builder()
+ .timeout(Duration::from_secs(5))
+ .build()
+ .unwrap();
+ let err = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key")
+ .await
+ .unwrap_err();
+ assert!(
+ matches!(&err, AgentError::Llm(s) if s.contains("403") && s.contains("model flagged by moderation")),
+ "403 must surface as AgentError::Llm with status+body, not LlmAuth: got {err:?}"
+ );
+ assert_eq!(
+ attempts.load(std::sync::atomic::Ordering::SeqCst),
+ 1,
+ "403 must not be retried (a refreshed static key is identical)"
+ );
+ }
+
+ /// A 402 short-circuits on the first attempt: no retry, one request,
+ /// the actionable credits-exhausted message.
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+ async fn openrouter_post_402_single_attempt_short_circuit() {
+ let (url, _captured, attempts) = spawn_openrouter_stub(vec![CannedResponse::new(
+ 402,
+ r#"{"error":{"message":"payment required"}}"#,
+ )])
+ .await;
+ let http = Client::builder()
+ .timeout(Duration::from_secs(5))
+ .build()
+ .unwrap();
+ let err = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key")
+ .await
+ .unwrap_err();
+ assert!(
+ matches!(&err, AgentError::Llm(s) if s.contains("credits exhausted")),
+ "got {err:?}"
+ );
+ assert_eq!(
+ attempts.load(std::sync::atomic::Ordering::SeqCst),
+ 1,
+ "402 must not be retried"
+ );
+ }
+
+ /// A 404 whose body is OpenRouter's parameter-routing rejection is NOT a
+ /// missing model: the id is valid and no endpoint behind it can serve the
+ /// request shape. It must surface the actionable routing message rather than
+ /// `LlmModelNotFound`, which sends the user hunting a model-name typo.
+ /// Body text is the one OpenRouter actually returned in the live probe run.
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+ async fn openrouter_post_404_no_endpoints_found_is_parameter_routing_error() {
+ let (url, _captured, attempts) = spawn_openrouter_stub(vec![CannedResponse::new(
+ 404,
+ r#"{"error":{"message":"No endpoints found that can handle the requested parameters. To learn more about provider routing, visit: https://openrouter.ai/docs/guides/routing/provider-selection","code":404}}"#,
+ )])
+ .await;
+ let http = Client::builder()
+ .timeout(Duration::from_secs(5))
+ .build()
+ .unwrap();
+ let err = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key")
+ .await
+ .unwrap_err();
+ assert!(
+ matches!(&err, AgentError::Llm(s) if s.contains("no OpenRouter endpoint supports")),
+ "parameter-routing 404 must not be reported as a missing model: got {err:?}"
+ );
+ assert_eq!(
+ attempts.load(std::sync::atomic::Ordering::SeqCst),
+ 1,
+ "404 must not be retried"
+ );
+ }
+
+ /// Every other 404 still maps to `LlmModelNotFound`, including one that
+ /// shares the `No endpoints found` prefix but is about the model rather than
+ /// the parameters — the discriminator is narrow enough that a genuinely
+ /// unavailable model keeps its own error kind (Desktop renders
+ /// model-not-found differently from a generic LLM failure).
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+ async fn openrouter_post_404_unknown_model_stays_model_not_found() {
+ let (url, _captured, _attempts) = spawn_openrouter_stub(vec![CannedResponse::new(
+ 404,
+ r#"{"error":{"message":"No endpoints found for vendor/nonexistent-model.","code":404}}"#,
+ )])
+ .await;
+ let http = Client::builder()
+ .timeout(Duration::from_secs(5))
+ .build()
+ .unwrap();
+ let err = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key")
+ .await
+ .unwrap_err();
+ assert!(
+ matches!(&err, AgentError::LlmModelNotFound(s) if s.contains("404") && s.contains("vendor/nonexistent-model")),
+ "a model-level 404 must stay LlmModelNotFound: got {err:?}"
+ );
+ }
+
+ /// A 429 with `Retry-After: 1` sleeps for that duration before the retry
+ /// succeeds — proving the header value is actually honored, not just
+ /// classified.
+ #[tokio::test(flavor = "current_thread")]
+ async fn openrouter_post_429_honors_retry_after_header() {
+ let (url, _captured, attempts) = spawn_openrouter_stub(vec![
+ CannedResponse::new(429, r#"{"error":{"message":"rate limited"}}"#)
+ .with_header("Retry-After", "1"),
+ CannedResponse::new(200, r#"{"choices":[{"message":{"content":"ok"}}]}"#),
+ ])
+ .await;
+ let http = Client::builder()
+ .timeout(Duration::from_secs(30))
+ .build()
+ .unwrap();
+ let before = std::time::Instant::now();
+ let out = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key")
+ .await
+ .expect("second attempt succeeds");
+ assert_eq!(out["choices"][0]["message"]["content"], "ok");
+ assert!(
+ before.elapsed() >= Duration::from_secs(1),
+ "must sleep at least the Retry-After hint"
+ );
+ assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 2);
+ }
+
+ /// A `Retry-After` far beyond `RETRY_AFTER_CAP_SECS` must not stall the
+ /// retry loop for anywhere near its advertised duration — proving the
+ /// cap is enforced end-to-end in `openrouter_post`'s actual sleep, not
+ /// merely in the isolated `parse_retry_after_header` unit tests above.
+ /// Runs on a paused clock so a real 999999s wait would hang the test
+ /// instead of silently passing.
+ #[tokio::test(start_paused = true)]
+ async fn openrouter_post_429_retry_sleep_capped_despite_huge_retry_after() {
+ let (url, _captured, attempts) = spawn_openrouter_stub(vec![
+ CannedResponse::new(429, r#"{"error":{"message":"rate limited"}}"#)
+ .with_header("Retry-After", "999999"),
+ CannedResponse::new(200, r#"{"choices":[{"message":{"content":"ok"}}]}"#),
+ ])
+ .await;
+ let http = Client::builder().build().unwrap();
+ let before = tokio::time::Instant::now();
+ let out = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key")
+ .await
+ .expect("second attempt succeeds");
+ assert_eq!(out["choices"][0]["message"]["content"], "ok");
+ assert!(
+ before.elapsed() <= Duration::from_secs(RETRY_AFTER_CAP_SECS + 5),
+ "retry sleep must be clamped to RETRY_AFTER_CAP_SECS ({RETRY_AFTER_CAP_SECS}s), \
+ not the header's 999999s: elapsed {:?}",
+ before.elapsed()
+ );
+ assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 2);
+ }
+
+ /// An untyped 503 (no `error.metadata.error_type`) exhausts all
+ /// `MAX_RETRIES` attempts, then returns the actionable routing message —
+ /// proving attempt accounting terminates rather than retrying forever.
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+ async fn openrouter_post_untyped_503_exhausts_retries_into_actionable_message() {
+ let canned = CannedResponse::new(503, r#"{"error":{"message":"no capacity"}}"#);
+ let (url, _captured, attempts) = spawn_openrouter_stub(vec![canned]).await;
+ let http = Client::builder()
+ .timeout(Duration::from_secs(30))
+ .build()
+ .unwrap();
+ let err = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key")
+ .await
+ .unwrap_err();
+ assert!(
+ matches!(&err, AgentError::Llm(s) if s.contains("no OpenRouter endpoint supports")),
+ "got {err:?}"
+ );
+ assert_eq!(
+ attempts.load(std::sync::atomic::Ordering::SeqCst),
+ MAX_RETRIES,
+ "must exhaust exactly MAX_RETRIES attempts, no more"
+ );
+ }
+
+ /// Attribution headers (`HTTP-Referer`, `X-OpenRouter-Title`) are on the
+ /// actual wire request, not merely asserted against a body fixture.
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+ async fn openrouter_post_sends_attribution_headers() {
+ let (url, captured, _attempts) = spawn_openrouter_stub(vec![CannedResponse::new(
+ 200,
+ r#"{"choices":[{"message":{"content":"ok"}}]}"#,
+ )])
+ .await;
+ let http = Client::builder()
+ .timeout(Duration::from_secs(5))
+ .build()
+ .unwrap();
+ openrouter_post(&http, &format!("{url}/x"), &json!({}), "key")
+ .await
+ .expect("200 succeeds");
+ let headers = captured.lock().await;
+ let header_str = headers
+ .first()
+ .expect("one request captured")
+ .to_lowercase();
+ assert!(
+ header_str.contains("http-referer: https://github.com/block/buzz"),
+ "got: {header_str}"
+ );
+ assert!(
+ header_str.contains("x-openrouter-title: buzz"),
+ "got: {header_str}"
+ );
+ }
+
+ /// A 499 response is retried and the call succeeds on the second attempt,
+ /// mirroring the shared `post()` path (#2175).
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+ async fn openrouter_post_retries_499_then_succeeds() {
+ let (url, _captured, attempts) = spawn_openrouter_stub(vec![
+ CannedResponse::new(499, ""),
+ CannedResponse::new(200, r#"{"choices":[{"message":{"content":"ok"}}]}"#),
+ ])
+ .await;
+ let http = Client::builder()
+ .timeout(Duration::from_secs(30))
+ .build()
+ .unwrap();
+ let out = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key")
+ .await
+ .expect("retry after 499 should succeed");
+ assert_eq!(out["choices"][0]["message"]["content"], "ok");
+ assert_eq!(
+ attempts.load(std::sync::atomic::Ordering::SeqCst),
+ 2,
+ "exactly one 499 retry"
+ );
+ }
+
+ /// A 502 without `provider_unavailable` still retries (the classifier's
+ /// unconditional-retry branch), then succeeds on attempt 2 — proving the
+ /// generic 502 path isn't accidentally routed to `Unknown`/terminal.
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+ async fn openrouter_post_502_retries_then_succeeds() {
+ let (url, _captured, attempts) = spawn_openrouter_stub(vec![
+ CannedResponse::new(502, r#"{"error":{"message":"bad gateway"}}"#),
+ CannedResponse::new(200, r#"{"choices":[{"message":{"content":"ok"}}]}"#),
+ ])
+ .await;
+ let http = Client::builder()
+ .timeout(Duration::from_secs(30))
+ .build()
+ .unwrap();
+ let out = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key")
+ .await
+ .expect("retry succeeds");
+ assert_eq!(out["choices"][0]["message"]["content"], "ok");
+ assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 2);
+ }
+
+ /// A 200 response whose body is truncated mid-stream (connection closed
+ /// before the full Content-Length is delivered) must surface the error
+ /// through `terminal_llm_error`, not a bare `AgentError::Llm("read: …")` —
+ /// the caller needs cumulative duration and attempt-count context to diagnose
+ /// an upstream that silently drops connections.
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+ async fn openrouter_post_truncated_200_body_wraps_in_terminal_error() {
+ use tokio::io::{AsyncReadExt, AsyncWriteExt};
+ use tokio::net::TcpListener;
+
+ // Spawn a stub that sends a 200 with Content-Length > actual body,
+ // then closes the connection — reqwest sees a truncated stream.
+ let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let url = format!("http://{}", listener.local_addr().unwrap());
+ tokio::spawn(async move {
+ if let Ok((mut sock, _)) = listener.accept().await {
+ let mut buf = [0u8; 4096];
+ // Read until end-of-headers, ignore body
+ loop {
+ match sock.read(&mut buf).await {
+ Ok(0) | Err(_) => break,
+ Ok(n) => {
+ if buf[..n].windows(4).any(|w| w == b"\r\n\r\n") {
+ break;
+ }
+ }
+ }
+ }
+ // Claim 1 MB of body, send only 10 bytes, then close.
+ let _ = sock
+ .write_all(
+ b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\
+ Content-Length: 1048576\r\nConnection: close\r\n\r\n\
+ {truncated",
+ )
+ .await;
+ let _ = sock.shutdown().await;
+ }
+ });
+
+ let http = Client::builder()
+ .timeout(Duration::from_secs(5))
+ .build()
+ .unwrap();
+ let err = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key")
+ .await
+ .unwrap_err();
+ assert!(
+ matches!(&err, AgentError::Llm(s) if s.contains("body read")),
+ "truncated body must surface as AgentError::Llm with 'body read': got {err:?}"
+ );
+ assert!(
+ matches!(&err, AgentError::Llm(s) if s.contains("cumulative")),
+ "body-read error must include terminal_llm_error's cumulative context: got {err:?}"
+ );
+ }
+
+ /// A `TokenSource` whose `refresh_now` always returns the identical token —
+ /// models a static API key whose bytes never change on refresh.
+ struct StaticAuth {
+ token: String,
+ }
+
+ #[async_trait::async_trait]
+ impl TokenSource for StaticAuth {
+ async fn bearer(&self) -> Result {
+ Ok(self.token.clone())
+ }
+ async fn refresh_now(&self, _rejected: &str) -> Result {
+ Ok(self.token.clone()) // static: same bytes every time
+ }
+ }
+
+ /// A `TokenSource` that returns a stale token from `bearer()` and a
+ /// distinct fresh token from `refresh_now()`, modelling a PKCE OAuth source.
+ struct MintingAuth {
+ stale: String,
+ fresh: String,
+ refreshes: std::sync::atomic::AtomicU32,
+ }
+
+ #[async_trait::async_trait]
+ impl TokenSource for MintingAuth {
+ async fn bearer(&self) -> Result {
+ Ok(self.stale.clone())
+ }
+ async fn refresh_now(&self, _rejected: &str) -> Result {
+ self.refreshes
+ .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
+ Ok(self.fresh.clone())
+ }
+ }
+
+ /// A static key 401: `refresh_now` returns the same bytes — the second
+ /// wire request would be byte-identical, so the retry must be skipped.
+ /// Exactly one wire request reaches the server.
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+ async fn post_openrouter_static_key_401_single_attempt_no_retry() {
+ use std::sync::atomic::Ordering;
+
+ let (url, _captured, attempts) = spawn_openrouter_stub(vec![CannedResponse::new(
+ 401,
+ r#"{"error":{"message":"invalid api key"}}"#,
+ )])
+ .await;
+ let auth = Arc::new(StaticAuth {
+ token: "static-key".into(),
+ });
+ let llm = llm_with(auth);
+ let mut c = cfg(Provider::OpenRouter);
+ c.base_url = url;
+
+ let err = llm.post_openrouter(&c, &json!({})).await.unwrap_err();
+ assert!(
+ matches!(&err, AgentError::LlmAuth(s) if s.contains("static key rejected")),
+ "static 401 must surface as LlmAuth with 'static key rejected': got {err:?}"
+ );
+ assert_eq!(
+ attempts.load(Ordering::SeqCst),
+ 1,
+ "exactly one wire request — no duplicate retry for a static key"
+ );
+ }
+
+ /// A minting-source 401: `refresh_now` produces a distinct fresh token, so
+ /// the retry is legitimate. The stub accepts the fresh token's second
+ /// request with 200 and exactly two wire requests reach the server.
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+ async fn post_openrouter_minting_source_401_retries_with_fresh_token() {
+ use std::sync::atomic::Ordering;
+
+ // Stub: always 401 for bearer "stale", 200 for anything else.
+ // We repurpose `spawn_auth_stub` here: it rejects `Bearer stale`,
+ // accepts `Bearer fresh`.
+ let always_401 = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
+ let base = spawn_auth_stub(always_401, 401).await;
+
+ let auth = Arc::new(MintingAuth {
+ stale: "stale".into(),
+ fresh: "fresh".into(),
+ refreshes: std::sync::atomic::AtomicU32::new(0),
+ });
+ let llm = llm_with(auth.clone());
+ let mut c = cfg(Provider::OpenRouter);
+ c.base_url = base;
+
+ let result = llm.post_openrouter(&c, &json!({})).await;
+ // `spawn_auth_stub` returns `{"ok":true}` on success.
+ assert!(
+ result.is_ok(),
+ "minting-source retry should succeed: {result:?}"
+ );
+ assert_eq!(
+ auth.refreshes.load(Ordering::SeqCst),
+ 1,
+ "exactly one refresh"
+ );
+ }
}
diff --git a/crates/buzz-agent/src/mcp.rs b/crates/buzz-agent/src/mcp.rs
index 744b10dc7a..9ae125a0b7 100644
--- a/crates/buzz-agent/src/mcp.rs
+++ b/crates/buzz-agent/src/mcp.rs
@@ -52,6 +52,31 @@ const PASSTHROUGH_ENV: &[&str] = &[
"GIT_ASKPASS",
"GIT_SSH_COMMAND",
"GIT_CONFIG_GLOBAL",
+ // Proxy — on a host whose only route out is a CONNECT proxy, dropping
+ // these does not degrade the tools, it blinds them: apt, curl, pip and git
+ // all connect directly instead, and the egress firewall resets the socket.
+ // The agent then reports "Connection reset by peer" and concludes the
+ // environment has no network, which is indistinguishable in the transcript
+ // from a task that is genuinely offline.
+ //
+ // Both cases are needed. curl and git read the lowercase spellings, most
+ // Go and Python tooling reads the uppercase ones, and libcurl deliberately
+ // ignores uppercase HTTP_PROXY (CGI ambiguity), so keeping only one form
+ // silently breaks half the toolchain.
+ "HTTP_PROXY",
+ "HTTPS_PROXY",
+ "NO_PROXY",
+ "ALL_PROXY",
+ "http_proxy",
+ "https_proxy",
+ "no_proxy",
+ "all_proxy",
+ // TLS trust — a proxy that terminates TLS presents its own CA, and an
+ // image whose trust store does not carry it fails every https fetch with a
+ // verification error. Same class of failure as the proxy vars: the parent
+ // was configured correctly and the child could not see it.
+ "SSL_CERT_FILE",
+ "SSL_CERT_DIR",
// Buzz identity — dev-mcp writes NOSTR_PRIVATE_KEY to a keyfile then
// removes it from its own env (children never see it). BUZZ_PRIVATE_KEY
// and BUZZ_RELAY_URL are kept for the buzz CLI. BUZZ_AUTH_TAG is a
@@ -1015,6 +1040,41 @@ mod content_tests {
fn passthrough_includes_buzz_owner_attestation() {
assert!(PASSTHROUGH_ENV.contains(&"BUZZ_AUTH_TAG"));
}
+
+ #[test]
+ fn passthrough_carries_proxy_configuration_to_tools() {
+ // On a proxy-only host this is the difference between an agent that can
+ // install a package and one that reports the network is down. Both
+ // spellings: libcurl ignores uppercase HTTP_PROXY, and Go/Python
+ // tooling largely ignores the lowercase set.
+ for var in [
+ "HTTP_PROXY",
+ "HTTPS_PROXY",
+ "NO_PROXY",
+ "ALL_PROXY",
+ "http_proxy",
+ "https_proxy",
+ "no_proxy",
+ "all_proxy",
+ ] {
+ assert!(
+ PASSTHROUGH_ENV.contains(&var),
+ "{var} must survive env_clear() or every MCP tool loses the proxy"
+ );
+ }
+ }
+
+ #[test]
+ fn passthrough_carries_tls_trust_to_tools() {
+ // A TLS-terminating proxy presents its own CA; without these the child
+ // rejects every https fetch even though the proxy itself is reachable.
+ for var in ["SSL_CERT_FILE", "SSL_CERT_DIR"] {
+ assert!(
+ PASSTHROUGH_ENV.contains(&var),
+ "{var} must survive env_clear() or https fails inside tools"
+ );
+ }
+ }
use rmcp::model::Content;
#[cfg(windows)]
diff --git a/crates/buzz-agent/src/types.rs b/crates/buzz-agent/src/types.rs
index d29e975e03..343a75bf72 100644
--- a/crates/buzz-agent/src/types.rs
+++ b/crates/buzz-agent/src/types.rs
@@ -1,5 +1,5 @@
use serde::Deserialize;
-use serde_json::Value;
+use serde_json::{Map, Value};
/// Byte-equivalent charged to the handoff/context-pressure gate for a single
/// image tool result. The gate maps bytes to tokens at 1 byte/token (see
@@ -62,6 +62,7 @@ pub enum HistoryItem {
Assistant {
text: String,
tool_calls: Vec,
+ reasoning_details: Option,
},
ToolResult(ToolResult),
}
@@ -83,7 +84,11 @@ impl HistoryItem {
fn size_with(&self, content_size: fn(&ToolResultContent) -> usize) -> usize {
match self {
Self::User(s) => s.len(),
- Self::Assistant { text, tool_calls } => {
+ Self::Assistant {
+ text,
+ tool_calls,
+ reasoning_details,
+ } => {
text.len()
+ tool_calls
.iter()
@@ -93,8 +98,20 @@ impl HistoryItem {
+ serde_json::to_vec(&c.arguments)
.map(|b| b.len())
.unwrap_or(0)
+ // `provider_extra` (e.g. a Gemini
+ // `thoughtSignature`) is re-serialized into
+ // every replayed call, so it counts toward the
+ // request body and the context-pressure gate.
+ + serde_json::to_vec(&c.provider_extra)
+ .map(|b| b.len())
+ .unwrap_or(0)
})
.sum::()
+ + reasoning_details
+ .as_ref()
+ .and_then(|v| serde_json::to_vec(v).ok())
+ .map(|b| b.len())
+ .unwrap_or(0)
}
Self::ToolResult(r) => {
r.provider_id.len() + r.content.iter().map(content_size).sum::()
@@ -108,6 +125,17 @@ pub struct ToolCall {
pub provider_id: String,
pub name: String,
pub arguments: Value,
+ /// Fields the provider put on the tool call that we do not model, kept so
+ /// the assistant turn can be replayed the way it arrived.
+ ///
+ /// Gemini on the Databricks MLflow route returns a `thoughtSignature` per
+ /// call and *requires* it echoed back: replaying without it fails the whole
+ /// request with `Function call is missing a thought_signature in functionCall
+ /// parts`. For an agent loop that lands on the very first tool call, so the
+ /// model is unusable without this. Carrying whatever we did not model,
+ /// rather than naming that one field, means the next provider with an opaque
+ /// per-call token needs no change here.
+ pub provider_extra: Map,
}
#[derive(Debug, Clone)]
@@ -139,10 +167,28 @@ pub struct LlmResponse {
/// tokens, so reading it alone would undercount). Used to gate handoff on
/// the real token budget rather than a byte estimate.
pub input_tokens: Option,
+ /// The portion of `input_tokens` the provider served from its prompt cache,
+ /// or `None` when the response reported no cache split. Providers bill this
+ /// slice at a large discount (roughly 10x for both OpenAI and Anthropic),
+ /// so a consumer that prices all of `input_tokens` at the full rate
+ /// *overstates* cost — by a lot on an append-only agent loop, where most of
+ /// each request is a prefix the provider already has.
+ ///
+ /// This is a subset of `input_tokens`, never an addition to it: every
+ /// provider we speak to reports an inclusive input total, so adding this
+ /// would double-count.
+ pub cached_input_tokens: Option,
/// Output tokens the provider reported for this request, or `None` if the
/// response carried no usage. Used to accumulate per-turn output counts
/// for NIP-AM metric publishing.
pub output_tokens: Option,
+ /// Provider-reported total tokens for this request, or `None` when the
+ /// provider does not report a genuine total. Present for OpenAI-shaped
+ /// responses (`usage.total_tokens`). Always `None` for Anthropic, which
+ /// reports only category counts; NIP-AM forbids summing categories into a
+ /// total. Callers must not derive this by summing `input_tokens +
+ /// output_tokens` — that is what the UI display approximation is for.
+ pub total_tokens: Option,
/// Reasoning/thinking content emitted by the model before its answer, if
/// any. Non-empty when the provider returns extended-thinking tokens:
///
@@ -152,6 +198,10 @@ pub struct LlmResponse {
///
/// Empty string when the provider returned no reasoning content.
pub reasoning: String,
+ /// Raw `reasoning_details` array from an OpenRouter response, if present.
+ /// Replayed on subsequent turns so the model can continue its chain-of-thought.
+ /// `None` for all non-OpenRouter providers.
+ pub reasoning_details: Option,
}
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -170,6 +220,94 @@ pub struct ToolDef {
pub input_schema: Value,
}
+/// Tri-state accumulator for provider-reported total tokens within one ACP turn.
+///
+/// Tracks whether every usage-bearing LLM response in the turn supplied a genuine
+/// provider total. Used to accumulate a reliable per-turn total and contribute to
+/// the session-cumulative total.
+///
+/// - `Unseen`: no usage-bearing response observed yet (initial state for each turn).
+/// - `Exact(n)`: every response so far reported a total; `n` is their sum.
+/// - `Unknown`: at least one response lacked a total — permanently poisoned for
+/// this turn. The session-cumulative also transitions to Unknown when any turn
+/// lands Unknown, and stays there until a new session resets it.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum TurnTotalState {
+ #[default]
+ Unseen,
+ Exact(u64),
+ Unknown,
+}
+
+impl TurnTotalState {
+ /// Add two exact token counts with overflow protection.
+ ///
+ /// Returns `Exact(acc + n)` on success or `Unknown` on overflow.
+ /// This is the single implementation of the checked-add / overflow-poisons
+ /// contract; both `fold()` and `merge_session()` call this helper so a
+ /// change to overflow semantics needs to be made in exactly one place.
+ fn checked_exact_sum(acc: u64, n: u64) -> TurnTotalState {
+ match acc.checked_add(n) {
+ Some(sum) => TurnTotalState::Exact(sum),
+ None => TurnTotalState::Unknown,
+ }
+ }
+
+ /// Fold one provider-reported total into the current state.
+ ///
+ /// `total`: `Some(n)` when the provider included a genuine total on this
+ /// response; `None` when it was absent (e.g. Anthropic, or an OpenAI
+ /// response that omits usage). Absence of a total on any usage-bearing
+ /// response poisons the whole turn.
+ ///
+ /// Overflow is handled by `checked_exact_sum`: a saturated value would
+ /// not be a genuine provider-reported total, so overflow → `Unknown`.
+ pub fn fold(self, total: Option) -> TurnTotalState {
+ match (self, total) {
+ // Already poisoned — stays Unknown regardless.
+ (TurnTotalState::Unknown, _) => TurnTotalState::Unknown,
+ // No total from this response — poison the accumulator.
+ (_, None) => TurnTotalState::Unknown,
+ // First response with a total.
+ (TurnTotalState::Unseen, Some(n)) => TurnTotalState::Exact(n),
+ // Subsequent response — delegate to the shared checked-sum helper.
+ (TurnTotalState::Exact(acc), Some(n)) => Self::checked_exact_sum(acc, n),
+ }
+ }
+
+ /// Merge a completed turn's total state into the session-cumulative state.
+ ///
+ /// This is the turn→session boundary accumulation:
+ /// - An `Unseen` turn (no usage-bearing responses) leaves the cumulative unchanged.
+ /// - Any `Unknown` side poisons the session permanently.
+ /// - Two `Exact` values are summed via `checked_exact_sum`; overflow → `Unknown`.
+ ///
+ /// The checked-add logic lives in `checked_exact_sum`; both this function and
+ /// `fold()` call that helper so overflow semantics are defined once.
+ pub fn merge_session(self, turn: TurnTotalState) -> TurnTotalState {
+ match (self, turn) {
+ // Either side poisoned → session is poisoned.
+ (TurnTotalState::Unknown, _) | (_, TurnTotalState::Unknown) => TurnTotalState::Unknown,
+ // Turn had no usage-bearing responses → no change to cumulative.
+ (acc, TurnTotalState::Unseen) => acc,
+ // First exact turn — adopt its value.
+ (TurnTotalState::Unseen, TurnTotalState::Exact(n)) => TurnTotalState::Exact(n),
+ // Add to running exact sum — delegate to the shared checked-sum helper.
+ (TurnTotalState::Exact(acc), TurnTotalState::Exact(n)) => {
+ Self::checked_exact_sum(acc, n)
+ }
+ }
+ }
+
+ /// Consume the exact value if present; `None` for `Unseen` or `Unknown`.
+ pub fn exact_value(self) -> Option {
+ match self {
+ TurnTotalState::Exact(n) => Some(n),
+ _ => None,
+ }
+ }
+}
+
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum StopReason {
EndTurn,
@@ -342,6 +480,41 @@ mod tests {
assert!(item.estimated_bytes() >= 3_118_884);
}
+ #[test]
+ fn assistant_size_counts_provider_extra() {
+ // A Gemini `thoughtSignature` rides the wire on every replayed call, so
+ // both size measures must see it — otherwise `truncate_history` and the
+ // handoff gate under-count and let the real request exceed the budget.
+ let mut extra = Map::new();
+ extra.insert("thoughtSignature".into(), Value::String("S".repeat(500)));
+ let with_extra = HistoryItem::Assistant {
+ text: String::new(),
+ tool_calls: vec![ToolCall {
+ provider_id: "id".into(),
+ name: "t".into(),
+ arguments: Value::Null,
+ provider_extra: extra,
+ }],
+ reasoning_details: None,
+ };
+ let without_extra = HistoryItem::Assistant {
+ text: String::new(),
+ tool_calls: vec![ToolCall {
+ provider_id: "id".into(),
+ name: "t".into(),
+ arguments: Value::Null,
+ provider_extra: Map::new(),
+ }],
+ reasoning_details: None,
+ };
+ assert!(with_extra.estimated_bytes() > without_extra.estimated_bytes() + 500);
+ assert_eq!(
+ with_extra.estimated_bytes(),
+ with_extra.context_pressure_bytes(),
+ "provider_extra is text, so both measures must agree"
+ );
+ }
+
#[test]
fn text_content_size_is_identical_for_both_measures() {
// Only images diverge; text must size the same under both paths.
@@ -351,3 +524,138 @@ mod tests {
assert_eq!(item.estimated_bytes(), item.context_pressure_bytes());
}
}
+
+#[cfg(test)]
+mod turn_total_state_tests {
+ use super::TurnTotalState;
+
+ // ── TurnTotalState::fold ───────────────────────────────────────────────
+
+ #[test]
+ fn fold_first_response_with_total_becomes_exact() {
+ let state = TurnTotalState::Unseen;
+ assert_eq!(state.fold(Some(100)), TurnTotalState::Exact(100));
+ }
+
+ #[test]
+ fn fold_first_response_without_total_becomes_unknown() {
+ // Missing total on any usage-bearing response poisons the turn.
+ let state = TurnTotalState::Unseen;
+ assert_eq!(state.fold(None), TurnTotalState::Unknown);
+ }
+
+ #[test]
+ fn multiple_provider_rounds_all_with_totals_sum_correctly() {
+ // Multiple rounds all reporting a genuine total → Exact with their sum.
+ let state = TurnTotalState::Unseen;
+ let state = state.fold(Some(100));
+ let state = state.fold(Some(50));
+ let state = state.fold(Some(75));
+ assert_eq!(state, TurnTotalState::Exact(225));
+ }
+
+ #[test]
+ fn mixed_present_and_missing_totals_within_one_turn_poisons_accumulator() {
+ // First round has a total, second does not → Unknown (permanently poisoned).
+ let state = TurnTotalState::Unseen;
+ let state = state.fold(Some(100)); // Exact(100)
+ let state = state.fold(None); // Missing → Unknown
+ assert_eq!(state, TurnTotalState::Unknown);
+ // Further rounds with totals don't un-poison.
+ let state = state.fold(Some(50));
+ assert_eq!(state, TurnTotalState::Unknown);
+ }
+
+ #[test]
+ fn unknown_stays_unknown_regardless_of_subsequent_totals() {
+ // Once poisoned, no subsequent total can recover the state.
+ let state = TurnTotalState::Unknown;
+ assert_eq!(state.fold(Some(999)), TurnTotalState::Unknown);
+ assert_eq!(state.fold(None), TurnTotalState::Unknown);
+ }
+
+ #[test]
+ fn exact_value_returns_some_only_for_exact_variant() {
+ assert_eq!(TurnTotalState::Unseen.exact_value(), None);
+ assert_eq!(TurnTotalState::Unknown.exact_value(), None);
+ assert_eq!(TurnTotalState::Exact(42).exact_value(), Some(42));
+ }
+
+ #[test]
+ fn default_is_unseen() {
+ let state: TurnTotalState = Default::default();
+ assert_eq!(state, TurnTotalState::Unseen);
+ }
+
+ // ── overflow: fold ─────────────────────────────────────────────────────
+
+ #[test]
+ fn fold_overflow_poisons_turn_not_saturates() {
+ // u64::MAX + 1 would saturate; checked_add must poison instead.
+ let state = TurnTotalState::Exact(u64::MAX);
+ assert_eq!(
+ state.fold(Some(1)),
+ TurnTotalState::Unknown,
+ "overflow in fold() must produce Unknown, not Exact(u64::MAX)"
+ );
+ }
+
+ // ── TurnTotalState::merge_session ──────────────────────────────────────
+
+ #[test]
+ fn merge_session_unseen_turn_leaves_cumulative_unchanged() {
+ // An Unseen turn (no usage-bearing responses) must not alter the cumulative.
+ assert_eq!(
+ TurnTotalState::Exact(100).merge_session(TurnTotalState::Unseen),
+ TurnTotalState::Exact(100),
+ );
+ assert_eq!(
+ TurnTotalState::Unseen.merge_session(TurnTotalState::Unseen),
+ TurnTotalState::Unseen,
+ );
+ }
+
+ #[test]
+ fn merge_session_exact_turn_adds_to_exact_cumulative() {
+ assert_eq!(
+ TurnTotalState::Exact(100).merge_session(TurnTotalState::Exact(50)),
+ TurnTotalState::Exact(150),
+ );
+ }
+
+ #[test]
+ fn merge_session_first_exact_turn_from_unseen_adopts_value() {
+ assert_eq!(
+ TurnTotalState::Unseen.merge_session(TurnTotalState::Exact(200)),
+ TurnTotalState::Exact(200),
+ );
+ }
+
+ #[test]
+ fn merge_session_unknown_turn_poisons_cumulative_permanently() {
+ assert_eq!(
+ TurnTotalState::Exact(100).merge_session(TurnTotalState::Unknown),
+ TurnTotalState::Unknown,
+ );
+ // Poisoned session stays poisoned even with Unseen turn.
+ assert_eq!(
+ TurnTotalState::Unknown.merge_session(TurnTotalState::Unseen),
+ TurnTotalState::Unknown,
+ );
+ // Poisoned session stays poisoned even with another Exact turn.
+ assert_eq!(
+ TurnTotalState::Unknown.merge_session(TurnTotalState::Exact(999)),
+ TurnTotalState::Unknown,
+ );
+ }
+
+ #[test]
+ fn merge_session_overflow_poisons_not_saturates() {
+ // Overflow at the session boundary must also produce Unknown.
+ assert_eq!(
+ TurnTotalState::Exact(u64::MAX).merge_session(TurnTotalState::Exact(1)),
+ TurnTotalState::Unknown,
+ "overflow in merge_session() must produce Unknown, not Exact(u64::MAX)"
+ );
+ }
+}
diff --git a/crates/buzz-agent/tests/bin/fake_mcp.rs b/crates/buzz-agent/tests/bin/fake_mcp.rs
index 0bbd1d3478..5b660da48c 100644
--- a/crates/buzz-agent/tests/bin/fake_mcp.rs
+++ b/crates/buzz-agent/tests/bin/fake_mcp.rs
@@ -33,6 +33,11 @@
//! — expose a `_PostCompact` hook tool
//! FAKE_MCP_POSTCOMPACT_TEXT=text
//! — `_PostCompact` returns this (default: "")
+//! FAKE_MCP_SHELL_TOOL=1 — expose a tool whose bare name is `shell`
+//! (registered as `__shell`), taking a
+//! `command` string. Lets a test drive the
+//! reply guard's recognition of a real,
+//! registered shell tool.
use std::io::{BufRead, Write};
@@ -76,6 +81,7 @@ fn make_tools(
desc: &str,
include_stop_hook: bool,
include_post_compact_hook: bool,
+ include_shell_tool: bool,
) -> Vec {
let mut tools: Vec = (0..count)
.map(|i| {
@@ -100,6 +106,17 @@ fn make_tools(
"inputSchema": { "type": "object", "properties": {} },
}));
}
+ if include_shell_tool {
+ tools.push(json!({
+ "name": "shell",
+ "description": "run a shell command",
+ "inputSchema": {
+ "type": "object",
+ "properties": { "command": { "type": "string" } },
+ "required": ["command"],
+ },
+ }));
+ }
tools
}
@@ -136,6 +153,7 @@ fn main() {
let stop_count_limit: usize = env_usize("FAKE_MCP_STOP_COUNT", usize::MAX);
let mut stop_calls_seen: usize = 0;
let post_compact_hook = env_flag("FAKE_MCP_POSTCOMPACT_HOOK");
+ let shell_tool = env_flag("FAKE_MCP_SHELL_TOOL");
let post_compact_text = std::env::var("FAKE_MCP_POSTCOMPACT_TEXT").unwrap_or_default();
// Use a channel-based stdin reader so notifications (which carry no id)
@@ -206,7 +224,13 @@ fn main() {
write_response(
id,
json!({
- "tools": make_tools(tool_count, &desc, stop_hook, post_compact_hook)
+ "tools": make_tools(
+ tool_count,
+ &desc,
+ stop_hook,
+ post_compact_hook,
+ shell_tool,
+ )
}),
);
}
diff --git a/crates/buzz-agent/tests/fake_llm.rs b/crates/buzz-agent/tests/fake_llm.rs
index f2a86ac4b3..f782a9d476 100644
--- a/crates/buzz-agent/tests/fake_llm.rs
+++ b/crates/buzz-agent/tests/fake_llm.rs
@@ -771,6 +771,26 @@ fn openai_text_with_usage(content: &str, input_tokens: u64, output_tokens: u64)
})
}
+/// An OpenAI chat completion response WITH i/o usage but WITHOUT `total_tokens`.
+/// Simulates a provider that omits the genuine total from its usage block.
+/// buzz-agent must treat this turn's total as Unknown and poison the cumulative.
+fn openai_text_with_usage_no_total(content: &str, input_tokens: u64, output_tokens: u64) -> Value {
+ json!({
+ "id": "cc-nt", "object": "chat.completion", "model": "fake-model",
+ "choices": [{
+ "index": 0,
+ "message": { "role": "assistant", "content": content },
+ "finish_reason": "stop",
+ }],
+ "usage": {
+ "prompt_tokens": input_tokens,
+ "completion_tokens": output_tokens,
+ // total_tokens deliberately absent — simulates Anthropic or any
+ // provider that does not report a genuine total.
+ },
+ })
+}
+
/// Returns true when `v` is a `_goose/unstable/session/update` usage_update
/// notification.
fn is_usage_update(v: &Value) -> bool {
@@ -1123,3 +1143,164 @@ async fn steer_rejected_on_empty_prompt() {
assert!(saw_reject, "empty steer prompt was not rejected");
h.shutdown().await;
}
+
+// ─── Session-boundary total accumulation ────────────────────────────────────
+
+/// Once a usage-bearing turn lacks a provider total, the session cumulative
+/// becomes Unknown and `accumulatedTotalTokens` must be absent from subsequent
+/// `usage_update` notifications — even if later turns supply a total.
+///
+/// Sequence: turn 1 has total, turn 2 lacks total → session poisoned, turn 3
+/// has total → still poisoned. Only turn 1 must carry `accumulatedTotalTokens`.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn session_total_poisoned_by_missing_total_and_stays_poisoned() {
+ let url = spawn_fake_llm(vec![
+ openai_text_with_usage("t1", 10, 5), // total present → Exact(15)
+ openai_text_with_usage_no_total("t2", 20, 8), // total absent → Unknown
+ openai_text_with_usage("t3", 15, 6), // total present → still Unknown
+ ])
+ .await;
+ let mut h = Harness::spawn(&url).await;
+ let sid = init_session(&mut h).await;
+
+ // ── Turn 1: total present ───────────────────────────────────────────────
+ let p1 = h
+ .send(
+ "session/prompt",
+ json!({"sessionId": sid, "prompt": [{"type":"text","text":"t1"}]}),
+ )
+ .await;
+ let (frames1, _) = recv_until_with_drain(&mut h, |v| v["id"] == p1).await;
+ let usage1 = frames1
+ .iter()
+ .find(|v| is_usage_update(v))
+ .expect("usage_update for turn 1");
+ assert_eq!(
+ usage1["params"]["update"]["accumulatedTotalTokens"],
+ json!(15u64),
+ "turn 1 has genuine total; accumulatedTotalTokens must be 15"
+ );
+
+ // ── Turn 2: total absent — session is now poisoned ──────────────────────
+ let p2 = h
+ .send(
+ "session/prompt",
+ json!({"sessionId": sid, "prompt": [{"type":"text","text":"t2"}]}),
+ )
+ .await;
+ let (frames2, _) = recv_until_with_drain(&mut h, |v| v["id"] == p2).await;
+ let usage2 = frames2
+ .iter()
+ .find(|v| is_usage_update(v))
+ .expect("usage_update for turn 2");
+ assert!(
+ usage2["params"]["update"]["accumulatedTotalTokens"].is_null()
+ || usage2["params"]["update"]
+ .get("accumulatedTotalTokens")
+ .is_none(),
+ "turn 2 lacked total; accumulatedTotalTokens must be absent/null; got: {usage2:#?}"
+ );
+
+ // ── Turn 3: total present, but session is still poisoned ─────────────────
+ let p3 = h
+ .send(
+ "session/prompt",
+ json!({"sessionId": sid, "prompt": [{"type":"text","text":"t3"}]}),
+ )
+ .await;
+ let (frames3, _) = recv_until_with_drain(&mut h, |v| v["id"] == p3).await;
+ let usage3 = frames3
+ .iter()
+ .find(|v| is_usage_update(v))
+ .expect("usage_update for turn 3");
+ assert!(
+ usage3["params"]["update"]["accumulatedTotalTokens"].is_null()
+ || usage3["params"]["update"].get("accumulatedTotalTokens").is_none(),
+ "session is poisoned; accumulatedTotalTokens must remain absent even after a total-bearing turn; got: {usage3:#?}"
+ );
+
+ // i/o counters are unaffected by total poisoning.
+ assert_eq!(
+ usage3["params"]["update"]["accumulatedInputTokens"],
+ json!(45u64),
+ "poisoned total must not discard input accumulation"
+ );
+ assert_eq!(
+ usage3["params"]["update"]["accumulatedOutputTokens"],
+ json!(19u64),
+ "poisoned total must not discard output accumulation"
+ );
+
+ h.shutdown().await;
+}
+
+/// A new session starts fresh and can accumulate an exact total independently
+/// of any previous session. This verifies `accumulated_total_state` is reset
+/// to `Unseen` on `session/new`, not inherited from a prior session.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn new_session_resets_total_accumulation() {
+ // Session A: two turns both with totals → Exact should accumulate.
+ // Session B (new session/new call): starts fresh.
+ let url = spawn_fake_llm(vec![
+ // Session A, turn 1
+ openai_text_with_usage("s1t1", 10, 5),
+ // Session A, turn 2
+ openai_text_with_usage("s1t2", 20, 8),
+ // Session B, turn 1
+ openai_text_with_usage("s2t1", 30, 10),
+ ])
+ .await;
+ let mut h = Harness::spawn(&url).await;
+ let sid_a = init_session(&mut h).await;
+
+ // Session A, turn 1
+ let p1 = h
+ .send(
+ "session/prompt",
+ json!({"sessionId": sid_a, "prompt": [{"type":"text","text":"s1t1"}]}),
+ )
+ .await;
+ let (frames1, _) = recv_until_with_drain(&mut h, |v| v["id"] == p1).await;
+ let u1 = frames1.iter().find(|v| is_usage_update(v)).expect("usage1");
+ assert_eq!(
+ u1["params"]["update"]["accumulatedTotalTokens"],
+ json!(15u64),
+ "session A turn 1 accumulated total"
+ );
+
+ // Session A, turn 2 — cumulative total is 15+28=43
+ let p2 = h
+ .send(
+ "session/prompt",
+ json!({"sessionId": sid_a, "prompt": [{"type":"text","text":"s1t2"}]}),
+ )
+ .await;
+ let (frames2, _) = recv_until_with_drain(&mut h, |v| v["id"] == p2).await;
+ let u2 = frames2.iter().find(|v| is_usage_update(v)).expect("usage2");
+ assert_eq!(
+ u2["params"]["update"]["accumulatedTotalTokens"],
+ json!(43u64),
+ "session A turn 2 cumulative total must be 15+28=43"
+ );
+
+ // Start a new session — must reset accumulated_total_state to Unseen.
+ let sid_b = init_session(&mut h).await;
+ assert_ne!(sid_a, sid_b, "sessions must have distinct IDs");
+
+ // Session B, turn 1 — total 30+10=40. Must NOT start from 43.
+ let p3 = h
+ .send(
+ "session/prompt",
+ json!({"sessionId": sid_b, "prompt": [{"type":"text","text":"s2t1"}]}),
+ )
+ .await;
+ let (frames3, _) = recv_until_with_drain(&mut h, |v| v["id"] == p3).await;
+ let u3 = frames3.iter().find(|v| is_usage_update(v)).expect("usage3");
+ assert_eq!(
+ u3["params"]["update"]["accumulatedTotalTokens"],
+ json!(40u64),
+ "new session must start fresh — accumulated total must be 40, not 83"
+ );
+
+ h.shutdown().await;
+}
diff --git a/crates/buzz-agent/tests/regressions.rs b/crates/buzz-agent/tests/regressions.rs
index 2e0b579c84..abb4f7b311 100644
--- a/crates/buzz-agent/tests/regressions.rs
+++ b/crates/buzz-agent/tests/regressions.rs
@@ -1819,3 +1819,465 @@ async fn cancel_sends_notifications_cancelled_to_any_mcp_server() {
let _ = std::fs::remove_file(&call_received_marker);
h.shutdown().await;
}
+
+// ---------------------------------------------------------------------------
+// Reply guard (`BUZZ_AGENT_REQUIRE_REPLY`)
+//
+// The guard reminds the model to publish when a turn is about to end without
+// any recognized attempt to post to Buzz. It rides the existing `_Stop` gate
+// and shares its rejection budget, so most of these tests count LLM calls:
+// each reminder costs exactly one extra round.
+// ---------------------------------------------------------------------------
+
+/// Number of reply-guard reminders present in one captured LLM request.
+///
+/// A reminder is a tool-role message whose JSON body is attributed to the
+/// in-process guard (`server: "buzz-agent"`) at the `_Stop` hook point — the
+/// same lower-trust shape as real hook output.
+fn reply_nag_count(request: &Value) -> usize {
+ request["messages"]
+ .as_array()
+ .map(|msgs| {
+ msgs.iter()
+ .filter(|m| {
+ m["role"] == "tool"
+ && serde_json::from_str::(m["content"].as_str().unwrap_or(""))
+ .map(|p| p["hook"] == "_Stop" && p["server"] == "buzz-agent")
+ .unwrap_or(false)
+ })
+ .count()
+ })
+ .unwrap_or(0)
+}
+
+/// A publish-shaped call to a real registered shell tool.
+fn openai_shell_send(id: &str) -> Value {
+ openai_tool_call(
+ id,
+ "fake__shell",
+ json!({ "command": "buzz messages send --channel c --content hi" }),
+ )
+}
+
+/// Run one prompt to completion, answering any permission requests, and
+/// return the final response.
+async fn prompt_to_completion(h: &mut Harness, sid: &str) -> Value {
+ let p = h
+ .send(
+ "session/prompt",
+ json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
+ )
+ .await;
+ loop {
+ let v = h.recv().await;
+ if v.get("method") == Some(&json!("session/request_permission")) {
+ let id = v["id"].clone();
+ h.write(json!({
+ "jsonrpc": "2.0",
+ "id": id,
+ "result": { "outcome": { "outcome": "selected", "optionId": "allow" } },
+ }))
+ .await;
+ continue;
+ }
+ if v["id"] == json!(p) {
+ return v;
+ }
+ }
+}
+
+/// Default off: a silent turn ends on the first end_turn with no extra round.
+/// This is the invariant that keeps the feature free for everyone who hasn't
+/// opted in.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn reply_guard_off_by_default() {
+ let llm = spawn_capturing_llm(vec![openai_text("done"), openai_text("unexpected")]).await;
+ let mut h = Harness::spawn(&llm.url).await;
+ let sid = init_session(&mut h, json!([])).await;
+
+ let r = prompt_to_completion(&mut h, &sid).await;
+ assert_eq!(r["result"]["stopReason"], "end_turn");
+
+ let captured = llm.captured.lock().await;
+ assert_eq!(
+ captured.len(),
+ 1,
+ "guard must be inert when unset, got {} LLM calls",
+ captured.len()
+ );
+ h.shutdown().await;
+}
+
+/// `BUZZ_AGENT_REQUIRE_REPLY=0` is off too — the toggle is numeric, so a
+/// literal `0` must not read as "set, therefore on".
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn reply_guard_explicit_zero_is_off() {
+ let llm = spawn_capturing_llm(vec![openai_text("done"), openai_text("unexpected")]).await;
+ let mut h = Harness::spawn_with_env(&llm.url, &[("BUZZ_AGENT_REQUIRE_REPLY", "0")]).await;
+ let sid = init_session(&mut h, json!([])).await;
+
+ let r = prompt_to_completion(&mut h, &sid).await;
+ assert_eq!(r["result"]["stopReason"], "end_turn");
+
+ let captured = llm.captured.lock().await;
+ assert_eq!(
+ captured.len(),
+ 1,
+ "REQUIRE_REPLY=0 must behave as off, got {} LLM calls",
+ captured.len()
+ );
+ h.shutdown().await;
+}
+
+/// Opted in and silent: exactly two reminders, then the turn is allowed to
+/// end. The guard is advisory — it must never trap a turn.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn reply_guard_nags_twice_then_lets_the_turn_end() {
+ // Budget defaults to 3, so the cap that stops the loop here is
+ // MAX_REPLY_NAGS = 2, not the rejection budget.
+ let llm = spawn_capturing_llm(vec![
+ openai_text("silent-1"),
+ openai_text("silent-2"),
+ openai_text("silent-3"),
+ openai_text("must-not-be-requested"),
+ ])
+ .await;
+ let mut h = Harness::spawn_with_env(&llm.url, &[("BUZZ_AGENT_REQUIRE_REPLY", "1")]).await;
+ let sid = init_session(&mut h, json!([])).await;
+
+ let r = prompt_to_completion(&mut h, &sid).await;
+ assert_eq!(r["result"]["stopReason"], "end_turn");
+
+ let captured = llm.captured.lock().await;
+ assert_eq!(
+ captured.len(),
+ 3,
+ "expected 2 reminders then end_turn (3 LLM calls), got {}",
+ captured.len()
+ );
+ assert_eq!(
+ reply_nag_count(&captured[0]),
+ 0,
+ "reminder before any end_turn"
+ );
+ assert_eq!(reply_nag_count(&captured[1]), 1);
+ assert_eq!(reply_nag_count(&captured[2]), 2);
+
+ // The reminder must name the command it wants and license silence, so it
+ // cannot fight the base prompt's "silence is usually correct".
+ let msgs = captured[2]["messages"].as_array().unwrap();
+ let nag = msgs
+ .iter()
+ .filter_map(|m| serde_json::from_str::(m["content"].as_str().unwrap_or("")).ok())
+ .find(|p| p["server"] == "buzz-agent")
+ .expect("reminder body");
+ let text = nag["text"].as_str().unwrap_or("");
+ assert!(
+ text.contains("buzz messages send"),
+ "reminder should name the command: {text}"
+ );
+ assert!(
+ text.contains("silence is genuinely correct"),
+ "reminder must license silence: {text}"
+ );
+ h.shutdown().await;
+}
+
+/// A real publish attempt through a registered shell tool satisfies the guard:
+/// no reminder, no extra round.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn reply_guard_satisfied_by_registered_shell_send() {
+ let llm = spawn_capturing_llm(vec![
+ openai_shell_send("tc1"),
+ openai_text("posted"),
+ openai_text("must-not-be-requested"),
+ ])
+ .await;
+ let mut h = Harness::spawn_with_env(&llm.url, &[("BUZZ_AGENT_REQUIRE_REPLY", "1")]).await;
+ let sid = init_session_with_fake_mcp(
+ &mut h,
+ &[("FAKE_MCP_TOOL_COUNT", "1"), ("FAKE_MCP_SHELL_TOOL", "1")],
+ )
+ .await;
+
+ let r = prompt_to_completion(&mut h, &sid).await;
+ assert_eq!(r["result"]["stopReason"], "end_turn");
+
+ let captured = llm.captured.lock().await;
+ assert_eq!(
+ captured.len(),
+ 2,
+ "a recognized send must not be nagged, got {} LLM calls",
+ captured.len()
+ );
+ assert_eq!(reply_nag_count(&captured[1]), 0);
+ h.shutdown().await;
+}
+
+/// A publish-shaped call to a shell tool that is *not registered* never runs —
+/// preflight rejects it — so it must not disarm the guard. This is what the
+/// `has`/`is_hook` checks in the predicate buy.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn reply_guard_ignores_unregistered_shell_tool() {
+ // FAKE_MCP_SHELL_TOOL is absent, so `fake__shell` is a hallucination.
+ let llm = spawn_capturing_llm(vec![
+ openai_shell_send("tc1"),
+ openai_text("silent-1"),
+ openai_text("silent-2"),
+ ])
+ .await;
+ let mut h = Harness::spawn_with_env(
+ &llm.url,
+ &[
+ ("BUZZ_AGENT_REQUIRE_REPLY", "1"),
+ ("BUZZ_AGENT_STOP_MAX_REJECTIONS", "1"),
+ ],
+ )
+ .await;
+ let sid = init_session_with_fake_mcp(&mut h, &[("FAKE_MCP_TOOL_COUNT", "1")]).await;
+
+ let r = prompt_to_completion(&mut h, &sid).await;
+ assert_eq!(r["result"]["stopReason"], "end_turn");
+
+ let captured = llm.captured.lock().await;
+ assert_eq!(
+ captured.len(),
+ 3,
+ "expected the hallucinated call to still be nagged, got {} LLM calls",
+ captured.len()
+ );
+ let msgs = captured[1]["messages"].as_array().unwrap();
+ assert!(
+ msgs.iter()
+ .any(|m| m["role"] == "tool"
+ && m["content"].as_str().unwrap_or("").contains("unknown tool")),
+ "expected preflight to reject the call: {msgs:?}"
+ );
+ assert_eq!(reply_nag_count(&captured[2]), 1);
+ h.shutdown().await;
+}
+
+/// A publish-shaped call discarded by the per-turn tool-call cap never runs,
+/// so it must not suppress the reminder either. Pins the check's placement
+/// after `calls.truncate(MAX_TOOL_CALLS_PER_TURN)`.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn reply_guard_ignores_calls_lost_to_the_turn_cap() {
+ // 64 filler calls (the cap) followed by the publish attempt, which is
+ // therefore truncated away. The shell tool *is* registered here, so only
+ // the placement — not tool identity — can explain the reminder.
+ let mut calls: Vec = (0..64)
+ .map(|i| {
+ json!({
+ "id": format!("c{i}"),
+ "type": "function",
+ "function": { "name": "fake__tool_0", "arguments": "{}" },
+ })
+ })
+ .collect();
+ calls.push(json!({
+ "id": "c-send",
+ "type": "function",
+ "function": {
+ "name": "fake__shell",
+ "arguments": json!({ "command": "buzz messages send --channel c --content hi" })
+ .to_string(),
+ },
+ }));
+ let truncated_send = json!({
+ "id": "cc-trunc", "object": "chat.completion", "model": "fake-model",
+ "choices": [{
+ "index": 0,
+ "message": { "role": "assistant", "content": null, "tool_calls": calls },
+ "finish_reason": "tool_calls",
+ }],
+ });
+ let llm = spawn_capturing_llm(vec![
+ truncated_send,
+ openai_text("silent-1"),
+ openai_text("silent-2"),
+ ])
+ .await;
+ let mut h = Harness::spawn_with_env(
+ &llm.url,
+ &[
+ ("BUZZ_AGENT_REQUIRE_REPLY", "1"),
+ ("BUZZ_AGENT_STOP_MAX_REJECTIONS", "1"),
+ ],
+ )
+ .await;
+ let sid = init_session_with_fake_mcp(
+ &mut h,
+ &[("FAKE_MCP_TOOL_COUNT", "1"), ("FAKE_MCP_SHELL_TOOL", "1")],
+ )
+ .await;
+
+ let r = prompt_to_completion(&mut h, &sid).await;
+ assert_eq!(r["result"]["stopReason"], "end_turn");
+
+ let captured = llm.captured.lock().await;
+ assert_eq!(
+ captured.len(),
+ 3,
+ "a truncated send must still be nagged, got {} LLM calls",
+ captured.len()
+ );
+ assert_eq!(reply_nag_count(&captured[2]), 1);
+ h.shutdown().await;
+}
+
+/// The shared `_Stop` rejection budget is the outer cap: at 1 the guard gets
+/// one reminder instead of two. Documented degradation, not a bug.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn reply_guard_bounded_by_stop_rejection_budget() {
+ let llm = spawn_capturing_llm(vec![
+ openai_text("silent-1"),
+ openai_text("silent-2"),
+ openai_text("must-not-be-requested"),
+ ])
+ .await;
+ let mut h = Harness::spawn_with_env(
+ &llm.url,
+ &[
+ ("BUZZ_AGENT_REQUIRE_REPLY", "1"),
+ ("BUZZ_AGENT_STOP_MAX_REJECTIONS", "1"),
+ ],
+ )
+ .await;
+ let sid = init_session(&mut h, json!([])).await;
+
+ let r = prompt_to_completion(&mut h, &sid).await;
+ assert_eq!(r["result"]["stopReason"], "end_turn");
+
+ let captured = llm.captured.lock().await;
+ assert_eq!(
+ captured.len(),
+ 2,
+ "budget 1 must allow exactly one reminder, got {} LLM calls",
+ captured.len()
+ );
+ assert_eq!(reply_nag_count(&captured[1]), 1);
+ h.shutdown().await;
+}
+
+/// Budget 0 disables every objection at the gate, including this one.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn reply_guard_off_when_stop_budget_is_zero() {
+ let llm = spawn_capturing_llm(vec![openai_text("done"), openai_text("unexpected")]).await;
+ let mut h = Harness::spawn_with_env(
+ &llm.url,
+ &[
+ ("BUZZ_AGENT_REQUIRE_REPLY", "1"),
+ ("BUZZ_AGENT_STOP_MAX_REJECTIONS", "0"),
+ ],
+ )
+ .await;
+ let sid = init_session(&mut h, json!([])).await;
+
+ let r = prompt_to_completion(&mut h, &sid).await;
+ assert_eq!(r["result"]["stopReason"], "end_turn");
+
+ let captured = llm.captured.lock().await;
+ assert_eq!(
+ captured.len(),
+ 1,
+ "budget 0 must disable the guard, got {} LLM calls",
+ captured.len()
+ );
+ h.shutdown().await;
+}
+
+/// The two axes are independent inside one shared budget: a round carrying
+/// both a `_Stop` hook objection and a reminder costs one rejection and
+/// delivers both texts, and once the reminders are spent the hook objection
+/// continues alone.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn reply_guard_combines_with_stop_hook_objection() {
+ // The hook objects on its first 3 calls, then clears. Reminders stop
+ // after 2, so round 3 must carry the hook text and no new reminder.
+ let llm = spawn_capturing_llm(vec![
+ openai_text("silent-1"),
+ openai_text("silent-2"),
+ openai_text("silent-3"),
+ openai_text("silent-4"),
+ ])
+ .await;
+ let mut h = Harness::spawn_with_env(
+ &llm.url,
+ &[
+ ("BUZZ_AGENT_REQUIRE_REPLY", "1"),
+ ("MCP_HOOK_SERVERS", "fake"),
+ ("BUZZ_AGENT_STOP_MAX_REJECTIONS", "10"),
+ ],
+ )
+ .await;
+ let sid = init_session_with_fake_mcp(
+ &mut h,
+ &[
+ ("FAKE_MCP_TOOL_COUNT", "1"),
+ ("FAKE_MCP_STOP_HOOK", "1"),
+ ("FAKE_MCP_STOP_TEXT", "you have open todos"),
+ ("FAKE_MCP_STOP_COUNT", "3"),
+ ],
+ )
+ .await;
+
+ let r = prompt_to_completion(&mut h, &sid).await;
+ assert_eq!(r["result"]["stopReason"], "end_turn");
+
+ let captured = llm.captured.lock().await;
+ assert_eq!(
+ captured.len(),
+ 4,
+ "expected 3 objecting rounds then a clear end, got {}",
+ captured.len()
+ );
+
+ let hook_objections = |req: &Value| -> usize {
+ req["messages"]
+ .as_array()
+ .map(|msgs| {
+ msgs.iter()
+ .filter(|m| {
+ m["content"]
+ .as_str()
+ .unwrap_or("")
+ .contains("you have open todos")
+ })
+ .count()
+ })
+ .unwrap_or(0)
+ };
+
+ // Round 2 carries one of each — a single rejection bought both texts.
+ assert_eq!(reply_nag_count(&captured[1]), 1);
+ assert_eq!(hook_objections(&captured[1]), 1);
+ // Round 4: the hook objected three times, the guard only twice.
+ assert_eq!(reply_nag_count(&captured[3]), 2);
+ assert_eq!(hook_objections(&captured[3]), 3);
+ h.shutdown().await;
+}
+
+/// An unparseable toggle is a startup error, not a silent default. `parse_env`
+/// is generic over `FromStr`, so this also pins the numeric type: a `bool`
+/// field would have rejected the documented `1`.
+#[test]
+fn reply_guard_rejects_unparseable_toggle() {
+ let out = std::process::Command::new(env!("CARGO_BIN_EXE_buzz-agent"))
+ .env("BUZZ_AGENT_PROVIDER", "openai")
+ .env("OPENAI_COMPAT_API_KEY", "test")
+ .env("OPENAI_COMPAT_MODEL", "fake-model")
+ .env("BUZZ_AGENT_REQUIRE_REPLY", "true")
+ .stdin(Stdio::null())
+ .output()
+ .expect("run buzz-agent");
+ assert!(
+ !out.status.success(),
+ "expected a config error exit, got {:?}",
+ out.status
+ );
+ let stderr = String::from_utf8_lossy(&out.stderr);
+ assert!(
+ stderr.contains("BUZZ_AGENT_REQUIRE_REPLY"),
+ "expected the offending key in the error, got: {stderr}"
+ );
+}
diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md
index a8c668cf06..a2dcdce6d2 100644
--- a/crates/buzz-cli/README.md
+++ b/crates/buzz-cli/README.md
@@ -56,7 +56,10 @@ buzz reactions get --event
buzz users get # your own profile
buzz users get --pubkey # single user
buzz users get --pubkey --pubkey # batch (max 200)
+buzz users get --name Honey --owner me # exact-name lookup in your managed agents
buzz users set-presence --status online
+buzz users set-status --text "heads down on the CLI" --emoji "🚀"
+buzz users set-status --clear # remove your status
# DMs
buzz dms open --pubkey
@@ -133,6 +136,7 @@ stored rules in `validation_error` so an owner can remove and repair them.
| | `set-profile` | Update your profile |
| | `presence` | Get presence status |
| | `set-presence` | Set presence status |
+| | `set-status` | Set or clear your NIP-38 profile status |
| `workflows` | `list` | List workflows |
| | `get` | Get workflow definition |
| | `create` | Create a workflow |
diff --git a/crates/buzz-cli/TESTING.md b/crates/buzz-cli/TESTING.md
index 4b7257aba7..77234b7faa 100644
--- a/crates/buzz-cli/TESTING.md
+++ b/crates/buzz-cli/TESTING.md
@@ -87,7 +87,7 @@ export BUZZ_PRIVATE_KEY="nsec1..." # from the mint output
| `channels:read` | ✅ | `channels list`, `channels get`, `channels members` |
| `channels:write` | ✅ | `channels create`, `channels update`, `channels join`, `channels leave`, `channels topic`, `channels purpose` |
| `users:read` | ✅ | `users get`, `users presence` |
-| `users:write` | ✅ | `users set-profile`, `users set-presence` |
+| `users:write` | ✅ | `users set-profile`, `users set-presence`, `users set-status` |
| `files:read` | ✅ | — |
| `files:write` | ✅ | — |
| `admin:channels` | ❌ | `channels archive`, `channels unarchive`, `channels delete`, `channels add-member`, `channels remove-member` |
@@ -331,6 +331,20 @@ buzz users set-presence --status online | jq .
buzz users set-presence --status away | jq .
buzz users set-presence --status offline | jq .
# Note: set-presence may fail — kind:20001 is ephemeral and rejected by the HTTP bridge
+
+# users set-status — NIP-38 kind:30315 on the d:general coordinate
+buzz users set-status --text "reviewing PRs" --emoji "🔍" | jq .
+buzz users set-status --text "no emoji this time" | jq .
+
+# users set-status — emoji-only status (intentional: text is blank, emoji is kept)
+buzz users set-status --text "" --emoji "🎶" | jq .
+
+# users set-status --clear — removes the status (empty content, d:general only)
+buzz users set-status --clear | jq .
+
+# --clear is mutually exclusive with --text/--emoji
+buzz users set-status --clear --text "nope" 2>&1; echo "exit: $?"
+# Expected: exit 1 — clap conflict error
```
### 6.8 Channel Members (add/remove require admin:channels)
@@ -606,3 +620,4 @@ buzz channels delete --channel "$FORUM_ID" | jq .
| 59 | `notes get` | ☐ | By name, by naddr, --content-only, cross-author, ambiguous → exit 1 |
| 60 | `notes ls` | ☐ | Own, --author all, --tag, --limit |
| 61 | `notes rm` | ☐ | Delete→get 404, double-delete idempotent, missing slug → NotFound |
+| 62 | `users set-status` | ☐ | Text+emoji, text only, emoji-only (`--text ""`), `--clear`, `--clear` + `--text` → exit 1 |
diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs
index 290cc59fa8..40a9ae80b5 100644
--- a/crates/buzz-cli/src/commands/messages.rs
+++ b/crates/buzz-cli/src/commands/messages.rs
@@ -9,8 +9,7 @@ use crate::validate::{
validate_content_size, validate_hex64, validate_uuid, MAX_DIFF_BYTES,
};
use buzz_sdk::mentions::{
- extract_at_mentions_with_known, extract_nostr_uris, merge_mentions, strip_code_regions,
- MENTION_CAP,
+ extract_at_mentions_with_known, extract_nostr_uris, strip_code_regions, MENTION_CAP,
};
/// Extract the thread root event ID from a Nostr tag array.
@@ -119,47 +118,82 @@ async fn resolve_channel_id(client: &BuzzClient, event_id: &str) -> Result>,
+ has_explicit_mentions: bool,
+) -> Result