diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 28af88229..681999afd 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -32,10 +32,11 @@ const rules = [ const overrides = new Map([ ["src-tauri/src/commands/agents.rs", 1294], ["src-tauri/src/managed_agents/nest.rs", 1420], - ["src-tauri/src/managed_agents/runtime.rs", 1940], + ["src-tauri/src/managed_agents/runtime.rs", 1975], ["src-tauri/src/managed_agents/personas.rs", 1080], ["src-tauri/src/managed_agents/persona_card.rs", 1050], - ["src/shared/api/tauri.ts", 1196], + ["src-tauri/src/managed_agents/types.rs", 1015], + ["src/shared/api/tauri.ts", 1205], ["src-tauri/src/nostr_convert.rs", 1126], ["src/shared/api/relayClientSession.ts", 1022], ["src-tauri/src/migration.rs", 1295], diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index dc64dc826..6703b065f 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -140,39 +140,20 @@ async fn start_local_agent_with_preflight( /// Build the standard agent JSON payload for provider deploy calls. /// -/// Fails closed if the agent points at a `persona_id` we can't load — persona -/// env_vars typically hold API credentials, and silently deploying with an -/// empty map would surface as an opaque 401 from the provider. -fn build_deploy_payload( - app: &AppHandle, - record: &ManagedAgentRecord, -) -> Result { - // Merge persona env_vars + agent env_vars for provider deploy. Same - // precedence as local spawn: persona first, agent overrides last. Without - // this, provider-backed agents wouldn't receive credentials saved on the - // persona or the agent itself. - let persona_env = - crate::managed_agents::resolve_persona_env(app, record.persona_id.as_deref())?; - let merged_env = crate::managed_agents::merged_user_env(&persona_env, &record.env_vars); - - // Resolve effective model/provider from the persona's structured fields. - // Agent record's model takes precedence (user override via UI). - let (effective_model, effective_provider) = if let Some(ref pid) = record.persona_id { - let personas = load_personas(app).map_err(|e| { - format!("failed to load personas for deploy payload model resolution: {e}") - })?; - let persona = personas.iter().find(|p| p.id == *pid); - let model = record - .model - .clone() - .or_else(|| persona.and_then(|p| p.model.clone())); - let provider = persona.and_then(|p| p.provider.clone()); - (model, provider) - } else { - (record.model.clone(), None) - }; - - Ok(serde_json::json!({ +/// Reads the agent's pinned record snapshot — `env_vars`, `model`, `provider` +/// were all captured from the persona at create time and never re-read live, so +/// a provider-backed agent pins identically to a local one. A persona edit +/// reaches it only via delete+respawn. +fn build_deploy_payload(record: &ManagedAgentRecord) -> serde_json::Value { + // The record's env_vars is the complete pinned env map (persona env merged + // under agent overrides at create). `merged_user_env` with an empty persona + // map applies the reserved-key / malformed-key / NUL filtering. + let merged_env = crate::managed_agents::merged_user_env( + &std::collections::BTreeMap::new(), + &record.env_vars, + ); + + serde_json::json!({ "name": &record.name, "relay_url": &record.relay_url, "private_key_nsec": &record.private_key_nsec, @@ -180,8 +161,8 @@ fn build_deploy_payload( "agent_command": &record.agent_command, "agent_args": &record.agent_args, "system_prompt": &record.system_prompt, - "model": effective_model, - "provider": effective_provider, + "model": &record.model, + "provider": &record.provider, "turn_timeout_seconds": record.turn_timeout_seconds, "idle_timeout_seconds": record.idle_timeout_seconds, "max_turn_duration_seconds": record.max_turn_duration_seconds, @@ -193,32 +174,7 @@ fn build_deploy_payload( // Merged persona + agent env vars. Providers that don't read this // field will simply ignore it — no protocol break. "env_vars": merged_env, - })) -} - -/// Persist a deploy-preparation error (currently: persona env resolution -/// failure inside `build_deploy_payload`) into the agent's `last_error` -/// so a refresh shows the cause. Mirrors what `deploy_to_provider` does -/// on its own failures — without this, an agent created with an invalid -/// persona_id would appear as `not_deployed` with no recorded reason. -fn persist_create_deploy_error( - app: &AppHandle, - state: &AppState, - pubkey: &str, - error: &str, -) -> Result<(), String> { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - let mut records = load_managed_agents(app)?; - let rec = records - .iter_mut() - .find(|r| r.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found"))?; - rec.last_error = Some(error.to_string()); - rec.updated_at = now_iso(); - save_managed_agents(app, &records) + }) } /// Deploy an agent to a provider backend. Resolves the binary, calls deploy via @@ -513,6 +469,35 @@ pub async fn create_managed_agent( &agent_command, ); + // Pin the persona config onto the record at create. After this, spawn + // and deploy read these snapshotted fields, never the live persona, so + // the agent stays on the config it was created with across restarts; + // delete+respawn re-runs create and rewrites the snapshot. env_vars are + // pinned too — without that, persona credential edits would leak into a + // running agent on restart. Agent-level env overrides (input.env_vars) + // layer on top, matching spawn precedence (persona env < agent env). + let persona_snapshot = requested_persona_id.as_deref().and_then(|pid| { + load_personas(&app) + .ok()? + .into_iter() + .find(|persona| persona.id == pid) + .map(|persona| { + crate::managed_agents::persona_events::persona_snapshot( + &persona, + &input.env_vars, + ) + }) + }); + let snapshot_prompt = persona_snapshot + .as_ref() + .and_then(|s| s.system_prompt.clone()); + let snapshot_model = persona_snapshot.as_ref().and_then(|s| s.model.clone()); + let snapshot_provider = persona_snapshot.as_ref().and_then(|s| s.provider.clone()); + let snapshot_source_version = persona_snapshot.as_ref().map(|s| s.source_version.clone()); + let snapshot_env_vars = persona_snapshot + .map(|s| s.env_vars) + .unwrap_or_else(|| input.env_vars.clone()); + let record = crate::managed_agents::ManagedAgentRecord { pubkey: pubkey.clone(), name: name.clone(), @@ -542,18 +527,24 @@ pub async fn create_managed_agent( .parallelism .filter(|count| (1..=32).contains(count)) .unwrap_or(DEFAULT_AGENT_PARALLELISM), - system_prompt: input - .system_prompt - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string), - model: input - .model - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string), + system_prompt: snapshot_prompt.or_else(|| { + input + .system_prompt + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + }), + model: snapshot_model.or_else(|| { + input + .model + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + }), + provider: snapshot_provider, + persona_source_version: snapshot_source_version, mcp_toolsets: input .mcp_toolsets .as_deref() @@ -575,7 +566,7 @@ pub async fn create_managed_agent( // NOT the display_name — ACP's resolve_persona_by_name() matches slugs. persona_team_dir: pack_metadata.as_ref().map(|(path, _)| path.clone()), persona_name_in_team: pack_metadata.as_ref().map(|(_, name)| name.clone()), - env_vars: input.env_vars.clone(), + env_vars: snapshot_env_vars, created_at: now_iso(), updated_at: now_iso(), last_started_at: None, @@ -665,31 +656,11 @@ pub async fn create_managed_agent( .iter() .find(|r| r.pubkey == pubkey) .ok_or_else(|| "agent disappeared".to_string())?; - build_deploy_payload(&app, rec) + build_deploy_payload(rec) }; - // The agent was already persisted in Phase 3 — converting a - // persona-resolution failure into `spawn_error` (rather than - // unwinding) keeps the record on disk and surfaces the cause - // in the agent's last_error / UI status. We persist the same - // error string into `last_error` so a refresh after restart - // still shows *why* deploy never happened, matching what - // `deploy_to_provider` does on its own failures. - match agent_json { - Err(e) => { - if let Err(persist_err) = persist_create_deploy_error(&app, &state, &pubkey, &e) - { - eprintln!( - "buzz-desktop: failed to persist deploy-prep error for {pubkey}: {persist_err}" - ); - } - Some(e) - } - Ok(json) => { - match deploy_to_provider(&app, &state, &pubkey, id, config, json, None).await { - Ok(()) => spawn_error, - Err(e) => Some(e), - } - } + match deploy_to_provider(&app, &state, &pubkey, id, config, agent_json, None).await { + Ok(()) => spawn_error, + Err(e) => Some(e), } } else { spawn_error @@ -803,7 +774,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, record)?, + agent_json: build_deploy_payload(record), } }; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index bd72dc3f7..bc0c997ce 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -109,7 +109,7 @@ use huddle::{ speak_agent_message, start_huddle, start_stt_pipeline, }; use managed_agents::{ - ensure_nest, kill_stale_tracked_processes, load_managed_agents, + backfill_persona_snapshots, ensure_nest, kill_stale_tracked_processes, load_managed_agents, restore_managed_agents_on_launch, save_managed_agents, sync_managed_agent_processes, try_regenerate_nest, BackendKind, ManagedAgentProcess, }; @@ -556,6 +556,16 @@ pub fn run() { eprintln!("buzz-desktop: sync-team-personas: {e}"); } + // Backfill the pinned persona snapshot for any pre-existing agent + // that predates the record-authoritative-spawn cutover (persona_id + // set but no source_version). Must run before + // restore_managed_agents_on_launch so no agent spawns from an empty + // snapshot. Synchronous and best-effort — a failure here must not + // block launch, but a missing persona is logged loudly inside. + if let Err(e) = backfill_persona_snapshots(&app_handle) { + eprintln!("buzz-desktop: persona-snapshot backfill failed: {e}"); + } + // Store the AppHandle so huddle commands can emit `huddle-state-changed` // events via `huddle::emit_huddle_state` without threading the handle // through every call site. diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 7c038adb8..084991f6b 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -4,6 +4,8 @@ mod env_vars; mod nest; mod persona_avatars; mod persona_card; +// `publish_persona_event` / `fetch_persona_events` are #939 publishing +// primitives not yet wired to a call site; keep them without a dead-code warn. #[allow(dead_code)] pub(crate) mod persona_events; mod personas; diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index 8b1f3c62d..f78bdeb05 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -974,6 +974,8 @@ mod tests { parallelism: 1, system_prompt: None, model: None, + provider: None, + persona_source_version: None, mcp_toolsets: None, start_on_app_launch: false, runtime_pid: None, diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index 1bd0a2ade..bc9292101 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -13,7 +13,7 @@ use super::PersonaRecord; use crate::app_state::AppState; /// The JSON body stored in a persona event's content field. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PersonaEventContent { pub display_name: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -129,6 +129,74 @@ pub async fn fetch_persona_events(state: &AppState) -> Result, crate::relay::query_relay(state, &[filter]).await } +/// SHA-256 (lowercase hex) of a persona's canonical content JSON. +/// +/// The drift indicator compares this digest, not event timestamps, to decide +/// whether an agent's persona snapshot is stale — timestamps are fragile across +/// clock skew and export/import round-trips. `PersonaEventContent` field order +/// is fixed by the struct definition, so `serde_json` produces a stable +/// canonical encoding. +pub fn persona_content_hash(content: &PersonaEventContent) -> String { + use sha2::{Digest, Sha256}; + let json = serde_json::to_vec(content).unwrap_or_default(); + let digest = Sha256::digest(&json); + hex::encode(digest) +} + +/// Project a `PersonaRecord` onto the content fields published in persona +/// events and engrams. Centralizes the field mapping so a new persona field is +/// added in exactly one place. +pub fn persona_event_content(record: &PersonaRecord) -> PersonaEventContent { + PersonaEventContent { + display_name: record.display_name.clone(), + avatar_url: record.avatar_url.clone(), + system_prompt: record.system_prompt.clone(), + runtime: record.runtime.clone(), + model: record.model.clone(), + provider: record.provider.clone(), + name_pool: record.name_pool.clone(), + } +} + +/// A persona's spawn-relevant config, pinned onto a `ManagedAgentRecord` at +/// create time. After the snapshot, spawn and deploy read these fields off the +/// record and never the live persona, so an agent stays pinned to the config +/// it was created with — restart reuses the snapshot, delete+respawn rewrites +/// it. +pub struct PersonaSnapshot { + pub system_prompt: Option, + pub model: Option, + pub provider: Option, + /// Persona env layered under the agent's own overrides (agent wins). This + /// is the complete env map the agent spawns with — no live persona lookup. + pub env_vars: BTreeMap, + /// `persona_content_hash` of the persona at snapshot time; the drift basis. + pub source_version: String, +} + +/// Build the pinned snapshot for an agent created from `persona`. +/// +/// `agent_env_overrides` are the agent's own env vars (persona-independent); +/// they win over persona env on key collision, matching spawn-time precedence +/// (persona env < agent env). The persona's `system_prompt` is always present, +/// so it is wrapped in `Some`. +pub fn persona_snapshot( + persona: &PersonaRecord, + agent_env_overrides: &BTreeMap, +) -> PersonaSnapshot { + let mut env_vars = persona.env_vars.clone(); + for (key, value) in agent_env_overrides { + env_vars.insert(key.clone(), value.clone()); + } + PersonaSnapshot { + system_prompt: Some(persona.system_prompt.clone()), + model: persona.model.clone(), + provider: persona.provider.clone(), + env_vars, + source_version: persona_content_hash(&persona_event_content(persona)), + } +} + #[cfg(test)] mod tests { use super::*; @@ -242,4 +310,40 @@ mod tests { assert!(!restored.is_builtin); assert!(restored.is_active); } + + #[test] + fn persona_content_hash_is_deterministic() { + let content = PersonaEventContent { + display_name: "Test".to_string(), + avatar_url: None, + system_prompt: "Hello".to_string(), + runtime: None, + model: None, + provider: None, + name_pool: vec![], + }; + let hash1 = persona_content_hash(&content); + let hash2 = persona_content_hash(&content); + assert_eq!(hash1, hash2); + assert_eq!(hash1.len(), 64); // SHA-256 hex + } + + #[test] + fn persona_content_hash_changes_on_edit() { + let content1 = PersonaEventContent { + display_name: "Test".to_string(), + avatar_url: None, + system_prompt: "Hello".to_string(), + runtime: None, + model: None, + provider: None, + name_pool: vec![], + }; + let mut content2 = content1.clone(); + content2.system_prompt = "Goodbye".to_string(); + assert_ne!( + persona_content_hash(&content1), + persona_content_hash(&content2) + ); + } } diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index 3469dc80d..ce418fcb3 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -1,8 +1,9 @@ #[cfg(feature = "mesh-llm")] use super::relay_mesh_model_id; use super::{ - find_managed_agent_mut, kill_stale_tracked_processes, load_managed_agents, save_managed_agents, - spawn_agent_child, sync_managed_agent_processes, BackendKind, ManagedAgentProcess, + find_managed_agent_mut, kill_stale_tracked_processes, load_managed_agents, load_personas, + save_managed_agents, spawn_agent_child, sync_managed_agent_processes, BackendKind, + ManagedAgentProcess, }; use crate::app_state::AppState; use crate::util; @@ -12,6 +13,67 @@ use tauri::Manager; type SpawnResult = Result; type AgentSpawnResult = (String, SpawnResult); +/// Backfill the pinned persona snapshot for pre-existing agents created before +/// the record became the spawn source of truth. Runs once at launch, before +/// `restore_managed_agents_on_launch` spawns anything, so no agent boots from an +/// empty snapshot. +/// +/// Only records with a `persona_id` but no `persona_source_version` are touched. +/// If the linked persona is gone, we log loudly and leave the snapshot empty — +/// the record's own `system_prompt`/`model` (possibly empty for persona-created +/// agents) is then all the config that remains, which is the same fallback an +/// orphaned agent already gets. +pub fn backfill_persona_snapshots(app: &tauri::AppHandle) -> Result<(), String> { + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + + let mut records = load_managed_agents(app)?; + let needs_backfill = records + .iter() + .any(|r| r.persona_id.is_some() && r.persona_source_version.is_none()); + if !needs_backfill { + return Ok(()); + } + + let personas = load_personas(app)?; + let mut changed = false; + for record in records.iter_mut() { + let Some(persona_id) = record.persona_id.clone() else { + continue; + }; + if record.persona_source_version.is_some() { + continue; + } + let Some(persona) = personas.iter().find(|p| p.id == persona_id) else { + eprintln!( + "buzz-desktop: persona-snapshot backfill: agent {} links persona {persona_id} which no longer exists; leaving snapshot empty — it will spawn from its record fields", + record.pubkey + ); + continue; + }; + // Layer the agent's own env overrides over persona env, matching + // create-time precedence (persona env < agent env). + let snapshot = super::persona_events::persona_snapshot(persona, &record.env_vars); + if let Some(prompt) = snapshot.system_prompt { + record.system_prompt = Some(prompt); + } + record.model = snapshot.model; + record.provider = snapshot.provider; + record.env_vars = snapshot.env_vars; + record.persona_source_version = Some(snapshot.source_version); + record.updated_at = util::now_iso(); + changed = true; + } + + if changed { + save_managed_agents(app, &records)?; + } + Ok(()) +} + /// Restore managed agents that were running before the app was closed. /// /// Split into three phases to minimise lock contention with the frontend: diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 328b888fd..b1a669abb 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1282,6 +1282,35 @@ pub fn sync_managed_agent_processes( changed } +/// Classify an agent's persona against the live catalog for the Agents-menu +/// drift indicator. Returns `(out_of_date, orphaned)`. +/// +/// Drift basis is the RECORD's `persona_source_version`, never the engram: +/// - persona_id set + persona present: out_of_date when the snapshot hash +/// differs from the persona's current content hash. +/// - persona_id set + persona gone: orphaned (no current hash to respawn into, +/// so never out_of_date — we must not tell the user to respawn into nothing). +/// - no persona_id: neither — a hand-built agent has no persona to drift from. +fn persona_drift_state( + record: &ManagedAgentRecord, + personas: &[crate::managed_agents::types::PersonaRecord], +) -> (bool, bool) { + let Some(persona_id) = record.persona_id.as_deref() else { + return (false, false); + }; + let Some(persona) = personas.iter().find(|p| p.id == persona_id) else { + return (false, true); + }; + let current = crate::managed_agents::persona_events::persona_content_hash( + &crate::managed_agents::persona_events::persona_event_content(persona), + ); + let out_of_date = record + .persona_source_version + .as_deref() + .is_some_and(|pinned| pinned != current); + (out_of_date, false) +} + pub fn build_managed_agent_summary( app: &AppHandle, record: &ManagedAgentRecord, @@ -1339,16 +1368,11 @@ pub fn build_managed_agent_summary( } }; - // Resolve the effective model and system_prompt from the linked persona - // (mirrors spawn-time logic) so the UI displays the current persona values, - // not the stale record snapshot. - let (effective_prompt, effective_model, _effective_provider) = - resolve_effective_prompt_model_provider( - record.persona_id.as_deref(), - personas, - record.system_prompt.clone(), - record.model.clone(), - ); + // Display contract: show the pinned record snapshot — what the agent + // actually runs — not the live persona. The drift flags below signal when + // the snapshot has fallen behind an edited persona; showing live values + // next to an "out of date" badge would contradict it. + let (persona_out_of_date, persona_orphaned) = persona_drift_state(record, personas); Ok(ManagedAgentSummary { pubkey: record.pubkey.clone(), @@ -1363,8 +1387,11 @@ pub fn build_managed_agent_summary( idle_timeout_seconds: record.idle_timeout_seconds, max_turn_duration_seconds: record.max_turn_duration_seconds, parallelism: record.parallelism, - system_prompt: effective_prompt, - model: effective_model, + system_prompt: record.system_prompt.clone(), + model: record.model.clone(), + provider: record.provider.clone(), + persona_out_of_date, + persona_orphaned, mcp_toolsets: record.mcp_toolsets.clone(), env_vars: record.env_vars.clone(), backend: record.backend.clone(), @@ -1451,10 +1478,12 @@ pub(crate) fn build_respond_to_env( Ok((set, remove)) } -/// Resolve the effective system prompt, model, and provider for a spawn. The -/// linked persona always wins so persona edits propagate on the next spawn; the -/// record snapshot is the fallback only when no persona is linked or it was -/// deleted. Provider comes from the persona (the record has no provider field). +/// Resolve the effective system prompt, model, and provider from the *live* +/// persona for **display and model-discovery only** — the ModelPicker shows the +/// current persona model as selected. The spawn and deploy paths deliberately +/// do NOT use this; they read the pinned record snapshot so a running agent +/// stays on the config it was created with. The linked persona wins here; the +/// record values are the fallback when no persona is linked or it was deleted. pub(crate) fn resolve_effective_prompt_model_provider( persona_id: Option<&str>, personas: &[crate::managed_agents::types::PersonaRecord], @@ -1606,18 +1635,16 @@ pub fn spawn_agent_child( command.env("BUZZ_ACP_PERSONA_NAME", persona_name); } - // Resolve system prompt, model, and provider: the linked persona is the - // source of truth, so persona edits reach the agent on the next spawn. Fall - // back to the record snapshot only when no persona is linked or it was - // deleted. Provider flows from the persona (the record has no provider). - let personas = super::load_personas(app).unwrap_or_default(); - let (effective_prompt, effective_model, effective_provider) = - resolve_effective_prompt_model_provider( - record.persona_id.as_deref(), - &personas, - record.system_prompt.clone(), - record.model.clone(), - ); + // System prompt, model, and provider come from the record snapshot — the + // record is the authoritative spawn source. For persona-created agents the + // snapshot was pinned at create (see `create_managed_agent`); for others + // these are the user-supplied values. Reading the record (never the live + // persona) is what keeps a running agent pinned across restarts: a persona + // edit reaches the agent only via delete+respawn, which rewrites the + // snapshot. + let effective_prompt = record.system_prompt.clone(); + let effective_model = record.model.clone(); + let effective_provider = record.provider.clone(); if let Some(prompt) = &effective_prompt { command.env("BUZZ_ACP_SYSTEM_PROMPT", prompt); @@ -1710,19 +1737,24 @@ pub fn spawn_agent_child( command.env(key, value); } - // ── User env vars: persona first, then per-agent (last wins) ──────── + // ── User env vars: the record snapshot ───────────────────────────── // - // Precedence: desktop parent env < persona env_vars < agent env_vars. - // These writes go LAST so user-provided values win over every Buzz-set - // env above — EXCEPT reserved keys (BUZZ_PRIVATE_KEY, NOSTR_PRIVATE_KEY, - // BUZZ_AUTH_TAG, BUZZ_API_TOKEN, BUZZ_ACP_PRIVATE_KEY, - // BUZZ_ACP_API_TOKEN), which `merged_user_env` strips. Those carry - // Buzz's identity and must never be GUI-overridable. - // Fail closed on persona-lookup errors: persona env_vars carry API - // credentials, so silently substituting an empty map would spawn an - // unauthenticated agent and surface as a confusing downstream auth error. - let persona_env = super::env_vars::resolve_persona_env(app, record.persona_id.as_deref())?; - for (key, value) in super::env_vars::merged_user_env(&persona_env, &record.env_vars) { + // The record's `env_vars` is the complete, pinned env map — persona env + // (snapshotted at create) already merged under the agent's own overrides. + // We read it directly and never look up the live persona, so credential + // edits on the persona reach the agent only via delete+respawn (which + // rewrites the snapshot), not on a plain restart. `merged_user_env` with an + // empty persona map still applies the reserved-key / malformed-key / NUL + // filtering as defense-in-depth for older on-disk records. + // + // These writes go LAST so user-provided values win over every Buzz-set env + // above — EXCEPT reserved keys (BUZZ_PRIVATE_KEY, NOSTR_PRIVATE_KEY, + // BUZZ_AUTH_TAG, BUZZ_API_TOKEN, BUZZ_ACP_PRIVATE_KEY, BUZZ_ACP_API_TOKEN), + // which `merged_user_env` strips. Those carry Buzz's identity and must + // never be GUI-overridable. + for (key, value) in + super::env_vars::merged_user_env(&std::collections::BTreeMap::new(), &record.env_vars) + { command.env(key, value); } diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index f8a922da5..c1a2bfe01 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -140,6 +140,8 @@ fn fixture( parallelism: 1, system_prompt: None, model: None, + provider: None, + persona_source_version: None, mcp_toolsets: None, env_vars: std::collections::BTreeMap::new(), start_on_app_launch: false, @@ -341,7 +343,157 @@ fn persona_with_no_model_clears_stale_record_model() { assert_eq!(model, None); } -// ── runtime_metadata_env_vars tests ───────────────────────────────────── +// ── persona pin/refresh acceptance (Phase 4) ──────────────────────────── +// +// The full lifecycle Will specified: create from P0, edit P0→P1 (env_vars +// included), restart stays pinned to P0, delete+respawn refreshes to P1. We +// exercise it at the pure seams that `create_managed_agent` and +// `build_managed_agent_summary` are built from: `persona_snapshot` (what create +// writes onto the record) and `persona_drift_state` (the Agents-menu badge). +// The env_var assertions are load-bearing — the credential pin is the field +// that would silently leak on restart if spawn re-read the live persona. + +use crate::managed_agents::persona_events::persona_snapshot; +use std::collections::BTreeMap; + +/// Apply a persona snapshot onto a record, mirroring `create_managed_agent`: +/// snapshotted prompt/model/provider/env_vars/source_version are pinned, with +/// the system_prompt unwrapped (the persona always carries one). +fn pin_persona(record: &mut ManagedAgentRecord, persona: &crate::managed_agents::PersonaRecord) { + let snapshot = persona_snapshot(persona, &record.env_vars); + record.persona_id = Some(persona.id.clone()); + record.system_prompt = snapshot.system_prompt; + record.model = snapshot.model; + record.provider = snapshot.provider; + record.env_vars = snapshot.env_vars; + record.persona_source_version = Some(snapshot.source_version); +} + +fn persona_v(id: &str, prompt: &str, env: &[(&str, &str)]) -> crate::managed_agents::PersonaRecord { + let mut p = persona_with_provider(id, prompt, Some("model-v"), Some("anthropic")); + p.env_vars = env + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + p +} + +#[test] +fn create_pins_full_persona_snapshot_including_env_vars() { + let p0 = persona_v("p", "prompt-v0", &[("ANTHROPIC_API_KEY", "key-v0")]); + let mut record = fixture(RespondTo::Anyone, vec![], Some("tag".into())); + pin_persona(&mut record, &p0); + + assert_eq!(record.system_prompt.as_deref(), Some("prompt-v0")); + assert_eq!(record.provider.as_deref(), Some("anthropic")); + assert_eq!( + record.env_vars.get("ANTHROPIC_API_KEY").map(String::as_str), + Some("key-v0"), + "create must pin persona env_vars — the credential pin" + ); + assert!(record.persona_source_version.is_some()); +} + +#[test] +fn restart_after_persona_edit_stays_pinned_to_old_snapshot() { + // Create from P0. + let p0 = persona_v("p", "prompt-v0", &[("ANTHROPIC_API_KEY", "key-v0")]); + let mut record = fixture(RespondTo::Anyone, vec![], Some("tag".into())); + pin_persona(&mut record, &p0); + + // Edit the persona to P1 (prompt + credential change). Restart reuses the + // SAME record — nothing rewrites the snapshot — so spawn reads P0 fields. + let p1 = persona_v("p", "prompt-v1", &[("ANTHROPIC_API_KEY", "key-v1")]); + + assert_eq!( + record.system_prompt.as_deref(), + Some("prompt-v0"), + "restart must keep the pinned prompt" + ); + assert_eq!( + record.env_vars.get("ANTHROPIC_API_KEY").map(String::as_str), + Some("key-v0"), + "restart must NOT pick up the edited credential — the CRITICAL" + ); + + // The badge flips: the record's snapshot now lags the edited persona. + let (out_of_date, orphaned) = super::persona_drift_state(&record, std::slice::from_ref(&p1)); + assert!( + out_of_date, + "edited persona must mark the instance out of date" + ); + assert!(!orphaned); +} + +#[test] +fn respawn_after_persona_edit_refreshes_to_new_snapshot() { + let p0 = persona_v("p", "prompt-v0", &[("ANTHROPIC_API_KEY", "key-v0")]); + let p1 = persona_v("p", "prompt-v1", &[("ANTHROPIC_API_KEY", "key-v1")]); + + // Respawn = delete the old record + create a fresh one. create re-snapshots + // the now-current persona (P1). + let mut respawned = fixture(RespondTo::Anyone, vec![], Some("tag".into())); + pin_persona(&mut respawned, &p1); + + assert_eq!(respawned.system_prompt.as_deref(), Some("prompt-v1")); + assert_eq!( + respawned + .env_vars + .get("ANTHROPIC_API_KEY") + .map(String::as_str), + Some("key-v1"), + "respawn must refresh the credential to the edited persona" + ); + + // Now pinned to current persona → not out of date. + let (out_of_date, orphaned) = super::persona_drift_state(&respawned, std::slice::from_ref(&p1)); + assert!(!out_of_date, "respawn pins to current persona — no drift"); + assert!(!orphaned); + + // Sanity: P0 differs from P1, so a record still pinned to P0 would drift. + let mut stale = fixture(RespondTo::Anyone, vec![], Some("tag".into())); + pin_persona(&mut stale, &p0); + assert!(super::persona_drift_state(&stale, std::slice::from_ref(&p1)).0); +} + +#[test] +fn agent_env_overrides_win_over_persona_env_in_snapshot() { + // Agent-level env_vars (input.env_vars) layer over persona env on collision, + // matching spawn precedence (persona env < agent env). + let persona = persona_v("p", "prompt", &[("ANTHROPIC_API_KEY", "persona-key")]); + let mut record = fixture(RespondTo::Anyone, vec![], Some("tag".into())); + record.env_vars = BTreeMap::from([("ANTHROPIC_API_KEY".to_string(), "agent-key".to_string())]); + pin_persona(&mut record, &persona); + + assert_eq!( + record.env_vars.get("ANTHROPIC_API_KEY").map(String::as_str), + Some("agent-key"), + "agent override must win over persona env" + ); +} + +#[test] +fn deleted_persona_is_orphaned_not_out_of_date() { + let p0 = persona_v("p", "prompt-v0", &[("KEY", "v0")]); + let mut record = fixture(RespondTo::Anyone, vec![], Some("tag".into())); + pin_persona(&mut record, &p0); + + // Persona no longer in the catalog → orphaned, never out of date (no + // current persona to respawn into). + let (out_of_date, orphaned) = super::persona_drift_state(&record, &[]); + assert!(!out_of_date); + assert!(orphaned); +} + +#[test] +fn non_persona_agent_never_drifts() { + // A hand-built agent (no persona_id) has nothing to drift from. + let record = fixture(RespondTo::Anyone, vec![], Some("tag".into())); + assert_eq!(record.persona_id, None); + let (out_of_date, orphaned) = super::persona_drift_state(&record, &[]); + assert!(!out_of_date); + assert!(!orphaned); +} use super::runtime_metadata_env_vars; diff --git a/desktop/src-tauri/src/managed_agents/team_repair.rs b/desktop/src-tauri/src/managed_agents/team_repair.rs index d9e272066..d34023b07 100644 --- a/desktop/src-tauri/src/managed_agents/team_repair.rs +++ b/desktop/src-tauri/src/managed_agents/team_repair.rs @@ -289,6 +289,8 @@ mod tests { parallelism: 1, system_prompt: None, model: None, + provider: None, + persona_source_version: None, mcp_toolsets: None, start_on_app_launch: false, runtime_pid: None, diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 51761a0f9..3f0bf4afe 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -123,6 +123,20 @@ pub struct ManagedAgentRecord { /// creation by matching this ID against the fresh session/new response. #[serde(default)] pub model: Option, + /// LLM inference provider snapshotted from the persona at create time + /// (e.g. 'databricks', 'anthropic'). Spawn and deploy read this, never the + /// live persona — so the agent stays pinned to the provider it was created + /// with across restarts. `#[serde(default)]` so pre-existing records + /// deserialize as `None` and get backfilled on first load. + #[serde(default)] + pub provider: Option, + /// Content hash of the persona at the time this agent was created — the + /// `persona_content_hash` of the snapshot in `system_prompt` / `model` / + /// `provider` / `env_vars`. The Agents menu compares it against the linked + /// persona's current hash to flag a stale (out-of-date) instance. `None` + /// for non-persona agents and for pre-existing records pending backfill. + #[serde(default)] + pub persona_source_version: Option, /// Comma-separated toolset string forwarded as BUZZ_TOOLSETS to the MCP subprocess. /// When None, the MCP server uses its own default ("default" toolset). #[serde(default)] @@ -231,6 +245,19 @@ pub struct ManagedAgentSummary { pub parallelism: u32, pub system_prompt: Option, pub model: Option, + /// LLM inference provider, from the agent's pinned record snapshot. + pub provider: Option, + /// `true` when the linked persona has been edited since this agent was + /// created — the running agent uses the older pinned snapshot. The UI + /// flags it and tells the user to delete + respawn to pick up the edit. + /// Always `false` for non-persona agents and for orphaned agents (their + /// persona is gone, so there is nothing newer to drift toward). + pub persona_out_of_date: bool, + /// `true` when the agent was created from a persona that no longer exists. + /// Distinct from out-of-date: there is no current persona to respawn into, + /// so the UI should not prompt a respawn — the pinned snapshot is all the + /// config that remains. + pub persona_orphaned: bool, pub mcp_toolsets: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub env_vars: BTreeMap, diff --git a/desktop/src/features/agents/ui/ManagedAgentRow.tsx b/desktop/src/features/agents/ui/ManagedAgentRow.tsx index 556f83a72..931ec9458 100644 --- a/desktop/src/features/agents/ui/ManagedAgentRow.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentRow.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { + AlertTriangle, ChevronDown, ChevronRight, Clipboard, @@ -256,6 +257,12 @@ function AgentSummary({ {personaLabel} ) : null} + {agent.personaOutOfDate ? ( + + + Out of date + + ) : null}
{truncatePubkey(agent.pubkey)} @@ -267,6 +274,12 @@ function AgentSummary({ Remote deployment )}
+ {agent.personaOutOfDate ? ( +

+ Persona updated since this agent was created. Delete and respawn + to apply the new configuration. +

+ ) : null} {channelNames.length > 0 ? (
{channelNames.map((channel) => ( diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 11e25da4d..c3a5618fd 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -214,6 +214,9 @@ export type RawManagedAgent = { parallelism: number; system_prompt: string | null; model: string | null; + provider: string | null; + persona_out_of_date: boolean; + persona_orphaned: boolean; mcp_toolsets: string | null; env_vars?: Record; status: ManagedAgent["status"]; @@ -869,6 +872,10 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent { parallelism: agent.parallelism, systemPrompt: agent.system_prompt, model: agent.model, + // Fallbacks for pre-feature mocks/fixtures. Real records always carry them. + provider: agent.provider ?? null, + personaOutOfDate: agent.persona_out_of_date ?? false, + personaOrphaned: agent.persona_orphaned ?? false, mcpToolsets: agent.mcp_toolsets, envVars: agent.env_vars ?? {}, status: agent.status, diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 653a1be99..49efc9b90 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -283,6 +283,21 @@ export type ManagedAgent = { parallelism: number; systemPrompt: string | null; model: string | null; + /** LLM inference provider, from the agent's pinned record snapshot. */ + provider: string | null; + /** + * `true` when the linked persona has been edited since this agent was + * created — the running agent uses the older pinned snapshot. Surface a + * "out of date" marker and prompt the user to delete + respawn to update. + * Always `false` for non-persona agents and for orphaned agents. + */ + personaOutOfDate: boolean; + /** + * `true` when the agent's linked persona no longer exists. Distinct from + * out-of-date: there is no current persona to respawn into, so do not prompt + * a respawn — the pinned snapshot is all the config that remains. + */ + personaOrphaned: boolean; mcpToolsets: string | null; /** Per-agent env vars. Layered on top of persona envVars. */ envVars: Record;