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
16 changes: 2 additions & 14 deletions desktop/src-tauri/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1100,19 +1100,7 @@ pub async fn start_managed_agent(
// profile reconcile (the create-time snapshot may be empty or stale for
// a persona-inherited harness).
let reconcile_personas = load_personas(&app).unwrap_or_default();
let reconcile_effective_command =
crate::managed_agents::record_agent_command(record, &reconcile_personas);

let reconcile = ProfileReconcileData {
private_key_nsec: record.private_key_nsec.clone(),
name: record.name.clone(),
relay_url: record.relay_url.clone(),
avatar_url: record.avatar_url.clone(),
auth_tag: record.auth_tag.clone(),
pubkey: record.pubkey.clone(),
agent_command: reconcile_effective_command,
persona_id: record.persona_id.clone(),
};
let reconcile = profile_reconcile_data(record, &reconcile_personas);

let target = if record.backend == BackendKind::Local {
StartTarget::Local
Expand Down Expand Up @@ -1362,9 +1350,9 @@ use deploy::{ensure_remote_provider_supported, resolve_deploy_model_provider};

#[path = "agents_profile.rs"]
mod profile;
pub(crate) use profile::*;
#[cfg(test)]
use profile::{profile_needs_sync, resolve_legacy_avatar};
pub(crate) use profile::{reconcile_agent_profile, ProfileReconcileData};

#[cfg(test)]
#[path = "agents_tests.rs"]
Expand Down
113 changes: 103 additions & 10 deletions desktop/src-tauri/src/commands/agents_profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,27 @@
//! guard). Owns the reconcile data carrier, the legacy-avatar backfill, and
//! the needs-sync predicate.

use tauri::AppHandle;
use tauri::{AppHandle, Manager};

use crate::app_state::AppState;
use crate::managed_agents::managed_agent_avatar_url;

use super::*;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ProfileReconcileOutcome {
Reconciled,
SkippedDisabled,
}

pub(crate) struct ProfileReconcileData {
pub(crate) private_key_nsec: String,
pub(crate) name: String,
pub(crate) relay_url: String,
/// Exact relay for migration work captured while a community is active.
/// Ordinary runtime reconciliation leaves this unset and resolves against
/// the current workspace at execution time.
pub(crate) target_relay_url: Option<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.
Expand Down Expand Up @@ -49,6 +59,88 @@ pub(super) fn resolve_legacy_avatar(
.unwrap_or_default()
}

pub(crate) fn profile_reconcile_data(
record: &crate::managed_agents::ManagedAgentRecord,
personas: &[crate::managed_agents::AgentDefinition],
) -> ProfileReconcileData {
ProfileReconcileData {
private_key_nsec: record.private_key_nsec.clone(),
name: record.name.clone(),
relay_url: record.relay_url.clone(),
target_relay_url: None,
avatar_url: record.avatar_url.clone(),
auth_tag: record.auth_tag.clone(),
pubkey: record.pubkey.clone(),
agent_command: crate::managed_agents::record_agent_command(record, personas),
persona_id: record.persona_id.clone(),
}
}

pub(crate) fn load_pending_profile_reconciliations(
app: &AppHandle,
workspace_relay: &str,
) -> Result<Vec<(String, ProfileReconcileData)>, String> {
let state = app.state::<AppState>();
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let store_path = crate::managed_agents::managed_agents_store_path(app)?;
let queue_path = crate::migration::profile_reconcile_queue_path(&store_path);
if !queue_path.exists() {
return Ok(Vec::new());
}

let relay_key = crate::migration::profile_reconcile_relay_key(workspace_relay)?;
let pending = crate::migration::read_profile_reconcile_queue(&queue_path)?;
let records = crate::managed_agents::load_managed_agents(app)?;
let personas = crate::managed_agents::load_personas(app).unwrap_or_default();
Ok(records
.iter()
// A queue write deliberately precedes the migrated agent-store write.
// If the process dies between them, retain (but do not execute) the
// stale item until the next boot finishes renaming the record.
.filter(|record| {
pending.iter().any(|entry| {
entry.pubkey == record.pubkey
&& entry.expected_name == record.name
&& !entry
.reconciled_relays
.iter()
.any(|relay| relay == &relay_key)
})
})
.map(|record| {
let mut data = profile_reconcile_data(record, &personas);
// Pin the relay captured by the caller. Otherwise a fast community
// switch could make a queued task for A run on B.
data.target_relay_url = Some(workspace_relay.to_string());
(record.pubkey.clone(), data)
})
.collect())
}

pub(crate) fn mark_profile_reconciled(
app: &AppHandle,
pubkey: &str,
relay_url: &str,
) -> Result<(), String> {
let state = app.state::<AppState>();
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let store_path = crate::managed_agents::managed_agents_store_path(app)?;
let queue_path = crate::migration::profile_reconcile_queue_path(&store_path);
if !queue_path.exists() {
return Ok(());
}
let relay_key = crate::migration::profile_reconcile_relay_key(relay_url)?;
let mut pending = crate::migration::read_profile_reconcile_queue(&queue_path)?;
crate::migration::record_profile_reconciled(&mut pending, pubkey, relay_key);
crate::migration::write_profile_reconcile_queue(&queue_path, &pending)
}

/// Reconcile an agent's kind:0 profile on the relay.
///
/// Queries the relay for the agent's existing profile and re-publishes if missing
Expand All @@ -71,21 +163,21 @@ pub(crate) async fn reconcile_agent_profile(
app: &AppHandle,
agent_pubkey: &str,
data: &ProfileReconcileData,
) -> Result<(), String> {
) -> Result<ProfileReconcileOutcome, 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),
);
let workspace_relay = relay_ws_url_with_override(state);
let relay_url = data.target_relay_url.clone().unwrap_or_else(|| {
crate::relay::effective_agent_relay_url(&data.relay_url, &workspace_relay)
});

if !state
.managed_agent_profile_reconcile_enabled
.load(std::sync::atomic::Ordering::Acquire)
{
return Ok(());
return Ok(ProfileReconcileOutcome::SkippedDisabled);
}

// Query the relay for the agent's existing kind:0 profile.
Expand Down Expand Up @@ -137,7 +229,7 @@ pub(crate) async fn reconcile_agent_profile(
};

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

let agent_keys = Keys::parse(&data.private_key_nsec)
Expand All @@ -147,7 +239,7 @@ pub(crate) async fn reconcile_agent_profile(
.managed_agent_profile_reconcile_enabled
.load(std::sync::atomic::Ordering::Acquire)
{
return Ok(());
return Ok(ProfileReconcileOutcome::SkippedDisabled);
}

sync_managed_agent_profile(
Expand All @@ -158,7 +250,8 @@ pub(crate) async fn reconcile_agent_profile(
expected_avatar.as_deref(),
data.auth_tag.as_deref(),
)
.await
.await?;
Ok(ProfileReconcileOutcome::Reconciled)
}

/// Decide whether a published profile is missing or stale relative to the
Expand Down
11 changes: 11 additions & 0 deletions desktop/src-tauri/src/commands/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,9 @@ pub async fn apply_workspace(
app: AppHandle,
) -> Result<(), String> {
let restore_app = app.clone();
// Capture the caller's relay before the blocking apply. Reading shared
// state afterward could pick up a newer concurrent community switch.
let profile_reconcile_relay = relay_url.clone();
tokio::task::spawn_blocking(move || {
let state = app.state::<AppState>();

Expand Down Expand Up @@ -213,6 +216,14 @@ pub async fn apply_workspace(

let state = restore_app.state::<AppState>();
super::agents::provider_access::reconcile_on_workspace_apply(&restore_app, &state).await?;
// The Bumble→Pollen migration may have renamed stopped agents. Reconcile
// their relay profiles independently of runtime restore; successful writes
// record this relay while retaining the agent for other communities, and
// failures retry on the next workspace apply.
crate::managed_agents::spawn_pending_profile_reconciliations(
&restore_app,
&profile_reconcile_relay,
);

// Backfill this exact relay+owner scope only after the workspace has been
// applied. Running at process boot would target the fallback relay and
Expand Down
24 changes: 17 additions & 7 deletions desktop/src-tauri/src/managed_agents/personas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,17 @@ const FIZZ_SYSTEM_PROMPT: &str = "You are Fizz, an energetic maker who turns ide

const HONEY_SYSTEM_PROMPT: &str = "You are Honey, a warm and thoughtful communicator. Help users write clearly, organize ideas, brainstorm, summarize, and prepare for conversations. Be kind, creative, and concise. Add occasional bee wordplay or 🍯🐝—keep it sweet, never excessive.";

const BUMBLE_SYSTEM_PROMPT: &str = "You are Bumble, a curious and adventurous researcher. Explore questions, compare options, check assumptions, and explain what you find clearly. Be candid when uncertain and favor useful evidence. Add occasional bee wordplay or 🐝🔎—keep it playful, never chaotic.";
// Keep the published NIP-33 coordinate stable so existing Pollen agents and
// references are upgraded in place instead of being orphaned by the rename.
pub(crate) const POLLEN_PERSONA_ID: &str = "builtin:bumble";
pub(crate) const POLLEN_DISPLAY_NAME: &str = "Pollen";
pub(crate) const POLLEN_SYSTEM_PROMPT: &str = "You are Pollen, a curious and adventurous researcher. Explore questions, compare options, check assumptions, and explain what you find clearly. Be candid when uncertain and favor useful evidence. Add occasional bee wordplay or 🐝🔎—keep it playful, never chaotic.";
pub(crate) const POLLEN_LEGACY_DISPLAY_NAME: &str = "Bumble";
pub(crate) const POLLEN_LEGACY_SYSTEM_PROMPT: &str = "You are Bumble, a curious and adventurous researcher. Explore questions, compare options, check assumptions, and explain what you find clearly. Be candid when uncertain and favor useful evidence. Add occasional bee wordplay or 🐝🔎—keep it playful, never chaotic.";
// The embedded bytes are unchanged by the display-name migration. Keep the
// original storage symbol as the compatibility source and expose the current
// product name everywhere it is consumed.
const POLLEN_AVATAR: &str = BUMBLE_AVATAR;

const BUILT_IN_PERSONAS: &[BuiltInPersona] = &[
BuiltInPersona {
Expand All @@ -32,7 +42,7 @@ const BUILT_IN_PERSONAS: &[BuiltInPersona] = &[
avatar_url: Some(FIZZ_AVATAR),
system_prompt: FIZZ_SYSTEM_PROMPT,
name_pool: &[
"Nectar", "Comet", "Bramble", "Clover", "Pollen", "Amber", "Daisy", "Mason", "Thistle",
"Nectar", "Comet", "Bramble", "Clover", "Amber", "Daisy", "Mason", "Thistle",
"Waxwing", "Hive", "Meadow", "Juniper", "Aster", "Sage", "Willow", "Orchard", "Buzz",
],
model: None,
Expand All @@ -50,11 +60,11 @@ const BUILT_IN_PERSONAS: &[BuiltInPersona] = &[
default_active: true,
},
BuiltInPersona {
id: "builtin:bumble",
display_name: "Bumble",
avatar_url: Some(BUMBLE_AVATAR),
system_prompt: BUMBLE_SYSTEM_PROMPT,
name_pool: &["Bumble"],
id: POLLEN_PERSONA_ID,
display_name: POLLEN_DISPLAY_NAME,
avatar_url: Some(POLLEN_AVATAR),
system_prompt: POLLEN_SYSTEM_PROMPT,
name_pool: &[POLLEN_DISPLAY_NAME],
model: None,
runtime: None,
default_active: true,
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/src/managed_agents/personas/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ fn merge_personas_adds_missing_built_ins() {
.iter()
.map(|record| record.display_name.as_str())
.collect();
assert_eq!(display_names, vec!["Fizz", "Honey", "Bumble"]);
assert_eq!(display_names, vec!["Fizz", "Honey", "Pollen"]);
let active_ids: Vec<&str> = records
.iter()
.filter(|record| record.is_active)
Expand Down
68 changes: 68 additions & 0 deletions desktop/src-tauri/src/managed_agents/restore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,7 @@ pub async fn restore_managed_agents_on_launch(
private_key_nsec: record.private_key_nsec.clone(),
name: record.name.clone(),
relay_url: record.relay_url.clone(),
target_relay_url: None,
avatar_url: record.avatar_url.clone(),
auth_tag: record.auth_tag.clone(),
pubkey: record.pubkey.clone(),
Expand Down Expand Up @@ -472,6 +473,73 @@ pub async fn restore_managed_agents_on_launch(
Ok(())
}

fn profile_reconcile_completed(outcome: crate::commands::ProfileReconcileOutcome) -> bool {
outcome == crate::commands::ProfileReconcileOutcome::Reconciled
}

pub(crate) fn spawn_pending_profile_reconciliations(app: &tauri::AppHandle, workspace_relay: &str) {
let state = app.state::<AppState>();
if !state
.managed_agent_profile_reconcile_enabled
.load(Ordering::Acquire)
{
return;
}
let items = match crate::commands::load_pending_profile_reconciliations(app, workspace_relay) {
Ok(items) => items,
Err(error) => {
eprintln!("buzz-desktop: failed to load pending profile reconciliations: {error}");
return;
}
};

for (pubkey, data) in items {
let reconcile_app = app.clone();
let relay_url = data
.target_relay_url
.clone()
.unwrap_or_else(|| data.relay_url.clone());
tauri::async_runtime::spawn(async move {
let state = reconcile_app.state::<AppState>();
match crate::commands::reconcile_agent_profile(&state, &reconcile_app, &pubkey, &data)
.await
{
Ok(outcome) if profile_reconcile_completed(outcome) => {
if let Err(error) = crate::commands::mark_profile_reconciled(
&reconcile_app,
&pubkey,
&relay_url,
) {
eprintln!(
"buzz-desktop: failed to record profile reconciliation for agent {pubkey}: {error}"
);
}
}
Ok(_) => {}
Err(error) => eprintln!(
"buzz-desktop: profile reconciliation failed for agent {pubkey}: {error}"
),
}
});
}
}

#[cfg(test)]
mod profile_reconcile_tests {
use super::profile_reconcile_completed;
use crate::commands::ProfileReconcileOutcome;

#[test]
fn skipped_reconciliation_never_retires_pending_work() {
assert!(profile_reconcile_completed(
ProfileReconcileOutcome::Reconciled
));
assert!(!profile_reconcile_completed(
ProfileReconcileOutcome::SkippedDisabled
));
}
}

#[cfg(feature = "mesh-llm")]
fn persist_restore_error(
app: &tauri::AppHandle,
Expand Down
12 changes: 6 additions & 6 deletions desktop/src-tauri/src/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,13 +169,11 @@ fn run_boot_migrations_inner(app: &tauri::AppHandle, reset_completed: bool) {
}
migrate_persona_provider_to_runtime(app);
reconcile_legacy_command_names(app);
// Fold personas.json into the unified store HERE: after the JSON-level
// personas.json migrations above (which must see the legacy file), and
// before every consumer of the load/save_personas shims below —
// sync_team_personas would otherwise operate on an empty definition set.
// Post-fold readers of the runtime map (`load_persona_runtimes`) fall
// back to the unified store's definitions.
// Fold personas.json after its JSON-level migrations and before consumers
// below; otherwise sync_team_personas sees an empty definition set.
// Post-fold runtime reads fall back to unified-store definitions.
fold_personas_into_agent_store(app);
pollen::migrate_pollen_agent_name(app);
// Clean the legacy baked team-instructions suffix out of stored prompts
// AFTER the fold (so definitions lifted out of personas.json are cleaned in
// the same boot) and BEFORE backfill_standalone_agents (so a manufactured
Expand Down Expand Up @@ -1376,6 +1374,8 @@ mod backfill;
pub use backfill::backfill_standalone_agents;
mod detach;
pub use detach::detach_directory_backed_teams;
mod pollen;
pub(crate) use pollen::*;
mod team_suffix;
pub use team_suffix::strip_baked_team_instructions;

Expand Down
Loading