Skip to content
Closed
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
4 changes: 2 additions & 2 deletions desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,14 @@ const rules = [
// Do not add to this list; split the file instead. Remove each entry as its
// file is broken up. Tracked as a follow-up.
const overrides = new Map([
["src-tauri/src/commands/agents.rs", 1294],
["src-tauri/src/commands/agents.rs", 1110],
// Residual repos_dir integration in ensure_nest_at: REPOS is provisioned
// outside NEST_DIRS (it may be a symlink), so it needs its own create +
// chmod-only-when-real-dir handling plus integration test coverage. The
// self-contained repos_dir functions and their unit tests live in repos.rs;
// this is the seam that must stay in nest.rs. Approved override; still queued
// to split with the rest of this list.
["src-tauri/src/managed_agents/nest.rs", 1447],
["src-tauri/src/managed_agents/nest.rs", 1449],
["src-tauri/src/managed_agents/runtime.rs", 1953],
["src-tauri/src/managed_agents/personas.rs", 1080],
["src-tauri/src/managed_agents/persona_card.rs", 1050],
Expand Down
50 changes: 29 additions & 21 deletions desktop/src-tauri/src/commands/agent_models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,21 @@ use crate::{
app_state::AppState,
managed_agents::{
build_managed_agent_summary, default_agent_workdir, find_managed_agent_mut,
known_acp_runtime, load_managed_agents, load_personas, managed_agent_avatar_url,
missing_command_message, normalize_agent_args, resolve_command,
resolve_effective_prompt_model_provider, save_managed_agents, sync_managed_agent_processes,
try_regenerate_nest, AgentModelInfo, AgentModelsResponse, UpdateManagedAgentRequest,
UpdateManagedAgentResponse,
known_acp_runtime, load_managed_agents, load_personas, missing_command_message,
normalize_agent_args, resolve_command, resolve_effective_prompt_model_provider,
save_managed_agents, sync_managed_agent_processes, try_regenerate_nest, AgentModelInfo,
AgentModelsResponse, UpdateManagedAgentRequest, UpdateManagedAgentResponse,
},
relay::{relay_ws_url_with_override, sync_managed_agent_profile},
util::now_iso,
};

fn trim_optional(value: Option<String>) -> Option<String> {
value
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}

/// Query available models from an agent via `buzz-acp models --json`.
///
/// Spawns a short-lived subprocess (no relay connection needed). The subprocess
Expand Down Expand Up @@ -139,7 +144,8 @@ pub async fn get_agent_models(
///
/// Does NOT auto-restart the agent. Runtime config changes (system prompt,
/// parallelism, commands, toolsets) take effect on the next agent spawn.
/// Name changes are synced to the relay immediately via a kind:0 re-publish.
/// Name and avatar changes are synced to the relay immediately via a kind:0
/// re-publish.
#[tauri::command]
pub async fn update_managed_agent(
input: UpdateManagedAgentRequest,
Expand All @@ -162,13 +168,21 @@ pub async fn update_managed_agent(
let record = find_managed_agent_mut(&mut records, &input.pubkey)?;

let mut name_changed = false;
let mut avatar_changed = false;
if let Some(name_update) = input.name {
let trimmed = name_update.trim().to_string();
if !trimmed.is_empty() && trimmed != record.name {
record.name = trimmed;
name_changed = true;
}
}
if let Some(avatar_update) = input.avatar_url {
let normalized = trim_optional(avatar_update);
if normalized != record.avatar_url {
record.avatar_url = normalized;
avatar_changed = true;
}
}
if let Some(model_update) = input.model {
record.model = model_update;
}
Expand All @@ -188,11 +202,13 @@ pub async fn update_managed_agent(
if let Some(turn_timeout_seconds) = input.turn_timeout_seconds {
record.turn_timeout_seconds = turn_timeout_seconds;
}
// Store the relay override exactly as supplied (trimmed). An explicit
// value pins the agent; empty falls back to the workspace relay at
// read-time. A name-only edit (relay_url == None) leaves the pin intact.
if let Some(relay_url) = input.relay_url {
record.relay_url = relay_url.trim().to_string();
let trimmed = relay_url.trim();
record.relay_url = if trimmed.is_empty() {
relay_ws_url_with_override(&state)
} else {
trimmed.to_string()
};
}
if let Some(acp_command) = input.acp_command {
record.acp_command = acp_command;
Expand Down Expand Up @@ -243,20 +259,12 @@ pub async fn update_managed_agent(
.find(|r| r.pubkey == input.pubkey)
.ok_or_else(|| format!("agent {} not found", input.pubkey))?;

let sync_params = if name_changed {
let sync_params = if name_changed || avatar_changed {
let agent_keys = Keys::parse(&record.private_key_nsec)
.map_err(|e| format!("failed to parse agent keys: {e}"))?;
// Re-publish the renamed profile to the agent's effective relay:
// an explicit per-agent relay wins; empty falls back to workspace.
let relay_url = crate::relay::effective_agent_relay_url(
&record.relay_url,
&relay_ws_url_with_override(&state),
);
let relay_url = record.relay_url.clone();
let display_name = record.name.clone();
let avatar_url = record
.avatar_url
.clone()
.or_else(|| managed_agent_avatar_url(&record.agent_command));
let avatar_url = record.avatar_url.clone();
let auth_tag = record.auth_tag.clone();
Some((agent_keys, relay_url, display_name, avatar_url, auth_tag))
} else {
Expand Down
159 changes: 35 additions & 124 deletions desktop/src-tauri/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ fn resolve_created_avatar_url(
requested_avatar_url: Option<&str>,
persona_avatar_url: Option<String>,
agent_command: &str,
use_command_fallback: bool,
) -> Option<String> {
requested_avatar_url
.and_then(trim_to_optional_string)
Expand All @@ -68,7 +69,13 @@ fn resolve_created_avatar_url(
.as_deref()
.and_then(trim_to_optional_string)
})
.or_else(|| managed_agent_avatar_url(agent_command))
.or_else(|| {
if use_command_fallback {
managed_agent_avatar_url(agent_command)
} else {
None
}
})
}

#[cfg(feature = "mesh-llm")]
Expand Down Expand Up @@ -145,7 +152,6 @@ async fn start_local_agent_with_preflight(
/// empty map would surface as an opaque 401 from the provider.
fn build_deploy_payload(
app: &AppHandle,
state: &AppState,
record: &ManagedAgentRecord,
) -> Result<serde_json::Value, String> {
// Merge persona env_vars + agent env_vars for provider deploy. Same
Expand Down Expand Up @@ -175,15 +181,7 @@ fn build_deploy_payload(

Ok(serde_json::json!({
"name": &record.name,
// Resolve the per-agent pin against the active workspace relay here:
// this payload crosses the host boundary to a remote provider harness
// that has no notion of the desktop's workspace, so the blank→workspace
// fallback (otherwise applied at read-time in `effective_agent_relay_url`)
// must be materialized into a concrete URL before serializing.
"relay_url": crate::relay::effective_agent_relay_url(
&record.relay_url,
&relay_ws_url_with_override(state),
),
"relay_url": &record.relay_url,
"private_key_nsec": &record.private_key_nsec,
"auth_tag": &record.auth_tag,
"agent_command": &record.agent_command,
Expand Down Expand Up @@ -393,15 +391,13 @@ pub async fn create_managed_agent(
.to_bech32()
.map_err(|error| format!("failed to encode private key: {error}"))?;

// Store the relay override exactly as supplied (trimmed). An explicit
// value pins the agent; empty stays empty and resolves to the active
// workspace relay at read-time. Uniform for Local and Provider.
let resolved_relay_url = input
.relay_url
.as_deref()
.map(str::trim)
.unwrap_or("")
.to_string();
.filter(|value| !value.is_empty())
.map(str::to_string)
.unwrap_or_else(|| relay_ws_url_with_override(&state));

(keys, private_key_nsec, pubkey, resolved_relay_url, input)
};
Expand Down Expand Up @@ -522,6 +518,7 @@ pub async fn create_managed_agent(
input.avatar_url.as_deref(),
persona_avatar_url,
&agent_command,
requested_persona_id.is_none(),
);

let record = crate::managed_agents::ManagedAgentRecord {
Expand Down Expand Up @@ -676,7 +673,7 @@ pub async fn create_managed_agent(
.iter()
.find(|r| r.pubkey == pubkey)
.ok_or_else(|| "agent disappeared".to_string())?;
build_deploy_payload(&app, &state, rec)
build_deploy_payload(&app, rec)
};
// The agent was already persisted in Phase 3 — converting a
// persona-resolution failure into `spawn_error` (rather than
Expand Down Expand Up @@ -743,21 +740,10 @@ pub(crate) struct ProfileReconcileData {
pub(crate) private_key_nsec: String,
pub(crate) name: String,
pub(crate) relay_url: String,
/// Expected avatar URL for the published profile. `None` for legacy records
/// that predate the `avatar_url` field — these will be backfilled from the
/// relay's existing kind:0 profile on first reconciliation.
/// Expected avatar URL for the published profile. Derived once at creation
/// and stored verbatim; reconciliation republishes it as-is.
pub(crate) avatar_url: Option<String>,
pub(crate) auth_tag: Option<String>,
/// The agent's pubkey (hex). Needed to update the persisted record during
/// avatar backfill migration.
pub(crate) pubkey: String,
/// The agent's command (e.g. "goose"). Used as fallback when no profile
/// exists on the relay during avatar backfill.
pub(crate) agent_command: String,
/// Persona ID if this agent was created from a persona. Used during avatar
/// backfill to recover the correct avatar from the persona record when the
/// relay profile has been corrupted.
pub(crate) persona_id: Option<String>,
}

#[tauri::command]
Expand Down Expand Up @@ -803,9 +789,6 @@ pub async fn start_managed_agent(
relay_url: record.relay_url.clone(),
avatar_url: record.avatar_url.clone(),
auth_tag: record.auth_tag.clone(),
pubkey: record.pubkey.clone(),
agent_command: record.agent_command.clone(),
persona_id: record.persona_id.clone(),
};

let target = if record.backend == BackendKind::Local {
Expand All @@ -814,7 +797,7 @@ pub async fn start_managed_agent(
StartTarget::Provider {
backend: record.backend.clone(),
cached_binary_path: record.provider_binary_path.clone(),
agent_json: build_deploy_payload(&app, &state, record)?,
agent_json: build_deploy_payload(&app, record)?,
}
};

Expand Down Expand Up @@ -866,17 +849,16 @@ pub async fn start_managed_agent(
// ── Profile reconciliation (fire-and-forget) ────────────────────────────
// On successful start, spawn a background task to ensure the agent's kind:0
// 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.
// profile sync at creation time failed silently. The avatar derived once at
// creation is published verbatim — there is no reconcile-time backfill.
if result.is_ok() {
let reconcile_pubkey = pubkey.clone();
let reconcile_app = app.clone();
tauri::async_runtime::spawn(async move {
use tauri::Manager;
let state = reconcile_app.state::<AppState>();
if let Err(e) =
reconcile_agent_profile(&state, &reconcile_app, &reconcile_pubkey, &reconcile_data)
.await
reconcile_agent_profile(&state, &reconcile_pubkey, &reconcile_data).await
{
eprintln!(
"buzz-desktop: profile reconciliation failed for agent {reconcile_pubkey}: {e}"
Expand All @@ -888,104 +870,33 @@ pub async fn start_managed_agent(
result
}

/// Resolve the avatar to backfill for a legacy agent record (pre-PR-921, no
/// stored `avatar_url`).
///
/// Priority: the persona's avatar wins, because the old reconciliation code
/// could have overwritten the relay's kind:0 `picture` with the command default
/// — making the relay an unreliable source for persona-backed agents. Only fall
/// back to the relay's `picture`, then the command icon, for agents with no
/// persona avatar to recover from.
fn resolve_legacy_avatar(
persona_avatar: Option<String>,
relay_picture: Option<String>,
agent_command: &str,
) -> String {
persona_avatar
.or(relay_picture)
.or_else(|| managed_agent_avatar_url(agent_command))
.unwrap_or_default()
}

/// Reconcile an agent's kind:0 profile on the relay.
///
/// Queries the relay for the agent's existing profile and re-publishes if missing
/// or stale (display_name or picture mismatch). This is fire-and-forget — errors
/// are returned to the caller for logging but never block agent startup.
///
/// For legacy records (pre-PR-921) where `avatar_url` is `None`, this function
/// backfills via `resolve_legacy_avatar` — preferring the persona record's avatar
/// over the relay's `picture`, since the old code may have corrupted the relay
/// profile — and persists the updated record. After backfill, normal
/// reconciliation proceeds.
/// or stale. This is fire-and-forget — errors are returned to the caller for
/// logging but never block agent startup.
///
/// Query and publish target the relay returned by `effective_agent_relay_url`
/// for every agent regardless of backend: an explicit per-agent `relay_url`
/// wins, and a blank one falls back to the active workspace relay. This keeps
/// reconciliation following the session's relay for never-pinned agents while
/// honoring a deliberate pin wherever it points.
/// The avatar is derived once at creation and stored on the record; reconcile
/// publishes that stored value verbatim with no backfill. Query and publish both
/// target the agent's stored `relay_url` so that, under an active workspace relay
/// override, reconciliation reads and writes the same relay the agent's profile
/// actually lives on.
pub(crate) async fn reconcile_agent_profile(
state: &AppState,
app: &AppHandle,
agent_pubkey: &str,
data: &ProfileReconcileData,
) -> Result<(), String> {
use crate::relay::{query_agent_profile, sync_managed_agent_profile};

// An explicit per-agent relay wins; an empty one falls back to the active
// workspace relay. Resolved once and used for both the read and write-back.
let relay_url = crate::relay::effective_agent_relay_url(
&data.relay_url,
&relay_ws_url_with_override(state),
);

// Query the relay for the agent's existing kind:0 profile.
let existing = query_agent_profile(state, &relay_url, agent_pubkey).await?;

// Resolve the expected avatar — backfilling for legacy records that have no
// stored avatar_url yet.
let expected_avatar = match data.avatar_url.as_deref() {
Some(url) => url.to_string(),
None => {
// Legacy record: the relay profile may have been corrupted by the
// old reconciliation code (it overwrote the persona avatar with the
// command default), so the persona record is the authoritative source.
let persona_avatar = data.persona_id.as_ref().and_then(|pid| {
load_personas(app)
.ok()?
.into_iter()
.find(|p| p.id == *pid)?
.avatar_url
});
let existing = query_agent_profile(state, &data.relay_url, agent_pubkey).await?;

let backfilled = resolve_legacy_avatar(
persona_avatar,
existing.as_ref().and_then(|info| info.picture.clone()),
&data.agent_command,
);

// Persist the backfilled avatar so this migration only runs once.
if !backfilled.is_empty() {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let mut records = load_managed_agents(app)?;
if let Some(record) = records.iter_mut().find(|r| r.pubkey == data.pubkey) {
record.avatar_url = Some(backfilled.clone());
save_managed_agents(app, &records)?;
}
}
// Republish the avatar exactly as derived once at creation. There is no
// reconcile-time backfill: a stored `None` (including an explicit clear)
// is published verbatim.
let expected_avatar = data.avatar_url.clone();

backfilled
}
};

if expected_avatar.is_empty() {
return Ok(());
}

if !profile_needs_sync(existing.as_ref(), &data.name, Some(&expected_avatar)) {
if !profile_needs_sync(existing.as_ref(), &data.name, expected_avatar.as_deref()) {
return Ok(());
}

Expand All @@ -994,10 +905,10 @@ pub(crate) async fn reconcile_agent_profile(

sync_managed_agent_profile(
state,
&relay_url,
&data.relay_url,
&agent_keys,
&data.name,
Some(&expected_avatar),
expected_avatar.as_deref(),
data.auth_tag.as_deref(),
)
.await
Expand Down
Loading
Loading