Skip to content
Closed
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
16 changes: 16 additions & 0 deletions crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,15 @@ pub struct CliArgs {
#[arg(long, env = "BUZZ_ACP_RELAY_OBSERVER", default_value_t = false)]
pub relay_observer: bool,

/// Whether the agent should default to replying in the current thread.
///
/// Mirrors the `thread_replies` behavioral config from persona packs.
/// When false, the harness does NOT append `--reply-to` instructions for
/// new top-level channel mentions, letting the agent post flat at channel root.
/// Existing thread and DM reply anchors are unaffected.
#[arg(long, env = "BUZZ_ACP_THREAD_REPLIES", default_value_t = true)]
pub thread_replies: bool,

/// Exit after this many seconds with no dispatched events and no turn in flight.
/// 0 disables inactivity self-termination.
#[arg(long, env = "BUZZ_ACP_EXIT_AFTER_INACTIVITY", default_value_t = 0)]
Expand Down Expand Up @@ -544,6 +553,11 @@ pub struct Config {
pub respond_to_allowlist: HashSet<String>,
/// Allowed `respond_to` modes. Empty = all modes allowed.
pub allowed_respond_to: Vec<String>,
/// Whether the agent should default to replying in the current thread.
///
/// Mirrors the `thread_replies` behavioral config from persona packs.
/// Defaults to `true` per `PERSONA_PACK_SPEC.md`.
pub thread_replies: bool,
/// Per-persona env vars to inject at agent spawn time (e.g., GOOSE_PROVIDER, GOOSE_MODEL, BUZZ_AGENT_MODEL).
/// Populated from persona pack resolution. Empty when no pack is configured.
pub persona_env_vars: Vec<(String, String)>,
Expand Down Expand Up @@ -1102,6 +1116,7 @@ impl Config {
respond_to: args.respond_to,
respond_to_allowlist,
allowed_respond_to,
thread_replies: args.thread_replies,
persona_env_vars,
has_generated_codex_config,
relay_observer: args.relay_observer,
Expand Down Expand Up @@ -1473,6 +1488,7 @@ mod tests {
respond_to: RespondTo::Anyone,
respond_to_allowlist: HashSet::new(),
allowed_respond_to: Vec::new(),
thread_replies: true,
persona_env_vars: vec![],
has_generated_codex_config: false,
relay_observer: false,
Expand Down
3 changes: 3 additions & 0 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1610,6 +1610,7 @@ async fn tokio_main() -> Result<()> {
.as_deref()
.and_then(|hex| nostr::PublicKey::from_hex(hex).ok()),
memory_enabled: config.memory_enabled,
thread_replies: config.thread_replies,
harness_name: crate::config::normalize_agent_command_identity(&config.agent_command),
relay_url: config.relay_url.clone(),
});
Expand Down Expand Up @@ -5129,6 +5130,7 @@ mod build_mcp_servers_tests {
respond_to: config::RespondTo::Anyone,
respond_to_allowlist: std::collections::HashSet::new(),
allowed_respond_to: vec![],
thread_replies: true,
persona_env_vars: vec![],
has_generated_codex_config: false,
relay_observer: false,
Expand Down Expand Up @@ -5351,6 +5353,7 @@ mod error_outcome_emission_tests {
respond_to: config::RespondTo::Anyone,
respond_to_allowlist: HashSet::new(),
allowed_respond_to: vec![],
thread_replies: true,
persona_env_vars: vec![],
has_generated_codex_config: false,
relay_observer: false,
Expand Down
8 changes: 8 additions & 0 deletions crates/buzz-acp/src/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,12 @@ pub struct PromptContext {
/// `[Agent Memory — core]` section. On by default; disabled via
/// `--no-memory` / `BUZZ_ACP_NO_MEMORY`.
pub memory_enabled: bool,
/// Whether the agent should default to replying in the current thread.
///
/// Mirrors the `thread_replies` behavioral config from persona packs.
/// When false, `format_prompt` skips forced `--reply-to` anchors for new
/// top-level channel mentions. Existing threads and DMs are unaffected.
pub thread_replies: bool,
/// Harness identity string for NIP-AM `harness` field. Derived from the
/// configured `agent_command` at startup (e.g. `"goose"`, `"buzz-agent"`).
pub harness_name: String,
Expand Down Expand Up @@ -1875,6 +1881,7 @@ pub async fn run_prompt_task(
system_prompt: ctx.system_prompt.as_deref(),
team_instructions: ctx.team_instructions.as_deref(),
agent_canvas: agent_canvas.as_deref(),
thread_replies: ctx.thread_replies,
},
)
} else {
Expand Down Expand Up @@ -6455,6 +6462,7 @@ mod tests {
agent_keys: agent_keys.clone(),
agent_owner_pubkey: owner_pubkey,
memory_enabled: false,
thread_replies: true,
harness_name: "goose".to_string(),
relay_url: "ws://127.0.0.1:3000".to_string(),
}
Expand Down
147 changes: 145 additions & 2 deletions crates/buzz-acp/src/queue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1354,7 +1354,6 @@ fn format_conversation_context(
}

/// Arguments for [`format_prompt`] beyond the required [`FlushBatch`].
#[derive(Default)]
pub struct FormatPromptArgs<'a> {
pub agent_core: Option<&'a str>,
pub channel_info: Option<&'a PromptChannelInfo>,
Expand All @@ -1377,6 +1376,31 @@ pub struct FormatPromptArgs<'a> {
/// For legacy agents it rides in the user message on every turn of the
/// session, alongside `[Base]`/`[System]`/`[Agent Memory — core]`.
pub agent_canvas: Option<&'a str>,
/// Whether the agent should default to replying in the current thread.
///
/// Mirrors the `thread_replies` behavioral config from persona packs.
/// When false, the harness does NOT append a forced `--reply-to` anchor
/// for new top-level channel mentions, leaving the agent free to post flat
/// at the channel root. Existing thread and DM anchors are unaffected.
/// Defaults to `true` to preserve existing behavior.
pub thread_replies: bool,
}

impl<'a> Default for FormatPromptArgs<'a> {
fn default() -> Self {
Self {
agent_core: None,
channel_info: None,
conversation_context: None,
profile_lookup: None,
has_system_prompt_support: false,
base_prompt: None,
system_prompt: None,
team_instructions: None,
agent_canvas: None,
thread_replies: true,
}
}
}

/// Format the `[Base]` section for the base prompt.
Expand Down Expand Up @@ -1469,8 +1493,14 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec<Str
// - top-level → anchor to the triggering event (it becomes the root)
// Agent↔agent turns get no forced anchor — deep nesting is intentional
// there. DMs are always 1:1 with a human, so they always anchor.
//
// Persona-level `thread_replies: false` opts out of forced anchors only for
// new top-level channel mentions. Existing thread and DM anchors continue to
// work so the agent can still participate in an active thread or DM.
let sender_pubkey = last_event.event.pubkey.to_hex();
let reply_anchor = if is_dm {
let reply_anchor = if !args.thread_replies && !is_dm && thread_tags.root_event_id.is_none() {
None
} else if is_dm {
thread_tags
.root_event_id
.is_some()
Expand Down Expand Up @@ -3976,6 +4006,119 @@ mod tests {
);
}

#[test]
fn test_reply_instruction_absent_for_top_level_when_thread_replies_false() {
let ch = Uuid::new_v4();
let event = make_event("hello world");
let event_id = event.id.to_hex();
let batch = FlushBatch {
channel_id: ch,
events: vec![BatchEvent {
event,
prompt_tag: "test".into(),
received_at: Instant::now(),
}],
cancelled_events: vec![],
cancel_reason: None,
};

// Top-level channel mention with thread_replies=false: the harness must
// NOT force a new thread anchor, so the agent can post flat at the
// channel root.
let prompt = format_prompt(
&batch,
&FormatPromptArgs {
thread_replies: false,
..Default::default()
},
)
.join("\n\n");
assert!(
!prompt.contains(&format!("--reply-to {event_id}")),
"thread_replies=false must suppress the new top-level reply anchor"
);
assert!(
!prompt.contains("new top-level message"),
"thread_replies=false must not emit the new-thread instruction"
);
}

#[test]
fn test_reply_instruction_present_for_existing_thread_when_thread_replies_false() {
let ch = Uuid::new_v4();
let root_id = "a".repeat(64);
let event = make_event_with_tags(
"@bot help",
vec![vec!["e".into(), root_id.clone(), "".into(), "reply".into()]],
);
let batch = FlushBatch {
channel_id: ch,
events: vec![BatchEvent {
event,
prompt_tag: "@mention".into(),
received_at: Instant::now(),
}],
cancelled_events: vec![],
cancel_reason: None,
};

// thread_replies=false only opts out of *new* top-level threads. The
// agent should still anchor to the root when replying inside an
// existing thread.
let prompt = format_prompt(
&batch,
&FormatPromptArgs {
thread_replies: false,
..Default::default()
},
)
.join("\n\n");
assert!(
prompt.contains(&format!("--reply-to {root_id}")),
"existing thread reply should still anchor to root when thread_replies=false"
);
}

#[test]
fn test_reply_instruction_present_for_dm_thread_reply_when_thread_replies_false() {
let ch = Uuid::new_v4();
let root_id = "b".repeat(64);
let event = make_event_with_tags(
"thanks",
vec![vec!["e".into(), root_id.clone(), "".into(), "reply".into()]],
);
let event_id = event.id.to_hex();
let batch = FlushBatch {
channel_id: ch,
events: vec![BatchEvent {
event,
prompt_tag: "@mention".into(),
received_at: Instant::now(),
}],
cancelled_events: vec![],
cancel_reason: None,
};
let ci = PromptChannelInfo {
name: "DM".into(),
channel_type: "dm".into(),
};

// DMs are always 1:1 and not governed by thread_replies.
let prompt = format_prompt(
&batch,
&FormatPromptArgs {
channel_info: Some(&ci),
thread_replies: false,
..Default::default()
},
)
.join("\n\n");
assert!(
prompt.contains(&format!("--reply-to {event_id}")),
"DM thread reply should keep its reply anchor when thread_replies=false"
);
}

#[test]
fn test_reply_instruction_absent_for_dm_non_reply() {
let ch = Uuid::new_v4();
Expand Down