Files
buzz/crates/buzz-acp/src/scope.rs
Salman Mohammed 674c173eb7 feat(buzz-acp): give each channel thread its own agent session (#6732)
## What this does

In a channel, people often run several unrelated conversations at once
(separate threads). Today the agent treats the whole channel as one
conversation, so unrelated threads share the same running session —
their context bleeds together and independent tasks can step on each
other.

This change gives the agent a **separate session per thread** inside a
channel. Direct messages stay as one conversation (unchanged). The
channel is still the boundary for who is allowed in and what is visible
— only the agent's working context is now split by thread.

## How it is turned on

Off by default. Operators opt in with one setting:

- `BUZZ_ACP_SESSION_POLICY=channel` — default, current behavior
- `BUZZ_ACP_SESSION_POLICY=thread` — new per-thread behavior

Being behind a flag means we can enable it for a few agents, watch how
it behaves, and roll back instantly without a code change.

## Key design decisions

- **Decide the thread once, up front.** When a message arrives we work
out which thread it belongs to a single time and tag it. Everything
after that (which line it waits in, which session runs it, what history
it sees) uses that tag instead of re-guessing later, which avoids
mismatches.
- **Default stays identical to today.** Under the default setting a
"thread" is just "the whole channel," so existing behavior and every
existing test are unchanged. The new, riskier behavior is strictly
opt-in.
- **Give the agent only its thread's history.** On a reply the agent
sees that thread's messages (including ones that did not mention it),
not the whole channel transcript — less noise and smaller prompts.
- **Don't let one channel use more memory than before.** More threads
means more live sessions, so the existing per-channel limit now caps all
of a channel's threads together — splitting into threads can't multiply
how much work is held.

## Bugs found and fixed while iterating (from review)

- **Same thread, two sessions.** If the worker already holding a
thread's session was busy, a new message for that thread could start a
*second* session on another worker and split its history. Now it waits
for the right worker instead of forking.
- **Interrupting the wrong thread.** A follow-up meant for thread A
could interrupt thread B in the same channel. Interrupts now target the
exact thread.
- **Stuck thread after a crash.** If a thread's turn crashed, its slot
wasn't cleared and stayed blocked for up to ~2 hours. It now clears
right away and retries.
- **Lost the original request.** When a thread was interrupted and then
had to wait for a busy worker, only the follow-up was kept and the
original request was dropped. The full request is now preserved on
retry.
- **Same thread seen as two.** Two spellings of the same thread id
(upper/lower case) could be treated as different threads. Normalized so
they count as one.

## Not in this PR

- The desktop Settings toggle and rollout wiring for managed agents —
https://github.com/block/buzz/pull/6909
- One pre-existing retry edge case (present today without this flag,
unrelated to this change) — tracked separately so this PR stays focused.

## Testing

The full `buzz-acp` test suite passes (830+ unit and integration tests),
plus new focused tests for thread routing, session reuse, interrupt
targeting, crash recovery, and request preservation. Behavior with the
flag off is unchanged.

---------

Signed-off-by: Salman Mohammed <smohammed@squareup.com>
Signed-off-by: Leo <5faf251baee50ee6bcde338aef6acdd70bb3e60115664c2cd490d94a55dfc488@buzz.block.builderlab.xyz>
Co-authored-by: Leo <5faf251baee50ee6bcde338aef6acdd70bb3e60115664c2cd490d94a55dfc488@buzz.block.builderlab.xyz>
2026-08-31 18:17:28 -04:00

406 lines
16 KiB
Rust

//! Session scoping for ACP.
//!
//! A [`SessionScope`] is the single hashable key that identifies an ACP
//! provider session and its conversational-context boundary. It is derived
//! **once**, when an eligible event is admitted, from the operator
//! [`SessionPolicy`], whether the channel is a DM, and the event's NIP-10
//! thread tags. Later code must never re-infer scope from the last event in a
//! batch — it carries the resolved scope instead.
//!
//! Policy matrix (see the "Make ACP sessions thread-scoped" ticket):
//!
//! | Surface | Scope |
//! | ----------------------------------- | --------------------------------------- |
//! | New top-level channel mention | `Thread(channel_id, triggering_event)` |
//! | Reply in a channel thread | `Thread(channel_id, canonical_root)` |
//! | Repeated mention in the same thread | reuse that thread scope |
//! | Direct message | `Conversation(channel_id)` |
//!
//! Under [`SessionPolicy::Channel`] (the current default / rollback path) every
//! surface collapses to `Conversation(channel_id)`, preserving today's
//! channel-keyed behavior exactly.
use nostr::Event;
use uuid::Uuid;
use crate::queue::parse_thread_tags;
/// Operator policy controlling how ACP provider sessions are scoped.
///
/// Selected via `--session-policy` / `BUZZ_ACP_SESSION_POLICY`. Defaults to
/// [`Channel`](SessionPolicy::Channel) so the feature ships dark and can be
/// canaried, then flipped, then rolled back without code changes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
pub enum SessionPolicy {
/// Legacy behavior: one provider session per channel. Every event in a
/// channel shares a `Conversation(channel_id)` scope.
#[default]
Channel,
/// Thread-scoped: each canonical channel thread gets an isolated provider
/// session. DMs remain conversation-scoped.
Thread,
}
impl SessionPolicy {
/// Append only the configured session model to the shared base instructions.
/// The resulting base is reused by modern and legacy ACP standing context.
pub(crate) fn append_session_model(self, base_prompt: &str) -> String {
let session_model = match self {
Self::Channel => include_str!("session_model_channel.md"),
Self::Thread => include_str!("session_model_thread.md"),
};
format!("{}\n\n{}", base_prompt.trim_end(), session_model.trim_end())
}
}
impl std::fmt::Display for SessionPolicy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Channel => f.write_str("channel"),
Self::Thread => f.write_str("thread"),
}
}
}
/// A hashable ACP execution and conversational-context scope.
///
/// This is the canonical key for provider sessions, queue partitions, in-flight
/// tracking, and context gathering. The channel remains the authorization and
/// collaboration boundary; the scope is the default *execution* boundary.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum SessionScope {
/// The whole channel is one session. Used for DMs always, and for every
/// channel event under [`SessionPolicy::Channel`].
Conversation { channel_id: Uuid },
/// A single canonical thread within a channel, keyed by its root event id
/// (64-char lowercase hex).
Thread {
channel_id: Uuid,
root_event_id: String,
},
}
impl SessionScope {
/// The channel this scope belongs to. Always available — the channel is the
/// authorization boundary regardless of scope variant.
pub fn channel_id(&self) -> Uuid {
match self {
Self::Conversation { channel_id } => *channel_id,
Self::Thread { channel_id, .. } => *channel_id,
}
}
/// The canonical thread-root event id for a [`Thread`](Self::Thread) scope,
/// or `None` for a conversation scope.
pub fn root_event_id(&self) -> Option<&str> {
match self {
Self::Conversation { .. } => None,
Self::Thread { root_event_id, .. } => Some(root_event_id),
}
}
/// True when this scope is thread-scoped (not conversation-scoped).
pub fn is_thread(&self) -> bool {
matches!(self, Self::Thread { .. })
}
/// Derive the scope for an admitted event.
///
/// Resolution order:
/// 1. DMs are always [`Conversation`](Self::Conversation) — the ticket keeps
/// direct messages conversation-scoped regardless of policy.
/// 2. Under [`SessionPolicy::Channel`], every channel event is
/// conversation-scoped (legacy / rollback behavior).
/// 3. Under [`SessionPolicy::Thread`], a channel event with a NIP-10 root
/// tag scopes to that canonical root; a top-level mention (no thread
/// tags) opens a new thread rooted at the triggering event id.
///
/// Thread roots are resolved with [`parse_thread_tags`], i.e. Buzz's shared
/// [`buzz_core::nip10`] canonical-root rules — a malformed marker id is
/// ignored (treated as top-level), and a lone `root` marker with no `reply`
/// is top-level, matching relay ingest.
///
/// The root id is normalized to lowercase before it becomes the scope key.
/// The shared NIP-10 parser accepts and preserves uppercase ASCII hex
/// (`is_ascii_hexdigit`), but the relay decodes event ids to bytes on
/// ingest, so `AB…` and `ab…` name the *same* thread. Without normalization
/// those equivalent spellings would hash to different `Thread` keys and
/// split one relay thread across two ACP sessions (queue state, provider
/// sessions, affinity, delivery ledgers). `nostr::EventId::to_hex()` is
/// already lowercase, so the top-level path is unaffected.
pub fn derive(policy: SessionPolicy, channel_id: Uuid, is_dm: bool, event: &Event) -> Self {
if is_dm || policy == SessionPolicy::Channel {
return Self::Conversation { channel_id };
}
let root_event_id = match parse_thread_tags(event).root_event_id {
Some(root) => root,
None => event.id.to_hex(),
};
Self::Thread {
channel_id,
root_event_id: root_event_id.to_ascii_lowercase(),
}
}
/// A compact, log-friendly label for telemetry (e.g. `conversation` or
/// `thread:<root8>`), never leaking full ids into high-cardinality fields.
pub fn telemetry_label(&self) -> String {
match self {
Self::Conversation { .. } => "conversation".to_string(),
Self::Thread { root_event_id, .. } => {
let short: String = root_event_id.chars().take(8).collect();
format!("thread:{short}")
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use nostr::{EventBuilder, Keys, Kind};
/// Build a signed event with the given NIP-10 `e`/`p` tags.
fn event_with_tags(tags: Vec<Vec<String>>) -> Event {
let keys = Keys::generate();
let tags: Vec<nostr::Tag> = tags
.into_iter()
.map(|t| nostr::Tag::parse(t).expect("valid tag"))
.collect();
EventBuilder::new(Kind::Custom(9), "hello")
.tags(tags)
.sign_with_keys(&keys)
.unwrap()
}
fn plain_event() -> Event {
event_with_tags(vec![])
}
#[test]
fn session_model_is_appended_once_and_matches_policy() {
let base = include_str!("base_prompt.md");
assert!(!base.contains("## Session Model"));
for policy in [SessionPolicy::Channel, SessionPolicy::Thread] {
let prompt = policy.append_session_model(base);
assert!(prompt.starts_with(base.trim_end()));
assert_eq!(prompt.matches("## Session Model").count(), 1);
assert!(prompt.ends_with("assume the owning session has it handled."));
assert!(prompt.contains("DMs stay one conversation"));
assert!(prompt.contains(
"core memory, your workspace on disk, relay access, and channel authorization"
));
assert!(prompt.contains("leave execution with the owning session"));
match policy {
SessionPolicy::Channel => {
assert!(prompt.contains("one per-channel session"));
assert!(!prompt.contains("each thread gets its own"));
assert!(!prompt.contains("sibling channel thread"));
}
SessionPolicy::Thread => {
assert!(prompt.contains("each thread gets its own"));
assert!(prompt.contains("sibling channel thread"));
assert!(!prompt.contains("one per-channel session"));
}
}
}
}
#[test]
fn dm_is_always_conversation_scoped_under_thread_policy() {
let ch = Uuid::new_v4();
// Even a DM with a reply tag stays conversation-scoped.
let root = "a".repeat(64);
let reply = event_with_tags(vec![
vec!["e".into(), root.clone(), String::new(), "root".into()],
vec!["e".into(), "b".repeat(64), String::new(), "reply".into()],
]);
let scope = SessionScope::derive(SessionPolicy::Thread, ch, true, &reply);
assert_eq!(scope, SessionScope::Conversation { channel_id: ch });
}
#[test]
fn channel_policy_collapses_everything_to_conversation() {
let ch = Uuid::new_v4();
let root = "a".repeat(64);
let reply = event_with_tags(vec![
vec!["e".into(), root.clone(), String::new(), "root".into()],
vec!["e".into(), "b".repeat(64), String::new(), "reply".into()],
]);
// A threaded reply under Channel policy is still conversation-scoped.
let scope = SessionScope::derive(SessionPolicy::Channel, ch, false, &reply);
assert_eq!(scope, SessionScope::Conversation { channel_id: ch });
// As is a top-level mention.
let scope = SessionScope::derive(SessionPolicy::Channel, ch, false, &plain_event());
assert_eq!(scope, SessionScope::Conversation { channel_id: ch });
}
#[test]
fn top_level_mention_opens_thread_rooted_at_trigger() {
let ch = Uuid::new_v4();
let ev = plain_event();
let scope = SessionScope::derive(SessionPolicy::Thread, ch, false, &ev);
assert_eq!(
scope,
SessionScope::Thread {
channel_id: ch,
root_event_id: ev.id.to_hex(),
}
);
}
#[test]
fn direct_reply_to_root_scopes_to_that_root() {
let ch = Uuid::new_v4();
let root = "c".repeat(64);
// A single `e` tag carrying only a `root` marker.
let ev = event_with_tags(vec![vec![
"e".into(),
root.clone(),
String::new(),
"root".into(),
]]);
// NIP-10: lone `root` with no `reply` is top-level per ingest rules, so
// this yields a top-level scope rooted at the trigger, not `root`.
let scope = SessionScope::derive(SessionPolicy::Thread, ch, false, &ev);
assert_eq!(
scope,
SessionScope::Thread {
channel_id: ch,
root_event_id: ev.id.to_hex(),
}
);
}
#[test]
fn nested_reply_scopes_to_canonical_root_not_parent() {
let ch = Uuid::new_v4();
let root = "c".repeat(64);
let parent = "d".repeat(64);
let ev = event_with_tags(vec![
vec!["e".into(), root.clone(), String::new(), "root".into()],
vec!["e".into(), parent.clone(), String::new(), "reply".into()],
]);
let scope = SessionScope::derive(SessionPolicy::Thread, ch, false, &ev);
// Scope keys on the canonical ROOT, never the immediate parent.
assert_eq!(
scope,
SessionScope::Thread {
channel_id: ch,
root_event_id: root,
}
);
}
#[test]
fn repeated_replies_in_same_thread_share_scope() {
let ch = Uuid::new_v4();
let root = "e".repeat(64);
let mk_reply = || {
event_with_tags(vec![
vec!["e".into(), root.clone(), String::new(), "root".into()],
vec!["e".into(), "f".repeat(64), String::new(), "reply".into()],
])
};
let a = SessionScope::derive(SessionPolicy::Thread, ch, false, &mk_reply());
let b = SessionScope::derive(SessionPolicy::Thread, ch, false, &mk_reply());
assert_eq!(a, b, "same-root replies must reuse the same thread scope");
}
#[test]
fn different_top_level_mentions_get_distinct_scopes() {
let ch = Uuid::new_v4();
let a = SessionScope::derive(SessionPolicy::Thread, ch, false, &plain_event());
let b = SessionScope::derive(SessionPolicy::Thread, ch, false, &plain_event());
assert_ne!(
a, b,
"two independent top-level mentions must not share a session"
);
}
#[test]
fn mixed_case_root_spellings_share_one_thread_scope() {
// The relay decodes event ids to bytes, so `AB…` and `ab…` name the
// same thread. Equivalent-case root tags must resolve to the SAME
// `SessionScope::Thread` key, or thread state would split in two.
let ch = Uuid::new_v4();
let root_lower = "a1b2c3d4e5f6".repeat(4) + &"0".repeat(16); // 64 hex
assert_eq!(root_lower.len(), 64);
let root_upper = root_lower.to_ascii_uppercase();
let mk = |root: &str| {
event_with_tags(vec![
vec!["e".into(), root.to_string(), String::new(), "root".into()],
vec!["e".into(), "f".repeat(64), String::new(), "reply".into()],
])
};
let lower = SessionScope::derive(SessionPolicy::Thread, ch, false, &mk(&root_lower));
let upper = SessionScope::derive(SessionPolicy::Thread, ch, false, &mk(&root_upper));
assert_eq!(
lower, upper,
"case-equivalent root spellings must share one thread scope"
);
// And the stored key is normalized to lowercase.
assert_eq!(upper.root_event_id(), Some(root_lower.as_str()));
}
#[test]
fn malformed_thread_tag_falls_back_to_top_level() {
let ch = Uuid::new_v4();
// A non-64-hex marker id is ignored by the shared NIP-10 resolver, so
// the event is treated as top-level (rooted at its own id).
let ev = event_with_tags(vec![vec![
"e".into(),
"not-a-valid-hex-id".into(),
String::new(),
"reply".into(),
]]);
let scope = SessionScope::derive(SessionPolicy::Thread, ch, false, &ev);
assert_eq!(
scope,
SessionScope::Thread {
channel_id: ch,
root_event_id: ev.id.to_hex(),
}
);
}
#[test]
fn accessors_and_labels() {
let ch = Uuid::new_v4();
let conv = SessionScope::Conversation { channel_id: ch };
assert_eq!(conv.channel_id(), ch);
assert_eq!(conv.root_event_id(), None);
assert!(!conv.is_thread());
assert_eq!(conv.telemetry_label(), "conversation");
let root = "abcdef0123456789".repeat(4); // 64 hex chars
let thread = SessionScope::Thread {
channel_id: ch,
root_event_id: root.clone(),
};
assert_eq!(thread.channel_id(), ch);
assert_eq!(thread.root_event_id(), Some(root.as_str()));
assert!(thread.is_thread());
assert_eq!(thread.telemetry_label(), "thread:abcdef01");
}
#[test]
fn scope_is_hashable_and_usable_as_map_key() {
use std::collections::HashMap;
let ch = Uuid::new_v4();
let mut map: HashMap<SessionScope, u32> = HashMap::new();
let s1 = SessionScope::Thread {
channel_id: ch,
root_event_id: "a".repeat(64),
};
let s2 = SessionScope::Conversation { channel_id: ch };
*map.entry(s1.clone()).or_insert(0) += 1;
*map.entry(s1.clone()).or_insert(0) += 1;
*map.entry(s2).or_insert(0) += 1;
assert_eq!(map.get(&s1), Some(&2));
assert_eq!(map.len(), 2);
}
}