Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ sprout channels add-member --channel "$CHANNEL" --pubkey "$AGENT_PUBKEY" --role
export SPROUT_PRIVATE_KEY="$AGENT_SK"
export SPROUT_RELAY_URL=ws://localhost:3000 # match step 3 (e.g. ws://localhost:3030 if overridden)
export SPROUT_ACP_RESPOND_TO=anyone # default is owner-only; opens the gate for testing
export SPROUT_ACP_MEMORY=true # opt in to NIP-AE core-memory prompt injection
export SPROUT_ACP_MCP_COMMAND="$PWD/target/release/sprout-mcp-server" # explicit path beats $PATH
export GOOSE_MODE=auto # must be 'auto' or goose hangs on prompts

Expand Down
66 changes: 65 additions & 1 deletion crates/sprout-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,30 @@ pub struct CliArgs {
#[arg(long, env = "SPROUT_ACP_NO_TYPING")]
pub no_typing: bool,

/// Enable NIP-AE agent core memory injection.
///
/// Memory injection is off by default for now. When enabled, the harness
/// fetches the agent's per-session core engram and renders it as an
/// `[Agent Memory — core]` prompt section (or renders the onboarding nudge
/// when the relay confirms no core engram exists). The `sprout mem` CLI
/// and the relay's acceptance of kind:30174 engrams are unaffected — this
/// flag controls prompt-time injection in the ACP harness only.
#[arg(long, env = "SPROUT_ACP_MEMORY", conflicts_with = "no_memory")]
pub memory: bool,

/// Disable NIP-AE agent core memory injection.
///
/// Deprecated compatibility alias for the previous default-on behavior.
/// The flag/env var is still accepted, but memory injection is already off
/// unless `--memory` / `SPROUT_ACP_MEMORY=true` is provided.
#[arg(
long,
env = "SPROUT_ACP_NO_MEMORY",
conflicts_with = "memory",
hide = true
)]
pub no_memory: bool,

/// Desired LLM model ID. Applied to every new ACP session after creation.
/// Use `sprout-acp models` to discover available model IDs.
#[arg(long, env = "SPROUT_ACP_MODEL")]
Expand Down Expand Up @@ -416,6 +440,11 @@ pub struct Config {
pub max_turns_per_session: u32,
pub presence_enabled: bool,
pub typing_enabled: bool,
/// Whether NIP-AE agent core memory injection is enabled. When false,
/// the harness skips the per-session core engram fetch and renders no
/// `[Agent Memory — core]` section. Mirrors the `--memory` /
/// `SPROUT_ACP_MEMORY` opt-in.
pub memory_enabled: bool,
/// Desired LLM model ID. Applied after every `session_new_full()`.
pub model: Option<String>,
/// Permission mode to apply after session creation. `Default` = skip.
Expand Down Expand Up @@ -761,6 +790,7 @@ impl Config {
max_turns_per_session: args.max_turns_per_session,
presence_enabled: !args.no_presence,
typing_enabled: !args.no_typing,
memory_enabled: args.memory && !args.no_memory,
model,
permission_mode: args.permission_mode,
respond_to: args.respond_to,
Expand All @@ -782,7 +812,7 @@ impl Config {
other => format!("respond_to={other}"),
};
format!(
"relay={} pubkey={} agent_cmd={} {} mcp_cmd={} idle_timeout={}s max_turn={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} meh={:?} ignore_self={} context_limit={} max_turns_per_session={} presence={} typing={} model={} permission_mode={} {}",
"relay={} pubkey={} agent_cmd={} {} mcp_cmd={} idle_timeout={}s max_turn={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} meh={:?} ignore_self={} context_limit={} max_turns_per_session={} presence={} typing={} memory={} model={} permission_mode={} {}",
self.relay_url,
self.keys.public_key().to_hex(),
self.agent_command,
Expand All @@ -800,6 +830,7 @@ impl Config {
self.max_turns_per_session,
self.presence_enabled,
self.typing_enabled,
self.memory_enabled,
self.model.as_deref().unwrap_or("(agent default)"),
self.permission_mode,
respond_to_detail,
Expand Down Expand Up @@ -1122,6 +1153,7 @@ mod tests {
max_turns_per_session: 0,
presence_enabled: true,
typing_enabled: true,
memory_enabled: false,
model: None,
permission_mode: PermissionMode::BypassPermissions,
respond_to: RespondTo::Anyone,
Expand Down Expand Up @@ -1705,6 +1737,38 @@ channels = "ALL"
);
}

// ── memory toggle ───────────────────────────────────────────────────────

#[test]
fn test_memory_enabled_default_false() {
let config = test_config(SubscribeMode::Mentions);
assert!(
!config.memory_enabled,
"memory_enabled should default to false"
);
}

#[test]
fn test_summary_includes_memory_disabled() {
let config = test_config(SubscribeMode::Mentions);
let s = config.summary();
assert!(
s.contains("memory=false"),
"summary should include memory=false by default, got: {s}"
);
}

#[test]
fn test_summary_reflects_memory_enabled() {
let mut config = test_config(SubscribeMode::Mentions);
config.memory_enabled = true;
let s = config.summary();
assert!(
s.contains("memory=true"),
"summary should include memory=true when enabled, got: {s}"
);
}

// ── permission mode ─────────────────────────────────────────────────────

#[test]
Expand Down
248 changes: 248 additions & 0 deletions crates/sprout-acp/src/engram_fetch.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
//! Fetch the agent's NIP-AE `core` engram at session creation and render it
//! into a prompt section.
//!
//! Scope per Tyler's spec:
//! - Fire one synchronous query for the core head when a *new* session is born.
//! - If a body is found, emit `[Agent Memory — core]\n<profile>`.
//! - If no body is found, emit an onboarding nudge so the agent learns how
//! to set its own core.
//! - On any *error* (transport, parse), log and emit nothing. We must not
//! mistake a relay outage for "no core" — that would invite the agent to
//! overwrite real, just-unreachable memory with a fresh profile.
//! - Either way, session creation is never blocked.

use nostr::{Event, Keys, PublicKey};
use sprout_core::engram::{conversation_key, d_tag, select_head, validate_and_decrypt, Body};
use sprout_core::kind::KIND_AGENT_ENGRAM;

use crate::relay::RestClient;

/// Section header rendered into the prompt.
const SECTION_LABEL: &str = "Agent Memory — core";

/// Onboarding nudge for new agents with no core yet.
///
/// Wording is from Tyler's brief: "No core memory found. Use `sprout mem`
/// to create a core memory. Ask your user about yourself."
pub const ONBOARDING_NUDGE: &str = "No core memory found. \
Use `sprout mem set core \"…\"` to create one (it will hold your identity, \
rules, and goals across sessions). Ask your user about yourself.";

/// Build the rendered prompt section for the agent's core.
///
/// Returns:
/// - `Some(profile_section)` when a valid core exists,
/// - `Some(nudge_section)` when the relay confirmed absence,
/// - `None` when the fetch failed (transport, parse, decrypt) — the caller
/// should inject no section in that case so the agent doesn't conclude
/// memory is empty.
pub async fn build_core_section(
rest: &RestClient,
agent_keys: &Keys,
owner: &PublicKey,
) -> Option<String> {
match fetch_core_body(rest, agent_keys, owner).await {
Ok(Some(profile)) => Some(format!("[{SECTION_LABEL}]\n{profile}")),
Ok(None) => Some(format!("[{SECTION_LABEL}]\n{ONBOARDING_NUDGE}")),
Err(reason) => {
tracing::warn!(
target: "engram::core",
"core fetch failed: {reason} — emitting no section to avoid \
confusing a relay outage with an absent core"
);
None
}
}
}

/// Query the relay for the core head and decode it. Returns:
/// - `Ok(Some(profile))` if a valid core body was found,
/// - `Ok(None)` only if the relay confirmed absence (empty result set),
/// - `Err(reason)` if the relay returned candidates we could not parse,
/// verify, or decrypt — those are NOT treated as absence (would let an
/// unreadable but real core be silently overwritten by the onboarding nudge),
/// - `Err` for transport / parse errors.
async fn fetch_core_body(
rest: &RestClient,
agent_keys: &Keys,
owner: &PublicKey,
) -> Result<Option<String>, String> {
let k_c = conversation_key(agent_keys.secret_key(), owner);
let d = d_tag(&k_c, sprout_core::engram::CORE_SLUG);

let filter = nostr::Filter::new()
.kind(nostr::Kind::Custom(KIND_AGENT_ENGRAM as u16))
.author(agent_keys.public_key())
.custom_tag(nostr::SingleLetterTag::lowercase(nostr::Alphabet::D), [d])
.custom_tag(
nostr::SingleLetterTag::lowercase(nostr::Alphabet::P),
[owner.to_hex()],
)
.limit(16);

let value = rest
.query(&[filter])
.await
.map_err(|e| format!("relay query failed: {e}"))?;
let arr = value
.as_array()
.ok_or_else(|| "relay query returned non-array".to_string())?;
decode_core_body(arr, agent_keys, owner)
}

/// Pure decoder: given the relay's JSON array, decide whether we have a
/// readable core, confirmed absence, or an ambiguous unreadable-state.
///
/// - Empty array → `Ok(None)` (confirmed absence; caller renders the nudge).
/// - At least one event decrypts → use the winning head's body.
/// * Body::Core → `Ok(Some(profile))`
/// * Body::Tombstone or unexpected shape → `Ok(None)` (treat as absent).
/// - Non-empty array but nothing decrypts → `Err` (fail closed; caller
/// emits no section, so the agent does not assume memory is empty and
/// try to overwrite a real-but-unreadable core).
fn decode_core_body(
arr: &[serde_json::Value],
agent_keys: &Keys,
owner: &PublicKey,
) -> Result<Option<String>, String> {
if arr.is_empty() {
return Ok(None);
}
let mut valid_with_body: Vec<(Event, Body)> = Vec::with_capacity(arr.len());
let mut candidates_seen = 0usize;
let mut last_decrypt_err: Option<String> = None;
for ev_json in arr {
let event: Event = match serde_json::from_value(ev_json.clone()) {
Ok(e) => e,
Err(_) => continue,
};
if event.verify().is_err() {
continue;
}
candidates_seen += 1;
match validate_and_decrypt(
&event,
&agent_keys.public_key(),
owner,
agent_keys.secret_key(),
owner,
) {
Ok(body) => valid_with_body.push((event, body)),
Err(e) => {
last_decrypt_err = Some(e.to_string());
continue;
}
}
}
if valid_with_body.is_empty() {
if candidates_seen > 0 {
return Err(format!(
"{candidates_seen} core candidate(s) returned but none decryptable (last error: {})",
last_decrypt_err.as_deref().unwrap_or("unknown")
));
}
return Err(
"relay returned core candidate(s) that could not be parsed or verified".to_string(),
);
}
let events: Vec<Event> = valid_with_body.iter().map(|(e, _)| e.clone()).collect();
// `select_head` returns `None` only on an empty iterator, which we
// ruled out above.
let Some(head) = select_head(events) else {
return Ok(None);
};
let head_id = head.id;
let body = valid_with_body
.into_iter()
.find(|(e, _)| e.id == head_id)
.map(|(_, b)| b);
match body {
Some(Body::Core { profile }) => Ok(Some(profile)),
// A tombstone or unexpectedly-shaped head means "no usable core."
_ => Ok(None),
}
}

#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use sprout_core::engram::{build_event, Body};

/// Empty array → confirmed absence → Ok(None), so the caller emits the
/// onboarding nudge. This is the only path that maps to "no core."
#[test]
fn decode_empty_array_is_confirmed_absence() {
let agent = Keys::generate();
let owner = Keys::generate();
let out = decode_core_body(&[], &agent, &owner.public_key()).unwrap();
assert_eq!(out, None);
}

/// Happy path: a real, decryptable core event yields the profile.
#[test]
fn decode_valid_core_returns_profile() {
let agent = Keys::generate();
let owner = Keys::generate();
let body = Body::Core {
profile: "I am Sami.".to_string(),
};
let ev = build_event(&agent, &owner.public_key(), &body, 1_700_000_000).unwrap();
let arr = vec![serde_json::to_value(&ev).unwrap()];
let out = decode_core_body(&arr, &agent, &owner.public_key()).unwrap();
assert_eq!(out.as_deref(), Some("I am Sami."));
}

/// Regression: when the relay returns a kind:30174 event addressed to
/// this agent that we cannot decrypt (here: encrypted to a *different*
/// owner's key, so the MAC fails for this agent↔owner pair), we MUST
/// return Err and NOT Ok(None). Returning Ok(None) would cause the
/// harness to emit the onboarding nudge, inviting the agent to overwrite
/// a real-but-unreadable core.
#[test]
fn decode_undecryptable_candidate_is_err_not_absent() {
let agent = Keys::generate();
let owner = Keys::generate();
let wrong_owner = Keys::generate();
// Build an engram encrypted to wrong_owner (not owner). It will pass
// sig verification but fail MAC/decrypt for the agent↔owner pair.
let body = Body::Core {
profile: "secret".to_string(),
};
let ev = build_event(&agent, &wrong_owner.public_key(), &body, 1_700_000_000).unwrap();
let arr = vec![serde_json::to_value(&ev).unwrap()];
let result = decode_core_body(&arr, &agent, &owner.public_key());
assert!(result.is_err(), "expected Err, got: {result:?}");
let msg = result.unwrap_err();
assert!(msg.contains("decryptable"), "got: {msg}");
}

/// An unexpectedly-shaped head (here: a Memory body in what was supposed
/// to be the core slot) is a legitimate, decryptable "no usable core" —
/// Ok(None). Real `rm core` is refused at the CLI, so this is a defensive
/// branch for malformed data on the wire.
#[test]
fn decode_non_core_body_is_absent() {
let agent = Keys::generate();
let owner = Keys::generate();
let body = Body::Memory {
slug: "mem/x".to_string(),
value: None,
};
let ev = build_event(&agent, &owner.public_key(), &body, 1_700_000_000).unwrap();
let arr = vec![serde_json::to_value(&ev).unwrap()];
let out = decode_core_body(&arr, &agent, &owner.public_key()).unwrap();
assert_eq!(out, None);
}

/// Non-empty array with only garbage entries (not even parseable as
/// events) is also treated as a fetch error, not absence.
#[test]
fn decode_unparseable_candidates_is_err() {
let agent = Keys::generate();
let owner = Keys::generate();
let arr = vec![json!({"not": "an event"}), json!("garbage")];
let result = decode_core_body(&arr, &agent, &owner.public_key());
assert!(result.is_err(), "expected Err, got: {result:?}");
}
}
Loading
Loading