Skip to content
Open
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
12 changes: 12 additions & 0 deletions crates/buzz-acp/src/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -1525,12 +1528,18 @@ async fn fetch_channel_info(channel_id: Uuid, rest: &RestClient) -> Option<Promp
let ev = events.first()?;
let tags = ev.get("tags")?.as_array()?;
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;
for tag in tags {
if let Some(arr) = tag.as_array() {
match arr.first().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,
_ => {}
Expand All @@ -1547,6 +1556,9 @@ async fn fetch_channel_info(channel_id: Uuid, rest: &RestClient) -> Option<Promp
Some(PromptChannelInfo {
name: name.unwrap_or("unknown").to_string(),
channel_type,
description: description.map(str::to_string),
topic: topic.map(str::to_string),
purpose: purpose.map(str::to_string),
})
}
Ok(Err(e)) => {
Expand Down
172 changes: 170 additions & 2 deletions crates/buzz-acp/src/queue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Channel topic (`topic` tag on the NIP-29 metadata event).
pub topic: Option<String>,
/// Channel purpose (`purpose` tag on the NIP-29 metadata event).
pub purpose: Option<String>,
}

/// 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<String> {
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 <UUID>` 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.
Expand Down Expand Up @@ -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(),
};

Expand Down Expand Up @@ -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();
Expand All @@ -2276,6 +2437,7 @@ mod tests {
let ci = PromptChannelInfo {
name: "engineering".into(),
channel_type: "stream".into(),
..Default::default()
};

let prompt = format_prompt(
Expand Down Expand Up @@ -2306,6 +2468,7 @@ mod tests {
let ci = PromptChannelInfo {
name: "DM".into(),
channel_type: "dm".into(),
..Default::default()
};

let prompt = format_prompt(
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -2631,6 +2796,7 @@ mod tests {
let ci = PromptChannelInfo {
name: "DM".into(),
channel_type: "dm".into(),
..Default::default()
};

// No context fetched — hints only.
Expand Down Expand Up @@ -3069,6 +3235,7 @@ mod tests {
let ci = PromptChannelInfo {
name: "DM".into(),
channel_type: "dm".into(),
..Default::default()
};

let prompt = format_prompt(
Expand Down Expand Up @@ -3122,6 +3289,7 @@ mod tests {
let ci = PromptChannelInfo {
name: "DM".into(),
channel_type: "dm".into(),
..Default::default()
};

let prompt = format_prompt(
Expand Down
37 changes: 31 additions & 6 deletions crates/buzz-acp/src/relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Channel topic (`topic` tag on the NIP-29 metadata event).
pub topic: Option<String>,
/// Channel purpose (`purpose` tag on the NIP-29 metadata event).
pub purpose: Option<String>,
}

/// Build the discovered-channel subscribe set from the membership UUIDs and the
Expand All @@ -99,7 +105,7 @@ 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, ChannelInfo> = 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 @@ -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;
Expand All @@ -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") => {
Expand All @@ -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),
},
);
}
}
}
Expand All @@ -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
}
Expand Down
Loading