Skip to content
Open
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
14 changes: 9 additions & 5 deletions desktop/src-tauri/src/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,15 +176,19 @@ fn run_boot_migrations_inner(app: &tauri::AppHandle, reset_completed: bool) {
// Post-fold readers of the runtime map (`load_persona_runtimes`) fall
// back to the unified store's definitions.
fold_personas_into_agent_store(app);
// Canonicalize structured provider/model fields and strip stale derived
// env keys BEFORE matching standalone agents to folded definitions. If
// this ran after backfill, pre-cleanup data could prevent a valid match
// and any provider rewrite would stale the newly written source hash.
reconcile_databricks_v1_to_v2(app);
refresh_builtin_agent_avatars(app);
// B5: manufacture definitions for standalone agents AFTER the fold (so
// pre-existing definition slugs are present for collision checks) and
// before event sync republishes — the backfilled link is what flips the
// 30177 projection to its slim shape.
// B5: link standalone agents to compatible folded definitions, otherwise
// manufacture definitions, AFTER the fold and provider/env cleanup (so
// matching sees canonical records) and before event sync republishes —
// the backfilled link is what flips the 30177 projection to its slim shape.
backfill_standalone_agents(app);
detach_directory_backed_teams(app);
reconcile_provider_mcp_commands(app);
reconcile_databricks_v1_to_v2(app);
materialize_agent_runtimes(app);
}

Expand Down
201 changes: 185 additions & 16 deletions desktop/src-tauri/src/migration/backfill.rs
Original file line number Diff line number Diff line change
@@ -1,32 +1,40 @@
//! B5 (unified agent model): one-time backfill of standalone agents into
//! definition-linked records. Every keyed record with `persona_id: None`
//! gets a key-less definition manufactured from its own settings, retiring
//! the standalone-create pattern (Wes's Option 2: backfill, not grandfather).
//! adopts exactly one matching folded definition when possible. Records with
//! no match get a key-less definition manufactured from their own settings;
//! ambiguous records are skipped rather than creating another duplicate.

use std::path::Path;

use crate::managed_agents::{
persona_events::{persona_content_hash, persona_event_content},
ManagedAgentRecord,
AgentDefinition, ManagedAgentRecord,
};

/// Manufacture definitions for standalone agents (B5 backfill).
/// Link standalone agents to definitions (B5 backfill).
///
/// For each keyed record with `persona_id: None`, append a key-less
/// definition record snapshotting the agent's own config and link the agent
/// to it. Safety rails (pinned in the B5 review gates):
/// For each keyed record with `persona_id: None`, adopt one matching key-less
/// definition left by the persona fold. If there is no match, append a key-less
/// definition snapshotting the agent's own config and link the agent to it. If
/// multiple definitions match, skip the record for manual disambiguation.
/// Safety rails (pinned in the B5 review gates):
/// - **Idempotent**: linked records are skipped, so a second run is a no-op.
/// - **`.bak` create-if-absent**: the pre-migration backup is taken once and
/// never clobbered — a partial first run must not replace the pristine
/// backup with a half-migrated snapshot on re-run.
/// - **Fail loudly per record**: a record that cannot be backfilled (slug
/// collision) is logged and skipped; the rest proceed.
/// - **No behavior change**: the definition snapshots the record's own
/// values (prompt present-even-if-empty via `to_definition_view`'s
/// `unwrap_or_default`, env COPIED so later instances inherit a working
/// config, quad copied to the definition defaults) and the record gains
/// `persona_source_version` = the new definition's content hash, so
/// neither `spawn_config_hash` nor the drift badge moves.
/// - **Unambiguous adoption**: only one active, non-team custom definition
/// whose snapshotted config matches can be adopted; multiple matches are
/// logged and skipped instead of manufacturing a third definition.
/// - **Fail loudly per record**: a record that cannot be backfilled (ambiguous
/// match or slug collision) is logged and skipped; the rest proceed.
/// - **Behavior preservation**: adoption requires compatible persisted
/// harness and spawn config and leaves instance-only fields untouched. A
/// manufactured definition snapshots the record's own values (prompt
/// present-even-if-empty via `to_definition_view`'s `unwrap_or_default`, env
/// COPIED so later instances inherit a working config, quad copied to the
/// definition defaults), keeping `spawn_config_hash` stable. In both paths
/// the record gains `persona_source_version` = the linked definition's
/// content hash so the drift badge starts in sync.
///
/// The manufactured definition's slug is the agent's pubkey: 64-hex passes
/// the NIP-AP slug grammar on both relay and desktop ends, and agent pubkeys
Expand All @@ -39,13 +47,150 @@ pub fn backfill_standalone_agents(app: &tauri::AppHandle) {
Ok(0) => {}
Ok(backfilled) => {
eprintln!(
"buzz-desktop: standalone-backfill: {backfilled} agents linked to manufactured definitions"
"buzz-desktop: standalone-backfill: {backfilled} agents linked to definitions"
);
}
Err(e) => eprintln!("buzz-desktop: standalone-backfill: {e}"),
}
}

fn runtime_matches(definition: &AgentDefinition, record: &ManagedAgentRecord) -> bool {
let definition_runtime = definition
.runtime
.as_deref()
.map(str::trim)
.filter(|runtime| !runtime.is_empty());

if let Some(override_command) = record
.agent_command_override
.as_deref()
.map(str::trim)
.filter(|command| !command.is_empty())
{
// Snapshot application only clears a known override when the
// definition names a different known runtime. A matching known pin,
// a custom command, or a definition without a catalog runtime remains
// authoritative and keeps the effective harness unchanged.
if let (Some(definition_runtime), Some(override_runtime)) = (
definition_runtime.and_then(crate::managed_agents::known_acp_runtime_exact),
crate::managed_agents::known_acp_runtime(override_command),
) {
return definition_runtime.id == override_runtime.id;
}
return true;
}

// Pre-unified standalone records have neither `runtime` nor an override;
// their persisted `agent_command` is the only record of the harness they
// were created to run. The current general resolver intentionally ignores
// that legacy snapshot, but this migration must use it to recognize the
// just-folded definition that originally produced the agent.
let legacy_command = record.agent_command.trim();
let before_command =
if record.runtime.is_none() && record.persona_id.is_none() && !legacy_command.is_empty() {
legacy_command.to_string()
} else {
crate::managed_agents::record_agent_command(record, &[])
};
let default_command = crate::managed_agents::default_agent_command();
let after_command = definition_runtime
.and_then(crate::managed_agents::known_acp_runtime_exact)
.and_then(|runtime| runtime.commands.first().copied())
.unwrap_or(default_command.as_str());
match (
crate::managed_agents::known_acp_runtime(&before_command),
crate::managed_agents::known_acp_runtime(after_command),
) {
(Some(before), Some(after)) => before.id == after.id,
_ => before_command == after_command,
}
}

fn effective_env_matches(definition: &AgentDefinition, record: &ManagedAgentRecord) -> bool {
let without_definition =
crate::managed_agents::merged_user_env(&Default::default(), &record.env_vars);
let with_definition =
crate::managed_agents::merged_user_env(&definition.env_vars, &record.env_vars);
without_definition == with_definition
}

fn behavior_defaults_match(definition: &AgentDefinition, record: &ManagedAgentRecord) -> bool {
let mode_matches = definition
.respond_to
.as_deref()
.map(|mode| mode == record.respond_to.as_str())
.unwrap_or(true);
let allowlist_matches = definition.respond_to.as_deref()
!= Some(crate::managed_agents::RespondTo::Allowlist.as_str())
|| definition.respond_to_allowlist == record.respond_to_allowlist;
let parallelism_matches = definition
.parallelism
.map(|parallelism| parallelism == record.parallelism)
.unwrap_or(true);

mode_matches && allowlist_matches && parallelism_matches
}

fn snapshot_field_matches(definition_value: Option<&str>, record_value: Option<&str>) -> bool {
definition_value
.filter(|value| !value.trim().is_empty())
.map(|value| record_value == Some(value))
.unwrap_or(true)
}

fn folded_definition_matches(definition: &AgentDefinition, record: &ManagedAgentRecord) -> bool {
!definition.is_builtin
&& definition.is_active
&& definition.source_team.is_none()
&& definition.source_team_persona_slug.is_none()
// Folded definitions cannot represent relay-mesh instance metadata.
&& record.relay_mesh.is_none()
&& definition.display_name
== record
.display_name
.as_deref()
.unwrap_or(record.name.as_str())
&& definition.system_prompt == record.system_prompt.as_deref().unwrap_or_default()
// Persona folding can retain a data URI after agent creation has
// uploaded that avatar and stored its media URL. Snapshot application
// never overwrites the instance avatar, so it is not an identity key.
&& runtime_matches(definition, record)
// Snapshot application preserves the record when either optional
// definition field is absent or blank.
&& snapshot_field_matches(definition.model.as_deref(), record.model.as_deref())
&& snapshot_field_matches(definition.provider.as_deref(), record.provider.as_deref())
Comment on lines +160 to +161

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve record-only model/provider before linking

When a folded definition has a blank/absent model or provider but the standalone record has one, this wildcard match links the record without manufacturing a definition that contains those record-only values. After persona_id is set, agent_event_content omits model/provider for every definition-linked 30177 event (desktop/src-tauri/src/managed_agents/agent_events.rs:70-96), so the next event sync or restore on another device resolves through the incomplete definition and loses the agent's configured model/provider. Require the definition to carry the same non-empty fields, or copy the record values into a definition, before slimming the record.

Useful? React with 👍 / 👎.

// Definition env is layered under per-instance overrides. Only adopt
// when activating that lower layer leaves the effective env unchanged.
&& effective_env_matches(definition, record)
&& (record.name_pool.is_empty() || definition.name_pool == record.name_pool)
// These fields were absent before B5, so absence means unknown. Once a
// definition carries explicit defaults they help disambiguate identity.
&& behavior_defaults_match(definition, record)
}

enum FoldedDefinitionMatch<'a> {
NoMatch,
Unique(&'a AgentDefinition),
Ambiguous,
}

fn matching_definition<'a>(
definitions: &'a [AgentDefinition],
record: &ManagedAgentRecord,
) -> FoldedDefinitionMatch<'a> {
let mut matches = definitions
.iter()
.filter(|definition| folded_definition_matches(definition, record));
let Some(matching) = matches.next() else {
return FoldedDefinitionMatch::NoMatch;
};
if matches.next().is_some() {
FoldedDefinitionMatch::Ambiguous
} else {
FoldedDefinitionMatch::Unique(matching)
}
}

/// Core backfill logic, decoupled from the Tauri `AppHandle` for testing.
/// Returns the number of records backfilled (0 = nothing to do).
fn backfill_standalone_agents_in_dir(base_dir: &Path) -> Result<usize, String> {
Expand Down Expand Up @@ -74,10 +219,34 @@ fn backfill_standalone_agents_in_dir(base_dir: &Path) -> Result<usize, String> {

let existing_slugs: std::collections::HashSet<String> =
all.iter().filter_map(|r| r.slug.clone()).collect();
let existing_definitions: Vec<AgentDefinition> = all
.iter()
.filter(|record| record.pubkey.is_empty())
.filter_map(ManagedAgentRecord::to_definition_view)
.collect();

let mut manufactured: Vec<ManagedAgentRecord> = Vec::new();
let mut backfilled = 0usize;
for record in all.iter_mut().filter(|r| needs_backfill(r)) {
match matching_definition(&existing_definitions, record) {
FoldedDefinitionMatch::Unique(definition) => {
record.persona_id = Some(definition.id.clone());
record.persona_source_version =
Some(persona_content_hash(&persona_event_content(definition)));
backfilled += 1;
continue;
}
FoldedDefinitionMatch::Ambiguous => {
eprintln!(
"buzz-desktop: standalone-backfill: multiple folded definitions match agent {} \
— skipped; disambiguate the definitions to let the next launch backfill it",
record.pubkey
);
continue;
}
FoldedDefinitionMatch::NoMatch => {}
}

// Pubkeys are unique so this cannot fire against another manufactured
// definition — only against a pre-existing definition improbably
// slugged as this agent's pubkey. Fail loudly, skip, continue: the
Expand Down
Loading