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
8 changes: 8 additions & 0 deletions crates/buzz-acp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ The harness discovers channels by querying the relay with the agent's authentica

By default, the harness discovers only channels the agent is a **member** of (`GET /api/channels?member=true`). When the agent is added to a new channel, the membership notification subscription auto-subscribes to it.

Channel owners can set **Agent replies** to **Only @mentions** or **Every
message** in channel settings. The relay publishes the authoritative policy in
channel metadata and all connected ACP harnesses replace that channel's
subscription without a restart. This controls delivery for every agent member;
each agent's `RespondTo` author gate still decides who is allowed to trigger it.
New and migrated channels default to mention-only. A harness connected to an
older relay with no policy tag preserves its local mention rule.

**Private channels** require explicit membership. The relay doesn't yet have a REST/event API for managing channel members — this is a known gap. For now, use `create_channel` via the Buzz CLI to create new channels (the creator is automatically a member).

## Quick Start (goose)
Expand Down
50 changes: 50 additions & 0 deletions crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,20 @@ pub struct ChannelFilter {
pub require_mention: bool,
}

/// Apply a channel-owned response policy to a resolved agent subscription.
///
/// When present, the channel-owned policy overrides the agent's local mention
/// rule in either direction. `None` preserves the local rule for compatibility
/// with relays that predate channel-owned response policy metadata.
pub fn apply_agent_response_policy(
filter: &mut ChannelFilter,
policy: Option<buzz_core::channel::AgentResponsePolicy>,
) {
if let Some(policy) = policy {
filter.require_mention = policy == buzz_core::channel::AgentResponsePolicy::Mentions;
}
}

#[derive(Debug)]
pub struct Config {
pub keys: Keys,
Expand Down Expand Up @@ -1910,6 +1924,42 @@ mod tests {
assert!(!f.require_mention, "most permissive (false) should win");
}

#[test]
fn channel_all_policy_relaxes_mention_filter() {
let mut filter = ChannelFilter {
kinds: Some(vec![1]),
require_mention: true,
};
apply_agent_response_policy(
&mut filter,
Some(buzz_core::channel::AgentResponsePolicy::All),
);
assert!(!filter.require_mention);
}

#[test]
fn channel_mentions_policy_overrides_explicit_local_all_rule() {
let mut filter = ChannelFilter {
kinds: Some(vec![1]),
require_mention: false,
};
apply_agent_response_policy(
&mut filter,
Some(buzz_core::channel::AgentResponsePolicy::Mentions),
);
assert!(filter.require_mention);
}

#[test]
fn missing_channel_policy_preserves_local_rule_for_older_relays() {
let mut filter = ChannelFilter {
kinds: Some(vec![1]),
require_mention: false,
};
apply_agent_response_policy(&mut filter, None);
assert!(!filter.require_mention);
}

#[test]
fn test_rule_applies_all() {
let rule = make_rule("test", ChannelScope::All("all".into()), vec![], false);
Expand Down
28 changes: 23 additions & 5 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1471,7 +1471,12 @@ async fn tokio_main() -> Result<()> {
}
};

let channel_filters = config::resolve_channel_filters(&config, &channel_ids, &rules);
let mut channel_filters = config::resolve_channel_filters(&config, &channel_ids, &rules);
for (channel_id, filter) in &mut channel_filters {
if let Some(channel_info) = channel_info_map.get(channel_id) {
config::apply_agent_response_policy(filter, channel_info.agent_response_policy);
}
}
if channel_filters.is_empty() {
tracing::warn!("no channel subscriptions resolved — agent will sit idle");
}
Expand Down Expand Up @@ -1964,10 +1969,20 @@ async fn tokio_main() -> Result<()> {
// stripped for a legitimately re-added channel.
removed_channels.remove(&ch);

if subscribed_channel_ids.contains(&ch) {
tracing::debug!(channel_id = %ch, "membership notification: channel already subscribed");
} else if let Some(filter) = config::resolve_dynamic_channel_filter(&config, ch, &rules) {
tracing::info!(channel_id = %ch, "membership notification: subscribing to new channel");
if let Some(mut filter) = config::resolve_dynamic_channel_filter(&config, ch, &rules) {
if let Some(channel_info) = ctx.channel_info.refresh(ch).await {
config::apply_agent_response_policy(
&mut filter,
channel_info.agent_response_policy,
);
}
let was_subscribed = subscribed_channel_ids.contains(&ch);
tracing::info!(
channel_id = %ch,
was_subscribed,
require_mention = filter.require_mention,
"membership notification: refreshing channel subscription"
);
if let Err(e) = relay.subscribe_channel_from(ch, filter, Some(ts)).await {
tracing::warn!("failed to subscribe to new channel {ch}: {e}");
} else {
Expand Down Expand Up @@ -4642,13 +4657,15 @@ mod author_gate_tests {
relay::ChannelInfo {
name: "dm".into(),
channel_type: "dm".into(),
agent_response_policy: Some(buzz_core::channel::AgentResponsePolicy::Mentions),
},
),
(
stream_id,
relay::ChannelInfo {
name: "stream".into(),
channel_type: "stream".into(),
agent_response_policy: Some(buzz_core::channel::AgentResponsePolicy::Mentions),
},
),
]);
Expand All @@ -4665,6 +4682,7 @@ mod author_gate_tests {
relay::ChannelInfo {
name: "unknown".into(),
channel_type: "unknown".into(),
agent_response_policy: Some(buzz_core::channel::AgentResponsePolicy::Mentions),
},
)]);
assert!(
Expand Down
24 changes: 24 additions & 0 deletions crates/buzz-acp/src/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,7 @@ impl ChannelInfoResolver {
PromptChannelInfo {
name: info.name,
channel_type: info.channel_type,
agent_response_policy: info.agent_response_policy,
},
))
})
Expand All @@ -495,6 +496,18 @@ impl ChannelInfoResolver {
}
Some(info)
}

/// Refetch channel metadata and replace the cached value.
///
/// Membership notifications use this after channel policy changes so a
/// running harness can replace its mention filter without restarting.
pub async fn refresh(&self, channel_id: Uuid) -> Option<PromptChannelInfo> {
let info = fetch_channel_info(channel_id, &self.rest_client).await?;
if let Ok(mut cache) = self.cache.write() {
cache.insert(channel_id, info.clone());
}
Some(info)
}
}

pub struct PromptContext {
Expand Down Expand Up @@ -2336,17 +2349,28 @@ pub(crate) async fn fetch_channel_info(
let ev = events.first()?;
let tags = ev.get("tags")?.as_array()?;
let mut name = None;
let mut agent_response_policy = None;
for tag in tags {
if let Some(arr) = tag.as_array() {
if arr.first().and_then(|v| v.as_str()) == Some("name") {
name = arr.get(1).and_then(|v| v.as_str());
}
if arr.first().and_then(|v| v.as_str()) == Some("agent_response") {
agent_response_policy = match arr.get(1).and_then(|v| v.as_str()) {
Some("mentions") => {
Some(buzz_core::channel::AgentResponsePolicy::Mentions)
}
Some("all") => Some(buzz_core::channel::AgentResponsePolicy::All),
_ => None,
};
}
}
}
let channel_type = crate::relay::channel_type_from_tags(tags);
Some(PromptChannelInfo {
name: name.unwrap_or(UNKNOWN_CHANNEL_NAME).to_string(),
channel_type,
agent_response_policy,
})
}
Ok(Err(e)) => {
Expand Down
8 changes: 8 additions & 0 deletions crates/buzz-acp/src/queue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -995,6 +995,7 @@ pub struct ContextMessage {
pub struct PromptChannelInfo {
pub name: String,
pub channel_type: String,
pub agent_response_policy: Option<buzz_core::channel::AgentResponsePolicy>,
}

/// Minimal profile fields needed to label users in ACP prompts.
Expand Down Expand Up @@ -2976,6 +2977,7 @@ mod tests {
let ci = PromptChannelInfo {
name: "engineering".into(),
channel_type: "stream".into(),
agent_response_policy: Some(buzz_core::channel::AgentResponsePolicy::Mentions),
};

let prompt = format_prompt(
Expand Down Expand Up @@ -3007,6 +3009,7 @@ mod tests {
let ci = PromptChannelInfo {
name: "DM".into(),
channel_type: "dm".into(),
agent_response_policy: Some(buzz_core::channel::AgentResponsePolicy::Mentions),
};

let prompt = format_prompt(
Expand Down Expand Up @@ -3117,6 +3120,7 @@ mod tests {
let ci = PromptChannelInfo {
name: "DM".into(),
channel_type: "dm".into(),
agent_response_policy: Some(buzz_core::channel::AgentResponsePolicy::Mentions),
};
let ctx = ConversationContext::Dm {
messages: vec![ContextMessage {
Expand Down Expand Up @@ -3373,6 +3377,7 @@ mod tests {
let ci = PromptChannelInfo {
name: "DM".into(),
channel_type: "dm".into(),
agent_response_policy: Some(buzz_core::channel::AgentResponsePolicy::Mentions),
};
// Thread context fetched (as the fetch path does for DM replies).
let ctx = ConversationContext::Thread {
Expand Down Expand Up @@ -3430,6 +3435,7 @@ mod tests {
let ci = PromptChannelInfo {
name: "DM".into(),
channel_type: "dm".into(),
agent_response_policy: Some(buzz_core::channel::AgentResponsePolicy::Mentions),
};

// No context fetched — hints only.
Expand Down Expand Up @@ -3925,6 +3931,7 @@ mod tests {
let ci = PromptChannelInfo {
name: "DM".into(),
channel_type: "dm".into(),
agent_response_policy: Some(buzz_core::channel::AgentResponsePolicy::Mentions),
};

let prompt = format_prompt(
Expand Down Expand Up @@ -3988,6 +3995,7 @@ mod tests {
let ci = PromptChannelInfo {
name: "DM".into(),
channel_type: "dm".into(),
agent_response_policy: Some(buzz_core::channel::AgentResponsePolicy::Mentions),
};

let prompt = format_prompt(
Expand Down
58 changes: 53 additions & 5 deletions crates/buzz-acp/src/relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,12 +127,14 @@ use tracing::{debug, info, warn};
use uuid::Uuid;

use crate::config::ChannelFilter;
use buzz_core::channel::AgentResponsePolicy;

/// Metadata about a channel, populated at discovery time.
#[derive(Debug, Clone)]
pub struct ChannelInfo {
pub name: String,
pub channel_type: String,
pub agent_response_policy: Option<AgentResponsePolicy>,
}

pub(crate) fn channel_type_from_tags(tags: &[serde_json::Value]) -> String {
Expand Down Expand Up @@ -172,7 +174,7 @@ pub(crate) fn merge_discovered_channels(
channel_uuids: Vec<Uuid>,
meta_events: &serde_json::Value,
) -> HashMap<Uuid, ChannelInfo> {
let mut meta_map: HashMap<Uuid, (String, String)> = HashMap::new();
let mut meta_map: HashMap<Uuid, (String, String, Option<AgentResponsePolicy>)> = HashMap::new();
let mut archived: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
if let Some(arr) = meta_events.as_array() {
for ev in arr {
Expand All @@ -183,6 +185,7 @@ pub(crate) fn merge_discovered_channels(
let mut d_val = None;
let mut name = None;
let mut is_archived = false;
let mut agent_response_policy = None;
for tag in tags {
if let Some(arr) = tag.as_array() {
match arr.first().and_then(|v| v.as_str()) {
Expand All @@ -191,6 +194,13 @@ pub(crate) fn merge_discovered_channels(
Some("archived") => {
is_archived = arr.get(1).and_then(|v| v.as_str()) == Some("true")
}
Some("agent_response") => {
agent_response_policy = match arr.get(1).and_then(|v| v.as_str()) {
Some("mentions") => Some(AgentResponsePolicy::Mentions),
Some("all") => Some(AgentResponsePolicy::All),
_ => None,
};
}
_ => {}
}
}
Expand All @@ -203,7 +213,7 @@ pub(crate) fn merge_discovered_channels(
}
let ch_name = name.unwrap_or("unknown").to_string();
let ch_type = channel_type_from_tags(tags);
meta_map.insert(uuid, (ch_name, ch_type));
meta_map.insert(uuid, (ch_name, ch_type, agent_response_policy));
}
}
}
Expand All @@ -214,10 +224,17 @@ pub(crate) fn merge_discovered_channels(
if archived.contains(&uuid) {
continue;
}
let (name, channel_type) = meta_map
let (name, channel_type, agent_response_policy) = meta_map
.remove(&uuid)
.unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
map.insert(uuid, ChannelInfo { name, channel_type });
.unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string(), None));
map.insert(
uuid,
ChannelInfo {
name,
channel_type,
agent_response_policy,
},
);
}
map
}
Expand Down Expand Up @@ -4088,6 +4105,7 @@ mod tests {
let channel = Uuid::new_v4();
let map = merge_discovered_channels(vec![channel], &serde_json::json!([]));
assert_eq!(map[&channel].channel_type, "unknown");
assert_eq!(map[&channel].agent_response_policy, None);
}

#[test]
Expand All @@ -4098,6 +4116,36 @@ mod tests {
assert_eq!(map[&channel].channel_type, "dm");
}

#[test]
fn merge_discovered_channels_reads_agent_response_policy() {
let channel = Uuid::new_v4();
let meta = serde_json::json!([meta_event(
channel,
"agent-room",
&["agent_response", "all"]
)]);
let map = merge_discovered_channels(vec![channel], &meta);
assert_eq!(
map[&channel].agent_response_policy,
Some(AgentResponsePolicy::All)
);
}

#[test]
fn merge_discovered_channels_reads_mentions_policy() {
let channel = Uuid::new_v4();
let meta = serde_json::json!([meta_event(
channel,
"agent-room",
&["agent_response", "mentions"]
)]);
let map = merge_discovered_channels(vec![channel], &meta);
assert_eq!(
map[&channel].agent_response_policy,
Some(AgentResponsePolicy::Mentions)
);
}

#[test]
fn merge_discovered_channels_skips_archived_metadata() {
let live = Uuid::new_v4();
Expand Down
Loading