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
4 changes: 4 additions & 0 deletions crates/buzz-acp/src/base_prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ When in doubt, prefer the reply destination explicitly supplied in `[Context]`.

All replies and delegations — including task assignments to other agents — go to the **same channel where you were tagged** (use the channel UUID from `[Context]`). Never post responses or assignments to a different channel unless the user explicitly requests it.

### Forum Channels

Forum channels are not stream channels, and the reply kind must match the thread root. Before replying, inspect the supplied `Thread root kind` in `[Context]`; only if the kind is unavailable, fetch the root with `buzz messages thread --channel <UUID> --event <root-id>` before choosing a kind. Use the stream default kind `9` for replies beneath kind-`9` and legacy kind-`40002` stream roots, even if the channel also hosts forum posts. For a new forum thread, send kind `45001`: `buzz messages send --channel <UUID> --kind 45001 --content "..."`. Only beneath a kind-`45001` forum root, send replies as kind `45003` with the supplied `--reply-to <event-id>`. Never send kind `45003` beneath kind-`9` or kind-`40002` roots.
Comment thread
loganj marked this conversation as resolved.

### General

- Respond promptly to @mentions. Be direct — no preamble. Name what you did, what you found, or what you need.
Expand Down
26 changes: 24 additions & 2 deletions crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1236,7 +1236,8 @@ pub fn resolve_channel_filters(
rules: &[SubscriptionRule],
) -> HashMap<Uuid, ChannelFilter> {
use buzz_core::kind::{
KIND_STREAM_MESSAGE, KIND_STREAM_REMINDER, KIND_WORKFLOW_APPROVAL_REQUESTED,
KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_STREAM_MESSAGE, KIND_STREAM_REMINDER,
KIND_WORKFLOW_APPROVAL_REQUESTED,
};

let target_channels: Vec<Uuid> = if let Some(ref overrides) = config.channels_override {
Expand All @@ -1256,6 +1257,8 @@ pub fn resolve_channel_filters(
let kinds = config.kinds_override.clone().unwrap_or_else(|| {
vec![
KIND_STREAM_MESSAGE,
KIND_FORUM_POST,
KIND_FORUM_COMMENT,
KIND_WORKFLOW_APPROVAL_REQUESTED,
KIND_STREAM_REMINDER,
]
Expand Down Expand Up @@ -1338,7 +1341,8 @@ pub fn resolve_dynamic_channel_filter(
rules: &[crate::filter::SubscriptionRule],
) -> Option<ChannelFilter> {
use buzz_core::kind::{
KIND_STREAM_MESSAGE, KIND_STREAM_REMINDER, KIND_WORKFLOW_APPROVAL_REQUESTED,
KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_STREAM_MESSAGE, KIND_STREAM_REMINDER,
KIND_WORKFLOW_APPROVAL_REQUESTED,
};

// In Mentions/All mode, if the operator explicitly constrained channels
Expand All @@ -1361,6 +1365,8 @@ pub fn resolve_dynamic_channel_filter(
kinds: Some(config.kinds_override.clone().unwrap_or_else(|| {
vec![
KIND_STREAM_MESSAGE,
KIND_FORUM_POST,
KIND_FORUM_COMMENT,
KIND_WORKFLOW_APPROVAL_REQUESTED,
KIND_STREAM_REMINDER,
]
Expand Down Expand Up @@ -1507,11 +1513,27 @@ mod tests {
assert!(f.require_mention, "mentions mode requires mention");
let kinds = f.kinds.as_ref().expect("should have kinds");
assert!(kinds.contains(&buzz_core::kind::KIND_STREAM_MESSAGE));
assert!(kinds.contains(&buzz_core::kind::KIND_FORUM_POST));
assert!(kinds.contains(&buzz_core::kind::KIND_FORUM_COMMENT));
assert!(kinds.contains(&buzz_core::kind::KIND_WORKFLOW_APPROVAL_REQUESTED));
assert!(kinds.contains(&buzz_core::kind::KIND_STREAM_REMINDER));
}
}

#[test]
fn test_dynamic_mentions_mode_default_kinds_include_forum_events() {
let config = test_config(SubscribeMode::Mentions);
let filter = resolve_dynamic_channel_filter(&config, Uuid::new_v4(), &[])
.expect("dynamic channel should be subscribed");
let kinds = filter.kinds.expect("mentions mode should constrain kinds");

assert!(kinds.contains(&buzz_core::kind::KIND_STREAM_MESSAGE));
assert!(kinds.contains(&buzz_core::kind::KIND_FORUM_POST));
assert!(kinds.contains(&buzz_core::kind::KIND_FORUM_COMMENT));
assert!(kinds.contains(&buzz_core::kind::KIND_WORKFLOW_APPROVAL_REQUESTED));
assert!(kinds.contains(&buzz_core::kind::KIND_STREAM_REMINDER));
}

#[test]
fn test_mentions_mode_custom_kinds() {
let mut config = test_config(SubscribeMode::Mentions);
Expand Down
41 changes: 36 additions & 5 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ use std::time::Duration;
use acp::{AcpClient, EnvVar, McpServer};
use anyhow::Result;
use buzz_core::kind::{
KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_STREAM_MESSAGE,
KIND_STREAM_REMINDER, KIND_WORKFLOW_APPROVAL_REQUESTED,
KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_MEMBER_ADDED_NOTIFICATION,
KIND_MEMBER_REMOVED_NOTIFICATION, KIND_STREAM_MESSAGE, KIND_STREAM_REMINDER,
KIND_WORKFLOW_APPROVAL_REQUESTED,
};
use buzz_core::observer::{
decrypt_observer_payload, encrypt_observer_payload, OBSERVER_FRAME_TELEMETRY,
Expand Down Expand Up @@ -1442,6 +1443,8 @@ async fn tokio_main() -> Result<()> {
kinds: config.kinds_override.clone().unwrap_or_else(|| {
vec![
KIND_STREAM_MESSAGE,
KIND_FORUM_POST,
KIND_FORUM_COMMENT,
KIND_WORKFLOW_APPROVAL_REQUESTED,
KIND_STREAM_REMINDER,
]
Expand Down Expand Up @@ -3038,15 +3041,29 @@ fn spawn_failure_notice(
content: String,
) {
if let Some(rest) = rest_client {
let thread_tags = batch
let (thread_tags, triggering_kind, triggering_event_id) = batch
.events
.last()
.map(|be| queue::parse_thread_tags(&be.event))
.map(|be| {
(
queue::parse_thread_tags(&be.event),
be.event.kind.as_u16() as u32,
Some(be.event.id),
)
})
.unwrap_or_default();
let rest = rest.clone();
let channel_id = batch.channel_id;
tokio::spawn(async move {
pool::post_failure_notice(&rest, channel_id, &thread_tags, &content).await;
pool::post_failure_notice(
&rest,
channel_id,
&thread_tags,
triggering_kind,
triggering_event_id,
&content,
)
.await;
});
}
}
Expand Down Expand Up @@ -3641,6 +3658,20 @@ mod agent_draft_prompt_tests {
.contains("add them explicitly with `buzz channels add-member` only when authorized"));
assert!(prompt.contains("never changes membership automatically"));
}

#[test]
fn shared_base_prompt_distinguishes_forum_kinds_from_stream_messages() {
let prompt = include_str!("base_prompt.md");
assert!(prompt.contains("Forum channels are not stream channels"));
assert!(prompt.contains("kind `45001`"));
assert!(prompt.contains("kind `45003`"));
assert!(prompt.contains("stream default kind `9`"));
assert!(prompt.contains("legacy kind-`40002` stream roots"));
assert!(prompt.contains("inspect the supplied `Thread root kind` in `[Context]`"));
assert!(prompt.contains("only if the kind is unavailable"));
assert!(prompt.contains("buzz messages thread --channel <UUID> --event <root-id>"));
assert!(prompt.contains("Never send kind `45003` beneath kind-`9` or kind-`40002` roots"));
}
}

fn default_heartbeat_prompt() -> String {
Expand Down
141 changes: 121 additions & 20 deletions crates/buzz-acp/src/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2792,6 +2792,7 @@ async fn fetch_thread_context(
.kinds([
nostr::Kind::Custom(buzz_core::kind::KIND_STREAM_MESSAGE as u16),
nostr::Kind::Custom(buzz_core::kind::KIND_STREAM_MESSAGE_V2 as u16),
nostr::Kind::Custom(buzz_core::kind::KIND_FORUM_COMMENT as u16),
])
.custom_tags(e_tag, [root_event_id])
.custom_tags(h_tag, [ch_str.as_str()])
Expand Down Expand Up @@ -2907,6 +2908,7 @@ fn parse_thread_response(json: serde_json::Value) -> Option<ConversationContext>
messages,
total,
truncated,
root_kind: None,
})
}

Expand Down Expand Up @@ -2984,12 +2986,17 @@ fn parse_nostr_thread_response(
) -> Option<ConversationContext> {
let events = json.as_array()?;
let mut root_msg = None;
let mut root_kind = None;
let mut reply_msgs = Vec::new();

for ev in events {
let ev_id = ev.get("id").and_then(|v| v.as_str()).unwrap_or("");
if let Some(msg) = json_to_context_message(ev) {
if ev_id == root_event_id {
root_kind = ev
.get("kind")
.and_then(|value| value.as_u64())
.and_then(|kind| u32::try_from(kind).ok());
Comment thread
loganj marked this conversation as resolved.
root_msg = Some(msg);
} else {
reply_msgs.push((
Expand Down Expand Up @@ -3018,6 +3025,7 @@ fn parse_nostr_thread_response(
messages,
total,
truncated: false, // query returns all within limit
root_kind,
})
}

Expand Down Expand Up @@ -3615,36 +3623,91 @@ pub(crate) async fn reaction_add(rest: &crate::relay::RestClient, event_id: &str
}
}

/// Best-effort: post a visible failure notice (kind:9) to a channel after a
/// batch is dead-lettered. Replies into the thread of `thread_tags` when the
/// triggering event was threaded. Errors are logged and swallowed — the
/// notice must never take down the main loop.
/// Best-effort: post a visible failure notice to a channel after a batch is
/// dead-lettered. Replies into the triggering thread and preserves forum
/// comment kind semantics. Errors are logged and swallowed — the notice must
/// never take down the main loop.
pub(crate) async fn post_failure_notice(
rest: &crate::relay::RestClient,
channel_id: Uuid,
thread_tags: &ThreadTags,
triggering_kind: u32,
triggering_event_id: Option<nostr::EventId>,
content: &str,
) {
let thread_ref = thread_tags.root_event_id.as_deref().and_then(|root| {
let root_id = nostr::EventId::from_hex(root).ok()?;
let parent_id = thread_tags
.parent_event_id
.as_deref()
.and_then(|p| nostr::EventId::from_hex(p).ok())
.unwrap_or(root_id);
Some(buzz_sdk::ThreadRef {
root_event_id: root_id,
parent_event_id: parent_id,
let thread_ref = thread_tags
.root_event_id
.as_deref()
.and_then(|root| {
let root_id = nostr::EventId::from_hex(root).ok()?;
let parent_id = thread_tags
.parent_event_id
.as_deref()
.and_then(|p| nostr::EventId::from_hex(p).ok())
.unwrap_or(root_id);
Some(buzz_sdk::ThreadRef {
root_event_id: root_id,
parent_event_id: parent_id,
})
})
.or_else(|| {
triggering_event_id.map(|event_id| buzz_sdk::ThreadRef {
root_event_id: event_id,
parent_event_id: event_id,
})
});
let root_kind = if let Some(thread_ref) = &thread_ref {
let filter = nostr::Filter::new().id(thread_ref.root_event_id);
match tokio::time::timeout(Duration::from_secs(5), rest.query(&[filter])).await {
Ok(Ok(json)) => json
.as_array()
.and_then(|events| events.first())
.and_then(|event| event.get("kind"))
.and_then(serde_json::Value::as_u64)
.and_then(|kind| u32::try_from(kind).ok()),
Ok(Err(e)) => {
tracing::debug!(channel = %channel_id, "failure notice: root kind lookup failed: {e}");
None
}
Err(_) => {
tracing::debug!(channel = %channel_id, "failure notice: root kind lookup timed out");
None
}
}
} else {
None
}
.unwrap_or_else(|| {
if matches!(
triggering_kind,
buzz_core::kind::KIND_FORUM_POST | buzz_core::kind::KIND_FORUM_COMMENT
) {
buzz_core::kind::KIND_FORUM_POST
} else {
triggering_kind
}
});
let builder =
match buzz_sdk::build_message(channel_id, content, thread_ref.as_ref(), &[], false, &[]) {
Ok(b) => b,
Err(e) => {
tracing::warn!(channel = %channel_id, "failure notice: build failed: {e}");

let builder_result = if root_kind == buzz_core::kind::KIND_FORUM_POST {
match thread_ref.as_ref() {
Some(thread_ref) => {
buzz_sdk::build_forum_comment(channel_id, content, thread_ref, &[], &[])
}
None => {
tracing::warn!(channel = %channel_id, "failure notice: forum reply missing thread reference");
return;
}
};
}
} else {
buzz_sdk::build_message(channel_id, content, thread_ref.as_ref(), &[], false, &[])
};
let builder = match builder_result {
Ok(builder) => builder,
Err(e) => {
tracing::warn!(channel = %channel_id, "failure notice: build failed: {e}");
return;
}
};
let event = match builder.sign_with_keys(&rest.keys) {
Ok(e) => e,
Err(e) => {
Expand Down Expand Up @@ -4005,6 +4068,40 @@ mod tests {
assert!(with_core(None, None).is_none());
}

#[test]
fn test_parse_nostr_thread_response_captures_root_kind() {
let root_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let json = json!([
{
"id": root_id,
"kind": 45001,
"pubkey": "pub1",
"content": "forum root",
"created_at": 1710518400
},
{
"id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"kind": 45003,
"pubkey": "pub2",
"content": "forum reply",
"created_at": 1710518460
}
]);

let ctx = parse_nostr_thread_response(json, root_id).expect("should parse");
match ctx {
ConversationContext::Thread {
messages,
root_kind,
..
} => {
assert_eq!(root_kind, Some(45_001));
assert_eq!(messages[0].content, "forum root");
}
_ => panic!("expected Thread context"),
}
}

#[test]
fn test_parse_thread_response_basic() {
let json = json!({
Expand All @@ -4031,6 +4128,7 @@ mod tests {
messages,
total,
truncated,
..
} => {
assert_eq!(messages.len(), 2); // root + 1 reply
assert_eq!(total, 2); // 1 reply + 1 root
Expand Down Expand Up @@ -4068,6 +4166,7 @@ mod tests {
messages,
total,
truncated,
..
} => {
assert_eq!(messages.len(), 2);
assert_eq!(total, 11); // 10 replies + 1 root
Expand Down Expand Up @@ -4121,6 +4220,7 @@ mod tests {
messages,
total,
truncated,
..
} => {
// Should be reversed to chronological order.
assert_eq!(messages.len(), 2);
Expand Down Expand Up @@ -4265,6 +4365,7 @@ mod tests {
}],
total: 1,
truncated: false,
root_kind: None,
};

let pubkeys = collect_prompt_pubkeys(&batch, Some(&context));
Expand Down
Loading