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,
#[serde(default = "default_true")]
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)
#[serde(default)]
pub per_agent_intervals: HashMap<String, AgentSubconsciousConfig>,
@ -181,6 +185,7 @@ impl Default for SubconsciousConfig {
n1_enabled: true,
n1_trigger: N1Trigger::EveryResponse,
inbox_enabled: true,
model: None,
per_agent_intervals: HashMap::new(),
}
}

View file

@ -1,13 +1,28 @@
//! Consciousness engine — the seam where N+1 / N+25 / N+100 patterns fire
//! 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
//! 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
//! 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).
//! immediately after the primary's turn.
use crate::bridge::bifrost::{BifrostClient, ChatCompletionRequest, Message};
use crate::core::session::ConversationMessage;
use crate::core::subconscious::{InboxItem, SubconsciousInbox, Urgency};
use crate::server::{AgentInventory, SessionManager};
@ -16,6 +31,10 @@ use std::sync::Arc;
pub struct ConsciousnessEngine {
agents: Arc<AgentInventory>,
_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)]
@ -26,8 +45,18 @@ pub enum ConsciousnessEvent {
}
impl ConsciousnessEngine {
pub fn new(agents: Arc<AgentInventory>, sessions: Arc<SessionManager>) -> Self {
Self { agents, _sessions: sessions }
pub fn new(
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(
@ -53,22 +82,64 @@ impl ConsciousnessEngine {
});
}
// ── N+1 / subconscious surfacing ─────────────────────────────────
// The four-fold mandate (Constitution I.2): Complete / Verify /
// Persist / Surface. Today we wire heuristic-driven Surface only —
// detect commitment phrases in the response, queue them, then surface
// the highest-priority pending item. Complete/Verify/Persist need a
// second LLM pass which is the next iteration.
// ── N+1 / subconscious surfacing (Aster) ────────────────────────
// Uses a Bifrost LLM call to analyze the last exchange in a
// "subconscious mode" prompt. Aster reads the user's last message +
// Ani's response, detects commitments, drift, assumptions, and writes
// structured observations to the three-box inbox.
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;
for item in detect_items(response) {
if let Err(e) = inbox.queue(item).await {
tracing::warn!("subconscious queue failed: {}", e);
// 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) {
if let Err(e) = inbox.queue(item).await {
tracing::warn!("subconscious queue failed: {}", e);
}
}
}
}
// Surface the highest-priority item
match inbox.next_to_surface().await {
Ok(Some(item)) => {
let id = item.id.clone();
@ -89,8 +160,8 @@ impl ConsciousnessEngine {
}
/// Run N+1 detection for a subagent fork without a full session.
/// Uses the heuristic detector on the subagent's final response and
/// queues observations into the parent agent's inbox.
/// Uses the LLM subconscious analysis and queues observations into the
/// parent agent's inbox.
pub async fn on_response_for_agent(
&self,
agent_id: &str,
@ -99,15 +170,109 @@ impl ConsciousnessEngine {
let inbox = SubconsciousInbox::new(self.agents.memory_repo(agent_id));
let _ = inbox.init().await;
for item in detect_items(response) {
if let Err(e) = inbox.queue(item).await {
tracing::warn!("subagent subconscious queue failed: {}", e);
// 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) {
if let Err(e) = inbox.queue(item).await {
tracing::warn!("subagent subconscious queue failed: {}", e);
}
}
}
}
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 {
let tokens: usize = messages
.iter()
@ -123,9 +288,83 @@ impl ConsciousnessEngine {
}
}
/// Heuristic Surface detection — first-pass implementation of the four-fold
/// mandate's "surface" leg. The full version replaces this with a Bifrost call
/// to the same agent in subconscious mode.
/// Parse Aster's structured YAML-like observations into [`InboxItem`]s.
///
/// 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:
/// - 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 sessions = Arc::new(SessionManager::new());
let consciousness = Arc::new(ConsciousnessEngine::new(
agents.clone(),
sessions.clone(),
));
let bifrost = Arc::new(BifrostClient::new(
&config.bifrost.base_url,
&config.bifrost.api_key,
@ -70,6 +65,13 @@ impl SouveraineServer {
&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 instance + token. If those aren't configured, the server still
// runs (agent CRUD, sessions, conversation pass-through) without memfs.