mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Will Pfleger
parent
a2e9c8c077
commit
d3f45d3e3f
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user