Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 19 additions & 19 deletions desktop/src-tauri/src/commands/agent_models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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,
Expand All @@ -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;
Expand Down
121 changes: 121 additions & 0 deletions desktop/src-tauri/src/commands/agent_references.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<ManagedAgentReference>, String> {
tokio::task::spawn_blocking(move || {
let state = app.state::<AppState>();
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<Item = (String, String)>,
) -> Result<Vec<ManagedAgentReference>, 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::<Result<Vec<_>, 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(),
}]
);
}
}
2 changes: 2 additions & 0 deletions desktop/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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::*;
Expand Down
68 changes: 63 additions & 5 deletions desktop/src-tauri/src/commands/personas/inbound.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<String> {
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
Expand All @@ -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,
};
Expand Down Expand Up @@ -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)?;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading