feat: implement compaction system with pragmatic balance architecture
This commit is contained in:
parent
9a9a31916c
commit
4d0eb048d5
18 changed files with 1234 additions and 8 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(());
|
||||
|
|
|
|||
|
|
@ -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
145
src/core/compact/config.rs
Normal 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
307
src/core/compact/mod.rs
Normal 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
162
src/core/compact/plan.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
461
src/core/compact/strategy.rs
Normal file
461
src/core/compact/strategy.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ impl Sensorium {
|
|||
agent_id: None,
|
||||
subagent_runner: None,
|
||||
subagent_depth: 0,
|
||||
compaction_engine: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue