From 0d512b11d0207e78e8360bc82f90f4f71e567d96 Mon Sep 17 00:00:00 2001 From: Larry Velez Date: Tue, 28 Jul 2026 20:55:12 -0400 Subject: [PATCH] fix(desktop): preserve cross-install agent identities Signed-off-by: Larry Velez --- .../src-tauri/src/commands/agent_models.rs | 38 +++--- .../src/commands/agent_references.rs | 121 ++++++++++++++++++ desktop/src-tauri/src/commands/mod.rs | 2 + .../src/commands/personas/inbound.rs | 68 +++++++++- .../personas/inbound/inbound_tests.rs | 33 +++++ .../src-tauri/src/commands/personas/mod.rs | 33 +++-- desktop/src-tauri/src/lib.rs | 1 + .../src/managed_agents/persona_events.rs | 61 +++++++++ .../managed_agents/persona_events/tests.rs | 2 + .../tests/identity_repair_tests.rs | 63 +++++++++ .../src-tauri/src/managed_agents/personas.rs | 11 ++ .../src/managed_agents/personas/tests.rs | 22 +++- .../src-tauri/src/managed_agents/retention.rs | 38 ++---- .../src/managed_agents/retention/queries.rs | 95 ++++++++++++++ .../src/managed_agents/types/requests.rs | 22 ++++ desktop/src/features/agents/hooks.ts | 12 ++ .../lib/managedAgentPersonaLinks.test.mjs | 43 +++++++ .../agents/lib/managedAgentPersonaLinks.ts | 39 ++++++ .../agents/lib/useAgentsDataRefresh.ts | 4 + .../agents/ui/AgentInstanceEditDialog.tsx | 14 +- .../agents/ui/OrphanedAgentRelinkField.tsx | 81 ++++++++++++ .../agents/ui/useManagedAgentActions.ts | 21 ++- .../src/features/messages/lib/useMentions.ts | 29 ++--- desktop/src/shared/api/agentReferences.ts | 28 ++++ desktop/src/shared/api/types.ts | 2 +- desktop/src/testing/e2eBridge.ts | 12 ++ 26 files changed, 808 insertions(+), 87 deletions(-) create mode 100644 desktop/src-tauri/src/commands/agent_references.rs create mode 100644 desktop/src-tauri/src/managed_agents/persona_events/tests/identity_repair_tests.rs create mode 100644 desktop/src-tauri/src/managed_agents/retention/queries.rs create mode 100644 desktop/src/features/agents/lib/managedAgentPersonaLinks.test.mjs create mode 100644 desktop/src/features/agents/lib/managedAgentPersonaLinks.ts create mode 100644 desktop/src/features/agents/ui/OrphanedAgentRelinkField.tsx create mode 100644 desktop/src/shared/api/agentReferences.ts diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index ca1fe9bdf6..e6074f1573 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -880,9 +880,24 @@ pub async fn update_managed_agent( state.clear_agent_session_caches(pubkey); } + let persona_link_update = input.persona_id; + let link_personas = if persona_link_update.is_some() { + Some(load_personas(&app)?) + } else { + None + }; let record = find_managed_agent_mut(&mut records, &input.pubkey)?; let previous_record = record.clone(); + if let Some(persona_update) = persona_link_update { + crate::managed_agents::persona_events::apply_persona_link_update( + record, + link_personas + .as_deref() + .expect("persona update loads definitions"), + persona_update, + )?; + } let mut name_changed = false; if let Some(name_update) = input.name { let trimmed = name_update.trim().to_string(); @@ -900,29 +915,16 @@ pub async fn update_managed_agent( if let Some(parallelism) = input.parallelism { record.parallelism = parallelism; } - // turn_timeout_seconds is intentionally not applied here — - // BUZZ_ACP_TURN_TIMEOUT is deprecated and ignored by the harness. - // Use idle_timeout_seconds or max_turn_duration_seconds instead. - // Store the relay override exactly as supplied (trimmed). An explicit - // value pins the agent; empty falls back to the workspace relay at - // read-time. A name-only edit (relay_url == None) leaves the pin intact. + // BUZZ_ACP_TURN_TIMEOUT is deprecated; use idle/max duration instead. if let Some(relay_url) = input.relay_url { record.relay_url = relay_url.trim().to_string(); } if let Some(acp_command) = input.acp_command { record.acp_command = acp_command; } - // Harness edit: the persona's runtime is authoritative, so an explicit - // `agent_command_override` is persisted ONLY when the user picks a - // command that diverges from the persona, and the empty/whitespace - // "Inherit from persona" sentinel clears both the pin and the - // materialized record runtime. A name-only edit - // (`agent_command == None`) leaves the pin intact. `harness_override` - // threads the user's explicit intent — see `apply_agent_command_update` - // and `update_time_agent_command_override` for the full resolution - // rules. + // Empty restores persona inheritance; absent preserves the current pin. if let Some(agent_command) = input.agent_command { - let personas = load_personas(&app).unwrap_or_default(); + let personas = link_personas.unwrap_or_else(|| load_personas(&app).unwrap_or_default()); crate::managed_agents::apply_agent_command_update( record, &personas, @@ -933,9 +935,7 @@ pub async fn update_managed_agent( if let Some(agent_args) = input.agent_args { record.agent_args = agent_args; } - // mcp_command is intentionally not applied here — the effective MCP - // command is always catalog-derived (known_acp_runtime at spawn time) - // and the per-record field is never read by the runtime. + // mcp_command is catalog-derived at spawn time, never request-applied. if let Some(env_vars) = input.env_vars { crate::managed_agents::validate_user_env_keys(&env_vars)?; record.env_vars = env_vars; diff --git a/desktop/src-tauri/src/commands/agent_references.rs b/desktop/src-tauri/src/commands/agent_references.rs new file mode 100644 index 0000000000..80738bac41 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_references.rs @@ -0,0 +1,121 @@ +use serde::Serialize; +use tauri::{AppHandle, Manager}; + +use crate::{ + app_state::AppState, + managed_agents::{ + agent_events::ManagedAgentEventContent, + retention::{ + active_retention_scope, get_retained_events, open_retention_db, + retained_tombstone_covers, + }, + }, +}; + +/// Public, non-runnable identity link learned from owner-authored kind:30177. +/// +/// A secondary install deliberately does not receive the agent's secret key, +/// but it still needs this association to avoid minting a duplicate body for +/// an account-scoped definition. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ManagedAgentReference { + pub pubkey: String, + pub name: String, + pub persona_id: String, +} + +#[tauri::command] +pub async fn list_managed_agent_references( + app: AppHandle, +) -> Result, String> { + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let scope = active_retention_scope(&app, &state)?; + let owner_pubkey = scope.owner_keys.public_key().to_hex(); + let conn = open_retention_db(&scope.db_path)?; + let retained = get_retained_events( + &conn, + buzz_core_pkg::kind::KIND_MANAGED_AGENT, + &owner_pubkey, + )?; + let mut live = Vec::new(); + for event in retained { + if !retained_tombstone_covers( + &conn, + buzz_core_pkg::kind::KIND_MANAGED_AGENT, + &owner_pubkey, + &event.d_tag, + event.created_at, + )? { + live.push((event.d_tag, event.content)); + } + } + references_from_retained(live) + }) + .await + .map_err(|error| format!("spawn_blocking failed: {error}"))? +} + +fn references_from_retained( + events: impl IntoIterator, +) -> Result, String> { + let mut references = events + .into_iter() + .filter_map(|(pubkey, content)| { + let content: ManagedAgentEventContent = match serde_json::from_str(&content) { + Ok(content) => content, + Err(error) => return Some(Err(format!("failed to parse managed-agent event: {error}"))), + }; + content.persona_id.map(|persona_id| { + Ok(ManagedAgentReference { + pubkey, + name: content.name, + persona_id, + }) + }) + }) + .collect::, String>>()?; + references.sort_by(|left, right| left.pubkey.cmp(&right.pubkey)); + Ok(references) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn retained_links_form_non_runnable_reference_index() { + let linked = serde_json::json!({ + "name": "Rimac-Buzz", + "persona_id": "rimac-definition", + "parallelism": 1, + "respond_to": "owner-only" + }) + .to_string(); + let standalone = serde_json::json!({ + "name": "Standalone", + "parallelism": 1, + "respond_to": "owner-only" + }) + .to_string(); + + let references = references_from_retained([ + ("497d45dd".to_string(), linked), + ("standalone-pubkey".to_string(), standalone), + ]) + .unwrap(); + + assert_eq!( + references, + vec![ManagedAgentReference { + pubkey: "497d45dd".to_string(), + name: "Rimac-Buzz".to_string(), + persona_id: "rimac-definition".to_string(), + }] + ); + } +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 1c89ee4f77..8248179cae 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -7,6 +7,7 @@ mod agent_model_process; mod agent_models; mod agent_models_env; mod agent_providers; +mod agent_references; mod agent_settings; mod agent_update_rollback; mod agents; @@ -67,6 +68,7 @@ pub use agent_logs::*; pub use agent_metric_archive::*; pub use agent_models::*; pub use agent_providers::*; +pub use agent_references::*; pub use agent_settings::*; pub use agents::*; pub use canvas::*; diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs index d7ffecef2d..26dae4698d 100644 --- a/desktop/src-tauri/src/commands/personas/inbound.rs +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -73,7 +73,10 @@ fn reconcile_inbound_persona_event_blocking( agent_events::managed_agent_content_from_event, load_managed_agents, load_teams, persona_events::persona_from_event, - retention::{open_retention_db, retain_inbound_event, InboundOutcome, RetainedEvent}, + retention::{ + open_retention_db, retain_inbound_event, retained_tombstone_covers, InboundOutcome, + RetainedEvent, + }, save_managed_agents, save_teams, team_events::team_content_from_event, }; @@ -131,6 +134,15 @@ fn reconcile_inbound_persona_event_blocking( return Ok(()); }; let conn = open_retention_db(&scope.db_path)?; + if retained_tombstone_covers( + &conn, + kind, + &event.pubkey.to_hex(), + &d_tag, + event.created_at.as_secs() as i64, + )? { + return Ok(()); + } let outcome = retain_inbound_event( &conn, &RetainedEvent { @@ -224,6 +236,26 @@ fn parse_deletion_coordinate(event: &nostr::Event) -> Option<(u32, String)> { }) } +/// Preserve local identities before an inbound shared-definition tombstone. +/// +/// Returns the pubkeys whose kind:30177 projection must be republished after +/// persistence. +fn detach_persona_agents( + agents: &mut [ManagedAgentRecord], + persona: &AgentDefinition, +) -> Vec { + let mut detached = Vec::new(); + for agent in agents + .iter_mut() + .filter(|agent| agent.persona_id.as_deref() == Some(persona.id.as_str())) + { + crate::managed_agents::persona_events::detach_persona(agent, persona); + agent.updated_at = now_iso(); + detached.push(agent.pubkey.clone()); + } + detached +} + /// Apply an inbound kind:5 NIP-09 deletion: remove the local record at the /// tombstone's target coordinate, scoped per-kind. Mirrors the upsert spine — /// arrival-scoped retention resolution under the store lock, then a per-kind @@ -239,8 +271,8 @@ fn reconcile_inbound_tombstone( use crate::managed_agents::{ load_managed_agents, load_teams, retention::{ - open_retention_db, retain_inbound_event, tombstone_retention_d_tag, InboundOutcome, - RetainedEvent, + delete_retained_event, open_retention_db, retain_inbound_event, + tombstone_retention_d_tag, InboundOutcome, RetainedEvent, }, save_managed_agents, save_teams, }; @@ -286,13 +318,39 @@ fn reconcile_inbound_tombstone( return Ok(()); } - // Remove the local record using the SAME per-kind match rule the apply fns - // use: persona by `persona_d_tag`, team by `id`, managed-agent by `pubkey`. + // Purge the superseded upsert so the non-runnable identity index cannot + // resurrect a deleted reference. + delete_retained_event( + &conn, + target_kind, + &event.pubkey.to_hex(), + &target_d_tag, + )?; + match target_kind { KIND_PERSONA => { let mut personas = load_personas(app)?; + let removed_persona = personas + .iter() + .find(|record| persona_d_tag(record) == target_d_tag) + .cloned(); + let mut agents = load_managed_agents(app)?; + let detached_agents = removed_persona + .as_ref() + .map(|persona| detach_persona_agents(&mut agents, persona)) + .unwrap_or_default(); + if !detached_agents.is_empty() { + save_managed_agents(app, &agents)?; + } + personas.retain(|record| persona_d_tag(record) != target_d_tag); save_personas(app, &personas)?; + + for pubkey in &detached_agents { + if let Some(agent) = agents.iter().find(|agent| agent.pubkey == *pubkey) { + crate::commands::agents::retain_managed_agent_pending(app, state, agent); + } + } } KIND_TEAM => { let mut teams = load_teams(app)?; diff --git a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs index 1005a83432..3e17f30973 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -632,6 +632,39 @@ fn tombstone_removal_predicates_match_apply_fn_keys() { assert!(agents.is_empty(), "agent removed by pubkey"); } +#[test] +fn persona_tombstone_detaches_linked_agents_without_changing_identity() { + let persona = local_in_app(); + let mut linked = local_agent(); + linked.persona_id = Some(persona.id.clone()); + linked.system_prompt = Some("stale instance prompt".to_string()); + let original_pubkey = linked.pubkey.clone(); + let mut unrelated = local_agent(); + unrelated.pubkey = "unrelated-agent-pubkey".to_string(); + unrelated.persona_id = Some("another-persona".to_string()); + let mut agents = vec![linked, unrelated]; + + let detached = detach_persona_agents(&mut agents, &persona); + + assert_eq!(detached, vec![original_pubkey.clone()]); + let preserved = agents + .iter() + .find(|agent| agent.pubkey == original_pubkey) + .unwrap(); + assert_eq!(preserved.persona_id, None); + assert_eq!(preserved.persona_source_version, None); + assert_eq!( + preserved.system_prompt.as_deref(), + Some("local prompt"), + "the disappearing definition is snapshotted before detachment" + ); + assert_eq!( + agents[1].persona_id.as_deref(), + Some("another-persona"), + "unrelated links are untouched" + ); +} + // ── Inbound signature gate ────────────────────────────────────────────────── #[test] diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index 66f7296a25..074a822f02 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -6,7 +6,7 @@ use crate::{ current_instance_id, delete_agent_key, load_managed_agents, load_personas, load_teams, save_managed_agents, save_personas, stop_managed_agent_process, sync_managed_agent_processes, try_regenerate_nest, validate_persona_activation_change, - validate_persona_deletion, AgentDefinition, ManagedAgentRecord, + validate_persona_deletion, AgentDefinition, ManagedAgentRecord, is_relay_synced_persona, }, util::now_iso, }; @@ -135,9 +135,10 @@ pub async fn delete_persona(id: String, app: AppHandle) -> Result<(), String> { .any(|persona_id| persona_id == id.as_str()) }); validate_persona_deletion(persona, referenced_by_team)?; - // Capture the coordinate before the record might leave the list. Only - // reached for non-builtin, non-team personas (both rejected above), - // so every deleted persona here is one this owner published. + // A definition learned from the owner's relay belongs to another + // install. Removing its bodyless entry here is a local cleanup, + // never authority to delete the shared definition everywhere. + let relay_synced = is_relay_synced_persona(persona); let d_tag = crate::managed_agents::persona_events::persona_d_tag(persona); // ── Phase 1: Stage ───────────────────────────────────────────── @@ -224,10 +225,22 @@ pub async fn delete_persona(id: String, app: AppHandle) -> Result<(), String> { })?; } - let original_len = personas.len(); - personas.retain(|record| record.id != id); - if personas.len() == original_len { - return Err(format!("persona {id} not found")); + if relay_synced { + let persona = personas + .iter_mut() + .find(|record| record.id == id) + .ok_or_else(|| format!("persona {id} not found"))?; + // Keep a local inactive marker so a reconnecting relay upsert + // patches this row without making the removed entry visible + // again in My Agents. + persona.is_active = false; + persona.updated_at = now_iso(); + } else { + let original_len = personas.len(); + personas.retain(|record| record.id != id); + if personas.len() == original_len { + return Err(format!("persona {id} not found")); + } } save_personas(&app, &personas)?; @@ -239,7 +252,9 @@ pub async fn delete_persona(id: String, app: AppHandle) -> Result<(), String> { super::agents::tombstone_managed_agent_pending(&app, &state, pk); super::agents::archive_managed_agent_pending(&app, &state, pk); } - tombstone_persona_pending(&app, &state, &d_tag); + if !relay_synced { + tombstone_persona_pending(&app, &state, &d_tag); + } // _store_guard drops here, before try_regenerate_nest. } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 35f4eae866..f305e18717 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -770,6 +770,7 @@ pub fn run() { resolve_oa_owner, list_relay_agents, list_managed_agents, + list_managed_agent_references, list_managed_agent_runtimes, start_managed_agent_runtime, stop_managed_agent_runtime, diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index ea61a811db..e6c1ac673e 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -521,5 +521,66 @@ pub fn preview_prospective_persona_snapshot( } preview } + +/// Link an existing local agent identity to a live definition and refresh its +/// pinned runnable snapshot. Used by the edit surface to repair orphaned +/// `persona_id` references without minting a replacement identity. +pub fn relink_persona(record: &mut ManagedAgentRecord, persona: &AgentDefinition) { + record.persona_id = Some(persona.id.clone()); + apply_persona_snapshot(record, persona); +} + +/// Convert a definition-linked local agent into a standalone agent while the +/// definition is still available. +/// +/// A shared definition tombstone must not leave a live local identity pointing +/// at a missing persona. Materialize the definition's effective config first, +/// including inherited env vars, then clear the relationship and drift marker. +/// The agent keeps its keypair and remains runnable from its own record. +pub fn detach_persona(record: &mut ManagedAgentRecord, persona: &AgentDefinition) { + apply_persona_snapshot(record, persona); + for (key, value) in &persona.env_vars { + record + .env_vars + .entry(key.clone()) + .or_insert_with(|| value.clone()); + } + record.persona_id = None; + record.persona_source_version = None; +} + +/// Apply the tri-state managed-agent definition update. +/// +/// `Some(id)` relinks to an active definition, while `None` detaches. The +/// caller distinguishes this from an absent request field. +pub fn apply_persona_link_update( + record: &mut ManagedAgentRecord, + personas: &[AgentDefinition], + persona_update: Option, +) -> Result<(), String> { + match persona_update { + Some(persona_id) => { + super::ensure_persona_is_active(personas, &persona_id)?; + let persona = personas + .iter() + .find(|persona| persona.id == persona_id) + .ok_or_else(|| format!("persona {persona_id} not found"))?; + relink_persona(record, persona); + } + None => { + if let Some(persona) = record + .persona_id + .as_deref() + .and_then(|id| personas.iter().find(|persona| persona.id == id)) + { + detach_persona(record, persona); + } else { + record.persona_id = None; + record.persona_source_version = None; + } + } + } + Ok(()) +} #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs index b9542f9a87..80d00ef555 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -164,6 +164,8 @@ fn sample_persona() -> AgentDefinition { } } +mod identity_repair_tests; + #[test] fn monotonic_created_at_bumps_past_head() { // No head: uses now (floor 0). diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests/identity_repair_tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests/identity_repair_tests.rs new file mode 100644 index 0000000000..2911b66da6 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests/identity_repair_tests.rs @@ -0,0 +1,63 @@ +use super::*; + +#[test] +fn relink_persona_repairs_identity_without_reminting() { + let persona = sample_persona(); + let mut record = persona.clone().into_agent_record(); + record.pubkey = "existing-agent-pubkey".to_string(); + record.slug = None; + record.persona_id = Some("deleted-persona".to_string()); + record.persona_source_version = None; + + relink_persona(&mut record, &persona); + + assert_eq!(record.pubkey, "existing-agent-pubkey"); + assert_eq!(record.persona_id.as_deref(), Some("test-persona")); + assert_eq!( + record.system_prompt.as_deref(), + Some("You are a test assistant.") + ); + assert!(record.persona_source_version.is_some()); +} + +#[test] +fn detach_persona_materializes_runnable_standalone_snapshot() { + let mut persona = sample_persona(); + persona + .env_vars + .insert("INHERITED".to_string(), "materialized".to_string()); + let mut record = persona.clone().into_agent_record(); + record.pubkey = "existing-agent-pubkey".to_string(); + record.slug = None; + record.persona_id = Some(persona.id.clone()); + record.env_vars.clear(); + record + .env_vars + .insert("KEY".to_string(), "instance-override".to_string()); + record + .env_vars + .insert("INSTANCE_ONLY".to_string(), "yes".to_string()); + + detach_persona(&mut record, &persona); + + assert_eq!(record.pubkey, "existing-agent-pubkey"); + assert_eq!(record.persona_id, None); + assert_eq!(record.persona_source_version, None); + assert_eq!(record.runtime.as_deref(), Some("goose")); + assert_eq!(record.model.as_deref(), Some("claude-opus-4")); + assert_eq!(record.provider.as_deref(), Some("anthropic")); + assert_eq!( + record.env_vars.get("KEY").map(String::as_str), + Some("instance-override"), + "instance override must win over inherited definition env" + ); + assert_eq!( + record.env_vars.get("INSTANCE_ONLY").map(String::as_str), + Some("yes") + ); + assert_eq!( + record.env_vars.get("INHERITED").map(String::as_str), + Some("materialized"), + "inherited env must survive after the definition is removed" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index 9bf7ab74b0..cdd7d4f13c 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -298,6 +298,17 @@ pub fn validate_persona_deletion( Ok(()) } +/// Whether this install learned the definition from the owner's relay rather +/// than authoring it locally. +/// +/// Locally-created definitions keep `source_team_persona_slug = None`; the +/// inbound kind:30175 parser records the relay d-tag there. Team definitions +/// also carry a slug, but have `source_team = Some` and are handled by the +/// existing team deletion guard. +pub fn is_relay_synced_persona(persona: &AgentDefinition) -> bool { + persona.source_team.is_none() && persona.source_team_persona_slug.is_some() +} + pub fn validate_persona_activation_change( persona: &AgentDefinition, active: bool, diff --git a/desktop/src-tauri/src/managed_agents/personas/tests.rs b/desktop/src-tauri/src/managed_agents/personas/tests.rs index 387b4d72c6..0f431e1ac3 100644 --- a/desktop/src-tauri/src/managed_agents/personas/tests.rs +++ b/desktop/src-tauri/src/managed_agents/personas/tests.rs @@ -1,7 +1,8 @@ use super::{ built_in_persona_records, ensure_persona_ids_are_active, ensure_persona_is_active, - merge_personas, migrate_retired_personas, validate_persona_activation_change, - validate_persona_deletion, BUILT_IN_PERSONAS, RETIRED_PERSONAS, + is_relay_synced_persona, merge_personas, migrate_retired_personas, + validate_persona_activation_change, validate_persona_deletion, BUILT_IN_PERSONAS, + RETIRED_PERSONAS, }; use crate::managed_agents::discovery::{default_agent_command, effective_agent_command}; use crate::managed_agents::AgentDefinition; @@ -31,6 +32,23 @@ fn custom_persona(id: &str, display_name: &str) -> AgentDefinition { } } +#[test] +fn relay_synced_persona_is_distinct_from_local_and_team_definitions() { + let local = custom_persona("local", "Local"); + assert!(!is_relay_synced_persona(&local)); + + let mut relay_synced = custom_persona("remote", "Remote"); + relay_synced.source_team_persona_slug = Some("remote".to_string()); + assert!(is_relay_synced_persona(&relay_synced)); + + let mut team = relay_synced; + team.source_team = Some("team-1".to_string()); + assert!( + !is_relay_synced_persona(&team), + "team definitions use their existing guarded deletion path" + ); +} + #[test] fn merge_personas_adds_missing_built_ins() { let (records, changed) = merge_personas(Vec::new(), "2026-03-19T00:00:00Z"); diff --git a/desktop/src-tauri/src/managed_agents/retention.rs b/desktop/src-tauri/src/managed_agents/retention.rs index 7e97fa1f56..a7a9ce5dac 100644 --- a/desktop/src-tauri/src/managed_agents/retention.rs +++ b/desktop/src-tauri/src/managed_agents/retention.rs @@ -16,6 +16,8 @@ use crate::app_state::AppState; mod legacy_migration; pub use legacy_migration::migrate_legacy_retention_db; +mod queries; +pub use queries::{get_retained_events, retained_tombstone_covers}; /// Durable event-retention scope for one community relay and owner identity. /// @@ -310,31 +312,17 @@ pub fn get_retained_personas( conn: &Connection, pubkey: &str, ) -> Result, String> { - let mut stmt = conn - .prepare( - "SELECT kind, pubkey, d_tag, content, created_at, raw_event, pending_sync - FROM persona_events - WHERE pubkey = ?1 - ORDER BY d_tag", - ) - .map_err(|e| format!("failed to prepare query: {e}"))?; - - let rows = stmt - .query_map(params![pubkey], |row| { - Ok(RetainedEvent { - kind: row.get(0)?, - pubkey: row.get(1)?, - d_tag: row.get(2)?, - content: row.get(3)?, - created_at: row.get(4)?, - raw_event: row.get(5)?, - pending_sync: row.get::<_, i32>(6)? != 0, - }) - }) - .map_err(|e| format!("failed to query retained events: {e}"))?; - - rows.collect::, _>>() - .map_err(|e| format!("failed to read retained event row: {e}")) + let mut rows = Vec::new(); + for kind in [ + buzz_core_pkg::kind::KIND_PERSONA, + buzz_core_pkg::kind::KIND_TEAM, + buzz_core_pkg::kind::KIND_MANAGED_AGENT, + 5, + ] { + rows.extend(get_retained_events(conn, kind, pubkey)?); + } + rows.sort_by(|left, right| left.d_tag.cmp(&right.d_tag)); + Ok(rows) } /// Get all events marked as pending sync (not yet confirmed on relay). diff --git a/desktop/src-tauri/src/managed_agents/retention/queries.rs b/desktop/src-tauri/src/managed_agents/retention/queries.rs new file mode 100644 index 0000000000..8a08ad64d1 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/retention/queries.rs @@ -0,0 +1,95 @@ +use rusqlite::{params, Connection}; + +use super::{get_retained_event, tombstone_retention_d_tag, RetainedEvent}; + +/// Load retained events for one owner and kind. +pub fn get_retained_events( + conn: &Connection, + kind: u32, + pubkey: &str, +) -> Result, String> { + let mut stmt = conn + .prepare( + "SELECT kind, pubkey, d_tag, content, created_at, raw_event, pending_sync + FROM persona_events + WHERE kind = ?1 AND pubkey = ?2 + ORDER BY d_tag", + ) + .map_err(|e| format!("failed to prepare query: {e}"))?; + + let rows = stmt + .query_map(params![kind, pubkey], |row| { + Ok(RetainedEvent { + kind: row.get(0)?, + pubkey: row.get(1)?, + d_tag: row.get(2)?, + content: row.get(3)?, + created_at: row.get(4)?, + raw_event: row.get(5)?, + pending_sync: row.get::<_, i32>(6)? != 0, + }) + }) + .map_err(|e| format!("failed to query retained events: {e}"))?; + + rows.collect::, _>>() + .map_err(|e| format!("failed to read retained event row: {e}")) +} + +/// Whether a retained NIP-09 tombstone suppresses an upsert at the target +/// coordinate. A strictly newer upsert may recreate the coordinate. +pub fn retained_tombstone_covers( + conn: &Connection, + target_kind: u32, + pubkey: &str, + d_tag: &str, + created_at: i64, +) -> Result { + Ok(get_retained_event( + conn, + 5, + pubkey, + &tombstone_retention_d_tag(target_kind, d_tag), + )? + .is_some_and(|tombstone| tombstone.created_at >= created_at)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::managed_agents::retention::{open_retention_db, retain_event}; + + #[test] + fn tombstone_suppression_is_delivery_order_independent() { + let dir = tempfile::tempdir().unwrap(); + let conn = open_retention_db(&dir.path().join("retention.db")).unwrap(); + retain_event( + &conn, + &RetainedEvent { + kind: 5, + pubkey: "owner".to_string(), + d_tag: tombstone_retention_d_tag(30177, "agent-pubkey"), + content: String::new(), + created_at: 2000, + raw_event: "{}".to_string(), + pending_sync: false, + }, + ) + .unwrap(); + + for created_at in [1999, 2000] { + assert!(retained_tombstone_covers( + &conn, + 30177, + "owner", + "agent-pubkey", + created_at + ) + .unwrap()); + } + assert!( + !retained_tombstone_covers(&conn, 30177, "owner", "agent-pubkey", 2001) + .unwrap(), + "a newer upsert may legitimately recreate the coordinate" + ); + } +} diff --git a/desktop/src-tauri/src/managed_agents/types/requests.rs b/desktop/src-tauri/src/managed_agents/types/requests.rs index e28b0bd461..729b289af5 100644 --- a/desktop/src-tauri/src/managed_agents/types/requests.rs +++ b/desktop/src-tauri/src/managed_agents/types/requests.rs @@ -200,6 +200,10 @@ pub struct CreateManagedAgentRequest { #[serde(rename_all = "camelCase")] pub struct UpdateManagedAgentRequest { pub pubkey: String, + /// Absent = don't touch. null = detach into a standalone agent. + /// "id" = link (or relink) to that active definition. + #[serde(default, deserialize_with = "crate::util::double_option")] + pub persona_id: Option>, /// Absent = don't touch. Present = rename the agent. #[serde(default)] pub name: Option, @@ -292,6 +296,24 @@ mod tests { } } + #[test] + fn managed_agent_persona_update_has_tri_state_semantics() { + let absent: UpdateManagedAgentRequest = + serde_json::from_str(r#"{"pubkey":"pk"}"#).unwrap(); + assert_eq!(absent.persona_id, None); + + let detach: UpdateManagedAgentRequest = + serde_json::from_str(r#"{"pubkey":"pk","personaId":null}"#).unwrap(); + assert_eq!(detach.persona_id, Some(None)); + + let relink: UpdateManagedAgentRequest = + serde_json::from_str(r#"{"pubkey":"pk","personaId":"persona-2"}"#).unwrap(); + assert_eq!( + relink.persona_id, + Some(Some("persona-2".to_string())) + ); + } + /// The anchor regression row: an absent behavior group must leave a /// stored behavior group untouched — legacy update_persona callers (team import, /// profile panel) send no behavior field and must not wipe it. diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 122c872e54..b1c480e812 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -38,6 +38,7 @@ import { saveCustomHarness, updateManagedAgent, } from "@/shared/api/tauri"; +import { listManagedAgentReferences } from "@/shared/api/agentReferences"; import type { HarnessDefinitionInput } from "@/shared/api/tauri"; import { setManagedAgentAutoRestart, @@ -106,6 +107,9 @@ export type { export const relayAgentsQueryKey = ["relay-agents"] as const; export const managedAgentsQueryKey = ["managed-agents"] as const; +export const managedAgentReferencesQueryKey = [ + "managed-agent-references", +] as const; export const personasQueryKey = ["personas"] as const; export const acpRuntimesQueryKey = ["acp-runtimes"] as const; export const acpAuthMethodsQueryKey = ["acp-auth-methods"] as const; @@ -359,6 +363,14 @@ export function useManagedAgentsQuery(options?: { enabled?: boolean }) { }); } +export function useManagedAgentReferencesQuery() { + return useQuery({ + queryKey: managedAgentReferencesQueryKey, + queryFn: listManagedAgentReferences, + staleTime: 5_000, + }); +} + export function useCreateManagedAgentMutation() { const queryClient = useQueryClient(); diff --git a/desktop/src/features/agents/lib/managedAgentPersonaLinks.test.mjs b/desktop/src/features/agents/lib/managedAgentPersonaLinks.test.mjs new file mode 100644 index 0000000000..78d4ae354d --- /dev/null +++ b/desktop/src/features/agents/lib/managedAgentPersonaLinks.test.mjs @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildManagedAgentPersonaLinks, + findRemotePersonaAgent, +} from "./managedAgentPersonaLinks.ts"; + +test("remote kind:30177 links participate in persona identity dedup", () => { + const remote = { + pubkey: "A".repeat(64), + name: "Rimac-Buzz", + personaId: "rimac-definition", + }; + const links = buildManagedAgentPersonaLinks([], [remote]); + + assert.equal( + links.byPubkey.get(remote.pubkey.toLowerCase()), + "rimac-definition", + ); + assert.deepEqual(links.personaIds, new Set(["rimac-definition"])); +}); + +test("remote persona body blocks a second-install mint but local body does not", () => { + const reference = { + pubkey: "a".repeat(64), + name: "Rimac-Buzz", + personaId: "rimac-definition", + }; + + assert.equal( + findRemotePersonaAgent("rimac-definition", new Set(), [reference]), + reference, + ); + assert.equal( + findRemotePersonaAgent( + "rimac-definition", + new Set([reference.pubkey.toUpperCase()]), + [reference], + ), + undefined, + ); +}); diff --git a/desktop/src/features/agents/lib/managedAgentPersonaLinks.ts b/desktop/src/features/agents/lib/managedAgentPersonaLinks.ts new file mode 100644 index 0000000000..074dd44883 --- /dev/null +++ b/desktop/src/features/agents/lib/managedAgentPersonaLinks.ts @@ -0,0 +1,39 @@ +import type { ManagedAgentReference } from "@/shared/api/agentReferences"; +import type { ManagedAgent } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +export function buildManagedAgentPersonaLinks( + agents: readonly Pick[], + references: readonly ManagedAgentReference[], +) { + const byPubkey = new Map( + references.map((reference) => [ + normalizePubkey(reference.pubkey), + reference.personaId, + ]), + ); + const personaIds = new Set( + references.map((reference) => reference.personaId), + ); + for (const agent of agents) { + if (!agent.personaId) continue; + byPubkey.set(normalizePubkey(agent.pubkey), agent.personaId); + personaIds.add(agent.personaId); + } + return { byPubkey, personaIds }; +} + +export function findRemotePersonaAgent( + personaId: string, + localPubkeys: ReadonlySet, + references: readonly ManagedAgentReference[], +) { + const normalizedLocalPubkeys = new Set( + [...localPubkeys].map((pubkey) => normalizePubkey(pubkey)), + ); + return references.find( + (reference) => + reference.personaId === personaId && + !normalizedLocalPubkeys.has(normalizePubkey(reference.pubkey)), + ); +} diff --git a/desktop/src/features/agents/lib/useAgentsDataRefresh.ts b/desktop/src/features/agents/lib/useAgentsDataRefresh.ts index 174fb9c92c..091d1adc9d 100644 --- a/desktop/src/features/agents/lib/useAgentsDataRefresh.ts +++ b/desktop/src/features/agents/lib/useAgentsDataRefresh.ts @@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useEffect } from "react"; import { + managedAgentReferencesQueryKey, managedAgentsQueryKey, personasQueryKey, relayAgentsQueryKey, @@ -43,6 +44,9 @@ export function useAgentsDataRefresh(): void { void queryClient.invalidateQueries({ queryKey: personasQueryKey }); void queryClient.invalidateQueries({ queryKey: teamsQueryKey }); void queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }); + void queryClient.invalidateQueries({ + queryKey: managedAgentReferencesQueryKey, + }); void queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey }); }, COALESCE_MS); }); diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index f3c410e2ff..9ff5064dd5 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -89,6 +89,7 @@ import { runtimeDropdownAction, usePendingHarnessSelection, } from "./addCustomHarness"; +import { useAgentRelink } from "./OrphanedAgentRelinkField"; const ADVANCED_FIELDS_MOTION_TRANSITION = { duration: 0.18, @@ -145,13 +146,9 @@ export function AgentInstanceEditDialog({ const [autoRestartOnConfigChange, setAutoRestartOnConfigChange] = React.useState(agent.autoRestartOnConfigChange); const personasQuery = usePersonasQuery(); - const linkedPersona = React.useMemo( - () => - agent.personaId - ? (personasQuery.data?.find((p) => p.id === agent.personaId) ?? null) - : null, - [agent.personaId, personasQuery.data], - ); + const personas = personasQuery.data ?? []; + const orphanRelink = useAgentRelink(agent, open, personas, setInheritHarness); + const linkedPersona = orphanRelink.linkedPersona; const inheritedEnvVars = linkedPersona?.envVars ?? {}; const [respondTo, setRespondTo] = React.useState( agent.respondTo, @@ -656,6 +653,7 @@ export function AgentInstanceEditDialog({ const submitEnvVars = inheritedSubmission.envVars; const input: UpdateManagedAgentInput = { pubkey: agent.pubkey, + personaId: orphanRelink.inputPersonaId, name: name.trim() !== agent.name ? name.trim() : undefined, // relayUrl deliberately never submitted: the legacy per-record pin is // ignored (#2122) and the stored value is preserved as-is. @@ -936,6 +934,8 @@ export function AgentInstanceEditDialog({ + {orphanRelink.renderField(updateMutation.isPending)} + {/* Who can talk to this agent */} void, +) { + const [personaId, setPersonaId] = React.useState(""); + + // biome-ignore lint/correctness/useExhaustiveDependencies: switching agents while the dialog stays open must clear a stale repair target + React.useEffect(() => { + if (open) setPersonaId(""); + }, [agent.pubkey, open]); + + const linkedPersona = React.useMemo(() => { + const effectiveId = personaId || agent.personaId; + return effectiveId + ? (personas.find((persona) => persona.id === effectiveId) ?? null) + : null; + }, [agent.personaId, personaId, personas]); + + const inputPersonaId: UpdateManagedAgentInput["personaId"] = + agent.personaOrphaned && personaId ? personaId : undefined; + + const options = React.useMemo( + () => + personas + .filter((persona) => persona.isActive) + .sort((left, right) => + left.displayName.localeCompare(right.displayName), + ) + .map((persona) => ({ + label: persona.displayName, + value: persona.id, + })), + [personas], + ); + + const renderField = (disabled: boolean) => + agent.personaOrphaned ? ( +
+ +

+ This agent's definition was deleted. Choose an existing + definition to repair the link without changing the agent's + identity. +

+ { + setPersonaId(value); + onRelink(true); + }} + options={options} + placeholder={ + options.length === 0 + ? "No active definitions available" + : "Choose a definition" + } + value={personaId} + /> +
+ ) : null; + + return { inputPersonaId, linkedPersona, renderField }; +} diff --git a/desktop/src/features/agents/ui/useManagedAgentActions.ts b/desktop/src/features/agents/ui/useManagedAgentActions.ts index e1c2e9c9fc..8f0e0b9489 100644 --- a/desktop/src/features/agents/ui/useManagedAgentActions.ts +++ b/desktop/src/features/agents/ui/useManagedAgentActions.ts @@ -5,6 +5,7 @@ import { useAvailableAcpRuntimes, useCreateManagedAgentMutation, useManagedAgentLogQuery, + useManagedAgentReferencesQuery, useManagedAgentsQuery, useRelayAgentsQuery, useSetManagedAgentStartOnAppLaunchMutation, @@ -22,7 +23,7 @@ import type { ManagedAgent, } from "@/shared/api/types"; import { removeChannelMember } from "@/shared/api/tauri"; -import { normalizePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; import { deleteManagedAgentWithRules, isManagedAgentActive, @@ -35,11 +36,13 @@ import { buildInstanceInputForDefinition, resolveStartRuntimeForDefinition, } from "../lib/instanceInputForDefinition"; +import { findRemotePersonaAgent } from "../lib/managedAgentPersonaLinks"; export function useManagedAgentActions() { const { globalConfig } = useGlobalAgentConfig(); const relayAgentsQuery = useRelayAgentsQuery(); const managedAgentsQuery = useManagedAgentsQuery(); + const managedAgentReferencesQuery = useManagedAgentReferencesQuery(); const [shouldLoadChannels, setShouldLoadChannels] = React.useState(false); const channelsQuery = useChannelsQuery({ enabled: shouldLoadChannels }); const startMutation = useStartManagedAgentMutation(); @@ -192,6 +195,22 @@ export function useManagedAgentActions() { setPersonaStartPending(persona.id, true); clearFeedback(); try { + const referenceResult = await managedAgentReferencesQuery.refetch(); + if (referenceResult.isError) { + throw new Error( + `Could not verify whether ${persona.displayName} already has an agent identity. Wait for agent sync and try again.`, + ); + } + const existingReference = findRemotePersonaAgent( + persona.id, + managedPubkeys, + referenceResult.data ?? [], + ); + if (existingReference) { + throw new Error( + `${persona.displayName} already has an agent identity on another install (${truncatePubkey(existingReference.pubkey)}). Start it from the install that owns its key, or delete that instance before creating a replacement.`, + ); + } const runtimes = await availableRuntimesForStart(availableRuntimesQuery); const { runtime, warnings } = resolveStartRuntimeForDefinition( persona, diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 0c73b75339..f2c0ebce9e 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -1,6 +1,7 @@ import * as React from "react"; import { useManagedAgentsQuery, + useManagedAgentReferencesQuery, usePersonasQuery, useRelayAgentsQuery, useTeamsQuery, @@ -34,6 +35,7 @@ import type { import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery"; import { normalizePubkey } from "@/shared/lib/pubkey"; +import { buildManagedAgentPersonaLinks } from "@/features/agents/lib/managedAgentPersonaLinks"; import { trimMapToSize } from "@/shared/lib/trimMapToSize"; import { flushMentionDebounce } from "./flushMentionDebounce"; import { hasMention } from "./hasMention"; @@ -104,6 +106,7 @@ export function useMentions( const members = externalMembers ?? membersQuery.data; const isArchivedDiscovery = useIsArchivedPredicate(); const managedAgentsQuery = useManagedAgentsQuery(); + const managedAgentReferencesQuery = useManagedAgentReferencesQuery(); const relayAgentsQuery = useRelayAgentsQuery(); const channelsQuery = useChannelsQuery(); const personasQuery = usePersonasQuery(); @@ -139,26 +142,16 @@ export function useMentions( ), [managedAgentsQuery.data], ); - const managedAgentPersonaIdsByPubkey = React.useMemo( + const { + byPubkey: managedAgentPersonaIdsByPubkey, + personaIds: managedAgentPersonaIds, + } = React.useMemo( () => - new Map( - (managedAgentsQuery.data ?? []) - .filter((agent) => Boolean(agent.personaId)) - .map((agent) => [ - normalizePubkey(agent.pubkey), - agent.personaId as string, - ]), + buildManagedAgentPersonaLinks( + managedAgentsQuery.data ?? [], + managedAgentReferencesQuery.data ?? [], ), - [managedAgentsQuery.data], - ); - const managedAgentPersonaIds = React.useMemo( - () => - new Set( - (managedAgentsQuery.data ?? []) - .map((agent) => agent.personaId) - .filter((personaId): personaId is string => Boolean(personaId)), - ), - [managedAgentsQuery.data], + [managedAgentReferencesQuery.data, managedAgentsQuery.data], ); const managedAgentPubkeys = React.useMemo( () => diff --git a/desktop/src/shared/api/agentReferences.ts b/desktop/src/shared/api/agentReferences.ts new file mode 100644 index 0000000000..2efe039df0 --- /dev/null +++ b/desktop/src/shared/api/agentReferences.ts @@ -0,0 +1,28 @@ +import { invokeTauri } from "@/shared/api/tauri"; + +/** Account-scoped agent identity learned from kind:30177 without local keys. */ +export type ManagedAgentReference = { + pubkey: string; + name: string; + personaId: string; +}; + +type RawManagedAgentReference = { + pubkey: string; + name: string; + persona_id: string; +}; + +export async function listManagedAgentReferences(): Promise< + ManagedAgentReference[] +> { + return ( + await invokeTauri( + "list_managed_agent_references", + ) + ).map((reference) => ({ + pubkey: reference.pubkey, + name: reference.name, + personaId: reference.persona_id, + })); +} diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 689c400b03..c8c52bc8c8 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -708,9 +708,9 @@ export type RuntimeConfigSurface = { extensions: ExtensionEntry[]; sources: ConfigSourceReport; }; - export type UpdateManagedAgentInput = { pubkey: string; + personaId?: string | null; // absent: unchanged; null: detach; ID: relink name?: string; model?: string | null; provider?: string | null; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 7b13273c60..2667f4521d 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -10766,6 +10766,18 @@ export function maybeInstallE2eTauriMocks() { } case "list_managed_agents": return handleListManagedAgents(activeConfig); + case "list_managed_agent_references": + return mockManagedAgents.flatMap((agent) => + agent.persona_id + ? [ + { + pubkey: agent.pubkey, + name: agent.name, + persona_id: agent.persona_id, + }, + ] + : [], + ); case "get_agent_memory": return handleGetAgentMemory( (payload as Parameters[0]) ?? {},