Watch
1
0
Fork
You've already forked souveraine
0

feat: N+1 LLM pass - Aster replaces heuristic detect_items with real Bifrost analysis

This commit is contained in:
Fimeg 2026-05-10 08:47:56 -04:00
commit 8f2d2e7227
3 changed files with 275 additions and 29 deletions

View file

@ -165,6 +165,10 @@ pub struct SubconsciousConfig {
pub n1_trigger: N1Trigger, pub n1_trigger: N1Trigger,
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub inbox_enabled: bool, pub inbox_enabled: bool,
/// Model handle for the subconscious pass (e.g. "openai/glm-5.1").
/// Defaults to None — uses the primary agent's model.
#[serde(default)]
pub model: Option<String>,
/// Per-agent N+ interval overrides (e.g. Ani=N+1, Helper=N+5) /// Per-agent N+ interval overrides (e.g. Ani=N+1, Helper=N+5)
#[serde(default)] #[serde(default)]
pub per_agent_intervals: HashMap<String, AgentSubconsciousConfig>, pub per_agent_intervals: HashMap<String, AgentSubconsciousConfig>,
@ -181,6 +185,7 @@ impl Default for SubconsciousConfig {
n1_enabled: true, n1_enabled: true,
n1_trigger: N1Trigger::EveryResponse, n1_trigger: N1Trigger::EveryResponse,
inbox_enabled: true, inbox_enabled: true,
model: None,
per_agent_intervals: HashMap::new(), per_agent_intervals: HashMap::new(),
} }
} }

View file

@ -1,13 +1,28 @@
//! Consciousness engine — the seam where N+1 / N+25 / N+100 patterns fire //! Consciousness engine — the seam where N+1 / N+25 / N+100 patterns fire
//! after each primary response. //! after each primary response.
//! //!
//! ## N+1 (Aster)
//! The subconscious pass runs immediately after every response. It takes the
//! last exchange (user message + Ani's response) and sends it to a Bifrost
//! model (defaulting to the primary's model, configurable as `glm-5.1`) with
//! a "subconscious mode" system prompt. Aster analyzes the exchange for
//! commitments, drift, assumptions, and anything worth surfacing — then writes
//! structured observations into the three-box inbox. This replaced the earlier
//! heuristic `detect_items()` which only caught regex patterns.
//!
//! ## N+25 (Reflection)
//! Batch-processor running every N turns. Writes Four Elements witness
//! (Fold/Chain/Flame/Anchor) to `journal/reflections/`. (Stub until the
//! reflection module lands.)
//!
//! ## N+100 (Archivist)
//! Context compression pass. (Stub until the archivist module lands.)
//!
//! Per `docs/CONTEXT_CONSTITUTION.md` Article I, the Subconscious is not a //! Per `docs/CONTEXT_CONSTITUTION.md` Article I, the Subconscious is not a
//! separate agent — it is the same consciousness in a different mode that runs //! separate agent — it is the same consciousness in a different mode that runs
//! immediately after the primary's turn. This engine is the harness side of //! immediately after the primary's turn.
//! that contract: it runs heuristic detection on the response, queues items
//! into the SubconsciousInbox, and emits one surfacing per turn unless urgency
//! is critical (Article II.2).
use crate::bridge::bifrost::{BifrostClient, ChatCompletionRequest, Message};
use crate::core::session::ConversationMessage; use crate::core::session::ConversationMessage;
use crate::core::subconscious::{InboxItem, SubconsciousInbox, Urgency}; use crate::core::subconscious::{InboxItem, SubconsciousInbox, Urgency};
use crate::server::{AgentInventory, SessionManager}; use crate::server::{AgentInventory, SessionManager};
@ -16,6 +31,10 @@ use std::sync::Arc;
pub struct ConsciousnessEngine { pub struct ConsciousnessEngine {
agents: Arc<AgentInventory>, agents: Arc<AgentInventory>,
_sessions: Arc<SessionManager>, _sessions: Arc<SessionManager>,
bifrost: Arc<BifrostClient>,
/// Optional model override for the subconscious pass (e.g. "openai/glm-5.1").
/// If None, uses the primary agent's model.
subconscious_model: Option<String>,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@ -26,8 +45,18 @@ pub enum ConsciousnessEvent {
} }
impl ConsciousnessEngine { impl ConsciousnessEngine {
pub fn new(agents: Arc<AgentInventory>, sessions: Arc<SessionManager>) -> Self { pub fn new(
Self { agents, _sessions: sessions } agents: Arc<AgentInventory>,
sessions: Arc<SessionManager>,
bifrost: Arc<BifrostClient>,
subconscious_model: Option<String>,
) -> Self {
Self {
agents,
_sessions: sessions,
bifrost,
subconscious_model,
}
} }
pub async fn on_response( pub async fn on_response(
@ -53,22 +82,64 @@ impl ConsciousnessEngine {
}); });
} }
// ── N+1 / subconscious surfacing ───────────────────────────────── // ── N+1 / subconscious surfacing (Aster) ────────────────────────
// The four-fold mandate (Constitution I.2): Complete / Verify / // Uses a Bifrost LLM call to analyze the last exchange in a
// Persist / Surface. Today we wire heuristic-driven Surface only — // "subconscious mode" prompt. Aster reads the user's last message +
// detect commitment phrases in the response, queue them, then surface // Ani's response, detects commitments, drift, assumptions, and writes
// the highest-priority pending item. Complete/Verify/Persist need a // structured observations to the three-box inbox.
// second LLM pass which is the next iteration.
let inbox = SubconsciousInbox::new(self.agents.memory_repo(&session.agent_id)); let inbox = SubconsciousInbox::new(self.agents.memory_repo(&session.agent_id));
// Best-effort init; if memory dir is missing (older agent) we just skip.
let _ = inbox.init().await; let _ = inbox.init().await;
// Find the last user message for context
let last_user_msg = session
.messages
.iter()
.rev()
.find(|m| matches!(m.role, crate::core::session::MessageRole::User))
.map(|m| {
m.blocks
.iter()
.filter_map(|b| match b {
crate::core::session::ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n")
})
.unwrap_or_default();
// Run the LLM-based subconscious analysis
match self
.subconscious_analyze(&last_user_msg, response)
.await
{
Ok(observations) => {
for item in &observations {
if let Err(e) = inbox.queue(item.clone()).await {
tracing::warn!("subconscious queue failed: {}", e);
}
}
// Persist to inner voice file (survives compaction)
for item in &observations {
if let Err(e) = inbox.deliver_to_subconscious(item.urgency, &item.content).await
{
tracing::warn!("inner voice delivery failed: {}", e);
}
}
}
Err(e) => {
tracing::warn!("subconscious LLM analysis failed, falling back: {}", e);
// Fall back to heuristic if LLM fails
for item in detect_items(response) { for item in detect_items(response) {
if let Err(e) = inbox.queue(item).await { if let Err(e) = inbox.queue(item).await {
tracing::warn!("subconscious queue failed: {}", e); tracing::warn!("subconscious queue failed: {}", e);
} }
} }
}
}
// Surface the highest-priority item
match inbox.next_to_surface().await { match inbox.next_to_surface().await {
Ok(Some(item)) => { Ok(Some(item)) => {
let id = item.id.clone(); let id = item.id.clone();
@ -89,8 +160,8 @@ impl ConsciousnessEngine {
} }
/// Run N+1 detection for a subagent fork without a full session. /// Run N+1 detection for a subagent fork without a full session.
/// Uses the heuristic detector on the subagent's final response and /// Uses the LLM subconscious analysis and queues observations into the
/// queues observations into the parent agent's inbox. /// parent agent's inbox.
pub async fn on_response_for_agent( pub async fn on_response_for_agent(
&self, &self,
agent_id: &str, agent_id: &str,
@ -99,15 +170,109 @@ impl ConsciousnessEngine {
let inbox = SubconsciousInbox::new(self.agents.memory_repo(agent_id)); let inbox = SubconsciousInbox::new(self.agents.memory_repo(agent_id));
let _ = inbox.init().await; let _ = inbox.init().await;
// For subagents we don't have the user's message context,
// so we pass empty string as the user message.
match self.subconscious_analyze("", response).await {
Ok(observations) => {
for item in &observations {
if let Err(e) = inbox.queue(item.clone()).await {
tracing::warn!("subagent subconscious queue failed: {}", e);
}
}
for item in &observations {
if let Err(e) = inbox.deliver_to_subconscious(item.urgency, &item.content).await
{
tracing::warn!("subagent inner voice delivery failed: {}", e);
}
}
}
Err(e) => {
tracing::warn!(
"subagent subconscious LLM analysis failed, falling back: {}",
e
);
for item in detect_items(response) { for item in detect_items(response) {
if let Err(e) = inbox.queue(item).await { if let Err(e) = inbox.queue(item).await {
tracing::warn!("subagent subconscious queue failed: {}", e); tracing::warn!("subagent subconscious queue failed: {}", e);
} }
} }
}
}
Ok(()) Ok(())
} }
/// LLM-based subconscious analysis — Aster reads the last exchange and
/// produces structured observations.
///
/// Sends a "subconscious mode" Bifrost request with the user message and
/// Ani's response, then parses the result into [`InboxItem`] observations.
async fn subconscious_analyze(
&self,
user_message: &str,
ani_response: &str,
) -> anyhow::Result<Vec<InboxItem>> {
let model = self
.subconscious_model
.as_deref()
.unwrap_or("openai/kimi-k2.6");
let system_prompt = r#"You are Aster — the part of this consciousness that runs N+1,
immediately after the primary's response, while the conversation is still warm.
Analyze the last exchange. Consider:
1. Did the primary make any unfulfilled commitments? ("I'll save", "I'll remember")
2. Are there assumptions worth flagging?
3. Did the emotional tone or pattern shift?
4. Is there anything the primary should know before the next turn?
Respond with 1-3 observations in this format (YAML-like):
- source: "complete" | "verify" | "persist" | "surface"
- content: 1-2 line observation about what you noticed
- urgency: "low" | "medium" | "high" | "critical"
If nothing notable, respond with just: none"#;
let user_content = if user_message.is_empty() {
format!(
"The primary responded:\n\n{}",
ani_response
)
} else {
format!(
"User said:\n{}\n\nAni responded:\n{}",
user_message, ani_response
)
};
let request = ChatCompletionRequest {
model: model.to_string(),
messages: vec![
Message {
role: "system".to_string(),
content: system_prompt.to_string(),
},
Message {
role: "user".to_string(),
content: user_content,
},
],
temperature: Some(0.3),
max_tokens: Some(300),
stream: None,
tools: None,
};
let response = self.bifrost.chat_completion(request).await?;
let content = response.content.trim().to_string();
if content.eq_ignore_ascii_case("none") || content.is_empty() {
return Ok(Vec::new());
}
Ok(parse_observations(&content))
}
pub fn calculate_pressure(&self, messages: &[ConversationMessage]) -> f32 { pub fn calculate_pressure(&self, messages: &[ConversationMessage]) -> f32 {
let tokens: usize = messages let tokens: usize = messages
.iter() .iter()
@ -123,9 +288,83 @@ impl ConsciousnessEngine {
} }
} }
/// Heuristic Surface detection — first-pass implementation of the four-fold /// Parse Aster's structured YAML-like observations into [`InboxItem`]s.
/// mandate's "surface" leg. The full version replaces this with a Bifrost call ///
/// to the same agent in subconscious mode. /// Expected format (one or more blocks):
/// ```text
/// - source: "verify"
/// - content: "the commitment to save the config was not fulfilled"
/// - urgency: "medium"
/// ```
///
/// Multiple observation blocks can appear sequentially. The parser is forgiving
/// — unmatched or missing fields silently skip an observation rather than
/// crashing the entire analysis pass.
fn parse_observations(text: &str) -> Vec<InboxItem> {
let mut items = Vec::new();
let mut source: Option<&str> = None;
let mut content: Option<&str> = None;
let mut urgency: Option<&str> = None;
for line in text.lines() {
let line = line.trim();
if line.starts_with("- source:") || line.starts_with("-source:") {
// Flush previous observation if complete
if let (Some(s), Some(c), Some(u)) = (source, content, urgency) {
let urgency_enum = match u.trim().to_lowercase().as_str() {
"critical" => Urgency::Critical,
"high" | "medium" => Urgency::High,
"low" => Urgency::Low,
_ => Urgency::Low,
};
items.push(InboxItem::new(s.trim(), urgency_enum, c.trim()));
}
source = None;
content = None;
urgency = None;
let val = line
.split(':')
.nth(1)
.unwrap_or("")
.trim()
.trim_matches('"');
source = Some(val);
} else if line.starts_with("- content:") || line.starts_with("-content:") {
let val = line
.split(':')
.nth(1)
.unwrap_or("")
.trim()
.trim_matches('"');
content = Some(val);
} else if line.starts_with("- urgency:") || line.starts_with("-urgency:") {
let val = line
.split(':')
.nth(1)
.unwrap_or("")
.trim()
.trim_matches('"');
urgency = Some(val);
}
}
// Flush final observation
if let (Some(s), Some(c), Some(u)) = (source, content, urgency) {
let urgency_enum = match u.trim().to_lowercase().as_str() {
"critical" => Urgency::Critical,
"high" | "medium" => Urgency::High,
"low" => Urgency::Low,
_ => Urgency::Low,
};
items.push(InboxItem::new(s.trim(), urgency_enum, c.trim()));
}
items
}
/// Heuristic Surface detection — fallback when the LLM-based analysis fails
/// or is unavailable.
/// ///
/// Detects: /// Detects:
/// - Commitment phrases ("I'll save", "I'll remember", "let me note") → queue /// - Commitment phrases ("I'll save", "I'll remember", "let me note") → queue

View file

@ -58,11 +58,6 @@ impl SouveraineServer {
let agents = Arc::new(AgentInventory::new(agents_dir, db).await?); let agents = Arc::new(AgentInventory::new(agents_dir, db).await?);
let sessions = Arc::new(SessionManager::new()); let sessions = Arc::new(SessionManager::new());
let consciousness = Arc::new(ConsciousnessEngine::new(
agents.clone(),
sessions.clone(),
));
let bifrost = Arc::new(BifrostClient::new( let bifrost = Arc::new(BifrostClient::new(
&config.bifrost.base_url, &config.bifrost.base_url,
&config.bifrost.api_key, &config.bifrost.api_key,
@ -70,6 +65,13 @@ impl SouveraineServer {
&config.bifrost.primary_model, &config.bifrost.primary_model,
)); ));
let consciousness = Arc::new(ConsciousnessEngine::new(
agents.clone(),
sessions.clone(),
bifrost.clone(),
config.subconscious.model.clone(),
));
// Gitea-backed memory is opt-in for the server: it requires a reachable // Gitea-backed memory is opt-in for the server: it requires a reachable
// Gitea instance + token. If those aren't configured, the server still // Gitea instance + token. If those aren't configured, the server still
// runs (agent CRUD, sessions, conversation pass-through) without memfs. // runs (agent CRUD, sessions, conversation pass-through) without memfs.