mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(relay,desktop): canonicalize agent definitions on kind:30175 (Phase 2) (#1655)
Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -3179,6 +3179,31 @@ mod tests {
|
||||
assert!(validate_persona_envelope(&ev).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_envelope_accepts_promptless_content() {
|
||||
// Unified agent model: system_prompt is optional — a definition can be
|
||||
// pure configuration. The relay validates only the envelope, so a
|
||||
// prompt-less body must ingest identically to a full one.
|
||||
let ev = make_event_with_tags(
|
||||
KIND_PERSONA,
|
||||
r#"{"display_name":"config-only"}"#,
|
||||
&[&["d", "config-only"]],
|
||||
);
|
||||
assert!(validate_persona_envelope(&ev).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_envelope_accepts_behavioral_fields() {
|
||||
// Widened content (respond_to / mcp_toolsets / parallelism) is opaque
|
||||
// to the relay — unknown-field tolerance is the contract.
|
||||
let ev = make_event_with_tags(
|
||||
KIND_PERSONA,
|
||||
r#"{"display_name":"x","respond_to":"owner-only","respond_to_allowlist":[],"mcp_toolsets":"default","parallelism":2}"#,
|
||||
&[&["d", "behavioral"]],
|
||||
);
|
||||
assert!(validate_persona_envelope(&ev).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_envelope_accepts_single_char() {
|
||||
let ev = make_persona(&[&["d", "a"]]);
|
||||
|
||||
@@ -41,9 +41,29 @@ fn sub_id(name: &str) -> String {
|
||||
/// `desktop/src-tauri/src/managed_agents/agent_events.rs`). Built inline here
|
||||
/// because the e2e crate does not depend on the desktop crate; the
|
||||
/// projection-function exclusion contract is unit-tested in that module. This
|
||||
/// is exactly the field set the desktop publishes — secrets, the backend blob,
|
||||
/// env vars, and runtime fields are absent by construction.
|
||||
/// is exactly the field set the desktop publishes for a definition-less
|
||||
/// record (a definition-linked one omits the definition quad — the slimmed
|
||||
/// NIP-AP shape; the relay accepts both, as the legacy-fat test below proves) —
|
||||
/// secrets, the backend blob, env vars, and runtime fields are absent by
|
||||
/// construction.
|
||||
fn agent_projection_content(name: &str) -> String {
|
||||
serde_json::json!({
|
||||
"name": name,
|
||||
"system_prompt": "You are a test agent.",
|
||||
"model": "claude-opus-4",
|
||||
"provider": "anthropic",
|
||||
"mcp_toolsets": "default",
|
||||
"parallelism": 24,
|
||||
"respond_to": "allowlist",
|
||||
"respond_to_allowlist": ["79be667e"]
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// A legacy "fat" definition-linked projection (pre-slimming shape): carries
|
||||
/// both `persona_id` and the definition quad. Old clients still publish this;
|
||||
/// the relay must keep accepting it.
|
||||
fn legacy_fat_agent_projection_content(name: &str) -> String {
|
||||
serde_json::json!({
|
||||
"name": name,
|
||||
"persona_id": "persona-1",
|
||||
@@ -134,6 +154,27 @@ async fn test_managed_agent_publish_and_query() {
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
/// The relay keeps accepting the legacy "fat" definition-linked projection
|
||||
/// (pre-slimming shape) — old clients publish it during the transition.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_legacy_fat_agent_event_still_accepted() {
|
||||
let url = relay_url();
|
||||
let keys = Keys::generate();
|
||||
let d_tag = agent_d_tag();
|
||||
let content = legacy_fat_agent_projection_content("Legacy Agent");
|
||||
|
||||
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
||||
let event = agent_event(&keys, &d_tag, &content);
|
||||
let ok = client.send_event(event).await.expect("send legacy agent");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"relay rejected legacy fat managed-agent event: {}",
|
||||
ok.message
|
||||
);
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
/// The relay round-trips published content byte-for-byte: a projection-shaped
|
||||
/// body goes out, and the relay returns exactly those fields and nothing more.
|
||||
/// The body here is a hand-built secret-free literal (`agent_projection_content`),
|
||||
|
||||
@@ -95,6 +95,89 @@ async fn test_persona_publish_and_query() {
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
/// NIP-AP revision: a prompt-less definition (display_name only) must ingest
|
||||
/// through the REAL relay path and round-trip byte-for-byte — not just pass
|
||||
/// the envelope validator helper.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_promptless_persona_ingests_and_round_trips() {
|
||||
let url = relay_url();
|
||||
let keys = Keys::generate();
|
||||
let d_tag = format!("promptless-{}", &uuid::Uuid::new_v4().to_string()[..8]);
|
||||
let content = serde_json::json!({ "display_name": "Config Only" }).to_string();
|
||||
|
||||
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
||||
let event = persona_event(&keys, &d_tag, &content);
|
||||
let ok = client.send_event(event).await.expect("send promptless");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"relay rejected prompt-less persona: {}",
|
||||
ok.message
|
||||
);
|
||||
|
||||
let sid = sub_id("promptless");
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(PERSONA_KIND))
|
||||
.author(keys.public_key())
|
||||
.custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag.as_str()]);
|
||||
client
|
||||
.subscribe(&sid, vec![filter])
|
||||
.await
|
||||
.expect("subscribe");
|
||||
let events = client
|
||||
.collect_until_eose(&sid, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("collect");
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].content, content, "byte-for-byte round-trip");
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
/// NIP-AP revision: the reserved behavioral fields ingest through the real
|
||||
/// relay path as opaque content and round-trip byte-for-byte.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_behavioral_fields_persona_ingests_and_round_trips() {
|
||||
let url = relay_url();
|
||||
let keys = Keys::generate();
|
||||
let d_tag = format!("behavioral-{}", &uuid::Uuid::new_v4().to_string()[..8]);
|
||||
let content = serde_json::json!({
|
||||
"display_name": "Behavioral",
|
||||
"system_prompt": "p",
|
||||
"respond_to": "owner-only",
|
||||
"respond_to_allowlist": [],
|
||||
"mcp_toolsets": "default",
|
||||
"parallelism": 2
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
||||
let event = persona_event(&keys, &d_tag, &content);
|
||||
let ok = client.send_event(event).await.expect("send behavioral");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"relay rejected behavioral-fields persona: {}",
|
||||
ok.message
|
||||
);
|
||||
|
||||
let sid = sub_id("behavioral");
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(PERSONA_KIND))
|
||||
.author(keys.public_key())
|
||||
.custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag.as_str()]);
|
||||
client
|
||||
.subscribe(&sid, vec![filter])
|
||||
.await
|
||||
.expect("subscribe");
|
||||
let events = client
|
||||
.collect_until_eose(&sid, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("collect");
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].content, content, "byte-for-byte round-trip");
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_persona_nip33_replacement_newer_wins() {
|
||||
|
||||
@@ -268,14 +268,61 @@ fn inbound_managed_agent_drops_injected_secrets_and_harness() {
|
||||
] {
|
||||
assert!(!json.contains(needle), "injected value leaked: {needle}");
|
||||
}
|
||||
// Projected fields ARE updated from the inbound event.
|
||||
// Instance-level projected fields ARE updated from the inbound event.
|
||||
assert_eq!(a.name, "Remote Agent");
|
||||
assert_eq!(a.system_prompt, Some("remote prompt".to_string()));
|
||||
assert_eq!(a.model, Some("remote-model".to_string()));
|
||||
assert_eq!(a.provider, Some("remote-provider".to_string()));
|
||||
assert_eq!(a.parallelism, 99);
|
||||
assert_eq!(a.respond_to, crate::managed_agents::RespondTo::Anyone);
|
||||
assert_eq!(a.respond_to_allowlist, vec!["deadbeef".to_string()]);
|
||||
// Definition-linked inbound (persona_id present): the definition quad is
|
||||
// NOT applied — those fields resolve through the kind:30175 definition,
|
||||
// and absent-on-the-wire must never clear a local snapshot.
|
||||
assert_eq!(
|
||||
a.system_prompt,
|
||||
Some("local prompt".to_string()),
|
||||
"linked inbound must not touch the local prompt snapshot"
|
||||
);
|
||||
}
|
||||
|
||||
/// Definition-less inbound (persona_id absent) still applies the definition
|
||||
/// quad unconditionally — the record is its own definition and the wire is
|
||||
/// its sync channel.
|
||||
#[test]
|
||||
fn inbound_definition_less_agent_applies_quad() {
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag};
|
||||
// Same wire shape as the hostile fixture, minus persona_id — a
|
||||
// definition-less instance syncing its own definition fields.
|
||||
let content = serde_json::json!({
|
||||
"name": "Remote Agent",
|
||||
"system_prompt": "remote prompt",
|
||||
"model": "remote-model",
|
||||
"provider": "remote-provider",
|
||||
"mcp_toolsets": "remote",
|
||||
"persona_source_version": "remote-version",
|
||||
"parallelism": 99,
|
||||
"respond_to": "anyone",
|
||||
"respond_to_allowlist": ["deadbeef"],
|
||||
});
|
||||
let keys = Keys::generate();
|
||||
let event = EventBuilder::new(Kind::Custom(30177), content.to_string())
|
||||
.tags(vec![Tag::parse(["d", AGENT_PUBKEY]).unwrap()])
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
|
||||
let content =
|
||||
crate::managed_agents::agent_events::managed_agent_content_from_event(&event).unwrap();
|
||||
let mut agents = vec![local_agent()];
|
||||
apply_inbound_managed_agent(&mut agents, AGENT_PUBKEY, content);
|
||||
|
||||
let a = &agents[0];
|
||||
assert_eq!(a.persona_id, None);
|
||||
assert_eq!(a.system_prompt, Some("remote prompt".to_string()));
|
||||
assert_eq!(a.model, Some("remote-model".to_string()));
|
||||
assert_eq!(a.provider, Some("remote-provider".to_string()));
|
||||
assert_eq!(
|
||||
a.persona_source_version,
|
||||
Some("remote-version".to_string()),
|
||||
"all four quad fields must apply on a definition-less sync"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -749,12 +749,20 @@ fn apply_inbound_managed_agent(
|
||||
) {
|
||||
if let Some(local) = agents.iter_mut().find(|record| record.pubkey == d_tag) {
|
||||
local.name = inbound.name;
|
||||
// Mirror of the slimmed writer (agent_event_content): a
|
||||
// definition-linked event omits the definition quad because those
|
||||
// fields resolve through the kind:30175 definition — absent means
|
||||
// "not carried", never "clear". Definition-less events still carry
|
||||
// the quad and apply it unconditionally (including clears).
|
||||
let definition_linked = inbound.persona_id.is_some();
|
||||
local.persona_id = inbound.persona_id;
|
||||
local.system_prompt = inbound.system_prompt;
|
||||
local.model = inbound.model;
|
||||
local.provider = inbound.provider;
|
||||
if !definition_linked {
|
||||
local.system_prompt = inbound.system_prompt;
|
||||
local.model = inbound.model;
|
||||
local.provider = inbound.provider;
|
||||
local.persona_source_version = inbound.persona_source_version;
|
||||
}
|
||||
local.mcp_toolsets = inbound.mcp_toolsets;
|
||||
local.persona_source_version = inbound.persona_source_version;
|
||||
local.parallelism = inbound.parallelism;
|
||||
local.respond_to = inbound.respond_to;
|
||||
local.respond_to_allowlist = inbound.respond_to_allowlist;
|
||||
|
||||
@@ -70,14 +70,39 @@ pub struct ManagedAgentEventContent {
|
||||
/// operational start/stop produces an identical projection and never
|
||||
/// republishes.
|
||||
pub fn agent_event_content(record: &ManagedAgentRecord) -> ManagedAgentEventContent {
|
||||
// Slimmed projection (NIP-AP "Slimming: kind:30177"): definition-linked
|
||||
// instances resolve prompt/model/provider/source_version through their
|
||||
// kind:30175 definition, so those fields are omitted from the wire.
|
||||
// Definition-less instances ARE their own definition and keep emitting
|
||||
// the quad — old readers parse a slimmed event successfully and would
|
||||
// otherwise overwrite their local snapshot with absent values, with no
|
||||
// restore path. This branch retires once every record is
|
||||
// definition-backed (B5 backfill).
|
||||
let definition_linked = record.persona_id.is_some();
|
||||
ManagedAgentEventContent {
|
||||
name: record.name.clone(),
|
||||
persona_id: record.persona_id.clone(),
|
||||
system_prompt: record.system_prompt.clone(),
|
||||
model: record.model.clone(),
|
||||
provider: record.provider.clone(),
|
||||
system_prompt: if definition_linked {
|
||||
None
|
||||
} else {
|
||||
record.system_prompt.clone()
|
||||
},
|
||||
model: if definition_linked {
|
||||
None
|
||||
} else {
|
||||
record.model.clone()
|
||||
},
|
||||
provider: if definition_linked {
|
||||
None
|
||||
} else {
|
||||
record.provider.clone()
|
||||
},
|
||||
mcp_toolsets: record.mcp_toolsets.clone(),
|
||||
persona_source_version: record.persona_source_version.clone(),
|
||||
persona_source_version: if definition_linked {
|
||||
None
|
||||
} else {
|
||||
record.persona_source_version.clone()
|
||||
},
|
||||
parallelism: record.parallelism,
|
||||
respond_to: record.respond_to,
|
||||
respond_to_allowlist: record.respond_to_allowlist.clone(),
|
||||
@@ -266,7 +291,41 @@ mod tests {
|
||||
assert!(json.contains("\"name\""));
|
||||
assert!(json.contains("Test Agent"));
|
||||
assert!(json.contains("persona_id"));
|
||||
assert!(json.contains("system_prompt"));
|
||||
// Slimmed projection: a definition-linked record resolves its prompt
|
||||
// through the definition, so the wire must NOT carry it.
|
||||
assert!(
|
||||
!json.contains("system_prompt"),
|
||||
"definition-linked projection must omit system_prompt"
|
||||
);
|
||||
}
|
||||
|
||||
/// Slimming (NIP-AP): definition-linked records omit the definition quad;
|
||||
/// definition-less records keep emitting it (they ARE their own
|
||||
/// definition — old readers would otherwise wipe fields with no restore
|
||||
/// path).
|
||||
#[test]
|
||||
fn projection_slims_definition_quad_only_when_linked() {
|
||||
let linked = sample_agent(); // persona_id: Some
|
||||
let json = serde_json::to_string(&agent_event_content(&linked)).unwrap();
|
||||
assert!(!json.contains("system_prompt"));
|
||||
assert!(!json.contains("\"model\""));
|
||||
assert!(!json.contains("\"provider\""));
|
||||
assert!(!json.contains("persona_source_version"));
|
||||
// Instance fields stay on the wire.
|
||||
assert!(json.contains("parallelism"));
|
||||
assert!(json.contains("respond_to"));
|
||||
assert!(json.contains("mcp_toolsets"));
|
||||
|
||||
let mut standalone = sample_agent();
|
||||
standalone.persona_id = None;
|
||||
let json = serde_json::to_string(&agent_event_content(&standalone)).unwrap();
|
||||
assert!(json.contains("system_prompt"), "standalone keeps prompt");
|
||||
assert!(json.contains("\"model\""), "standalone keeps model");
|
||||
assert!(json.contains("\"provider\""), "standalone keeps provider");
|
||||
assert!(
|
||||
json.contains("persona_source_version"),
|
||||
"standalone keeps source_version"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -297,10 +356,29 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn projection_changes_on_meaningful_edit() {
|
||||
// Instance-level edit surfaces for every record.
|
||||
let agent = sample_agent();
|
||||
let mut edited = agent.clone();
|
||||
edited.system_prompt = Some("A different prompt.".to_string());
|
||||
edited.parallelism += 1;
|
||||
assert_ne!(agent_event_content(&agent), agent_event_content(&edited));
|
||||
|
||||
// Definition-level edit surfaces only for definition-less records —
|
||||
// linked records resolve the prompt through their definition.
|
||||
let mut standalone = sample_agent();
|
||||
standalone.persona_id = None;
|
||||
let mut edited = standalone.clone();
|
||||
edited.system_prompt = Some("A different prompt.".to_string());
|
||||
assert_ne!(
|
||||
agent_event_content(&standalone),
|
||||
agent_event_content(&edited)
|
||||
);
|
||||
let mut linked_edit = sample_agent();
|
||||
linked_edit.system_prompt = Some("A different prompt.".to_string());
|
||||
assert_eq!(
|
||||
agent_event_content(&sample_agent()),
|
||||
agent_event_content(&linked_edit),
|
||||
"a linked record's local prompt snapshot is not wire state"
|
||||
);
|
||||
}
|
||||
|
||||
/// The inbound structural guard: a foreign event whose content JSON crams
|
||||
|
||||
@@ -23,7 +23,12 @@ use crate::app_state::AppState;
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PersonaEventContent {
|
||||
pub display_name: String,
|
||||
pub system_prompt: String,
|
||||
/// Optional since the unified agent model (NIP-AP revision): a definition
|
||||
/// can be pure configuration. Writers emit `Some` whenever the record has
|
||||
/// a prompt (including the empty string) so pre-revision content bytes —
|
||||
/// and therefore `persona_content_hash` — are unchanged.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub system_prompt: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub avatar_url: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -34,6 +39,17 @@ pub struct PersonaEventContent {
|
||||
pub provider: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub name_pool: Vec<String>,
|
||||
/// Definition-level defaults copied onto instances at creation
|
||||
/// (NIP-AP behavioral fields). Absent = defer to client defaults;
|
||||
/// `skip_serializing_if` keeps pre-revision hashes stable.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub respond_to: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub respond_to_allowlist: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mcp_toolsets: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub parallelism: Option<u32>,
|
||||
}
|
||||
|
||||
/// Derive the d-tag (persona slug) from a `PersonaRecord`.
|
||||
@@ -116,15 +132,9 @@ pub fn monotonic_created_at(prior_head_created_at: Option<i64>) -> nostr::Timest
|
||||
///
|
||||
/// Returns an unsigned `EventBuilder` — the caller signs and submits.
|
||||
pub fn build_persona_event(record: &PersonaRecord) -> Result<EventBuilder, String> {
|
||||
let content = 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(),
|
||||
};
|
||||
// Single projection point — persona_event_content owns the field mapping
|
||||
// (and the hash-stability rules that come with it).
|
||||
let content = persona_event_content(record);
|
||||
|
||||
let content_json = serde_json::to_string(&content)
|
||||
.map_err(|e| format!("failed to serialize persona content: {e}"))?;
|
||||
@@ -173,7 +183,7 @@ pub fn persona_from_event(event: &nostr::Event) -> Result<PersonaRecord, String>
|
||||
id: d_tag.clone(),
|
||||
display_name: content.display_name,
|
||||
avatar_url: content.avatar_url,
|
||||
system_prompt: content.system_prompt,
|
||||
system_prompt: content.system_prompt.unwrap_or_default(),
|
||||
runtime: content.runtime,
|
||||
model: content.model,
|
||||
provider: content.provider,
|
||||
@@ -282,11 +292,22 @@ 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(),
|
||||
// Always Some — including for an empty prompt — so pre-revision
|
||||
// records serialize byte-identically and persona_content_hash is
|
||||
// stable across the upgrade (drift badges must not flip).
|
||||
system_prompt: Some(record.system_prompt.clone()),
|
||||
runtime: record.runtime.clone(),
|
||||
model: record.model.clone(),
|
||||
provider: record.provider.clone(),
|
||||
name_pool: record.name_pool.clone(),
|
||||
// NIP-AP behavioral defaults: RESERVED this release — parsed at the
|
||||
// wire layer but not yet carried on PersonaRecord or applied at
|
||||
// instance creation (that lands with the create-path unification).
|
||||
// Guarded by `behavioral_defaults_are_staged_not_applied`.
|
||||
respond_to: None,
|
||||
respond_to_allowlist: Vec::new(),
|
||||
mcp_toolsets: None,
|
||||
parallelism: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,12 +554,16 @@ mod tests {
|
||||
|
||||
let content = PersonaEventContent {
|
||||
display_name: "Test Agent".to_string(),
|
||||
system_prompt: "You are a test assistant.".to_string(),
|
||||
system_prompt: Some("You are a test assistant.".to_string()),
|
||||
avatar_url: Some("https://example.com/avatar.png".to_string()),
|
||||
runtime: Some("goose".to_string()),
|
||||
model: Some("claude-opus-4".to_string()),
|
||||
provider: Some("anthropic".to_string()),
|
||||
name_pool: vec!["Alpha".to_string(), "Beta".to_string()],
|
||||
respond_to: None,
|
||||
respond_to_allowlist: Vec::new(),
|
||||
mcp_toolsets: None,
|
||||
parallelism: None,
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_string(&content).unwrap(),
|
||||
@@ -546,6 +571,38 @@ mod tests {
|
||||
"serialized content drifted from the NIP-AP Event 1 vector"
|
||||
);
|
||||
|
||||
// Hash invariance across the unified-model widening: REAL pre-revision
|
||||
// content bytes (fixture string, not a round-trip through the new
|
||||
// struct) must parse and re-serialize byte-identically, so
|
||||
// persona_content_hash — the drift-badge basis — is unchanged on
|
||||
// upgrade. A bare Option serializing "system_prompt":null would flip
|
||||
// every persona's hash fleet-wide.
|
||||
let parsed: PersonaEventContent = serde_json::from_str(VECTOR).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_string(&parsed).unwrap(),
|
||||
VECTOR,
|
||||
"pre-revision content bytes must survive a parse/serialize round-trip unchanged"
|
||||
);
|
||||
assert_eq!(
|
||||
persona_content_hash(&parsed),
|
||||
{
|
||||
use sha2::{Digest, Sha256};
|
||||
hex::encode(Sha256::digest(VECTOR.as_bytes()))
|
||||
},
|
||||
"persona_content_hash of pre-revision bytes must equal the direct digest"
|
||||
);
|
||||
|
||||
// Hash stability, adversarial shapes: the empty prompt and the
|
||||
// minimal old-writer body (display_name + system_prompt only) are the
|
||||
// two easiest regressions if the projection or skip attributes ever
|
||||
// change.
|
||||
const EMPTY_PROMPT: &str = r#"{"display_name":"X","system_prompt":""}"#;
|
||||
let parsed: PersonaEventContent = serde_json::from_str(EMPTY_PROMPT).unwrap();
|
||||
assert_eq!(serde_json::to_string(&parsed).unwrap(), EMPTY_PROMPT);
|
||||
const MINIMAL: &str = r#"{"display_name":"Minimal","system_prompt":"Hello."}"#;
|
||||
let parsed: PersonaEventContent = serde_json::from_str(MINIMAL).unwrap();
|
||||
assert_eq!(serde_json::to_string(&parsed).unwrap(), MINIMAL);
|
||||
|
||||
// An event built from this content carries the byte-exact vector as its
|
||||
// signed content, so a second implementer following the spec computes
|
||||
// the same NIP-01 id.
|
||||
@@ -637,16 +694,64 @@ mod tests {
|
||||
.all(|t| t.as_slice().first().map(String::as_str) != Some("e")));
|
||||
}
|
||||
|
||||
/// NIP-AP behavioral defaults are RESERVED this release: the wire type
|
||||
/// parses them (foreign data survives deserialization) but the local
|
||||
/// projection does not emit them and PersonaRecord does not carry them.
|
||||
/// This test locks the staged behavior so activating the fields later is
|
||||
/// a deliberate act — and documents that a foreign definition's
|
||||
/// behavioral values do NOT survive a local edit-and-republish cycle yet.
|
||||
#[test]
|
||||
fn behavioral_defaults_are_staged_not_applied() {
|
||||
const FOREIGN: &str = r#"{"display_name":"F","system_prompt":"p","respond_to":"anyone","respond_to_allowlist":["deadbeef"],"mcp_toolsets":"default","parallelism":4}"#;
|
||||
let parsed: PersonaEventContent = serde_json::from_str(FOREIGN).unwrap();
|
||||
// Wire layer preserves the fields...
|
||||
assert_eq!(parsed.respond_to.as_deref(), Some("anyone"));
|
||||
assert_eq!(parsed.parallelism, Some(4));
|
||||
// ...but the record round-trip drops them (staged, not applied).
|
||||
let record = persona_from_event_content_for_test(parsed);
|
||||
let reprojected = persona_event_content(&record);
|
||||
assert_eq!(reprojected.respond_to, None);
|
||||
assert_eq!(reprojected.respond_to_allowlist, Vec::<String>::new());
|
||||
assert_eq!(reprojected.mcp_toolsets, None);
|
||||
assert_eq!(reprojected.parallelism, None);
|
||||
}
|
||||
|
||||
/// Test-only bridge: build a PersonaRecord from parsed content the same
|
||||
/// way `persona_from_event` maps fields, without needing a signed event.
|
||||
fn persona_from_event_content_for_test(content: PersonaEventContent) -> PersonaRecord {
|
||||
PersonaRecord {
|
||||
id: "staged".to_string(),
|
||||
display_name: content.display_name,
|
||||
avatar_url: content.avatar_url,
|
||||
system_prompt: content.system_prompt.unwrap_or_default(),
|
||||
runtime: content.runtime,
|
||||
model: content.model,
|
||||
provider: content.provider,
|
||||
name_pool: content.name_pool,
|
||||
is_builtin: false,
|
||||
is_active: true,
|
||||
source_team: None,
|
||||
source_team_persona_slug: None,
|
||||
env_vars: BTreeMap::new(),
|
||||
created_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
updated_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_content_hash_is_deterministic() {
|
||||
let content = PersonaEventContent {
|
||||
display_name: "Test".to_string(),
|
||||
avatar_url: None,
|
||||
system_prompt: "Hello".to_string(),
|
||||
system_prompt: Some("Hello".to_string()),
|
||||
runtime: None,
|
||||
model: None,
|
||||
provider: None,
|
||||
name_pool: vec![],
|
||||
respond_to: None,
|
||||
respond_to_allowlist: Vec::new(),
|
||||
mcp_toolsets: None,
|
||||
parallelism: None,
|
||||
};
|
||||
let hash1 = persona_content_hash(&content);
|
||||
let hash2 = persona_content_hash(&content);
|
||||
@@ -659,14 +764,18 @@ mod tests {
|
||||
let content1 = PersonaEventContent {
|
||||
display_name: "Test".to_string(),
|
||||
avatar_url: None,
|
||||
system_prompt: "Hello".to_string(),
|
||||
system_prompt: Some("Hello".to_string()),
|
||||
runtime: None,
|
||||
model: None,
|
||||
provider: None,
|
||||
name_pool: vec![],
|
||||
respond_to: None,
|
||||
respond_to_allowlist: Vec::new(),
|
||||
mcp_toolsets: None,
|
||||
parallelism: None,
|
||||
};
|
||||
let mut content2 = content1.clone();
|
||||
content2.system_prompt = "Goodbye".to_string();
|
||||
content2.system_prompt = Some("Goodbye".to_string());
|
||||
assert_ne!(
|
||||
persona_content_hash(&content1),
|
||||
persona_content_hash(&content2)
|
||||
|
||||
@@ -219,3 +219,84 @@ fn monotonic_bump_supersedes_future_dated_head() {
|
||||
.unwrap();
|
||||
assert!(row.content.contains("New prompt after skew"));
|
||||
}
|
||||
|
||||
/// The slimming transition: a definition-linked record whose retained row
|
||||
/// holds the legacy fat projection republishes ONCE (the slimmed shape), and
|
||||
/// the second boot is a true no-op — the republish wave is one-time.
|
||||
#[test]
|
||||
fn slimming_republish_wave_is_one_time() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let keys = nostr::Keys::generate();
|
||||
let mut record = sample_record("e".repeat(64).as_str(), "agent-five");
|
||||
record.persona_id = Some("persona-1".to_string());
|
||||
record.persona_source_version = Some("abc123".to_string());
|
||||
write_store(&dir, &[record]);
|
||||
|
||||
// Seed a SYNCED legacy-fat retained row — the pre-upgrade state — so the
|
||||
// first-boot republish below is distinctly the fat→slim content change,
|
||||
// not the ordinary fresh-record retain.
|
||||
let fat_content = serde_json::json!({
|
||||
"name": "agent-five",
|
||||
"persona_id": "persona-1",
|
||||
"system_prompt": "You are a test agent.",
|
||||
"persona_source_version": "abc123",
|
||||
"parallelism": 1,
|
||||
"respond_to": "owner-only"
|
||||
})
|
||||
.to_string();
|
||||
{
|
||||
let conn = open_retention_db(&dir.path().join("retention.db")).unwrap();
|
||||
retain_event(
|
||||
&conn,
|
||||
&RetainedEvent {
|
||||
kind: KIND_MANAGED_AGENT,
|
||||
pubkey: keys.public_key().to_hex(),
|
||||
d_tag: "e".repeat(64),
|
||||
content: fat_content,
|
||||
created_at: 1,
|
||||
raw_event: String::new(),
|
||||
pending_sync: false,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// First boot after upgrade: projection content changed (fat -> slim) so
|
||||
// the agent republishes.
|
||||
assert_eq!(reconcile_agents_in_dir(dir.path(), &keys).unwrap(), 1);
|
||||
let conn = open_retention_db(&dir.path().join("retention.db")).unwrap();
|
||||
let row = get_retained_event(
|
||||
&conn,
|
||||
KIND_MANAGED_AGENT,
|
||||
&keys.public_key().to_hex(),
|
||||
&"e".repeat(64),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(
|
||||
!row.content.contains("system_prompt"),
|
||||
"definition-linked retained content must be the slimmed shape"
|
||||
);
|
||||
assert!(!row.content.contains("\"model\""), "model must be slimmed");
|
||||
assert!(
|
||||
!row.content.contains("\"provider\""),
|
||||
"provider must be slimmed"
|
||||
);
|
||||
assert!(
|
||||
!row.content.contains("persona_source_version"),
|
||||
"persona_source_version must be slimmed"
|
||||
);
|
||||
assert!(
|
||||
!row.content.contains("abc123"),
|
||||
"source version value must be absent"
|
||||
);
|
||||
assert!(row.pending_sync, "slimmed rewrite must queue for publish");
|
||||
drop(conn);
|
||||
|
||||
// Second boot: identical projection — a true no-op, no republish loop.
|
||||
assert_eq!(
|
||||
reconcile_agents_in_dir(dir.path(), &keys).unwrap(),
|
||||
0,
|
||||
"second boot must be a no-op (idempotence)"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,12 +62,16 @@ The `content` field is a **plaintext** (unencrypted) JSON object:
|
||||
```jsonc
|
||||
{
|
||||
"display_name": "<string>",
|
||||
"system_prompt": "<string>",
|
||||
"system_prompt": "<string | null>",
|
||||
"avatar_url": "<string | null>",
|
||||
"runtime": "<string | null>",
|
||||
"model": "<string | null>",
|
||||
"provider": "<string | null>",
|
||||
"name_pool": ["<string>", ...]
|
||||
"name_pool": ["<string>", ...],
|
||||
"respond_to": "<string | null>",
|
||||
"respond_to_allowlist": ["<64-hex pubkey>", ...],
|
||||
"mcp_toolsets": "<string | null>",
|
||||
"parallelism": "<integer | null>"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -75,18 +79,36 @@ The `content` field is a **plaintext** (unencrypted) JSON object:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `display_name` | string | Human-readable name for the persona. |
|
||||
| `system_prompt` | string | The system prompt injected into agent sessions. |
|
||||
| `display_name` | string | Human-readable name for the agent definition. |
|
||||
|
||||
### Optional fields
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `system_prompt` | string \| null | `null` | The system prompt injected into agent sessions. Optional since the unified agent model: a definition can be pure configuration (e.g. provider/model only). Readers MUST treat an absent or `null` prompt as "no prompt". |
|
||||
| `avatar_url` | string \| null | `null` | URL to an avatar image. |
|
||||
| `runtime` | string \| null | `null` | ACP runtime identifier (e.g. `"goose"`, `"claude-code"`). |
|
||||
| `model` | string \| null | `null` | Model identifier (e.g. `"claude-opus-4"`). |
|
||||
| `provider` | string \| null | `null` | Model provider (e.g. `"anthropic"`). |
|
||||
| `name_pool` | string[] | `[]` | Pool of display names for agent instances spawned from this persona. When non-empty, the spawning system picks a name from this pool for each new agent instance, enabling multiple concurrent agents from the same persona to have distinct identities. |
|
||||
| `name_pool` | string[] | `[]` | Pool of display names for agent instances spawned from this definition. When non-empty, the spawning system picks a name from this pool for each new agent instance, enabling multiple concurrent agents from the same definition to have distinct identities. |
|
||||
| `respond_to` | string \| null | `null` | **Reserved.** Default respond-to policy for instances spawned from this definition: `"anyone"`, `"owner-only"`, or `"allowlist"`. `null` defers to the client default. |
|
||||
| `respond_to_allowlist` | string[] | `[]` | **Reserved.** Allowlisted author pubkeys (64-char lowercase hex) when `respond_to` is `"allowlist"`. Ignored otherwise. |
|
||||
| `mcp_toolsets` | string \| null | `null` | **Reserved.** MCP toolset selector string passed to spawned instances. |
|
||||
| `parallelism` | integer \| null | `null` | **Reserved.** Default max concurrent turns for spawned instances. `null` defers to the client default. |
|
||||
|
||||
The behavioral fields (`respond_to`, `respond_to_allowlist`, `mcp_toolsets`,
|
||||
`parallelism`) are definition-level *defaults*: a spawned instance copies them
|
||||
at creation and may be reconfigured independently afterwards. They were
|
||||
previously carried only on the kind:30177 projection (see
|
||||
"Slimming: kind:30177" below).
|
||||
|
||||
**Status: reserved.** In the current implementation these four fields are
|
||||
*parsed but not yet applied*: readers tolerate and preserve them at the wire
|
||||
layer, but the local definition store does not yet carry them and writers do
|
||||
not emit them. The instance-copy-at-creation behavior activates in a
|
||||
subsequent release (the create-path unification). Until then a definition
|
||||
carrying these fields round-trips through the wire type but the values do not
|
||||
survive a local edit-and-republish cycle.
|
||||
|
||||
Unknown fields MUST be ignored by readers (forward compatibility).
|
||||
|
||||
@@ -155,6 +177,43 @@ Agents spawned from a persona MAY store a private snapshot at the reserved engra
|
||||
|
||||
The `mem/persona` slug conforms to [NIP-AE](NIP-AE.md)'s slug grammar and requires no amendment to that spec.
|
||||
|
||||
### Slimming: kind:30177 (instance state)
|
||||
|
||||
Kind:30177 is keyed by **agent pubkey** (one event per instance) while
|
||||
kind:30175 is keyed by **definition slug** — they occupy different key
|
||||
spaces and serve different roles. 30177 remains the per-instance
|
||||
cross-device sync channel; with the unified agent model it is **slimmed**
|
||||
to carry only instance-level state:
|
||||
|
||||
- Writers MUST NOT include definition-level fields
|
||||
(`system_prompt`, `model`, `provider`, `persona_source_version`) in new
|
||||
kind:30177 events **for definition-linked instances**. Those resolve
|
||||
through the linked kind:30175 definition. Writers continue to publish
|
||||
instance-level fields (name, linked definition id, `respond_to` +
|
||||
allowlist, `parallelism`, `mcp_toolsets`).
|
||||
- **Exception — definition-less instances:** an instance with no linked
|
||||
definition is its own definition; writers MUST keep emitting the
|
||||
definition-level fields for such instances. (Rationale: old readers
|
||||
parse a slimmed event successfully and would overwrite their local
|
||||
snapshot with absent values; a definition-linked instance self-heals
|
||||
from its definition at next spawn, but a definition-less one has no
|
||||
restore path.) This exception retires naturally once all instances are
|
||||
definition-backed.
|
||||
- Readers SHOULD continue to accept legacy "fat" kind:30177 events
|
||||
during the transition. Where the linked 30175 head and a legacy 30177
|
||||
event both carry a field, the 30175 head is authoritative.
|
||||
- Deletion/retention rules for kind:30177 are unchanged so historical
|
||||
tombstones keep working.
|
||||
|
||||
### Mixed-version note
|
||||
|
||||
Clients released before this revision require `system_prompt` in 30175
|
||||
content and will fail to parse (and therefore silently drop) prompt-less
|
||||
definitions published by newer clients. This is a benign divergence —
|
||||
old devices simply do not see new-style definitions until upgraded — not
|
||||
data corruption. Implementations SHOULD log dropped events rather than
|
||||
surface per-event errors.
|
||||
|
||||
### NIP-OA (Owner Attestation)
|
||||
|
||||
Agents spawned from a persona carry [NIP-OA](NIP-OA.md) owner attestation — an `auth` tag proving that `pubkey_o` authorized the agent's key. The persona event itself does not contain attestation; it is the *definition* from which attestation is issued at spawn time.
|
||||
@@ -209,11 +268,14 @@ id = <derived per NIP-01: sha256([0, pubkey, created_at, kind, tags
|
||||
sig = <BIP-340 Schnorr signature with aux=0x00…00>
|
||||
```
|
||||
|
||||
### Event 2 — minimal persona (required fields only)
|
||||
### Event 2 — minimal definition (required fields only)
|
||||
|
||||
A definition need not carry a prompt — pure-configuration definitions
|
||||
(e.g. provider/model presets) are valid:
|
||||
|
||||
```jsonc
|
||||
// Body:
|
||||
{"display_name":"Minimal","system_prompt":"Hello."}
|
||||
{"display_name":"Minimal"}
|
||||
```
|
||||
|
||||
```
|
||||
@@ -221,7 +283,7 @@ kind = 30175
|
||||
pubkey = 79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798
|
||||
created_at = 1700000001
|
||||
tags = [["d", "minimal"]]
|
||||
content = {"display_name":"Minimal","system_prompt":"Hello."}
|
||||
content = {"display_name":"Minimal"}
|
||||
id = <derived per NIP-01>
|
||||
sig = <BIP-340 Schnorr signature with aux=0x00…00>
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user