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
141 changes: 141 additions & 0 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,97 @@ async fn publish_presence(
Ok(())
}

/// Build the kind:10100 agent-profile content JSON.
///
/// Field names are the cross-client contract: desktop reads them in
/// `agents_from_events` and mobile in `AgentDirectoryEntry.fromEvent`, and the
/// relay's ingest side effect requires `channel_add_policy`. Collections are
/// normalized (lowercased pubkeys) and sorted so republishes are
/// deterministic.
fn agent_profile_content(
respond_to: &RespondTo,
allowlist: &HashSet<String>,
channel_ids: &HashSet<Uuid>,
) -> String {
let mut allow: Vec<String> = allowlist.iter().map(|pk| pk.to_ascii_lowercase()).collect();
allow.sort();
let mut channels: Vec<String> = channel_ids.iter().map(Uuid::to_string).collect();
channels.sort();
serde_json::json!({
"respond_to": respond_to.to_string(),
"respond_to_allowlist": allow,
"channel_ids": channels,
"channel_add_policy": "anyone",
})
.to_string()
}

/// Publish/refresh the agent's kind:10100 directory profile (replaceable).
///
/// Self-attested discovery metadata for client UX — mention pickers,
/// "managed by" labels, and respond_to-aware eligibility. Without it,
/// clients cannot see that `respond_to=anyone` and hide the agent from
/// non-owners' mention autocomplete. Enforcement stays in `author_allowed`;
/// clients must treat this as advisory.
async fn publish_agent_profile(
publisher: &relay::RelayEventPublisher,
keys: &nostr::Keys,
respond_to: &RespondTo,
allowlist: &HashSet<String>,
channel_ids: &HashSet<Uuid>,
) -> Result<(), relay::RelayError> {
use buzz_core::kind::KIND_AGENT_PROFILE;
use nostr::{EventBuilder, Kind};

let content = agent_profile_content(respond_to, allowlist, channel_ids);
let event = EventBuilder::new(Kind::Custom(KIND_AGENT_PROFILE as u16), content)
.tags([])
.sign_with_keys(keys)
.map_err(|e| relay::RelayError::Http(format!("agent profile sign error: {e}")))?;
publisher.publish_event(event).await?;
Ok(())
}

#[cfg(test)]
mod agent_profile_content_tests {
use super::*;

#[test]
fn content_matches_cross_client_contract() {
let mut allowlist = HashSet::new();
allowlist.insert("B".repeat(64));
allowlist.insert("a".repeat(64));
let mut channels = HashSet::new();
let ch = Uuid::parse_str("af804d33-a3c1-4415-858e-3747c296a613").unwrap();
channels.insert(ch);

let content = agent_profile_content(&RespondTo::Allowlist, &allowlist, &channels);
let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();

assert_eq!(parsed["respond_to"], "allowlist");
assert_eq!(
parsed["respond_to_allowlist"],
serde_json::json!(["a".repeat(64), "b".repeat(64)]),
);
assert_eq!(parsed["channel_ids"], serde_json::json!([ch.to_string()]));
assert_eq!(parsed["channel_add_policy"], "anyone");
}

#[test]
fn respond_to_strings_match_client_comparisons() {
for (mode, expected) in [
(RespondTo::Anyone, "anyone"),
(RespondTo::OwnerOnly, "owner-only"),
(RespondTo::Allowlist, "allowlist"),
(RespondTo::Nobody, "nobody"),
] {
let content = agent_profile_content(&mode, &HashSet::new(), &HashSet::new());
let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(parsed["respond_to"], expected);
}
}
}

fn emit_runtime_lifecycle(
observer: Option<&observer::ObserverHandle>,
start_nonce: &str,
Expand Down Expand Up @@ -1566,6 +1657,29 @@ async fn tokio_main() -> Result<()> {
}
}

// Advertise the agent in the relay directory (kind:10100) so other
// clients can see its respond_to policy and offer it in mention
// autocomplete. Published after channel subscriptions so channel_ids
// reflects the live set. Best-effort: a miss self-heals on the next
// restart or membership change.
if let Err(e) = publish_agent_profile(
&presence_publisher,
&presence_keys,
&config.respond_to,
&config.respond_to_allowlist,
&subscribed_channel_ids,
)
.await
{
tracing::warn!("failed to publish agent profile (kind:10100): {e}");
} else {
tracing::info!(
channels = subscribed_channel_ids.len(),
respond_to = %config.respond_to,
"agent profile (kind:10100) published"
);
}

if config.lazy_pool {
emit_runtime_lifecycle(
observer.as_ref(),
Expand Down Expand Up @@ -2044,6 +2158,20 @@ async fn tokio_main() -> Result<()> {
tracing::warn!("failed to subscribe to new channel {ch}: {e}");
} else {
subscribed_channel_ids.insert(ch);
// Keep the directory profile's channel_ids
// fresh so the new channel's members can
// mention this agent without an agent restart.
if let Err(e) = publish_agent_profile(
&presence_publisher,
&presence_keys,
&config.respond_to,
&config.respond_to_allowlist,
&subscribed_channel_ids,
)
.await
{
tracing::warn!("failed to refresh agent profile (kind:10100): {e}");
}
}
} else {
tracing::debug!(channel_id = %ch, "membership notification: no matching rules — skipping");
Expand All @@ -2054,6 +2182,19 @@ async fn tokio_main() -> Result<()> {
if let Err(e) = relay.unsubscribe_channel(ch).await {
tracing::warn!("failed to unsubscribe from channel {ch}: {e}");
}
// Drop the channel from the directory profile so
// clients stop offering this agent there.
if let Err(e) = publish_agent_profile(
&presence_publisher,
&presence_keys,
&config.respond_to,
&config.respond_to_allowlist,
&subscribed_channel_ids,
)
.await
{
tracing::warn!("failed to refresh agent profile (kind:10100): {e}");
}
// Drain queued events and invalidate sessions for the
// removed channel. Events already in-flight will
// complete normally (the relay may reject actions if
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,27 +136,83 @@ test("getMentionableAgentPubkeys: keeps managed agents and shared relay agents",
assert.deepEqual(result, new Set([PUB_A, PUB_B, PUB_C]));
});

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

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

test("isAgentIdentityInManagedList: keeps agents owned by others or with unknown owner", () => {
const managedAgentPubkeys = new Set([PUB_A]);

// Foreign-owned agent (e.g. a channel-member agent someone else manages) —
// not ours to prune; directory policy decides visibility downstream.
assert.equal(
isAgentIdentityInManagedList(
{ isAgent: true, pubkey: PUB_B, ownerPubkey: OTHER_OWNER_PUBKEY },
managedAgentPubkeys,
CURRENT_PUBKEY,
),
true,
);
// Unknown ownership (no verified NIP-OA tag) — keep.
assert.equal(
isAgentIdentityInManagedList(
{ isAgent: true, pubkey: PUB_B },
managedAgentPubkeys,
CURRENT_PUBKEY,
),
true,
);
// Unknown current user — cannot attribute, keep.
assert.equal(
isAgentIdentityInManagedList(
{ isAgent: true, pubkey: PUB_B, ownerPubkey: CURRENT_PUBKEY },
managedAgentPubkeys,
null,
),
true,
);
});

test("isAgentIdentityInManagedList: drops stale self-owned agent identities", () => {
const managedAgentPubkeys = new Set([PUB_A]);

// Proves our ownership but is not a current managed identity — a stale
// incarnation of one of our own recreated agents.
assert.equal(
isAgentIdentityInManagedList(
{ isAgent: true, pubkey: PUB_B, ownerPubkey: CURRENT_PUBKEY },
managedAgentPubkeys,
CURRENT_PUBKEY,
),
false,
);
// Owner comparison is case-insensitive.
assert.equal(
isAgentIdentityInManagedList(
{
isAgent: true,
pubkey: PUB_B,
ownerPubkey: CURRENT_PUBKEY.toUpperCase(),
},
managedAgentPubkeys,
CURRENT_PUBKEY,
),
false,
);
Expand Down
19 changes: 14 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,22 @@ export function getMentionableAgentPubkeys({
}

export function isAgentIdentityInManagedList(
candidate: { isAgent?: boolean; pubkey: string },
candidate: { isAgent?: boolean; pubkey: string; ownerPubkey?: string | null },
managedAgentPubkeys: ReadonlySet<string>,
currentPubkey?: string | null,
) {
return (
candidate.isAgent !== true ||
managedAgentPubkeys.has(normalizePubkey(candidate.pubkey))
);
if (candidate.isAgent !== true) return true;
if (managedAgentPubkeys.has(normalizePubkey(candidate.pubkey))) return true;
// Only gate agents the current user owns: an agent identity that proves our
// ownership (verified NIP-OA tag) but is absent from our managed list is a
// stale incarnation of one of our own agents — hide it. Agents owned by
// someone else (or with unknown ownership) are not ours to prune; they fall
// through to shouldHideAgentFromMentions, which applies directory policy.
const owner = candidate.ownerPubkey
? normalizePubkey(candidate.ownerPubkey)
: null;
const current = currentPubkey ? normalizePubkey(currentPubkey) : null;
return owner === null || current === null || owner !== current;
}

export function shouldHideAgentFromMentions({
Expand Down
6 changes: 5 additions & 1 deletion desktop/src/features/channels/ui/MembersSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,11 @@ export function MembersSidebar({
)) ||
memberPubkeys.has(pubkey) ||
isArchivedDiscovery(pubkey) ||
!isAgentIdentityInManagedList(candidate, managedAgentPubkeys)
!isAgentIdentityInManagedList(
candidate,
managedAgentPubkeys,
currentPubkey,
)
) {
return;
}
Expand Down
28 changes: 27 additions & 1 deletion desktop/src/features/messages/lib/mentionCandidates.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { resolveTeamPersonas } from "@/features/agents/lib/teamPersonas";
import type { AgentPersona, AgentTeam, ChannelRole } from "@/shared/api/types";
import type {
AgentPersona,
AgentTeam,
ChannelRole,
UserSearchResult,
} from "@/shared/api/types";
import { truncatePubkey } from "@/shared/lib/pubkey";

export type TeamMentionMember = {
Expand Down Expand Up @@ -130,3 +135,24 @@ export function formatTeamMention(
) {
return `${teamName}(${members.map((member) => `@${member.displayName}`).join(" ")}) `;
}

export function formatSearchUserDisplayName(user: UserSearchResult) {
return user.displayName?.trim() || user.nip05Handle?.trim() || null;
}

export function formatSearchUserSecondaryLabel(user: UserSearchResult) {
const displayName = user.displayName?.trim();
const nip05Handle = user.nip05Handle?.trim();
if (displayName && nip05Handle) {
return nip05Handle;
}
return null;
}

export function appendUniqueName(current: string[], name: string): string[] {
return current.some(
(candidate) => candidate.toLowerCase() === name.toLowerCase(),
)
? current
: [...current, name];
}
30 changes: 10 additions & 20 deletions desktop/src/features/messages/lib/useMentions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import type {
AgentPersona,
ChannelMember,
ChannelType,
UserSearchResult,
} from "@/shared/api/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery";
Expand All @@ -41,7 +40,10 @@ import { useDraftMentionRouting } from "./useDraftMentionRouting";
import { rankMentionCandidates } from "./mentionRanking";
import { mapMentionCandidateToSuggestion } from "./mentionSuggestionMapping";
import {
appendUniqueName,
buildTeamMentionCandidates,
formatSearchUserDisplayName,
formatSearchUserSecondaryLabel,
formatTeamMention,
globalSearchIdentityKey,
type MentionCandidate,
Expand All @@ -56,24 +58,6 @@ export type PersonaMentionTarget = {
type UseMentionsOptions = {
channelType?: ChannelType | null;
};
function formatSearchUserDisplayName(user: UserSearchResult) {
return user.displayName?.trim() || user.nip05Handle?.trim() || null;
}
function formatSearchUserSecondaryLabel(user: UserSearchResult) {
const displayName = user.displayName?.trim();
const nip05Handle = user.nip05Handle?.trim();
if (displayName && nip05Handle) {
return nip05Handle;
}
return null;
}
function appendUniqueName(current: string[], name: string): string[] {
return current.some(
(candidate) => candidate.toLowerCase() === name.toLowerCase(),
)
? current
: [...current, name];
}
export function useMentions(
channelId: string | null,
externalMembers?: ChannelMember[],
Expand Down Expand Up @@ -246,7 +230,13 @@ export function useMentions(
if (isArchivedDiscovery(pubkey)) {
return;
}
if (!isAgentIdentityInManagedList(candidate, managedAgentPubkeys)) {
if (
!isAgentIdentityInManagedList(
candidate,
managedAgentPubkeys,
currentPubkey,
)
) {
return;
}
if (
Expand Down