Files
buzz/desktop/src-tauri/src/huddle/agents.rs
Tyler ea0960f8d0 Clarify immediate spoken huddle replies (#5863)
## Summary
- state that only `buzz messages send` messages are spoken in a huddle
- require the first tool call after being addressed to be a brief spoken
pickup
- explicitly override the normal no-bare-acknowledgment rule and bound
follow-up speech
- pin those invariants in the prompt test

## Test plan
- `cargo test --workspace` from `desktop/src-tauri`
- pre-push `desktop-tauri-checks` (clippy and full workspace tests)

Signed-off-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Co-authored-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
2026-08-14 10:14:59 -04:00

325 lines
13 KiB
Rust

//! Agent enrollment for huddles.
//!
//! Mental model:
//! add_agent_to_huddle → kind:9000 to ephemeral channel
//! → preserve existing parent membership, or
//! kind:9000 to parent channel (best-effort)
//!
//! ACP spawning is NOT needed here: the running agent process auto-subscribes
//! when it receives the kind:9000 membership notification. Huddle-specific
//! env vars (interrupt mode, custom system prompt) are a post-MVP enhancement.
use std::collections::HashSet;
use serde::Serialize;
use tauri::State;
use uuid::Uuid;
use crate::{
app_state::AppState,
events,
huddle::relay_api::{
fetch_channel_members, fetch_channel_members_with_roles, validate_pubkey_hex,
MAX_HUDDLE_AGENTS,
},
relay::submit_event,
};
use super::{pipeline::start_auto_enabled_transcription, HuddlePhase};
// ── Constants ─────────────────────────────────────────────────────────────────
/// Voice-mode instructions posted as kind:48106 to the ephemeral channel at
/// huddle start. Agents load this event into the channel session system prompt.
///
/// Keep this deliberately short: the invariant that matters is that a directly
/// addressed user receives an immediate spoken response before any other work.
pub fn voice_mode_guidelines(parent_channel_id: &str) -> String {
format!(
"\
You are in a live voice huddle attached to channel {parent_channel_id}.
Only messages sent with `buzz messages send` to this huddle channel are spoken aloud, in the order sent; everything else you produce is silent.
When a user addresses you, your FIRST tool call must send a brief spoken reply to this channel, before any file read, search, or other tool call. The usual rule against bare acknowledgments does not apply here; the pickup is the feedback that you heard them.
Then work, sending each useful sentence as its own message the moment it is ready—a few sentences per answer, not a monologue.
Speak plainly without markdown; post code or long detail to the attached channel instead.
If you are not addressed, stay silent."
)
}
// ── Agent enrollment ──────────────────────────────────────────────────────────
/// Result of adding an agent to a huddle.
///
/// **Invariant:** `ephemeral_added` is always `true` on success — the function
/// returns `Err` before constructing this struct if the ephemeral add fails.
/// The field exists for forward compatibility with future batch-add operations
/// where partial success may be meaningful.
///
/// `parent_added` reflects whether the parent already contained the agent or
/// the parent-channel add succeeded; `parent_error` carries the error string
/// when neither condition could be confirmed.
#[derive(Debug, Serialize)]
pub struct AgentAddResult {
/// Always `true` — invariant guaranteed by [`add_agent_to_huddle`].
pub ephemeral_added: bool,
/// Whether the agent was also added to the parent channel (best-effort).
pub parent_added: bool,
/// Error from the parent-channel add, if it failed.
pub parent_error: Option<String>,
}
/// Result of reconciling channel agent additions into the active Huddle.
#[derive(Debug, Serialize)]
pub struct AgentHuddleSyncResult {
/// Whether `channel_id` belonged to the active Huddle.
pub matched_active_huddle: bool,
/// Agents newly enrolled in the Huddle's ephemeral channel.
pub added: Vec<String>,
}
// Multiple frontend mutation paths can observe the same membership addition
// (for example, the member hook and the mention send flow). Serialize native
// reconciliation so they share the first result instead of racing duplicate
// membership events through a relay read that has not caught up yet.
static AGENT_SYNC_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
/// Add an agent to both the ephemeral and parent huddle channels.
///
/// Returns `Err` only if the ephemeral-channel add fails (policy rejection or
/// network error). The parent-channel add is best-effort: failure is captured
/// in `AgentAddResult::parent_error` rather than propagated.
///
/// The running ACP process for this agent will auto-subscribe to the new
/// channel when it receives the kind:9000 membership notification.
pub async fn add_agent_to_huddle(
ephemeral_channel_id: Uuid,
parent_channel_id: Uuid,
agent_pubkey: &str,
state: &AppState,
) -> Result<AgentAddResult, String> {
// 1. Add agent to ephemeral channel (required — fail hard on rejection).
let add_eph = events::build_add_member(ephemeral_channel_id, agent_pubkey, Some("bot"))?;
submit_event(add_eph, state).await?;
// 2. Preserve any active parent membership, regardless of role. Rewriting
// an existing DM member as `bot` is both unnecessary and forbidden for
// non-admins. Otherwise add the agent so it has full context.
// Best-effort: capture a real error but don't propagate it.
let parent_channel_id_string = parent_channel_id.to_string();
let parent_already_contains_agent =
fetch_channel_members_with_roles(&parent_channel_id_string, state)
.await
.is_ok_and(|members| contains_member(&members, agent_pubkey));
let (parent_added, parent_error) = if parent_already_contains_agent {
(true, None)
} else {
let add_parent = events::build_add_member(parent_channel_id, agent_pubkey, Some("bot"))?;
match submit_event(add_parent, state).await {
Ok(_) => (true, None),
Err(e) => {
let active_after_error =
fetch_channel_members_with_roles(&parent_channel_id_string, state)
.await
.is_ok_and(|members| contains_member(&members, agent_pubkey));
if active_after_error {
(true, None)
} else {
eprintln!("buzz-desktop: add agent to parent channel failed: {e}");
(false, Some(e))
}
}
}
};
Ok(AgentAddResult {
ephemeral_added: true,
parent_added,
parent_error,
})
}
/// Reconcile explicitly added channel agents into the active Huddle.
///
/// The source channel may be either the Huddle's parent or its ephemeral chat.
/// Existing ephemeral membership is hydrated first so a mention sent from the
/// Huddle chat does not publish a duplicate membership event. Missing agents
/// are added through the same parent + ephemeral path as the Add agent picker.
pub(crate) async fn sync_agents_for_active_huddle(
channel_id: &str,
agent_pubkeys: Vec<String>,
state: &AppState,
) -> Result<AgentHuddleSyncResult, String> {
let mut seen = HashSet::new();
let mut requested = Vec::new();
for pubkey in agent_pubkeys {
let normalized = pubkey.to_ascii_lowercase();
validate_pubkey_hex(&normalized)?;
if seen.insert(normalized.clone()) {
requested.push(normalized);
}
}
if requested.is_empty() {
return Ok(AgentHuddleSyncResult {
matched_active_huddle: false,
added: Vec::new(),
});
}
let _sync_guard = AGENT_SYNC_LOCK.lock().await;
let (ephemeral_channel_id, parent_channel_id, huddle_generation, state_agents) = {
let huddle = state.huddle()?;
if !matches!(huddle.phase, HuddlePhase::Connected | HuddlePhase::Active) {
return Ok(AgentHuddleSyncResult {
matched_active_huddle: false,
added: Vec::new(),
});
}
let ephemeral_channel_id = huddle
.ephemeral_channel_id
.clone()
.ok_or("no ephemeral channel")?;
let parent_channel_id = huddle
.parent_channel_id
.clone()
.ok_or("no parent channel")?;
if channel_id != ephemeral_channel_id && channel_id != parent_channel_id {
return Ok(AgentHuddleSyncResult {
matched_active_huddle: false,
added: Vec::new(),
});
}
let state_agents = huddle
.agent_pubkeys
.lock()
.unwrap_or_else(|error| error.into_inner())
.clone();
(
ephemeral_channel_id,
parent_channel_id,
huddle.huddle_generation,
state_agents,
)
};
// Membership reads can lag a just-accepted write, so merge the relay view
// with local state instead of allowing a stale snapshot to remove agents.
let fresh_agents = fetch_channel_members(&ephemeral_channel_id, Some("bot"), state)
.await
.unwrap_or_default();
let mut known_agents = HashSet::new();
let mut merged_agents = Vec::new();
for pubkey in state_agents.into_iter().chain(fresh_agents) {
let normalized = pubkey.to_ascii_lowercase();
if known_agents.insert(normalized.clone()) {
merged_agents.push(normalized);
}
}
let missing: Vec<String> = requested
.into_iter()
.filter(|pubkey| !known_agents.contains(pubkey))
.collect();
if known_agents.len() + missing.len() > MAX_HUDDLE_AGENTS {
return Err(format!(
"agent limit reached: {} requested with {} already present (max {})",
missing.len(),
known_agents.len(),
MAX_HUDDLE_AGENTS
));
}
let ephemeral_uuid = Uuid::parse_str(&ephemeral_channel_id).map_err(|e| e.to_string())?;
let parent_uuid = Uuid::parse_str(&parent_channel_id).map_err(|e| e.to_string())?;
let mut added = Vec::new();
for pubkey in missing {
add_agent_to_huddle(ephemeral_uuid, parent_uuid, &pubkey, state).await?;
merged_agents.push(pubkey.clone());
added.push(pubkey);
}
let (roster_changed, transcription_auto_enabled) = {
let mut huddle = state.huddle()?;
if !huddle.is_current_huddle(&ephemeral_channel_id, huddle_generation) {
return Ok(AgentHuddleSyncResult {
matched_active_huddle: true,
added,
});
}
let mut roster_changed = false;
{
let mut current_agents = huddle
.agent_pubkeys
.lock()
.unwrap_or_else(|error| error.into_inner());
if *current_agents != merged_agents {
*current_agents = merged_agents.clone();
roster_changed = true;
}
}
for pubkey in &merged_agents {
if !huddle.participants.contains(pubkey) {
huddle.participants.push(pubkey.clone());
roster_changed = true;
}
}
(
roster_changed,
huddle.maybe_auto_enable_transcription_for_agents(),
)
};
if transcription_auto_enabled {
start_auto_enabled_transcription(state, &ephemeral_channel_id).await;
} else if roster_changed {
state.emit_huddle_state_changed();
}
Ok(AgentHuddleSyncResult {
matched_active_huddle: true,
added,
})
}
#[tauri::command]
pub async fn sync_agents_to_active_huddle(
channel_id: String,
agent_pubkeys: Vec<String>,
state: State<'_, AppState>,
) -> Result<AgentHuddleSyncResult, String> {
sync_agents_for_active_huddle(&channel_id, agent_pubkeys, &state).await
}
fn contains_member(members: &[(String, Option<String>)], pubkey: &str) -> bool {
members
.iter()
.any(|(member_pubkey, _)| member_pubkey.eq_ignore_ascii_case(pubkey))
}
#[cfg(test)]
mod tests {
use super::{contains_member, voice_mode_guidelines};
#[test]
fn voice_mode_guidelines_pin_spoken_reply_as_first_tool_call() {
let guidelines = voice_mode_guidelines("parent-channel");
assert_eq!(guidelines.lines().count(), 6);
assert!(guidelines.contains("Only messages sent with `buzz messages send`"));
assert!(guidelines.contains("your FIRST tool call must send a brief spoken reply"));
assert!(guidelines.contains("before any file read, search, or other tool call"));
assert!(guidelines.contains("rule against bare acknowledgments does not apply here"));
assert!(guidelines.contains("parent-channel"));
}
#[test]
fn existing_parent_membership_is_preserved_regardless_of_role() {
let members = vec![
("agent-member".to_owned(), Some("member".to_owned())),
("agent-bot".to_owned(), Some("bot".to_owned())),
];
assert!(contains_member(&members, "AGENT-MEMBER"));
assert!(contains_member(&members, "agent-bot"));
assert!(!contains_member(&members, "missing"));
}
}