Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
2 changes: 2 additions & 0 deletions desktop/src-tauri/src/commands/agent_config_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ fn goose_runtime() -> &'static KnownAcpRuntime {

fn agent_record() -> ManagedAgentRecord {
ManagedAgentRecord {
description: None,
pubkey: "agent".to_string(),
name: "Agent".to_string(),
persona_id: Some("persona-1".to_string()),
Expand Down Expand Up @@ -127,6 +128,7 @@ fn agent_record() -> ManagedAgentRecord {

fn persona_with_model(model: &str) -> AgentDefinition {
AgentDefinition {
description: None,
id: "persona-1".to_string(),
display_name: "Persona".to_string(),
avatar_url: None,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,7 @@ mod real_relay_tests {
&agent,
"Agent Probe",
None,
None,
Some(&auth_tag),
)
.await
Expand Down
37 changes: 14 additions & 23 deletions desktop/src-tauri/src/commands/agent_models_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -428,29 +428,20 @@ fn model_discovery_ignores_stale_record_for_linked_agent() {
)
.expect("sample managed agent record");

let persona = crate::managed_agents::AgentDefinition {
id: "persona-1".to_string(),
display_name: "Persona".to_string(),
avatar_url: None,
system_prompt: "You are a persona.".to_string(),
runtime: Some("goose".to_string()),
model: Some("persona-model".to_string()),
provider: Some("anthropic".to_string()),
name_pool: Vec::new(),
is_builtin: false,
is_active: true,
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
team_catalog_source: None,
env_vars: BTreeMap::new(),
respond_to: None,
respond_to_allowlist: Vec::new(),
parallelism: None,
created_at: "".to_string(),
updated_at: "".to_string(),
};
let persona: crate::managed_agents::AgentDefinition = serde_json::from_str(
r#"{
"id": "persona-1",
"display_name": "Persona",
"system_prompt": "You are a persona.",
"runtime": "goose",
"model": "persona-model",
"provider": "anthropic",
"is_active": true,
"created_at": "",
"updated_at": ""
}"#,
)
.expect("sample persona");

// agent_model_discovery_config is the single helper get_agent_models
// consumes — the stale record bytes must lose to the persona's current
Expand Down
13 changes: 11 additions & 2 deletions desktop/src-tauri/src/commands/agent_models_update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,8 +244,16 @@ pub async fn update_managed_agent(
.avatar_url
.clone()
.or_else(|| managed_agent_avatar_url(&effective_command));
let about = crate::managed_agents::record_effective_description(record, &personas);
let auth_tag = record.auth_tag.clone();
Some((agent_keys, relay_url, display_name, avatar_url, auth_tag))
Some((
agent_keys,
relay_url,
display_name,
avatar_url,
about,
auth_tag,
))
} else {
None
};
Expand Down Expand Up @@ -291,13 +299,14 @@ pub async fn update_managed_agent(
// A rename is committed only when profile sync succeeds; otherwise restore
// the complete pre-edit record so Desktop and the relay keep one
// authoritative name.
if let Some((agent_keys, relay_url, display_name, avatar_url, auth_tag)) = sync_params {
if let Some((agent_keys, relay_url, display_name, avatar_url, about, auth_tag)) = sync_params {
if let Err(sync_error) = sync_managed_agent_profile(
&state,
&relay_url,
&agent_keys,
&display_name,
avatar_url.as_deref(),
about.as_deref(),
auth_tag.as_deref(),
)
.await
Expand Down
21 changes: 10 additions & 11 deletions desktop/src-tauri/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use crate::{
CreateManagedAgentResponse, ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig,
DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS,
},
relay::{relay_ws_url_with_override, sync_managed_agent_profile},
relay::relay_ws_url_with_override,
util::now_iso,
};

Expand Down Expand Up @@ -486,7 +486,7 @@ pub async fn create_managed_agent(
};

// ── Phase 3: save record (sync lock) ───────────────────────────────────────
let (agent, resolved_avatar_url) = {
let (agent, resolved_avatar_url, profile_about) = {
let _store_guard = state
.managed_agents_store_lock
.lock()
Expand Down Expand Up @@ -637,10 +637,10 @@ pub async fn create_managed_agent(
input.parallelism,
linked_persona.as_ref(),
)?;

let record = ManagedAgentRecord {
pubkey: pubkey.clone(),
name: name.clone(),
description: None,
persona_id: requested_persona_id.clone(),
team_id,
private_key_nsec: private_key_nsec.clone(),
Expand Down Expand Up @@ -739,9 +739,12 @@ pub async fn create_managed_agent(
// before any .await — owner-authored, every agent (Will's ruling: no
// is_builtin/persona-membership gate).
retain_managed_agent_pending(&app, &state, record);
// Effective owner-authored description for the kind:0 `about`.
let profile_about = crate::managed_agents::record_effective_description(record, &personas);
(
summarize_from_disk(&app, record, &runtimes)?,
resolved_avatar_url,
profile_about,
)
};

Expand Down Expand Up @@ -781,20 +784,16 @@ pub async fn create_managed_agent(
// ── Phase 4: sync agent profile on relay (async, outside lock) ───────────
// Use the avatar persisted on the record so the published profile and any
// later reconciliation agree on the same value.
let profile_relay_url = crate::relay::effective_agent_relay_url(
&resolved_relay_url,
&relay_ws_url_with_override(&state),
);
let mut profile_sync_error = (sync_managed_agent_profile(
let mut profile_sync_error = profile::publish_agent_profile_with_about(
&state,
&profile_relay_url,
&resolved_relay_url,
&agent_keys,
&name,
resolved_avatar_url.as_deref(),
profile_about.as_deref(),
auth_tag.as_deref(),
)
.await)
.err();
.await;
profile_sync_error =
super::agent_models::flush_managed_agent_policy(&app, &state, profile_sync_error).await;

Expand Down
81 changes: 77 additions & 4 deletions desktop/src-tauri/src/commands/agents_profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ pub(crate) struct ProfileReconcileData {
/// backfill to recover the correct avatar from the persona record when the
/// relay profile has been corrupted.
pub(crate) persona_id: Option<String>,
/// Expected kind:0 `about` — the agent's effective public description
/// (owner-authored when present; see
/// `managed_agents::record_effective_description`). `None` publishes an
/// about-less profile.
pub(crate) about: Option<String>,
}

/// Resolve the avatar to backfill for a legacy agent record (pre-PR-921, no
Expand Down Expand Up @@ -96,6 +101,7 @@ pub(crate) fn profile_reconcile_data(
pubkey: record.pubkey.clone(),
agent_command: crate::managed_agents::record_agent_command(record, personas),
persona_id: record.persona_id.clone(),
about: crate::managed_agents::record_effective_description(record, personas),
}
}

Expand Down Expand Up @@ -254,7 +260,12 @@ pub(crate) async fn reconcile_agent_profile(
Some(expected_avatar)
};

if !profile_needs_sync(existing.as_ref(), &data.name, expected_avatar.as_deref()) {
if !profile_needs_sync(
existing.as_ref(),
&data.name,
expected_avatar.as_deref(),
data.about.as_deref(),
) {
return Ok(ProfileReconcileOutcome::Reconciled);
}

Expand All @@ -274,30 +285,92 @@ pub(crate) async fn reconcile_agent_profile(
&agent_keys,
&data.name,
expected_avatar.as_deref(),
data.about.as_deref(),
data.auth_tag.as_deref(),
)
.await?;
Ok(ProfileReconcileOutcome::Reconciled)
}

/// 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.
/// expected name, avatar, and about. A missing profile always needs sync; a
/// present one is stale when the display name, picture, or about diverges.
/// For about, `None` and the empty string are treated as equal so an
/// about-less profile never triggers a pointless republish loop.
pub(super) fn profile_needs_sync(
existing: Option<&crate::relay::AgentProfileInfo>,
expected_name: &str,
expected_avatar: Option<&str>,
expected_about: Option<&str>,
) -> bool {
match existing {
None => true,
Some(info) => {
let name_matches = info.display_name.as_deref() == Some(expected_name);
let picture_matches = info.picture.as_deref() == expected_avatar;
!name_matches || !picture_matches
let about_matches = info.about.as_deref().unwrap_or("") == expected_about.unwrap_or("");
!name_matches || !picture_matches || !about_matches
}
}
}

/// Publish a managed agent's kind:0 profile with the authored public
/// description as `about`, resolving the effective
/// relay URL from the record's stored value. Returns the sync error (if any)
/// rather than failing the caller — profile publish is best-effort in the
/// create and snapshot-import flows that share this helper.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn publish_agent_profile_with_about(
state: &AppState,
record_relay_url: &str,
agent_keys: &nostr::Keys,
display_name: &str,
avatar_url: Option<&str>,
about: Option<&str>,
auth_tag: Option<&str>,
) -> Option<String> {
let relay_url = crate::relay::effective_agent_relay_url(
record_relay_url,
&relay_ws_url_with_override(state),
);
crate::relay::sync_managed_agent_profile(
state,
&relay_url,
agent_keys,
display_name,
avatar_url,
about,
auth_tag,
)
.await
.err()
}

/// Publish a fresh persona-backed agent's kind:0 profile, computing the
/// effective public `about` from the persona itself.
/// Shared by flows in files at the size ratchet (snapshot import).
pub(crate) async fn publish_persona_profile(
state: &AppState,
record_relay_url: &str,
agent_keys: &nostr::Keys,
display_name: &str,
avatar_url: Option<&str>,
persona: &crate::managed_agents::AgentDefinition,
auth_tag: Option<&str>,
) -> Option<String> {
let about = crate::managed_agents::effective_agent_description(persona.description.as_deref());
publish_agent_profile_with_about(
state,
record_relay_url,
agent_keys,
display_name,
avatar_url,
about.as_deref(),
auth_tag,
)
.await
}

// Async so the blocking body (disk reads/writes + process termination) runs off
// the main UI thread via spawn_blocking. State is re-derived from the owned
// AppHandle inside the closure (`State<'_, _>` is borrowed, MutexGuard is !Send).
Loading
Loading