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
38 changes: 6 additions & 32 deletions desktop/src-tauri/src/commands/agent_discovery.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
use tauri::State;

use crate::{
app_state::AppState,
managed_agents::{
command_availability, is_npm_global_install, AcpRuntimeCatalogEntry,
DiscoverManagedAgentPrereqsRequest, InstallRuntimeResult, ManagedAgentPrereqsInfo,
RelayAgentInfo, DEFAULT_ACP_COMMAND,
},
nostr_convert,
relay::query_relay,
use crate::managed_agents::{
command_availability, is_npm_global_install, AcpRuntimeCatalogEntry,
DiscoverManagedAgentPrereqsRequest, InstallRuntimeResult, ManagedAgentPrereqsInfo,
DEFAULT_ACP_COMMAND,
};

mod post_install_verification;
mod relay_agents;
pub use relay_agents::list_relay_agents;

fn active_installs() -> &'static std::sync::Mutex<std::collections::HashSet<String>> {
use std::collections::HashSet;
Expand Down Expand Up @@ -1053,27 +1048,6 @@ pub async fn discover_managed_agent_prereqs(
.map_err(|e| format!("spawn_blocking failed: {e}"))
}

#[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(
&state,
&[serde_json::json!({
"kinds": [10100],
})],
)
.await?;

// 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")
.cloned()
.unwrap_or_else(|| serde_json::json!([]));
serde_json::from_value(agents).map_err(|e| format!("agent parse failed: {e}"))
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
50 changes: 50 additions & 0 deletions desktop/src-tauri/src/commands/agent_discovery/relay_agents.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
use std::collections::HashSet;

use tauri::State;

use crate::{
app_state::AppState, managed_agents::RelayAgentInfo, nostr_convert, relay::query_relay,
};

#[tauri::command]
pub async fn list_relay_agents(state: State<'_, AppState>) -> Result<Vec<RelayAgentInfo>, String> {
let owner_pubkey = state
.keys
.lock()
.map_err(|error| error.to_string())?
.public_key()
.to_hex();
let events = query_relay(
&state,
&[
serde_json::json!({ "kinds": [10100] }),
serde_json::json!({ "kinds": [0], "#auth": [&owner_pubkey] }),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Paginate the owner-tagged profile scan

In communities with more than 1,000 current kind-0 profiles, this can silently omit valid externally managed agents. I checked the query_relay /query catchall path: only kinds is pushed into EventQuery, while #auth is applied by filters_match after buzz-db has capped the candidate query at the newest 1,000 rows. Therefore, an owner's agent profile outside that window is never returned unless it also has a kind-10100 event. Push the auth-tag constraint into the database query or paginate the kind-0 candidates until the scan is exhausted.

Useful? React with 👍 / 👎.

],
)
.await?;

let parse_agents = |value: serde_json::Value| {
serde_json::from_value::<Vec<RelayAgentInfo>>(
value
.get("agents")
.cloned()
.unwrap_or_else(|| serde_json::json!([])),
)
.map_err(|error| format!("agent parse failed: {error}"))
};
let mut agents = parse_agents(nostr_convert::agents_from_events(&events))?;
let owned_profile_agents = parse_agents(nostr_convert::owned_agent_profiles_from_events(
&events,
&owner_pubkey,
))?;
let mut known_pubkeys: HashSet<String> = agents
.iter()
.map(|agent| agent.pubkey.to_lowercase())
.collect();
agents.extend(
owned_profile_agents
.into_iter()
.filter(|agent| known_pubkeys.insert(agent.pubkey.to_lowercase())),
);
Ok(agents)
}
144 changes: 3 additions & 141 deletions desktop/src-tauri/src/nostr_convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@

use std::collections::{BTreeSet, HashMap};

use nostr::{Event, ToBech32};
use nostr::Event;
use serde_json::{json, Value};

use crate::models::*;

mod agent_discovery;
mod user_search;
pub use agent_discovery::{agents_from_events, owned_agent_profiles_from_events};
pub use user_search::{
list_user_search_results, rank_user_search_results, search_users_from_events,
user_search_result_from_event,
Expand Down Expand Up @@ -436,65 +438,6 @@ pub fn search_response_from_events(events: &[Event]) -> SearchResponse {
}
}

// ── kind:10100 (agent profiles) ─────────────────────────────────────────────

/// Convert kind:10100 agent profile events to the agent discovery format.
///
/// Returns a JSON array of `{pubkey, name, ...}` objects parsed from each
/// event's content.
pub fn agents_from_events(events: &[Event]) -> Value {
let arr: Vec<Value> = events
.iter()
.map(|ev| {
let mut v: Value = serde_json::from_str(&ev.content).unwrap_or_else(|_| json!({}));
let pubkey = ev.pubkey.to_hex();
// Full npub fallback — truncated prefixes are grindable (see pubkey-display).
let npub = ev.pubkey.to_bech32().unwrap_or_else(|_| pubkey.clone());
// Always overwrite the pubkey with the event author — it's the
// authoritative source even if the content claims otherwise.
if let Some(obj) = v.as_object_mut() {
obj.insert("pubkey".to_string(), json!(pubkey.clone()));
let fallback_name = obj
.get("display_name")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.map(str::to_string)
.unwrap_or_else(|| npub.clone());
if !obj.get("name").is_some_and(Value::is_string) {
obj.insert("name".to_string(), json!(fallback_name));
}
if !obj.get("agent_type").is_some_and(Value::is_string) {
obj.insert("agent_type".to_string(), json!("agent"));
}
if !obj.get("channels").is_some_and(Value::is_array) {
obj.insert("channels".to_string(), json!([]));
}
if !obj.get("channel_ids").is_some_and(Value::is_array) {
obj.insert("channel_ids".to_string(), json!([]));
}
if !obj.get("capabilities").is_some_and(Value::is_array) {
obj.insert("capabilities".to_string(), json!([]));
}
if !obj.get("status").is_some_and(Value::is_string) {
obj.insert("status".to_string(), json!("offline"));
}
} else {
v = json!({
"pubkey": pubkey,
"name": npub,
"agent_type": "agent",
"channels": [],
"channel_ids": [],
"capabilities": [],
"status": "offline",
});
}
v
})
.collect();
json!({ "agents": arr })
}

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

/// Convert a kind:13534 relay membership list to the relay members format.
Expand Down Expand Up @@ -882,87 +825,6 @@ mod tests {
assert_eq!(r.hits[0].score, 1.0);
}

#[test]
fn agents_overwrites_pubkey_from_event_author() {
let e = ev(10100, r#"{"pubkey":"forged","name":"agent-1"}"#, vec![]);
let v = agents_from_events(std::slice::from_ref(&e));
let arr = v.get("agents").and_then(Value::as_array).unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(
arr[0].get("pubkey").and_then(Value::as_str).unwrap(),
e.pubkey.to_hex()
);
assert_eq!(arr[0].get("name").and_then(Value::as_str), Some("agent-1"));
}

#[test]
fn agents_handles_invalid_content() {
let e = ev(10100, "not-json", vec![]);
let v = agents_from_events(std::slice::from_ref(&e));
let arr = v.get("agents").and_then(Value::as_array).unwrap();
assert_eq!(
arr[0].get("pubkey").and_then(Value::as_str).unwrap(),
e.pubkey.to_hex()
);
}

#[test]
fn agents_default_sparse_agent_profiles_for_directory_parse() {
let e = ev(
10100,
r#"{"channel_add_policy":"owner-only","display_name":"Scout"}"#,
vec![],
);
let v = agents_from_events(std::slice::from_ref(&e));
let agents = v.get("agents").cloned().unwrap();
let parsed: Vec<crate::managed_agents::RelayAgentInfo> =
serde_json::from_value(agents).unwrap();

assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].pubkey, e.pubkey.to_hex());
assert_eq!(parsed[0].name, "Scout");
assert_eq!(parsed[0].agent_type, "agent");
assert_eq!(parsed[0].channels, Vec::<String>::new());
assert_eq!(parsed[0].capabilities, Vec::<String>::new());
assert_eq!(parsed[0].status, "offline");
assert_eq!(parsed[0].respond_to, None);
}

#[test]
fn agents_preserves_public_respond_to_mode_for_directory_parse() {
let e = ev(10100, r#"{"name":"Scout","respond_to":"anyone"}"#, vec![]);
let v = agents_from_events(std::slice::from_ref(&e));
let agents = v.get("agents").cloned().unwrap();
let parsed: Vec<crate::managed_agents::RelayAgentInfo> =
serde_json::from_value(agents).unwrap();

assert_eq!(parsed.len(), 1);
assert_eq!(
parsed[0].respond_to,
Some(crate::managed_agents::RespondTo::Anyone)
);
}

#[test]
fn agents_preserves_allowlist_metadata_for_directory_parse() {
let e = ev(
10100,
r#"{"name":"Scout","respond_to":"allowlist","respond_to_allowlist":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]}"#,
vec![],
);
let v = agents_from_events(std::slice::from_ref(&e));
let agents = v.get("agents").cloned().unwrap();
let parsed: Vec<crate::managed_agents::RelayAgentInfo> =
serde_json::from_value(agents).unwrap();

assert_eq!(parsed.len(), 1);
assert_eq!(
parsed[0].respond_to,
Some(crate::managed_agents::RespondTo::Allowlist)
);
assert_eq!(parsed[0].respond_to_allowlist, vec!["a".repeat(64)]);
}

#[test]
fn relay_members_dedupes_and_defaults_role() {
let pk1 = "a".repeat(64);
Expand Down
Loading