feat(desktop): pin persona config to agent record at create (#945)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-06-17 21:36:46 -04:00
committed by GitHub
parent 7c982079b0
commit a47bb15254
14 changed files with 545 additions and 145 deletions

View File

@@ -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],

View File

@@ -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<serde_json::Value, String> {
// 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);
/// 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,
);
// 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!({
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),
}
};

View File

@@ -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.

View File

@@ -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;

View File

@@ -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,

View File

@@ -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<Vec<nostr::Event>,
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<String>,
pub model: Option<String>,
pub provider: Option<String>,
/// 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<String, String>,
/// `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<String, String>,
) -> 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)
);
}
}

View File

@@ -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<ManagedAgentProcess, String>;
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::<AppState>();
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:

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -123,6 +123,20 @@ pub struct ManagedAgentRecord {
/// creation by matching this ID against the fresh session/new response.
#[serde(default)]
pub model: Option<String>,
/// 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<String>,
/// 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<String>,
/// 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<String>,
pub model: Option<String>,
/// LLM inference provider, from the agent's pinned record snapshot.
pub provider: Option<String>,
/// `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<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env_vars: BTreeMap<String, String>,

View File

@@ -1,6 +1,7 @@
import * as React from "react";
import {
AlertTriangle,
ChevronDown,
ChevronRight,
Clipboard,
@@ -256,6 +257,12 @@ function AgentSummary({
<Badge variant="secondary">{personaLabel}</Badge>
) : null}
<AgentOriginBadge agent={agent} />
{agent.personaOutOfDate ? (
<Badge className="gap-1" variant="warning">
<AlertTriangle className="h-3 w-3" />
Out of date
</Badge>
) : null}
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span className="font-mono">{truncatePubkey(agent.pubkey)}</span>
@@ -267,6 +274,12 @@ function AgentSummary({
<span>Remote deployment</span>
)}
</div>
{agent.personaOutOfDate ? (
<p className="mt-1.5 text-xs text-amber-600 dark:text-amber-400">
Persona updated since this agent was created. Delete and respawn
to apply the new configuration.
</p>
) : null}
{channelNames.length > 0 ? (
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
{channelNames.map((channel) => (

View File

@@ -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<string, string>;
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,

View File

@@ -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<string, string>;