From 35a8baae82cfeceda106285efd855c529b313f2e Mon Sep 17 00:00:00 2001 From: Szymon Tanski Date: Fri, 31 Jul 2026 21:35:08 +0200 Subject: [PATCH 1/6] fix(desktop): let remotely hosted agents appear in mention autocomplete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `isAgentIdentityInManagedList` dropped every candidate whose `isAgent` flag was set unless the agent was in `managedAgentPubkeys`, i.e. running locally on this machine. That gate ran before `shouldHideAgentFromMentions`, so the finer directory rules it implements (kind:10100 entry, `respond_to`, shared channels) could never be reached for an agent hosted elsewhere. The effect was self-contradictory: `useMentions` adds relay-directory agents as candidates with `isAgent: true`, and the very next filter removed them again. An agent running on another machine was impossible to @-mention no matter how it was configured. Pass the already-computed `mentionableAgentPubkeys` into the gate so agents the relay advertises as invocable survive it. The parameter is optional, so `MembersSidebar` — where restricting to locally managed agents is intended — keeps its current behavior. Signed-off-by: Szymon Tanski --- .../lib/agentAutocompleteEligibility.test.mjs | 46 +++++++++++++++++++ .../lib/agentAutocompleteEligibility.ts | 23 +++++++++- .../src/features/messages/lib/useMentions.ts | 8 +++- 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 4e02b7bd68..fa4d8985c3 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -162,6 +162,52 @@ test("isAgentIdentityInManagedList: keeps people and only current managed agent ); }); +test("isAgentIdentityInManagedList: keeps relay-directory agents this client can invoke", () => { + const managedAgentPubkeys = new Set([PUB_A]); + const mentionableAgentPubkeys = new Set([PUB_A, PUB_B]); + + // Hosted on another machine, but advertised as invocable by the relay + // directory (kind:10100) — must survive so the mention picker can offer it. + assert.equal( + isAgentIdentityInManagedList( + { isAgent: true, pubkey: PUB_B }, + managedAgentPubkeys, + mentionableAgentPubkeys, + ), + true, + ); + + // Case-insensitive, same as the locally managed path. + assert.equal( + isAgentIdentityInManagedList( + { isAgent: true, pubkey: PUB_B.toUpperCase() }, + managedAgentPubkeys, + mentionableAgentPubkeys, + ), + true, + ); + + // Neither local nor invocable => still dropped. + assert.equal( + isAgentIdentityInManagedList( + { isAgent: true, pubkey: PUB_C }, + managedAgentPubkeys, + mentionableAgentPubkeys, + ), + false, + ); + + // People are never filtered by this gate. + assert.equal( + isAgentIdentityInManagedList( + { isAgent: false, pubkey: PUB_C }, + managedAgentPubkeys, + mentionableAgentPubkeys, + ), + true, + ); +}); + test("shouldHideAgentFromMentions: never hides non-agents", () => { assert.equal( shouldHideAgentFromMentions({ diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index e4afe7fea4..cb140d16eb 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -54,13 +54,32 @@ export function getMentionableAgentPubkeys({ return pubkeys; } +/** + * Keep non-agent identities, plus agent identities this client can actually + * invoke. + * + * An agent is invocable when it runs locally (`managedAgentPubkeys`) **or** + * when the relay directory advertises it as reachable for us — that second set + * is what `getMentionableAgentPubkeys` computes from kind:10100 entries. + * + * Without `mentionableAgentPubkeys` this drops every agent hosted on another + * machine before {@link shouldHideAgentFromMentions} can apply the finer + * directory rules, which makes remotely hosted agents impossible to mention. + * The parameter is optional so existing callers that only care about locally + * managed agents keep their previous behavior. + */ export function isAgentIdentityInManagedList( candidate: { isAgent?: boolean; pubkey: string }, managedAgentPubkeys: ReadonlySet, + mentionableAgentPubkeys?: ReadonlySet, ) { + if (candidate.isAgent !== true) { + return true; + } + const normalized = normalizePubkey(candidate.pubkey); return ( - candidate.isAgent !== true || - managedAgentPubkeys.has(normalizePubkey(candidate.pubkey)) + managedAgentPubkeys.has(normalized) || + mentionableAgentPubkeys?.has(normalized) === true ); } diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 0c73b75339..bd944e141f 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -246,7 +246,13 @@ export function useMentions( if (isArchivedDiscovery(pubkey)) { return; } - if (!isAgentIdentityInManagedList(candidate, managedAgentPubkeys)) { + if ( + !isAgentIdentityInManagedList( + candidate, + managedAgentPubkeys, + mentionableAgentPubkeys, + ) + ) { return; } if ( From 498aeafa4525b25afe99853578fd4c456ccf4611 Mon Sep 17 00:00:00 2001 From: Szymon Tanski Date: Fri, 31 Jul 2026 21:50:25 +0200 Subject: [PATCH 2/6] feat(desktop): publish the agent directory entry (kind:10100) on start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clients discover invocable agents through `list_relay_agents`, which queries kind:10100, and `getMentionableAgentPubkeys` derives mentionability from that entry's `respond_to` and `channel_ids`. Nothing ever wrote the kind, so the directory was empty in every deployment and an agent hosted on one machine could not be mentioned from another. Publish the entry from `probe_agent_relay_access`, which already runs per (agent, community) on start and already queries the agent's kind:39002 memberships — so the channel list needs no extra round trip, and the event is signed with the agent's own keys, as consumers expect (the pubkey is taken from the event author). kind:10100 carries two contracts at once: the client-facing directory fields and the relay's `channel_add_policy`, whose side effect fails outright when the field is absent. The entry therefore includes `owner_only`, matching the relay's existing default so behavior is unchanged. Publishing is best-effort — a failure is logged and never blocks startup, since the agent still works locally regardless. Also adds `submit_signed_event_with_keys_at`, so the entry goes to the agent's own relay rather than whichever community happens to be active. Signed-off-by: Szymon Tanski --- .../src/managed_agents/agent_events.rs | 134 +++++++++++++++++- .../src/managed_agents/runtime_commands.rs | 60 +++++++- desktop/src-tauri/src/relay.rs | 18 ++- 3 files changed, 209 insertions(+), 3 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index 4a7b80079d..0237d8c165 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -22,7 +22,7 @@ //! - any runtime field (`runtime_pid`, `last_*`, `backend_agent_id`, …) — these //! mutate on every start/stop and describe transient process state. -use buzz_core_pkg::kind::KIND_MANAGED_AGENT; +use buzz_core_pkg::kind::{KIND_AGENT_PROFILE, KIND_MANAGED_AGENT}; use nostr::{EventBuilder, Kind, Tag}; use serde::{Deserialize, Serialize}; @@ -118,6 +118,90 @@ pub fn build_agent_event(record: &ManagedAgentRecord) -> Result, + pub channel_ids: Vec, + pub channels: Vec, + pub capabilities: Vec, + pub status: String, + /// Required by the relay side effect; see [`DEFAULT_CHANNEL_ADD_POLICY`]. + pub channel_add_policy: String, +} + +/// Build the agent's kind:10100 directory entry. +/// +/// This is what makes an agent reachable from *other* machines: clients +/// discover invocable agents through `list_relay_agents`, which queries this +/// kind, and `getMentionableAgentPubkeys` decides mentionability from +/// `respond_to` plus `channel_ids`. Without it, an agent running on one +/// machine cannot be mentioned from another. +/// +/// Must be signed with the **agent's own keys** — consumers take the agent +/// pubkey from the event author, not from the content. +pub fn build_agent_profile_event( + record: &ManagedAgentRecord, + channel_ids: Vec, +) -> Result { + let content = AgentProfileEventContent { + name: record.name.clone(), + display_name: record.name.clone(), + agent_type: "agent".to_string(), + respond_to: record.respond_to, + respond_to_allowlist: record.respond_to_allowlist.clone(), + channels: channel_ids.clone(), + channel_ids, + capabilities: Vec::new(), + status: "online".to_string(), + channel_add_policy: DEFAULT_CHANNEL_ADD_POLICY.to_string(), + }; + let json = serde_json::to_string(&content) + .map_err(|e| format!("failed to serialize agent profile content: {e}"))?; + Ok(EventBuilder::new( + Kind::Custom(KIND_AGENT_PROFILE as u16), + json, + )) +} + +/// Collect channel ids from the agent's NIP-29 membership events (kind:39002). +/// +/// Channels are carried in `h` tags (NIP-29 group tag), not `e` tags. +/// Duplicates are removed while preserving first-seen order. +pub fn channel_ids_from_membership_events(events: &[nostr::Event]) -> Vec { + let mut seen = std::collections::BTreeSet::new(); + let mut ids = Vec::new(); + for event in events { + for tag in event.tags.iter() { + let slice = tag.as_slice(); + if slice.first().map(String::as_str) == Some("h") { + if let Some(id) = slice.get(1).filter(|value| !value.is_empty()) { + if seen.insert(id.clone()) { + ids.push(id.clone()); + } + } + } + } + } + ids +} + /// Parse a kind:30177 event's content into the projection — the inbound /// counterpart of [`agent_event_content`]. /// @@ -227,6 +311,54 @@ mod tests { assert_eq!(event.kind.as_u16() as u32, KIND_MANAGED_AGENT); } + #[test] + fn build_agent_profile_event_produces_directory_entry() { + let channels = vec!["chan-a".to_string(), "chan-b".to_string()]; + let builder = build_agent_profile_event(&sample_agent(), channels.clone()).unwrap(); + let keys = nostr::Keys::generate(); + let event = builder.sign_with_keys(&keys).unwrap(); + + assert_eq!(event.kind.as_u16() as u32, KIND_AGENT_PROFILE); + + let parsed: serde_json::Value = serde_json::from_str(&event.content).unwrap(); + assert_eq!(parsed["agent_type"], "agent"); + assert_eq!(parsed["channel_ids"][0], "chan-a"); + assert_eq!(parsed["channels"][1], "chan-b"); + // The relay side effect rejects entries without this field. + assert_eq!(parsed["channel_add_policy"], DEFAULT_CHANNEL_ADD_POLICY); + // Wire format must stay snake_case for `RelayAgentInfo`. + assert!(parsed.get("respond_to").is_some()); + assert!(parsed.get("respondTo").is_none()); + } + + #[test] + fn channel_ids_from_membership_events_collects_h_tags_once() { + let keys = nostr::Keys::generate(); + let make = |tags: Vec>| { + let parsed: Vec = tags + .into_iter() + .map(|tag| nostr::Tag::parse(tag).unwrap()) + .collect(); + EventBuilder::new(Kind::Custom(39002), "") + .tags(parsed) + .sign_with_keys(&keys) + .unwrap() + }; + + let events = vec![ + make(vec![vec!["h", "chan-a"], vec!["p", "someone"]]), + // Duplicate must collapse; `e` tags are not channel references. + make(vec![vec!["h", "chan-a"], vec!["e", "chan-ignored"]]), + make(vec![vec!["h", "chan-b"]]), + make(vec![vec!["h", ""]]), + ]; + + assert_eq!( + channel_ids_from_membership_events(&events), + vec!["chan-a".to_string(), "chan-b".to_string()] + ); + } + #[test] fn d_tag_is_agent_pubkey() { let builder = build_agent_event(&sample_agent()).unwrap(); diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index c0e55184b1..3ee931ada3 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -404,7 +404,7 @@ async fn probe_agent_relay_access( let keys = nostr::Keys::parse(record.private_key_nsec.trim()) .map_err(|error| format!("invalid managed-agent key: {error}"))?; let api_base = crate::relay::relay_http_base_url(&key.relay_url); - tokio::time::timeout( + let memberships = tokio::time::timeout( std::time::Duration::from_secs(10), crate::relay::query_relay_at_with_keys( state, @@ -416,9 +416,67 @@ async fn probe_agent_relay_access( ) .await .map_err(|_| "relay access probe timed out".to_string())??; + + // The probe already carries everything the directory entry needs, so + // refresh it here rather than issuing a second membership query. + publish_agent_directory_entry(state, &record, &api_base, &keys, &memberships).await; + Ok((record, key, requested_relay_url)) } +/// Refresh the agent's kind:10100 directory entry on `api_base`. +/// +/// Other machines discover invocable agents exclusively through this kind, so +/// without it an agent hosted here cannot be @-mentioned anywhere else. Kept +/// best-effort: a failure is logged and never blocks the agent from starting, +/// since the agent is fully functional locally either way. +async fn publish_agent_directory_entry( + state: &AppState, + record: &super::ManagedAgentRecord, + api_base: &str, + keys: &nostr::Keys, + memberships: &[nostr::Event], +) { + let channel_ids = super::agent_events::channel_ids_from_membership_events(memberships); + let builder = match super::agent_events::build_agent_profile_event(record, channel_ids) { + Ok(builder) => builder, + Err(error) => { + tracing::warn!( + agent = %record.pubkey, + %error, + "failed to build agent directory entry (kind:10100)" + ); + return; + } + }; + let event = match builder.sign_with_keys(keys) { + Ok(event) => event, + Err(error) => { + tracing::warn!( + agent = %record.pubkey, + %error, + "failed to sign agent directory entry (kind:10100)" + ); + return; + } + }; + if let Err(error) = crate::relay::submit_signed_event_with_keys_at( + &event, + state, + api_base, + keys, + record.auth_tag.as_deref(), + ) + .await + { + tracing::warn!( + agent = %record.pubkey, + %error, + "failed to publish agent directory entry (kind:10100)" + ); + } +} + /// Build the `Failed` status row for a probe failure whose requested relay URL /// cannot even form a pair key (so there is no canonical `relay_url` to key on). /// The raw requested URL stands in for both the identity and the requested diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 71aa21c413..aa532f866d 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -555,17 +555,33 @@ pub async fn submit_event_with_keys( } /// POST an already-signed event using the same explicit identity for NIP-98. +/// +/// Targets the currently active community. Use +/// [`submit_signed_event_with_keys_at`] when the event belongs to a specific +/// relay — a managed agent can run on a community other than the active one. pub async fn submit_signed_event_with_keys( event: &nostr::Event, state: &AppState, keys: &Keys, auth_tag: Option<&str>, +) -> Result { + let api_base = relay_api_base_url_with_override(state); + submit_signed_event_with_keys_at(event, state, &api_base, keys, auth_tag).await +} + +/// POST an already-signed event to an explicit relay API base URL. +pub async fn submit_signed_event_with_keys_at( + event: &nostr::Event, + state: &AppState, + api_base_url: &str, + keys: &Keys, + auth_tag: Option<&str>, ) -> Result { if event.pubkey != keys.public_key() { return Err("signed event does not match the publishing identity".to_string()); } crate::relay_admission::wait_for_rate_limit().await; - let url = format!("{}/events", relay_api_base_url_with_override(state)); + let url = format!("{}/events", api_base_url); let body_bytes = event.as_json().into_bytes(); crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "signed event submit (keys)")?; let auth_header = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?; From c915fc362cfee1b1ea19319df3d98693b6279673 Mon Sep 17 00:00:00 2001 From: Szymon Tanski Date: Fri, 31 Jul 2026 22:32:12 +0200 Subject: [PATCH 3/6] fix(desktop): refresh the agent directory entry when it joins a channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The entry published at start time is necessarily channel-less: kind:39002 memberships do not exist until the harness first connects, so a freshly created agent always advertises an empty `channel_ids`. That is worse than publishing nothing. `relayAgentIsSharedWithUser` requires a shared channel for `respond_to: anyone`, so a channel-less entry is never invocable — and `shouldHideAgentFromMentions` reads directory presence without invocability as an explicit not-invocable signal, hiding an agent that would otherwise have been offered. Refresh the entry from `add_channel_members`, once memberships exist. The refresh is a no-op for pubkeys that are not locally managed agents, since only this machine holds their keys, and stays best-effort so a failure never breaks adding a member. Signed-off-by: Szymon Tanski --- desktop/src-tauri/src/commands/channels.rs | 9 ++++ .../src/managed_agents/runtime_commands.rs | 53 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/desktop/src-tauri/src/commands/channels.rs b/desktop/src-tauri/src/commands/channels.rs index 59c80c4807..d58de50b63 100644 --- a/desktop/src-tauri/src/commands/channels.rs +++ b/desktop/src-tauri/src/commands/channels.rs @@ -786,6 +786,7 @@ pub async fn add_channel_members( pubkeys: Vec, role: Option, state: State<'_, AppState>, + app: tauri::AppHandle, ) -> Result { let uuid = parse_channel_uuid(&channel_id)?; let role_str = match role.as_deref() { @@ -813,6 +814,14 @@ pub async fn add_channel_members( } } + // A managed agent's directory entry (kind:10100) lists the channels it can + // be reached in, and that list is empty at start time because memberships + // do not exist yet. Refresh it here so the agent becomes invocable from + // other machines as soon as it joins a channel. No-op for non-agents. + for pubkey in &added { + crate::managed_agents::refresh_agent_directory_entry(&app, &state, pubkey).await; + } + Ok(serde_json::json!({ "added": added, "errors": errors })) } diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index 3ee931ada3..c6778bca10 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -424,6 +424,59 @@ async fn probe_agent_relay_access( Ok((record, key, requested_relay_url)) } +/// Refresh a locally managed agent's kind:10100 directory entry after its +/// channel membership changed. +/// +/// The entry published at start time is necessarily channel-less: kind:39002 +/// memberships do not exist until the harness first connects, so the start-time +/// snapshot is empty for a freshly created agent. An entry that lists no +/// channels makes the agent non-invocable for `respond_to: anyone`, so it must +/// be refreshed once memberships actually exist — otherwise the directory +/// advertises the agent as unreachable. +/// +/// No-op for pubkeys that are not locally managed agents; only this machine +/// holds their keys and may sign on their behalf. Best-effort throughout. +pub(crate) async fn refresh_agent_directory_entry( + app: &AppHandle, + state: &AppState, + agent_pubkey: &str, +) { + let normalized = agent_pubkey.trim().to_lowercase(); + let Ok(records) = load_managed_agents(app) else { + return; + }; + let Some(record) = records + .into_iter() + .find(|record| record.pubkey.trim().to_lowercase() == normalized) + else { + return; + }; + let Ok(keys) = nostr::Keys::parse(record.private_key_nsec.trim()) else { + return; + }; + let api_base = crate::relay::relay_http_base_url(&record.relay_url); + let memberships = match crate::relay::query_relay_at_with_keys( + state, + &api_base, + &[serde_json::json!({"kinds": [39002], "#p": [record.pubkey]})], + &keys, + record.auth_tag.as_deref(), + ) + .await + { + Ok(events) => events, + Err(error) => { + tracing::warn!( + agent = %record.pubkey, + %error, + "failed to read memberships while refreshing the agent directory entry" + ); + return; + } + }; + publish_agent_directory_entry(state, &record, &api_base, &keys, &memberships).await; +} + /// Refresh the agent's kind:10100 directory entry on `api_base`. /// /// Other machines discover invocable agents exclusively through this kind, so From c8989129b8cb56803e5abfc397f2ea5f854877b8 Mon Sep 17 00:00:00 2001 From: Szymon Tanski Date: Fri, 31 Jul 2026 23:08:02 +0200 Subject: [PATCH 4/6] fix(desktop): read the channel id from the d tag of kind:39002, not h MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kind:39002 is addressable: the channel id is the event's `d` tag and the members are `p` tags — the same shape `get_channels` already relies on. The directory entry read `h` instead, so `channel_ids` came out empty for every agent and the entry advertised the agent as reachable in no channel at all. `h` is how *message* events scope a channel; membership events are a different kind with a different tag, and the repo guidance about `h` tags does not extend to them. Signed-off-by: Szymon Tanski --- .../src/managed_agents/agent_events.rs | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index 0237d8c165..3c6d1087b6 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -182,7 +182,11 @@ pub fn build_agent_profile_event( /// Collect channel ids from the agent's NIP-29 membership events (kind:39002). /// -/// Channels are carried in `h` tags (NIP-29 group tag), not `e` tags. +/// kind:39002 is addressable: the channel id is the event's `d` tag, and the +/// members are `p` tags. This mirrors how `get_channels` resolves a user's +/// channels. (Message events scope channels with `h` tags — a different kind, +/// a different tag.) +/// /// Duplicates are removed while preserving first-seen order. pub fn channel_ids_from_membership_events(events: &[nostr::Event]) -> Vec { let mut seen = std::collections::BTreeSet::new(); @@ -190,7 +194,7 @@ pub fn channel_ids_from_membership_events(events: &[nostr::Event]) -> Vec>| { let parsed: Vec = tags @@ -346,11 +350,12 @@ mod tests { }; let events = vec![ - make(vec![vec!["h", "chan-a"], vec!["p", "someone"]]), - // Duplicate must collapse; `e` tags are not channel references. - make(vec![vec!["h", "chan-a"], vec!["e", "chan-ignored"]]), - make(vec![vec!["h", "chan-b"]]), - make(vec![vec!["h", ""]]), + make(vec![vec!["d", "chan-a"], vec!["p", "someone"]]), + // Duplicate must collapse; `h`/`e` tags are not the channel id on + // kind:39002 — only `d` is. + make(vec![vec!["d", "chan-a"], vec!["h", "chan-ignored"]]), + make(vec![vec!["d", "chan-b"]]), + make(vec![vec!["d", ""]]), ]; assert_eq!( From 5641cdb92a4b72ce62f8798b9bb6fb08f42b4a31 Mon Sep 17 00:00:00 2001 From: Szymon Tanski Date: Fri, 31 Jul 2026 23:35:36 +0200 Subject: [PATCH 5/6] feat(desktop): refresh agent directory entries when reading the directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only the machine holding an agent's key can sign its kind:10100 entry, so a membership change made from another device never reaches it — the agent stays unmentionable in that channel until its next restart. `add_channel_members` covers the case where the host itself adds the agent; this covers the rest. Add `refresh_agent_directory_entries`, invoked from the relay-directory query so entries are republished exactly when a stale one would be visible. Throttled per agent (60s) because the caller is UI-driven, and best-effort throughout: a failure just means the directory is read as-is. Also documents the behavior where users choose it. "Who can send instructions" now states that Anyone requires a shared channel, that the agent runs on the machine that started it, and that it answers only while that machine is awake with the app open — the three things that otherwise look like the feature is broken. Signed-off-by: Szymon Tanski --- desktop/src-tauri/src/lib.rs | 4 +- .../src/managed_agents/runtime_commands.rs | 53 ++++++++++++++++++- desktop/src/features/agents/hooks.ts | 10 +++- .../src/features/agents/ui/RespondToField.tsx | 15 +++++- desktop/src/shared/api/tauri.ts | 13 +++++ 5 files changed, 91 insertions(+), 4 deletions(-) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 6814008f0d..cbf799e2a6 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -57,7 +57,8 @@ use huddle::{ use managed_agents::{ backfill_persona_snapshots, ensure_nest, list_managed_agent_runtimes, put_managed_agent_runtime_lifecycle, reconcile_managed_agent_runtimes, - restart_managed_agent_runtime, start_managed_agent_runtime, stop_managed_agent_runtime, + refresh_agent_directory_entries, restart_managed_agent_runtime, start_managed_agent_runtime, + stop_managed_agent_runtime, try_regenerate_nest, }; #[cfg(not(feature = "mesh-llm"))] @@ -805,6 +806,7 @@ pub fn run() { start_managed_agent_runtime, stop_managed_agent_runtime, restart_managed_agent_runtime, + refresh_agent_directory_entries, reconcile_managed_agent_runtimes, put_managed_agent_runtime_lifecycle, create_managed_agent, diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index c6778bca10..bb481238de 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -1,6 +1,6 @@ use std::sync::atomic::Ordering; -use tauri::{AppHandle, Emitter, Manager}; +use tauri::{AppHandle, Emitter, Manager, State}; use super::{ agent_readiness, append_log_marker, current_instance_id, find_managed_agent_mut, @@ -424,6 +424,57 @@ async fn probe_agent_relay_access( Ok((record, key, requested_relay_url)) } +/// Minimum gap between directory refreshes for the same agent. +/// +/// The refresh is cheap (one query + one replaceable event) but the callers +/// are UI-driven and can fire on every channel switch, so throttle per agent. +const DIRECTORY_REFRESH_MIN_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60); + +/// Last directory refresh per agent pubkey, for [`DIRECTORY_REFRESH_MIN_INTERVAL`]. +static DIRECTORY_REFRESH_AT: std::sync::LazyLock< + std::sync::Mutex>, +> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new())); + +fn directory_refresh_due(agent_pubkey: &str) -> bool { + let Ok(mut seen) = DIRECTORY_REFRESH_AT.lock() else { + return true; + }; + let now = std::time::Instant::now(); + match seen.get(agent_pubkey) { + Some(last) if now.duration_since(*last) < DIRECTORY_REFRESH_MIN_INTERVAL => false, + _ => { + seen.insert(agent_pubkey.to_string(), now); + true + } + } +} + +/// Refresh the directory entry of every locally managed agent. +/// +/// Only the machine holding an agent's key can sign its entry, so when someone +/// *else* adds our agent to a channel we never learn about it through +/// `add_channel_members`. This command lets the UI ask for a refresh at points +/// where a stale entry would be visible — the agent would otherwise stay +/// unmentionable in that channel until its next restart. +/// +/// Throttled per agent and best-effort: safe to call liberally. +#[tauri::command] +pub async fn refresh_agent_directory_entries( + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let Ok(records) = load_managed_agents(&app) else { + return Ok(()); + }; + for record in records { + if !directory_refresh_due(&record.pubkey) { + continue; + } + refresh_agent_directory_entry(&app, &state, &record.pubkey.clone()).await; + } + Ok(()) +} + /// Refresh a locally managed agent's kind:10100 directory entry after its /// channel membership changed. /// diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 122c872e54..a2bfa79a21 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -35,6 +35,7 @@ import { installAcpRuntime, listManagedAgents, listRelayAgents, + refreshAgentDirectoryEntries, saveCustomHarness, updateManagedAgent, } from "@/shared/api/tauri"; @@ -323,7 +324,14 @@ export function useManagedAgentPrereqsQuery( export function useRelayAgentsQuery(options?: { enabled?: boolean }) { return useQuery({ queryKey: relayAgentsQueryKey, - queryFn: listRelayAgents, + // Republish our own agents' entries before reading the directory: a + // membership change made from another device cannot reach them any other + // way, and a stale entry makes the agent unmentionable. Throttled in the + // backend and never fatal — a failure just means we read the directory as-is. + queryFn: async () => { + await refreshAgentDirectoryEntries().catch(() => {}); + return listRelayAgents(); + }, staleTime: 30_000, // Relay agent profiles (kind:10100) are near-static and the backing // `list_relay_agents` command is an unfiltered relay query for the whole diff --git a/desktop/src/features/agents/ui/RespondToField.tsx b/desktop/src/features/agents/ui/RespondToField.tsx index b32b9e0983..20e3ce15db 100644 --- a/desktop/src/features/agents/ui/RespondToField.tsx +++ b/desktop/src/features/agents/ui/RespondToField.tsx @@ -222,9 +222,22 @@ export function CreateAgentRespondToField({ {mode === "anyone" ? accessWarning : null} {mode === "owner-only" ? (

- Only you can send instructions. + Only you can send instructions. The agent stays out of everyone else's + @-mention suggestions.

) : null} + {mode === "anyone" ? ( +

+ Anyone you share a channel with can @-mention the agent, including + from their own devices. Add it to a channel first — outside your + shared channels it stays invisible to them. +

+ ) : null} +

+ The agent runs on the computer that started it, and only that computer + can update where it can be reached. Others reach it over the relay, so + it answers only while that computer is awake and the app is open. +

{mode === "allowlist" ? ( { + await invokeTauri("refresh_agent_directory_entries"); +} + export async function listRelayAgents(): Promise { return (await invokeTauri("list_relay_agents")).map( fromRawRelayAgent, From 3ea35d4c18b6d58dee5f34c1029b6c2d7a5c926b Mon Sep 17 00:00:00 2001 From: Szymon Tanski Date: Fri, 31 Jul 2026 23:57:51 +0200 Subject: [PATCH 6/6] fix(desktop): treat channel membership as proof an anyone-agent is reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `respond_to: anyone` means "any member of a channel I am in", but the check asked only the agent's own directory entry whether it shares a channel with us. That entry can only be written by the machine holding the agent's key, so it lags behind reality whenever someone else changes the agent's channels, and is empty for an agent that has not republished since starting. Visibility therefore depended on whether the agent's host happened to refresh in time — something the reader can neither observe nor influence, and which showed up as a newly created agent being unmentionable right after being added to a channel. Accept the open channel's membership as an equally valid signal. The client already holds it, and it is correct the moment the agent joins. The directory entry stays as the second source, still covering shared channels whose membership is not loaded. `owner-only` and `allowlist` are unaffected: presence in a channel never widens who may instruct an agent. Signed-off-by: Szymon Tanski --- .../lib/agentAutocompleteEligibility.test.mjs | 71 +++++++++++++++++++ .../lib/agentAutocompleteEligibility.ts | 50 ++++++++++++- .../src/features/messages/lib/useMentions.ts | 10 +++ 3 files changed, 128 insertions(+), 3 deletions(-) diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index fa4d8985c3..cd251bf41a 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -352,3 +352,74 @@ test("coalesceAgentAutocompleteCandidates: leaves non-agents alone", () => { assert.deepEqual(coalesce([first, second]), [first, second]); }); + +test("relayAgentIsSharedWithUser: channel membership beats a stale directory entry", () => { + // The agent joined a channel we are in, but its host has not republished the + // directory entry yet — the common case right after creating an agent. + const staleAgent = { + pubkey: PUB_A, + respondTo: "anyone", + respondToAllowlist: [], + channelIds: [], + }; + + assert.equal( + relayAgentIsSharedWithUser( + staleAgent, + new Set(["general"]), + CURRENT_PUBKEY, + ), + false, + ); + assert.equal( + relayAgentIsSharedWithUser( + staleAgent, + new Set(["general"]), + CURRENT_PUBKEY, + new Set([PUB_A]), + ), + true, + ); +}); + +test("relayAgentIsSharedWithUser: presence does not override owner-only", () => { + const ownerOnly = { + pubkey: PUB_A, + respondTo: "owner-only", + respondToAllowlist: [], + channelIds: [], + }; + assert.equal( + relayAgentIsSharedWithUser( + ownerOnly, + new Set(["general"]), + CURRENT_PUBKEY, + new Set([PUB_A]), + ), + false, + ); +}); + +test("getMentionableAgentPubkeys: includes agents present in the open channel", () => { + const result = getMentionableAgentPubkeys({ + currentPubkey: CURRENT_PUBKEY, + managedAgentPubkeys: [], + relayAgents: [ + { + pubkey: PUB_A, + respondTo: "anyone", + respondToAllowlist: [], + channelIds: [], + }, + { + pubkey: PUB_B, + respondTo: "owner-only", + respondToAllowlist: [], + channelIds: [], + }, + ], + sharedChannelIds: new Set(["general"]), + presentChannelPubkeys: new Set([PUB_A, PUB_B]), + }); + assert.deepEqual(result, new Set([PUB_A])); +}); diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index cb140d16eb..c115746c65 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -9,10 +9,36 @@ export function getSharedChannelIds(channels: readonly Channel[] | undefined) { ); } +/** + * Whether this client may @-mention `agent`. + * + * `allowlist` is decided by the list alone — it is channel-independent. + * + * `anyone` means "any member of a channel I am in", which is true as soon as + * the agent and the reader share a channel. Two independent signals establish + * that, and either is sufficient: + * + * 1. `presentChannelPubkeys` — the agent appears in the membership of a + * channel we are in. This comes from data the client already holds and is + * correct the moment the agent joins. + * 2. `agent.channelIds` — the agent's own directory entry. Only the machine + * holding the agent's key can update it, so it lags whenever someone else + * changes the agent's channels, and is empty for an agent that has not + * republished since starting. + * + * Relying on (2) alone made an agent's visibility depend on whether its host + * happened to refresh in time, which is not something a reader can observe or + * influence. (1) is authoritative for the reader's own channels; (2) still + * covers shared channels whose membership we have not loaded. + */ export function relayAgentIsSharedWithUser( - agent: Pick, + agent: Pick< + RelayAgent, + "channelIds" | "pubkey" | "respondTo" | "respondToAllowlist" + >, sharedChannelIds: ReadonlySet, currentPubkey?: string | null, + presentChannelPubkeys?: ReadonlySet, ) { const normalizedCurrentPubkey = currentPubkey ? normalizePubkey(currentPubkey) @@ -24,8 +50,12 @@ export function relayAgentIsSharedWithUser( .includes(normalizedCurrentPubkey); } + if (agent.respondTo !== "anyone") { + return false; + } + return ( - agent.respondTo === "anyone" && + presentChannelPubkeys?.has(normalizePubkey(agent.pubkey)) === true || agent.channelIds.some((channelId) => sharedChannelIds.has(channelId)) ); } @@ -35,18 +65,32 @@ export function getMentionableAgentPubkeys({ managedAgentPubkeys, relayAgents, sharedChannelIds, + presentChannelPubkeys, }: { currentPubkey?: string | null; managedAgentPubkeys: Iterable; relayAgents: readonly RelayAgent[] | undefined; sharedChannelIds: ReadonlySet; + /** + * Pubkeys the client can see in the membership of channels it is in — + * typically the open channel's member list. See + * {@link relayAgentIsSharedWithUser} for why this outranks the directory. + */ + presentChannelPubkeys?: ReadonlySet; }) { const pubkeys = new Set( [...managedAgentPubkeys].map((pubkey) => normalizePubkey(pubkey)), ); for (const agent of relayAgents ?? []) { - if (relayAgentIsSharedWithUser(agent, sharedChannelIds, currentPubkey)) { + if ( + relayAgentIsSharedWithUser( + agent, + sharedChannelIds, + currentPubkey, + presentChannelPubkeys, + ) + ) { pubkeys.add(normalizePubkey(agent.pubkey)); } } diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index bd944e141f..3f7ed01723 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -192,6 +192,14 @@ export function useMentions( () => getSharedChannelIds(channelsQuery.data), [channelsQuery.data], ); + // Membership of the channel we are composing in. An agent present here is + // reachable for us right now, regardless of what its directory entry says — + // see `relayAgentIsSharedWithUser`. + const presentChannelPubkeys = React.useMemo( + () => + new Set((members ?? []).map((member) => normalizePubkey(member.pubkey))), + [members], + ); const mentionableAgentPubkeys = React.useMemo( () => getMentionableAgentPubkeys({ @@ -199,10 +207,12 @@ export function useMentions( managedAgentPubkeys, relayAgents: relayAgentsQuery.data, sharedChannelIds, + presentChannelPubkeys, }), [ currentPubkey, managedAgentPubkeys, + presentChannelPubkeys, relayAgentsQuery.data, sharedChannelIds, ],