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:
Wes
2026-07-08 14:49:44 -06:00
committed by GitHub
parent fd5d04de05
commit b2c63291f1
9 changed files with 574 additions and 40 deletions

View File

@@ -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"]]);

View File

@@ -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`),

View File

@@ -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() {

View File

@@ -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]

View File

@@ -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;

View File

@@ -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

View File

@@ -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)

View File

@@ -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)"
);
}

View File

@@ -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>
```