Files
buzz/desktop/src-tauri/src/huddle/agents.rs
Logan Johnson a38fedb234 fix(desktop): recognize agents without NIP-OA in Huddles (#8544)
🤖
## Summary
In a Huddle (a voice call in a channel), Buzz Desktop decides which
participants are agents. It uses that answer to choose whose text it
speaks aloud, which agents can publish voice, and when the last person
leaves and the Huddle ends.

Desktop currently treats a participant as an agent only when its channel
role is `bot`. #8494 changes the relay so that it gives `bot` only to
agents with a verified owner attestation (NIP-OA). After that change,
agents without an attestation join as ordinary members, and Desktop
treats them as people:
- their messages are not spoken,
- their voice publisher is refused,
- the Huddle stays open after the last person leaves while such an agent
is still connected.

With this PR, Desktop also counts a member as an agent when a trusted
person has published a managed-agent record for it. Desktop already
publishes such a record for every agent it runs, on every machine.
Trusted people are:
- **you**, so your own agents count as agents on all of your machines;
- **the Huddle's owner**, usually the person who started it, so the
agents they added count as agents for everyone who joins.

Some agents without an attestation still count as people: an agent added
by someone who does not own the Huddle, as seen from any machine other
than that person's. The only way to recognize those agents is an owner
attestation. This PR does not sign one.

### Details
- `huddle/roster.rs` (`fetch_huddle_roster`) is now the only place that
decides agent or person for Huddles. A member is an agent if its role is
`bot`, or if the current user or a channel `owner` published a
kind:30177 managed-agent record whose `d` tag is that member's key.
Records from other authors are ignored. Records whose signature does not
verify are ignored.
- All Huddle checks use the roster: voice-publisher authorization,
`get_huddle_agent_pubkeys` (spoken-text gating and the participant
list), the periodic roster refresh, adding an agent, cleanup at the end
of the Huddle, and the last-person check.
- Trusting the owner's records is a compatibility policy for Huddles,
not proof of identity. A record creates no owner. It lets the channel
owner mark a member as an agent for every Huddle behavior that reads the
roster: spoken text, voice publishing, remote transcription and
turn-taking (both skip agent audio), cleanup, and the last-person check.
Before #8494 the client chose the role, so an owner could already do
this by labeling a member `bot`.
- If the managed-agent records cannot be read, Desktop does not drop
agents that have the `bot` role. Spoken text, voice publishing, adding
agents and cleanup keep working for those agents. The Huddle does not
end automatically, because an agent without an attestation could be
counted as a person. When you join, and on each periodic refresh,
Desktop adds the `bot` agents to its stored list and keeps stored agents
that are still members.

### Related issue
Follow-up to #8494 (the relay stores `bot` only for attested agents). No
existing issue found.

### Testing
- New helper-level unit tests in `huddle/roster.rs`. They do not run the
relay query, the pipeline writers or the leave path.
- the `bot` role, the user's own record and the owner's record each mark
a member as an agent;
  - records from other members are ignored;
  - a record with a forged signature is ignored;
- when the record read fails, `bot` agents stay agents, the Huddle
cannot end automatically, and the next successful read restores the full
roster.
- when the record read fails, a joiner with no stored agents still
stores the `bot` agents, an agent known only by its record stays stored,
and a stored agent that left drops out.
- New tests in `huddle/roster_hydration_tests.rs` run the real
`post_connect_setup` and hot-start entrypoints against a fake relay
whose managed-agent record read fails. The voice-model, audio and
pipeline steps are passed in, so the tests replace them. Post-connect
stores the `bot` agent before media setup starts; the hot-start refresh
also keeps a still-member agent known from an earlier record and drops
one that left; the same incomplete roster gives no human count for the
last-person check. The tests fail when either entrypoint skips its
roster read, and when the roster code accepts only complete rosters.
- `cargo test --lib huddle::` (Huddle tests only), `cargo clippy --tests
-D warnings` and `cargo fmt --check` pass in `desktop/src-tauri`.
- Not yet tested in a live Huddle.

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-26 16:00:13 -04:00

366 lines
15 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_with_roles, validate_pubkey_hex, MAX_HUDDLE_AGENTS},
huddle::roster::fetch_huddle_roster,
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. Its attached main channel is {parent_channel_id}; that is not the live huddle channel.
The channel UUID in the current `[Context]` block is the live huddle channel. Only messages sent with `buzz messages send` to that current Context 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 the current Context 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 main 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>,
/// Channels whose membership this sync changed (ephemeral, and the parent
/// when an agent was enrolled there), so the frontend can refresh them.
/// Reported even when `error` is set, because earlier adds still landed.
pub changed_channel_ids: Vec<String>,
/// Failure that stopped the sync after `changed_channel_ids` were written.
pub error: Option<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(),
changed_channel_ids: Vec::new(),
error: None,
});
}
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(),
changed_channel_ids: Vec::new(),
error: None,
});
}
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(),
changed_channel_ids: Vec::new(),
error: None,
});
}
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_huddle_roster(&ephemeral_channel_id, state)
.await
.map(|roster| roster.agents)
.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();
let mut changed_channel_ids = Vec::new();
for pubkey in missing {
match add_agent_to_huddle(ephemeral_uuid, parent_uuid, &pubkey, state).await {
Ok(result) => {
note_changed(&mut changed_channel_ids, &ephemeral_channel_id);
if result.parent_added {
note_changed(&mut changed_channel_ids, &parent_channel_id);
}
}
Err(error) => {
// The ephemeral add may have landed before the failure.
note_changed(&mut changed_channel_ids, &ephemeral_channel_id);
return Ok(AgentHuddleSyncResult {
matched_active_huddle: true,
added,
changed_channel_ids,
error: Some(error),
});
}
}
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,
changed_channel_ids,
error: None,
});
}
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,
changed_channel_ids,
error: None,
})
}
#[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 note_changed(changed: &mut Vec<String>, channel_id: &str) {
if !changed.iter().any(|id| id == channel_id) {
changed.push(channel_id.to_owned());
}
}
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("Its attached main channel is parent-channel"));
assert!(guidelines.contains("that is not the live huddle channel"));
assert!(guidelines.contains("current `[Context]` block is the live huddle channel"));
assert!(guidelines.contains("buzz messages send` to that current Context channel"));
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"));
}
#[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"));
}
}