mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
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:
@@ -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],
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user