feat(desktop): write persona-snapshot engram at agent spawn

At spawn time, after resolving the effective prompt/model/provider from
the linked persona, write a NIP-AE encrypted engram (kind:30174) to the
local retention store. The engram captures provenance (persona_id,
source_version) and the effective persona state so future relay
distribution can deliver persona updates without restart.

This is write-only — the engram is never read back into the prompt path.
load_from_retention remains dead code (zero call sites) until PR 3 wires
the read side. Errors are logged but never block agent spawn.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-06-10 11:14:08 -04:00
co-authored by Will Pfleger
parent a2e9c8c077
commit d3f45d3e3f
2 changed files with 317 additions and 2 deletions
+130 -2
View File
@@ -5,8 +5,8 @@ use tauri::AppHandle;
use crate::{
managed_agents::{
append_log_marker, known_acp_runtime, login_shell_path, managed_agent_log_path,
missing_command_message, normalize_agent_args, open_log_file, resolve_command,
ManagedAgentProcess, ManagedAgentRecord, ManagedAgentSummary,
managed_agents_base_dir, missing_command_message, normalize_agent_args, open_log_file,
resolve_command, ManagedAgentProcess, ManagedAgentRecord, ManagedAgentSummary,
},
util::now_iso,
};
@@ -788,6 +788,123 @@ fn resolve_effective_prompt_model_provider(
}
}
/// Write a persona-snapshot engram at spawn time (provenance only).
///
/// Records the effective persona state (prompt, model, provider) as a NIP-AE
/// encrypted engram signed by the agent. This is write-only — the engram is
/// never read back into the prompt path (dead code until PR 3 wires the read
/// side). Errors are logged but never block agent spawn.
fn write_persona_engram_at_spawn(
app: &AppHandle,
record: &ManagedAgentRecord,
owner_hex: Option<&str>,
effective_prompt: Option<&str>,
effective_model: Option<&str>,
effective_provider: Option<&str>,
) {
let db_path = match managed_agents_base_dir(app) {
Ok(dir) => dir.join("retention.db"),
Err(e) => {
eprintln!(
"sprout-desktop: persona-engram: failed to resolve retention path for {}: {e}",
record.name
);
return;
}
};
if let Err(e) = write_persona_engram_to_db(
&db_path,
record,
owner_hex,
effective_prompt,
effective_model,
effective_provider,
) {
eprintln!("sprout-desktop: persona-engram: {}{e}", record.name);
}
}
/// Core engram-write logic, decoupled from `AppHandle` for testing.
///
/// Builds a NIP-AE encrypted engram containing the persona snapshot and
/// writes it to the retention store at `db_path`. Returns `Err` on any
/// failure; the caller decides whether to log or propagate.
fn write_persona_engram_to_db(
db_path: &std::path::Path,
record: &ManagedAgentRecord,
owner_hex: Option<&str>,
effective_prompt: Option<&str>,
effective_model: Option<&str>,
effective_provider: Option<&str>,
) -> Result<(), String> {
use nostr::{Keys, PublicKey};
use serde::Serialize;
use sprout_core::engram::{self, Body};
use sprout_core::kind::KIND_AGENT_ENGRAM;
use super::retention::{open_retention_db, retain_event, RetainedEvent};
let owner = owner_hex.ok_or("no owner pubkey available")?;
let owner_pubkey =
PublicKey::from_hex(owner).map_err(|e| format!("invalid owner pubkey: {e}"))?;
let agent_keys =
Keys::parse(&record.private_key_nsec).map_err(|e| format!("invalid agent keys: {e}"))?;
#[derive(Serialize)]
struct PersonaSnapshot<'a> {
persona_id: Option<&'a str>,
system_prompt: Option<&'a str>,
model: Option<&'a str>,
provider: Option<&'a str>,
source_version: &'a str,
}
let snapshot = PersonaSnapshot {
persona_id: record.persona_id.as_deref(),
system_prompt: effective_prompt,
model: effective_model,
provider: effective_provider,
source_version: &record.updated_at,
};
let value =
serde_json::to_string(&snapshot).map_err(|e| format!("failed to serialize: {e}"))?;
let slug = "mem/persona-snapshot";
let body = Body::Memory {
slug: slug.to_string(),
value: Some(value),
};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let event = engram::build_event(&agent_keys, &owner_pubkey, &body, now)
.map_err(|e| format!("failed to build engram: {e}"))?;
let conn = open_retention_db(db_path).map_err(|e| format!("failed to open db: {e}"))?;
let k_c = engram::conversation_key(agent_keys.secret_key(), &owner_pubkey);
let d = engram::d_tag(&k_c, slug);
let retained = RetainedEvent {
kind: KIND_AGENT_ENGRAM,
pubkey: agent_keys.public_key().to_hex(),
d_tag: d,
content: event.content.to_string(),
created_at: event.created_at.as_secs() as i64,
raw_event: nostr::JsonUtil::as_json(&event),
pending_sync: true,
};
retain_event(&conn, &retained).map_err(|e| format!("failed to retain: {e}"))
}
/// Spawn an agent process without holding any locks on records or runtimes.
/// Returns the child process and log path on success. The caller is responsible
/// for updating `ManagedAgentRecord` fields and inserting into the runtimes map.
@@ -933,6 +1050,17 @@ pub fn spawn_agent_child(
record.model.clone(),
);
// Write persona snapshot as a NIP-AE engram (provenance only — never read
// back into the prompt path until PR 3 wires the read side).
write_persona_engram_at_spawn(
app,
record,
owner_hex,
effective_prompt.as_deref(),
effective_model.as_deref(),
effective_provider.as_deref(),
);
if let Some(prompt) = &effective_prompt {
command.env("SPROUT_ACP_SYSTEM_PROMPT", prompt);
} else {
@@ -328,3 +328,190 @@ fn runtime_metadata_env_vars_injects_model_even_with_acp_model_switching() {
]
);
}
// ── write_persona_engram_to_db tests ──────────────────────────────────────
use super::write_persona_engram_to_db;
/// Build a record fixture with a real nsec for engram tests.
fn engram_fixture() -> ManagedAgentRecord {
// Generate a real key pair so engram crypto works.
let keys = nostr::Keys::generate();
let mut rec = fixture(RespondTo::Anyone, vec![], Some("tag".into()));
rec.private_key_nsec = keys.secret_key().to_secret_hex();
rec.pubkey = keys.public_key().to_hex();
rec.persona_id = Some("test-persona".into());
rec.system_prompt = Some("You are a test agent.".into());
rec.model = Some("claude-opus-4".into());
rec.updated_at = "2026-06-10T14:00:00Z".into();
rec
}
#[test]
fn engram_write_stores_valid_event_in_retention() {
use crate::managed_agents::retention::{get_retained_event, open_retention_db};
use sprout_core::kind::KIND_AGENT_ENGRAM;
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("retention.db");
let record = engram_fixture();
// Owner is a separate key.
let owner_keys = nostr::Keys::generate();
let owner_hex = owner_keys.public_key().to_hex();
write_persona_engram_to_db(
&db_path,
&record,
Some(&owner_hex),
Some("You are a test agent."),
Some("claude-opus-4"),
Some("anthropic"),
)
.expect("engram write should succeed");
// Verify the row exists in the DB.
let conn = open_retention_db(&db_path).unwrap();
let agent_keys = nostr::Keys::parse(&record.private_key_nsec).unwrap();
let k_c =
sprout_core::engram::conversation_key(agent_keys.secret_key(), &owner_keys.public_key());
let d = sprout_core::engram::d_tag(&k_c, "mem/persona-snapshot");
let row = get_retained_event(&conn, KIND_AGENT_ENGRAM, &record.pubkey, &d)
.unwrap()
.expect("should find retained engram");
assert_eq!(row.kind, KIND_AGENT_ENGRAM);
assert_eq!(row.pubkey, record.pubkey);
assert!(row.pending_sync);
}
#[test]
fn engram_write_produces_decryptable_event() {
use sprout_core::engram;
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("retention.db");
let record = engram_fixture();
let owner_keys = nostr::Keys::generate();
let owner_hex = owner_keys.public_key().to_hex();
write_persona_engram_to_db(
&db_path,
&record,
Some(&owner_hex),
Some("You are a test agent."),
Some("claude-opus-4"),
Some("anthropic"),
)
.unwrap();
// Read back the raw_event and decrypt it.
let conn = crate::managed_agents::retention::open_retention_db(&db_path).unwrap();
let agent_keys = nostr::Keys::parse(&record.private_key_nsec).unwrap();
let k_c = engram::conversation_key(agent_keys.secret_key(), &owner_keys.public_key());
let d = engram::d_tag(&k_c, "mem/persona-snapshot");
let row = crate::managed_agents::retention::get_retained_event(
&conn,
sprout_core::kind::KIND_AGENT_ENGRAM,
&record.pubkey,
&d,
)
.unwrap()
.unwrap();
let event: nostr::Event = serde_json::from_str(&row.raw_event).unwrap();
// Decrypt as the owner.
let body = engram::validate_and_decrypt(
&event,
&agent_keys.public_key(),
&owner_keys.public_key(),
owner_keys.secret_key(),
&agent_keys.public_key(),
)
.expect("owner should be able to decrypt");
match body {
engram::Body::Memory { slug, value } => {
assert_eq!(slug, "mem/persona-snapshot");
let value_str = value.expect("should have a value");
let parsed: serde_json::Value = serde_json::from_str(&value_str).unwrap();
assert_eq!(parsed["persona_id"], "test-persona");
assert_eq!(parsed["system_prompt"], "You are a test agent.");
assert_eq!(parsed["model"], "claude-opus-4");
assert_eq!(parsed["provider"], "anthropic");
assert_eq!(parsed["source_version"], "2026-06-10T14:00:00Z");
}
_ => panic!("expected Body::Memory"),
}
}
#[test]
fn engram_write_skips_when_no_owner() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("retention.db");
let record = engram_fixture();
let result = write_persona_engram_to_db(
&db_path,
&record,
None, // no owner
Some("prompt"),
None,
None,
);
assert!(result.is_err());
assert!(result.unwrap_err().contains("no owner pubkey"));
}
#[test]
fn engram_write_is_idempotent() {
use crate::managed_agents::retention::{get_retained_event, open_retention_db};
use sprout_core::kind::KIND_AGENT_ENGRAM;
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("retention.db");
let record = engram_fixture();
let owner_keys = nostr::Keys::generate();
let owner_hex = owner_keys.public_key().to_hex();
// Write twice — second write should succeed (upsert with >= created_at).
write_persona_engram_to_db(
&db_path,
&record,
Some(&owner_hex),
Some("prompt v1"),
None,
None,
)
.unwrap();
write_persona_engram_to_db(
&db_path,
&record,
Some(&owner_hex),
Some("prompt v2"),
None,
None,
)
.unwrap();
// Only one row should exist (same d_tag).
let conn = open_retention_db(&db_path).unwrap();
let agent_keys = nostr::Keys::parse(&record.private_key_nsec).unwrap();
let k_c =
sprout_core::engram::conversation_key(agent_keys.secret_key(), &owner_keys.public_key());
let d = sprout_core::engram::d_tag(&k_c, "mem/persona-snapshot");
let row = get_retained_event(&conn, KIND_AGENT_ENGRAM, &record.pubkey, &d)
.unwrap()
.expect("should find row");
// The second write wins (newer created_at).
assert!(row.created_at > 0);
}