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,
|
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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue