From 2887b9727212f5b4b50f17d3a972e6ccad5407bb Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 8 Jul 2026 14:12:15 -0600 Subject: [PATCH 1/3] feat(relay,desktop): canonicalize agent definitions on kind:30175 (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kind:30175 becomes the single canonical agent-definition event; kind:30177 slims to instance-only state. Spec (NIP-AP): display_name is the sole required content field — system_prompt is optional so a definition can be pure configuration; four behavioral fields added as definition-level defaults (respond_to + allowlist, mcp_toolsets, parallelism; copied onto instances at creation); "Slimming: kind:30177" section replaces the fat projection — writers omit the definition quad (system_prompt/model/provider/persona_source_version) for definition-linked instances, which resolve those through their definition. 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. The exception retires when every record is definition-backed (B5 backfill). Mixed-version note documents that pre-revision clients drop prompt-less 30175s on parse (benign divergence, logged). Hash stability: PersonaEventContent widens with skip_serializing_if discipline and the writer always emits Some(prompt), so pre-revision content bytes — and persona_content_hash, the drift-badge basis — are unchanged on upgrade. Guarded by a fixture-bytes invariance test (real old-shape JSON, not a round-trip through the new struct). Inbound mirror: apply_inbound_managed_agent skips the definition quad for definition-linked events (absent means not-carried, never clear) and applies it unconditionally for definition-less ones. Boot reconcile republishes each definition-linked agent once (fat -> slim content change); second boot is a proven no-op. Relay: envelope validation unchanged; conformance tests prove prompt-less and behavioral-field 30175s ingest, and the legacy fat 30177 shape stays accepted. Frozen-shape renegotiation (disclosed): PersonaEventContent gains fields; ManagedAgentRecord is untouched. Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- crates/buzz-relay/src/handlers/ingest.rs | 25 ++++++ .../tests/e2e_managed_agent.rs | 45 +++++++++- .../src/commands/personas/inbound_tests.rs | 49 +++++++++- .../src-tauri/src/commands/personas/mod.rs | 16 +++- .../src/managed_agents/agent_events.rs | 90 +++++++++++++++++-- .../src/managed_agents/persona_events.rs | 82 +++++++++++++---- .../src/managed_agents/reconcile/tests.rs | 38 ++++++++ docs/nips/NIP-AP.md | 70 +++++++++++++-- 8 files changed, 375 insertions(+), 40 deletions(-) diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 4b348a5205..5833caafd4 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -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"]]); diff --git a/crates/buzz-test-client/tests/e2e_managed_agent.rs b/crates/buzz-test-client/tests/e2e_managed_agent.rs index a84ab185a5..4303c21b7f 100644 --- a/crates/buzz-test-client/tests/e2e_managed_agent.rs +++ b/crates/buzz-test-client/tests/e2e_managed_agent.rs @@ -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`), diff --git a/desktop/src-tauri/src/commands/personas/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound_tests.rs index 8d43e0282f..f4fdd42846 100644 --- a/desktop/src-tauri/src/commands/personas/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound_tests.rs @@ -268,14 +268,55 @@ 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", + "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())); } #[test] diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index 4f91c9adfe..76230b5d00 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -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; diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index 30c0a88e34..b4931cbc23 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -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 diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index 03a20a9f96..4f9275bab5 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -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, #[serde(default, skip_serializing_if = "Option::is_none")] pub avatar_url: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -34,6 +39,17 @@ pub struct PersonaEventContent { pub provider: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub name_pool: Vec, + /// 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, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub respond_to_allowlist: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mcp_toolsets: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parallelism: Option, } /// Derive the d-tag (persona slug) from a `PersonaRecord`. @@ -116,15 +132,9 @@ pub fn monotonic_created_at(prior_head_created_at: Option) -> nostr::Timest /// /// Returns an unsigned `EventBuilder` — the caller signs and submits. pub fn build_persona_event(record: &PersonaRecord) -> Result { - 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 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,18 @@ 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(), + respond_to: None, + respond_to_allowlist: Vec::new(), + mcp_toolsets: None, + parallelism: None, } } @@ -533,12 +550,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 +567,27 @@ 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" + ); + // 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. @@ -642,11 +684,15 @@ mod tests { 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 +705,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) diff --git a/desktop/src-tauri/src/managed_agents/reconcile/tests.rs b/desktop/src-tauri/src/managed_agents/reconcile/tests.rs index 933f21e42b..d42948fdb3 100644 --- a/desktop/src-tauri/src/managed_agents/reconcile/tests.rs +++ b/desktop/src-tauri/src/managed_agents/reconcile/tests.rs @@ -219,3 +219,41 @@ 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]); + + // 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" + ); + 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)" + ); +} diff --git a/docs/nips/NIP-AP.md b/docs/nips/NIP-AP.md index 50ca53ba26..dab883e9c7 100644 --- a/docs/nips/NIP-AP.md +++ b/docs/nips/NIP-AP.md @@ -62,12 +62,16 @@ The `content` field is a **plaintext** (unencrypted) JSON object: ```jsonc { "display_name": "", - "system_prompt": "", + "system_prompt": "", "avatar_url": "", "runtime": "", "model": "", "provider": "", - "name_pool": ["", ...] + "name_pool": ["", ...], + "respond_to": "", + "respond_to_allowlist": ["<64-hex pubkey>", ...], + "mcp_toolsets": "", + "parallelism": "" } ``` @@ -75,18 +79,28 @@ 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` | 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[] | `[]` | Allowlisted author pubkeys (64-char lowercase hex) when `respond_to` is `"allowlist"`. Ignored otherwise. | +| `mcp_toolsets` | string \| null | `null` | MCP toolset selector string passed to spawned instances. | +| `parallelism` | integer \| null | `null` | 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 deprecated kind:30177 projection (see +"Deprecation: kind:30177" below). Unknown fields MUST be ignored by readers (forward compatibility). @@ -155,6 +169,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 +260,14 @@ id = ``` -### 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 +275,7 @@ kind = 30175 pubkey = 79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 created_at = 1700000001 tags = [["d", "minimal"]] -content = {"display_name":"Minimal","system_prompt":"Hello."} +content = {"display_name":"Minimal"} id = sig = ``` From a95c3ca0a5cf033881dd6e861a8e06463e4bbf37 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 8 Jul 2026 14:24:42 -0600 Subject: [PATCH 2/3] test(desktop): seed a fat retained row in the republish-wave test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer note: starting from a fresh retention DB made first-boot "1" the ordinary fresh-record retain — the fat->slim transition itself wasn't distinctly exercised. Seed a synced legacy-fat row first, so the republish is provably the content change, and assert pending_sync. Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- .../src/managed_agents/reconcile/tests.rs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/desktop/src-tauri/src/managed_agents/reconcile/tests.rs b/desktop/src-tauri/src/managed_agents/reconcile/tests.rs index d42948fdb3..797492fa90 100644 --- a/desktop/src-tauri/src/managed_agents/reconcile/tests.rs +++ b/desktop/src-tauri/src/managed_agents/reconcile/tests.rs @@ -232,6 +232,35 @@ fn slimming_republish_wave_is_one_time() { 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); @@ -248,6 +277,7 @@ fn slimming_republish_wave_is_one_time() { !row.content.contains("system_prompt"), "definition-linked retained content must be the slimmed shape" ); + assert!(row.pending_sync, "slimmed rewrite must queue for publish"); drop(conn); // Second boot: identical projection — a true no-op, no republish loop. From b558b6e0ba32f0acf0cdc717ebe33cb989199f9a Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 8 Jul 2026 14:35:32 -0600 Subject: [PATCH 3/3] fix(spec,tests): mark behavioral defaults reserved; close review test gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings (Will): - Spec/impl divergence: NIP-AP declared the four behavioral fields as live definition-level defaults, but the desktop path parses-then-drops them (PersonaRecord carries none; outbound hardcodes absent). Narrowed the spec to reserved/parsed-but-not-yet-applied (activates with the create-path unification) and locked the staged behavior with behavioral_defaults_are_staged_not_applied — activating the fields later must consciously break a test. - Republish-wave test now asserts all four slimmed fields absent, not just system_prompt. - Definition-less inbound apply now covers persona_source_version (the fourth quad field). - Relay coverage now exercises the real ingest path: two ignored e2e tests publish a prompt-less and a behavioral-fields 30175 through the live relay and assert byte-for-byte round-trip, complementing the envelope-validator unit tests. - Hash-stability nit cases added: empty prompt and the minimal old-writer shape both round-trip byte-identically. Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- crates/buzz-test-client/tests/e2e_persona.rs | 83 +++++++++++++++++++ .../src/commands/personas/inbound_tests.rs | 6 ++ .../src/managed_agents/persona_events.rs | 59 +++++++++++++ .../src/managed_agents/reconcile/tests.rs | 13 +++ docs/nips/NIP-AP.md | 20 +++-- 5 files changed, 175 insertions(+), 6 deletions(-) diff --git a/crates/buzz-test-client/tests/e2e_persona.rs b/crates/buzz-test-client/tests/e2e_persona.rs index 5054018869..058609f371 100644 --- a/crates/buzz-test-client/tests/e2e_persona.rs +++ b/crates/buzz-test-client/tests/e2e_persona.rs @@ -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() { diff --git a/desktop/src-tauri/src/commands/personas/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound_tests.rs index f4fdd42846..4748d17604 100644 --- a/desktop/src-tauri/src/commands/personas/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound_tests.rs @@ -297,6 +297,7 @@ fn inbound_definition_less_agent_applies_quad() { "model": "remote-model", "provider": "remote-provider", "mcp_toolsets": "remote", + "persona_source_version": "remote-version", "parallelism": 99, "respond_to": "anyone", "respond_to_allowlist": ["deadbeef"], @@ -317,6 +318,11 @@ fn inbound_definition_less_agent_applies_quad() { 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] diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index 4f9275bab5..d9722e2293 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -300,6 +300,10 @@ pub fn persona_event_content(record: &PersonaRecord) -> PersonaEventContent { 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, @@ -588,6 +592,17 @@ mod tests { "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. @@ -679,6 +694,50 @@ 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::::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 { diff --git a/desktop/src-tauri/src/managed_agents/reconcile/tests.rs b/desktop/src-tauri/src/managed_agents/reconcile/tests.rs index 797492fa90..5af05f99d6 100644 --- a/desktop/src-tauri/src/managed_agents/reconcile/tests.rs +++ b/desktop/src-tauri/src/managed_agents/reconcile/tests.rs @@ -277,6 +277,19 @@ fn slimming_republish_wave_is_one_time() { !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); diff --git a/docs/nips/NIP-AP.md b/docs/nips/NIP-AP.md index dab883e9c7..6427ab9caf 100644 --- a/docs/nips/NIP-AP.md +++ b/docs/nips/NIP-AP.md @@ -91,16 +91,24 @@ The `content` field is a **plaintext** (unencrypted) JSON object: | `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 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` | 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[] | `[]` | Allowlisted author pubkeys (64-char lowercase hex) when `respond_to` is `"allowlist"`. Ignored otherwise. | -| `mcp_toolsets` | string \| null | `null` | MCP toolset selector string passed to spawned instances. | -| `parallelism` | integer \| null | `null` | Default max concurrent turns for spawned instances. `null` defers to the client default. | +| `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 deprecated kind:30177 projection (see -"Deprecation: kind:30177" below). +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).