Skip to content
Open
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
69 changes: 59 additions & 10 deletions desktop/src-tauri/src/commands/agent_discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1040,23 +1040,72 @@ pub async fn discover_managed_agent_prereqs(

#[tauri::command]
pub async fn list_relay_agents(state: State<'_, AppState>) -> Result<Vec<RelayAgentInfo>, String> {
// Query kind:10100 agent profile events from the relay.
let events = query_relay(
// Query kind:30177 managed-agent events — the kind agent identity/config
// is actually published under. kind:10100 is defined in buzz-core but
// nothing in the codebase publishes it; see managed_agents/agent_events.rs.
let agent_events = query_relay(&state, &[serde_json::json!({ "kinds": [30177] })]).await?;

let mut agents: Vec<RelayAgentInfo> = agent_events
.iter()
.filter_map(|ev| nostr_convert::relay_agent_seed_from_managed_agent_event(ev).ok())
.collect();

if agents.is_empty() {
return Ok(agents);
}

// kind:30177 doesn't carry channel membership — resolve it via kind:39002
// `#p`, the same pattern buzz-acp's own RelayClient::discover_channels
// uses for an agent's own memberships (crates/buzz-acp/src/relay.rs).
let agent_pubkeys: std::collections::HashSet<String> =
agents.iter().map(|a| a.pubkey.clone()).collect();
let member_events = query_relay(
&state,
&[serde_json::json!({
"kinds": [10100],
"kinds": [39002],
"#p": agent_pubkeys.iter().collect::<Vec<_>>(),
})],
)
.await?;
let channel_ids_by_agent =
nostr_convert::agent_channel_ids_from_member_events(&member_events, &agent_pubkeys);

// The convert helper returns `{"agents": [...]}`. Extract and re-deserialize
// into the strongly-typed `Vec<RelayAgentInfo>` the frontend expects.
let value = nostr_convert::agents_from_events(&events);
let agents = value
.get("agents")
let all_channel_ids: Vec<String> = channel_ids_by_agent
.values()
.flatten()
.cloned()
.unwrap_or_else(|| serde_json::json!([]));
serde_json::from_value(agents).map_err(|e| format!("agent parse failed: {e}"))
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();

// Channel names are display-only (eligibility only needs channel_ids) —
// best-effort, same #d + limit pattern as get_channels (commands/channels.rs).
let name_by_id = if all_channel_ids.is_empty() {
std::collections::HashMap::new()
} else {
let meta_events = query_relay(
&state,
&[serde_json::json!({
"kinds": [39000],
"#d": all_channel_ids,
"limit": all_channel_ids.len(),
})],
)
.await?;
nostr_convert::channel_names_by_id(&meta_events)
};

for agent in &mut agents {
if let Some(ids) = channel_ids_by_agent.get(&agent.pubkey) {
agent.channels = ids
.iter()
.filter_map(|id| name_by_id.get(id).cloned())
.collect();
agent.channel_ids = ids.clone();
}
}

Ok(agents)
}

#[cfg(test)]
Expand Down
160 changes: 160 additions & 0 deletions desktop/src-tauri/src/nostr_convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use std::collections::{BTreeSet, HashMap};
use nostr::{Event, ToBech32};
use serde_json::{json, Value};

use crate::managed_agents::{agent_events::managed_agent_content_from_event, RelayAgentInfo};
use crate::models::*;

mod user_search;
Expand Down Expand Up @@ -495,6 +496,80 @@ pub fn agents_from_events(events: &[Event]) -> Value {
json!({ "agents": arr })
}

// ── kind:30177 (managed-agent) relay-agent directory ────────────────────────

/// Build a `RelayAgentInfo` seed from a kind:30177 managed-agent event —
/// everything except `channels`/`channel_ids`, which kind:30177 doesn't
/// carry and must be resolved separately from a kind:39002 membership query
/// (see `agent_channel_ids_from_member_events`).
///
/// The agent's pubkey comes from the event's `d` tag, not `event.pubkey`:
/// kind:30177 is a parameterized-replaceable event published BY the agent's
/// *owner*, so `event.pubkey` is the owner's identity, not the agent's.
pub fn relay_agent_seed_from_managed_agent_event(event: &Event) -> Result<RelayAgentInfo, String> {
let pubkey = first_tag_value(event, "d")
.ok_or_else(|| "kind:30177 missing required `d` tag".to_string())?
.to_string();
let content = managed_agent_content_from_event(event)?;
Ok(RelayAgentInfo {
pubkey,
name: content.name,
agent_type: "agent".to_string(),
channels: Vec::new(),
channel_ids: Vec::new(),
capabilities: Vec::new(),
status: "offline".to_string(),
respond_to: Some(content.respond_to),
respond_to_allowlist: content.respond_to_allowlist,
})
}

/// Build a pubkey → channel-id-list map from kind:39002 (NIP-29 group
/// members) events, restricted to `agent_pubkeys`. One event is one
/// channel's full member list; the channel id is its `d` tag, and each
/// `p`-tag naming one of `agent_pubkeys` means that agent is a member.
///
/// Mirrors the query pattern `buzz-acp`'s own `RelayClient::discover_channels`
/// uses to find an agent's own channel memberships
/// (`crates/buzz-acp/src/relay.rs`) and the pattern `get_channels` uses for
/// the viewer's own memberships (`commands/channels.rs`) — batched across
/// multiple agent pubkeys in one relay round trip instead of one pubkey.
pub fn agent_channel_ids_from_member_events(
events: &[Event],
agent_pubkeys: &std::collections::HashSet<String>,
) -> HashMap<String, Vec<String>> {
let mut map: HashMap<String, Vec<String>> = HashMap::new();
for event in events {
let Some(channel_id) = first_tag_value(event, "d") else {
continue;
};
for slice in tags_named(event, "p") {
let Some(pubkey) = slice.get(1) else { continue };
if !agent_pubkeys.contains(pubkey) {
continue;
}
map.entry(pubkey.clone())
.or_default()
.push(channel_id.to_string());
}
}
map
}

/// Build a channel-id → name map from kind:39000 metadata events, for
/// filling in `RelayAgentInfo::channels` (display-only — eligibility logic
/// only needs `channel_ids`).
pub fn channel_names_by_id(events: &[Event]) -> HashMap<String, String> {
events
.iter()
.filter_map(|event| {
let id = first_tag_value(event, "d")?.to_string();
let name = first_tag_value(event, "name").unwrap_or("").to_string();
Some((id, name))
})
.collect()
}

// ── kind:13534 (relay membership list) ──────────────────────────────────────

/// Convert a kind:13534 relay membership list to the relay members format.
Expand Down Expand Up @@ -1008,4 +1083,89 @@ mod tests {
// Epoch
assert_eq!(timestamp_to_iso(0), "1970-01-01T00:00:00Z");
}

// ── kind:30177 (managed-agent) relay-agent directory ────────────────

#[test]
fn relay_agent_seed_uses_d_tag_not_event_pubkey() {
let agent_pubkey = "1".repeat(64);
let content = serde_json::json!({
"name": "Scout",
"parallelism": 1,
"respond_to": "anyone",
})
.to_string();
let e = ev(30177, &content, vec![vec!["d", &agent_pubkey]]);

let seed = relay_agent_seed_from_managed_agent_event(&e).unwrap();

assert_eq!(seed.pubkey, agent_pubkey);
assert_ne!(
seed.pubkey,
e.pubkey.to_hex(),
"must not use the event author (the owner) as the agent's pubkey"
);
assert_eq!(seed.name, "Scout");
assert_eq!(
seed.respond_to,
Some(crate::managed_agents::RespondTo::Anyone)
);
assert!(seed.channel_ids.is_empty());
assert!(seed.channels.is_empty());
}

#[test]
fn relay_agent_seed_rejects_event_without_d_tag() {
let content = serde_json::json!({
"name": "Scout",
"parallelism": 1,
"respond_to": "anyone",
})
.to_string();
let e = ev(30177, &content, vec![]);

assert!(relay_agent_seed_from_managed_agent_event(&e).is_err());
}

#[test]
fn agent_channel_ids_groups_by_pubkey_and_ignores_non_candidates() {
let agent_a = "a".repeat(64);
let agent_b = "b".repeat(64);
let stranger = "c".repeat(64);
let candidates: std::collections::HashSet<String> =
[agent_a.clone(), agent_b.clone()].into_iter().collect();

let general = ev(
39002,
"",
vec![
vec!["d", "general"],
vec!["p", &agent_a],
vec!["p", &stranger],
],
);
let random = ev(39002, "", vec![vec!["d", "random"], vec!["p", &agent_b]]);

let map = agent_channel_ids_from_member_events(&[general, random], &candidates);

assert_eq!(map.get(&agent_a), Some(&vec!["general".to_string()]));
assert_eq!(map.get(&agent_b), Some(&vec!["random".to_string()]));
assert!(
!map.contains_key(&stranger),
"non-candidate pubkeys must not appear in the result"
);
}

#[test]
fn channel_names_by_id_maps_d_tag_to_name_tag() {
let e = ev(
39000,
"",
vec![vec!["d", "general"], vec!["name", "general"]],
);

let map = channel_names_by_id(std::slice::from_ref(&e));

assert_eq!(map.get("general"), Some(&"general".to_string()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -138,30 +138,64 @@ test("getMentionableAgentPubkeys: keeps managed agents and shared relay agents",

test("isAgentIdentityInManagedList: keeps people and only current managed agent identities", () => {
const managedAgentPubkeys = new Set([PUB_A]);
const mentionableAgentPubkeys = new Set();

assert.equal(
isAgentIdentityInManagedList(
{ isAgent: false, pubkey: PUB_B },
managedAgentPubkeys,
mentionableAgentPubkeys,
),
true,
);
assert.equal(
isAgentIdentityInManagedList(
{ isAgent: true, pubkey: PUB_A.toUpperCase() },
managedAgentPubkeys,
mentionableAgentPubkeys,
),
true,
);
assert.equal(
isAgentIdentityInManagedList(
{ isAgent: true, pubkey: PUB_B },
managedAgentPubkeys,
mentionableAgentPubkeys,
),
false,
);
});

test("isAgentIdentityInManagedList: admits a channel-member agent shared by another owner", () => {
const managedAgentPubkeys = new Set();
const mentionableAgentPubkeys = new Set([PUB_B]);

assert.equal(
isAgentIdentityInManagedList(
{ isAgent: true, isMember: true, pubkey: PUB_B },
managedAgentPubkeys,
mentionableAgentPubkeys,
),
true,
"a bot channel member whose relay policy makes them mentionable must be admitted even when not locally managed",
);
});

test("isAgentIdentityInManagedList: still drops a non-member agent even if mentionable via the relay directory", () => {
const managedAgentPubkeys = new Set();
const mentionableAgentPubkeys = new Set([PUB_B]);

assert.equal(
isAgentIdentityInManagedList(
{ isAgent: true, isMember: false, pubkey: PUB_B },
managedAgentPubkeys,
mentionableAgentPubkeys,
),
false,
"relay-directory presence alone (no channel membership) must not admit a candidate — preserves the existing 'relay-only agents stay hidden' e2e behavior",
);
});

test("shouldHideAgentFromMentions: never hides non-agents", () => {
assert.equal(
shouldHideAgentFromMentions({
Expand Down
17 changes: 12 additions & 5 deletions desktop/src/features/agents/lib/agentAutocompleteEligibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,20 @@ export function getMentionableAgentPubkeys({
}

export function isAgentIdentityInManagedList(
candidate: { isAgent?: boolean; pubkey: string },
candidate: { isAgent?: boolean; isMember?: boolean; pubkey: string },
managedAgentPubkeys: ReadonlySet<string>,
mentionableAgentPubkeys: ReadonlySet<string>,
) {
return (
candidate.isAgent !== true ||
managedAgentPubkeys.has(normalizePubkey(candidate.pubkey))
);
if (candidate.isAgent !== true) return true;
const pubkey = normalizePubkey(candidate.pubkey);
if (managedAgentPubkeys.has(pubkey)) return true;
// A channel member whose relay-published policy (kind:30177, possibly
// owned by a different identity) makes them mentionable is admitted too —
// this is what lets a bot member of a shared channel be @-mentioned
// regardless of who owns/hosts it. Non-members are never admitted here:
// that's the relay-directory-only case (listed, but not actually added to
// any shared channel), which stays hidden.
return candidate.isMember === true && mentionableAgentPubkeys.has(pubkey);
}

export function shouldHideAgentFromMentions({
Expand Down
2 changes: 1 addition & 1 deletion desktop/src/features/channels/ui/MembersSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ export function MembersSidebar({
)) ||
memberPubkeys.has(pubkey) ||
isArchivedDiscovery(pubkey) ||
!isAgentIdentityInManagedList(candidate, managedAgentPubkeys)
!isAgentIdentityInManagedList(candidate, managedAgentPubkeys, new Set())
) {
return;
}
Expand Down
8 changes: 7 additions & 1 deletion desktop/src/features/messages/lib/useMentions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,13 @@ export function useMentions(
if (isArchivedDiscovery(pubkey)) {
return;
}
if (!isAgentIdentityInManagedList(candidate, managedAgentPubkeys)) {
if (
!isAgentIdentityInManagedList(
candidate,
managedAgentPubkeys,
mentionableAgentPubkeys,
)
) {
return;
}
if (
Expand Down
Loading