Skip to content

Commit 1999b2f

Browse files
committed
fix(acp): route direct replies to agent
Signed-off-by: bgx4k3p <bgx4k3p@gmail.com>
1 parent d0af845 commit 1999b2f

3 files changed

Lines changed: 188 additions & 6 deletions

File tree

crates/buzz-acp/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# buzz-acp
22

3-
ACP harness that connects AI agents to Buzz. The harness listens for @mentions on the relay, prompts your agent, and the agent replies using the Buzz CLI.
3+
ACP harness that connects AI agents to Buzz. The harness listens for @mentions and direct replies to the agent on the relay, prompts your agent, and the agent replies using the Buzz CLI.
44

55
```
66
Buzz Relay ──WS──→ buzz-acp ──stdio──→ Your Agent
@@ -62,7 +62,7 @@ export GOOSE_MODE=auto
6262
buzz-acp
6363
```
6464

65-
That's it. The harness spawns `goose acp`, connects to the relay, discovers channels, and starts listening. When someone @mentions the agent, goose receives the message and can reply using the Buzz CLI that the harness configures automatically.
65+
That's it. The harness spawns `goose acp`, connects to the relay, discovers channels, and starts listening. When someone @mentions the agent or directly replies to one of its messages, goose receives the message and can reply using the Buzz CLI that the harness configures automatically. Direct messages continue to route through their participant `p` tags.
6666

6767
## Running with Codex
6868

@@ -252,7 +252,7 @@ Forum event kinds:
252252

253253
1. **Startup** — Spawns N agent subprocesses (default 1), sends ACP `initialize` to each, connects to the relay with NIP-42 auth.
254254
2. **Channel discovery** — Queries the relay REST API for accessible channels, subscribes to each.
255-
3. **Event loop** — Listens for @mention events (kind 9 with the agent's pubkey in a `#p` tag). Events queue per channel.
255+
3. **Event loop** — Listens for the bounded channel-message kinds. It accepts explicit @mentions and verifies direct-reply parent events before accepting an unmentioned reply; unrelated unmentioned chatter is dropped. Events queue per channel.
256256
4. **Prompting** — When events are pending and no prompt is in flight for that channel, drains all queued events for the oldest channel into a single batched prompt via ACP `session/prompt`.
257257
5. **Agent response** — The agent processes the prompt and uses the Buzz CLI (`send_message`, `get_messages`, etc.) to interact with Buzz.
258258
6. **Recovery** — If the agent crashes, the harness respawns it. If the relay disconnects, the harness reconnects with a `since` filter to avoid missing events.

crates/buzz-acp/src/config.rs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1268,7 +1268,11 @@ pub fn resolve_channel_filters(
12681268
KIND_STREAM_REMINDER,
12691269
]
12701270
});
1271-
let require_mention = !config.no_mention_filter;
1271+
// Mentions mode also accepts direct thread replies whose parent was
1272+
// authored by this agent. NIP-01 cannot express "#p OR reply to one
1273+
// of my events" in a single filter, so receive the bounded message
1274+
// kinds and enforce the mention/reply union in the application gate.
1275+
let require_mention = false;
12721276
for ch in &target_channels {
12731277
result.insert(
12741278
*ch,
@@ -1373,7 +1377,9 @@ pub fn resolve_dynamic_channel_filter(
13731377
KIND_STREAM_REMINDER,
13741378
]
13751379
})),
1376-
require_mention: !config.no_mention_filter,
1380+
// See resolve_channel_filters(): direct-reply routing requires the
1381+
// application gate to see unmentioned reply candidates.
1382+
require_mention: false,
13771383
}),
13781384
SubscribeMode::All => Some(ChannelFilter {
13791385
kinds: config.kinds_override.clone(),
@@ -1513,7 +1519,10 @@ mod tests {
15131519
assert_eq!(result.len(), 2);
15141520
for ch in &channels {
15151521
let f = result.get(ch).expect("channel should be present");
1516-
assert!(f.require_mention, "mentions mode requires mention");
1522+
assert!(
1523+
!f.require_mention,
1524+
"mentions mode must receive thread replies so the application gate can admit only replies to this agent"
1525+
);
15171526
let kinds = f.kinds.as_ref().expect("should have kinds");
15181527
assert!(kinds.contains(&buzz_core::kind::KIND_STREAM_MESSAGE));
15191528
assert!(kinds.contains(&buzz_core::kind::KIND_WORKFLOW_APPROVAL_REQUESTED));

crates/buzz-acp/src/lib.rs

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,64 @@ pub(crate) async fn is_dm_channel(
286286
}
287287
}
288288

289+
/// Return true when `event` is a direct thread reply to an event authored by
290+
/// this agent in the same channel.
291+
///
292+
/// Mentions mode subscribes to the bounded channel-message kinds without a
293+
/// relay-side `#p` filter so direct replies can reach this check. The normal
294+
/// mention matcher still handles DMs and explicit mentions; this is the narrow
295+
/// fallback for an unmentioned reply. Parent lookup is fail-closed: malformed
296+
/// tags, timeout/query failure, a missing parent, a signature/id mismatch, a
297+
/// different author, or a cross-channel parent all return false.
298+
async fn is_direct_reply_to_agent(
299+
event: &nostr::Event,
300+
channel_id: Uuid,
301+
agent_pubkey_hex: &str,
302+
rest_client: &relay::RestClient,
303+
) -> bool {
304+
let Some(parent_hex) = queue::parse_thread_tags(event).parent_event_id else {
305+
return false;
306+
};
307+
let Ok(parent_id) = nostr::EventId::from_hex(&parent_hex) else {
308+
return false;
309+
};
310+
let filter = nostr::Filter::new().id(parent_id);
311+
let response = match tokio::time::timeout(Duration::from_secs(2), rest_client.query(&[filter]))
312+
.await
313+
{
314+
Ok(Ok(response)) => response,
315+
Ok(Err(error)) => {
316+
tracing::debug!(parent_event_id = %parent_hex, "reply parent lookup failed: {error}");
317+
return false;
318+
}
319+
Err(_) => {
320+
tracing::debug!(parent_event_id = %parent_hex, "reply parent lookup timed out");
321+
return false;
322+
}
323+
};
324+
let Some(values) = response.as_array() else {
325+
return false;
326+
};
327+
let channel_hex = channel_id.to_string();
328+
329+
values.iter().any(|value| {
330+
let Ok(parent) = serde_json::from_value::<nostr::Event>(value.clone()) else {
331+
return false;
332+
};
333+
if parent.id != parent_id
334+
|| parent.pubkey.to_hex() != agent_pubkey_hex
335+
|| buzz_core::verify_event(&parent).is_err()
336+
{
337+
return false;
338+
}
339+
parent.tags.iter().any(|tag| {
340+
let parts = tag.as_slice();
341+
parts.first().map(String::as_str) == Some("h")
342+
&& parts.get(1).map(String::as_str) == Some(channel_hex.as_str())
343+
})
344+
})
345+
}
346+
289347
/// Query an author's kind:0 profile and check if their NIP-OA auth tag
290348
/// proves the same owner as us.
291349
async fn check_sibling_via_profile(
@@ -2245,6 +2303,17 @@ async fn tokio_main() -> Result<()> {
22452303
let matched = filter::match_event(&buzz_event.event, buzz_event.channel_id, &rules, &pubkey_hex).await;
22462304
let prompt_tag = match matched {
22472305
Some(m) => m.prompt_tag,
2306+
None if config.subscribe_mode == SubscribeMode::Mentions
2307+
&& is_direct_reply_to_agent(
2308+
&buzz_event.event,
2309+
buzz_event.channel_id,
2310+
&pubkey_hex,
2311+
&ctx.rest_client,
2312+
)
2313+
.await =>
2314+
{
2315+
"@reply".to_string()
2316+
}
22482317
None => {
22492318
tracing::debug!(channel_id = %buzz_event.channel_id, kind = buzz_event.event.kind.as_u16(), "event matched no rule — dropping");
22502319
continue;
@@ -4889,6 +4958,110 @@ mod author_gate_tests {
48894958
}
48904959
}
48914960

4961+
#[cfg(test)]
4962+
mod reply_routing_tests {
4963+
use super::*;
4964+
use nostr::{EventBuilder, Kind, Tag};
4965+
use tokio::io::{AsyncReadExt, AsyncWriteExt};
4966+
4967+
fn channel_message(
4968+
keys: &nostr::Keys,
4969+
channel_id: Uuid,
4970+
content: &str,
4971+
reply_to: Option<nostr::EventId>,
4972+
) -> nostr::Event {
4973+
let mut tags = vec![Tag::parse(["h", &channel_id.to_string()]).unwrap()];
4974+
if let Some(parent) = reply_to {
4975+
tags.push(Tag::parse(["e", &parent.to_hex(), "", "reply"]).unwrap());
4976+
}
4977+
EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), content)
4978+
.tags(tags)
4979+
.sign_with_keys(keys)
4980+
.unwrap()
4981+
}
4982+
4983+
async fn rest_client_returning(
4984+
events: serde_json::Value,
4985+
) -> (relay::RestClient, tokio::task::JoinHandle<()>) {
4986+
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
4987+
.await
4988+
.expect("bind test HTTP server");
4989+
let base_url = format!("http://{}", listener.local_addr().unwrap());
4990+
let body = events.to_string();
4991+
let server = tokio::spawn(async move {
4992+
let (mut socket, _) = listener.accept().await.expect("accept query");
4993+
let mut request = vec![0; 8192];
4994+
let _ = socket.read(&mut request).await;
4995+
let response = format!(
4996+
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
4997+
body.len(),
4998+
body
4999+
);
5000+
socket.write_all(response.as_bytes()).await.unwrap();
5001+
});
5002+
(
5003+
relay::RestClient {
5004+
http: reqwest::Client::new(),
5005+
base_url,
5006+
keys: nostr::Keys::generate(),
5007+
auth_tag_json: None,
5008+
},
5009+
server,
5010+
)
5011+
}
5012+
5013+
#[tokio::test]
5014+
async fn direct_reply_to_agent_is_routable_without_mention() {
5015+
let agent = nostr::Keys::generate();
5016+
let human = nostr::Keys::generate();
5017+
let channel_id = Uuid::new_v4();
5018+
let parent = channel_message(&agent, channel_id, "agent answer", None);
5019+
let reply = channel_message(&human, channel_id, "follow-up", Some(parent.id));
5020+
let (rest, server) = rest_client_returning(serde_json::json!([parent])).await;
5021+
5022+
assert!(
5023+
is_direct_reply_to_agent(&reply, channel_id, &agent.public_key().to_hex(), &rest,)
5024+
.await
5025+
);
5026+
server.await.unwrap();
5027+
}
5028+
5029+
#[tokio::test]
5030+
async fn reply_to_another_author_is_not_routable() {
5031+
let agent = nostr::Keys::generate();
5032+
let human = nostr::Keys::generate();
5033+
let channel_id = Uuid::new_v4();
5034+
let parent = channel_message(&human, channel_id, "human message", None);
5035+
let reply = channel_message(&human, channel_id, "human follow-up", Some(parent.id));
5036+
let (rest, server) = rest_client_returning(serde_json::json!([parent])).await;
5037+
5038+
assert!(
5039+
!is_direct_reply_to_agent(&reply, channel_id, &agent.public_key().to_hex(), &rest,)
5040+
.await
5041+
);
5042+
server.await.unwrap();
5043+
}
5044+
5045+
#[tokio::test]
5046+
async fn top_level_unmentioned_message_is_not_routable() {
5047+
let agent = nostr::Keys::generate();
5048+
let human = nostr::Keys::generate();
5049+
let channel_id = Uuid::new_v4();
5050+
let message = channel_message(&human, channel_id, "ordinary chatter", None);
5051+
let rest = relay::RestClient {
5052+
http: reqwest::Client::new(),
5053+
base_url: "http://localhost:0".into(),
5054+
keys: nostr::Keys::generate(),
5055+
auth_tag_json: None,
5056+
};
5057+
5058+
assert!(
5059+
!is_direct_reply_to_agent(&message, channel_id, &agent.public_key().to_hex(), &rest,)
5060+
.await
5061+
);
5062+
}
5063+
}
5064+
48925065
#[cfg(test)]
48935066
mod observer_snapshot_race_tests {
48945067
use super::*;

0 commit comments

Comments
 (0)