From 6ad58ab79616091c00c927048148eb8c47405a29 Mon Sep 17 00:00:00 2001 From: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Tue, 23 Jun 2026 21:17:33 -0400 Subject: [PATCH] feat(acp): add channel description/topic/purpose to [Context] block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent [Context] block rendered only the channel name. This adds the channel's description, topic, and purpose — already carried as about/topic/ purpose tags on the NIP-29 group metadata event the harness fetches, so no extra relay round-trips. Each field inlines verbatim when under a 200-char budget; longer values collapse to a placeholder pointing at `buzz channels get` to keep the prompt compact. Fields are wired through both ingestion paths: the discovery-time ChannelInfo cache (relay.rs) and the on-demand fetch_channel_info fallback (pool.rs). Canvas is deliberately out of scope: it is a separate event kind (40100), not on the metadata event, so surfacing it costs an extra fetch — left as a follow-up. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- crates/buzz-acp/src/pool.rs | 12 +++ crates/buzz-acp/src/queue.rs | 172 ++++++++++++++++++++++++++++++++++- crates/buzz-acp/src/relay.rs | 37 ++++++-- 3 files changed, 213 insertions(+), 8 deletions(-) diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 6f82f2a24..8419657f3 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -1098,6 +1098,9 @@ pub async fn run_prompt_task( Some(ci) => Some(PromptChannelInfo { name: ci.name.clone(), channel_type: ci.channel_type.clone(), + description: ci.description.clone(), + topic: ci.topic.clone(), + purpose: ci.purpose.clone(), }), None => fetch_channel_info(b.channel_id, &ctx.rest_client).await, }; @@ -1525,12 +1528,18 @@ async fn fetch_channel_info(channel_id: Uuid, rest: &RestClient) -> Option name = arr.get(1).and_then(|v| v.as_str()), + Some("about") => description = arr.get(1).and_then(|v| v.as_str()), + Some("topic") => topic = arr.get(1).and_then(|v| v.as_str()), + Some("purpose") => purpose = arr.get(1).and_then(|v| v.as_str()), Some("hidden") => is_hidden = true, Some("private") => is_private = true, _ => {} @@ -1547,6 +1556,9 @@ async fn fetch_channel_info(channel_id: Uuid, rest: &RestClient) -> Option { diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index 2a4617125..39e412cc0 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -737,10 +737,62 @@ pub struct ContextMessage { } /// Channel metadata for prompt formatting. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct PromptChannelInfo { pub name: String, pub channel_type: String, + /// Channel description (`about` tag on the NIP-29 metadata event). + pub description: Option, + /// Channel topic (`topic` tag on the NIP-29 metadata event). + pub topic: Option, + /// Channel purpose (`purpose` tag on the NIP-29 metadata event). + pub purpose: Option, +} + +/// Per-field character budget for inlining channel metadata into `[Context]`. +/// +/// Fields under this length are inlined verbatim; longer fields are replaced +/// with a placeholder pointing the agent at `buzz channels get` so the prompt +/// stays compact. +const CHANNEL_FIELD_INLINE_BUDGET: usize = 200; + +/// Render one optional channel metadata field as a `[Context]` line. +/// +/// Returns `None` when the field is absent or empty. Short values inline +/// verbatim; values over [`CHANNEL_FIELD_INLINE_BUDGET`] collapse to a +/// placeholder so the agent knows to fetch the full text on demand. +fn format_channel_field(label: &str, value: Option<&str>) -> Option { + let value = value.map(str::trim).filter(|v| !v.is_empty())?; + if value.chars().count() <= CHANNEL_FIELD_INLINE_BUDGET { + Some(format!("{label}: {value}")) + } else { + Some(format!( + "{label}: (long — run `buzz channels get --channel ` to read)" + )) + } +} + +/// Render the channel metadata lines (description/topic/purpose) for `[Context]`. +/// +/// Returns an empty string when no fields are present, so callers can append +/// it unconditionally. +fn format_channel_metadata(channel_info: Option<&PromptChannelInfo>) -> String { + let Some(ci) = channel_info else { + return String::new(); + }; + let mut out = String::new(); + for line in [ + format_channel_field("Description", ci.description.as_deref()), + format_channel_field("Topic", ci.topic.as_deref()), + format_channel_field("Purpose", ci.purpose.as_deref()), + ] + .into_iter() + .flatten() + { + out.push('\n'); + out.push_str(&line); + } + out } /// Minimal profile fields needed to label users in ACP prompts. @@ -900,8 +952,15 @@ fn format_context_hints( has_conversation_context: bool, triggering_event_id: Option<&str>, ) -> String { + // Channel name + any inlined metadata (description/topic/purpose). The + // metadata lines are folded into `channel_display` so they render directly + // under the `Channel:` line in every scope branch below. let channel_display = match channel_info { - Some(ci) => format!("{} (#{channel_id})", ci.name), + Some(ci) => format!( + "{} (#{channel_id}){}", + ci.name, + format_channel_metadata(channel_info) + ), None => channel_id.to_string(), }; @@ -2260,6 +2319,108 @@ mod tests { // ── Context formatting tests ───────────────────────────────────────────── + #[test] + fn test_format_channel_field_inlines_short_value() { + assert_eq!( + format_channel_field("Topic", Some("ship the thing")).as_deref(), + Some("Topic: ship the thing") + ); + } + + #[test] + fn test_format_channel_field_skips_empty_and_whitespace() { + assert_eq!(format_channel_field("Topic", None), None); + assert_eq!(format_channel_field("Topic", Some("")), None); + assert_eq!(format_channel_field("Topic", Some(" ")), None); + } + + #[test] + fn test_format_channel_field_trims_inlined_value() { + assert_eq!( + format_channel_field("Purpose", Some(" hello ")).as_deref(), + Some("Purpose: hello") + ); + } + + #[test] + fn test_format_channel_field_placeholder_when_over_budget() { + let long = "x".repeat(CHANNEL_FIELD_INLINE_BUDGET + 1); + let rendered = format_channel_field("Description", Some(&long)).unwrap(); + assert!(rendered.starts_with("Description: (long —")); + assert!(rendered.contains("buzz channels get")); + // The long value itself must not leak into the prompt. + assert!(!rendered.contains(&long)); + } + + #[test] + fn test_format_channel_field_inlines_at_exact_budget() { + let exact = "y".repeat(CHANNEL_FIELD_INLINE_BUDGET); + let rendered = format_channel_field("Topic", Some(&exact)).unwrap(); + assert_eq!(rendered, format!("Topic: {exact}")); + } + + #[test] + fn test_format_channel_metadata_renders_all_present_fields() { + let ci = PromptChannelInfo { + name: "eng".into(), + channel_type: "stream".into(), + description: Some("the eng channel".into()), + topic: Some("Q3 roadmap".into()), + purpose: Some("coordinate eng work".into()), + }; + let out = format_channel_metadata(Some(&ci)); + assert_eq!( + out, + "\nDescription: the eng channel\nTopic: Q3 roadmap\nPurpose: coordinate eng work" + ); + } + + #[test] + fn test_format_channel_metadata_empty_when_none_present() { + let ci = PromptChannelInfo { + name: "eng".into(), + channel_type: "stream".into(), + ..Default::default() + }; + assert_eq!(format_channel_metadata(Some(&ci)), ""); + assert_eq!(format_channel_metadata(None), ""); + } + + #[test] + fn test_format_prompt_channel_scope_inlines_metadata() { + let ch = Uuid::new_v4(); + let event = make_event("hello"); + let batch = FlushBatch { + channel_id: ch, + events: vec![BatchEvent { + event, + prompt_tag: "test".into(), + received_at: Instant::now(), + }], + cancelled_events: vec![], + }; + let ci = PromptChannelInfo { + name: "engineering".into(), + channel_type: "stream".into(), + description: Some("where eng happens".into()), + topic: Some("Q3 roadmap".into()), + purpose: None, + }; + let prompt = format_prompt( + &batch, + &FormatPromptArgs { + channel_info: Some(&ci), + ..Default::default() + }, + ) + .join("\n\n"); + assert!(prompt.contains("engineering (#")); + assert!(prompt.contains("\nDescription: where eng happens")); + assert!(prompt.contains("\nTopic: Q3 roadmap")); + // Absent field produces no line. + assert!(!prompt.contains("Purpose:")); + } + #[test] fn test_format_prompt_with_channel_info() { let ch = Uuid::new_v4(); @@ -2276,6 +2437,7 @@ mod tests { let ci = PromptChannelInfo { name: "engineering".into(), channel_type: "stream".into(), + ..Default::default() }; let prompt = format_prompt( @@ -2306,6 +2468,7 @@ mod tests { let ci = PromptChannelInfo { name: "DM".into(), channel_type: "dm".into(), + ..Default::default() }; let prompt = format_prompt( @@ -2413,6 +2576,7 @@ mod tests { let ci = PromptChannelInfo { name: "DM".into(), channel_type: "dm".into(), + ..Default::default() }; let ctx = ConversationContext::Dm { messages: vec![ContextMessage { @@ -2575,6 +2739,7 @@ mod tests { let ci = PromptChannelInfo { name: "DM".into(), channel_type: "dm".into(), + ..Default::default() }; // Thread context fetched (as the fetch path does for DM replies). let ctx = ConversationContext::Thread { @@ -2631,6 +2796,7 @@ mod tests { let ci = PromptChannelInfo { name: "DM".into(), channel_type: "dm".into(), + ..Default::default() }; // No context fetched — hints only. @@ -3069,6 +3235,7 @@ mod tests { let ci = PromptChannelInfo { name: "DM".into(), channel_type: "dm".into(), + ..Default::default() }; let prompt = format_prompt( @@ -3122,6 +3289,7 @@ mod tests { let ci = PromptChannelInfo { name: "DM".into(), channel_type: "dm".into(), + ..Default::default() }; let prompt = format_prompt( diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index 5a9986644..0dccb147f 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -83,6 +83,12 @@ use crate::config::ChannelFilter; pub struct ChannelInfo { pub name: String, pub channel_type: String, + /// Channel description (`about` tag on the NIP-29 metadata event). + pub description: Option, + /// Channel topic (`topic` tag on the NIP-29 metadata event). + pub topic: Option, + /// Channel purpose (`purpose` tag on the NIP-29 metadata event). + pub purpose: Option, } /// Build the discovered-channel subscribe set from the membership UUIDs and the @@ -99,7 +105,7 @@ fn merge_discovered_channels( channel_uuids: Vec, meta_events: &serde_json::Value, ) -> HashMap { - let mut meta_map: HashMap = HashMap::new(); + let mut meta_map: HashMap = HashMap::new(); let mut archived: std::collections::HashSet = std::collections::HashSet::new(); if let Some(arr) = meta_events.as_array() { for ev in arr { @@ -109,6 +115,9 @@ fn merge_discovered_channels( }; let mut d_val = None; let mut name = None; + let mut description = None; + let mut topic = None; + let mut purpose = None; let mut is_hidden = false; let mut is_private = false; let mut is_archived = false; @@ -117,6 +126,9 @@ fn merge_discovered_channels( match arr.first().and_then(|v| v.as_str()) { Some("d") => d_val = arr.get(1).and_then(|v| v.as_str()), Some("name") => name = arr.get(1).and_then(|v| v.as_str()), + Some("about") => description = arr.get(1).and_then(|v| v.as_str()), + Some("topic") => topic = arr.get(1).and_then(|v| v.as_str()), + Some("purpose") => purpose = arr.get(1).and_then(|v| v.as_str()), Some("hidden") => is_hidden = true, Some("private") => is_private = true, Some("archived") => { @@ -141,7 +153,16 @@ fn merge_discovered_channels( } else { "stream".to_string() }; - meta_map.insert(uuid, (ch_name, ch_type)); + meta_map.insert( + uuid, + ChannelInfo { + name: ch_name, + channel_type: ch_type, + description: description.map(str::to_string), + topic: topic.map(str::to_string), + purpose: purpose.map(str::to_string), + }, + ); } } } @@ -152,10 +173,14 @@ fn merge_discovered_channels( if archived.contains(&uuid) { continue; } - let (name, channel_type) = meta_map - .remove(&uuid) - .unwrap_or_else(|| ("unknown".to_string(), "stream".to_string())); - map.insert(uuid, ChannelInfo { name, channel_type }); + let info = meta_map.remove(&uuid).unwrap_or_else(|| ChannelInfo { + name: "unknown".to_string(), + channel_type: "stream".to_string(), + description: None, + topic: None, + purpose: None, + }); + map.insert(uuid, info); } map }