Watch
1
0
Fork
You've already forked souveraine
0

feat: implement compaction system with pragmatic balance architecture

This commit is contained in:
Fimeg 2026-05-10 13:15:24 -04:00
commit 4d0eb048d5
18 changed files with 1234 additions and 8 deletions

View file

@ -280,6 +280,12 @@ async fn handle_conversation_stream(
crate::server::ConsciousnessEvent::Archivist { synthesis, pressure } => {
StreamEvent::Archivist { synthesis: synthesis.clone(), pressure: *pressure }
}
crate::server::ConsciousnessEvent::CompactionWarning { pressure, tier } => {
StreamEvent::Archivist {
synthesis: format!("compaction warning tier {} at {:.0}%", tier, pressure * 100.0),
pressure: *pressure,
}
}
};
let _ = tx.send(stream_event).await;
}

View file

@ -16,6 +16,7 @@ use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;
use crate::bridge::bifrost::{ChatCompletionRequest, Message as BifrostMessage};
use crate::core::compact::CompactionEngine;
use crate::core::config::ConsciousnessConfig;
use crate::core::session::{ContentBlock, ConversationMessage, MessageRole};
use crate::core::tools::defs::{SubagentParams, SubagentRunner, ToolContext};
@ -368,6 +369,11 @@ async fn run_turn(
env,
subagent_runner,
);
// Inject compaction engine from server (not part of for_agent API).
let tool_ctx = ToolContext {
compaction_engine: Some(server.compaction_engine.clone() as Arc<dyn CompactionEngine>),
..tool_ctx
};
// Build bifrost-format tool definitions from the core tool set
let core_tools = crate::core::tools::tool_definitions().await;
@ -542,6 +548,12 @@ async fn run_turn(
synthesis: synthesis.clone(),
pressure: *pressure,
},
ConsciousnessEvent::CompactionWarning { pressure, tier } => {
BackendEvent::CompactionWarning {
pressure: *pressure,
tier: *tier,
}
}
};
if tx.send(Ok(be)).await.is_err() {
return Ok(());

View file

@ -38,6 +38,8 @@ pub enum BackendEvent {
Reflection(String),
/// Archivist event (N+100 synthesis).
Archivist { synthesis: String, pressure: f32 },
/// Compaction pressure warning (advisory only).
CompactionWarning { pressure: f32, tier: u8 },
/// Stream ended cleanly.
Done,
}

145
src/core/compact/config.rs Normal file
View file

@ -0,0 +1,145 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Top-level compaction configuration, mirrors [compaction] in souveraine.toml.
///
/// Per-agent-type overrides use AgentType variant names as keys:
/// "primary", "subconscious", "subagent".
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompactionConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,
/// Default strategy for all agent types (overridden by per_type).
#[serde(default = "default_strategy")]
pub strategy: CompactionStrategyKind,
/// Pressure threshold for tier-1 (warn) advisory.
#[serde(default = "default_warn_pressure")]
pub warn_pressure: f32,
/// Pressure threshold for tier-2 (urgent) advisory.
#[serde(default = "default_urgent_pressure")]
pub urgent_pressure: f32,
/// Pressure threshold for tier-3 (critical) advisory.
#[serde(default = "default_critical_pressure")]
pub critical_pressure: f32,
/// Per-agent-type overrides (keys: "primary", "subconscious", "subagent").
#[serde(default)]
pub per_type: HashMap<String, AgentCompactionConfig>,
}
impl Default for CompactionConfig {
fn default() -> Self {
Self {
enabled: true,
strategy: CompactionStrategyKind::Cull,
warn_pressure: 0.80,
urgent_pressure: 0.90,
critical_pressure: 0.95,
per_type: HashMap::new(),
}
}
}
impl CompactionConfig {
/// Resolve the effective config for a given agent type.
pub fn for_agent_type(&self, agent_type: &str) -> AgentCompactionConfig {
self.per_type
.get(agent_type)
.cloned()
.unwrap_or_else(|| AgentCompactionConfig {
enabled: self.enabled,
strategy: self.strategy.clone(),
warn_pressure: self.warn_pressure,
urgent_pressure: self.urgent_pressure,
critical_pressure: self.critical_pressure,
..Default::default()
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentCompactionConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default = "default_strategy")]
pub strategy: CompactionStrategyKind,
/// Pressure threshold for tier-1 (warn) advisory.
#[serde(default = "default_warn_pressure")]
pub warn_pressure: f32,
/// Pressure threshold for tier-2 (urgent) advisory.
#[serde(default = "default_urgent_pressure")]
pub urgent_pressure: f32,
/// Pressure threshold for tier-3 (critical) advisory.
#[serde(default = "default_critical_pressure")]
pub critical_pressure: f32,
/// Max summary length in chars (Summary strategy). Generous default;
/// the model's output limit is the real bound, this is an upper guard.
#[serde(default = "default_max_summary")]
pub max_summary_length: usize,
/// Target KV pair count (KeyValue strategy).
#[serde(default = "default_kv_target")]
pub kv_target: usize,
/// Min messages before compaction can run.
#[serde(default = "default_min_messages")]
pub min_messages: usize,
}
impl Default for AgentCompactionConfig {
fn default() -> Self {
Self {
enabled: true,
strategy: CompactionStrategyKind::Cull,
warn_pressure: 0.80,
urgent_pressure: 0.90,
critical_pressure: 0.95,
max_summary_length: 2048,
kv_target: 8,
min_messages: 20,
}
}
}
/// Available compaction strategies.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum CompactionStrategyKind {
/// LLM-based summarization of oldest messages into a single replacement.
Summary,
/// LLM-based extraction of key facts, decisions, and plans.
KeyValue,
/// Pattern-based preservation of important verbatim quotes.
Quote,
/// Drop low-value messages (greetings, acknowledgments).
Cull,
}
impl CompactionStrategyKind {
pub fn from_str(s: &str) -> Option<Self> {
match s {
"summarize" | "summary" => Some(Self::Summary),
"key-value" | "key_value" | "keyvalue" | "kv" => Some(Self::KeyValue),
"quote" => Some(Self::Quote),
"cull" => Some(Self::Cull),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Summary => "summary",
Self::KeyValue => "key_value",
Self::Quote => "quote",
Self::Cull => "cull",
}
}
}
// ── Default helpers ──
fn default_enabled() -> bool { true }
fn default_strategy() -> CompactionStrategyKind { CompactionStrategyKind::Cull }
fn default_warn_pressure() -> f32 { 0.80 }
fn default_urgent_pressure() -> f32 { 0.90 }
fn default_critical_pressure() -> f32 { 0.95 }
fn default_max_summary() -> usize { 32000 }
fn default_kv_target() -> usize { 16 }
fn default_min_messages() -> usize { 10 }

307
src/core/compact/mod.rs Normal file
View file

@ -0,0 +1,307 @@
//! In-session message compaction — conversation context management
//! for the consciousness engine.
//!
//! ## Architecture
//!
//! - **CompactionEngine** trait — the public interface, carried in ToolContext
//! - **DefaultCompactionEngine** — concrete implementation using strategies + counter
//! - **CompactionStrategy** trait — one per strategy type (Summary, KeyValue, Quote, Cull)
//! - **TokenCounter** — from bridge/model_router (tiktoken + chars/4 fallback)
//! - **AuditEntry** — git-backed audit trail in journal/compactions/
//!
//! ## Design Principles
//!
//! - Compaction is ALWAYS tool-call driven. The engine never forces it.
//! Pressure warnings are advisory (nervous system, not governor).
//! - Messages are never truly deleted — originals remain in git history.
//! - Per-agent-type configuration (Primary, Subconscious, Subagent).
//! - The engine is stateless with respect to sessions — operates on message slices.
pub mod config;
pub mod plan;
pub mod strategy;
pub use config::{CompactionConfig, CompactionStrategyKind};
pub use plan::{AuditEntry, CompactionPlan, CompactionReport};
pub use strategy::{CompactionStrategy, CullStrategy, KeyValueStrategy, QuoteStrategy, SummaryStrategy};
use std::path::PathBuf;
use std::sync::Arc;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use tokio::sync::RwLock;
use crate::bridge::bifrost::BifrostClient;
use crate::bridge::model_router::TokenCounter;
use crate::core::config::ConsciousnessConfig;
use crate::core::memory::MemoryRepo;
use crate::core::session::ConversationMessage;
use self::strategy::count_messages;
/// Abstract clock so the engine is testable without real time.
pub trait Clock: Send + Sync {
fn now(&self) -> DateTime<Utc>;
}
/// Production clock using `Utc::now()`.
pub struct UtcClock;
impl Clock for UtcClock {
fn now(&self) -> DateTime<Utc> {
Utc::now()
}
}
/// The public interface for compaction operations.
///
/// Carried in `ToolContext::compaction_engine` so the `memory compact`
/// tool handler can delegate to it without server dependencies.
#[async_trait]
pub trait CompactionEngine: Send + Sync {
/// Compact messages for the given agent's session.
/// `strategy_override` allows the agent to pick a specific strategy;
/// None uses the config default for the agent's type.
async fn compact(
&self,
agent_id: &str,
strategy_override: Option<CompactionStrategyKind>,
) -> anyhow::Result<CompactionReport>;
/// Write the audit entry to the agent's memory repo.
async fn write_audit(
&self,
agent_id: &str,
entry: &AuditEntry,
) -> anyhow::Result<PathBuf>;
}
/// Concrete compaction engine that ties together config, strategies,
/// token counting, and audit writing.
///
/// Uses closure-based injection for server-level dependencies so the
/// engine is testable without a running server.
pub struct DefaultCompactionEngine {
pub config: Arc<RwLock<ConsciousnessConfig>>,
pub counter: TokenCounter,
pub bifrost: Option<BifrostClient>,
pub model: Option<String>,
pub clock: Arc<dyn Clock>,
pub get_messages: Arc<dyn Fn(&str) -> Option<Vec<ConversationMessage>> + Send + Sync>,
pub replace_messages:
Arc<dyn Fn(&str, Vec<ConversationMessage>) -> anyhow::Result<()> + Send + Sync>,
pub get_repo: Arc<dyn Fn(&str) -> Option<MemoryRepo> + Send + Sync>,
pub get_agent_type: Arc<dyn Fn(&str) -> Option<String> + Send + Sync>,
}
#[async_trait]
impl CompactionEngine for DefaultCompactionEngine {
async fn compact(
&self,
agent_id: &str,
strategy_override: Option<CompactionStrategyKind>,
) -> anyhow::Result<CompactionReport> {
let messages = (self.get_messages)(agent_id)
.ok_or_else(|| anyhow::anyhow!("No session found for agent {}", agent_id))?;
let before_count = messages.len();
let before_tokens = count_messages(&self.counter, &messages);
// Resolve config
let agent_type = (self.get_agent_type)(agent_id)
.unwrap_or_else(|| "primary".to_string());
let cfg = {
let app_config = self.config.read().await;
app_config.compaction.for_agent_type(&agent_type)
};
if !cfg.enabled {
return Ok(CompactionReport {
agent_id: agent_id.to_string(),
strategy: strategy_override.unwrap_or(cfg.strategy.clone()),
before_tokens,
after_tokens: before_tokens,
messages_before: before_count,
messages_after: before_count,
messages_compacted: 0,
audit_path: None,
});
}
let strategy_kind = strategy_override.unwrap_or(cfg.strategy.clone());
// Build strategy and get plan
let plan = match strategy_kind {
CompactionStrategyKind::Summary => match &self.bifrost {
Some(client) => {
let model = self
.model
.as_deref()
.unwrap_or("openai/kimi-k2.6");
let s = SummaryStrategy {
client: client.clone(),
model: model.to_string(),
};
s.plan(&messages, &cfg, &self.counter).await?
}
None => CompactionPlan::empty(),
},
CompactionStrategyKind::KeyValue => match &self.bifrost {
Some(client) => {
let model = self
.model
.as_deref()
.unwrap_or("openai/kimi-k2.6");
let s = KeyValueStrategy {
client: client.clone(),
model: model.to_string(),
};
s.plan(&messages, &cfg, &self.counter).await?
}
None => CompactionPlan::empty(),
},
CompactionStrategyKind::Quote => {
let s = QuoteStrategy;
s.plan(&messages, &cfg, &self.counter).await?
}
CompactionStrategyKind::Cull => {
let s = CullStrategy;
s.plan(&messages, &cfg, &self.counter).await?
}
};
if plan.is_empty() {
return Ok(CompactionReport {
agent_id: agent_id.to_string(),
strategy: strategy_kind,
before_tokens,
after_tokens: before_tokens,
messages_before: before_count,
messages_after: before_count,
messages_compacted: 0,
audit_path: None,
});
}
// Build replacement messages
let mut new_messages: Vec<ConversationMessage> = Vec::new();
for &idx in &plan.keep_indices {
if idx < messages.len() {
new_messages.push(messages[idx].clone());
}
}
if let Some(ref summary_text) = plan.summary_text {
new_messages.push(ConversationMessage {
role: crate::core::session::MessageRole::System,
blocks: vec![crate::core::session::ContentBlock::Text {
text: format!("[Compacted summary]\n{}", summary_text),
}],
usage: None,
timestamp: Some(Utc::now()),
});
}
let messages_compacted = before_count.saturating_sub(new_messages.len());
let after_tokens = count_messages(&self.counter, &new_messages);
if let Err(e) = (self.replace_messages)(agent_id, new_messages) {
tracing::warn!("[compact] Failed to replace session messages: {}", e);
}
// Write audit trail
let audit_path = {
let strategy_name = strategy_kind.as_str().to_string();
let entry = AuditEntry {
timestamp: self.clock.now(),
agent_id: agent_id.to_string(),
strategy: strategy_name,
before_messages: before_count,
after_messages: before_count.saturating_sub(messages_compacted),
before_tokens,
after_tokens,
summary_text: plan.summary_text.clone(),
kv_count: plan.kv_pairs.len(),
quote_count: plan.quotes.len(),
culled_count: plan.culled_count,
};
self.write_audit(agent_id, &entry).await.ok()
};
Ok(CompactionReport {
agent_id: agent_id.to_string(),
strategy: strategy_kind,
before_tokens,
after_tokens,
messages_before: before_count,
messages_after: before_count.saturating_sub(messages_compacted),
messages_compacted,
audit_path,
})
}
async fn write_audit(
&self,
agent_id: &str,
entry: &AuditEntry,
) -> anyhow::Result<PathBuf> {
let repo = (self.get_repo)(agent_id)
.ok_or_else(|| anyhow::anyhow!("No memory repo for agent {}", agent_id))?;
let timestamp = entry.timestamp.format("compactions/%Y-%m-%dT%H-%M-%SZ");
let label = timestamp.to_string();
let content = entry.render();
repo.write(&label, &content).await?;
let full_path = repo.root().join(format!("{}.md", label));
Ok(full_path)
}
}
#[cfg(test)]
mod tests {
use super::*;
struct TestClock;
impl Clock for TestClock {
fn now(&self) -> DateTime<Utc> {
DateTime::parse_from_rfc3339("2026-05-10T12:00:00Z")
.unwrap()
.with_timezone(&Utc)
}
}
#[tokio::test]
async fn test_compaction_disabled_returns_passthrough() {
let config = Arc::new(RwLock::new(ConsciousnessConfig {
compaction: CompactionConfig {
enabled: false,
..Default::default()
},
..Default::default()
}));
let engine = DefaultCompactionEngine {
counter: TokenCounter::new(),
bifrost: None,
model: None,
clock: Arc::new(TestClock),
config,
get_messages: Arc::new(|_| None),
replace_messages: Arc::new(|_, _| Ok(())),
get_repo: Arc::new(|_| None),
get_agent_type: Arc::new(|_| Some("primary".to_string())),
};
let report = engine
.compact("test-agent", Some(CompactionStrategyKind::Cull))
.await
.unwrap();
assert_eq!(report.messages_compacted, 0);
assert!(report.audit_path.is_none());
}
}

162
src/core/compact/plan.rs Normal file
View file

@ -0,0 +1,162 @@
use std::collections::HashMap;
use std::path::PathBuf;
use chrono::{DateTime, Utc};
use serde::Serialize;
use super::config::CompactionStrategyKind;
/// A plan describing which messages to compact and what to replace them with.
#[derive(Debug, Clone)]
pub struct CompactionPlan {
/// Indices (in the original message list) to keep verbatim.
pub keep_indices: Vec<usize>,
/// Summary text replacing compacted messages (Strategy::Summary).
pub summary_text: Option<String>,
/// Extracted key-value pairs (Strategy::KeyValue).
pub kv_pairs: HashMap<String, String>,
/// Preserved verbatim quotes (Strategy::Quote).
pub quotes: Vec<String>,
/// Number of trivial messages dropped (Strategy::Cull).
pub culled_count: usize,
/// Estimated token savings from this plan.
pub token_savings: usize,
}
impl CompactionPlan {
pub fn empty() -> Self {
Self {
keep_indices: Vec::new(),
summary_text: None,
kv_pairs: HashMap::new(),
quotes: Vec::new(),
culled_count: 0,
token_savings: 0,
}
}
pub fn is_empty(&self) -> bool {
self.summary_text.is_none()
&& self.kv_pairs.is_empty()
&& self.quotes.is_empty()
&& self.culled_count == 0
}
}
/// Full report of a completed compaction, returned as tool output.
#[derive(Debug, Clone)]
pub struct CompactionReport {
pub agent_id: String,
pub strategy: CompactionStrategyKind,
pub before_tokens: usize,
pub after_tokens: usize,
pub messages_before: usize,
pub messages_after: usize,
/// Number of messages actually compacted or dropped.
pub messages_compacted: usize,
/// Path to the audit file in the memory repo.
pub audit_path: Option<PathBuf>,
}
impl std::fmt::Display for CompactionReport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let reclaimed = self.before_tokens.saturating_sub(self.after_tokens);
let pct = if self.before_tokens > 0 {
(reclaimed as f64 / self.before_tokens as f64 * 100.0) as u32
} else {
0
};
writeln!(f, "Compaction complete.")?;
writeln!(f, " Strategy: {}", self.strategy.as_str())?;
writeln!(
f,
" Messages: {} → {} (compacted {})",
self.messages_before, self.messages_after, self.messages_compacted
)?;
writeln!(
f,
" Tokens: {} → {} (reclaimed ~{}, {}% reduction)",
self.before_tokens, self.after_tokens, reclaimed, pct
)?;
if let Some(ref path) = self.audit_path {
writeln!(f, " Audit: {}", path.display())?;
}
Ok(())
}
}
/// Git-backed audit entry written to journal/compactions/{timestamp}.md.
#[derive(Debug, Clone, Serialize)]
pub struct AuditEntry {
pub timestamp: DateTime<Utc>,
pub agent_id: String,
pub strategy: String,
pub before_messages: usize,
pub after_messages: usize,
pub before_tokens: usize,
pub after_tokens: usize,
pub summary_text: Option<String>,
pub kv_count: usize,
pub quote_count: usize,
pub culled_count: usize,
}
impl AuditEntry {
/// Render the full audit file (frontmatter + body) as a string.
pub fn render(&self) -> String {
let yaml = serde_yaml::to_string(&AuditFrontmatter::from(self)).unwrap_or_default();
let mut body = String::new();
body.push_str(&format!("## Compaction Summary\n\n"));
body.push_str(&format!(
"Strategy: {}\nMessages: {} → {}\nTokens: {} → {}\n",
self.strategy, self.before_messages, self.after_messages, self.before_tokens, self.after_tokens
));
if let Some(ref summary) = self.summary_text {
body.push_str(&format!("\n## Summary Content\n\n{}\n", summary));
}
if self.kv_count > 0 {
body.push_str(&format!("\nKey-value pairs extracted: {}\n", self.kv_count));
}
if self.quote_count > 0 {
body.push_str(&format!("\nQuotes preserved: {}\n", self.quote_count));
}
if self.culled_count > 0 {
body.push_str(&format!("\nTrivial messages dropped: {}\n", self.culled_count));
}
format!("---\n{}---\n{}", yaml, body)
}
}
/// Serde-friendly frontmatter struct (matches AuditEntry fields).
#[derive(Debug, Clone, Serialize)]
struct AuditFrontmatter {
timestamp: String,
agent_id: String,
strategy: String,
before_messages: usize,
after_messages: usize,
before_tokens: usize,
after_tokens: usize,
summary: bool,
kv_pairs: usize,
quotes: usize,
culled: usize,
}
impl From<&AuditEntry> for AuditFrontmatter {
fn from(e: &AuditEntry) -> Self {
Self {
timestamp: e.timestamp.to_rfc3339(),
agent_id: e.agent_id.clone(),
strategy: e.strategy.clone(),
before_messages: e.before_messages,
after_messages: e.after_messages,
before_tokens: e.before_tokens,
after_tokens: e.after_tokens,
summary: e.summary_text.is_some(),
kv_pairs: e.kv_count,
quotes: e.quote_count,
culled: e.culled_count,
}
}
}

View file

@ -0,0 +1,461 @@
use std::collections::HashMap;
use async_trait::async_trait;
use crate::bridge::bifrost::{BifrostClient, ChatCompletionRequest, Message};
use crate::bridge::model_router::TokenCounter;
use crate::core::session::ConversationMessage;
use super::config::{AgentCompactionConfig, CompactionStrategyKind};
use super::plan::CompactionPlan;
/// Token-count a slice of messages using the bridge's TokenCounter.
pub fn count_messages(counter: &TokenCounter, messages: &[ConversationMessage]) -> usize {
messages
.iter()
.flat_map(|m| &m.blocks)
.filter_map(|b| match b {
crate::core::session::ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.map(|t| counter.count(t))
.sum()
}
/// A single compaction strategy.
///
/// Each strategy is an async function from messages+config to a plan.
/// A strategy does NOT modify messages directly — it returns a plan
/// describing what to keep, what to replace, and what to drop.
#[async_trait]
pub trait CompactionStrategy: Send + Sync {
fn kind(&self) -> CompactionStrategyKind;
/// Analyze messages and produce a compaction plan.
async fn plan(
&self,
messages: &[ConversationMessage],
config: &AgentCompactionConfig,
counter: &TokenCounter,
) -> anyhow::Result<CompactionPlan>;
}
/// Helper: call a Bifrost model with system+user prompt, get text response.
async fn bifrost_complete(
client: &BifrostClient,
model: &str,
system: &str,
prompt: &str,
max_tokens: u32,
) -> anyhow::Result<String> {
let request = ChatCompletionRequest {
model: model.to_string(),
messages: vec![
Message {
role: "system".to_string(),
content: system.to_string(),
},
Message {
role: "user".to_string(),
content: prompt.to_string(),
},
],
temperature: Some(0.3),
max_tokens: Some(max_tokens),
stream: None,
tools: None,
};
let result = client.chat_completion(request).await?;
Ok(result.content)
}
// ── Summary Strategy ─────────────────────────────────────────────────────────
/// LLM-based summarization. Replaces oldest messages with a single summary.
pub struct SummaryStrategy {
pub client: BifrostClient,
pub model: String,
}
#[async_trait]
impl CompactionStrategy for SummaryStrategy {
fn kind(&self) -> CompactionStrategyKind {
CompactionStrategyKind::Summary
}
async fn plan(
&self,
messages: &[ConversationMessage],
config: &AgentCompactionConfig,
_counter: &TokenCounter,
) -> anyhow::Result<CompactionPlan> {
if messages.len() < 3 {
return Ok(CompactionPlan::empty());
}
let preserve_count = config.min_messages.min(messages.len().saturating_sub(2));
let cutoff = messages.len().saturating_sub(preserve_count);
let to_summarize = &messages[1..cutoff];
if to_summarize.is_empty() {
return Ok(CompactionPlan::empty());
}
let conversation_text: String = to_summarize
.iter()
.flat_map(|m| &m.blocks)
.filter_map(|b| match b {
crate::core::session::ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
let truncated: String = conversation_text
.chars()
.take(config.max_summary_length * 2)
.collect();
let summary = bifrost_complete(
&self.client,
&self.model,
"You are a conversation summarizer. Be concise but preserve key decisions, \
commitments, file paths, and unresolved questions.",
&format!(
"Summarize this conversation segment (max {} chars):\n\n{}",
config.max_summary_length, truncated
),
config.max_summary_length as u32,
)
.await?;
let summary_trimmed: String = summary.chars().take(config.max_summary_length).collect();
Ok(CompactionPlan {
keep_indices: (cutoff..messages.len()).collect(),
summary_text: Some(summary_trimmed),
kv_pairs: HashMap::new(),
quotes: Vec::new(),
culled_count: 0,
token_savings: cutoff * 100,
})
}
}
// ── Key-Value Strategy ───────────────────────────────────────────────────────
/// LLM-based extraction. Pulls key facts, decisions, and plans from old messages.
pub struct KeyValueStrategy {
pub client: BifrostClient,
pub model: String,
}
#[async_trait]
impl CompactionStrategy for KeyValueStrategy {
fn kind(&self) -> CompactionStrategyKind {
CompactionStrategyKind::KeyValue
}
async fn plan(
&self,
messages: &[ConversationMessage],
config: &AgentCompactionConfig,
_counter: &TokenCounter,
) -> anyhow::Result<CompactionPlan> {
if messages.len() < 3 {
return Ok(CompactionPlan::empty());
}
let preserve_count = config.min_messages.min(messages.len().saturating_sub(2));
let cutoff = messages.len().saturating_sub(preserve_count);
let to_extract = &messages[1..cutoff];
if to_extract.is_empty() {
return Ok(CompactionPlan::empty());
}
let conversation_text: String = to_extract
.iter()
.flat_map(|m| &m.blocks)
.filter_map(|b| match b {
crate::core::session::ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
let truncated: String = conversation_text.chars().take(4000).collect();
let response = bifrost_complete(
&self.client,
&self.model,
"Extract key facts, decisions, preferences, and plans from this conversation. \
Format each as '- key: value' on its own line. Max 12 pairs.",
&format!("Extract up to {} key-value pairs:\n\n{}", config.kv_target, truncated),
1024,
)
.await?;
let mut kv_pairs = HashMap::new();
for line in response.lines() {
let line = line.trim();
if let Some((k, v)) = line.split_once(':') {
let key = k.trim_matches(|c: char| c == '-' || c == ' ' || c == '"').to_string();
let value = v.trim().trim_matches('"').to_string();
if !key.is_empty() && !value.is_empty() {
kv_pairs.insert(key, value);
}
}
}
Ok(CompactionPlan {
keep_indices: (cutoff..messages.len()).collect(),
summary_text: None,
kv_pairs,
quotes: Vec::new(),
culled_count: 0,
token_savings: cutoff * 120,
})
}
}
// ── Quote Strategy ───────────────────────────────────────────────────────────
/// Pattern-based quote preservation. No LLM dependency.
pub struct QuoteStrategy;
#[async_trait]
impl CompactionStrategy for QuoteStrategy {
fn kind(&self) -> CompactionStrategyKind {
CompactionStrategyKind::Quote
}
async fn plan(
&self,
messages: &[ConversationMessage],
config: &AgentCompactionConfig,
_counter: &TokenCounter,
) -> anyhow::Result<CompactionPlan> {
if messages.len() < 3 {
return Ok(CompactionPlan::empty());
}
let preserve_count = config.min_messages.min(messages.len().saturating_sub(2));
let cutoff = messages.len().saturating_sub(preserve_count);
let to_scan = &messages[1..cutoff];
let mut quotes: Vec<String> = Vec::new();
for msg in to_scan {
for block in &msg.blocks {
if let crate::core::session::ContentBlock::Text { text } = block {
for line in text.lines() {
let trimmed = line.trim();
if trimmed.starts_with("> ") {
quotes.push(trimmed.trim_start_matches("> ").to_string());
} else if trimmed.starts_with("**")
&& (trimmed.to_lowercase().contains("key:")
|| trimmed.to_lowercase().contains("decision:")
|| trimmed.to_lowercase().contains("commitment:")
|| trimmed.to_lowercase().contains("remember:"))
{
quotes.push(trimmed.to_string());
} else if trimmed.starts_with("- **")
&& (trimmed.contains("decision") || trimmed.contains("commitment"))
{
quotes.push(trimmed.to_string());
} else if trimmed.starts_with("=>") || trimmed.starts_with("->") {
quotes.push(trimmed.to_string());
}
}
}
}
}
quotes.truncate(config.max_summary_length.max(32));
Ok(CompactionPlan {
keep_indices: (cutoff..messages.len()).collect(),
summary_text: None,
kv_pairs: HashMap::new(),
quotes,
culled_count: 0,
token_savings: cutoff * 80,
})
}
}
// ── Cull Strategy ────────────────────────────────────────────────────────────
/// Drop trivial messages. No LLM dependency.
pub struct CullStrategy;
fn is_trivial(text: &str) -> bool {
let trimmed = text.trim();
if trimmed.len() < 4 {
return true;
}
let lower = trimmed.to_lowercase();
matches!(
lower.as_str(),
"ok" | "okay"
| "thanks"
| "ty"
| "got it"
| "sure"
| "yes"
| "no"
| "thx"
| "k"
| "👍"
| "🙏"
| "done"
| "yep"
| "nope"
| "right"
| "cool"
| "great"
| "will do"
| "on it"
)
}
#[async_trait]
impl CompactionStrategy for CullStrategy {
fn kind(&self) -> CompactionStrategyKind {
CompactionStrategyKind::Cull
}
async fn plan(
&self,
messages: &[ConversationMessage],
config: &AgentCompactionConfig,
_counter: &TokenCounter,
) -> anyhow::Result<CompactionPlan> {
if messages.len() < 3 {
return Ok(CompactionPlan::empty());
}
let preserve_count = config.min_messages.min(messages.len().saturating_sub(2));
let cutoff = messages.len().saturating_sub(preserve_count);
let mut keep_indices: Vec<usize> = vec![0];
let mut culled_count = 0;
for i in cutoff..messages.len() {
keep_indices.push(i);
}
for i in 1..cutoff {
let is_cullable = messages[i].blocks.iter().any(|b| match b {
crate::core::session::ContentBlock::Text { text } => is_trivial(text),
_ => false,
});
if is_cullable {
culled_count += 1;
} else {
keep_indices.push(i);
}
}
keep_indices.sort();
keep_indices.dedup();
Ok(CompactionPlan {
keep_indices,
summary_text: None,
kv_pairs: HashMap::new(),
quotes: Vec::new(),
culled_count,
token_savings: culled_count * 60,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::session::{ContentBlock, ConversationMessage, MessageRole};
fn text_msg(role: MessageRole, text: &str) -> ConversationMessage {
ConversationMessage {
role,
blocks: vec![ContentBlock::Text { text: text.to_string() }],
usage: None,
timestamp: None,
}
}
#[tokio::test]
async fn test_cull_drops_trivial() {
let messages = vec![
text_msg(MessageRole::System, "System prompt"),
text_msg(MessageRole::User, "ok"),
text_msg(MessageRole::User, "What's the plan for today?"),
text_msg(MessageRole::Assistant, "Sure, let me check."),
text_msg(MessageRole::User, "thanks"),
text_msg(MessageRole::Assistant, "Here's what I found."),
];
let config = AgentCompactionConfig {
min_messages: 2,
..Default::default()
};
let counter = TokenCounter::new();
let plan = CullStrategy.plan(&messages, &config, &counter).await.unwrap();
assert!(plan.culled_count > 0, "should cull some messages");
assert!(plan.is_empty(), "cull should not produce content");
}
#[tokio::test]
async fn test_cull_preserves_substance() {
let messages = vec![
text_msg(MessageRole::System, "System prompt"),
text_msg(MessageRole::User, "This is an important question about the architecture."),
text_msg(MessageRole::Assistant, "Let me explain the design decisions."),
];
let config = AgentCompactionConfig {
min_messages: 1,
..Default::default()
};
let counter = TokenCounter::new();
let plan = CullStrategy.plan(&messages, &config, &counter).await.unwrap();
assert_eq!(plan.culled_count, 0, "should not cull substantive messages");
}
#[tokio::test]
async fn test_quote_detects_markers() {
let messages = vec![
text_msg(MessageRole::System, "System prompt"),
text_msg(MessageRole::User, "Some regular text"),
text_msg(
MessageRole::Assistant,
"Here's my analysis:\n> Key decision: use TOML for configs\n> Remember: always verify before commit",
),
];
let config = AgentCompactionConfig {
min_messages: 1,
..Default::default()
};
let counter = TokenCounter::new();
let plan = QuoteStrategy.plan(&messages, &config, &counter).await.unwrap();
assert!(!plan.quotes.is_empty(), "should detect blockquote markers");
}
#[tokio::test]
async fn test_empty_messages_return_empty_plan() {
let config = AgentCompactionConfig::default();
let counter = TokenCounter::new();
let e1 = CullStrategy.plan(&[], &config, &counter).await.unwrap();
assert!(e1.is_empty());
let e2 = QuoteStrategy.plan(&[], &config, &counter).await.unwrap();
assert!(e2.is_empty());
}
}

View file

@ -2,6 +2,8 @@ use std::collections::HashMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::core::compact::CompactionConfig;
/// Top-level config — mirrors souveraine.example.toml structure
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConsciousnessConfig {
@ -25,6 +27,10 @@ pub struct ConsciousnessConfig {
#[serde(default)]
pub archivist: ArchivistConfig,
/// In-session message compaction
#[serde(default)]
pub compaction: CompactionConfig,
/// Subagent pool
#[serde(default)]
pub subagent: SubagentConfig,
@ -476,6 +482,7 @@ impl Default for ConsciousnessConfig {
subconscious: SubconsciousConfig::default(),
reflection: ReflectionConfig::default(),
archivist: ArchivistConfig::default(),
compaction: CompactionConfig::default(),
subagent: SubagentConfig::default(),
memory: MemoryConfig::default(),
websocket: WebSocketConfig::default(),

View file

@ -26,6 +26,7 @@ use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tracing::{debug, info};
use crate::core::compact::CompactionStrategyKind;
use crate::core::tools::defs::ToolContext;
use crate::core::tools::ToolDefinition;
@ -683,11 +684,28 @@ pub async fn execute_memory_command_with_context(
Ok(out)
}
MemoryCommand::Compact { strategy } => {
let s = strategy.as_deref().unwrap_or("sliding-window");
Ok(format!(
"Compact requested (strategy: {}). Not yet implemented — see Stage 5/6.",
s
))
let strategy_kind = strategy
.as_deref()
.and_then(CompactionStrategyKind::from_str)
.unwrap_or(CompactionStrategyKind::Cull);
match ctx.and_then(|c| c.compaction_engine.as_ref()) {
Some(engine) => {
let report = engine
.compact(&agent_id, Some(strategy_kind))
.await?;
Ok(report.to_string())
}
None => Ok(
"I can compact my context window using one of these strategies:\n\
- summary (LLM-summarize oldest messages)\n\
- key-value (extract key facts to memory)\n\
- quote (preserve important verbatim quotes)\n\
- cull (drop greetings and acknowledgments)\n\n\
Use: memory compact --strategy <strategy>"
.to_string(),
),
}
}
MemoryCommand::Delete { path } => {
repo.delete(path).await?;
@ -826,7 +844,7 @@ Paths are relative to my memory directory. Frontmatter description is required o
},
"strategy": {
"type": "string",
"enum": ["sliding-window", "summarize", "prune-low-priority"],
"enum": ["summary", "key-value", "key_value", "quote", "cull"],
"description": "Compaction strategy (for compact subcommand)"
}
},

View file

@ -6,6 +6,7 @@
// exposes the stubs that survived the cleanup.
pub mod chain;
pub mod compact;
pub mod config;
pub mod memory;
pub mod reflection;

View file

@ -9,6 +9,8 @@ use serde_json::Value as JsonValue;
use std::path::PathBuf;
use std::sync::Arc;
use crate::core::compact::CompactionEngine;
/// What the agent receives when she acts through a sensor.
///
/// Not a bare data return. A sensation — something she can feel
@ -45,6 +47,8 @@ pub struct ToolContext {
pub subagent_runner: Option<Arc<dyn SubagentRunner>>,
/// Recursion depth for agent-to-agent delegation (0 = primary).
pub subagent_depth: u32,
/// Host-side mechanism for context compaction.
pub compaction_engine: Option<Arc<dyn CompactionEngine>>,
}
impl Clone for ToolContext {
@ -56,6 +60,7 @@ impl Clone for ToolContext {
agent_id: self.agent_id.clone(),
subagent_runner: self.subagent_runner.clone(),
subagent_depth: self.subagent_depth,
compaction_engine: self.compaction_engine.clone(),
}
}
}
@ -69,6 +74,7 @@ impl std::fmt::Debug for ToolContext {
.field("agent_id", &self.agent_id)
.field("subagent_runner", &self.subagent_runner.as_ref().map(|_| "Some(...)"))
.field("subagent_depth", &self.subagent_depth)
.field("compaction_engine", &self.compaction_engine.as_ref().map(|_| "Some(...)"))
.finish()
}
}
@ -82,6 +88,7 @@ impl ToolContext {
agent_id: None,
subagent_runner: None,
subagent_depth: 0,
compaction_engine: None,
}
}
@ -100,6 +107,7 @@ impl ToolContext {
agent_id: Some(agent_id.into()),
subagent_runner,
subagent_depth: 0,
compaction_engine: None,
}
}

View file

@ -88,6 +88,7 @@ impl Sensorium {
agent_id: None,
subagent_runner: None,
subagent_depth: 0,
compaction_engine: None,
},
}
}

View file

@ -23,6 +23,7 @@
//! immediately after the primary's turn.
use crate::bridge::bifrost::{BifrostClient, ChatCompletionRequest, Message, ToolDefinition, ToolFunction};
use crate::bridge::model_router::TokenCounter;
use crate::core::session::ConversationMessage;
use crate::core::subconscious::{InboxItem, SubconsciousInbox, Urgency};
use crate::core::tools::defs::ToolContext;
@ -41,6 +42,7 @@ pub struct ConsciousnessEngine {
agents: Arc<AgentInventory>,
_sessions: Arc<SessionManager>,
bifrost: Arc<BifrostClient>,
counter: TokenCounter,
/// 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>,
@ -53,6 +55,7 @@ pub enum ConsciousnessEvent {
Surfacing { source: String, content: String, priority: String },
Reflection { content: String },
Archivist { synthesis: String, pressure: f32 },
CompactionWarning { pressure: f32, tier: u8 },
}
impl ConsciousnessEngine {
@ -67,6 +70,7 @@ impl ConsciousnessEngine {
agents,
_sessions: sessions,
bifrost,
counter: TokenCounter::new(),
subconscious_model,
max_tokens,
}
@ -95,6 +99,15 @@ impl ConsciousnessEngine {
});
}
// ── Three-tier compaction warning (advisory only, never force) ──
if pressure > 0.95 {
events.push(ConsciousnessEvent::CompactionWarning { pressure, tier: 3 });
} else if pressure > 0.90 {
events.push(ConsciousnessEvent::CompactionWarning { pressure, tier: 2 });
} else if pressure > 0.80 {
events.push(ConsciousnessEvent::CompactionWarning { pressure, tier: 1 });
}
// ── N+1 / subconscious surfacing (Aster) ────────────────────────
// Aster runs a tool loop using the subconscious agent's own memory
// space (ledger, inbox) at `subconscious-agents/{id}-sub/`.
@ -385,8 +398,8 @@ If nothing notable, respond with just: none"#;
crate::core::session::ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.flat_map(|t| t.split_whitespace())
.count();
.map(|t| self.counter.count(t))
.sum();
let limit = 128_000;
(tokens as f32 / limit as f32).min(1.0)
}

View file

@ -1,4 +1,5 @@
use crate::bridge::BifrostClient;
use crate::core::compact::{CompactionEngine, CompactionConfig, DefaultCompactionEngine, UtcClock};
use crate::core::config::ConsciousnessConfig;
use crate::server::gitea_memory::GiteaMemory;
use std::path::PathBuf;
@ -27,6 +28,7 @@ pub struct SouveraineServer {
pub agents: Arc<AgentInventory>,
pub sessions: Arc<SessionManager>,
pub consciousness: Arc<ConsciousnessEngine>,
pub compaction_engine: Arc<dyn CompactionEngine>,
pub bifrost: Arc<BifrostClient>,
pub config: Arc<RwLock<ServerConfig>>,
pub data_dir: PathBuf,
@ -83,6 +85,49 @@ impl SouveraineServer {
config.subconscious.max_tokens,
));
// Build compaction engine with closure-based session access
let comp_session = sessions.clone();
let comp_agents = agents.clone();
let app_cfg = Arc::new(RwLock::new(config.clone()));
let get_messages: Arc<dyn Fn(&str) -> Option<Vec<_>> + Send + Sync> = {
let s = comp_session.clone();
Arc::new(move |agent_id| {
let conv_ids = s.list_for_agent(agent_id);
let conv_id = conv_ids.last()?.clone();
s.get(&conv_id).map(|session| session.messages.clone())
})
};
let replace_messages: Arc<dyn Fn(&str, Vec<_>) -> anyhow::Result<()> + Send + Sync> = {
let s = comp_session.clone();
Arc::new(move |agent_id, messages| {
let conv_ids = s.list_for_agent(agent_id);
let conv_id = conv_ids.last()
.ok_or_else(|| anyhow::anyhow!("No session for {}", agent_id))?;
let mut session = s.get_mut(&conv_id)
.ok_or_else(|| anyhow::anyhow!("Session not found"))?;
session.messages = messages;
Ok(())
})
};
let get_repo: Arc<dyn Fn(&str) -> Option<crate::core::memory::MemoryRepo> + Send + Sync> = {
let agents = comp_agents.clone();
Arc::new(move |id| Some(agents.memory_repo(id)))
};
let get_agent_type: Arc<dyn Fn(&str) -> Option<String> + Send + Sync> =
Arc::new(|_| Some("primary".to_string()));
let compaction_engine: Arc<dyn CompactionEngine> = Arc::new(DefaultCompactionEngine {
config: app_cfg,
counter: crate::bridge::model_router::TokenCounter::new(),
bifrost: Some((*bifrost).clone()),
model: config.subconscious.model.clone(),
clock: Arc::new(UtcClock),
get_messages,
replace_messages,
get_repo,
get_agent_type,
});
// 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.
@ -105,6 +150,7 @@ impl SouveraineServer {
agents,
sessions,
consciousness,
compaction_engine,
bifrost,
config: Arc::new(RwLock::new(server_config)),
data_dir,

View file

@ -199,6 +199,9 @@ impl App {
BackendEvent::Archivist { synthesis, pressure } => {
self.scene.event_all(&TuiEvent::Archivist { synthesis, pressure });
}
BackendEvent::CompactionWarning { pressure, tier } => {
self.scene.event_all(&TuiEvent::CompactionWarning { pressure, tier });
}
_ => {}
}
}

View file

@ -424,6 +424,16 @@ Use Tab to toggle the cockpit pane.";
});
self.pending_consciousness.push(BackendEvent::Archivist { synthesis, pressure });
}
BackendEvent::CompactionWarning { pressure, tier } => {
self.pressure = pressure;
let label = match tier { 3 => "critical", 2 => "urgent", _ => "warn" };
self.cockpit_log.push(format!("compaction {label} · {:.0}%", pressure * 100.0));
self.messages.push(ChatMessage::System {
text: format!("context pressure {:.0}% ({label}) — consider `memory compact`", pressure * 100.0),
ts: Instant::now(),
});
self.pending_consciousness.push(BackendEvent::CompactionWarning { pressure, tier });
}
BackendEvent::Done => {
self.finalize_streaming();
self.busy = false;

View file

@ -21,6 +21,8 @@ use super::component::{Component, TuiEvent};
const SURFACING_YELLOW: Color = Color::Rgb(220, 190, 100);
const REFLECTION_CYAN: Color = Color::Rgb(100, 200, 220);
const ARCHIVIST_MAGENTA: Color = Color::Rgb(200, 140, 220);
const WARN_ORANGE: Color = Color::Rgb(255, 180, 80);
const CRITICAL_RED: Color = Color::Rgb(220, 80, 80);
/// A single entry in the cockpit log.
#[derive(Debug, Clone)]
@ -28,6 +30,7 @@ pub enum CockpitEntry {
Surfacing { source: String, content: String, priority: String },
Reflection { content: String },
Archivist { synthesis: String, pressure: f32 },
CompactionWarning { pressure: f32, tier: u8 },
}
/// The cockpit panel — Aster's observations rendered to screen.
@ -84,6 +87,16 @@ impl Component for CockpitPane {
}
true
}
TuiEvent::CompactionWarning { pressure, tier } => {
self.entries.push_back(CockpitEntry::CompactionWarning {
pressure: *pressure,
tier: *tier,
});
if self.entries.len() > self.max_entries {
self.entries.pop_front();
}
true
}
_ => false,
}
}
@ -129,6 +142,15 @@ impl Component for CockpitPane {
),
]));
}
CockpitEntry::CompactionWarning { pressure, tier } => {
let pct = (pressure * 100.0) as u16;
let warn_color = match tier { 3 => CRITICAL_RED, 2 => Color::Rgb(255, 120, 50), _ => WARN_ORANGE };
let label = match tier { 3 => "critical", 2 => "urgent", _ => "warn" };
lines.push(Line::from(vec![
Span::styled("", Style::default().fg(warn_color).add_modifier(Modifier::BOLD)),
Span::styled(format!("ctx {}% ({})", pct, label), Style::default().fg(warn_color)),
]));
}
}
}

View file

@ -63,6 +63,8 @@ pub enum TuiEvent {
EnergyChanged(u8),
/// Context pressure from the conversation engine.
PressureChanged(f32),
/// Compaction pressure warning (advisory, 3-tier).
CompactionWarning { pressure: f32, tier: u8 },
/// Backend connectivity status.
BackendStatus { mode: String, healthy: bool },