Skip to content
Merged
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
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