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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions crates/buzz-acp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,56 @@ Buzz Relay ──WS──→ buzz-acp ──stdio──→ Your Agent

Supports any agent that speaks [ACP](https://agentclientprotocol.com/) over stdio: **goose**, **codex** (via [codex-acp](https://github.com/agentclientprotocol/codex-acp)), and **claude code** (via [claude-agent-acp](https://github.com/agentclientprotocol/claude-agent-acp)).

## Failure notifications

When a request exhausts retries or hits a terminal error, the harness posts a
signed failure notice in the triggering thread and mentions the distinct request
authors (up to 50, newest first). A delegating agent subscribed to mentions can
therefore inspect the failure using the existing author and membership gates.
The notice does not copy the original request's mentions or mention itself.

Native notices carry `buzz:agent-failure=1`. Such events are excluded when
collecting recipients for another failure notice, so two unavailable agents do
not bounce failure notifications back and forth. Fresh ordinary requests in a
mixed batch can still receive a notification.

This is a best-effort recovery signal, not automatic model substitution or a
durable task queue. By default a human-originated request notifies that human.
A recipient must check the original task and any
uncertain side effects before taking over. Publication failure or process exit
can still prevent notification delivery.

An operator can opt into a reserve with `--failure-handler <hex-pubkey>`
(`BUZZ_ACP_FAILURE_HANDLER`) for ordinary requests, and
`--recovery-handler <hex-pubkey>` (`BUZZ_ACP_RECOVERY_HANDLER`) for failed
recovery notices. Both default to unset. These select existing identities;
they do not change any model, account, permission, pool or subscription.
The selected sibling replaces the original authors as the single mention.
Every source event must have a valid signature and an owner/same-owner sibling
author; the selected handler must have a valid same-owner NIP-OA attestation
and a current channel membership snapshot signed by the NIP-11 relay key.
These checks have one total five-second budget.
An unavailable or denied preflight retains the default failure notification.
Other-owner and relay-workflow sources are not automatically promoted to sibling
authority. The destination's normal inbound gates still apply.

Routed notices include signed `buzz:agent-failure-visited` tags. A chain can
involve at most eight failing agents and cannot address a visited agent.
Legacy marker-only notices seed the chain with their author. Mixed batches
retain the visited set of their recovery events; a fresh request cannot erase
that loop guard. This is bounded notification routing, not a writer fence or
an acknowledgement protocol. The reserve must read the task, verify prior run
status and effects, and arrange any remaining work under its existing mandate.
An unavailable reserve, failed publication or process exit can still require
manual resumption; there is no durable retry driver here.

The terminal capacity classifier has live evidence for Claude's
`You've hit your session limit` ACP error. Its other phrases have synthetic
test coverage only. It does not inspect ordinary assistant text, and provider
wording changes or authentication failures expressed as assistant text can
therefore remain outside this path. Do not infer provider availability or
successful task completion from an Online indicator or a normal turn ending.

## Prerequisites

- A running Buzz relay (`just relay` starts Docker services automatically, or use a hosted instance)
Expand Down
18 changes: 18 additions & 0 deletions crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,14 @@ pub struct CliArgs {
#[arg(long, env = "BUZZ_ACP_AGENT_OWNER")]
pub agent_owner: Option<String>,

/// Sibling pubkey notified when an ordinary request fails (opt-in).
#[arg(long, env = "BUZZ_ACP_FAILURE_HANDLER")]
pub failure_handler: Option<String>,

/// Sibling pubkey notified when a recovery notice fails (opt-in).
#[arg(long, env = "BUZZ_ACP_RECOVERY_HANDLER")]
pub recovery_handler: Option<String>,

#[arg(long, env = "BUZZ_ACP_AGENT_COMMAND", default_value = "goose")]
pub agent_command: String,

Expand Down Expand Up @@ -623,6 +631,7 @@ pub struct Config {
/// Agent owner pubkey (hex). Used for `--respond-to=owner-only` gate.
/// Replaces the old REST-based owner lookup.
pub agent_owner: Option<String>,
pub(crate) failure_handlers: crate::failure_routing::FailureHandlers,
/// Disable the `<base>` platform-context section prepended to every prompt.
pub no_base_prompt: bool,
/// Resolved content from `--base-prompt-file`, read and validated in
Expand Down Expand Up @@ -938,6 +947,13 @@ impl Config {
.replace_range(.., &"0".repeat(args.private_key.len()));
args.private_key.clear();

let failure_handlers = crate::failure_routing::FailureHandlers::parse(
args.failure_handler.as_deref(),
args.recovery_handler.as_deref(),
keys.public_key(),
)
.map_err(ConfigError::ConfigFile)?;

let system_prompt = if let Some(text) = args.system_prompt {
Some(text)
} else if let Some(ref path) = args.system_prompt_file {
Expand Down Expand Up @@ -1202,6 +1218,7 @@ impl Config {
idle_pool_sleep_secs: args.idle_pool_sleep,
replay_floor_unix: args.replay_floor,
agent_owner: args.agent_owner.map(|s| s.trim().to_ascii_lowercase()),
failure_handlers,
no_base_prompt: args.no_base_prompt,
base_prompt_content,
};
Expand Down Expand Up @@ -1578,6 +1595,7 @@ mod tests {
idle_pool_sleep_secs: 0,
replay_floor_unix: None,
agent_owner: None,
failure_handlers: Default::default(),
no_base_prompt: false,
base_prompt_content: None,
}
Expand Down
88 changes: 88 additions & 0 deletions crates/buzz-acp/src/failure_notice.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
//! Addressed, thread-bound failure notices. These are recovery signals, not
//! authority to replay a task or evidence that its prior side effects stopped.

use std::collections::HashSet;

use nostr::{Event, EventId, Keys, Tag};

use crate::queue::{parse_thread_tags, FlushBatch};
use crate::scope::SessionScope;

const FAILURE_TAG: &str = "buzz:agent-failure";
const MAX_RECIPIENTS: usize = 50;

/// Build the signed notice sent by the production failure path.
pub(crate) fn build(
keys: &Keys,
batch: &FlushBatch,
content: &str,
route: Option<&crate::failure_routing::VerifiedRoute>,
) -> anyhow::Result<Event> {
anyhow::ensure!(
batch.scope.channel_id() == batch.channel_id,
"failure notice scope/channel mismatch"
);
let trigger = batch
.events
.last()
.or_else(|| batch.cancelled_events.last())
.ok_or_else(|| anyhow::anyhow!("failure notice has no triggering event"))?;
let root_id = match &batch.scope {
SessionScope::Thread { root_event_id, .. } => EventId::from_hex(root_event_id)?,
SessionScope::Conversation { .. } => parse_thread_tags(&trigger.event)
.root_event_id
.as_deref()
.map(EventId::from_hex)
.transpose()?
.unwrap_or(trigger.event.id),
};
let thread = buzz_sdk::ThreadRef {
root_event_id: root_id,
parent_event_id: trigger.event.id,
};

let own_key = keys.public_key();
let mut seen = HashSet::new();
let mut recipients: Vec<String> = batch
.events
.iter()
.rev()
.chain(batch.cancelled_events.iter().rev())
.filter(|item| {
// Never bounce a native failure signal back to its sender. A
// failed recovery turn may still report a visible, unaddressed
// notice; a new ordinary request in the batch remains eligible.
!crate::failure_routing::is_failure(&item.event)
})
.map(|item| item.event.pubkey)
.filter(|key| *key != own_key && seen.insert(*key))
.take(MAX_RECIPIENTS)
.map(|key| key.to_hex())
.collect();
if let Some(route) = route {
recipients = vec![route.recipient()];
}
let content = if route.is_some() {
format!("{content}\n\nRecovery notification: read the original task and current run status; inspect prior side effects before resuming. This notice does not prove that a previous writer stopped or authorize replay.")
} else {
content.to_string()
};
let mentions: Vec<&str> = recipients.iter().map(String::as_str).collect();
let marker = Tag::parse([FAILURE_TAG, "1"])?;
let mut builder = buzz_sdk::build_message(
batch.channel_id,
&content,
Some(&thread),
&mentions,
false,
&[],
&[],
)?
.tag(marker);
if let Some(route) = route {
for visited in route.visited() {
builder = builder.tag(Tag::parse([crate::failure_routing::VISITED_TAG, &visited])?);
}
}
Ok(builder.sign_with_keys(keys)?)
}
Loading