mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): persona engram instantiation + fleet update + legacy sync gate
Write a mem/persona engram (kind:30174) when creating an agent from a persona. The engram is NIP-44 encrypted under the agent-owner conversation key with an HMAC-blinded d-tag, conforming to the existing engram envelope contract. Content includes the persona snapshot plus provenance (owner pubkey, kind:30175, slug, content SHA-256 digest). Fleet update reconciles agent engrams against current persona content on app launch and on persona save. Comparison is digest-based (not timestamp) to handle clock skew and export/import round-trips. No agent restart -- the live resolve path still reads the persona catalog at spawn; the engram is provenance and future-proofing for when it becomes the runtime source. Gate the directory-backed team sync machinery (import_team_from_directory, sync_team_from_dir, sync_team_personas) behind a legacy_team_sync feature flag (default-enabled). Fleet update replaces sync_team_personas in the launch sequence. The old code remains compilable for one release cycle as a rollback path. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
co-authored by
Will Pfleger
parent
a2e9c8c077
commit
45b0782011
@@ -30,7 +30,7 @@ const rules = [
|
||||
// Do not add to this list; split the file instead. Remove each entry as its
|
||||
// file is broken up. Tracked as a follow-up.
|
||||
const overrides = new Map([
|
||||
["src-tauri/src/commands/agents.rs", 1287],
|
||||
["src-tauri/src/commands/agents.rs", 1370],
|
||||
["src-tauri/src/managed_agents/nest.rs", 1420],
|
||||
["src-tauri/src/managed_agents/runtime.rs", 1465],
|
||||
["src-tauri/src/managed_agents/personas.rs", 1080],
|
||||
|
||||
@@ -17,8 +17,9 @@ name = "sprout_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
default = ["legacy_team_sync"]
|
||||
mesh-llm = ["dep:mesh-llm-sdk", "dep:mesh-llm-host-runtime"]
|
||||
legacy_team_sync = []
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
@@ -618,6 +618,27 @@ pub async fn create_managed_agent(
|
||||
.await)
|
||||
.err();
|
||||
|
||||
// ── Phase 4b: write persona engram (async, best-effort) ─────────────────
|
||||
// If the agent was created from a persona, snapshot it into a `mem/persona`
|
||||
// engram. This is provenance-only for now (the live resolve path still reads
|
||||
// the persona catalog at spawn); fleet update keeps it current.
|
||||
if requested_persona_id.is_some() {
|
||||
if let Err(e) = crate::managed_agents::fleet_update::write_persona_engram_at_creation(
|
||||
&state,
|
||||
&agent_keys,
|
||||
&resolved_relay_url,
|
||||
requested_persona_id.as_deref().unwrap(),
|
||||
&app,
|
||||
)
|
||||
.await
|
||||
{
|
||||
eprintln!(
|
||||
"sprout-desktop: create-agent: persona engram write failed for {}: {e}",
|
||||
&pubkey[..pubkey.len().min(8)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 5: provider deploy (async, outside lock) ───────────────────────
|
||||
let spawn_error = if input.spawn_after_create && input.backend != BackendKind::Local {
|
||||
if let BackendKind::Provider { ref id, ref config } = input.backend {
|
||||
|
||||
@@ -140,6 +140,19 @@ pub fn update_persona(
|
||||
.find(|record| record.id == input.id)
|
||||
.ok_or_else(|| format!("persona {} disappeared unexpectedly", input.id))?;
|
||||
try_regenerate_nest(&app);
|
||||
|
||||
// Fleet update: propagate persona edits to agent engrams (best-effort).
|
||||
let fleet_app = app.clone();
|
||||
let persona_id = input.id.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) =
|
||||
crate::managed_agents::fleet_update::fleet_update_for_persona(&fleet_app, &persona_id)
|
||||
.await
|
||||
{
|
||||
eprintln!("sprout-desktop: fleet-update-on-save: {e}");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,16 @@ use tauri::{AppHandle, State};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::export_util::save_json_with_dialog;
|
||||
#[cfg(feature = "legacy_team_sync")]
|
||||
use crate::managed_agents::{
|
||||
import_team_from_directory as do_import_team, sync_team_from_dir as do_sync_team, SyncResult,
|
||||
};
|
||||
use crate::{
|
||||
app_state::AppState,
|
||||
managed_agents::{
|
||||
delete_team_with_cascade, encode_team_json, ensure_persona_ids_are_active,
|
||||
import_team_from_directory as do_import_team, load_personas, load_teams, parse_team_json,
|
||||
save_teams, sync_team_from_dir as do_sync_team, try_regenerate_nest, CreateTeamRequest,
|
||||
ParsedTeamPreview, SyncResult, TeamRecord, UpdateTeamRequest,
|
||||
delete_team_with_cascade, encode_team_json, ensure_persona_ids_are_active, load_personas,
|
||||
load_teams, parse_team_json, save_teams, try_regenerate_nest, CreateTeamRequest,
|
||||
ParsedTeamPreview, TeamRecord, UpdateTeamRequest,
|
||||
},
|
||||
util::now_iso,
|
||||
};
|
||||
@@ -114,6 +117,7 @@ pub fn delete_team(id: String, app: AppHandle, state: State<'_, AppState>) -> Re
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "legacy_team_sync")]
|
||||
#[tauri::command]
|
||||
pub fn install_team_from_directory(
|
||||
app: AppHandle,
|
||||
@@ -134,6 +138,7 @@ pub fn install_team_from_directory(
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(feature = "legacy_team_sync")]
|
||||
#[tauri::command]
|
||||
pub fn sync_team_directory(
|
||||
app: AppHandle,
|
||||
|
||||
@@ -543,9 +543,20 @@ pub fn run() {
|
||||
.map_err(|e| -> Box<dyn std::error::Error> { e.to_string().into() })?;
|
||||
migration::migrate_personas_to_events(&app_handle, &owner_keys);
|
||||
|
||||
if let Err(e) = managed_agents::sync_team_personas(&app_handle) {
|
||||
eprintln!("sprout-desktop: sync-team-personas: {e}");
|
||||
}
|
||||
// Fleet update: reconcile every agent's `mem/persona` engram against
|
||||
// its persona's current content. Replaces the old directory-based
|
||||
// `sync_team_personas`. Best-effort and relay-dependent, so it runs
|
||||
// off-thread — engram drift is non-fatal and resolves next launch.
|
||||
let fleet_app = app_handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
match managed_agents::fleet_update::check_fleet_updates(&fleet_app).await {
|
||||
Ok(0) => {}
|
||||
Ok(n) => {
|
||||
eprintln!("sprout-desktop: fleet-update: rewrote {n} persona engram(s)")
|
||||
}
|
||||
Err(e) => eprintln!("sprout-desktop: fleet-update: {e}"),
|
||||
}
|
||||
});
|
||||
|
||||
// Store the AppHandle so huddle commands can emit `huddle-state-changed`
|
||||
// events via `huddle::emit_huddle_state` without threading the handle
|
||||
@@ -744,7 +755,9 @@ pub fn run() {
|
||||
create_team,
|
||||
update_team,
|
||||
delete_team,
|
||||
#[cfg(feature = "legacy_team_sync")]
|
||||
install_team_from_directory,
|
||||
#[cfg(feature = "legacy_team_sync")]
|
||||
sync_team_directory,
|
||||
pick_team_directory,
|
||||
export_team_to_json,
|
||||
|
||||
@@ -25,6 +25,7 @@ use std::collections::BTreeMap;
|
||||
///
|
||||
/// Non-structured knobs (`GOOSE_TEMPERATURE`, `GOOSE_CONTEXT_LIMIT`) are NOT
|
||||
/// in this list — they have no structured counterpart and must be preserved.
|
||||
#[cfg_attr(not(feature = "legacy_team_sync"), allow(dead_code))]
|
||||
pub(crate) const DERIVED_PROVIDER_MODEL_ENV_KEYS: &[&str] = &[
|
||||
"GOOSE_MODEL",
|
||||
"GOOSE_PROVIDER",
|
||||
@@ -34,6 +35,7 @@ pub(crate) const DERIVED_PROVIDER_MODEL_ENV_KEYS: &[&str] = &[
|
||||
|
||||
/// Returns `true` if `key` is a derived provider/model env key that should be
|
||||
/// filtered out of persisted `PersonaRecord.env_vars` at pack import time.
|
||||
#[cfg(feature = "legacy_team_sync")]
|
||||
pub(crate) fn is_derived_provider_model_key(key: &str) -> bool {
|
||||
DERIVED_PROVIDER_MODEL_ENV_KEYS
|
||||
.iter()
|
||||
@@ -46,6 +48,7 @@ pub(crate) fn is_derived_provider_model_key(key: &str) -> bool {
|
||||
/// The structured `PersonaRecord.provider` / `PersonaRecord.model` fields are
|
||||
/// the authoritative source of truth. Keeping the derived copies would cause
|
||||
/// stale env values to override updated structured fields at spawn/deploy time.
|
||||
#[cfg(feature = "legacy_team_sync")]
|
||||
pub(crate) fn filter_derived_provider_model_env_vars(
|
||||
env_vars: impl IntoIterator<Item = (String, String)>,
|
||||
) -> BTreeMap<String, String> {
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
//! Fleet update: keep agent `mem/persona` engrams in sync with persona edits.
|
||||
//!
|
||||
//! When a persona's content changes, each agent instantiated from it carries a
|
||||
//! stale snapshot in its `mem/persona` engram. Fleet update detects the drift
|
||||
//! (by comparing content hashes, not timestamps — timestamps are fragile across
|
||||
//! clock skew and export/import) and rewrites the affected engrams.
|
||||
//!
|
||||
//! This runs on persona save (immediate propagation) and at app launch (catches
|
||||
//! edits made while the app was closed). It never restarts running agents — the
|
||||
//! live resolve path still reads the persona catalog at spawn, so a running
|
||||
//! agent already sees edits on its next session. The engram is provenance and
|
||||
//! future-proofing for when it becomes the runtime source; keeping it current
|
||||
//! is the only job here.
|
||||
|
||||
use nostr::PublicKey;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use super::persona_events::{
|
||||
build_persona_engram, persona_content_hash, persona_engram_from_event_as_owner,
|
||||
persona_event_content, PERSONA_ENGRAM_SLUG,
|
||||
};
|
||||
use super::personas::load_personas;
|
||||
use super::storage::load_managed_agents;
|
||||
use super::{ManagedAgentRecord, PersonaRecord};
|
||||
use crate::app_state::AppState;
|
||||
use crate::relay;
|
||||
use sprout_core::engram::{conversation_key, d_tag, select_head};
|
||||
use sprout_core::kind::KIND_AGENT_ENGRAM;
|
||||
|
||||
/// What fleet update should do with one agent's persona engram.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FleetAction {
|
||||
/// Engram already matches the current persona — nothing to do.
|
||||
NoOp,
|
||||
/// Engram is missing or stale — (re)write it.
|
||||
Write,
|
||||
}
|
||||
|
||||
/// Decide whether an agent's persona engram needs rewriting.
|
||||
///
|
||||
/// Pure and side-effect free so the decision can be unit-tested in isolation:
|
||||
/// a missing engram (`None`) always writes; otherwise we write iff the stored
|
||||
/// content version differs from the persona's current content hash.
|
||||
pub fn fleet_action_for(current_hash: &str, stored_version: Option<&str>) -> FleetAction {
|
||||
match stored_version {
|
||||
Some(version) if version == current_hash => FleetAction::NoOp,
|
||||
_ => FleetAction::Write,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run fleet update across all managed agents (launch-time reconciliation).
|
||||
///
|
||||
/// Best-effort: relay errors for one agent are logged and skipped, never fatal.
|
||||
/// Returns the number of engrams written.
|
||||
pub async fn check_fleet_updates(app: &AppHandle) -> Result<usize, String> {
|
||||
let state = app.state::<AppState>();
|
||||
let personas = load_personas(app)?;
|
||||
let agents = load_managed_agents(app)?;
|
||||
|
||||
let (owner_keys, relay_url) = owner_context(&state)?;
|
||||
let mut written = 0;
|
||||
|
||||
for agent in &agents {
|
||||
let Some(persona) = persona_for_agent(agent, &personas) else {
|
||||
continue;
|
||||
};
|
||||
match reconcile_agent(&state, &owner_keys, &relay_url, agent, persona).await {
|
||||
Ok(true) => written += 1,
|
||||
Ok(false) => {}
|
||||
Err(e) => eprintln!(
|
||||
"sprout-desktop: fleet-update: skipped agent {}: {e}",
|
||||
short(&agent.pubkey)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
/// Run fleet update for the agents tied to a single persona (on persona save).
|
||||
///
|
||||
/// Targeted variant of [`check_fleet_updates`] — only touches agents whose
|
||||
/// `persona_id` matches. Best-effort with the same skip-on-error posture.
|
||||
pub async fn fleet_update_for_persona(app: &AppHandle, persona_id: &str) -> Result<usize, String> {
|
||||
let state = app.state::<AppState>();
|
||||
let personas = load_personas(app)?;
|
||||
let agents = load_managed_agents(app)?;
|
||||
|
||||
let Some(persona) = personas.iter().find(|p| p.id == persona_id) else {
|
||||
return Ok(0); // Persona deleted between save and update — nothing to do.
|
||||
};
|
||||
|
||||
let (owner_keys, relay_url) = owner_context(&state)?;
|
||||
let mut written = 0;
|
||||
|
||||
for agent in &agents {
|
||||
if agent.persona_id.as_deref() != Some(persona_id) {
|
||||
continue;
|
||||
}
|
||||
match reconcile_agent(&state, &owner_keys, &relay_url, agent, persona).await {
|
||||
Ok(true) => written += 1,
|
||||
Ok(false) => {}
|
||||
Err(e) => eprintln!(
|
||||
"sprout-desktop: fleet-update: skipped agent {}: {e}",
|
||||
short(&agent.pubkey)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
/// Reconcile one agent's engram against a persona. Returns `Ok(true)` if a
|
||||
/// rewrite was published, `Ok(false)` for a no-op.
|
||||
async fn reconcile_agent(
|
||||
state: &AppState,
|
||||
owner_keys: &nostr::Keys,
|
||||
relay_url: &str,
|
||||
agent: &ManagedAgentRecord,
|
||||
persona: &PersonaRecord,
|
||||
) -> Result<bool, String> {
|
||||
let agent_keys = nostr::Keys::parse(&agent.private_key_nsec)
|
||||
.map_err(|e| format!("invalid agent key: {e}"))?;
|
||||
let agent_pubkey = agent_keys.public_key();
|
||||
|
||||
let current_hash = persona_content_hash(&persona_event_content(persona));
|
||||
let stored_version = read_engram_source_version(state, owner_keys, &agent_pubkey).await;
|
||||
|
||||
if fleet_action_for(¤t_hash, stored_version.as_deref()) == FleetAction::NoOp {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
apply_fleet_update(
|
||||
state,
|
||||
&agent_keys,
|
||||
&owner_keys.public_key(),
|
||||
relay_url,
|
||||
persona,
|
||||
)
|
||||
.await?;
|
||||
eprintln!(
|
||||
"sprout-desktop: fleet-update: rewrote mem/persona engram for agent {} (persona {})",
|
||||
short(&agent.pubkey),
|
||||
persona.id
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Write the initial `mem/persona` engram for a freshly created agent.
|
||||
///
|
||||
/// Called from `create_managed_agent` once the persona is known. Best-effort:
|
||||
/// the caller logs and continues on error so engram failure never blocks agent
|
||||
/// creation. The owner key comes from app state; the persona is resolved by id.
|
||||
pub async fn write_persona_engram_at_creation(
|
||||
state: &AppState,
|
||||
agent_keys: &nostr::Keys,
|
||||
relay_url: &str,
|
||||
persona_id: &str,
|
||||
app: &AppHandle,
|
||||
) -> Result<(), String> {
|
||||
let personas = load_personas(app)?;
|
||||
let persona = personas
|
||||
.iter()
|
||||
.find(|p| p.id == persona_id)
|
||||
.ok_or_else(|| format!("persona {persona_id} not found"))?;
|
||||
|
||||
let owner_pubkey = {
|
||||
let keys = state.keys.lock().map_err(|e| e.to_string())?;
|
||||
keys.public_key()
|
||||
};
|
||||
apply_fleet_update(state, agent_keys, &owner_pubkey, relay_url, persona).await
|
||||
}
|
||||
|
||||
/// Build and publish a fresh `mem/persona` engram for one agent.
|
||||
async fn apply_fleet_update(
|
||||
state: &AppState,
|
||||
agent_keys: &nostr::Keys,
|
||||
owner_pubkey: &PublicKey,
|
||||
relay_url: &str,
|
||||
persona: &PersonaRecord,
|
||||
) -> Result<(), String> {
|
||||
let now = now_secs();
|
||||
let event = build_persona_engram(agent_keys, owner_pubkey, persona, now)?;
|
||||
publish_agent_event(state, agent_keys, relay_url, &event).await
|
||||
}
|
||||
|
||||
/// Fetch the agent's current `mem/persona` engram from the relay and return the
|
||||
/// recorded `provenance.source_version`. `None` if no engram exists or it can't
|
||||
/// be read/decrypted — both cases trigger a (re)write, which is safe under
|
||||
/// NIP-33 latest-wins replacement.
|
||||
///
|
||||
/// The owner holds the only key needed: the agent↔owner conversation key is
|
||||
/// symmetric, so the owner decrypts with its own secret key and the agent's
|
||||
/// pubkey. No agent secret key is required at read time.
|
||||
async fn read_engram_source_version(
|
||||
state: &AppState,
|
||||
owner_keys: &nostr::Keys,
|
||||
agent_pubkey: &PublicKey,
|
||||
) -> Option<String> {
|
||||
let owner_pubkey = owner_keys.public_key();
|
||||
let k_c = conversation_key(owner_keys.secret_key(), agent_pubkey);
|
||||
let expected_d = d_tag(&k_c, PERSONA_ENGRAM_SLUG);
|
||||
|
||||
// Read-gated kind:30174 query: owner-authored auth, #p = owner (the owner
|
||||
// reading engrams addressed to them). #d narrows to the persona slot.
|
||||
let filter = serde_json::json!({
|
||||
"kinds": [KIND_AGENT_ENGRAM],
|
||||
"authors": [agent_pubkey.to_hex()],
|
||||
"#p": [owner_pubkey.to_hex()],
|
||||
"#d": [expected_d],
|
||||
});
|
||||
let events = relay::query_relay(state, &[filter]).await.ok()?;
|
||||
|
||||
let head = select_head(events)?;
|
||||
let body = persona_engram_from_event_as_owner(&head, agent_pubkey, owner_keys).ok()?;
|
||||
Some(body.provenance.source_version)
|
||||
}
|
||||
|
||||
/// Publish an engram event signed by the agent, authenticating the write with
|
||||
/// the agent's keys (NIP-98) and NIP-OA auth tag.
|
||||
async fn publish_agent_event(
|
||||
state: &AppState,
|
||||
agent_keys: &nostr::Keys,
|
||||
relay_url: &str,
|
||||
event: &nostr::Event,
|
||||
) -> Result<(), String> {
|
||||
let url = format!("{}/events", relay::relay_http_base_url(relay_url));
|
||||
let body_bytes = nostr::JsonUtil::as_json(event).into_bytes();
|
||||
let auth = relay::build_nip98_auth_header_for_keys(
|
||||
agent_keys,
|
||||
&reqwest::Method::POST,
|
||||
&url,
|
||||
&body_bytes,
|
||||
)?;
|
||||
|
||||
let response = state
|
||||
.http_client
|
||||
.post(&url)
|
||||
.header("Authorization", auth)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body_bytes)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("publish request failed: {e}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(relay::relay_error_message(response).await);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve the persona an agent was instantiated from, if any.
|
||||
fn persona_for_agent<'a>(
|
||||
agent: &ManagedAgentRecord,
|
||||
personas: &'a [PersonaRecord],
|
||||
) -> Option<&'a PersonaRecord> {
|
||||
let persona_id = agent.persona_id.as_deref()?;
|
||||
personas.iter().find(|p| p.id == persona_id)
|
||||
}
|
||||
|
||||
/// Snapshot the owner's keys and relay URL for a fleet-update pass.
|
||||
fn owner_context(state: &AppState) -> Result<(nostr::Keys, String), String> {
|
||||
let owner_keys = {
|
||||
let keys = state.keys.lock().map_err(|e| e.to_string())?;
|
||||
keys.clone()
|
||||
};
|
||||
let relay_url = relay::relay_ws_url_with_override(state);
|
||||
Ok((owner_keys, relay_url))
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn short(pubkey: &str) -> &str {
|
||||
&pubkey[..pubkey.len().min(8)]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn missing_engram_writes() {
|
||||
assert_eq!(fleet_action_for("abc123", None), FleetAction::Write);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matching_hash_is_noop() {
|
||||
assert_eq!(
|
||||
fleet_action_for("abc123", Some("abc123")),
|
||||
FleetAction::NoOp
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn differing_hash_writes() {
|
||||
assert_eq!(
|
||||
fleet_action_for("abc123", Some("def456")),
|
||||
FleetAction::Write
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
mod backend;
|
||||
mod discovery;
|
||||
mod env_vars;
|
||||
pub(crate) mod fleet_update;
|
||||
mod nest;
|
||||
mod persona_avatars;
|
||||
mod persona_card;
|
||||
|
||||
@@ -5,15 +5,19 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use nostr::{EventBuilder, Kind, Tag};
|
||||
use nostr::{EventBuilder, Kind, PublicKey, Tag};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sprout_core::kind::KIND_PERSONA;
|
||||
|
||||
use super::PersonaRecord;
|
||||
use crate::app_state::AppState;
|
||||
|
||||
/// The slug for the per-agent persona memory engram. The agent stores a
|
||||
/// snapshot of the persona it was instantiated from under this memory slot.
|
||||
pub const PERSONA_ENGRAM_SLUG: &str = "mem/persona";
|
||||
|
||||
/// 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")]
|
||||
@@ -132,6 +136,153 @@ pub async fn fetch_persona_events(state: &AppState) -> Result<Vec<nostr::Event>,
|
||||
crate::relay::query_relay(state, &[filter]).await
|
||||
}
|
||||
|
||||
/// Provenance recorded inside a persona engram's encrypted body. Identifies the
|
||||
/// source persona event the agent was instantiated from and a content digest
|
||||
/// used by fleet update to detect drift. Kept inside the encrypted body (not as
|
||||
/// a plaintext tag) so the engram's blinding guarantee is preserved.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PersonaProvenance {
|
||||
pub owner_pubkey: String,
|
||||
pub kind: u32,
|
||||
pub slug: String,
|
||||
/// SHA-256 of the canonical persona content JSON at the time of the write.
|
||||
pub source_version: String,
|
||||
}
|
||||
|
||||
/// The decrypted body of a `mem/persona` engram: the persona snapshot plus its
|
||||
/// provenance. Serialized as the engram's memory `value` string.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PersonaEngramBody {
|
||||
#[serde(flatten)]
|
||||
pub content: PersonaEventContent,
|
||||
pub provenance: PersonaProvenance,
|
||||
}
|
||||
|
||||
/// SHA-256 (lowercase hex) of a persona's canonical content JSON.
|
||||
///
|
||||
/// Fleet update compares this digest, not event timestamps, to decide whether
|
||||
/// an agent's engram 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(),
|
||||
env_vars: record.env_vars.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the decrypted body for a persona engram from a `PersonaRecord`.
|
||||
fn persona_engram_body(record: &PersonaRecord, owner_pubkey: &PublicKey) -> PersonaEngramBody {
|
||||
let content = persona_event_content(record);
|
||||
let source_version = persona_content_hash(&content);
|
||||
let provenance = PersonaProvenance {
|
||||
owner_pubkey: owner_pubkey.to_hex(),
|
||||
kind: KIND_PERSONA,
|
||||
slug: persona_d_tag(record),
|
||||
source_version,
|
||||
};
|
||||
PersonaEngramBody {
|
||||
content,
|
||||
provenance,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a signed `mem/persona` engram (kind:30174) for an agent.
|
||||
///
|
||||
/// The engram is authored by the agent and addressed to the owner: its content
|
||||
/// is NIP-44 encrypted under `ECDH(agent_seckey, owner_pubkey)` and the d-tag is
|
||||
/// HMAC-blinded over the `mem/persona` slug. The persona snapshot and its
|
||||
/// provenance live inside the encrypted body.
|
||||
pub fn build_persona_engram(
|
||||
agent_keys: &nostr::Keys,
|
||||
owner_pubkey: &PublicKey,
|
||||
record: &PersonaRecord,
|
||||
created_at: u64,
|
||||
) -> Result<nostr::Event, String> {
|
||||
let body = persona_engram_body(record, owner_pubkey);
|
||||
let value = serde_json::to_string(&body)
|
||||
.map_err(|e| format!("failed to serialize engram body: {e}"))?;
|
||||
|
||||
let engram_body = sprout_core::engram::Body::Memory {
|
||||
slug: PERSONA_ENGRAM_SLUG.to_string(),
|
||||
value: Some(value),
|
||||
};
|
||||
|
||||
sprout_core::engram::build_event(agent_keys, owner_pubkey, &engram_body, created_at)
|
||||
.map_err(|e| format!("failed to build persona engram: {e}"))
|
||||
}
|
||||
|
||||
/// Decode a persona engram body from a relay event, validating the envelope and
|
||||
/// decrypting under the agent↔owner conversation key.
|
||||
pub fn persona_engram_from_event(
|
||||
event: &nostr::Event,
|
||||
agent_keys: &nostr::Keys,
|
||||
owner_pubkey: &PublicKey,
|
||||
) -> Result<PersonaEngramBody, String> {
|
||||
let body = sprout_core::engram::validate_and_decrypt(
|
||||
event,
|
||||
&agent_keys.public_key(),
|
||||
owner_pubkey,
|
||||
agent_keys.secret_key(),
|
||||
owner_pubkey,
|
||||
)
|
||||
.map_err(|e| format!("failed to validate persona engram: {e}"))?;
|
||||
|
||||
engram_body_from_decrypted(body)
|
||||
}
|
||||
|
||||
/// Decode a persona engram as the owner (fleet update reads engrams using the
|
||||
/// owner's secret key + agent's pubkey — the conversation key is symmetric).
|
||||
pub fn persona_engram_from_event_as_owner(
|
||||
event: &nostr::Event,
|
||||
agent_pubkey: &PublicKey,
|
||||
owner_keys: &nostr::Keys,
|
||||
) -> Result<PersonaEngramBody, String> {
|
||||
let body = sprout_core::engram::validate_and_decrypt(
|
||||
event,
|
||||
agent_pubkey,
|
||||
&owner_keys.public_key(),
|
||||
owner_keys.secret_key(),
|
||||
agent_pubkey,
|
||||
)
|
||||
.map_err(|e| format!("failed to validate persona engram: {e}"))?;
|
||||
|
||||
engram_body_from_decrypted(body)
|
||||
}
|
||||
|
||||
fn engram_body_from_decrypted(
|
||||
body: sprout_core::engram::Body,
|
||||
) -> Result<PersonaEngramBody, String> {
|
||||
match body {
|
||||
sprout_core::engram::Body::Memory {
|
||||
value: Some(value), ..
|
||||
} => serde_json::from_str(&value)
|
||||
.map_err(|e| format!("failed to parse persona engram body: {e}")),
|
||||
sprout_core::engram::Body::Memory { value: None, .. } => {
|
||||
Err("persona engram is a tombstone".to_string())
|
||||
}
|
||||
sprout_core::engram::Body::Core { .. } => {
|
||||
Err("expected memory engram, got core".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -243,4 +394,70 @@ mod tests {
|
||||
assert!(!restored.is_builtin);
|
||||
assert!(restored.is_active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_engram_round_trip() {
|
||||
let record = sample_persona();
|
||||
let agent_keys = nostr::Keys::generate();
|
||||
let owner_keys = nostr::Keys::generate();
|
||||
let owner_pubkey = owner_keys.public_key();
|
||||
|
||||
let now = 1_700_000_000u64;
|
||||
let event = build_persona_engram(&agent_keys, &owner_pubkey, &record, now).unwrap();
|
||||
|
||||
// Verify it's a kind:30174 event
|
||||
assert_eq!(
|
||||
event.kind.as_u16() as u32,
|
||||
sprout_core::kind::KIND_AGENT_ENGRAM
|
||||
);
|
||||
// Verify it's authored by the agent
|
||||
assert_eq!(event.pubkey, agent_keys.public_key());
|
||||
|
||||
// Decrypt and verify content
|
||||
let body = persona_engram_from_event(&event, &agent_keys, &owner_pubkey).unwrap();
|
||||
assert_eq!(body.content.display_name, "Test Persona");
|
||||
assert_eq!(body.content.system_prompt, "You are a test assistant.");
|
||||
assert_eq!(body.provenance.owner_pubkey, owner_pubkey.to_hex());
|
||||
assert_eq!(body.provenance.kind, KIND_PERSONA);
|
||||
assert_eq!(body.provenance.slug, "test-slug");
|
||||
assert!(!body.provenance.source_version.is_empty());
|
||||
}
|
||||
|
||||
#[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![],
|
||||
env_vars: BTreeMap::new(),
|
||||
};
|
||||
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![],
|
||||
env_vars: BTreeMap::new(),
|
||||
};
|
||||
let mut content2 = content1.clone();
|
||||
content2.system_prompt = "Goodbye".to_string();
|
||||
assert_ne!(
|
||||
persona_content_hash(&content1),
|
||||
persona_content_hash(&content2)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +165,7 @@ pub fn save_teams(app: &AppHandle, records: &[TeamRecord]) -> Result<(), String>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Teams directory: `<AppDataDir>/agents/teams/`
|
||||
#[cfg(feature = "legacy_team_sync")]
|
||||
fn teams_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let dir = managed_agents_base_dir(app)?.join("teams");
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("failed to create teams dir: {e}"))?;
|
||||
@@ -172,6 +173,7 @@ fn teams_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
}
|
||||
|
||||
/// Validate team/pack ID: only `[a-zA-Z0-9._-]+` allowed (zip-slip defense).
|
||||
#[cfg(feature = "legacy_team_sync")]
|
||||
pub(crate) fn validate_team_id(id: &str) -> Result<(), String> {
|
||||
if id.is_empty() {
|
||||
return Err("team ID is empty".into());
|
||||
@@ -199,6 +201,7 @@ pub(crate) fn validate_team_id(id: &str) -> Result<(), String> {
|
||||
}
|
||||
|
||||
/// Copy a directory tree, skipping symlinks (zip-slip defense).
|
||||
#[cfg(feature = "legacy_team_sync")]
|
||||
fn copy_dir_no_symlinks(src: &std::path::Path, dst: &std::path::Path) -> Result<(), String> {
|
||||
fs::create_dir_all(dst).map_err(|e| format!("failed to create {}: {e}", dst.display()))?;
|
||||
for entry in fs::read_dir(src).map_err(|e| format!("failed to read {}: {e}", src.display()))? {
|
||||
@@ -225,6 +228,7 @@ fn copy_dir_no_symlinks(src: &std::path::Path, dst: &std::path::Path) -> Result<
|
||||
///
|
||||
/// Copies the directory into `<data>/agents/teams/<resolved.id>/`,
|
||||
/// creates PersonaRecords for each persona, creates a TeamRecord with source_dir set.
|
||||
#[cfg(feature = "legacy_team_sync")]
|
||||
pub fn import_team_from_directory(
|
||||
app: &AppHandle,
|
||||
source_dir: &std::path::Path,
|
||||
@@ -415,6 +419,7 @@ pub fn delete_team_with_cascade(app: &AppHandle, team_id: &str) -> Result<(), St
|
||||
}
|
||||
|
||||
/// Re-reads a directory-backed team and reconciles with stored records.
|
||||
#[cfg(feature = "legacy_team_sync")]
|
||||
pub fn sync_team_from_dir(
|
||||
app: &AppHandle,
|
||||
team_id: &str,
|
||||
@@ -715,6 +720,13 @@ pub fn parse_team_json(json_bytes: &[u8]) -> Result<ParsedTeamPreview, String> {
|
||||
/// Sync all directory-backed teams on launch — the team equivalent of the
|
||||
/// former `sync_pack_personas`. Silently skips teams whose source directory
|
||||
/// is missing (e.g., external drive unmounted).
|
||||
///
|
||||
/// Superseded by `fleet_update::check_fleet_updates`, which propagates persona
|
||||
/// edits to agent engrams instead of directory files. Retained behind
|
||||
/// `legacy_team_sync` for one release as a rollback path; `#[allow(dead_code)]`
|
||||
/// because the launch call site moved to fleet update.
|
||||
#[cfg(feature = "legacy_team_sync")]
|
||||
#[allow(dead_code)]
|
||||
pub fn sync_team_personas(app: &AppHandle) -> Result<(), String> {
|
||||
let teams = load_teams(app)?;
|
||||
for team in &teams {
|
||||
|
||||
@@ -525,6 +525,7 @@ pub struct UpdateTeamRequest {
|
||||
}
|
||||
|
||||
/// Result of syncing a directory-backed team with its backing directory.
|
||||
#[cfg_attr(not(feature = "legacy_team_sync"), allow(dead_code))]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SyncResult {
|
||||
pub personas_added: Vec<String>,
|
||||
|
||||
Reference in New Issue
Block a user