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
35 changes: 26 additions & 9 deletions desktop/src-tauri/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1188,11 +1188,15 @@ pub async fn start_managed_agent(
// profile is published on the relay. This self-heals cases where the initial
// profile sync at creation time failed silently. For legacy records (pre-PR-921)
// with no persisted avatar, this also backfills the avatar from the relay.
if result.is_ok()
&& state
.managed_agent_profile_reconcile_enabled
.load(std::sync::atomic::Ordering::Acquire)
{
// Called whenever the start succeeded, regardless of the
// `agentManagedProfiles` experiment. `reconcile_agent_profile` publishes the
// kind:10100 directory record first and *then* re-checks that flag before
// touching kind:0, so the experiment still decides profile-metadata
// ownership while agent discovery keeps working either way. Gating here
// instead would leave remote agents unmentionable for exactly the users who
// enabled the experiment — and nothing else in the product publishes that
// record.
if result.is_ok() {
let reconcile_pubkey = pubkey.clone();
let reconcile_app = app.clone();
tauri::async_runtime::spawn(async move {
Expand All @@ -1218,7 +1222,9 @@ pub async fn stop_managed_agent(
app: AppHandle,
) -> Result<ManagedAgentSummary, String> {
use tauri::Manager;
tokio::task::spawn_blocking(move || {
let directory_app = app.clone();
let directory_pubkey = pubkey.clone();
let summary = tokio::task::spawn_blocking(move || {
let state = app.state::<AppState>();
let _store_guard = state
.managed_agents_store_lock
Expand Down Expand Up @@ -1267,7 +1273,15 @@ pub async fn stop_managed_agent(
)
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
.map_err(|e| format!("spawn_blocking failed: {e}"))??;

// The agent is no longer running, so refresh its directory record to
// `offline`. Other clients read that record to decide whether to offer the
// agent in mentions; without this they would keep offering an agent that
// cannot answer.
profile::spawn_offline_directory_update(&directory_app, &directory_pubkey);

Ok(summary)
}

// Async so the blocking body (disk reads/writes, process termination, keyring
Expand Down Expand Up @@ -1367,8 +1381,11 @@ pub(crate) use deploy::resolve_deploy_model_provider;
#[path = "agents_profile.rs"]
mod profile;
#[cfg(test)]
use profile::{profile_needs_sync, resolve_legacy_avatar};
pub(crate) use profile::{reconcile_agent_profile, ProfileReconcileData};
use profile::{
build_agent_directory_content, channel_ids_from_member_events, channel_names_from_meta_events,
existing_channel_add_policy, profile_needs_sync, resolve_legacy_avatar,
};
pub(crate) use profile::{publish_agent_directory, reconcile_agent_profile, ProfileReconcileData};

#[cfg(test)]
#[path = "agents_tests.rs"]
Expand Down
279 changes: 279 additions & 0 deletions desktop/src-tauri/src/commands/agents_profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,27 @@ pub(crate) async fn reconcile_agent_profile(
&relay_ws_url_with_override(state),
);

// ── kind:10100 agent directory record ───────────────────────────────────
// Published *before* both gates below, deliberately.
//
// The `managed_agent_profile_reconcile_enabled` guard tracks the
// `agentManagedProfiles` experiment, which decides whether the desktop or
// the agent itself owns the agent's kind:0 profile metadata. The directory
// record is a different concern — discovery, not metadata — and nothing
// else in the product publishes it. Gating it on that experiment would
// leave remote agents undiscoverable for exactly the users who opted in.
//
// It also precedes the kind:0 staleness check further down: that check asks
// whether profile metadata diverged, and for a steady-state agent the
// answer is no, so anything behind it never runs for the agents that most
// need announcing. The directory record must refresh on every start.
//
// Failures are logged, not propagated: a directory publish that cannot
// reach the relay must not prevent kind:0 reconciliation or block startup.
if let Err(e) = publish_agent_directory(state, app, &relay_url, agent_pubkey, "online").await {
eprintln!("buzz-desktop: agent directory publish failed for {agent_pubkey}: {e}");
}

if !state
.managed_agent_profile_reconcile_enabled
.load(std::sync::atomic::Ordering::Acquire)
Expand Down Expand Up @@ -161,6 +182,264 @@ pub(crate) async fn reconcile_agent_profile(
.await
}

/// Publish the agent's kind:10100 directory record for the current state.
///
/// Reads the agent's channel memberships from the relay, projects the
/// publishable subset of its record, and publishes the result signed by the
/// agent's own key. `status` is the liveness the record should advertise —
/// `"online"` when the agent is starting, `"offline"` when it has stopped.
///
/// Both existing `reconcile_agent_profile` call sites (agent create/start and
/// boot restore) reach this through that function, so no new call site is
/// needed for an agent to appear in the directory.
pub(crate) async fn publish_agent_directory(
state: &AppState,
app: &AppHandle,
relay_url: &str,
agent_pubkey: &str,
status: &str,
) -> Result<(), String> {
let record = load_managed_agents(app)?
.into_iter()
.find(|r| r.pubkey == agent_pubkey)
.ok_or_else(|| format!("no managed agent record for {agent_pubkey}"))?;

// The record must be signed by the agent, not its owner — see
// `relay::publish_agent_directory_record`. `load_managed_agents` rehydrates
// the nsec from the keyring, so this is populated even though it is blanked
// in the on-disk JSON.
let agent_keys = Keys::parse(&record.private_key_nsec)
.map_err(|e| format!("failed to parse agent keys: {e}"))?;

let api_base = crate::relay::relay_http_base_url(relay_url);
let auth_tag = record.auth_tag.as_deref();

// Channels the agent belongs to: kind:39002 membership events tagging it,
// whose `d` tag carries the channel id. Queried as the agent so the read is
// scoped to what the agent itself can see.
let member_events = crate::relay::query_relay_at_with_keys(
state,
&api_base,
&[serde_json::json!({"kinds": [39002], "#p": [agent_pubkey]})],
&agent_keys,
auth_tag,
)
.await?;
let channel_ids = channel_ids_from_member_events(&member_events);

// kind:39000 is addressable — exactly one event per `d` tag — so a limit
// equal to the id count is both necessary and sufficient. Mirrors
// `get_channels`.
let channel_names = if channel_ids.is_empty() {
Vec::new()
} else {
let meta_events = crate::relay::query_relay_at_with_keys(
state,
&api_base,
&[serde_json::json!({
"kinds": [39000],
"#d": channel_ids,
"limit": channel_ids.len(),
})],
&agent_keys,
auth_tag,
)
.await?;
channel_names_from_meta_events(&meta_events, &channel_ids)
};

// `channel_add_policy` is required by the relay's side-effect handler for
// this kind, and that handler writes the value straight into
// `users.channel_add_policy`. So the agent's existing policy is read back
// and re-sent verbatim: hardcoding a value here would reset a deliberate
// `owner_only` or `nobody` to `anyone` on every single agent start.
let prior_records = crate::relay::query_relay_at_with_keys(
state,
&api_base,
&[serde_json::json!({
"kinds": [buzz_sdk_pkg::kind::KIND_AGENT_PROFILE],
"authors": [agent_pubkey],
"limit": 1,
})],
&agent_keys,
auth_tag,
)
.await
.unwrap_or_default();
let channel_add_policy = existing_channel_add_policy(&prior_records);

let content = build_agent_directory_content(
&record,
&channel_ids,
&channel_names,
status,
&channel_add_policy,
);

crate::relay::publish_agent_directory_record(state, relay_url, &agent_keys, &content, auth_tag)
.await
}

/// Refresh a stopped agent's directory record so other clients stop offering
/// it in their mention autocomplete.
///
/// Fire-and-forget, mirroring the profile-reconcile pattern on the start path:
/// a directory refresh that cannot reach the relay must never fail the stop the
/// user asked for. kind:10100 is replaceable, so this is a plain re-publish
/// with the status flipped — no tombstone.
pub(crate) fn spawn_offline_directory_update(app: &AppHandle, agent_pubkey: &str) {
let app = app.clone();
let agent_pubkey = agent_pubkey.to_string();
tauri::async_runtime::spawn(async move {
use tauri::Manager;
let state = app.state::<AppState>();
let relay_url = relay_ws_url_with_override(&state);
if let Err(e) =
publish_agent_directory(&state, &app, &relay_url, &agent_pubkey, "offline").await
{
eprintln!(
"buzz-desktop: offline directory update failed for agent {agent_pubkey}: {e}"
);
}
});
}

/// The relay's default `channel_add_policy`, matching the schema default on
/// `users.channel_add_policy`. Sending this for an agent that has never
/// published a directory record leaves the stored policy exactly as it was.
const DEFAULT_CHANNEL_ADD_POLICY: &str = "anyone";

/// Recover the `channel_add_policy` an agent already published, if any.
///
/// This must be preserved rather than hardcoded. The relay's side effect for
/// kind:10100 writes whatever the record carries straight into
/// `users.channel_add_policy`, so publishing a fixed value would silently
/// rewrite an operator's deliberate `owner_only` or `nobody` choice every time
/// the agent starts. A missing or malformed prior record falls back to the
/// schema default, which is a no-op against a never-configured agent.
pub(super) fn existing_channel_add_policy(events: &[nostr::Event]) -> String {
events
.iter()
.find_map(|ev| {
serde_json::from_str::<serde_json::Value>(&ev.content)
.ok()?
.get("channel_add_policy")?
.as_str()
.map(str::to_string)
})
.unwrap_or_else(|| DEFAULT_CHANNEL_ADD_POLICY.to_string())
}

/// Extract channel ids from kind:39002 membership events.
///
/// The channel id lives in the event's `d` tag. An event without one is skipped
/// rather than failing the batch — one malformed membership record must not
/// stop an agent from announcing the channels it *is* in.
pub(super) fn channel_ids_from_member_events(events: &[nostr::Event]) -> Vec<String> {
let mut ids: Vec<String> = events
.iter()
.filter_map(|ev| {
ev.tags.iter().find_map(|t| {
let slice = t.as_slice();
if slice.len() >= 2 && slice[0] == "d" {
Some(slice[1].clone())
} else {
None
}
})
})
.collect();
ids.sort();
ids.dedup();
ids
}

/// Resolve display names for `channel_ids` from kind:39000 metadata events,
/// falling back to the id when a channel has no readable metadata. Positional:
/// the returned vector lines up with `channel_ids`.
///
/// kind:39000 carries the channel id in its `d` tag and the human-readable name
/// in a `name` tag; `content` is empty on these events, so the name must be read
/// from the tags rather than parsed out of the body.
pub(super) fn channel_names_from_meta_events(
events: &[nostr::Event],
channel_ids: &[String],
) -> Vec<String> {
let names: std::collections::HashMap<String, String> = events
.iter()
.filter_map(|ev| {
let mut channel_id = None;
let mut name = None;
for tag in ev.tags.iter() {
let slice = tag.as_slice();
if slice.len() < 2 {
continue;
}
match slice[0].as_str() {
"d" => channel_id = Some(slice[1].clone()),
"name" => name = Some(slice[1].clone()),
_ => {}
}
}
Some((channel_id?, name?))
})
.collect();

channel_ids
.iter()
.map(|id| names.get(id).cloned().unwrap_or_else(|| id.clone()))
.collect()
}

/// Build the world-readable content for an agent's kind:10100 directory record.
///
/// The relay serves kind:10100 to any authenticated member, so this is an
/// explicit allowlist of publishable fields rather than a projection of the
/// managed-agent record. Anything absent here is absent deliberately — in
/// particular the agent's nsec, its NIP-OA auth tag, its environment
/// variables, and its runtime/harness configuration must never leave the host.
///
/// `pubkey` is carried for readability only. `agents_from_events` overwrites it
/// with the event author, which is what makes a record's identity derive from
/// its signature rather than from a self-declared field — and is why only the
/// hosting machine, which holds the agent's key, can publish one.
///
/// `capabilities` is currently always empty: a managed agent record declares no
/// capability set, and the client already defaults the field when it is absent.
pub(super) fn build_agent_directory_content(
record: &ManagedAgentRecord,
channel_ids: &[String],
channel_names: &[String],
status: &str,
channel_add_policy: &str,
) -> serde_json::Value {
let name = record
.display_name
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(&record.name);

let agent_type = if record.agent_command.trim().is_empty() {
"agent"
} else {
record.agent_command.trim()
};

serde_json::json!({
"pubkey": record.pubkey,
"name": name,
"agent_type": agent_type,
"channels": channel_names,
"channel_ids": channel_ids,
"capabilities": Vec::<String>::new(),
"status": status,
"respond_to": record.respond_to.as_str(),
"respond_to_allowlist": record.respond_to_allowlist,
"channel_add_policy": channel_add_policy,
})
}

/// Decide whether a published profile is missing or stale relative to the
/// expected name and avatar. A missing profile always needs sync; a present
/// one is stale when either the display name or picture diverges.
Expand Down
Loading