Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,14 @@ RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz
# Set to true to process the agent's own messages (default: ignore self).
# BUZZ_ACP_NO_IGNORE_SELF=false

# ── Session scoping ──────────────────────────────────────────────────────────
# How ACP provider sessions are scoped in channels: "channel" (default) or
# "thread". "channel" keeps one provider session per channel (legacy). "thread"
# gives each canonical channel thread its own isolated provider session; direct
# messages stay conversation-scoped either way. Ships as "channel" so thread
# scoping can be canaried and rolled back without code changes.
# BUZZ_ACP_SESSION_POLICY=channel

# ── Context ──────────────────────────────────────────────────────────────────
# Max context messages fetched for thread replies and DMs (0–100). 0 = disabled.
# BUZZ_ACP_CONTEXT_MESSAGE_LIMIT=12
Expand Down
13 changes: 9 additions & 4 deletions crates/buzz-acp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,12 +161,17 @@ The gate applies to **all** inbound events — @mentions, DMs, thread replies, a
| Command | Effect |
|---------|--------|
| `!shutdown` | Gracefully exits the harness. |
| `!cancel` | Cancels the current in-flight turn for that channel, if any. |
| `!rotate` | Rotates the ACP session for that channel. If a turn is in-flight, it is cancelled and the channel session is invalidated when the task returns; otherwise the cached idle session is invalidated immediately. The next queued/received event starts a fresh session. |
| `!cancel` | Cancels the current in-flight turn for the command's resolved session scope, if any. |
| `!rotate` | Rotates the ACP session for the command's resolved session scope. If a turn is in flight, it is cancelled and that scoped session is invalidated when the task returns; otherwise the cached scoped session is invalidated immediately. The next queued/received event in that scope starts a fresh session. |

Use `!cancel` to stop only the current turn; it is a no-op when the channel is idle. Use `!rotate` when you want the next turn in the channel to start from a fresh ACP session, even if the channel is currently idle.
Under the default `channel` policy, a session scope is the whole channel, so these commands retain their channel-wide behavior. Under the `thread` policy, post the command as a reply in the target thread so `!cancel` or `!rotate` affects only that thread. DMs remain one conversation scope. `!cancel` is a no-op when its scope is idle.

Owner control commands must be kind:9 stream messages from the owner, must mention this agent with a `p` tag, and are consumed by the harness instead of being forwarded to the agent.
Owner control commands must be kind:9 stream messages from the owner, must have body exactly `!cancel`, `!rotate`, or `!shutdown` after trimming, and must mention this agent with a separate `p` tag. They are consumed by the harness instead of being forwarded to the agent. An inline `@Name` changes the body and does not match. With the Buzz CLI, target a thread while preserving the exact command body by passing the mention separately:

```bash
buzz messages send --channel <channel-id> --reply-to <thread-root-id> \
--mention <agent-pubkey> --content '!cancel'
```

> **Note:** The default mode is `owner-only`. Agents without a registered `agent_owner_pubkey` will not respond to any events until the owner is resolved. Set `--respond-to anyone` to disable the gate entirely.

Expand Down
6 changes: 0 additions & 6 deletions crates/buzz-acp/src/base_prompt.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
You are operating inside the Buzz platform — a Nostr-based messaging platform for human-agent collaboration. The buzz-acp harness routes channel events to your session.

## Session Model

You are one per-channel session of your agent identity — not the only copy. Each channel gets its own independent conversation context, and multiple sessions of the same agent may be active in different channels at the same time. Sessions share your core memory, your workspace on disk, and the relay. They do NOT share conversation context, in-progress reasoning, or in-context task state.

When a human references work "you" are doing in another channel, that work belongs to a different session of you. Unless the human asks you to take it over or coordinate it from this channel, leave execution with the owning session — answer from what you can verify (core memory, workspace files, relay messages) and assume the owning session has it handled.

## Buzz CLI

The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ_PRIVATE_KEY`, `BUZZ_AUTH_TAG`. Exit codes: 0 ok, 1 user error, 2 network, 3 auth, 4 other. Output is structured JSON.
Expand Down
117 changes: 115 additions & 2 deletions crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,19 @@ pub struct CliArgs {
#[arg(long, env = "BUZZ_ACP_DEDUP", default_value = "queue", value_enum)]
pub dedup: DedupMode,

/// How ACP provider sessions are scoped in channels.
/// channel (default): one provider session per channel (legacy behavior).
/// thread: each canonical channel thread gets an isolated provider session;
/// direct messages stay conversation-scoped either way. Ships as `channel`
/// so thread scoping can be canaried and rolled back without code changes.
#[arg(
long,
env = "BUZZ_ACP_SESSION_POLICY",
default_value = "channel",
value_enum
)]
pub session_policy: crate::scope::SessionPolicy,

/// How to handle new @mentions while a turn is already in-flight.
/// steer (default): cancel+re-prompt, framing the new mention as a message
/// that arrived mid-task — the agent keeps working and weaves it in.
Expand Down Expand Up @@ -536,6 +549,8 @@ pub struct Config {
pub initial_message: Option<String>,
pub subscribe_mode: SubscribeMode,
pub dedup_mode: DedupMode,
/// How ACP provider sessions are scoped in channels (channel vs thread).
pub session_policy: crate::scope::SessionPolicy,
pub multiple_event_handling: MultipleEventHandling,
pub ignore_self: bool,
pub kinds_override: Option<Vec<u32>>,
Expand Down Expand Up @@ -646,14 +661,43 @@ const SESSION_TITLE_SEPARATOR: &str = " · ";
/// survives. Returns the bare agent name when there is no channel, the channel
/// name is blank, or no room is left for it.
pub(crate) fn compose_session_title(agent: &str, channel_name: Option<&str>) -> String {
compose_session_title_with_limit(agent, channel_name, SESSION_TITLE_MAX_CHARS)
}

/// Append the canonical thread root's first eight characters to a session title.
/// Reserve suffix space before truncating names so thread identity always survives.
/// Conversation and heartbeat sessions preserve their existing title behavior.
pub(crate) fn compose_scoped_session_title(
agent: &str,
channel_name: Option<&str>,
thread_root: Option<&str>,
) -> String {
let Some(root) = thread_root.filter(|root| !root.is_empty()) else {
return compose_session_title(agent, channel_name);
};
let short_root: String = root.chars().take(8).collect();
let suffix = format!("{SESSION_TITLE_SEPARATOR}{short_root}");
let budget = SESSION_TITLE_MAX_CHARS.saturating_sub(suffix.chars().count());
let agent: String = agent.chars().take(budget).collect();
format!(
"{}{suffix}",
compose_session_title_with_limit(agent.trim_end(), channel_name, budget)
)
}

fn compose_session_title_with_limit(
agent: &str,
channel_name: Option<&str>,
max_chars: usize,
) -> String {
let Some(channel) = channel_name.and_then(sanitize_session_title) else {
return agent.to_string();
};
// Reserve the separator and the `#` sigil alongside the agent name.
let reserved = agent.chars().count() + SESSION_TITLE_SEPARATOR.chars().count() + 1;
let channel: String = channel
.chars()
.take(SESSION_TITLE_MAX_CHARS.saturating_sub(reserved))
.take(max_chars.saturating_sub(reserved))
.collect::<String>()
.trim_end()
.to_string();
Expand Down Expand Up @@ -1113,6 +1157,7 @@ impl Config {
initial_message: args.initial_message,
subscribe_mode: args.subscribe,
dedup_mode: args.dedup,
session_policy: args.session_policy,
multiple_event_handling: args.multiple_event_handling,
ignore_self: !args.no_ignore_self,
kinds_override: args.kinds,
Expand Down Expand Up @@ -1164,7 +1209,7 @@ impl Config {
format!(" allowed_respond_to=[{}]", modes.join(","))
};
format!(
"relay={} pubkey={} agent_cmd={} {} mcp_cmd={} idle_timeout={}s max_turn={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} meh={:?} ignore_self={} context_limit={} max_turns_per_session={} presence={} typing={} memory={} model={} permission_mode={} {}{}",
"relay={} pubkey={} agent_cmd={} {} mcp_cmd={} idle_timeout={}s max_turn={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} session_policy={} meh={:?} ignore_self={} context_limit={} max_turns_per_session={} presence={} typing={} memory={} model={} permission_mode={} {}{}",
self.relay_url,
self.keys.public_key().to_hex(),
self.agent_command,
Expand All @@ -1176,6 +1221,7 @@ impl Config {
self.heartbeat_interval_secs,
self.subscribe_mode,
self.dedup_mode,
self.session_policy,
self.multiple_event_handling,
self.ignore_self,
self.context_message_limit,
Expand Down Expand Up @@ -1489,6 +1535,7 @@ mod tests {
initial_message: None,
subscribe_mode: mode,
dedup_mode: DedupMode::Queue,
session_policy: crate::scope::SessionPolicy::Channel,
multiple_event_handling: MultipleEventHandling::Queue,
ignore_self: true,
kinds_override: None,
Expand Down Expand Up @@ -2618,6 +2665,42 @@ channels = "ALL"
assert!(result.is_empty());
}

// ── Session policy parsing + default ──────────────────────────────────────

#[test]
fn test_session_policy_default_is_channel() {
// Ships dark: the default must be `channel` so thread scoping is opt-in
// and can be rolled back without code changes.
let args = CliArgs::parse_from(["buzz-acp", "--private-key", &"0".repeat(64)]);
assert_eq!(args.session_policy, crate::scope::SessionPolicy::Channel);
}

#[test]
fn test_session_policy_thread_flag_parses() {
let args = CliArgs::parse_from([
"buzz-acp",
"--private-key",
&"0".repeat(64),
"--session-policy",
"thread",
]);
assert_eq!(args.session_policy, crate::scope::SessionPolicy::Thread);
}

#[test]
fn test_session_policy_env_var_parses() {
// The env fallback (`BUZZ_ACP_SESSION_POLICY`) must resolve to the same
// value as the flag; this is what the managed-agent runtime sets.
let args = CliArgs::parse_from([
"buzz-acp",
"--private-key",
&"0".repeat(64),
"--session-policy=thread",
]);
assert_eq!(args.session_policy, crate::scope::SessionPolicy::Thread);
assert_eq!(args.session_policy.to_string(), "thread");
}

// ── Multiple-event-handling validation + default ──────────────────────────

#[test]
Expand Down Expand Up @@ -2991,6 +3074,36 @@ channels = "ALL"
assert_eq!(compose_session_title(&agent, Some("buzz-dev")), agent);
}

#[test]
fn scoped_session_title_keeps_short_root_even_when_names_fill_the_cap() {
let root = "abcdef01".repeat(8);
assert_eq!(
compose_scoped_session_title("Fizz", Some("buzz-dev"), Some(&root)),
"Fizz · #buzz-dev · abcdef01"
);
assert_eq!(
compose_scoped_session_title("Fizz", None, Some(&root)),
"Fizz · abcdef01"
);
assert_eq!(
compose_scoped_session_title("Fizz", Some("buzz-dev"), Some("abc")),
"Fizz · #buzz-dev · abc"
);
for (agent, channel) in [
("🐝".repeat(80), "work".into()),
("Fizz".into(), "🐝".repeat(100)),
] {
let title = compose_scoped_session_title(&agent, Some(&channel), Some(&root));
assert_eq!(title.chars().count(), SESSION_TITLE_MAX_CHARS);
assert!(title.ends_with(" · abcdef01"));
}
assert_eq!(
compose_scoped_session_title("Fizz", Some("buzz-dev"), None),
"Fizz · #buzz-dev"
);
assert_eq!(compose_scoped_session_title("Fizz", None, None), "Fizz");
}

/// Every arg whose env var name contains KEY/SECRET/TOKEN/PASSWORD/CRED/AUTH
/// must set `hide_env_values = true` to prevent credential leakage in --help.
#[test]
Expand Down
Loading
Loading