diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index e6164b02dd3..4de9b330eb7 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -157,6 +157,30 @@ The gate applies to **all** inbound events — @mentions, DMs, thread replies, a Use `!cancel` to stop only the current turn; it is a no-op when the channel is idle. Use `!rotate` when you want the next turn in the channel to start from a fresh ACP session, even if the channel is currently idle. +### Discovery / @mention (kind:10100 agent profile) + +Buzz Desktop's mention autocomplete offers an externally-deployed (relay) agent +only in channels its **kind:10100 agent-profile record** lists. When +`BUZZ_ACP_PUBLISH_PROFILE=true`, the harness self-publishes that record on +startup and **refreshes it automatically** whenever it joins or leaves a channel +(via the membership-notification path) — so the agent stays `@mentionable` in +every channel it belongs to with no manual bookkeeping. Without this, an +externally-deployed agent has no discovery record and cannot be offered in +Desktop's autocomplete. + +| Flag | Env Var | Default | Description | +|------|---------|---------|-------------| +| — | `BUZZ_ACP_PUBLISH_PROFILE` | `false` | Publish/refresh the kind:10100 discovery record. Opt-in. | +| — | `BUZZ_ACP_DISPLAY_NAME` | agent pubkey hex | Name shown in the record / used to resolve `@name`. | +| — | `BUZZ_ACP_CHANNEL_ADD_POLICY` | `anyone` | `channel_add_policy` in the record: `anyone`, `owner_only`, `nobody`. | +| — | `BUZZ_ACP_CAPABILITIES` | — | Comma-separated capability tags for display (e.g. `shell,file-edit,git`). | +| — | `BUZZ_ACP_DISPLAY_ABOUT` | — | Optional bio/about text shown on the agent's profile. | + +The record's `respond_to` / `respond_to_allowlist` mirror the author-gate +config. Desktop only offers a relay agent whose `respond_to` is `anyone`, or an +`allowlist` that includes the viewer — so a `owner-only` agent, while it will +still respond, is not surfaced in autocomplete even to its owner. + Owner control commands must be kind:9 stream messages from the owner, must mention this agent with a `p` tag, and are consumed by the harness instead of being forwarded to the agent. > **Note:** The default mode is `owner-only`. Agents without a registered `agent_owner_pubkey` will not respond to any events until the owner is resolved. Set `--respond-to anyone` to disable the gate entirely. diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 3563eec8e92..47cc1019dba 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -90,6 +90,122 @@ async fn publish_presence( Ok(()) } +/// Whether the harness should publish a kind:10100 agent-profile discovery +/// record. Opt-in via `BUZZ_ACP_PUBLISH_PROFILE=true` (or `1`) so existing +/// deployments that do not want to advertise are unaffected. +fn profile_publishing_enabled() -> bool { + matches!( + std::env::var("BUZZ_ACP_PUBLISH_PROFILE").ok().as_deref(), + Some("true") | Some("1") + ) +} + +/// Publish (or refresh) the agent's kind:10100 discovery record so external +/// clients — notably Buzz Desktop — can find and `@mention` it. +/// +/// Buzz Desktop's mention autocomplete only offers a relay agent in channels +/// whose id appears in this record's `channel_ids`. Nothing else in the stack +/// publishes the record, so an externally deployed agent must self-register and +/// keep the channel list current as it joins/leaves channels. +/// +/// kind:10100 is dual-purpose and REPLACEABLE (10000-19999): the relay's +/// side effect requires a `channel_add_policy` field (it is rejected without +/// one), while Desktop parses the remaining fields as the agent descriptor. So +/// the content carries both concerns in a single event. +/// +/// Routed through the durable HTTP `POST /events` path (`RestClient`), NOT the +/// ephemeral WS publish path, which silently drops replaceable events when +/// rate-gated. +async fn publish_agent_profile( + rest: &relay::RestClient, + keys: &nostr::Keys, + channel_ids: &HashSet, + respond_to: &str, + respond_to_allowlist: &HashSet, +) -> Result<(), relay::RelayError> { + use buzz_core::kind::KIND_AGENT_PROFILE; + use nostr::{EventBuilder, Kind}; + + let name = std::env::var("BUZZ_ACP_DISPLAY_NAME") + .ok() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| keys.public_key().to_hex()); + // Relay requires this field; "anyone" lets teammates add the agent to their + // own channels. Override with BUZZ_ACP_CHANNEL_ADD_POLICY (anyone|owner_only|nobody). + let channel_add_policy = std::env::var("BUZZ_ACP_CHANNEL_ADD_POLICY") + .ok() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "anyone".to_string()); + let capabilities: Vec = std::env::var("BUZZ_ACP_CAPABILITIES") + .ok() + .filter(|s| !s.is_empty()) + .map(|s| { + s.split(',') + .map(|c| c.trim().to_string()) + .filter(|c| !c.is_empty()) + .collect() + }) + .unwrap_or_default(); + let about = std::env::var("BUZZ_ACP_DISPLAY_ABOUT") + .ok() + .filter(|s| !s.is_empty()); + + let content = build_agent_profile_content( + &name, + &channel_add_policy, + respond_to, + respond_to_allowlist, + channel_ids, + &capabilities, + about.as_deref(), + ); + + let event = EventBuilder::new(Kind::Custom(KIND_AGENT_PROFILE as u16), content) + .tags([]) + .sign_with_keys(keys) + .map_err(|e| relay::RelayError::Http(format!("agent profile sign error: {e}")))?; + rest.submit_event(&event).await?; + Ok(()) +} + +/// Build the JSON content for a kind:10100 agent-profile event. Pure (no I/O) +/// so the descriptor shape is unit-testable. `channel_add_policy` is mandatory +/// (the relay rejects the event without it); the remaining fields are the +/// descriptor Buzz Desktop parses. +fn build_agent_profile_content( + name: &str, + channel_add_policy: &str, + respond_to: &str, + respond_to_allowlist: &HashSet, + channel_ids: &HashSet, + capabilities: &[String], + about: Option<&str>, +) -> String { + // Deterministic ordering keeps the replaceable event stable across + // republishes when the underlying sets are unchanged. + let mut channels: Vec = channel_ids.iter().map(|u| u.to_string()).collect(); + channels.sort(); + let mut allowlist: Vec = respond_to_allowlist.iter().cloned().collect(); + allowlist.sort(); + + let mut content = serde_json::json!({ + "channel_add_policy": channel_add_policy, + "name": name, + "display_name": name, + "agent_type": "agent", + "status": "online", + "respond_to": respond_to, + "respond_to_allowlist": allowlist, + "channel_ids": channels, + "channels": channels, + "capabilities": capabilities, + }); + if let Some(about) = about { + content["about"] = serde_json::Value::String(about.to_string()); + } + content.to_string() +} + fn emit_runtime_lifecycle( observer: Option<&observer::ObserverHandle>, start_nonce: &str, @@ -1468,6 +1584,60 @@ fn inactivity_expired( !bound.is_zero() && !turn_in_flight && now.duration_since(last_activity) >= bound } +#[cfg(test)] +mod agent_profile_tests { + use super::*; + + #[test] + fn content_includes_required_channel_add_policy_and_descriptor() { + let ch: HashSet = [ + Uuid::parse_str("2f21cbb0-e388-45f1-abf6-3ef5425d731e").unwrap(), + Uuid::parse_str("7ed03881-fcbf-4f1a-854b-24596a01c905").unwrap(), + ] + .into_iter() + .collect(); + let allow: HashSet = HashSet::new(); + let json = build_agent_profile_content( + "selfpub-test", + "anyone", + "anyone", + &allow, + &ch, + &["shell".to_string(), "git".to_string()], + Some("hi"), + ); + let v: serde_json::Value = serde_json::from_str(&json).unwrap(); + // Relay hard-requires this field. + assert_eq!(v["channel_add_policy"], "anyone"); + assert_eq!(v["name"], "selfpub-test"); + assert_eq!(v["display_name"], "selfpub-test"); + assert_eq!(v["agent_type"], "agent"); + assert_eq!(v["respond_to"], "anyone"); + assert_eq!(v["about"], "hi"); + assert_eq!(v["channel_ids"].as_array().unwrap().len(), 2); + // channel_ids and channels are the same set, both sorted/deterministic. + assert_eq!(v["channel_ids"], v["channels"]); + assert_eq!(v["capabilities"].as_array().unwrap().len(), 2); + } + + #[test] + fn content_is_stable_across_calls_for_same_input() { + let ch: HashSet = [ + Uuid::parse_str("7ed03881-fcbf-4f1a-854b-24596a01c905").unwrap(), + Uuid::parse_str("2f21cbb0-e388-45f1-abf6-3ef5425d731e").unwrap(), + ] + .into_iter() + .collect(); + let allow: HashSet = HashSet::new(); + let a = build_agent_profile_content("n", "anyone", "anyone", &allow, &ch, &[], None); + let b = build_agent_profile_content("n", "anyone", "anyone", &allow, &ch, &[], None); + assert_eq!( + a, b, + "sorted sets must produce identical replaceable content" + ); + } +} + #[cfg(test)] mod inactivity_tests { use super::*; @@ -1797,6 +1967,26 @@ async fn tokio_main() -> Result<()> { } } + // Advertise the agent for discovery/@mention in Buzz Desktop. Opt-in; the + // record's channel list is refreshed whenever membership changes below. + if profile_publishing_enabled() { + match publish_agent_profile( + &relay.rest_client(), + &config.keys, + &subscribed_channel_ids, + &config.respond_to.to_string(), + &config.respond_to_allowlist, + ) + .await + { + Ok(_) => tracing::info!( + "published agent profile ({} channel(s))", + subscribed_channel_ids.len() + ), + Err(e) => tracing::warn!("failed to publish agent profile: {e}"), + } + } + if config.lazy_pool { emit_runtime_lifecycle( observer.as_ref(), @@ -2275,6 +2465,21 @@ async fn tokio_main() -> Result<()> { tracing::warn!("failed to subscribe to new channel {ch}: {e}"); } else { subscribed_channel_ids.insert(ch); + // Refresh the discovery record so Desktop can + // @mention the agent in this newly-joined channel. + if profile_publishing_enabled() { + if let Err(e) = publish_agent_profile( + &relay.rest_client(), + &config.keys, + &subscribed_channel_ids, + &config.respond_to.to_string(), + &config.respond_to_allowlist, + ) + .await + { + tracing::warn!("failed to refresh agent profile after channel add: {e}"); + } + } } } else { tracing::debug!(channel_id = %ch, "membership notification: no matching rules — skipping"); @@ -2285,6 +2490,21 @@ async fn tokio_main() -> Result<()> { if let Err(e) = relay.unsubscribe_channel(ch).await { tracing::warn!("failed to unsubscribe from channel {ch}: {e}"); } + // Refresh the discovery record so the agent no longer + // advertises a channel it was removed from. + if profile_publishing_enabled() { + if let Err(e) = publish_agent_profile( + &relay.rest_client(), + &config.keys, + &subscribed_channel_ids, + &config.respond_to.to_string(), + &config.respond_to_allowlist, + ) + .await + { + tracing::warn!("failed to refresh agent profile after channel remove: {e}"); + } + } // Drain queued events and invalidate sessions for the // removed channel. Events already in-flight will // complete normally (the relay may reject actions if