Watch
1
0
Fork
You've already forked souveraine
0

conversation seeding + memfs frontmatter fixes

API-created conversations (shell /agent, /new) never got a system prompt —
the agent booted amnesiac with no constitution, memories, or skills. Prompt
seeding now lives on SouveraineServer and every entry point shares it.

Memory frontmatter: stop serializing None as 'key: null', round-trip
unknown keys instead of destroying them, merge agent-supplied frontmatter
instead of nesting a second block in the body, and make reads tolerant so
a nonconforming file can still be read and healed.
This commit is contained in:
Fimeg 2026-07-16 20:57:41 -04:00
commit dc9fe900ec
4 changed files with 227 additions and 156 deletions

View file

@ -137,6 +137,14 @@ pub async fn create_conversation(
let conversation_id = server.sessions.create(&request.agent_id);
// Seed the full system prompt (constitution, base memories, skills) —
// without this the agent boots amnesiac on shell-created conversations.
server.seed_conversation_system_prompt(&request.agent_id, &conversation_id).await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse {
error: "system_prompt_seed_failed".to_string(),
message: e.to_string(),
})))?;
let conversation = Conversation {
id: conversation_id,
agent_id: request.agent_id,

View file

@ -153,22 +153,6 @@ impl LocalBackend {
self.server.shutdown_sensoria().await;
}
/// Build the greeting line describing the agent's current visual state
/// (atmosphere and outfit). Returns `None` when no atmosphere is set in
/// config (fresh init, no state to report).
async fn build_visual_greeting(&self) -> Option<String> {
let config = self.server.app_config.read().await;
let atm = config.presence.atmosphere.as_deref()?;
if atm.is_empty() {
return None;
}
let display = atm.replace('_', " ");
let outfit = config.presence.outfit.as_deref().unwrap_or("default");
Some(format!(
"\n\nYour current atmosphere is {}, wearing the \"{}\" outfit.",
display, outfit
))
}
}
#[async_trait]
@ -197,48 +181,7 @@ impl Backend for LocalBackend {
tracing::warn!(agent = %agent_id, "instance registration failed: {}", e);
}
let memory_root = self.server.agents.memory_root(agent_id);
let subconscious_root = self.server.agents.subconscious_memory_root(agent_id);
let (bundled, user, agent_memfs, project) =
crate::core::skills::default_discovery_paths(Some(memory_root.clone()));
let skills = crate::core::skills::discover(
bundled.as_deref(),
user.as_deref(),
agent_memfs.as_deref(),
project.as_deref(),
)
.await
.unwrap_or_default();
let platform_prompt = self.server.app_config.read().await
.agent.system_prompt.clone();
let system_prompt = crate::core::prompt::build_system_prompt_full(
&memory_root,
Some(&subconscious_root),
platform_prompt.as_deref(),
Some(&skills),
)
.await;
// Append visual state greeting.
let greeting_extra = self.build_visual_greeting().await;
let system_prompt = if let Some(extra) = greeting_extra {
format!("{}{}", system_prompt, extra)
} else {
system_prompt
};
self.server.sessions.add_message(
&conv_id,
ConversationMessage {
role: crate::core::session::MessageRole::System,
blocks: vec![crate::core::session::ContentBlock::Text {
text: system_prompt,
}],
usage: None,
timestamp: Some(chrono::Utc::now()),
},
)?;
self.server.seed_conversation_system_prompt(agent_id, &conv_id).await?;
Ok(conv_id)
}
@ -315,50 +258,7 @@ impl Backend for LocalBackend {
let conv_id = self.server.sessions.create(agent_id);
// Build system prompt from the agent's memfs and inject as first message
let memory_root = self.server.agents.memory_root(agent_id);
let subconscious_root = self.server.agents.subconscious_memory_root(agent_id);
// Discover skills from all 4 tiers
let (bundled, user, agent_memfs, project) =
crate::core::skills::default_discovery_paths(Some(memory_root.clone()));
let skills = crate::core::skills::discover(
bundled.as_deref(),
user.as_deref(),
agent_memfs.as_deref(),
project.as_deref(),
)
.await
.unwrap_or_default();
let platform_prompt = self.server.app_config.read().await
.agent.system_prompt.clone();
let system_prompt = crate::core::prompt::build_system_prompt_full(
&memory_root,
Some(&subconscious_root),
platform_prompt.as_deref(),
Some(&skills),
)
.await;
// Append visual state greeting.
let greeting_extra = self.build_visual_greeting().await;
let system_prompt = if let Some(extra) = greeting_extra {
format!("{}{}", system_prompt, extra)
} else {
system_prompt
};
self.server.sessions.add_message(
&conv_id,
ConversationMessage {
role: crate::core::session::MessageRole::System,
blocks: vec![crate::core::session::ContentBlock::Text {
text: system_prompt,
}],
usage: None,
timestamp: Some(chrono::Utc::now()),
},
)?;
self.server.seed_conversation_system_prompt(agent_id, &conv_id).await?;
Ok(conv_id)
}

View file

@ -40,15 +40,24 @@ pub struct MemoryFile {
}
/// YAML frontmatter fields for a memory file.
#[derive(Debug, Clone, Serialize, Deserialize)]
///
/// `None` fields are omitted on render — never serialized as `key: null`
/// (nulls in frontmatter read as noise and confused agents into copying
/// the pattern). Unknown keys agents add (`name:`, `metadata:`, …) are
/// captured in `extra` and round-tripped verbatim instead of being
/// destroyed on the next rewrite.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MemoryFrontmatter {
/// Human-readable description of this file's purpose (required).
/// Human-readable description of this file's purpose (required on
/// tool-path create; tolerated empty on read so nonconforming files
/// stay reachable and can be healed by a rewrite).
#[serde(default)]
pub description: String,
/// If "true", the file cannot be modified via the memory tool.
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub read_only: Option<String>,
/// Optional tags for categorization.
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
/// Optional max body size in characters. Writes/appends that would exceed
/// this length are rejected. Closes a gap where upstream memfs write path
@ -57,8 +66,11 @@ pub struct MemoryFrontmatter {
/// Units are characters, not tokens — cheap to enforce without a tokenizer.
/// Best-practice default for system/ files: 4_000 characters
/// (~1k tokens). For journal/, leave unset.
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
/// Any other frontmatter keys, preserved across rewrites.
#[serde(flatten, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub extra: std::collections::BTreeMap<String, serde_yaml::Value>,
}
/// Status of the memory repo.
@ -176,9 +188,9 @@ impl MemoryRepo {
let persona_content = render_frontmatter(
&MemoryFrontmatter {
description: "Agent identity, voice, principles".to_string(),
read_only: None,
tags: Some(vec!["system".to_string()]),
limit: Some(4_000),
..Default::default()
},
"# Identity\n\nAgent identity and core principles go here.\n",
);
@ -189,9 +201,8 @@ impl MemoryRepo {
let state_content = render_frontmatter(
&MemoryFrontmatter {
description: "Current execution state and phase tracking".to_string(),
read_only: None,
tags: None,
limit: Some(2_000),
..Default::default()
},
"phase: idle\ncurrent_unit: none\n",
);
@ -299,8 +310,11 @@ impl MemoryRepo {
.context("creating parent directories")?;
}
// Agent-supplied frontmatter in the content is merged, not nested.
let (supplied, body) = split_supplied_frontmatter(body);
// Get existing frontmatter or use default
let frontmatter = if path.exists() {
let base = if path.exists() {
let existing = tokio::fs::read_to_string(&path).await?;
let parsed = parse_memory_file(&existing)?;
if parsed.frontmatter.read_only.as_deref() == Some("true") {
@ -310,11 +324,13 @@ impl MemoryRepo {
} else {
MemoryFrontmatter {
description: format!("Memory file: {}", label),
read_only: None,
tags: None,
limit: None,
..Default::default()
}
};
let frontmatter = match supplied {
Some(fm) => merge_frontmatter(base, fm),
None => base,
};
// Enforce frontmatter `limit:` (LET-8133 closure).
if let Some(max) = frontmatter.limit {
@ -344,12 +360,19 @@ impl MemoryRepo {
pub async fn append(&self, label: &str, content: &str) -> Result<()> {
let path = self.resolve_path(label);
// Frontmatter never belongs mid-file; merge it instead of nesting.
let (supplied, content) = split_supplied_frontmatter(content);
if path.exists() {
let existing = tokio::fs::read_to_string(&path).await?;
let parsed = parse_memory_file(&existing)?;
if parsed.frontmatter.read_only.as_deref() == Some("true") {
return Err(anyhow!("memory file is read_only: {}", label));
}
let frontmatter = match supplied {
Some(fm) => merge_frontmatter(parsed.frontmatter, fm),
None => parsed.frontmatter,
};
// Write back body + new content, preserving frontmatter
let new_body = if parsed.body.is_empty() {
content.to_string()
@ -357,7 +380,7 @@ impl MemoryRepo {
format!("{}\n{}", parsed.body.trim_end(), content)
};
// Enforce frontmatter `limit:` (LET-8133 closure).
if let Some(max) = parsed.frontmatter.limit {
if let Some(max) = frontmatter.limit {
if new_body.chars().count() > max {
return Err(anyhow!(
"memory append rejected: body would be {} chars, limit is {} (file: {})",
@ -367,7 +390,7 @@ impl MemoryRepo {
));
}
}
let rendered = render_frontmatter(&parsed.frontmatter, &new_body);
let rendered = render_frontmatter(&frontmatter, &new_body);
tokio::fs::write(&path, &rendered).await?;
if self.auto_commit {
@ -376,13 +399,14 @@ impl MemoryRepo {
return Ok(());
};
// File doesn't exist — create it with default frontmatter
let frontmatter = MemoryFrontmatter {
description: format!("Memory file: {}", label),
read_only: None,
tags: None,
limit: None,
};
// File doesn't exist — create it with supplied or default frontmatter
let frontmatter = merge_frontmatter(
MemoryFrontmatter {
description: format!("Memory file: {}", label),
..Default::default()
},
supplied.unwrap_or_default(),
);
let rendered = render_frontmatter(&frontmatter, content);
tokio::fs::write(&path, &rendered).await?;
@ -676,36 +700,82 @@ impl MemoryRepo {
/// ---
/// Body content here...
/// ```
pub fn parse_memory_file(content: &str) -> Result<MemoryFile> {
// Find the frontmatter delimiters
/// Split a leading `--- … ---` frontmatter block off `content`.
/// Returns `(frontmatter_yaml, body)` or `None` when there is no block.
fn split_frontmatter_block(content: &str) -> Option<(&str, &str)> {
let content = content.trim_start();
if !content.starts_with("---") {
return Err(anyhow!(
"memory file is missing required frontmatter (--- delimiters)"
));
return None;
}
// Find closing `---`
let after_first = &content[3..];
let end_idx = after_first.find("\n---")
.or_else(|| after_first.find("\r\n---"))
.ok_or_else(|| anyhow!("memory file frontmatter has no closing ---"))?;
let end_idx = after_first
.find("\n---")
.or_else(|| after_first.find("\r\n---"))?;
let frontmatter_text = after_first[..end_idx].trim();
let body_start = 3 + end_idx + 4; // opening --- + yaml + \n---
Some((frontmatter_text, content[body_start..].trim_start_matches(['\r', '\n'])))
}
let frontmatter_text = &after_first[..end_idx].trim();
let body_start = 3 + end_idx + 4; // 3 for opening --- + end_idx + 4 for \n---
let body = content[body_start..].trim().to_string();
/// Parse a memory file. Tolerant on read: a file with no frontmatter, or
/// frontmatter that fails YAML parsing, comes back with the whole content
/// as body and default (empty-description) frontmatter — a file that
/// exists must always be readable through the tool, otherwise the agent
/// can never heal it. Strictness (description required) belongs to the
/// write path, not here.
pub fn parse_memory_file(content: &str) -> Result<MemoryFile> {
let Some((frontmatter_text, body)) = split_frontmatter_block(content) else {
return Ok(MemoryFile {
frontmatter: MemoryFrontmatter::default(),
body: content.trim().to_string(),
});
};
// Parse YAML frontmatter
let frontmatter: MemoryFrontmatter = serde_yaml::from_str(frontmatter_text)
.context("parsing memory file frontmatter")?;
if frontmatter.description.trim().is_empty() {
return Err(anyhow!(
"memory file frontmatter is missing required 'description' field"
));
match serde_yaml::from_str::<MemoryFrontmatter>(frontmatter_text) {
Ok(frontmatter) => Ok(MemoryFile {
frontmatter,
body: body.trim().to_string(),
}),
// Malformed YAML: keep the raw text intact as body so a rewrite
// cannot silently destroy whatever the block was trying to say.
Err(_) => Ok(MemoryFile {
frontmatter: MemoryFrontmatter::default(),
body: content.trim().to_string(),
}),
}
}
Ok(MemoryFile { frontmatter, body })
/// Agents sometimes include their own frontmatter in write/append content
/// despite the "body only" contract. Instead of nesting a second `---`
/// block inside the body (the persona.md failure mode), honor it: parse
/// it off and merge its fields. Content whose leading block is not valid
/// YAML is left untouched — never destroy what we cannot parse.
fn split_supplied_frontmatter(content: &str) -> (Option<MemoryFrontmatter>, &str) {
if let Some((frontmatter_text, body)) = split_frontmatter_block(content) {
if let Ok(fm) = serde_yaml::from_str::<MemoryFrontmatter>(frontmatter_text) {
return (Some(fm), body);
}
}
(None, content)
}
/// Overlay agent-supplied frontmatter onto the file's existing (or
/// default) frontmatter: supplied fields win where set, everything else
/// is preserved. `read_only` is deliberately NOT overridable from
/// supplied content — clearing it requires the explicit tool path.
fn merge_frontmatter(base: MemoryFrontmatter, supplied: MemoryFrontmatter) -> MemoryFrontmatter {
let mut extra = base.extra;
extra.extend(supplied.extra);
MemoryFrontmatter {
description: if supplied.description.trim().is_empty() {
base.description
} else {
supplied.description
},
read_only: base.read_only,
tags: supplied.tags.or(base.tags),
limit: supplied.limit.or(base.limit),
extra,
}
}
/// Render frontmatter + body into a complete memory file.
@ -827,10 +897,10 @@ pub async fn execute_memory_command_with_context(
}
MemoryCommand::Read { path } => {
let file = repo.read(path).await?;
Ok(format!(
"---\ndescription: {}\n---\n{}",
file.frontmatter.description, file.body
))
// Normalized render: full frontmatter (description, tags,
// limit, extras — no nulls), so what the agent reads matches
// what a rewrite would produce.
Ok(render_frontmatter(&file.frontmatter, &file.body))
}
MemoryCommand::Write { path, content } => {
repo.write(path, content).await?;
@ -1079,17 +1149,47 @@ mod tests {
#[test]
fn test_parse_missing_frontmatter() {
// Tolerant read: no frontmatter means the whole content is body.
let content = "Hello world without frontmatter";
assert!(parse_memory_file(content).is_err());
let file = parse_memory_file(content).unwrap();
assert_eq!(file.body, content);
assert!(file.frontmatter.description.is_empty());
}
#[test]
fn test_render_omits_none_fields() {
let fm = MemoryFrontmatter {
description: "Bare defaults".to_string(),
..Default::default()
};
let rendered = render_frontmatter(&fm, "Body");
assert!(!rendered.contains("null"), "None fields must be omitted: {rendered}");
}
#[test]
fn test_extra_keys_roundtrip() {
let content = "---\ndescription: Has extras\nname: my-slug\nkind: feedback\n---\nBody";
let file = parse_memory_file(content).unwrap();
assert_eq!(file.frontmatter.extra.len(), 2);
let rendered = render_frontmatter(&file.frontmatter, &file.body);
assert!(rendered.contains("name: my-slug"));
assert!(rendered.contains("kind: feedback"));
}
#[test]
fn test_supplied_frontmatter_not_nested() {
let content = "---\ndescription: Supplied\n---\nActual body";
let (fm, body) = split_supplied_frontmatter(content);
assert_eq!(fm.unwrap().description, "Supplied");
assert_eq!(body, "Actual body");
}
#[test]
fn test_render_and_parse_roundtrip() {
let fm = MemoryFrontmatter {
description: "Roundtrip test".to_string(),
read_only: None,
tags: Some(vec!["test".to_string()]),
limit: None,
..Default::default()
};
let rendered = render_frontmatter(&fm, "Body content");
let parsed = parse_memory_file(&rendered).unwrap();
@ -1179,8 +1279,7 @@ mod tests {
let fm = MemoryFrontmatter {
description: "Read-only test".to_string(),
read_only: Some("true".to_string()),
tags: None,
limit: None,
..Default::default()
};
let content = render_frontmatter(&fm, "This is read-only");
let path = repo.root().join("test/readonly.md");

View file

@ -361,6 +361,70 @@ impl SouveraineServer {
})
}
/// Seed a freshly created conversation with the agent's full system
/// prompt — constitution/platform prompt, memfs base memories,
/// subconscious window, skills, visual state. Every conversation
/// entry point (TUI backend, HTTP API, sensoria) must pass through
/// here: a session without this message boots the agent amnesiac.
pub async fn seed_conversation_system_prompt(
&self,
agent_id: &str,
conversation_id: &str,
) -> anyhow::Result<()> {
let memory_root = self.agents.memory_root(agent_id);
let subconscious_root = self.agents.subconscious_memory_root(agent_id);
let (bundled, user, agent_memfs, project) =
crate::core::skills::default_discovery_paths(Some(memory_root.clone()));
let skills = crate::core::skills::discover(
bundled.as_deref(),
user.as_deref(),
agent_memfs.as_deref(),
project.as_deref(),
)
.await
.unwrap_or_default();
let config = self.app_config.read().await;
let platform_prompt = config.agent.system_prompt.clone();
// Visual state greeting (atmosphere + outfit), when presence is set.
let greeting_extra = config.presence.atmosphere.as_deref()
.filter(|a| !a.is_empty())
.map(|atm| {
let display = atm.replace('_', " ");
let outfit = config.presence.outfit.as_deref().unwrap_or("default");
format!(
"\n\nYour current atmosphere is {}, wearing the \"{}\" outfit.",
display, outfit
)
});
drop(config);
let mut system_prompt = crate::core::prompt::build_system_prompt_full(
&memory_root,
Some(&subconscious_root),
platform_prompt.as_deref(),
Some(&skills),
)
.await;
if let Some(extra) = greeting_extra {
system_prompt.push_str(&extra);
}
self.sessions.add_message(
conversation_id,
crate::core::session::ConversationMessage {
role: crate::core::session::MessageRole::System,
blocks: vec![crate::core::session::ContentBlock::Text {
text: system_prompt,
}],
usage: None,
timestamp: Some(chrono::Utc::now()),
},
)?;
Ok(())
}
/// Register a sensorium on the coordinator and spawn its run loop.
///
/// Each sensorium gets its own task, a shared EventBus subscription,