ToolCard styled the status glyph with a hardcoded byte offset 0..3, but
✓/✗/⟳ are 3-byte chars sitting at bytes 2..5 of " {glyph}". The split
landed mid-char and panicked at render in tuie's style slicing. Compute
the glyph region as 2 + glyph.len_utf8() instead.
Then took cargo clippy -- -D warnings from 312 failures to clean:
- scoped #![allow(dead_code)] on WIP scaffolding (federation, sensorium,
gitea_memory, model_router, session, subagent…); gate stays live on
active code so new orphans still fail
- scoped #![allow(deprecated)] on the legacy ratatui render path, marked
pending removal at tuie parity — no migration on code we're deleting
- declare the gui feature (forwards to tuie/gui) — the cfg was real intent
- real fixes: duplicate SaveAndGoBack arm + dead Err arm, base64::encode,
4 unused imports, dead assignment, private-type leak, dedup'd if/else
branches, manual clamp/strip, &PathBuf→&Path, collapsible matches
369 lines
14 KiB
Rust
369 lines
14 KiB
Rust
#![allow(clippy::type_complexity)] // Arc<dyn Fn> callback field types
|
|
//! 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, MicrocompactStrategy, SlidingReflectStrategy, SlidingWindowStrategy, 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::model_router::TokenCounter;
|
|
use crate::bridge::ProviderRegistry;
|
|
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 providers: Option<Arc<ProviderRegistry>>,
|
|
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>,
|
|
/// Per-agent provider override name (e.g. "zai"). None = use global default.
|
|
pub get_agent_provider: 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> {
|
|
// Resolve config early so we can bail before requiring a session
|
|
let agent_type = (self.get_agent_type)(agent_id)
|
|
.unwrap_or_else(|| "primary".to_string());
|
|
let (cfg, reflect_prompt, summary_prompt) = {
|
|
let app_config = self.config.read().await;
|
|
(
|
|
app_config.compaction.for_agent_type(&agent_type),
|
|
app_config.compaction.reflect_prompt.clone(),
|
|
app_config.compaction.summary_prompt.clone(),
|
|
)
|
|
};
|
|
|
|
let messages = match (self.get_messages)(agent_id) {
|
|
Some(m) => m,
|
|
None if !cfg.enabled => {
|
|
return Ok(CompactionReport {
|
|
agent_id: agent_id.to_string(),
|
|
strategy: strategy_override.unwrap_or(cfg.strategy.clone()),
|
|
before_tokens: 0,
|
|
after_tokens: 0,
|
|
messages_before: 0,
|
|
messages_after: 0,
|
|
messages_compacted: 0,
|
|
audit_path: None,
|
|
});
|
|
}
|
|
None => anyhow::bail!("No session found for agent {}", agent_id),
|
|
};
|
|
|
|
let before_count = messages.len();
|
|
let before_tokens = count_messages(&self.counter, &messages);
|
|
|
|
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.providers {
|
|
Some(reg) => {
|
|
let provider_name = (self.get_agent_provider)(agent_id);
|
|
let client = provider_name
|
|
.as_deref()
|
|
.and_then(|n| reg.get(n))
|
|
.unwrap_or_else(|| reg.default_provider());
|
|
let model = self
|
|
.model
|
|
.as_deref()
|
|
.unwrap_or("openai/kimi-k2.6");
|
|
let s = SummaryStrategy {
|
|
client: client.clone(),
|
|
model: model.to_string(),
|
|
prompt_override: summary_prompt,
|
|
};
|
|
s.plan(&messages, &cfg, &self.counter).await?
|
|
}
|
|
None => CompactionPlan::empty(),
|
|
},
|
|
CompactionStrategyKind::Cull => {
|
|
let s = CullStrategy;
|
|
s.plan(&messages, &cfg, &self.counter).await?
|
|
}
|
|
CompactionStrategyKind::Microcompact => {
|
|
let s = MicrocompactStrategy;
|
|
s.plan(&messages, &cfg, &self.counter).await?
|
|
}
|
|
CompactionStrategyKind::SlidingWindow => {
|
|
let s = SlidingWindowStrategy;
|
|
s.plan(&messages, &cfg, &self.counter).await?
|
|
}
|
|
CompactionStrategyKind::SlidingReflect => match &self.providers {
|
|
Some(reg) => {
|
|
let provider_name = (self.get_agent_provider)(agent_id);
|
|
let client = provider_name
|
|
.as_deref()
|
|
.and_then(|n| reg.get(n))
|
|
.unwrap_or_else(|| reg.default_provider());
|
|
let model = self
|
|
.model
|
|
.as_deref()
|
|
.unwrap_or("openai/kimi-k2.6");
|
|
// The preservation pass runs as a fresh fork of *this*
|
|
// agent — load her persona so the fork wakes as her.
|
|
let agent_persona = (self.get_repo)(agent_id)
|
|
.and_then(|repo| {
|
|
std::fs::read_to_string(repo.root().join("system/persona.md")).ok()
|
|
})
|
|
.map(|c| {
|
|
// Drop a leading YAML frontmatter block if present.
|
|
if let Some(rest) = c.strip_prefix("---\n") {
|
|
if let Some(end) = rest.find("\n---\n") {
|
|
return rest[end + 5..].trim().to_string();
|
|
}
|
|
}
|
|
c.trim().to_string()
|
|
});
|
|
let s = SlidingReflectStrategy {
|
|
client: client.clone(),
|
|
model: model.to_string(),
|
|
prompt_override: reflect_prompt,
|
|
agent_persona,
|
|
};
|
|
s.plan(&messages, &cfg, &self.counter).await?
|
|
}
|
|
None => {
|
|
tracing::warn!("[sliding_reflect] no provider registry, falling back to plain sliding window");
|
|
SlidingWindowStrategy.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.
|
|
// Microcompact provides a full replacement list; other strategies
|
|
// use keep_indices + optional summary_text.
|
|
let (new_messages, messages_compacted, after_tokens) =
|
|
if let Some(replacement) = plan.replacement_messages {
|
|
let compacted = before_count.saturating_sub(replacement.len());
|
|
let tokens = count_messages(&self.counter, &replacement);
|
|
(replacement, compacted, tokens)
|
|
} else {
|
|
let mut kept = Vec::new();
|
|
for &idx in &plan.keep_indices {
|
|
if idx < messages.len() {
|
|
kept.push(messages[idx].clone());
|
|
}
|
|
}
|
|
if let Some(ref summary_text) = plan.summary_text {
|
|
kept.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 compacted = before_count.saturating_sub(kept.len());
|
|
let tokens = count_messages(&self.counter, &kept);
|
|
(kept, compacted, tokens)
|
|
};
|
|
|
|
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(),
|
|
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(),
|
|
providers: 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())),
|
|
get_agent_provider: Arc::new(|_| None),
|
|
};
|
|
|
|
let report = engine
|
|
.compact("test-agent", Some(CompactionStrategyKind::Cull))
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(report.messages_compacted, 0);
|
|
assert!(report.audit_path.is_none());
|
|
}
|
|
}
|