feat: N+1 LLM pass - Aster replaces heuristic detect_items with real Bifrost analysis
This commit is contained in:
parent
7da881ad8a
commit
8f2d2e7227
3 changed files with 275 additions and 29 deletions
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
// 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 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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue